Skip to content

utils

Module for internal utility functions to share between modules

Classes:

Name Description
Vector

Simple Vector implementation that takes a start and end point.

Functions:

Name Description
box_on_point

Build a rectangular box on a point

build_mapx
center_circle

Create a circle using a center point and a radius

convert_schema

Convert a Schema from one format to another

export_project_lyrx

Pull all layers from a project file and output them in a directory as lyrx files

export_project_maps

Pull all layers from a project file and output them in a directory as mapx files

get_subtype_count

Get the subtype counts for a Table or FeatureClass

get_subtype_counts

Get a mapping of subtype counts for all featureclasses that have subtypes in the provided Dataset

nat

Natural sort key for use in string sorting

patch_schema_rules

Patch an exported Schema doc by re-linking attribute rules to table names

print

Print a message to the ArcGIS Pro message queue and stdout

shortest_path

Find the shortest path or paths given a source point, target point and network of Polylines

split_at_points

Split lines at provided points

split_lines_at_points

Split a Polyline or Sequence/Iterable of polylines at provided points

two_point_circle

Create a circle using a center point and an end point

Attributes:

Name Type Description
LineCollection
PointLike

LineCollection = FeatureClass[Polyline, Any] | Sequence[Polyline] | Iterator[Polyline] module-attribute

PointLike = PointGeometry | Point module-attribute

Vector dataclass

Simple Vector implementation that takes a start and end point.

If PointGeometries are passed as the start and end points, the end point will inherit the reference of the start point

Attributes:

Name Type Description
x1 float

The X coordinate of the startpoint

y1 float

The Y coordinate of the startpoint

x2 float

The X coordiante of the endpoint

y2 float

The Y coordiante of the endpoint

ang float

The angle of the vector in radians

dist float

The magnitute of the vector (distance b/w start and end)

cos float

the cos of the vector angle in radians

sin float

The sin of the vector angle in radians

mid Point

The midpoint of the vector along its magnitude

Methods:

Name Description
__init__
__post_init__
translate

Translate the provided point along the vector direction.

Source code in src/arcpie/utils.py
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
@dataclass
class Vector:
    """Simple Vector implementation that takes a start and end point.\n

    If `PointGeometries` are passed as the start and end points, the end point
    will inherit the reference of the start point

    Attributes:
        x1 (float): The X coordinate of the startpoint
        y1 (float): The Y coordinate of the startpoint
        x2 (float): The X coordiante of the endpoint
        y2 (float): The Y coordiante of the endpoint
        ang (float): The angle of the vector in radians
        dist (float): The magnitute of the vector (distance b/w start and end)
        cos (float): the cos of the vector angle in radians
        sin (float): The sin of the vector angle in radians
        mid (Point): The midpoint of the vector along its magnitude
    """
    start: Point | PointGeometry
    end: Point | PointGeometry

    def __post_init__(self) -> None:
        _ref = None
        if isinstance(self.start, PointGeometry):
            _ref = self.start.spatialReference
            self.start = self.start.centroid
        if isinstance(self.end, PointGeometry):
            if _ref and self.end.spatialReference != _ref:
                self.end = self.end.projectAs(_ref)
            self.end = self.end.centroid
        self.x1: float = self.start.X
        self.x2: float = self.end.X
        self.y1: float = self.start.Y
        self.y2: float = self.end.Y
        self.ang: float = math.atan2(self.y2-self.y1, self.x2-self.x1)
        self.dist: float = ((self.x2-self.x1)**2+(self.y2-self.y1)**2)**0.5
        self.cos = math.cos(self.ang)
        self.sin = math.sin(self.ang)
        self.mid: Point = self.translate(self.start, self.dist/2)

    def translate(self, point: _PointType, dist: float|None=None) -> _PointType:
        """Translate the provided point along the vector direction.

        The Point will be moved from its original location along the vector angle the provided distance.
        The location of the `Vector` object is not taken into account, only angle and magnitude

        Args:
            point (Point|PointGeometry): The point to translate along the given vector
            dist (float|None): The distance to translate the point (default: `self.dist`)

        Returns:
            (Point|PointGeometry): Return the provided geometry back translated

        Note:
            Whatever point type you provide will be given back to you
        """
        ref: SpatialReference | None = getattr(point, 'spatialReference')
        if isinstance(point, Point):
            target = point
        else:
            target = point.centroid
        dist = dist or self.dist
        target = Point(target.X+dist*self.cos, target.Y+dist*self.sin, target.Z, target.M, target.ID)
        if isinstance(point, PointGeometry):
            return PointGeometry(target, ref) # pyright: ignore[reportReturnType]
        return target   # pyright: ignore[reportReturnType]

end instance-attribute

start instance-attribute

__init__(start, end)

__post_init__()

