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/__init__.py +60 -0
- geojson_diff/__main__.py +7 -0
- geojson_diff/_shapely.py +80 -0
- geojson_diff/cli.py +374 -0
- geojson_diff/core.py +678 -0
- geojson_diff/geometry.py +454 -0
- geojson_diff/git.py +112 -0
- geojson_diff/io.py +254 -0
- geojson_diff/matching.py +218 -0
- geojson_diff/properties.py +208 -0
- geojson_diff/py.typed +0 -0
- geojson_diff/render/__init__.py +146 -0
- geojson_diff/render/html.py +237 -0
- geojson_diff/render/layer.py +111 -0
- geojson_diff/render/machine.py +20 -0
- geojson_diff/render/markdown.py +143 -0
- geojson_diff/render/text.py +85 -0
- geojson_diff/tolerance.py +86 -0
- geojson_diff-0.1.0.dist-info/METADATA +478 -0
- geojson_diff-0.1.0.dist-info/RECORD +24 -0
- geojson_diff-0.1.0.dist-info/WHEEL +5 -0
- geojson_diff-0.1.0.dist-info/entry_points.txt +3 -0
- geojson_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
- geojson_diff-0.1.0.dist-info/top_level.txt +1 -0
geojson_diff/geometry.py
ADDED
|
@@ -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
|