geojson-diff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
geojson_diff/core.py ADDED
@@ -0,0 +1,678 @@
1
+ """The diff engine: match features, compare them and classify the changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable, Sequence
7
+ from dataclasses import asdict, dataclass, field, fields, replace
8
+ from typing import Any, Optional
9
+
10
+ from .geometry import (
11
+ BBox,
12
+ Metric,
13
+ bbox,
14
+ bbox_union,
15
+ centroid,
16
+ count_vertices,
17
+ geometry_area,
18
+ geometry_length,
19
+ hausdorff_distance,
20
+ is_geographic,
21
+ iter_parts,
22
+ same_structure,
23
+ vertex_changes,
24
+ )
25
+ from .io import Source, iter_features, source_name
26
+ from .matching import (
27
+ INDEXABLE,
28
+ STRATEGIES,
29
+ Record,
30
+ build_index,
31
+ key_function,
32
+ match_by_key,
33
+ match_spatial,
34
+ property_value,
35
+ )
36
+ from .properties import PropertyChange, diff_properties
37
+ from .tolerance import Tolerance, parse_tolerance
38
+
39
+ __all__ = [
40
+ "STATUSES",
41
+ "DiffOptions",
42
+ "DiffResult",
43
+ "FeatureChange",
44
+ "GeometryDiff",
45
+ "compare_features",
46
+ "compare_geometry",
47
+ "diff",
48
+ ]
49
+
50
+ STATUSES = ("added", "removed", "modified", "moved", "unchanged")
51
+ """Every classification, in display order."""
52
+
53
+ _M_PER_DEG = math.pi * 6371008.8 / 180.0
54
+
55
+ Feature = dict[str, Any]
56
+ Geometry = Optional[dict[str, Any]]
57
+
58
+
59
+ @dataclass
60
+ class DiffOptions:
61
+ """Settings for :func:`diff`.
62
+
63
+ ``match`` is an ordered list of strategies; features left unmatched by
64
+ one strategy are offered to the next. ``"auto"`` expands to
65
+ ``["key"]`` when ``keys`` are given and to ``["id", "hash"]`` otherwise.
66
+ """
67
+
68
+ match: Sequence[str] = ("auto",)
69
+ keys: Sequence[str] = ()
70
+ tolerance: Tolerance = field(default_factory=Tolerance)
71
+ geographic: bool | None = None
72
+ precision: int = 7
73
+ strict_types: bool = False
74
+ ignore_properties: Sequence[str] = ()
75
+ max_distance: Tolerance = field(default_factory=lambda: Tolerance(50.0, "m"))
76
+ attribute_weight: float = 0.5
77
+ min_score: float = 0.5
78
+ keep_unchanged: bool = False
79
+ use_shapely: bool = False
80
+
81
+ def __post_init__(self) -> None:
82
+ if isinstance(self.match, str):
83
+ self.match = [s.strip() for s in self.match.split(",") if s.strip()]
84
+ if isinstance(self.keys, str):
85
+ self.keys = [k.strip() for k in self.keys.split(",") if k.strip()]
86
+ if isinstance(self.ignore_properties, str):
87
+ self.ignore_properties = [self.ignore_properties]
88
+ self.tolerance = parse_tolerance(self.tolerance) # type: ignore[arg-type]
89
+ self.max_distance = parse_tolerance(self.max_distance) # type: ignore[arg-type]
90
+ for name in self.match:
91
+ if name != "auto" and name not in STRATEGIES:
92
+ raise ValueError(
93
+ f"unknown match strategy {name!r}; choose from auto, {', '.join(STRATEGIES)}"
94
+ )
95
+ if "key" in self.match and not self.keys:
96
+ raise ValueError("the 'key' strategy needs at least one property name (keys=...)")
97
+
98
+ def strategies(self) -> list[str]:
99
+ """The effective, de-duplicated strategy chain."""
100
+ out: list[str] = []
101
+ for name in self.match or ["auto"]:
102
+ expanded = (["key"] if self.keys else ["id", "hash"]) if name == "auto" else [name]
103
+ for item in expanded:
104
+ if item not in out:
105
+ out.append(item)
106
+ return out
107
+
108
+ def to_dict(self) -> dict[str, Any]:
109
+ out: dict[str, Any] = {}
110
+ for f in fields(self):
111
+ value = getattr(self, f.name)
112
+ if isinstance(value, Tolerance):
113
+ value = str(value)
114
+ elif isinstance(value, (list, tuple)):
115
+ value = list(value)
116
+ out[f.name] = value
117
+ out["strategies"] = self.strategies()
118
+ return out
119
+
120
+
121
+ @dataclass
122
+ class GeometryDiff:
123
+ """Geometry comparison of one matched feature pair.
124
+
125
+ Distances, lengths and areas are in metres / square metres for
126
+ longitude/latitude data (``units == "m"``) and in CRS units otherwise
127
+ (``units == "crs"``).
128
+ """
129
+
130
+ changed: bool
131
+ old_type: str | None
132
+ new_type: str | None
133
+ units: str = "m"
134
+ max_vertex_shift: float | None = None
135
+ hausdorff: float | None = None
136
+ centroid_shift: float | None = None
137
+ vertices_old: int = 0
138
+ vertices_new: int = 0
139
+ vertices_added: int = 0
140
+ vertices_removed: int = 0
141
+ vertices_moved: int = 0
142
+ area_old: float = 0.0
143
+ area_new: float = 0.0
144
+ length_old: float = 0.0
145
+ length_new: float = 0.0
146
+ bbox: BBox | None = None
147
+ symmetric_difference_area: float | None = None
148
+
149
+ @property
150
+ def type_changed(self) -> bool:
151
+ return self.old_type != self.new_type
152
+
153
+ @property
154
+ def area_delta(self) -> float:
155
+ return self.area_new - self.area_old
156
+
157
+ @property
158
+ def length_delta(self) -> float:
159
+ return self.length_new - self.length_old
160
+
161
+ def to_dict(self) -> dict[str, Any]:
162
+ if not self.changed:
163
+ # Metrics are only computed for geometries that changed.
164
+ return {
165
+ "changed": False,
166
+ "old_type": self.old_type,
167
+ "new_type": self.new_type,
168
+ "units": self.units,
169
+ "vertices": self.vertices_new,
170
+ }
171
+ out = asdict(self)
172
+ out["type_changed"] = self.type_changed
173
+ out["area_delta"] = self.area_delta
174
+ out["length_delta"] = self.length_delta
175
+ out["bbox"] = list(self.bbox) if self.bbox else None
176
+ return {k: _finite(v) for k, v in out.items()}
177
+
178
+
179
+ def _finite(value: Any) -> Any:
180
+ if isinstance(value, float) and not math.isfinite(value):
181
+ return None
182
+ return value
183
+
184
+
185
+ @dataclass
186
+ class FeatureChange:
187
+ """The classification of one feature (pair)."""
188
+
189
+ status: str
190
+ key: str
191
+ old_index: int | None = None
192
+ new_index: int | None = None
193
+ old: Feature | None = None
194
+ new: Feature | None = None
195
+ properties: list[PropertyChange] = field(default_factory=list)
196
+ geometry: GeometryDiff | None = None
197
+ matched_by: str | None = None
198
+ score: float | None = None
199
+ area_delta: float = 0.0
200
+ length_delta: float = 0.0
201
+
202
+ @property
203
+ def feature(self) -> Feature:
204
+ """The newest available version of the feature."""
205
+ return self.new if self.new is not None else self.old or {}
206
+
207
+ @property
208
+ def geometry_type(self) -> str | None:
209
+ geom = self.feature.get("geometry")
210
+ return geom.get("type") if geom else None
211
+
212
+ @property
213
+ def bbox(self) -> BBox | None:
214
+ boxes = []
215
+ if self.status in ("removed", "modified", "moved") and self.old is not None:
216
+ boxes.append(bbox(self.old.get("geometry")))
217
+ if self.status in ("added", "modified", "moved") and self.new is not None:
218
+ boxes.append(bbox(self.new.get("geometry")))
219
+ return bbox_union(boxes)
220
+
221
+ def to_dict(self, include_features: bool = True) -> dict[str, Any]:
222
+ """JSON-Patch-like representation of the change."""
223
+ op = {"added": "add", "removed": "remove"}.get(self.status, "modify")
224
+ if self.status == "unchanged":
225
+ op = "none"
226
+ out: dict[str, Any] = {"op": op, "status": self.status, "key": self.key}
227
+ if self.old_index is not None:
228
+ out["old_index"] = self.old_index
229
+ if self.new_index is not None:
230
+ out["new_index"] = self.new_index
231
+ if self.matched_by:
232
+ out["matched_by"] = self.matched_by
233
+ if self.score is not None:
234
+ out["score"] = round(self.score, 4)
235
+ if self.status == "added" and include_features:
236
+ out["feature"] = self.new
237
+ elif self.status == "removed" and include_features:
238
+ out["feature"] = self.old
239
+ if self.status in ("modified", "moved", "unchanged"):
240
+ patch = [c.to_dict() for c in self.properties]
241
+ if self.geometry is not None and self.geometry.changed and include_features:
242
+ patch.append(
243
+ {
244
+ "op": "replace",
245
+ "path": "/geometry",
246
+ "old": (self.old or {}).get("geometry"),
247
+ "value": (self.new or {}).get("geometry"),
248
+ }
249
+ )
250
+ out["patch"] = patch
251
+ if self.geometry is not None:
252
+ out["geometry"] = self.geometry.to_dict()
253
+ out["area_delta"] = _finite(self.area_delta)
254
+ out["length_delta"] = _finite(self.length_delta)
255
+ box = self.bbox
256
+ out["bbox"] = list(box) if box else None
257
+ return out
258
+
259
+
260
+ @dataclass
261
+ class DiffResult:
262
+ """Everything :func:`diff` found."""
263
+
264
+ changes: list[FeatureChange]
265
+ counts: dict[str, int]
266
+ old_count: int
267
+ new_count: int
268
+ options: DiffOptions
269
+ old_name: str = "old"
270
+ new_name: str = "new"
271
+ warnings: list[str] = field(default_factory=list)
272
+
273
+ @property
274
+ def has_changes(self) -> bool:
275
+ return any(self.counts.get(s, 0) for s in STATUSES if s != "unchanged")
276
+
277
+ @property
278
+ def changed(self) -> list[FeatureChange]:
279
+ """Changes other than ``unchanged``."""
280
+ return [c for c in self.changes if c.status != "unchanged"]
281
+
282
+ def by_status(self, status: str) -> list[FeatureChange]:
283
+ return [c for c in self.changes if c.status == status]
284
+
285
+ @property
286
+ def bbox(self) -> BBox | None:
287
+ """Bounding box of every changed geometry (before and after)."""
288
+ return bbox_union([c.bbox for c in self.changed])
289
+
290
+ @property
291
+ def area_delta(self) -> float:
292
+ return sum(c.area_delta for c in self.changed)
293
+
294
+ @property
295
+ def length_delta(self) -> float:
296
+ return sum(c.length_delta for c in self.changed)
297
+
298
+ @property
299
+ def units(self) -> str:
300
+ for change in self.changed:
301
+ if change.geometry is not None:
302
+ return change.geometry.units
303
+ geom = change.feature.get("geometry")
304
+ if geom:
305
+ geographic = self.options.geographic
306
+ if geographic is None:
307
+ geographic = is_geographic(geom)
308
+ return "m" if geographic else "crs"
309
+ return "m"
310
+
311
+ def summary(self) -> dict[str, Any]:
312
+ box = self.bbox
313
+ out: dict[str, Any] = {
314
+ "old_features": self.old_count,
315
+ "new_features": self.new_count,
316
+ }
317
+ out.update({s: self.counts.get(s, 0) for s in STATUSES})
318
+ out["has_changes"] = self.has_changes
319
+ out["units"] = self.units
320
+ out["bbox"] = list(box) if box else None
321
+ out["area_delta"] = _finite(self.area_delta)
322
+ out["length_delta"] = _finite(self.length_delta)
323
+ return out
324
+
325
+ def to_dict(self, include_features: bool = True) -> dict[str, Any]:
326
+ from . import __version__
327
+
328
+ return {
329
+ "format": "geojson-diff",
330
+ "version": 1,
331
+ "generator": f"geojson-diff {__version__}",
332
+ "old": self.old_name,
333
+ "new": self.new_name,
334
+ "options": self.options.to_dict(),
335
+ "summary": self.summary(),
336
+ "warnings": list(self.warnings),
337
+ "changes": [c.to_dict(include_features) for c in self.changes],
338
+ }
339
+
340
+
341
+ # ------------------------------------------------------------------- comparison
342
+
343
+
344
+ def _z_shift(a: Geometry, b: Geometry) -> float:
345
+ worst = 0.0
346
+ for (_, pa), (_, pb) in zip(iter_parts(a), iter_parts(b)):
347
+ for p, q in zip(pa, pb):
348
+ za = p[2] if len(p) > 2 else 0.0
349
+ zb = q[2] if len(q) > 2 else 0.0
350
+ worst = max(worst, abs(za - zb))
351
+ return worst
352
+
353
+
354
+ def compare_geometry(
355
+ old: Geometry, new: Geometry, options: DiffOptions | None = None
356
+ ) -> GeometryDiff:
357
+ """Compare two geometries against ``options.tolerance``.
358
+
359
+ The geometry is *unchanged* when both versions have the same structure
360
+ and every vertex stayed within the tolerance, or - for different vertex
361
+ layouts (re-ordered rings, extra collinear vertices) - when their
362
+ Hausdorff distance is within the tolerance. Z values are compared against
363
+ the tolerance value (in metres for metric tolerances).
364
+ """
365
+ opts = options or DiffOptions()
366
+ old_type = old.get("type") if old else None
367
+ new_type = new.get("type") if new else None
368
+ geographic = opts.geographic
369
+ if old == new:
370
+ # Fast path for the common case; the first vertex decides the units.
371
+ if geographic is None:
372
+ first = next(iter(iter_parts(old)), None)
373
+ geographic = True
374
+ if first and first[1]:
375
+ geographic = is_geographic({"type": "Point", "coordinates": first[1][0]})
376
+ n = count_vertices(old)
377
+ return GeometryDiff(
378
+ False, old_type, new_type, "m" if geographic else "crs", vertices_old=n, vertices_new=n
379
+ )
380
+
381
+ if geographic is None:
382
+ geographic = is_geographic(old, new)
383
+ units = "m" if geographic else "crs"
384
+ tol = opts.tolerance
385
+ check = Metric(geographic if tol.is_metric else False)
386
+ report = Metric(geographic)
387
+ grid = tol.value / _M_PER_DEG if (tol.is_metric and geographic) else tol.value
388
+
389
+ changed: bool
390
+ max_shift: float | None = None
391
+ if not old or not new or old_type != new_type:
392
+ changed = True
393
+ elif same_structure(old, new):
394
+ pairs = [
395
+ (p, q)
396
+ for (_, pa), (_, pb) in zip(iter_parts(old), iter_parts(new))
397
+ for p, q in zip(pa, pb)
398
+ ]
399
+ worst = max((check.point(p, q) for p, q in pairs), default=0.0)
400
+ max_shift = max((report.point(p, q) for p, q in pairs), default=0.0)
401
+ if worst <= tol.value:
402
+ changed = _z_shift(old, new) > (tol.value if tol.is_metric else 0.0)
403
+ else:
404
+ # Same layout but a vertex left the tolerance: a rotated ring may
405
+ # still be the same shape, so confirm with the Hausdorff distance.
406
+ changed = hausdorff_distance(old, new, check.metres, limit=tol.value) > tol.value
407
+ else:
408
+ changed = hausdorff_distance(old, new, check.metres, limit=tol.value) > tol.value
409
+ if opts.use_shapely and tol.value == 0.0 and old and new and old_type == new_type:
410
+ from ._shapely import exact_equal
411
+
412
+ changed = not exact_equal(old, new)
413
+
414
+ result = GeometryDiff(
415
+ changed,
416
+ old_type,
417
+ new_type,
418
+ units,
419
+ max_vertex_shift=max_shift,
420
+ vertices_old=count_vertices(old),
421
+ vertices_new=count_vertices(new),
422
+ )
423
+ if not changed:
424
+ return result
425
+ result.hausdorff = hausdorff_distance(old, new, report.metres) if old and new else None
426
+ ca, cb = centroid(old), centroid(new)
427
+ result.centroid_shift = report.point(ca, cb) if ca and cb else None
428
+ counts = vertex_changes(old, new, tol.value, check, grid)
429
+ result.vertices_added = counts["added"]
430
+ result.vertices_removed = counts["removed"]
431
+ result.vertices_moved = counts["moved"]
432
+ result.area_old = geometry_area(old, geographic)
433
+ result.area_new = geometry_area(new, geographic)
434
+ result.length_old = geometry_length(old, geographic)
435
+ result.length_new = geometry_length(new, geographic)
436
+ result.bbox = bbox_union([bbox(old), bbox(new)])
437
+ if opts.use_shapely and old and new:
438
+ from ._shapely import symmetric_difference_area
439
+
440
+ result.symmetric_difference_area = symmetric_difference_area(old, new, geographic)
441
+ return result
442
+
443
+
444
+ def _label(feature: Feature, index: int, keys: Sequence[str]) -> str:
445
+ if feature.get("id") is not None:
446
+ return str(feature["id"])
447
+ if keys:
448
+ values = [property_value(feature, k) for k in keys]
449
+ if all(isinstance(v, (str, int, float, bool)) for v in values):
450
+ if len(keys) == 1:
451
+ return str(values[0])
452
+ return ", ".join(f"{k}={v}" for k, v in zip(keys, values))
453
+ return f"#{index}"
454
+
455
+
456
+ def compare_features(
457
+ old: Feature,
458
+ new: Feature,
459
+ options: DiffOptions | None = None,
460
+ *,
461
+ old_index: int = 0,
462
+ new_index: int = 0,
463
+ matched_by: str | None = None,
464
+ score: float | None = None,
465
+ ) -> FeatureChange:
466
+ """Compare two versions of one feature and classify the change.
467
+
468
+ The status is ``moved`` when the geometry changed beyond the tolerance
469
+ (property changes are still listed), ``modified`` when only properties
470
+ (or the feature ``id``) changed and ``unchanged`` otherwise.
471
+ """
472
+ opts = options or DiffOptions()
473
+ props = diff_properties(
474
+ old.get("properties"),
475
+ new.get("properties"),
476
+ strict_types=opts.strict_types,
477
+ ignore=opts.ignore_properties,
478
+ )
479
+ if old.get("id") != new.get("id"):
480
+ op = "add" if "id" not in old else "remove" if "id" not in new else "replace"
481
+ props.insert(0, PropertyChange(op, ["id"], old=old.get("id"), new=new.get("id")))
482
+ geom = compare_geometry(old.get("geometry"), new.get("geometry"), opts)
483
+ if geom.changed:
484
+ status = "moved"
485
+ elif props:
486
+ status = "modified"
487
+ else:
488
+ status = "unchanged"
489
+ return FeatureChange(
490
+ status=status,
491
+ key=_label(new, new_index, opts.keys),
492
+ old_index=old_index,
493
+ new_index=new_index,
494
+ old=old,
495
+ new=new,
496
+ properties=props,
497
+ geometry=geom,
498
+ matched_by=matched_by,
499
+ score=score,
500
+ area_delta=geom.area_delta if geom.changed else 0.0,
501
+ length_delta=geom.length_delta if geom.changed else 0.0,
502
+ )
503
+
504
+
505
+ # ----------------------------------------------------------------------- engine
506
+
507
+
508
+ def diff(
509
+ old: Source | Iterable[Feature],
510
+ new: Source | Iterable[Feature],
511
+ options: DiffOptions | None = None,
512
+ **overrides: Any,
513
+ ) -> DiffResult:
514
+ """Diff two GeoJSON datasets.
515
+
516
+ ``old`` and ``new`` may be paths, open files, parsed GeoJSON objects or
517
+ iterables of features. Keyword ``overrides`` set :class:`DiffOptions`
518
+ fields, e.g. ``diff(a, b, keys=["parcel_id"], tolerance="0.5m")``.
519
+
520
+ The old dataset is indexed; the new one is streamed. With an indexable
521
+ first strategy (id, key or hash) unchanged features are discarded as soon
522
+ as they are seen, so memory stays proportional to the old file plus the
523
+ changes.
524
+ """
525
+ opts = options or DiffOptions()
526
+ if overrides:
527
+ opts = replace(opts, **overrides)
528
+ strategies = opts.strategies()
529
+ warnings: list[str] = []
530
+
531
+ old_iter = _features(old)
532
+ new_iter = _features(new)
533
+
534
+ remaining: dict[int, Record] = {}
535
+ for i, feature in enumerate(old_iter):
536
+ remaining[i] = Record(i, feature)
537
+ old_count = len(remaining)
538
+
539
+ counts = dict.fromkeys(STATUSES, 0)
540
+ changes: list[FeatureChange] = []
541
+
542
+ def record(change: FeatureChange) -> None:
543
+ counts[change.status] += 1
544
+ if change.status != "unchanged" or opts.keep_unchanged:
545
+ changes.append(change)
546
+
547
+ def pair(o: Record, n: Record, by: str, score: float | None = None) -> None:
548
+ remaining.pop(o.index, None)
549
+ record(
550
+ compare_features(
551
+ o.feature,
552
+ n.feature,
553
+ opts,
554
+ old_index=o.index,
555
+ new_index=n.index,
556
+ matched_by=by,
557
+ score=score,
558
+ )
559
+ )
560
+
561
+ first = strategies[0]
562
+ index = None
563
+ keyfn = None
564
+ if first in INDEXABLE:
565
+ keyfn = key_function(first, opts.keys, opts.precision)
566
+ index, dups = build_index(remaining.values(), keyfn)
567
+ if dups:
568
+ warnings.append(
569
+ f"{dups} duplicate {first} key(s) in {source_name(old)}; paired in input order"
570
+ )
571
+ pending: list[Record] = []
572
+ seen_new: set = set()
573
+ new_dups = 0
574
+ new_count = 0
575
+ for j, feature in enumerate(new_iter):
576
+ new_count += 1
577
+ if index is not None and keyfn is not None:
578
+ key = keyfn(feature)
579
+ if key is not None:
580
+ if key in seen_new:
581
+ new_dups += 1
582
+ seen_new.add(key)
583
+ bucket = index.get(key)
584
+ if bucket:
585
+ pair(bucket.popleft(), Record(j, feature), first)
586
+ continue
587
+ pending.append(Record(j, feature))
588
+ if new_dups:
589
+ warnings.append(
590
+ f"{new_dups} duplicate {first} key(s) in {source_name(new)}; paired in input order"
591
+ )
592
+ del seen_new
593
+
594
+ for strategy in strategies[1 if index is not None else 0 :]:
595
+ if not remaining or not pending:
596
+ break
597
+ olds = list(remaining.values())
598
+ if strategy == "spatial":
599
+ matched = match_spatial(
600
+ olds,
601
+ pending,
602
+ max_distance=opts.max_distance,
603
+ attribute_weight=opts.attribute_weight,
604
+ min_score=opts.min_score,
605
+ ignore=opts.ignore_properties,
606
+ geographic=opts.geographic,
607
+ )
608
+ triples: list[tuple[Record, Record, float | None]] = list(matched) # type: ignore[arg-type]
609
+ else:
610
+ fn = key_function(strategy, opts.keys, opts.precision)
611
+ triples = [(o, n, None) for o, n in match_by_key(olds, pending, fn)]
612
+ used = set()
613
+ for o, n, score in triples:
614
+ pair(o, n, strategy, score)
615
+ used.add(n.index)
616
+ pending = [n for n in pending if n.index not in used]
617
+
618
+ for rec in remaining.values():
619
+ geom = rec.feature.get("geometry")
620
+ record(
621
+ FeatureChange(
622
+ "removed",
623
+ _label(rec.feature, rec.index, opts.keys),
624
+ old_index=rec.index,
625
+ old=rec.feature,
626
+ area_delta=-geometry_area(geom, opts.geographic),
627
+ length_delta=-geometry_length(geom, opts.geographic),
628
+ )
629
+ )
630
+ for rec in pending:
631
+ geom = rec.feature.get("geometry")
632
+ record(
633
+ FeatureChange(
634
+ "added",
635
+ _label(rec.feature, rec.index, opts.keys),
636
+ new_index=rec.index,
637
+ new=rec.feature,
638
+ area_delta=geometry_area(geom, opts.geographic),
639
+ length_delta=geometry_length(geom, opts.geographic),
640
+ )
641
+ )
642
+
643
+ order = {s: i for i, s in enumerate(STATUSES)}
644
+
645
+ def sort_key(c: FeatureChange) -> tuple[int, int, int]:
646
+ primary = c.new_index if c.new_index is not None else c.old_index
647
+ return (
648
+ order[c.status],
649
+ primary if primary is not None else -1,
650
+ c.old_index or 0,
651
+ )
652
+
653
+ changes.sort(key=sort_key)
654
+ return DiffResult(
655
+ changes=changes,
656
+ counts=counts,
657
+ old_count=old_count,
658
+ new_count=new_count,
659
+ options=opts,
660
+ old_name=source_name(old) if not _is_feature_iterable(old) else "old",
661
+ new_name=source_name(new) if not _is_feature_iterable(new) else "new",
662
+ warnings=warnings,
663
+ )
664
+
665
+
666
+ def _features(source: Any) -> Iterable[Any]:
667
+ return source if _is_feature_iterable(source) else iter_features(source)
668
+
669
+
670
+ def _is_feature_iterable(obj: Any) -> bool:
671
+ """True for iterables of features that are not GeoJSON sources themselves."""
672
+ if (
673
+ isinstance(obj, (str, bytes, dict, list))
674
+ or hasattr(obj, "read")
675
+ or hasattr(obj, "__fspath__")
676
+ ):
677
+ return False
678
+ return hasattr(obj, "__iter__")