Source code in src/arcpie/utils.py
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
def __post_init__(self) -> None:
    _ref = None
    if isinstance(self.start, PointGeometry):
        _ref = self.start.spatialReference
        self.start = self.start.centroid
    if isinstance(self.end, PointGeometry):
        if _ref and self.end.spatialReference != _ref:
            self.end = self.end.projectAs(_ref)
        self.end = self.end.centroid
    self.x1: float = self.start.X
    self.x2: float = self.end.X
    self.y1: float = self.start.Y
    self.y2: float = self.end.Y
    self.ang: float = math.atan2(self.y2-self.y1, self.x2-self.x1)
    self.dist: float = ((self.x2-self.x1)**2+(self.y2-self.y1)**2)**0.5
    self.cos = math.cos(self.ang)
    self.sin = math.sin(self.ang)
    self.mid: Point = self.translate(self.start, self.dist/2)

translate(point, dist=None)

Translate the provided point along the vector direction.

The Point will be moved from its original location along the vector angle the provided distance. The location of the Vector object is not taken into account, only angle and magnitude

Parameters:

Name Type Description Default
point Point | PointGeometry

The point to translate along the given vector

required
dist float | None

The distance to translate the point (default: self.dist)

None

Returns:

Type Description
Point | PointGeometry

Return the provided geometry back translated

Note

Whatever point type you provide will be given back to you

Source code in src/arcpie/utils.py
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
def translate(self, point: _PointType, dist: float|None=None) -> _PointType:
    """Translate the provided point along the vector direction.

    The Point will be moved from its original location along the vector angle the provided distance.
    The location of the `Vector` object is not taken into account, only angle and magnitude

    Args:
        point (Point|PointGeometry): The point to translate along the given vector
        dist (float|None): The distance to translate the point (default: `self.dist`)

    Returns:
        (Point|PointGeometry): Return the provided geometry back translated

    Note:
        Whatever point type you provide will be given back to you
    """
    ref: SpatialReference | None = getattr(point, 'spatialReference')
    if isinstance(point, Point):
        target = point
    else:
        target = point.centroid
    dist = dist or self.dist
    target = Point(target.X+dist*self.cos, target.Y+dist*self.sin, target.Z, target.M, target.ID)
    if isinstance(point, PointGeometry):
        return PointGeometry(target, ref) # pyright: ignore[reportReturnType]
    return target   # pyright: ignore[reportReturnType]

box_on_point(center, width, height, angle=0.0, ref=None, start='tl')

Build a rectangular box on a point

Parameters:

Name Type Description Default
center Point | PointGeometry

The center point of the box

required
width float

The width of the box

required
height float

The height of the box

required
angle float

An angle to roatate the box by in radians (default: 0.0)

0.0
ref SpatialReference | None

An optional spatial reference to apply to the output polygon

None
start Literal['tl', 'tr', 'bl', 'br']

The corner of the box that should be the start point (default: 'tl')

'tl'

Returns:

Type Description
Polygon

A rectangular polygon (with provided ref or ref inhereted from center)

Source code in src/arcpie/utils.py
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
def box_on_point(
    center: Point | PointGeometry, 
    width: float, height: float, 
    angle: float=0.0, 
    ref: SpatialReference|None=None, 
    start: Literal['tl', 'tr', 'bl', 'br']='tl'
    ) -> Polygon:
    """Build a rectangular box on a point

    Args:
        center (Point|PointGeometry): The center point of the box
        width (float): The width of the box
        height (float): The height of the box
        angle (float): An angle to roatate the box by in radians (default: 0.0)
        ref (SpatialReference|None): An optional spatial reference to apply to the output polygon
        start (Literal['tl', 'tr', 'bl', 'br']): The corner of the box that should be the start point (default: 'tl')

    Returns:
        (Polygon): A rectangular polygon (with provided ref or ref inhereted from center)
    """
    if isinstance(center, PointGeometry):
        ref = ref or center.spatialReference
        if center.spatialReference != ref:
            center = center.projectAs(ref)
        center = center.centroid

    h_width = width/2
    h_height = height/2
    tl = Point(center.X-h_width, center.Y+h_height)
    tr = Point(center.X+h_width, center.Y+h_height)
    bl = Point(center.X-h_width, center.Y-h_height)
    br = Point(center.X+h_width, center.Y-h_height)
    points = deque([tl, tr, br, bl])

    if start == 'tr':
        points.rotate(-1)
    elif start == 'br':
        points.rotate(-2)
    elif start == 'bl':
        points.rotate(-3)

    box = Polygon(Array(), spatial_reference=ref)
    if angle:
        box = box.rotate(center, angle) # type: ignore
        assert isinstance(box, Polygon)
    return box

build_mapx(source_map, layers, tables)

