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.
@@ -0,0 +1,454 @@
1
+ """Pure-Python geometry helpers: distances, Hausdorff, area, length, hashing.
2
+
3
+ All functions accept GeoJSON geometry dictionaries. Longitude/latitude data
4
+ (RFC 7946) is measured on a sphere (haversine for distances, the spherical
5
+ excess formula for areas); projected data is measured in CRS units.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import math
13
+ from collections.abc import Iterator, Sequence
14
+ from difflib import SequenceMatcher
15
+ from typing import Any
16
+
17
+ __all__ = [
18
+ "EARTH_RADIUS_M",
19
+ "Metric",
20
+ "bbox",
21
+ "bbox_union",
22
+ "centroid",
23
+ "geometry_area",
24
+ "geometry_hash",
25
+ "geometry_length",
26
+ "geometry_vertices",
27
+ "hausdorff_distance",
28
+ "haversine",
29
+ "is_geographic",
30
+ "iter_parts",
31
+ "same_structure",
32
+ "vertex_changes",
33
+ ]
34
+
35
+ EARTH_RADIUS_M = 6371008.8
36
+ """Mean Earth radius used for haversine distances."""
37
+ _AREA_RADIUS_M = 6378137.0 # WGS84 semi-major axis, as used by common GeoJSON area tools
38
+ _M_PER_DEG = math.pi * EARTH_RADIUS_M / 180.0
39
+
40
+ Position = Sequence[float]
41
+ Geometry = dict[str, Any]
42
+ BBox = tuple[float, float, float, float]
43
+
44
+
45
+ # --------------------------------------------------------------------------- parts
46
+
47
+
48
+ def iter_parts(geom: Geometry | None) -> Iterator[tuple[str, list[Position]]]:
49
+ """Yield ``(kind, positions)`` for every primitive part of ``geom``.
50
+
51
+ ``kind`` is ``"point"``, ``"line"`` or ``"ring"``.
52
+ """
53
+ if not geom:
54
+ return
55
+ kind = geom.get("type")
56
+ coords = geom.get("coordinates")
57
+ if kind == "Point":
58
+ if coords:
59
+ yield "point", [coords]
60
+ elif kind == "MultiPoint":
61
+ for point in coords or []:
62
+ yield "point", [point]
63
+ elif kind == "LineString":
64
+ yield "line", list(coords or [])
65
+ elif kind == "MultiLineString":
66
+ for line in coords or []:
67
+ yield "line", list(line)
68
+ elif kind == "Polygon":
69
+ for ring in coords or []:
70
+ yield "ring", list(ring)
71
+ elif kind == "MultiPolygon":
72
+ for polygon in coords or []:
73
+ for ring in polygon:
74
+ yield "ring", list(ring)
75
+ elif kind == "GeometryCollection":
76
+ for member in geom.get("geometries") or []:
77
+ yield from iter_parts(member)
78
+ else:
79
+ raise ValueError(f"unsupported geometry type {kind!r}")
80
+
81
+
82
+ def geometry_vertices(geom: Geometry | None) -> list[Position]:
83
+ """All positions of ``geom`` (closing ring positions included)."""
84
+ out: list[Position] = []
85
+ for _, positions in iter_parts(geom):
86
+ out.extend(positions)
87
+ return out
88
+
89
+
90
+ def _distinct(kind: str, positions: list[Position]) -> list[Position]:
91
+ if kind == "ring" and len(positions) > 1 and list(positions[0]) == list(positions[-1]):
92
+ return positions[:-1]
93
+ return positions
94
+
95
+
96
+ def bbox(geom: Geometry | None) -> BBox | None:
97
+ """``(min_x, min_y, max_x, max_y)`` of ``geom`` or ``None`` when empty."""
98
+ xs: list[float] = []
99
+ ys: list[float] = []
100
+ for pos in geometry_vertices(geom):
101
+ xs.append(float(pos[0]))
102
+ ys.append(float(pos[1]))
103
+ if not xs:
104
+ return None
105
+ return (min(xs), min(ys), max(xs), max(ys))
106
+
107
+
108
+ def bbox_union(boxes: Sequence[BBox | None]) -> BBox | None:
109
+ """Union of bounding boxes, ignoring ``None`` entries."""
110
+ real = [b for b in boxes if b is not None]
111
+ if not real:
112
+ return None
113
+ return (
114
+ min(b[0] for b in real),
115
+ min(b[1] for b in real),
116
+ max(b[2] for b in real),
117
+ max(b[3] for b in real),
118
+ )
119
+
120
+
121
+ def is_geographic(*geoms: Geometry | None) -> bool:
122
+ """True when every coordinate looks like longitude/latitude."""
123
+ for geom in geoms:
124
+ for pos in geometry_vertices(geom):
125
+ if not (-180.0 <= pos[0] <= 180.0 and -90.0 <= pos[1] <= 90.0):
126
+ return False
127
+ return True
128
+
129
+
130
+ def centroid(geom: Geometry | None) -> tuple[float, float] | None:
131
+ """Mean of the distinct vertices (a cheap, stable representative point)."""
132
+ sx = sy = 0.0
133
+ n = 0
134
+ for kind, positions in iter_parts(geom):
135
+ for pos in _distinct(kind, positions):
136
+ sx += pos[0]
137
+ sy += pos[1]
138
+ n += 1
139
+ if n == 0:
140
+ return None
141
+ return (sx / n, sy / n)
142
+
143
+
144
+ # ----------------------------------------------------------------------- distances
145
+
146
+
147
+ def haversine(a: Position, b: Position) -> float:
148
+ """Great-circle distance in metres between two lon/lat positions."""
149
+ lon1, lat1, lon2, lat2 = map(math.radians, (a[0], a[1], b[0], b[1]))
150
+ h = (
151
+ math.sin((lat2 - lat1) / 2.0) ** 2
152
+ + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2.0) ** 2
153
+ )
154
+ return 2.0 * EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(h)))
155
+
156
+
157
+ def _wrap(dlon: float) -> float:
158
+ while dlon > 180.0:
159
+ dlon -= 360.0
160
+ while dlon < -180.0:
161
+ dlon += 360.0
162
+ return dlon
163
+
164
+
165
+ class Metric:
166
+ """Distance functions in metres (lon/lat data) or in coordinate units."""
167
+
168
+ def __init__(self, metres: bool) -> None:
169
+ self.metres = metres
170
+
171
+ def point(self, a: Position, b: Position) -> float:
172
+ if self.metres:
173
+ return haversine(a, b)
174
+ return math.hypot(b[0] - a[0], b[1] - a[1])
175
+
176
+ def segment(self, p: Position, a: Position, b: Position) -> float:
177
+ """Distance from ``p`` to the segment ``a``-``b``."""
178
+ if self.metres:
179
+ # Local equirectangular projection centred on p: accurate for the
180
+ # short segments that matter when comparing two versions of a feature.
181
+ k = math.cos(math.radians(p[1]))
182
+ ax, ay = _wrap(a[0] - p[0]) * k, a[1] - p[1]
183
+ bx, by = _wrap(b[0] - p[0]) * k, b[1] - p[1]
184
+ return _seg_origin(ax, ay, bx, by) * _M_PER_DEG
185
+ return _seg_origin(a[0] - p[0], a[1] - p[1], b[0] - p[0], b[1] - p[1])
186
+
187
+
188
+ def _seg_origin(ax: float, ay: float, bx: float, by: float) -> float:
189
+ """Distance from the origin to the segment (ax, ay)-(bx, by)."""
190
+ dx, dy = bx - ax, by - ay
191
+ denom = dx * dx + dy * dy
192
+ if denom == 0.0:
193
+ return math.hypot(ax, ay)
194
+ t = -(ax * dx + ay * dy) / denom
195
+ t = 0.0 if t < 0.0 else min(t, 1.0)
196
+ return math.hypot(ax + t * dx, ay + t * dy)
197
+
198
+
199
+ def _segments(geom: Geometry | None) -> list[tuple[Position, Position]]:
200
+ segs: list[tuple[Position, Position]] = []
201
+ for _, positions in iter_parts(geom):
202
+ if len(positions) == 1:
203
+ segs.append((positions[0], positions[0]))
204
+ for i in range(len(positions) - 1):
205
+ segs.append((positions[i], positions[i + 1]))
206
+ return segs
207
+
208
+
209
+ def _directed(
210
+ points: list[Position],
211
+ segs: list[tuple[Position, Position]],
212
+ metric: Metric,
213
+ cmax: float,
214
+ limit: float | None,
215
+ ) -> float:
216
+ n, m = len(points), len(segs)
217
+ for i, p in enumerate(points):
218
+ cmin = math.inf
219
+ start = i * m // n
220
+ for k in range(m):
221
+ off = (k + 1) // 2 if k % 2 else -(k // 2)
222
+ a, b = segs[(start + off) % m]
223
+ d = metric.segment(p, a, b)
224
+ if d < cmin:
225
+ cmin = d
226
+ if cmin <= cmax:
227
+ break # early break: this point cannot raise the maximum
228
+ if cmin > cmax:
229
+ cmax = cmin
230
+ if limit is not None and cmax > limit:
231
+ return cmax
232
+ return cmax
233
+
234
+
235
+ def hausdorff_distance(
236
+ a: Geometry | None,
237
+ b: Geometry | None,
238
+ metres: bool | None = None,
239
+ limit: float | None = None,
240
+ ) -> float:
241
+ """Symmetric Hausdorff distance between two geometries (pure Python).
242
+
243
+ Every vertex of one geometry is measured against the segments (and
244
+ points) of the other, in both directions. Uses the early-break algorithm
245
+ of Taha & Hanbury (2015), visiting candidate segments outward from the
246
+ aligned index, which makes it close to linear for two versions of the same
247
+ feature. When ``limit`` is given the search stops as soon as the distance
248
+ is known to exceed it (the returned value is then a lower bound above
249
+ ``limit``). ``metres=None`` measures in metres when both geometries are
250
+ longitude/latitude.
251
+ """
252
+ if metres is None:
253
+ metres = is_geographic(a, b)
254
+ metric = Metric(metres)
255
+ pa, pb = geometry_vertices(a), geometry_vertices(b)
256
+ if not pa and not pb:
257
+ return 0.0
258
+ if not pa or not pb:
259
+ return math.inf
260
+ cmax = _directed(pa, _segments(b), metric, 0.0, limit)
261
+ if limit is not None and cmax > limit:
262
+ return cmax
263
+ return _directed(pb, _segments(a), metric, cmax, limit)
264
+
265
+
266
+ # --------------------------------------------------------------------- area/length
267
+
268
+
269
+ def _ring_area_geodesic(ring: list[Position]) -> float:
270
+ n = len(ring)
271
+ if n < 3:
272
+ return 0.0
273
+ total = 0.0
274
+ for i in range(n):
275
+ if i == n - 2:
276
+ lo, mid, hi = n - 2, n - 1, 0
277
+ elif i == n - 1:
278
+ lo, mid, hi = n - 1, 0, 1
279
+ else:
280
+ lo, mid, hi = i, i + 1, i + 2
281
+ total += (math.radians(ring[hi][0]) - math.radians(ring[lo][0])) * math.sin(
282
+ math.radians(ring[mid][1])
283
+ )
284
+ return abs(total * _AREA_RADIUS_M * _AREA_RADIUS_M / 2.0)
285
+
286
+
287
+ def _ring_area_planar(ring: list[Position]) -> float:
288
+ total = 0.0
289
+ n = len(ring)
290
+ for i in range(n):
291
+ x1, y1 = ring[i][0], ring[i][1]
292
+ x2, y2 = ring[(i + 1) % n][0], ring[(i + 1) % n][1]
293
+ total += x1 * y2 - x2 * y1
294
+ return abs(total) / 2.0
295
+
296
+
297
+ def _polygon_area(rings: Sequence[list[Position]], metres: bool) -> float:
298
+ if not rings:
299
+ return 0.0
300
+ fn = _ring_area_geodesic if metres else _ring_area_planar
301
+ outer = fn(list(rings[0]))
302
+ holes = sum(fn(list(r)) for r in rings[1:])
303
+ return max(0.0, outer - holes)
304
+
305
+
306
+ def geometry_area(geom: Geometry | None, metres: bool | None = None) -> float:
307
+ """Area in square metres (lon/lat data) or squared CRS units."""
308
+ if not geom:
309
+ return 0.0
310
+ if metres is None:
311
+ metres = is_geographic(geom)
312
+ kind = geom.get("type")
313
+ if kind == "Polygon":
314
+ return _polygon_area(geom.get("coordinates") or [], metres)
315
+ if kind == "MultiPolygon":
316
+ return sum(_polygon_area(p, metres) for p in geom.get("coordinates") or [])
317
+ if kind == "GeometryCollection":
318
+ return sum(geometry_area(g, metres) for g in geom.get("geometries") or [])
319
+ return 0.0
320
+
321
+
322
+ def geometry_length(geom: Geometry | None, metres: bool | None = None) -> float:
323
+ """Length of lines (perimeter for polygons) in metres or CRS units."""
324
+ if not geom:
325
+ return 0.0
326
+ if metres is None:
327
+ metres = is_geographic(geom)
328
+ metric = Metric(metres)
329
+ total = 0.0
330
+ for kind, positions in iter_parts(geom):
331
+ if kind == "point":
332
+ continue
333
+ for i in range(len(positions) - 1):
334
+ total += metric.point(positions[i], positions[i + 1])
335
+ return total
336
+
337
+
338
+ # ------------------------------------------------------------------------- hashing
339
+
340
+
341
+ def _round(pos: Position, precision: int) -> list[float]:
342
+ return [round(float(c), precision) + 0.0 for c in pos]
343
+
344
+
345
+ def _canonical_ring(ring: list[Position], precision: int) -> list[list[float]]:
346
+ pts = [_round(p, precision) for p in ring]
347
+ if len(pts) > 1 and pts[0] == pts[-1]:
348
+ pts = pts[:-1]
349
+ if not pts:
350
+ return []
351
+
352
+ def rotate(seq: list[list[float]]) -> list[list[float]]:
353
+ i = seq.index(min(seq))
354
+ return seq[i:] + seq[:i]
355
+
356
+ forward = rotate(pts)
357
+ backward = rotate(pts[::-1])
358
+ best = min(forward, backward)
359
+ return best + [best[0]]
360
+
361
+
362
+ def _canonical(geom: Geometry | None, precision: int) -> Any:
363
+ if not geom:
364
+ return None
365
+ kind = geom.get("type")
366
+ coords = geom.get("coordinates")
367
+ if kind == "Point":
368
+ return [kind, _round(coords, precision) if coords else []]
369
+ if kind == "LineString":
370
+ return [kind, [_round(p, precision) for p in coords or []]]
371
+ if kind == "MultiPoint":
372
+ return [kind, sorted(_round(p, precision) for p in coords or [])]
373
+ if kind == "MultiLineString":
374
+ lines = [[_round(p, precision) for p in line] for line in coords or []]
375
+ return [kind, sorted(lines)]
376
+ if kind == "Polygon":
377
+ return [kind, [_canonical_ring(r, precision) for r in coords or []]]
378
+ if kind == "MultiPolygon":
379
+ polys = [[_canonical_ring(r, precision) for r in poly] for poly in coords or []]
380
+ return [kind, sorted(polys)]
381
+ if kind == "GeometryCollection":
382
+ members = [_canonical(g, precision) for g in geom.get("geometries") or []]
383
+ return [kind, sorted(members, key=lambda m: json.dumps(m))]
384
+ raise ValueError(f"unsupported geometry type {kind!r}")
385
+
386
+
387
+ def geometry_hash(geom: Geometry | None, precision: int = 7) -> str:
388
+ """Stable SHA-1 of a geometry, insensitive to ring start/orientation and part order.
389
+
390
+ Coordinates are rounded to ``precision`` decimals (7 decimals is about
391
+ 1 cm at the equator).
392
+ """
393
+ payload = json.dumps(_canonical(geom, precision), separators=(",", ":"))
394
+ return hashlib.sha1(payload.encode("utf-8")).hexdigest()
395
+
396
+
397
+ # ------------------------------------------------------------------ vertex changes
398
+
399
+
400
+ def same_structure(a: Geometry | None, b: Geometry | None) -> bool:
401
+ """True when both geometries have the same type, parts and vertex counts."""
402
+ if not a or not b or a.get("type") != b.get("type"):
403
+ return False
404
+ pa = [(k, len(p)) for k, p in iter_parts(a)]
405
+ pb = [(k, len(p)) for k, p in iter_parts(b)]
406
+ return pa == pb
407
+
408
+
409
+ def _snap_key(pos: Position, grid: float) -> tuple[int, int]:
410
+ return (round(pos[0] / grid), round(pos[1] / grid))
411
+
412
+
413
+ def vertex_changes(
414
+ a: Geometry | None,
415
+ b: Geometry | None,
416
+ tolerance: float,
417
+ metric: Metric,
418
+ grid: float,
419
+ ) -> dict[str, int]:
420
+ """Count vertices ``added``, ``removed`` and ``moved`` between two geometries.
421
+
422
+ Parts are compared in order. Equal-length parts are compared vertex by
423
+ vertex against ``tolerance`` (measured with ``metric``); otherwise the
424
+ vertex sequences, snapped to a ``grid`` in coordinate units, are aligned
425
+ with :class:`difflib.SequenceMatcher`.
426
+ """
427
+ parts_a = [_distinct(k, p) for k, p in iter_parts(a)]
428
+ parts_b = [_distinct(k, p) for k, p in iter_parts(b)]
429
+ added = removed = moved = 0
430
+ grid = grid if grid > 0 else 1e-9
431
+ for i in range(max(len(parts_a), len(parts_b))):
432
+ pa = parts_a[i] if i < len(parts_a) else []
433
+ pb = parts_b[i] if i < len(parts_b) else []
434
+ if len(pa) == len(pb):
435
+ moved += sum(1 for p, q in zip(pa, pb) if metric.point(p, q) > tolerance)
436
+ continue
437
+ sa = [_snap_key(p, grid) for p in pa]
438
+ sb = [_snap_key(p, grid) for p in pb]
439
+ for tag, i1, i2, j1, j2 in SequenceMatcher(None, sa, sb, autojunk=False).get_opcodes():
440
+ la, lb = i2 - i1, j2 - j1
441
+ if tag == "replace":
442
+ moved += min(la, lb)
443
+ added += max(0, lb - la)
444
+ removed += max(0, la - lb)
445
+ elif tag == "insert":
446
+ added += lb
447
+ elif tag == "delete":
448
+ removed += la
449
+ return {"added": added, "removed": removed, "moved": moved}
450
+
451
+
452
+ def count_vertices(geom: Geometry | None) -> int:
453
+ """Number of distinct vertices (closing ring positions not counted)."""
454
+ return sum(len(_distinct(k, p)) for k, p in iter_parts(geom))
geojson_diff/git.py ADDED
@@ -0,0 +1,112 @@
1
+ """Git integration: textconv filter, external diff driver and revision access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ import subprocess
9
+ from typing import Any
10
+
11
+ from .geometry import count_vertices, geometry_hash
12
+ from .io import Source, iter_features
13
+
14
+ __all__ = ["NULL_PATHS", "driver_sources", "read_revision", "textconv"]
15
+
16
+ NULL_PATHS = ("/dev/null", "nul", "NUL")
17
+ """Placeholders git passes for the missing side of an added or deleted file."""
18
+
19
+ _EMPTY: dict[str, Any] = {"type": "FeatureCollection", "features": []}
20
+
21
+
22
+ def _round(obj: Any, precision: int) -> Any:
23
+ if isinstance(obj, (int, float)) and not isinstance(obj, bool):
24
+ return round(float(obj), precision) + 0.0 # 29 and 29.0 render alike
25
+ if isinstance(obj, list):
26
+ return [_round(o, precision) for o in obj]
27
+ if isinstance(obj, dict):
28
+ return {k: _round(v, precision) for k, v in obj.items()}
29
+ return obj
30
+
31
+
32
+ def _sort_key(feature: dict[str, Any], index: int) -> tuple[int, str, int]:
33
+ fid = feature.get("id")
34
+ if fid is None:
35
+ return (1, "", index)
36
+ return (0, json.dumps(fid) if not isinstance(fid, str) else fid, index)
37
+
38
+
39
+ def textconv(source: Source, precision: int = 7) -> str:
40
+ """A line-oriented, stable rendering of a GeoJSON file for ``git diff``.
41
+
42
+ Features are sorted by ``id`` (input order otherwise), one property per
43
+ line with sorted keys, so a plain line diff shows exactly which
44
+ attributes and geometries changed. Coordinates are rounded to
45
+ ``precision`` decimals.
46
+ """
47
+ features = list(iter_features(source))
48
+ order = sorted(range(len(features)), key=lambda i: _sort_key(features[i], i))
49
+ lines: list[str] = [f"# geojson-diff textconv: {len(features)} features"]
50
+ for i in order:
51
+ feature = features[i]
52
+ geom = feature.get("geometry")
53
+ fid = feature.get("id")
54
+ label = f"id={json.dumps(fid, ensure_ascii=False)}" if fid is not None else f"#{i}"
55
+ kind = geom.get("type") if geom else "null"
56
+ lines.append(f"feature {label} ({kind})")
57
+ props = feature.get("properties") or {}
58
+ for key in sorted(props):
59
+ value = json.dumps(props[key], ensure_ascii=False, sort_keys=True)
60
+ lines.append(f" {json.dumps(key, ensure_ascii=False)}: {value}")
61
+ if geom:
62
+ digest = geometry_hash(geom, precision)[:12]
63
+ lines.append(f" geometry: {kind}, {count_vertices(geom)} vertices, hash {digest}")
64
+ coords = geom.get("coordinates", geom.get("geometries"))
65
+ lines.append(" coordinates: " + json.dumps(_round(coords, precision)))
66
+ return "\n".join(lines) + "\n"
67
+
68
+
69
+ def _side(path: str) -> Source:
70
+ if path in NULL_PATHS or not os.path.exists(path):
71
+ return dict(_EMPTY)
72
+ return path
73
+
74
+
75
+ def driver_sources(args: list[str]) -> tuple[str, str, Source, Source]:
76
+ """Interpret the arguments git passes to a ``diff.<driver>.command``.
77
+
78
+ Git calls the command with ``path old-file old-hex old-mode new-file
79
+ new-hex new-mode`` (two more arguments for renames: the new path and a
80
+ similarity message). Returns the old and new display paths and the two
81
+ sources; ``/dev/null`` becomes an empty collection.
82
+ """
83
+ if len(args) not in (7, 9):
84
+ raise ValueError(
85
+ "--git expects the 7 (or 9) arguments git passes to an external diff command, "
86
+ f"got {len(args)}"
87
+ )
88
+ new_path = args[7] if len(args) == 9 else args[0]
89
+ return args[0], new_path, _side(args[1]), _side(args[4])
90
+
91
+
92
+ def read_revision(rev: str, path: str, cwd: str | None = None, timeout: float = 60.0) -> Source:
93
+ """Contents of ``path`` at git revision ``rev`` (an empty collection if absent).
94
+
95
+ ``path`` is relative to the current directory, like other git commands.
96
+ """
97
+ spec = f"{rev}:./{path.replace(os.sep, '/')}" if not os.path.isabs(path) else f"{rev}:{path}"
98
+ proc = subprocess.run(
99
+ ["git", "show", spec],
100
+ cwd=cwd,
101
+ capture_output=True,
102
+ timeout=timeout,
103
+ check=False,
104
+ )
105
+ if proc.returncode != 0:
106
+ message = proc.stderr.decode("utf-8", "replace").strip()
107
+ if "exists on disk, but not in" in message or "does not exist in" in message:
108
+ return dict(_EMPTY)
109
+ raise ValueError(f"git show {spec} failed: {message or proc.returncode}")
110
+ stream = io.BytesIO(proc.stdout)
111
+ stream.name = f"{rev}:{path}" # type: ignore[attr-defined]
112
+ return stream