geojson-diff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ """geojson-diff: feature-level diffs of GeoJSON datasets for reviews, PRs and pipelines.
2
+
3
+ >>> from geojson_diff import diff
4
+ >>> result = diff("old.geojson", "new.geojson", keys=["parcel_id"], tolerance="0.5m") # doctest: +SKIP
5
+ >>> result.counts # doctest: +SKIP
6
+ {'added': 2, 'removed': 1, 'modified': 3, 'moved': 1, 'unchanged': 6}
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from .core import (
14
+ STATUSES,
15
+ DiffOptions,
16
+ DiffResult,
17
+ FeatureChange,
18
+ GeometryDiff,
19
+ compare_features,
20
+ compare_geometry,
21
+ diff,
22
+ )
23
+ from .geometry import (
24
+ geometry_area,
25
+ geometry_hash,
26
+ geometry_length,
27
+ hausdorff_distance,
28
+ haversine,
29
+ )
30
+ from .io import GeoJSONError, iter_features, load_features
31
+ from .properties import PropertyChange, diff_properties
32
+ from .render import render
33
+ from .render.layer import diff_layer
34
+ from .tolerance import Tolerance, parse_tolerance
35
+
36
+ __all__ = [
37
+ "STATUSES",
38
+ "DiffOptions",
39
+ "DiffResult",
40
+ "FeatureChange",
41
+ "GeoJSONError",
42
+ "GeometryDiff",
43
+ "PropertyChange",
44
+ "Tolerance",
45
+ "__version__",
46
+ "compare_features",
47
+ "compare_geometry",
48
+ "diff",
49
+ "diff_layer",
50
+ "diff_properties",
51
+ "geometry_area",
52
+ "geometry_hash",
53
+ "geometry_length",
54
+ "hausdorff_distance",
55
+ "haversine",
56
+ "iter_features",
57
+ "load_features",
58
+ "parse_tolerance",
59
+ "render",
60
+ ]
@@ -0,0 +1,7 @@
1
+ """``python -m geojson_diff``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,80 @@
1
+ """Optional exact geometry operations backed by shapely (``pip install geojson-diff[shapely]``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ __all__ = ["HAS_SHAPELY", "exact_equal", "symmetric_difference_area"]
9
+
10
+ try: # pragma: no cover - depends on the environment
11
+ import shapely
12
+ from shapely.geometry import shape
13
+
14
+ HAS_SHAPELY = True
15
+ except ImportError: # pragma: no cover - depends on the environment
16
+ HAS_SHAPELY = False
17
+
18
+ _M_PER_DEG = math.pi * 6371008.8 / 180.0
19
+
20
+
21
+ def _require() -> None:
22
+ if not HAS_SHAPELY: # pragma: no cover - depends on the environment
23
+ raise ImportError("this feature needs shapely: pip install 'geojson-diff[shapely]'")
24
+
25
+
26
+ def _project(obj: Any, lat0: float) -> Any:
27
+ k = math.cos(math.radians(lat0)) * _M_PER_DEG
28
+ if isinstance(obj, list) and obj and isinstance(obj[0], (int, float)):
29
+ return [obj[0] * k, obj[1] * _M_PER_DEG] + list(obj[2:])
30
+ if isinstance(obj, list):
31
+ return [_project(o, lat0) for o in obj]
32
+ return obj
33
+
34
+
35
+ def _local(geom: dict[str, Any], lat0: float | None) -> dict[str, Any]:
36
+ if lat0 is None:
37
+ return geom
38
+ if geom.get("type") == "GeometryCollection":
39
+ members: list[dict[str, Any]] = [_local(g, lat0) for g in geom.get("geometries") or []]
40
+ return {"type": "GeometryCollection", "geometries": members}
41
+ return {
42
+ "type": geom["type"],
43
+ "coordinates": _project(geom.get("coordinates"), lat0),
44
+ }
45
+
46
+
47
+ def _lat0(*geoms: dict[str, Any]) -> float:
48
+ lats: list[float] = []
49
+ for geom in geoms:
50
+ b = shape(geom).bounds
51
+ if b and not any(math.isnan(v) for v in b):
52
+ lats.extend([b[1], b[3]])
53
+ return (min(lats) + max(lats)) / 2.0 if lats else 0.0
54
+
55
+
56
+ def symmetric_difference_area(
57
+ old: dict[str, Any], new: dict[str, Any], geographic: bool = True
58
+ ) -> float | None:
59
+ """Area of ``old XOR new`` in square metres (lon/lat, local projection) or CRS units.
60
+
61
+ Returns ``None`` for non-areal geometries or when shapely cannot compute it.
62
+ """
63
+ _require()
64
+ lat0 = _lat0(old, new) if geographic else None
65
+ a, b = shape(_local(old, lat0)), shape(_local(new, lat0))
66
+ if a.area == 0.0 and b.area == 0.0:
67
+ return None
68
+ try:
69
+ return float(a.symmetric_difference(b).area)
70
+ except Exception: # pragma: no cover - invalid geometries
71
+ try:
72
+ return float(shapely.make_valid(a).symmetric_difference(shapely.make_valid(b)).area)
73
+ except Exception:
74
+ return None
75
+
76
+
77
+ def exact_equal(old: dict[str, Any], new: dict[str, Any]) -> bool:
78
+ """Topological equality (same point set) according to shapely."""
79
+ _require()
80
+ return bool(shape(old).equals(shape(new)))
geojson_diff/cli.py ADDED
@@ -0,0 +1,374 @@
1
+ """Command-line interface: ``geojson-diff`` and ``git-geojson-diff``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from typing import TextIO
10
+
11
+ from . import __version__
12
+ from .core import DiffOptions, DiffResult, diff
13
+ from .io import GeoJSONError, Source
14
+ from .render import FORMATS, render
15
+
16
+ __all__ = ["EXIT_CHANGES", "EXIT_ERROR", "EXIT_OK", "git_main", "main", "parse_fail_on"]
17
+
18
+ EXIT_OK = 0
19
+ EXIT_CHANGES = 1
20
+ EXIT_ERROR = 2
21
+
22
+ _CATEGORIES = ("added", "removed", "modified", "moved")
23
+ _EXTENSIONS = {
24
+ ".json": "json",
25
+ ".md": "md",
26
+ ".markdown": "md",
27
+ ".geojson": "geojson",
28
+ ".html": "html",
29
+ ".htm": "html",
30
+ ".txt": "text",
31
+ }
32
+
33
+
34
+ def parse_fail_on(spec: str | None) -> dict[str, int]:
35
+ """Parse ``removed,modified`` or ``removed:10,moved:0`` into allowed counts.
36
+
37
+ Each listed category fails the run when its count exceeds the number
38
+ after the colon (default 0). ``any`` stands for all four categories.
39
+ """
40
+ if not spec:
41
+ return {}
42
+ limits: dict[str, int] = {}
43
+ for item in spec.split(","):
44
+ item = item.strip()
45
+ if not item:
46
+ continue
47
+ name, _, raw = item.partition(":")
48
+ name = name.strip().lower()
49
+ try:
50
+ allowed = int(raw) if raw.strip() else 0
51
+ except ValueError:
52
+ raise ValueError(f"invalid --fail-on threshold in {item!r}") from None
53
+ if allowed < 0:
54
+ raise ValueError(f"--fail-on threshold must be >= 0 in {item!r}")
55
+ names = _CATEGORIES if name in ("any", "all", "changed") else (name,)
56
+ for n in names:
57
+ if n not in _CATEGORIES:
58
+ raise ValueError(
59
+ f"unknown --fail-on category {name!r}; use {', '.join(_CATEGORIES)} or any"
60
+ )
61
+ limits[n] = allowed
62
+ return limits
63
+
64
+
65
+ def exit_code(result: DiffResult, fail_on: dict[str, int]) -> int:
66
+ """0 when nothing (relevant) changed, 1 otherwise."""
67
+ if not fail_on:
68
+ return EXIT_CHANGES if result.has_changes else EXIT_OK
69
+ for name, allowed in fail_on.items():
70
+ if result.counts.get(name, 0) > allowed:
71
+ return EXIT_CHANGES
72
+ return EXIT_OK
73
+
74
+
75
+ def _add_diff_options(parser: argparse.ArgumentParser) -> None:
76
+ m = parser.add_argument_group("matching")
77
+ m.add_argument(
78
+ "-k",
79
+ "--key",
80
+ action="append",
81
+ default=[],
82
+ metavar="PROP",
83
+ help="match on property PROP (repeat or comma-separate for a composite key; "
84
+ "dotted for nested; 'id' falls back to the feature id, '@id' is the feature id)",
85
+ )
86
+ m.add_argument(
87
+ "-m",
88
+ "--match",
89
+ default="auto",
90
+ metavar="STRATEGIES",
91
+ help="comma-separated chain of id, key, hash, spatial (default: auto = key if --key "
92
+ "is given, else id,hash)",
93
+ )
94
+ m.add_argument(
95
+ "--max-distance",
96
+ default="50m",
97
+ metavar="DIST",
98
+ help="spatial matching: maximum centroid distance (default 50m)",
99
+ )
100
+ m.add_argument(
101
+ "--attribute-weight",
102
+ type=float,
103
+ default=0.5,
104
+ metavar="W",
105
+ help="spatial matching: weight of attribute similarity in [0, 1] (default 0.5)",
106
+ )
107
+ m.add_argument(
108
+ "--min-score",
109
+ type=float,
110
+ default=0.5,
111
+ metavar="S",
112
+ help="spatial matching: minimum pair score in [0, 1] (default 0.5)",
113
+ )
114
+ m.add_argument(
115
+ "--precision",
116
+ type=int,
117
+ default=7,
118
+ metavar="N",
119
+ help="decimals kept when hashing geometries (default 7)",
120
+ )
121
+ c = parser.add_argument_group("comparison")
122
+ c.add_argument(
123
+ "-t",
124
+ "--tolerance",
125
+ default="0",
126
+ metavar="TOL",
127
+ help="geometry tolerance, e.g. 0.5m, 10cm, 1e-6deg (default 0: exact)",
128
+ )
129
+ c.add_argument(
130
+ "--crs",
131
+ choices=("auto", "geographic", "projected"),
132
+ default="auto",
133
+ help="coordinate type (default auto: lon/lat when all coordinates fit)",
134
+ )
135
+ c.add_argument(
136
+ "-i",
137
+ "--ignore-property",
138
+ action="append",
139
+ default=[],
140
+ metavar="PROP",
141
+ help="ignore property PROP (repeatable, dotted for nested keys)",
142
+ )
143
+ c.add_argument(
144
+ "--strict-types",
145
+ action="store_true",
146
+ help="treat 1 and 1.0 as different values",
147
+ )
148
+ c.add_argument(
149
+ "--shapely",
150
+ action="store_true",
151
+ help="use shapely (optional extra) for exact equality and symmetric-difference area",
152
+ )
153
+ o = parser.add_argument_group("output")
154
+ o.add_argument("-f", "--format", choices=FORMATS, help="output format (default text)")
155
+ o.add_argument("-o", "--output", metavar="FILE", help="write the report to FILE")
156
+ o.add_argument(
157
+ "--color",
158
+ choices=("auto", "always", "never"),
159
+ default="auto",
160
+ help="colour text output (default auto: when stdout is a terminal)",
161
+ )
162
+ o.add_argument("--show-unchanged", action="store_true", help="list unchanged features too")
163
+ o.add_argument("--max-rows", type=int, default=100, metavar="N", help="rows in md tables")
164
+ o.add_argument("--title", default="", help="title for md/html reports")
165
+ o.add_argument(
166
+ "--md-map",
167
+ action="store_true",
168
+ help="append a ```geojson map block to md output (GitHub renders it)",
169
+ )
170
+ o.add_argument("-q", "--quiet", action="store_true", help="print nothing; exit code only")
171
+ x = parser.add_argument_group("exit status")
172
+ x.add_argument(
173
+ "--fail-on",
174
+ metavar="LIST",
175
+ help="exit 1 only when these categories exceed their thresholds, "
176
+ "e.g. 'removed,modified' or 'removed:10,moved:0' (default: any change)",
177
+ )
178
+ x.add_argument("--exit-zero", action="store_true", help="always exit 0 unless an error occurs")
179
+
180
+
181
+ def _options(args: argparse.Namespace) -> DiffOptions:
182
+ keys: list[str] = []
183
+ for item in args.key:
184
+ keys.extend(k.strip() for k in item.split(",") if k.strip())
185
+ geographic = {"auto": None, "geographic": True, "projected": False}[args.crs]
186
+ return DiffOptions(
187
+ match=args.match,
188
+ keys=keys,
189
+ tolerance=args.tolerance,
190
+ geographic=geographic,
191
+ precision=args.precision,
192
+ strict_types=args.strict_types,
193
+ ignore_properties=list(args.ignore_property),
194
+ max_distance=args.max_distance,
195
+ attribute_weight=args.attribute_weight,
196
+ min_score=args.min_score,
197
+ keep_unchanged=args.show_unchanged,
198
+ use_shapely=args.shapely,
199
+ )
200
+
201
+
202
+ def _enable_windows_ansi() -> None: # pragma: no cover - Windows console only
203
+ if os.name != "nt":
204
+ return
205
+ try:
206
+ import ctypes
207
+
208
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
209
+ handle = kernel32.GetStdHandle(-11)
210
+ mode = ctypes.c_uint32()
211
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
212
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
213
+ except Exception:
214
+ pass
215
+
216
+
217
+ def _use_color(choice: str, stream: TextIO) -> bool:
218
+ if choice == "always":
219
+ return True
220
+ if choice == "never":
221
+ return False
222
+ if os.environ.get("NO_COLOR"):
223
+ return False
224
+ if os.environ.get("FORCE_COLOR"):
225
+ return True
226
+ isatty = getattr(stream, "isatty", None)
227
+ return bool(isatty and isatty())
228
+
229
+
230
+ def _emit(text: str, output: str | None) -> None:
231
+ if output:
232
+ with open(output, "w", encoding="utf-8", newline="\n") as handle:
233
+ handle.write(text)
234
+ return
235
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
236
+ if reconfigure is not None:
237
+ try:
238
+ if sys.stdout.isatty():
239
+ reconfigure(errors="replace")
240
+ else: # pipes and redirections (CI logs, '> report.md') get UTF-8
241
+ reconfigure(encoding="utf-8")
242
+ except Exception: # pragma: no cover - exotic streams
243
+ pass
244
+ sys.stdout.write(text)
245
+ sys.stdout.flush()
246
+
247
+
248
+ def _report(result: DiffResult, args: argparse.Namespace, force_text: bool = False) -> None:
249
+ fmt = args.format
250
+ if fmt is None and args.output and not force_text:
251
+ fmt = _EXTENSIONS.get(os.path.splitext(args.output)[1].lower(), "text")
252
+ fmt = fmt or "text"
253
+ if fmt == "text":
254
+ color = False if args.output else _use_color(args.color, sys.stdout)
255
+ if color:
256
+ _enable_windows_ansi()
257
+ text = render(result, "text", color=color, show_unchanged=args.show_unchanged)
258
+ elif fmt == "md":
259
+ text = render(
260
+ result,
261
+ "md",
262
+ max_rows=args.max_rows,
263
+ title=args.title,
264
+ include_map=args.md_map,
265
+ )
266
+ elif fmt == "html":
267
+ text = render(result, "html", title=args.title)
268
+ elif fmt == "geojson":
269
+ text = render(result, "geojson", include_unchanged=args.show_unchanged)
270
+ else:
271
+ text = render(result, "json")
272
+ if not args.quiet:
273
+ _emit(text, args.output)
274
+
275
+
276
+ def _run(old: Source, new: Source, args: argparse.Namespace) -> int:
277
+ fail_on = parse_fail_on(args.fail_on)
278
+ result = diff(old, new, _options(args))
279
+ _report(result, args)
280
+ if args.exit_zero:
281
+ return EXIT_OK
282
+ return exit_code(result, fail_on)
283
+
284
+
285
+ def _error(prog: str, exc: BaseException) -> int:
286
+ sys.stderr.write(f"{prog}: error: {exc}\n")
287
+ return EXIT_ERROR
288
+
289
+
290
+ def build_parser() -> argparse.ArgumentParser:
291
+ parser = argparse.ArgumentParser(
292
+ prog="geojson-diff",
293
+ description="Diff two GeoJSON FeatureCollections (or GeoJSONSeq files) feature by "
294
+ "feature. Exit status: 0 no changes, 1 changes, 2 error.",
295
+ epilog="Git: 'geojson-diff --textconv FILE' prints a diff-friendly rendering; "
296
+ "'geojson-diff --git ...' is an external diff driver (diff.<name>.command).",
297
+ )
298
+ parser.add_argument("paths", nargs="*", metavar="PATH", help="OLD and NEW files ('-' = stdin)")
299
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
300
+ mode = parser.add_mutually_exclusive_group()
301
+ mode.add_argument(
302
+ "--textconv",
303
+ action="store_true",
304
+ help="print a stable line-per-attribute rendering of one file (git textconv)",
305
+ )
306
+ mode.add_argument(
307
+ "--git",
308
+ action="store_true",
309
+ help="act as a git external diff driver (git passes 7 or 9 arguments)",
310
+ )
311
+ _add_diff_options(parser)
312
+ return parser
313
+
314
+
315
+ def main(argv: Sequence[str] | None = None) -> int:
316
+ """Entry point of ``geojson-diff``."""
317
+ parser = build_parser()
318
+ args = parser.parse_args(argv)
319
+ try:
320
+ if args.textconv:
321
+ if len(args.paths) != 1:
322
+ parser.error("--textconv takes exactly one file")
323
+ from .git import textconv
324
+
325
+ _emit(textconv(args.paths[0], args.precision), args.output)
326
+ return EXIT_OK
327
+ if args.git:
328
+ from .git import driver_sources
329
+
330
+ old_path, new_path, old, new = driver_sources(list(args.paths))
331
+ result = diff(old, new, _options(args))
332
+ result.old_name, result.new_name = f"a/{old_path}", f"b/{new_path}"
333
+ _report(result, args)
334
+ # git aborts the whole diff when an external driver exits non-zero.
335
+ return EXIT_OK
336
+ if len(args.paths) != 2:
337
+ parser.error("expected two files: OLD NEW")
338
+ return _run(args.paths[0], args.paths[1], args)
339
+ except (GeoJSONError, OSError, ValueError, KeyError, TypeError) as exc:
340
+ return _error("geojson-diff", exc)
341
+
342
+
343
+ def git_main(argv: Sequence[str] | None = None) -> int:
344
+ """Entry point of ``git-geojson-diff REV1 [REV2] PATH``."""
345
+ parser = argparse.ArgumentParser(
346
+ prog="git-geojson-diff",
347
+ description="Diff a GeoJSON file between two git revisions (REV2 defaults to the "
348
+ "working tree). Also available as 'git geojson-diff'. Exit status: 0 no changes, "
349
+ "1 changes, 2 error.",
350
+ )
351
+ parser.add_argument("revs", nargs="+", metavar="ARG", help="REV1 [REV2] PATH")
352
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
353
+ _add_diff_options(parser)
354
+ args = parser.parse_args(argv)
355
+ if len(args.revs) not in (2, 3):
356
+ parser.error("expected REV1 [REV2] PATH")
357
+ from .git import read_revision
358
+
359
+ try:
360
+ path = args.revs[-1]
361
+ old = read_revision(args.revs[0], path)
362
+ if len(args.revs) == 3:
363
+ new = read_revision(args.revs[1], path)
364
+ else:
365
+ new = path
366
+ return _run(old, new, args)
367
+ except (GeoJSONError, OSError, ValueError, KeyError, TypeError) as exc:
368
+ return _error("git-geojson-diff", exc)
369
+ except Exception as exc: # subprocess timeouts and the like
370
+ return _error("git-geojson-diff", exc)
371
+
372
+
373
+ if __name__ == "__main__": # pragma: no cover
374
+ sys.exit(main())