Source code in src/arcpie/utils.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def build_mapx(source_map: Map, layers: list[Layer], tables: list[StandaloneTable]) -> dict[str, Any]:
    _base_map = source_map.mapx

    # Remove existing definitions
    _base_map.pop('layerDefinition', None)
    _base_map.pop('tableDefinitions', None)

    # Remove existing CIM paths
    _map_def: dict[str, Any] = _base_map['mapDefinition']
    _map_def.pop('layers', None)
    _map_def.pop('standaloneTables', None)

    if layers:
        _map_def['layers'] = [l.URI for l in layers]
        _base_map['layerDefinitions'] = [l.cim_dict for l in layers]

    if tables:
        _map_def['standaloneTables'] = [t.URI for t in tables]
        _base_map['tableDefinitions'] = [t.cim_dict for t in tables]

    return _base_map

center_circle(center, radius, ref=None)

Create a circle using a center point and a radius

Parameters:

Name Type Description Default
center Point | PointGeometry

The center of the circle

required
radius float

(float): The dist

required
ref SpatialReference | None

(SpatialReference|None): The SpatialReference to use with the returned geometry

None

Returns:

Type Description
Polyline

A Circular Polyline

Note

If a PointGeometry are provided, it will be projected as the provided ref If no ref is provided, the shape will inherit the reference of the center

Reference resolution is as follows: ref -> center.spatialReference

If no center reference can be found and no ref is provided, the returned geometry will have no reference

Source code in src/arcpie/utils.py
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
def center_circle(center: Point|PointGeometry, radius: float, ref: SpatialReference|None=None) -> Polyline:
    """Create a circle using a center point and a radius

    Args:
        center (Point|PointGeometry): The center of the circle
        radius: (float): The dist
        ref: (SpatialReference|None): The SpatialReference to use with the returned geometry

    Returns:
        (Polyline): A Circular Polyline

    Note:
        If a PointGeometry are provided, it will be projected as the provided ref
        If no ref is provided, the shape will inherit the reference of the center

        Reference resolution is as follows:
        `ref -> center.spatialReference`

        If no center reference can be found and no ref is provided, the returned geometry will 
        have no reference
    """
    if isinstance(center, PointGeometry):
        ref = ref or center.spatialReference
        center = center.centroid 
    return two_point_circle(center, Point(center.X, center.Y+radius), ref)

convert_schema(schema, to='JSON')

Convert a Schema from one format to another

Parameters:

Name Type Description Default
schema Dataset | Path | str

Path to the schemafile or Dataset to convert

required
to Literal['JSON', 'XLSX', 'HTML', 'PDF', 'XML']

Target format (default: 'JSON')

'JSON'

Yields:

Name Type Description
bytes BytesIO

Raw bytes object containing the schema file

Source code in src/arcpie/utils.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def convert_schema(schema: Dataset[Any]|Path|str, to: Literal['JSON', 'XLSX', 'HTML', 'PDF', 'XML']='JSON') -> BytesIO:
    """Convert a Schema from one format to another

    Args:
        schema (Dataset|Path|str): Path to the schemafile or Dataset to convert
        to (Literal['JSON', 'XLSX', 'HTML', 'PDF', 'XML']): Target format (default: 'JSON')

    Yields:
        bytes: Raw bytes object containing the schema file
    """
    with TemporaryDirectory(suffix=to) as temp:
        temp = Path(temp)
        if not isinstance(schema, (Path, str)):
            # Convert Dataset to report
            schema, = GenerateSchemaReport(str(schema.conn), str(temp), 'json_schema', 'JSON')
        schema = Path(schema)
        conversion, = ConvertSchemaReport(str(schema), str(temp), 'out', to)
        return BytesIO(Path(conversion).read_bytes())

export_project_lyrx(project, out_dir, *, indent=4, sort=False, skip_empty=True)

Pull all layers from a project file and output them in a directory as lyrx files

Parameters:

Name Type Description Default
project Project

The arcpie.Project instance to export

required
out_dir Path | str

The target directory for the layer files

required
indent int

Indentation level of the ouput files (default: 4)

4
sort bool

Sort the output file by key name (default: False)

False
skip_empty bool

Skips writing empty lyrx files for layers with no lyrx data (default: True)

True
Usage
>>> export_project_lyrx(arcpie.Project('<path/to/aprx>'), '<path/to/output_dir>')
Note

Output structure will match the structure of the project: Map -> Group -> Layer Where each level is a directory. Group Layers will have a directory entry with individual files for each layer they contain, as well as a single layerfile that contains all their child layers.

