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/io.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Streaming readers for GeoJSON FeatureCollections and GeoJSONSeq.
|
|
2
|
+
|
|
3
|
+
The reader never needs the whole document in memory at once: the
|
|
4
|
+
``features`` array of a FeatureCollection is decoded one feature at a time,
|
|
5
|
+
and GeoJSONSeq / newline-delimited files are decoded record by record.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Iterable, Iterator
|
|
15
|
+
from typing import IO, Any, Union
|
|
16
|
+
|
|
17
|
+
__all__ = ["GeoJSONError", "Source", "iter_features", "load_features"]
|
|
18
|
+
|
|
19
|
+
Feature = dict[str, Any]
|
|
20
|
+
Source = Union[str, "os.PathLike[str]", dict[str, Any], list[Any], IO[str], IO[bytes]]
|
|
21
|
+
|
|
22
|
+
_GEOMETRY_TYPES = {
|
|
23
|
+
"Point",
|
|
24
|
+
"MultiPoint",
|
|
25
|
+
"LineString",
|
|
26
|
+
"MultiLineString",
|
|
27
|
+
"Polygon",
|
|
28
|
+
"MultiPolygon",
|
|
29
|
+
"GeometryCollection",
|
|
30
|
+
}
|
|
31
|
+
_RS = "\x1e"
|
|
32
|
+
_WS = " \t\r\n" + _RS
|
|
33
|
+
_CHUNK = 1 << 20
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GeoJSONError(ValueError):
|
|
37
|
+
"""Raised when an input is not valid (or not supported) GeoJSON."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _as_feature(obj: Any, where: str) -> Feature:
|
|
41
|
+
if not isinstance(obj, dict):
|
|
42
|
+
raise GeoJSONError(f"{where}: expected a GeoJSON object, got {type(obj).__name__}")
|
|
43
|
+
kind = obj.get("type")
|
|
44
|
+
if kind == "Feature":
|
|
45
|
+
return obj
|
|
46
|
+
if kind in _GEOMETRY_TYPES:
|
|
47
|
+
return {"type": "Feature", "geometry": obj, "properties": {}}
|
|
48
|
+
raise GeoJSONError(f"{where}: expected a Feature, got type {kind!r}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _iter_object(obj: Any, where: str) -> Iterator[Feature]:
|
|
52
|
+
if isinstance(obj, list):
|
|
53
|
+
for i, item in enumerate(obj):
|
|
54
|
+
yield _as_feature(item, f"{where}[{i}]")
|
|
55
|
+
return
|
|
56
|
+
if isinstance(obj, dict) and obj.get("type") == "FeatureCollection":
|
|
57
|
+
features = obj.get("features")
|
|
58
|
+
if not isinstance(features, list):
|
|
59
|
+
raise GeoJSONError(f"{where}: FeatureCollection without a 'features' array")
|
|
60
|
+
for i, item in enumerate(features):
|
|
61
|
+
yield _as_feature(item, f"{where} feature {i}")
|
|
62
|
+
return
|
|
63
|
+
yield _as_feature(obj, where)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Reader:
|
|
67
|
+
"""Incremental JSON tokenizer over a text stream (top level only)."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, stream: IO[str], name: str) -> None:
|
|
70
|
+
self.stream = stream
|
|
71
|
+
self.name = name
|
|
72
|
+
self.buf = ""
|
|
73
|
+
self.pos = 0
|
|
74
|
+
self.eof = False
|
|
75
|
+
self.decoder = json.JSONDecoder()
|
|
76
|
+
|
|
77
|
+
def fill(self) -> bool:
|
|
78
|
+
if self.eof:
|
|
79
|
+
return False
|
|
80
|
+
if self.pos > _CHUNK:
|
|
81
|
+
self.buf = self.buf[self.pos :]
|
|
82
|
+
self.pos = 0
|
|
83
|
+
# Read geometrically larger chunks so huge single values stay linear.
|
|
84
|
+
data = self.stream.read(max(_CHUNK, len(self.buf)))
|
|
85
|
+
if not data:
|
|
86
|
+
self.eof = True
|
|
87
|
+
return False
|
|
88
|
+
if not self.buf and data.startswith(""):
|
|
89
|
+
data = data[1:]
|
|
90
|
+
self.buf += data
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def skip_ws(self) -> bool:
|
|
94
|
+
"""Skip whitespace (and RS separators); return False at end of input."""
|
|
95
|
+
while True:
|
|
96
|
+
while self.pos < len(self.buf) and self.buf[self.pos] in _WS:
|
|
97
|
+
self.pos += 1
|
|
98
|
+
if self.pos < len(self.buf):
|
|
99
|
+
return True
|
|
100
|
+
if not self.fill():
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def peek(self) -> str:
|
|
104
|
+
if not self.skip_ws():
|
|
105
|
+
return ""
|
|
106
|
+
return self.buf[self.pos]
|
|
107
|
+
|
|
108
|
+
def expect(self, char: str) -> None:
|
|
109
|
+
if self.peek() != char:
|
|
110
|
+
got = self.peek() or "end of input"
|
|
111
|
+
raise GeoJSONError(f"{self.name}: expected {char!r} but found {got!r}")
|
|
112
|
+
self.pos += 1
|
|
113
|
+
|
|
114
|
+
def value(self) -> Any:
|
|
115
|
+
if not self.skip_ws():
|
|
116
|
+
raise GeoJSONError(f"{self.name}: unexpected end of input")
|
|
117
|
+
while True:
|
|
118
|
+
try:
|
|
119
|
+
obj, end = self.decoder.raw_decode(self.buf, self.pos)
|
|
120
|
+
except json.JSONDecodeError as exc:
|
|
121
|
+
if self.fill():
|
|
122
|
+
continue
|
|
123
|
+
raise GeoJSONError(
|
|
124
|
+
f"{self.name}: invalid JSON ({exc.msg} at char {exc.pos})"
|
|
125
|
+
) from None
|
|
126
|
+
if end >= len(self.buf) and self.fill():
|
|
127
|
+
continue # a number or literal may continue in the next chunk
|
|
128
|
+
self.pos = end
|
|
129
|
+
return obj
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _iter_stream(stream: IO[str], name: str) -> Iterator[Feature]:
|
|
133
|
+
reader = _Reader(stream, name)
|
|
134
|
+
first = reader.peek()
|
|
135
|
+
if first == "":
|
|
136
|
+
return
|
|
137
|
+
if first == "[":
|
|
138
|
+
reader.expect("[")
|
|
139
|
+
if reader.peek() == "]":
|
|
140
|
+
reader.pos += 1
|
|
141
|
+
return
|
|
142
|
+
index = 0
|
|
143
|
+
while True:
|
|
144
|
+
yield _as_feature(reader.value(), f"{name}[{index}]")
|
|
145
|
+
index += 1
|
|
146
|
+
sep = reader.peek()
|
|
147
|
+
reader.pos += 1
|
|
148
|
+
if sep == "]":
|
|
149
|
+
return
|
|
150
|
+
if sep != ",":
|
|
151
|
+
raise GeoJSONError(f"{name}: expected ',' or ']' in feature array")
|
|
152
|
+
if first != "{":
|
|
153
|
+
raise GeoJSONError(f"{name}: not a GeoJSON document")
|
|
154
|
+
|
|
155
|
+
record = 0
|
|
156
|
+
while True:
|
|
157
|
+
# Decode one top-level object key by key, streaming a 'features' array.
|
|
158
|
+
reader.expect("{")
|
|
159
|
+
members: dict[str, Any] = {}
|
|
160
|
+
streamed = False
|
|
161
|
+
if reader.peek() == "}":
|
|
162
|
+
reader.pos += 1
|
|
163
|
+
else:
|
|
164
|
+
while True:
|
|
165
|
+
key = reader.value()
|
|
166
|
+
if not isinstance(key, str):
|
|
167
|
+
raise GeoJSONError(f"{name}: invalid object key")
|
|
168
|
+
reader.expect(":")
|
|
169
|
+
if key == "features" and reader.peek() == "[":
|
|
170
|
+
streamed = True
|
|
171
|
+
reader.expect("[")
|
|
172
|
+
if reader.peek() == "]":
|
|
173
|
+
reader.pos += 1
|
|
174
|
+
else:
|
|
175
|
+
index = 0
|
|
176
|
+
while True:
|
|
177
|
+
item = reader.value()
|
|
178
|
+
yield _as_feature(item, f"{name} feature {index}")
|
|
179
|
+
index += 1
|
|
180
|
+
sep = reader.peek()
|
|
181
|
+
reader.pos += 1
|
|
182
|
+
if sep == "]":
|
|
183
|
+
break
|
|
184
|
+
if sep != ",":
|
|
185
|
+
raise GeoJSONError(f"{name}: expected ',' or ']' in features")
|
|
186
|
+
else:
|
|
187
|
+
members[key] = reader.value()
|
|
188
|
+
sep = reader.peek()
|
|
189
|
+
reader.pos += 1
|
|
190
|
+
if sep == "}":
|
|
191
|
+
break
|
|
192
|
+
if sep != ",":
|
|
193
|
+
raise GeoJSONError(f"{name}: expected ',' or '}}' in object")
|
|
194
|
+
if streamed:
|
|
195
|
+
if members.get("type", "FeatureCollection") != "FeatureCollection":
|
|
196
|
+
raise GeoJSONError(f"{name}: object with 'features' is not a FeatureCollection")
|
|
197
|
+
elif members.get("type") == "FeatureCollection":
|
|
198
|
+
raise GeoJSONError(f"{name}: FeatureCollection without a 'features' array")
|
|
199
|
+
else:
|
|
200
|
+
yield _as_feature(members, f"{name} record {record}")
|
|
201
|
+
record += 1
|
|
202
|
+
# GeoJSONSeq / newline-delimited GeoJSON: further top-level objects follow.
|
|
203
|
+
nxt = reader.peek()
|
|
204
|
+
if nxt == "":
|
|
205
|
+
return
|
|
206
|
+
if nxt != "{":
|
|
207
|
+
raise GeoJSONError(f"{name}: unexpected data after record {record}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _open_text(path: str) -> IO[str]:
|
|
211
|
+
return open(path, encoding="utf-8-sig", newline="")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def iter_features(source: Source, name: str | None = None) -> Iterator[Feature]:
|
|
215
|
+
"""Yield the features of a GeoJSON source one at a time.
|
|
216
|
+
|
|
217
|
+
``source`` may be a path, ``"-"`` for standard input, an open text or
|
|
218
|
+
binary file, an already-parsed GeoJSON object (FeatureCollection, Feature,
|
|
219
|
+
geometry or list of features). FeatureCollections, single Features, bare
|
|
220
|
+
geometries, JSON arrays of features, GeoJSONSeq (RFC 8142) and
|
|
221
|
+
newline-delimited GeoJSON are all accepted.
|
|
222
|
+
"""
|
|
223
|
+
if isinstance(source, (dict, list)):
|
|
224
|
+
yield from _iter_object(source, name or "<object>")
|
|
225
|
+
return
|
|
226
|
+
if isinstance(source, (str, os.PathLike)):
|
|
227
|
+
path = os.fspath(source)
|
|
228
|
+
if path == "-":
|
|
229
|
+
stdin = sys.stdin.buffer if hasattr(sys.stdin, "buffer") else sys.stdin
|
|
230
|
+
yield from iter_features(stdin, name or "<stdin>")
|
|
231
|
+
return
|
|
232
|
+
with _open_text(path) as handle:
|
|
233
|
+
yield from _iter_stream(handle, name or path)
|
|
234
|
+
return
|
|
235
|
+
if hasattr(source, "read"):
|
|
236
|
+
stream: Any = source
|
|
237
|
+
probe = stream.read(0)
|
|
238
|
+
if isinstance(probe, bytes):
|
|
239
|
+
stream = io.TextIOWrapper(stream, encoding="utf-8-sig")
|
|
240
|
+
yield from _iter_stream(stream, str(name or getattr(source, "name", "<stream>")))
|
|
241
|
+
return
|
|
242
|
+
raise TypeError(f"unsupported GeoJSON source: {type(source).__name__}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def load_features(source: Source, name: str | None = None) -> list[Feature]:
|
|
246
|
+
"""Return all features of ``source`` as a list (see :func:`iter_features`)."""
|
|
247
|
+
return list(iter_features(source, name))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def source_name(source: Source | Iterable[Any]) -> str:
|
|
251
|
+
"""A short display name for ``source``."""
|
|
252
|
+
if isinstance(source, (str, os.PathLike)):
|
|
253
|
+
return os.fspath(source)
|
|
254
|
+
return str(getattr(source, "name", "<object>"))
|
geojson_diff/matching.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Feature matching strategies: id, property key(s), geometry hash, spatial."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections import defaultdict, deque
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from .geometry import Metric, centroid, geometry_hash, is_geographic
|
|
13
|
+
from .properties import similarity
|
|
14
|
+
from .tolerance import Tolerance
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"INDEXABLE",
|
|
18
|
+
"STRATEGIES",
|
|
19
|
+
"Record",
|
|
20
|
+
"key_function",
|
|
21
|
+
"match_by_key",
|
|
22
|
+
"match_spatial",
|
|
23
|
+
"property_value",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
STRATEGIES = ("id", "key", "hash", "spatial")
|
|
27
|
+
INDEXABLE = ("id", "key", "hash")
|
|
28
|
+
"""Strategies that can match through a dictionary index (and therefore stream)."""
|
|
29
|
+
|
|
30
|
+
_M_PER_DEG = 111_320.0
|
|
31
|
+
_MISSING = object()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Record:
|
|
36
|
+
"""A feature with its position in the input."""
|
|
37
|
+
|
|
38
|
+
index: int
|
|
39
|
+
feature: dict[str, Any]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
KeyFunction = Callable[[dict[str, Any]], Optional[str]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _norm(value: Any) -> str:
|
|
46
|
+
if isinstance(value, str):
|
|
47
|
+
return value
|
|
48
|
+
if isinstance(value, float) and value.is_integer():
|
|
49
|
+
value = int(value)
|
|
50
|
+
return json.dumps(value, sort_keys=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def property_value(feature: dict[str, Any], name: str) -> Any:
|
|
54
|
+
"""Value of property ``name`` (dotted for nested), or a sentinel when absent.
|
|
55
|
+
|
|
56
|
+
``@id`` reads the feature's top-level ``id``; ``id`` falls back to it when
|
|
57
|
+
there is no ``id`` property.
|
|
58
|
+
"""
|
|
59
|
+
if name == "@id":
|
|
60
|
+
return feature.get("id", _MISSING)
|
|
61
|
+
props = feature.get("properties") or {}
|
|
62
|
+
current: Any = props
|
|
63
|
+
for part in name.split("."):
|
|
64
|
+
if isinstance(current, dict) and part in current:
|
|
65
|
+
current = current[part]
|
|
66
|
+
else:
|
|
67
|
+
current = _MISSING
|
|
68
|
+
break
|
|
69
|
+
if current is _MISSING and name == "id" and "id" in feature:
|
|
70
|
+
return feature["id"]
|
|
71
|
+
return current
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def key_function(strategy: str, keys: Sequence[str] = (), precision: int = 7) -> KeyFunction:
|
|
75
|
+
"""Return the function mapping a feature to its match key (``None`` if it has none)."""
|
|
76
|
+
if strategy == "id":
|
|
77
|
+
|
|
78
|
+
def by_id(feature: dict[str, Any]) -> str | None:
|
|
79
|
+
value = feature.get("id")
|
|
80
|
+
return None if value is None else _norm(value)
|
|
81
|
+
|
|
82
|
+
return by_id
|
|
83
|
+
if strategy == "key":
|
|
84
|
+
if not keys:
|
|
85
|
+
raise ValueError("the 'key' strategy needs at least one property name (--key)")
|
|
86
|
+
names = list(keys)
|
|
87
|
+
|
|
88
|
+
def by_key(feature: dict[str, Any]) -> str | None:
|
|
89
|
+
values = [property_value(feature, n) for n in names]
|
|
90
|
+
if any(v is _MISSING or v is None for v in values):
|
|
91
|
+
return None
|
|
92
|
+
return "\x1f".join(_norm(v) for v in values)
|
|
93
|
+
|
|
94
|
+
return by_key
|
|
95
|
+
if strategy == "hash":
|
|
96
|
+
|
|
97
|
+
def by_hash(feature: dict[str, Any]) -> str | None:
|
|
98
|
+
geom = feature.get("geometry")
|
|
99
|
+
return None if not geom else geometry_hash(geom, precision)
|
|
100
|
+
|
|
101
|
+
return by_hash
|
|
102
|
+
raise ValueError(f"strategy {strategy!r} cannot be used as an index key")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_index(
|
|
106
|
+
records: Iterable[Record], keyfn: KeyFunction
|
|
107
|
+
) -> tuple[dict[str, deque[Record]], int]:
|
|
108
|
+
"""Index records by key; returns the index and the number of duplicate keys."""
|
|
109
|
+
index: dict[str, deque[Record]] = defaultdict(deque)
|
|
110
|
+
duplicates = 0
|
|
111
|
+
for rec in records:
|
|
112
|
+
key = keyfn(rec.feature)
|
|
113
|
+
if key is None:
|
|
114
|
+
continue
|
|
115
|
+
if key in index:
|
|
116
|
+
duplicates += 1
|
|
117
|
+
index[key].append(rec)
|
|
118
|
+
return index, duplicates
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def match_by_key(
|
|
122
|
+
olds: Sequence[Record], news: Sequence[Record], keyfn: KeyFunction
|
|
123
|
+
) -> list[tuple[Record, Record]]:
|
|
124
|
+
"""Pair records with equal keys; duplicates pair up in input order."""
|
|
125
|
+
index, _ = build_index(olds, keyfn)
|
|
126
|
+
pairs: list[tuple[Record, Record]] = []
|
|
127
|
+
for rec in news:
|
|
128
|
+
key = keyfn(rec.feature)
|
|
129
|
+
if key is None:
|
|
130
|
+
continue
|
|
131
|
+
bucket = index.get(key)
|
|
132
|
+
if bucket:
|
|
133
|
+
pairs.append((bucket.popleft(), rec))
|
|
134
|
+
return pairs
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _family(geom: dict[str, Any] | None) -> str:
|
|
138
|
+
if not geom:
|
|
139
|
+
return "none"
|
|
140
|
+
kind = str(geom.get("type", ""))
|
|
141
|
+
return kind.replace("Multi", "").replace("LineString", "Line")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def match_spatial(
|
|
145
|
+
olds: Sequence[Record],
|
|
146
|
+
news: Sequence[Record],
|
|
147
|
+
*,
|
|
148
|
+
max_distance: Tolerance,
|
|
149
|
+
attribute_weight: float = 0.5,
|
|
150
|
+
min_score: float = 0.5,
|
|
151
|
+
ignore: Iterable[str] = (),
|
|
152
|
+
geographic: bool | None = None,
|
|
153
|
+
) -> list[tuple[Record, Record, float]]:
|
|
154
|
+
"""Pair features by centroid proximity and attribute similarity.
|
|
155
|
+
|
|
156
|
+
Candidates must share a geometry family (point, line, polygon) and lie
|
|
157
|
+
within ``max_distance``. Each candidate scores
|
|
158
|
+
``(1 - w) * (1 - distance / max_distance) + w * similarity`` with
|
|
159
|
+
``w = attribute_weight``; pairs scoring below ``min_score`` are dropped.
|
|
160
|
+
The assignment is greedy on descending score, then ascending distance,
|
|
161
|
+
old index and new index, so it is fully deterministic.
|
|
162
|
+
"""
|
|
163
|
+
if not 0.0 <= attribute_weight <= 1.0:
|
|
164
|
+
raise ValueError("attribute_weight must be between 0 and 1")
|
|
165
|
+
ignore = list(ignore)
|
|
166
|
+
old_c = [(r, centroid(r.feature.get("geometry"))) for r in olds]
|
|
167
|
+
new_c = [(r, centroid(r.feature.get("geometry"))) for r in news]
|
|
168
|
+
if geographic is None:
|
|
169
|
+
geographic = all(
|
|
170
|
+
is_geographic(r.feature.get("geometry")) for r, _ in old_c + new_c if r.feature
|
|
171
|
+
)
|
|
172
|
+
metres = max_distance.is_metric and geographic
|
|
173
|
+
metric = Metric(metres)
|
|
174
|
+
limit = max_distance.value
|
|
175
|
+
cell = (limit / _M_PER_DEG if metres else limit) or 1e-9
|
|
176
|
+
|
|
177
|
+
grid: dict[tuple[int, int], list[tuple[Record, tuple[float, float]]]] = defaultdict(list)
|
|
178
|
+
for rec, c in new_c:
|
|
179
|
+
if c is not None:
|
|
180
|
+
grid[(math.floor(c[0] / cell), math.floor(c[1] / cell))].append((rec, c))
|
|
181
|
+
|
|
182
|
+
candidates: list[tuple[float, float, int, int, Record, Record]] = []
|
|
183
|
+
for rec, c in old_c:
|
|
184
|
+
if c is None:
|
|
185
|
+
continue
|
|
186
|
+
family = _family(rec.feature.get("geometry"))
|
|
187
|
+
cx, cy = math.floor(c[0] / cell), math.floor(c[1] / cell)
|
|
188
|
+
span = 1
|
|
189
|
+
if metres:
|
|
190
|
+
span = math.ceil(1.0 / max(math.cos(math.radians(min(89.0, abs(c[1]) + cell))), 0.01))
|
|
191
|
+
for gx in range(cx - span, cx + span + 1):
|
|
192
|
+
for gy in range(cy - 1, cy + 2):
|
|
193
|
+
for other, oc in grid.get((gx, gy), ()):
|
|
194
|
+
if _family(other.feature.get("geometry")) != family:
|
|
195
|
+
continue
|
|
196
|
+
d = metric.point(c, oc)
|
|
197
|
+
if d > limit:
|
|
198
|
+
continue
|
|
199
|
+
closeness = 1.0 - d / limit if limit > 0 else 1.0
|
|
200
|
+
sim = similarity(
|
|
201
|
+
rec.feature.get("properties"),
|
|
202
|
+
other.feature.get("properties"),
|
|
203
|
+
ignore,
|
|
204
|
+
)
|
|
205
|
+
score = (1.0 - attribute_weight) * closeness + attribute_weight * sim
|
|
206
|
+
if score >= min_score:
|
|
207
|
+
candidates.append((-score, d, rec.index, other.index, rec, other))
|
|
208
|
+
candidates.sort(key=lambda t: t[:4])
|
|
209
|
+
used_old: set = set()
|
|
210
|
+
used_new: set = set()
|
|
211
|
+
pairs: list[tuple[Record, Record, float]] = []
|
|
212
|
+
for neg_score, _, oi, ni, rec, other in candidates:
|
|
213
|
+
if oi in used_old or ni in used_new:
|
|
214
|
+
continue
|
|
215
|
+
used_old.add(oi)
|
|
216
|
+
used_new.add(ni)
|
|
217
|
+
pairs.append((rec, other, -neg_score))
|
|
218
|
+
return pairs
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Typed, nested diff of feature properties (JSON Pointer paths)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Union
|
|
10
|
+
|
|
11
|
+
__all__ = ["PropertyChange", "diff_properties", "json_type", "pointer", "similarity"]
|
|
12
|
+
|
|
13
|
+
PathPart = Union[str, int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def json_type(value: Any) -> str:
|
|
17
|
+
"""JSON type name of a Python value: null, boolean, integer, number, string, array, object."""
|
|
18
|
+
if value is None:
|
|
19
|
+
return "null"
|
|
20
|
+
if isinstance(value, bool):
|
|
21
|
+
return "boolean"
|
|
22
|
+
if isinstance(value, int):
|
|
23
|
+
return "integer"
|
|
24
|
+
if isinstance(value, float):
|
|
25
|
+
return "number"
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
return "string"
|
|
28
|
+
if isinstance(value, (list, tuple)):
|
|
29
|
+
return "array"
|
|
30
|
+
if isinstance(value, dict):
|
|
31
|
+
return "object"
|
|
32
|
+
return type(value).__name__
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _escape(part: PathPart) -> str:
|
|
36
|
+
return str(part).replace("~", "~0").replace("/", "~1")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pointer(parts: Sequence[PathPart]) -> str:
|
|
40
|
+
"""RFC 6901 JSON Pointer for ``parts``."""
|
|
41
|
+
return "".join("/" + _escape(p) for p in parts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _dotted(parts: Sequence[PathPart]) -> str:
|
|
45
|
+
out = ""
|
|
46
|
+
for part in parts:
|
|
47
|
+
if isinstance(part, int):
|
|
48
|
+
out += f"[{part}]"
|
|
49
|
+
else:
|
|
50
|
+
out += ("." if out else "") + part
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class PropertyChange:
|
|
56
|
+
"""One change inside a feature.
|
|
57
|
+
|
|
58
|
+
``op`` is ``"add"``, ``"remove"`` or ``"replace"`` (JSON Patch vocabulary);
|
|
59
|
+
``parts`` is the path below the feature, e.g. ``["properties", "tags", 0]``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
op: str
|
|
63
|
+
parts: list[PathPart]
|
|
64
|
+
old: Any = None
|
|
65
|
+
new: Any = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def path(self) -> str:
|
|
69
|
+
"""JSON Pointer of the change, e.g. ``/properties/tags/0``."""
|
|
70
|
+
return pointer(self.parts)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def name(self) -> str:
|
|
74
|
+
"""Human-readable path without the ``properties`` prefix, e.g. ``tags[0]``."""
|
|
75
|
+
parts = self.parts[1:] if self.parts and self.parts[0] == "properties" else self.parts
|
|
76
|
+
return _dotted(parts) or "(root)"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def old_type(self) -> str | None:
|
|
80
|
+
return None if self.op == "add" else json_type(self.old)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def new_type(self) -> str | None:
|
|
84
|
+
return None if self.op == "remove" else json_type(self.new)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def type_changed(self) -> bool:
|
|
88
|
+
return self.op == "replace" and self.old_type != self.new_type
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
out: dict[str, Any] = {"op": self.op, "path": self.path}
|
|
92
|
+
if self.op != "add":
|
|
93
|
+
out["old"] = self.old
|
|
94
|
+
out["old_type"] = self.old_type
|
|
95
|
+
if self.op != "remove":
|
|
96
|
+
out["value"] = self.new
|
|
97
|
+
out["new_type"] = self.new_type
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class _Ctx:
|
|
103
|
+
strict_types: bool
|
|
104
|
+
ignore: tuple[tuple[PathPart, ...], ...]
|
|
105
|
+
out: list[PropertyChange] = field(default_factory=list)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _scalars_equal(a: Any, b: Any, strict_types: bool) -> bool:
|
|
109
|
+
ta, tb = json_type(a), json_type(b)
|
|
110
|
+
if ta != tb:
|
|
111
|
+
if strict_types or {ta, tb} != {"integer", "number"}:
|
|
112
|
+
return False
|
|
113
|
+
if ta == "number" and tb == "number" and math.isnan(a) and math.isnan(b):
|
|
114
|
+
return True
|
|
115
|
+
return bool(a == b)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _ignored(parts: Sequence[PathPart], ignore: Iterable[tuple[PathPart, ...]]) -> bool:
|
|
119
|
+
return any(tuple(parts[: len(prefix)]) == prefix for prefix in ignore)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _walk(old: Any, new: Any, parts: list[PathPart], ctx: _Ctx) -> None:
|
|
123
|
+
if _ignored(parts, ctx.ignore):
|
|
124
|
+
return
|
|
125
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
126
|
+
for key in old:
|
|
127
|
+
if key not in new and not _ignored(parts + [key], ctx.ignore):
|
|
128
|
+
ctx.out.append(PropertyChange("remove", parts + [key], old=old[key]))
|
|
129
|
+
for key in old:
|
|
130
|
+
if key in new:
|
|
131
|
+
_walk(old[key], new[key], parts + [key], ctx)
|
|
132
|
+
for key in new:
|
|
133
|
+
if key not in old and not _ignored(parts + [key], ctx.ignore):
|
|
134
|
+
ctx.out.append(PropertyChange("add", parts + [key], new=new[key]))
|
|
135
|
+
return
|
|
136
|
+
if isinstance(old, list) and isinstance(new, list) and len(old) == len(new):
|
|
137
|
+
for i, (a, b) in enumerate(zip(old, new)):
|
|
138
|
+
_walk(a, b, parts + [i], ctx)
|
|
139
|
+
return
|
|
140
|
+
if isinstance(old, (dict, list)) or isinstance(new, (dict, list)):
|
|
141
|
+
if _canonical(old) != _canonical(new):
|
|
142
|
+
ctx.out.append(PropertyChange("replace", list(parts), old=old, new=new))
|
|
143
|
+
return
|
|
144
|
+
if not _scalars_equal(old, new, ctx.strict_types):
|
|
145
|
+
ctx.out.append(PropertyChange("replace", list(parts), old=old, new=new))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _canonical(value: Any) -> str:
|
|
149
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _ignore_paths(ignore: Iterable[str]) -> tuple[tuple[PathPart, ...], ...]:
|
|
153
|
+
out = []
|
|
154
|
+
for item in ignore:
|
|
155
|
+
out.append(("properties", *item.split(".")))
|
|
156
|
+
return tuple(out)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def diff_properties(
|
|
160
|
+
old: dict[str, Any] | None,
|
|
161
|
+
new: dict[str, Any] | None,
|
|
162
|
+
*,
|
|
163
|
+
strict_types: bool = False,
|
|
164
|
+
ignore: Iterable[str] = (),
|
|
165
|
+
) -> list[PropertyChange]:
|
|
166
|
+
"""Diff two property dictionaries recursively.
|
|
167
|
+
|
|
168
|
+
Nested objects are compared key by key and equal-length arrays element by
|
|
169
|
+
element; arrays whose length changed are reported as one ``replace``.
|
|
170
|
+
``1`` and ``1.0`` are equal unless ``strict_types`` is true; ``True`` and
|
|
171
|
+
``1`` are never equal. ``ignore`` lists property names (dotted for nested
|
|
172
|
+
keys, e.g. ``"meta.updated"``) to skip.
|
|
173
|
+
"""
|
|
174
|
+
ctx = _Ctx(strict_types=strict_types, ignore=_ignore_paths(ignore))
|
|
175
|
+
_walk(old or {}, new or {}, ["properties"], ctx)
|
|
176
|
+
return ctx.out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def similarity(
|
|
180
|
+
old: dict[str, Any] | None, new: dict[str, Any] | None, ignore: Iterable[str] = ()
|
|
181
|
+
) -> float:
|
|
182
|
+
"""Attribute similarity in ``[0, 1]``: share of equal top-level properties.
|
|
183
|
+
|
|
184
|
+
Unequal strings earn partial credit from :class:`difflib.SequenceMatcher`.
|
|
185
|
+
Two empty property sets are fully similar.
|
|
186
|
+
"""
|
|
187
|
+
from difflib import SequenceMatcher
|
|
188
|
+
|
|
189
|
+
skip = set(ignore)
|
|
190
|
+
a = {k: v for k, v in (old or {}).items() if k not in skip}
|
|
191
|
+
b = {k: v for k, v in (new or {}).items() if k not in skip}
|
|
192
|
+
keys = set(a) | set(b)
|
|
193
|
+
if not keys:
|
|
194
|
+
return 1.0
|
|
195
|
+
score = 0.0
|
|
196
|
+
for key in keys:
|
|
197
|
+
if key not in a or key not in b:
|
|
198
|
+
continue
|
|
199
|
+
va, vb = a[key], b[key]
|
|
200
|
+
if _canonical(va) == _canonical(vb) or (
|
|
201
|
+
json_type(va) in ("integer", "number")
|
|
202
|
+
and json_type(vb) in ("integer", "number")
|
|
203
|
+
and va == vb
|
|
204
|
+
):
|
|
205
|
+
score += 1.0
|
|
206
|
+
elif isinstance(va, str) and isinstance(vb, str):
|
|
207
|
+
score += SequenceMatcher(None, va, vb).ratio()
|
|
208
|
+
return score / len(keys)
|
geojson_diff/py.typed
ADDED
|
File without changes
|