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/__init__.py
ADDED
|
@@ -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
|
+
]
|
geojson_diff/__main__.py
ADDED
geojson_diff/_shapely.py
ADDED
|
@@ -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())
|