Source code in src/arcpie/utils.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def export_project_lyrx(project: Project, out_dir: Path, *, indent: int=4, sort: bool=False, skip_empty: bool=True) -> None:
    """Pull all layers from a project file and output them in a directory as lyrx files

    Args:
        project (Project): The `arcpie.Project` instance to export
        out_dir (Path|str): The target directory for the layer files
        indent (int): Indentation level of the ouput files (default: 4)
        sort (bool): Sort the output file by key name (default: False)
        skip_empty (bool): Skips writing empty lyrx files for layers with no lyrx data (default: True)

    Usage:
        ```python
        >>> export_project_lyrx(arcpie.Project('<path/to/aprx>'), '<path/to/output_dir>')
        ```

    Note:
        Output structure will match the structure of the project:
        `Map -> Group -> Layer`
        Where each level is a directory. Group Layers will have a directory entry with individual
        files for each layer they contain, as well as a single layerfile that contains all their 
        child layers.
    """
    out_dir = Path(out_dir)
    for map in project.maps:
        map_dir = out_dir / map.unique_name
        for layer in map.layers:
            _lyrx = getattr(layer, 'lyrx', None)
            if _lyrx is None:
                print(f'{(layer.cim_dict or {}).get("type")} is invalid!')
                continue
            if skip_empty and not _lyrx:
                continue
            out_file = (map_dir / layer.longName).with_suffix('.lyrx')
            out_file.parent.mkdir(parents=True, exist_ok=True)
            out_file.write_text(json.dumps(_lyrx, indent=indent, sort_keys=sort), encoding='utf-8')

export_project_maps(project, out_dir, *, indent=4, sort=False)

Pull all layers from a project file and output them in a directory as mapx files

Parameters:

Name Type Description Default
project Project

The arcpie.Project instance to export

required
out_dir Path | str

The target directory for the mapx files

required
indent int

Indentation level of the ouput files (default: 4)

4
sort bool

Sort the output file by key name (default: False)

False
Usage
>>> export_project_maps(arcpie.Project('<path/to/aprx>'), '<path/to/output_dir>')
Source code in src/arcpie/utils.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def export_project_maps(project: Project, out_dir: Path|str, *, indent: int=4, sort: bool=False) -> None:
    """Pull all layers from a project file and output them in a directory as mapx files

    Args:
        project (Project): The `arcpie.Project` instance to export
        out_dir (Path|str): The target directory for the mapx files
        indent (int): Indentation level of the ouput files (default: 4)
        sort (bool): Sort the output file by key name (default: False)

    Usage:
        ```python
        >>> export_project_maps(arcpie.Project('<path/to/aprx>'), '<path/to/output_dir>')
        ```
    """
    out_dir = Path(out_dir)
    for map in project.maps:
        map_dir = out_dir / rf'{map.unique_name}'
        out_file = map_dir.with_suffix(f'{map_dir.suffix}.mapx') # handle '.' in map name
        out_file.parent.mkdir(parents=True, exist_ok=True)
        out_file.write_text(json.dumps(map.mapx, indent=indent, sort_keys=sort), encoding='utf-8')

get_subtype_count(fc, drop_empty=False)

Get the subtype counts for a Table or FeatureClass

Parameters:

Name Type Description Default
fc Table | FeatureClass

The Table/FeatureClass you want subtype counts for

required
drop_empty bool

Drop any counts that have no features from the output dictionary (default: False)

False

Returns:

Type Description
dict[str, int]

A mapping of subtype name to subtype count

Source code in src/arcpie/utils.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def get_subtype_count(fc: Table | FeatureClass, drop_empty: bool=False) -> dict[str, int]:
    """Get the subtype counts for a Table or FeatureClass

    Args:
        fc (Table | FeatureClass): The Table/FeatureClass you want subtype counts for
        drop_empty (bool): Drop any counts that have no features from the output dictionary (default: False)

    Returns:
        (dict[str, int]): A mapping of subtype name to subtype count
    """
    return {
        subtype['Name']: cnt
        for code, subtype in fc.subtypes.items() 
        if fc.subtype_field # has Subtypes
        and (
            (cnt := count(fc[where(f'{fc.subtype_field} = {code}')])) # Get count
            or drop_empty # Drop Empty counts?
        )
    }

get_subtype_counts(gdb, *, drop_empty=False)

Get a mapping of subtype counts for all featureclasses that have subtypes in the provided Dataset

Parameters:

Name Type Description Default
gdb Dataset

The Dataset instance to get subtype counts for

required
drop_empty bool

Drop any counts that have no features from the output dictionary (default: False)

False

Returns:

Type Description
dict[str, dict[str, int]]

A mapping of FeatureClass -> SubtypeName -> SubtypeCount

