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/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__")
|