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