Usage
>>> get_subtype_counts(Dataset('<path/to/gdb>', drop_empty=True))
{
    'FC1': 
        {
            'Default': 10
            'Subtype 1': 6
            ...
        },
    ...
}
Source code in src/arcpie/utils.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def get_subtype_counts(gdb: Dataset, *, drop_empty: bool=False) -> dict[str, dict[str, int]]:
    """Get a mapping of subtype counts for all featureclasses that have subtypes in the provided Dataset

    Args:
        gdb (Dataset): The Dataset instance to get subtype counts for
        drop_empty (bool): Drop any counts that have no features from the output dictionary (default: False)

    Returns:
        (dict[str, dict[str, int]]): A mapping of FeatureClass -> SubtypeName -> SubtypeCount

    Usage:
        ```python
        >>> get_subtype_counts(Dataset('<path/to/gdb>', drop_empty=True))
        {
            'FC1': 
                {
                    'Default': 10
                    'Subtype 1': 6
                    ...
                },
            ...
        }
        ```  
    """
    feats: list[Table] = [*gdb.feature_classes.values(), *gdb.tables.values()]
    return {
        fc.name: counts
        for fc in feats
        if (counts := get_subtype_count(fc))
        or not drop_empty
    }

nat(val)

Natural sort key for use in string sorting

Parameters:

Name Type Description Default
val str

A value that you want the natural sort key for

required

Returns:

Type Description
tuple[tuple[int, ...], tuple[str, ...]

A tuple containing all numeric and

tuple[str, ...]

string components in order of appearance. Best used as a sort key

Usage
>>> pages = ['P-1.3', 'P-2.11', ...]
>>> pages.sort(key=nat)
>>> print(pages)
['P-1.1', 'P-1.2', ...]
Source code in src/arcpie/utils.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def nat(val: str) -> tuple[tuple[int, ...], tuple[str, ...]]:
    """Natural sort key for use in string sorting

    Args:
        val (str): A value that you want the natural sort key for

    Returns:
        (tuple[tuple[int, ...], tuple[str, ...]): A tuple containing all numeric and 
        string components in order of appearance. Best used as a sort key

    Usage:
        ```python
        >>> pages = ['P-1.3', 'P-2.11', ...]
        >>> pages.sort(key=nat)
        >>> print(pages)
        ['P-1.1', 'P-1.2', ...]
        ```
    """
    _digits: list[int] = []
    _alpha: list[str] = []
    _digit_chars: list[str] = []
    for s in val:
       if s.isdigit():
          _digit_chars.append(s)
       else:
          _alpha.append(s)
          if _digit_chars:
             _digits.append(int(''.join(_digit_chars)))
             _digit_chars.clear()
    if _digit_chars:
       _digits.append(int(''.join(_digit_chars)))
    return tuple(_digits), tuple(_alpha)

patch_schema_rules(schema, *, remove_rules=False)

Patch an exported Schema doc by re-linking attribute rules to table names

Parameters:

Name Type Description Default
schema Path | str

The input schema to patch

required
remove_rules bool

Remove attribute rules from the schema (default: False)

False

Returns:

Name Type Description
SchemaWorkspace SchemaWorkspace

A patched schema dictionary

Source code in src/arcpie/utils.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def patch_schema_rules(schema: SchemaWorkspace|Path|str, 
                       *,
                       remove_rules: bool=False) -> SchemaWorkspace:
    """Patch an exported Schema doc by re-linking attribute rules to table names

    Args:
        schema (Path|str): The input schema to patch
        remove_rules (bool): Remove attribute rules from the schema (default: False)

    Returns:
        SchemaWorkspace: A patched schema dictionary
    """
    # Load schema
    if isinstance(schema, dict):
        workspace = schema
    else:
        schema = Path(schema)
        if not schema.suffix == '.json':
            raise ValueError(f'Schema Patching can only be done on json schemas!, got {schema.suffix}')
        workspace: SchemaWorkspace = json.load(schema.open(encoding='utf-8'))

    # Get all root Features
    features = [
        ds 
        for ds in workspace['datasets'] 
        if 'datasets' not in ds
    ]
    # Get all features that live in a FeatureDataset
    # Use listcomp since typing is weird
    [
        features.extend(ds['datasets']) 
        for ds in workspace['datasets'] 
        if 'datasets' in ds
    ]
    # Get a translation dictionary of catalogID -> name
    guid_to_name = {
        fc['catalogID']: fc['name'] 
        for fc in features 
        if 'catalogID' in fc
    }
    for feature in filter(lambda fc: 'attributeRules' in fc, features):
        feature: SchemaDataset
        if remove_rules:
            feature['attributeRules'] = []
            continue
        rules = feature['attributeRules']
        for rule in rules:
            script = rule['scriptExpression']
            for guid, name in guid_to_name.items():
                script = script.replace(guid, name)
            rule['scriptExpression'] = script
    return workspace

print(*values, sep=' ', end='\n', file=None, flush=False, severity=None)

Print a message to the ArcGIS Pro message queue and stdout set severity to 'WARNING' or 'ERROR' to print to the ArcGIS Pro message queue with the appropriate severity

Source code in src/arcpie/utils.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def print(*values: object,
          sep: str = " ",
          end: str = "\n",
          file: Any = None,
          flush: bool = False,
          severity: Literal['INFO', 'WARNING', 'ERROR']|None = None) -> None:
    """ Print a message to the ArcGIS Pro message queue and stdout
    set severity to 'WARNING' or 'ERROR' to print to the ArcGIS Pro message queue with the appropriate severity
    """

    # Print the message to stdout
    builtins.print(*values, sep=sep, end=end, file=file, flush=flush)

    end = "" if end == '\n' else end
    message = f"{sep.join(map(str, values))}{end}"
    # Print the message to the ArcGIS Pro message queue with the appropriate severity
    match severity:
        case "WARNING":
            AddWarning(f"{message}")
        case "ERROR":
            AddError(f"{message}")
        case _:
            AddMessage(f"{message}")
    return

shortest_path(source, target, network, *, all_paths=False, method='dijkstra', weighted=True, precision=6)

shortest_path(
    source: PointLike,
    target: PointLike,
    network: LineCollection,
    *,
    all_paths: Literal[False] = False,
    method: Literal[
        "dijkstra", "bellman-ford"
    ] = "dijkstra",
    weighted: bool = True,
    precision: int = 6,
) -> Polyline | None
shortest_path(
    source: PointLike,
    target: PointLike,
    network: LineCollection,
    *,
    all_paths: Literal[True] = True,
    method: Literal[
        "dijkstra", "bellman-ford"
    ] = "dijkstra",
    weighted: bool = True,
    precision: int = 6,
) -> list[Polyline] | None

Find the shortest path or paths given a source point, target point and network of Polylines

Parameters:

Name Type Description Default
source PointGeometry | Point

The start point for the path

required
target PointGeometry | Point

The end point for the path

required
network FeatureClass[Polyline, Any] | Sequence[Polyline] | Iterator[Polyline]

The polylines to traverse

required
all_paths bool

If True, yield all shortest paths from (default: False)

False
method Literal['dijkstra', 'bellman-ford']

The graph traversal algorithm to use (default: 'dijkstra')

'dijkstra'
weighted bool

Use line lengths to weight the paths (default: True)

True
precision int

Number of decimal places to round coordinates to (default: 6)

6

Returns:

Type Description
Polyline | None

The unioned polyline of the path or None if no path is found

Yields:

Type Description
Polyline

Yields all shortest paths if all_paths is set (None is still returned if no path found)

Raises:

Type Description
ValueError

When input arguments are not of the correct types (PointLike, PointLike, LineCollection)

Example
path = shortest_path(p1, p2, line_features)
paths = shortest_path(p1, p2, line_features, all_paths=True)

if path is None:
    print('No Path')
else:
    print(path.length)

# Check that path(s) were found
if paths is None:
    print('No Path')
else:
    for p in paths:
        print(p.length)
Source code in src/arcpie/utils.py
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
def shortest_path(
    source: PointLike, 
    target: PointLike, 
    network: LineCollection,
    *,
    all_paths: bool=False,
    method: Literal['dijkstra', 'bellman-ford']='dijkstra',
    weighted: bool=True,
    precision: int=6,
    ) -> Polyline | list[Polyline] | None:
    """Find the shortest path or paths given a source point, target point and network of Polylines

    Args:
        source (PointGeometry | Point): The start point for the path
        target (PointGeometry | Point): The end point for the path
        network (FeatureClass[Polyline, Any] | Sequence[Polyline] | Iterator[Polyline]): The polylines to traverse
        all_paths (bool): If True, yield all shortest paths from (default: False)
        method (Literal['dijkstra', 'bellman-ford']): The graph traversal algorithm to use (default: 'dijkstra')
        weighted (bool): Use line lengths to weight the paths (default: True)
        precision (int): Number of decimal places to round coordinates to (default: 6)

    Returns:
        (Polyline | None): The unioned polyline of the path or None if no path is found

    Yields:
        (Polyline): Yields all shortest paths if `all_paths` is set (None is still *returned* if no path found)

    Raises:
        (ValueError): When input arguments are not of the correct types (`PointLike`, `PointLike`, `LineCollection`)

    Example:
        ```python
        path = shortest_path(p1, p2, line_features)
        paths = shortest_path(p1, p2, line_features, all_paths=True)

        if path is None:
            print('No Path')
        else:
            print(path.length)

        # Check that path(s) were found
        if paths is None:
            print('No Path')
        else:
            for p in paths:
                print(p.length)
        ```   
    """

    # Parameter Validations
    if isinstance(network, FeatureClass):
        if network.describe.shapeType != 'Polyline':
            raise ValueError(f'network must have polyline geometry')
        network = list(network.shapes)

    if not isinstance(network, Sequence):
        network = list(network)

    if not network:
        return None

    if not isinstance(network[0], Polyline): # type: ignore
        raise ValueError(f'network must have polyline geometry')

    # Project point geometries to match reference of network, extract X,Y tuples
    if isinstance(source, PointGeometry):
        if source.isMultipart:
            raise ValueError('source point must not be multipart')
        source = source.projectAs(network[0].spatialReference).centroid
    _source = (round(source.X, precision), round(source.Y, precision))

    if isinstance(target, PointGeometry):
        if target.isMultipart:
            raise ValueError('target point must not be multipart')
        target = target.projectAs(network[0].spatialReference).centroid
    _target = (round(target.X, precision), round(target.Y, precision))

    G = Graph(
        [
            (
                (round(l.firstPoint.X, precision), round(l.firstPoint.Y, precision)), 
                (round(l.lastPoint.X, precision), round(l.lastPoint.Y, precision)), 
                {'shape': l, 'length': l.length}
            ) 
            for l in network
        ]
    )
    try:
        if all_paths:
            paths = nx_all_shortest_paths(G, _source, _target, weight='length' if weighted else None, method=method)
        else:
            paths = nx_shortest_path(G, _source, _target, weight='length' if weighted else None, method=method)
    except (NodeNotFound, NetworkXNoPath):
        return None

    if all_paths:
        _paths: list[Polyline] = []
        for path in paths:
            assert isinstance(path, list)
            edges: list[Polyline] = [G.get_edge_data(u, v)['shape'] for u, v in zip(path, path[1:])]
            _paths.append(reduce(lambda acc, s: acc.union(s), edges))  # pyright: ignore[reportArgumentType]
        return _paths

    else:
        assert isinstance(paths, list)
        edges: list[Polyline] = [G.get_edge_data(u, v)['shape'] for u, v in zip(paths, paths[1:])]
        if edges:
            return reduce(lambda acc, s: acc.union(s), edges) # pyright: ignore[reportReturnType]

split_at_points(lines, points, *, buffer=0.0, min_len=0.0)

Split lines at provided points

Parameters:

Name Type Description Default
lines FeatureClass[Polyline]

Line features to split

required
points FeatureClass[PointGeometry]

Points to split on

required
buffer float

Split buffer in feature units (default: 0.0 [exact])

0.0
min_len float

Minumum length for a new line in feature units (default: 0.0)

0.0

Yields:

Type Description
tuple[int, Polyline]]

Tuples of parent OID and child shape

Warning

When splitting features in differing projections, the point features will be projected into the spatial reference of the line features.

Example
>>> # Simple process for splitting lines in place
... 
>>> # Initialize a set to capture the removed ids
>>> removed: set[int] = set()
>>> with lines.editor:
...     # Insert new lines
...     with lines.insert_cursor('SHAPE@') as cur:
...         for parent, new_line in split_at_points(lines, points):
...             cur.insertRow([new_line])
...             removed.add(parent) # Add parent ID to removed
...     # Remove old lines (if you're inserting to the same featureclass)
...     with lines.update_cursor('OID@') as cur:
...         for _ in filter(lambda r: r[0] in removed, cur):
...             cur.deleteRow() 
Source code in src/arcpie/utils.py
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
def split_at_points(lines: FeatureClass[Polyline, Any], points: FeatureClass[PointGeometry, Any], 
                *, 
                buffer: float=0.0,
                min_len: float=0.0) -> Iterator[tuple[int, Polyline]]:
    """Split lines at provided points

    Args:
        lines (FeatureClass[Polyline]): Line features to split
        points (FeatureClass[PointGeometry]): Points to split on
        buffer (float): Split buffer in feature units (default: 0.0 [exact])
        min_len (float): Minumum length for a new line in feature units (default: 0.0)

    Yields:
        ( tuple[int, Polyline]] ): Tuples of parent OID and child shape

    Warning:
        When splitting features in differing projections, the point features will be projected
        into the spatial reference of the line features.

    Example:
        ```python
        >>> # Simple process for splitting lines in place
        ... 
        >>> # Initialize a set to capture the removed ids
        >>> removed: set[int] = set()
        >>> with lines.editor:
        ...     # Insert new lines
        ...     with lines.insert_cursor('SHAPE@') as cur:
        ...         for parent, new_line in split_at_points(lines, points):
        ...             cur.insertRow([new_line])
        ...             removed.add(parent) # Add parent ID to removed
        ...     # Remove old lines (if you're inserting to the same featureclass)
        ...     with lines.update_cursor('OID@') as cur:
        ...         for _ in filter(lambda r: r[0] in removed, cur):
        ...             cur.deleteRow() 
        ```
    """
    line_iter: Iterator[tuple[Polyline, int]] = lines[('SHAPE@', 'OID@')]
    for line, oid in line_iter:
        int_points: list[PointGeometry] = []
        with points.reference_as(line.spatialReference), points.fields_as('SHAPE@'):
            int_points = [r['SHAPE@'] for r in points[line.buffer(buffer)]]

        if len(int_points) == 0 or all(p.touches(line) for p in int_points):
            continue

        prev_measure = 0.0
        measures = sorted(line.measureOnLine(p) for p in int_points) + [line.length]
        for measure in measures:
            seg = line.segmentAlongLine(prev_measure, measure)
            prev_measure = measure
            if seg and seg.length >= (min_len or 0):
                yield oid, seg

split_lines_at_points(lines, points)

Split a Polyline or Sequence/Iterable of polylines at provided points

Parameters:

Name Type Description Default
lines Polyline | Sequence[Polyline] | Iterator[Polyline]

The line or lines to split

required
points Sequence[PointGeometry] | Iterator[PointGeometry]

The points to split at

required

Yields:

Type Description
Polyline

Segments of the polyline split at the input points

Source code in src/arcpie/utils.py
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def split_lines_at_points(lines: Polyline | Sequence[Polyline] | Iterator[Polyline], points: Sequence[PointGeometry] | Iterator[PointGeometry]) -> Iterator[Polyline]:
    """Split a Polyline or Sequence/Iterable of polylines at provided points

    Args:
        lines (Polyline | Sequence[Polyline] | Iterator[Polyline]): The line or lines to split
        points (Sequence[PointGeometry] | Iterator[PointGeometry]): The points to split at

    Yields:
        (Polyline): Segments of the polyline split at the input points
    """
    if isinstance(lines, Polyline):
        lines = [lines]
    if not isinstance(points, list):
        points = list(points)

    for line in lines:
        int_points = [p for p in points if not p.disjoint(line)]
        if not int_points:
            yield line
            continue
        if all(p.touches(line) for p in int_points):
            yield line
            continue
        prev_measure = 0.0
        measures = sorted(line.measureOnLine(p) for p in int_points)
        for measure in measures + [line.length]:
            if prev_measure == measure:
                continue
            yield line.segmentAlongLine(prev_measure, measure)
            prev_measure = measure

two_point_circle(center, end, ref=None)

Create a circle using a center point and an end point

Parameters:

Name Type Description Default
center Point | PointGeometry

The center of the circle

required
end Point | PointGeometry

(Point|PointGeometry): The end point of the arc (distance from center is Circle radius)

required
ref SpatialReference | None

(SpatialReference|None): The SpatialReference to use with the returned geometry

None

Returns:

Type Description
Polyline

A Circular Polyline

Note

If PointGeometries are provided, they will be projected as the provided ref If no ref is provided, the shape will inherit the reference of the center

Reference resolution is as follows: ref -> center.spatialReference -> end.spatialReference

If both points are Point objects with no spatial reference, and no ref is provided, The returned Polyline will have no spatial reference

Source code in src/arcpie/utils.py
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
def two_point_circle(center: Point|PointGeometry, end: Point|PointGeometry, ref: SpatialReference|None=None) -> Polyline:
    """Create a circle using a center point and an end point

    Args:
        center (Point|PointGeometry): The center of the circle
        end: (Point|PointGeometry): The end point of the arc (distance from center is Circle radius)
        ref: (SpatialReference|None): The SpatialReference to use with the returned geometry

    Returns:
        (Polyline): A Circular Polyline

    Note:
        If PointGeometries are provided, they will be projected as the provided ref
        If no ref is provided, the shape will inherit the reference of the center

        Reference resolution is as follows:
        `ref -> center.spatialReference -> end.spatialReference`

        If both points are Point objects with no spatial reference, and no ref is provided, 
        The returned Polyline will have no spatial reference
    """
    if isinstance(center, PointGeometry):
        _c_ref = center.spatialReference
        if ref and _c_ref != ref:
            center = center.projectAs(ref)
        else:
            ref = ref or _c_ref
        center = center.centroid
    if isinstance(end, PointGeometry):
        _e_ref = end.spatialReference
        if ref and _e_ref != ref:
            end = end.projectAs(ref)
        else:
            ref = ref or _e_ref
        end = end.centroid

    esri_json: dict[str, Any] = {}
    _arc: dict[str, Any] = {'a': [[end.X, end.Y, end.Z, end.M], [center.X, center.Y], 0, 1]}
    esri_json['curvePaths'] = [[[end.X, end.Y, end.Z], _arc]]
    if ref:
        esri_json['spatialReference'] = {'wkid': ref.factoryCode, 'latestWkid': ref.factoryCode}
    return AsShape(esri_json, esri_json=True) # type: ignore