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,85 @@
1
+ """Human-readable terminal output (ANSI colours optional)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ..core import DiffResult
8
+ from . import describe_change, format_area, format_length
9
+
10
+ __all__ = ["render_text"]
11
+
12
+ _COLORS = {
13
+ "added": "\x1b[32m",
14
+ "removed": "\x1b[31m",
15
+ "modified": "\x1b[33m",
16
+ "moved": "\x1b[36m",
17
+ "unchanged": "\x1b[2m",
18
+ "bold": "\x1b[1m",
19
+ "dim": "\x1b[2m",
20
+ }
21
+ _RESET = "\x1b[0m"
22
+ _CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
23
+
24
+
25
+ def _clean(text: str) -> str:
26
+ """Neutralise control characters so data cannot inject terminal escapes."""
27
+ return _CONTROL.sub(lambda m: "\\x%02x" % ord(m.group()), text)
28
+
29
+
30
+ _SYMBOLS = {
31
+ "added": "+",
32
+ "removed": "-",
33
+ "modified": "~",
34
+ "moved": ">",
35
+ "unchanged": "=",
36
+ }
37
+
38
+
39
+ def render_text(result: DiffResult, color: bool = False, show_unchanged: bool = False) -> str:
40
+ """Plain-text report; ``color=True`` adds ANSI escape codes."""
41
+
42
+ def paint(text: str, style: str) -> str:
43
+ return f"{_COLORS[style]}{text}{_RESET}" if color else text
44
+
45
+ c = result.counts
46
+ lines: list[str] = [
47
+ paint(_clean(f"geojson-diff {result.old_name} -> {result.new_name}"), "bold"),
48
+ f"{result.old_count} -> {result.new_count} features: "
49
+ + ", ".join(
50
+ paint(f"{c.get(s, 0)} {s}", s) if c.get(s, 0) else f"0 {s}"
51
+ for s in ("added", "removed", "modified", "moved", "unchanged")
52
+ ),
53
+ paint(
54
+ f"matched by {', '.join(result.options.strategies())}; "
55
+ f"tolerance {result.options.tolerance}",
56
+ "dim",
57
+ ),
58
+ ]
59
+ for warning in result.warnings:
60
+ lines.append(paint(_clean(f"warning: {warning}"), "modified"))
61
+ shown = [ch for ch in result.changes if show_unchanged or ch.status != "unchanged"]
62
+ if not result.has_changes:
63
+ lines.append("")
64
+ lines.append("No changes.")
65
+ if shown:
66
+ lines.append("")
67
+ width = max((len(ch.key) for ch in shown), default=0)
68
+ for change in shown:
69
+ head = f"{_SYMBOLS[change.status]} {change.status:<9} {change.key:<{width}}"
70
+ if change.geometry_type:
71
+ head += f" {change.geometry_type}"
72
+ lines.append(paint(_clean(head.rstrip()), change.status))
73
+ for detail in describe_change(change):
74
+ lines.append(_clean(f" {detail}"))
75
+ if result.has_changes:
76
+ box = result.bbox
77
+ units = result.units
78
+ lines.append("")
79
+ if box:
80
+ lines.append("bbox of changes: [" + ", ".join(f"{v:.6f}" for v in box) + "]")
81
+ lines.append(
82
+ f"area delta {format_area(result.area_delta, units, True)}, "
83
+ f"length delta {format_length(result.length_delta, units, True)}"
84
+ )
85
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,86 @@
1
+ """Distance tolerances such as ``0.5m``, ``2 cm`` or ``1e-6deg``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Union
8
+
9
+ __all__ = ["Tolerance", "parse_tolerance"]
10
+
11
+ _UNIT_FACTORS = {
12
+ "m": ("m", 1.0),
13
+ "meter": ("m", 1.0),
14
+ "meters": ("m", 1.0),
15
+ "metre": ("m", 1.0),
16
+ "metres": ("m", 1.0),
17
+ "km": ("m", 1000.0),
18
+ "cm": ("m", 0.01),
19
+ "mm": ("m", 0.001),
20
+ "ft": ("m", 0.3048),
21
+ "deg": ("deg", 1.0),
22
+ "degree": ("deg", 1.0),
23
+ "degrees": ("deg", 1.0),
24
+ "°": ("deg", 1.0),
25
+ "": ("deg", 1.0),
26
+ }
27
+
28
+ _PATTERN = re.compile(r"^\s*([0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)\s*([a-zA-Z°]*)\s*$")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Tolerance:
33
+ """A non-negative distance tolerance.
34
+
35
+ ``unit`` is ``"m"`` (metres, measured with the haversine formula for
36
+ longitude/latitude data) or ``"deg"`` (raw coordinate units: degrees for
37
+ RFC 7946 data, CRS units for projected data).
38
+ """
39
+
40
+ value: float = 0.0
41
+ unit: str = "m"
42
+
43
+ def __post_init__(self) -> None:
44
+ if self.unit not in ("m", "deg"):
45
+ raise ValueError(f"unknown tolerance unit {self.unit!r}; use 'm' or 'deg'")
46
+ if not self.value >= 0.0: # also rejects NaN
47
+ raise ValueError("tolerance must be a non-negative number")
48
+
49
+ @property
50
+ def is_metric(self) -> bool:
51
+ """True when the tolerance is expressed in metres."""
52
+ return self.unit == "m"
53
+
54
+ def __str__(self) -> str:
55
+ if self.value == 0.0:
56
+ return "0 (exact)"
57
+ value = f"{self.value:g}"
58
+ return f"{value} m" if self.unit == "m" else f"{value} deg"
59
+
60
+
61
+ ToleranceLike = Union[Tolerance, str, float, int, None]
62
+
63
+
64
+ def parse_tolerance(value: ToleranceLike) -> Tolerance:
65
+ """Parse ``"0.5m"``, ``"10 cm"``, ``"1km"``, ``"1e-6deg"`` or a bare number.
66
+
67
+ A bare number (or a string without unit) is interpreted in coordinate
68
+ units (degrees for longitude/latitude data).
69
+ """
70
+ if value is None:
71
+ return Tolerance(0.0, "m")
72
+ if isinstance(value, Tolerance):
73
+ return value
74
+ if isinstance(value, bool):
75
+ raise ValueError("tolerance must be a number or a string such as '0.5m'")
76
+ if isinstance(value, (int, float)):
77
+ return Tolerance(float(value), "deg")
78
+ match = _PATTERN.match(str(value))
79
+ if not match:
80
+ raise ValueError(f"invalid tolerance {value!r}; expected e.g. '0.5m', '10cm' or '1e-6deg'")
81
+ number, unit = match.groups()
82
+ key = unit.lower()
83
+ if key not in _UNIT_FACTORS:
84
+ raise ValueError(f"unknown tolerance unit {unit!r} in {value!r}")
85
+ canonical, factor = _UNIT_FACTORS[key]
86
+ return Tolerance(float(number) * factor, canonical)
@@ -0,0 +1,478 @@
1
+ Metadata-Version: 2.4
2
+ Name: geojson-diff
3
+ Version: 0.1.0
4
+ Summary: Feature-level diffs of GeoJSON datasets for code review, pull requests and data pipelines: text, JSON, Markdown, GeoJSON and HTML map reports, git diff driver and CI exit codes.
5
+ Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/YusufEminoglu/geojson_diff_sdk
8
+ Project-URL: Repository, https://github.com/YusufEminoglu/geojson_diff_sdk
9
+ Project-URL: Issues, https://github.com/YusufEminoglu/geojson_diff_sdk/issues
10
+ Project-URL: Documentation, https://github.com/YusufEminoglu/geojson_diff_sdk#readme
11
+ Project-URL: Changelog, https://github.com/YusufEminoglu/geojson_diff_sdk/blob/main/CHANGELOG.md
12
+ Keywords: geojson,diff,gis,geospatial,git,code-review,pull-request,data-pipeline,ci,geojsonseq,hausdorff,leaflet,change-detection,data-quality
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Environment :: Console
22
+ Classifier: Topic :: Scientific/Engineering :: GIS
23
+ Classifier: Topic :: Software Development :: Quality Assurance
24
+ Classifier: Topic :: Software Development :: Version Control :: Git
25
+ Classifier: Topic :: Utilities
26
+ Classifier: Intended Audience :: Science/Research
27
+ Classifier: Intended Audience :: Developers
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.9
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE
32
+ Provides-Extra: shapely
33
+ Requires-Dist: shapely>=2.0.0; extra == "shapely"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
36
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
37
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
38
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
39
+ Dynamic: license-file
40
+
41
+ # geojson-diff
42
+
43
+ [![CI](https://github.com/YusufEminoglu/geojson_diff_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/geojson_diff_sdk/actions/workflows/ci.yml)
44
+ [![PyPI](https://img.shields.io/pypi/v/geojson-diff.svg)](https://pypi.org/project/geojson-diff/)
45
+ [![Python](https://img.shields.io/pypi/pyversions/geojson-diff.svg)](https://pypi.org/project/geojson-diff/)
46
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
47
+
48
+ **Feature-level diffs of GeoJSON datasets** for GIS and data teams that review
49
+ changes in git, pull requests and data pipelines.
50
+
51
+ A line diff of a GeoJSON file tells you that line 18 412 changed. `geojson-diff`
52
+ tells you that parcel `1042` got a new height, bus stop `77` moved 21 m, three
53
+ buildings were removed and one was added - and it can say so in your terminal, in a
54
+ pull-request comment, on a map, or through an exit code that fails a CI job.
55
+
56
+ - Diffs **FeatureCollections, GeoJSONSeq (RFC 8142) and newline-delimited GeoJSON**.
57
+ - Matches features **by id, by property key(s), by geometry hash or spatially**
58
+ (proximity + attribute similarity, deterministic assignment), in a chain.
59
+ - Classifies **added / removed / modified / moved / unchanged**.
60
+ - **Typed, nested property diffs** with JSON Pointer paths.
61
+ - **Tolerance-aware geometry diffs** in metres (haversine) or degrees: Hausdorff
62
+ distance, vertex-level change counts, area and length deltas, bbox of changes.
63
+ - Outputs **text** (coloured on a TTY), **JSON** (patch-like), **Markdown** (PR
64
+ comments), a **GeoJSON diff layer** (simplestyle, renders on GitHub) and a
65
+ self-contained **HTML report with a Leaflet map**.
66
+ - **Git diff driver / textconv**, a `git-geojson-diff REV1 REV2 PATH` helper and
67
+ **CI exit codes** with `--fail-on` thresholds.
68
+ - **Pure Python standard library**, streaming reader, typed (`py.typed`). Optional
69
+ `shapely` extra for exact equality and symmetric-difference areas.
70
+
71
+ ## Installation
72
+
73
+ ```bash
74
+ pip install geojson-diff
75
+ pip install "geojson-diff[shapely]" # optional: exact equality + symmetric-difference area
76
+ ```
77
+
78
+ Python 3.9 or newer. No required dependencies.
79
+
80
+ ## Quick start
81
+
82
+ ```bash
83
+ geojson-diff old.geojson new.geojson [--key id] [--tolerance 0.5m] \
84
+ [--format text|json|md|geojson|html] [-o out]
85
+ ```
86
+
87
+ Using the two files in [`examples/`](examples):
88
+
89
+ ```console
90
+ $ geojson-diff parcels_v1.geojson parcels_v2.geojson --tolerance 0.5m
91
+ geojson-diff parcels_v1.geojson -> parcels_v2.geojson
92
+ 5 -> 5 features: 1 added, 1 removed, 1 modified, 1 moved, 2 unchanged
93
+ matched by id, hash; tolerance 0.5 m
94
+
95
+ + added 6 Point
96
+ name: "New cafe"
97
+ amenity: "cafe"
98
+ - removed 3 Polygon
99
+ name: "Old shed"
100
+ height: 3
101
+ ~ modified 1 Polygon
102
+ height: 12 -> 14.5 (integer -> number)
103
+ tags.use: "residential" -> "mixed"
104
+ + tags.levels: 4
105
+ > moved 4 LineString
106
+ + surface: "asphalt"
107
+ geometry: Hausdorff 21.0 m, centroid shift 5.6 m, vertices 1 moved (4 -> 4), length +3.3 m
108
+
109
+ bbox of changes: [29.000000, 41.000000, 29.004500, 41.001800]
110
+ area delta -93.5 m2, length delta -35.7 m
111
+ $ echo $?
112
+ 1
113
+ ```
114
+
115
+ Bus stop `2` was nudged by about 9 cm, which is inside the 0.5 m tolerance, so it is
116
+ reported as unchanged. Without `--tolerance` the comparison is exact and the stop
117
+ shows up as `moved`.
118
+
119
+ ## How features are classified
120
+
121
+ | Status | Meaning |
122
+ |---|---|
123
+ | `added` | only in the new file |
124
+ | `removed` | only in the old file |
125
+ | `modified` | matched, properties (or the feature `id`) changed, geometry within tolerance |
126
+ | `moved` | matched, geometry changed beyond the tolerance (property changes are listed too) |
127
+ | `unchanged` | matched, nothing changed |
128
+
129
+ A geometry is **unchanged** when both versions have the same layout and every vertex
130
+ stayed within the tolerance, or - when the vertex layout differs - when the Hausdorff
131
+ distance between them is within the tolerance. Rotating a ring's start vertex,
132
+ flipping its orientation or inserting a collinear vertex therefore does not count
133
+ as a change. Z values are compared against the tolerance value in metres (exactly
134
+ for degree tolerances).
135
+
136
+ ### Tolerances
137
+
138
+ | `--tolerance` | Meaning |
139
+ |---|---|
140
+ | `0` (default) | exact comparison |
141
+ | `0.5m`, `10cm`, `5mm`, `1km`, `2ft` | metres; haversine on longitude/latitude data |
142
+ | `1e-6deg`, `0.00001°`, `0.00001` | coordinate units (degrees for RFC 7946 data) |
143
+
144
+ Longitude/latitude data is detected automatically; projected data (coordinates
145
+ outside +-180/+-90, or `--crs projected`) is measured in CRS units and reported with
146
+ `units` instead of metres.
147
+
148
+ ### Matching strategies
149
+
150
+ `--match` takes an ordered chain; features that one strategy cannot match are offered
151
+ to the next.
152
+
153
+ | Strategy | Matches on | Notes |
154
+ |---|---|---|
155
+ | `id` | the feature's top-level `id` | `1` and `"1"` are the same key |
156
+ | `key` | one or more properties (`--key parcel_id`, `--key block --key lot`) | dotted paths for nested keys (`meta.ref`); `id` falls back to the feature id, `@id` always means the feature id |
157
+ | `hash` | a normalised geometry hash | rounded to `--precision` decimals (7); insensitive to ring start, orientation and part order |
158
+ | `spatial` | centroid proximity + attribute similarity | `--max-distance 50m`, `--attribute-weight 0.5`, `--min-score 0.5`; same geometry family only |
159
+ | `auto` (default) | `key` if `--key` is given, else `id` then `hash` | |
160
+
161
+ Spatial matching scores every candidate pair as
162
+ `(1 - w) * (1 - distance / max_distance) + w * similarity` and assigns pairs greedily
163
+ by score, then distance, then input order, so the result is always the same for the
164
+ same inputs. Duplicate keys are paired in input order and reported as warnings.
165
+
166
+ ```bash
167
+ geojson-diff old.geojson new.geojson --key parcel_id
168
+ geojson-diff old.geojson new.geojson --key block,lot --ignore-property updated_at
169
+ geojson-diff old.geojson new.geojson --match id,hash,spatial --max-distance 25m
170
+ ```
171
+
172
+ ### Property diffs
173
+
174
+ Nested objects are compared key by key and equal-length arrays element by element;
175
+ paths are RFC 6901 JSON Pointers (`/properties/tags/0`). `1` and `1.0` are equal
176
+ unless `--strict-types` is given; `true` and `1`, or `"1"` and `1`, never are, and
177
+ type changes are reported (`integer -> number`). `--ignore-property` (repeatable,
178
+ dotted for nested keys) skips noisy attributes such as timestamps.
179
+
180
+ ## Output formats
181
+
182
+ `--format` (or the extension of `-o`) selects the output:
183
+
184
+ | Format | Use |
185
+ |---|---|
186
+ | `text` | terminal; coloured when stdout is a TTY (`--color`, `NO_COLOR`, `FORCE_COLOR`) |
187
+ | `json` | machine-readable, JSON-Patch-like |
188
+ | `md` | pull-request comments and CI job summaries |
189
+ | `geojson` | a diff layer for QGIS, geojson.io or GitHub's map viewer |
190
+ | `html` | a single-file report with a Leaflet before/after map |
191
+
192
+ ### Markdown
193
+
194
+ ```console
195
+ $ geojson-diff parcels_v1.geojson parcels_v2.geojson -t 0.5m -f md
196
+ ```
197
+
198
+ ```markdown
199
+ ### GeoJSON diff: parcels&#95;v1.geojson → parcels&#95;v2.geojson
200
+
201
+ **4 feature changes** (5 → 5 features; matched by id, hash; tolerance 0.5 m)
202
+
203
+ | Added | Removed | Modified | Moved | Unchanged |
204
+ |---:|---:|---:|---:|---:|
205
+ | 1 | 1 | 1 | 1 | 2 |
206
+
207
+ bbox of changes 29.000000, 41.000000, 29.004500, 41.001800 · area Δ -93.5 m² · length Δ -35.7 m
208
+
209
+ <details open><summary>Feature changes (4)</summary>
210
+
211
+ | Change | Feature | Geometry | Details |
212
+ |---|---|---|---|
213
+ | added | 6 | Point | name: "New cafe"<br>amenity: "cafe" |
214
+ | removed | 3 | Polygon | name: "Old shed"<br>height: 3 |
215
+ | modified | 1 | Polygon | height: 12 → 14.5 (integer → number)<br>tags.use: "residential" → "mixed"<br>+ tags.levels: 4 |
216
+ | moved | 4 | LineString | + surface: "asphalt"<br>geometry: Hausdorff 21.0 m, centroid shift 5.6 m, vertices 1 moved (4 → 4), length +3.3 m |
217
+
218
+ </details>
219
+
220
+ <sub>Generated by geojson-diff</sub>
221
+ ```
222
+
223
+ Every data value is escaped (HTML entities for Markdown syntax, `@` and `#`), so a
224
+ property value cannot inject markup, links, table columns or user mentions.
225
+ `--md-map` appends the diff layer as a ` ```geojson ` block, which GitHub renders as
226
+ an interactive map; `--max-rows` limits the table.
227
+
228
+ ### JSON
229
+
230
+ ```console
231
+ $ geojson-diff parcels_v1.geojson parcels_v2.geojson -t 0.5m -f json
232
+ ```
233
+
234
+ The document holds `options`, a `summary` and one entry per change. Property changes
235
+ use JSON Patch vocabulary with the old value and JSON types added:
236
+
237
+ ```json
238
+ {
239
+ "op": "modify",
240
+ "status": "modified",
241
+ "key": "1",
242
+ "old_index": 0,
243
+ "new_index": 0,
244
+ "matched_by": "id",
245
+ "patch": [
246
+ {"op": "replace", "path": "/properties/height", "old": 12, "old_type": "integer",
247
+ "value": 14.5, "new_type": "number"},
248
+ {"op": "replace", "path": "/properties/tags/use", "old": "residential",
249
+ "old_type": "string", "value": "mixed", "new_type": "string"},
250
+ {"op": "add", "path": "/properties/tags/levels", "value": 4, "new_type": "integer"}
251
+ ],
252
+ "geometry": {"changed": false, "old_type": "Polygon", "new_type": "Polygon",
253
+ "units": "m", "vertices": 4},
254
+ "area_delta": 0.0,
255
+ "length_delta": 0.0,
256
+ "bbox": [29.001, 41.001, 29.0014, 41.0013]
257
+ }
258
+ ```
259
+
260
+ `moved` entries add `{"op": "replace", "path": "/geometry", ...}` to the patch and a
261
+ `geometry` block with `hausdorff`, `max_vertex_shift`, `centroid_shift`,
262
+ `vertices_added/removed/moved`, `area_old/new/delta`, `length_old/new/delta`, `bbox`
263
+ and (with `--shapely`) `symmetric_difference_area`. `added` / `removed` entries carry
264
+ the whole feature.
265
+
266
+ ### GeoJSON diff layer
267
+
268
+ `-f geojson` writes a FeatureCollection in which every changed feature is styled with
269
+ [simplestyle-spec](https://github.com/mapbox/simplestyle-spec) properties (`stroke`,
270
+ `fill`, `fill-opacity`, `marker-color`, `title`, `description`) - green for added,
271
+ red for removed, orange for modified, blue for moved, with a grey `before` outline for
272
+ moved features. GitHub renders `.geojson` files with that styling. The change itself
273
+ is described by `diff:status`, `diff:role` (`change` or `before`), `diff:key`,
274
+ `diff:changed` and `diff:hausdorff`.
275
+
276
+ ### HTML report
277
+
278
+ ```bash
279
+ geojson-diff old.geojson new.geojson -t 0.5m -o review.html
280
+ ```
281
+
282
+ A single file with summary cards, a Leaflet map (before and after layers, popups) and
283
+ a table of changes. Leaflet 1.9.4 is loaded from unpkg with Subresource Integrity and
284
+ OpenStreetMap tiles are attributed. Data is embedded as escaped JSON and rendered
285
+ with `textContent`, and the table is HTML-escaped, so hostile property values cannot
286
+ run script. The OpenStreetMap tile servers may refuse requests from `file://` pages
287
+ that send no referrer; serve the report over HTTP (for example as a CI artifact or
288
+ `python -m http.server`) or pass your own tile server through the Python API
289
+ (`render(result, "html", tiles=..., attribution=...)`).
290
+
291
+ ## Git integration
292
+
293
+ ### Readable `git diff` with a textconv filter
294
+
295
+ `--textconv` prints a stable, line-per-attribute rendering (features sorted by id,
296
+ properties sorted by key, coordinates rounded), so the ordinary line diff becomes
297
+ meaningful. Tell git which files to treat this way in `.gitattributes`:
298
+
299
+ ```gitattributes
300
+ *.geojson diff=geojson
301
+ *.geojsons diff=geojson
302
+ ```
303
+
304
+ and define the `geojson` diff driver once in your git configuration (run this
305
+ yourself; it is per repository unless you add `--global`):
306
+
307
+ ```bash
308
+ git config diff.geojson.textconv "geojson-diff --textconv"
309
+ git config diff.geojson.cachetextconv true
310
+ ```
311
+
312
+ ### Feature-level `git diff` with an external diff driver
313
+
314
+ Instead of (or as well as) textconv, git can hand both versions to `geojson-diff`:
315
+
316
+ ```bash
317
+ git config diff.geojson.command "geojson-diff --git --tolerance 0.5m"
318
+ git diff # uses the driver for *.geojson
319
+ git log -p --ext-diff -- data/parcels.geojson
320
+ git diff --no-ext-diff # plain line diff when you need it
321
+ ```
322
+
323
+ Git calls the command with `path old-file old-hex old-mode new-file new-hex new-mode`
324
+ (nine arguments for renames); `/dev/null` stands for the missing side of an added or
325
+ deleted file. In this mode `geojson-diff` always exits 0 unless the input is broken,
326
+ because git aborts the whole diff when an external driver fails. Any option can be
327
+ added to the command (`--key`, `--format md`, ...).
328
+
329
+ ### Diffing revisions: `git-geojson-diff`
330
+
331
+ ```bash
332
+ git-geojson-diff HEAD~1 HEAD data/parcels.geojson
333
+ git-geojson-diff main data/parcels.geojson # main vs. working tree
334
+ git geojson-diff v1.2 v1.3 data/parcels.geojson --format html -o review.html
335
+ ```
336
+
337
+ Because the script is on your `PATH` as `git-geojson-diff`, git also runs it as
338
+ `git geojson-diff`. A file missing at one revision counts as empty, so added and
339
+ deleted files work. It takes every `geojson-diff` option and uses the same exit codes.
340
+
341
+ ## CI usage
342
+
343
+ | Exit code | Meaning |
344
+ |---|---|
345
+ | `0` | no changes (or none above the `--fail-on` thresholds) |
346
+ | `1` | changes found |
347
+ | `2` | error (unreadable or invalid input, bad arguments) |
348
+
349
+ ```bash
350
+ geojson-diff old.geojson new.geojson --fail-on removed # fail only on removals
351
+ geojson-diff old.geojson new.geojson --fail-on removed:10,moved:0 # allow up to 10 removals
352
+ geojson-diff old.geojson new.geojson --fail-on any:5 -q # any category above 5
353
+ geojson-diff old.geojson new.geojson --exit-zero -f md >> "$GITHUB_STEP_SUMMARY"
354
+ ```
355
+
356
+ `--fail-on` takes `added`, `removed`, `modified`, `moved` or `any`, each with an
357
+ optional `:N` allowance (exit 1 when the count exceeds N; default 0).
358
+
359
+ ### GitHub Actions: post the summary on pull requests
360
+
361
+ [`examples/github-actions/geojson-diff.yml`](examples/github-actions/geojson-diff.yml)
362
+ diffs every changed `*.geojson` file between the pull-request base and head, posts a
363
+ single Markdown comment (updated on later pushes), copies it to the job summary and
364
+ fails the check when features were removed. The core of it:
365
+
366
+ ```yaml
367
+ - name: Diff changed GeoJSON files
368
+ id: diff
369
+ shell: bash
370
+ env:
371
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
372
+ run: |
373
+ set +e # exit status 1 means "changes found", not a failure of this step
374
+ status=0
375
+ : > geojson-diff.md
376
+ while IFS= read -r -d '' path; do
377
+ git-geojson-diff "$BASE_SHA" HEAD "$path" \
378
+ --format md --title "GeoJSON diff: $path" \
379
+ --tolerance 0.5m --fail-on removed --max-rows 50 >> geojson-diff.md
380
+ rc=$?
381
+ echo >> geojson-diff.md
382
+ if [ "$rc" -eq 2 ]; then echo "::error file=$path::geojson-diff failed"; status=2; fi
383
+ if [ "$rc" -eq 1 ] && [ "$status" -eq 0 ]; then status=1; fi
384
+ done < <(git diff -z --name-only "$BASE_SHA" HEAD -- '*.geojson' '*.geojsons')
385
+ cat geojson-diff.md >> "$GITHUB_STEP_SUMMARY"
386
+ echo "status=$status" >> "$GITHUB_OUTPUT"
387
+
388
+ - name: Post the summary on the pull request
389
+ if: github.event.pull_request.head.repo.full_name == github.repository
390
+ env:
391
+ GH_TOKEN: ${{ github.token }}
392
+ PR: ${{ github.event.pull_request.number }}
393
+ run: gh pr comment "$PR" --body-file geojson-diff.md --edit-last --create-if-none
394
+ ```
395
+
396
+ The checkout needs `fetch-depth: 0` and the job `pull-requests: write`; see the full
397
+ file for the remaining steps.
398
+
399
+ ## Python API
400
+
401
+ ```python
402
+ from geojson_diff import DiffOptions, diff, render, diff_layer
403
+
404
+ result = diff(
405
+ "old.geojson",
406
+ "new.geojson",
407
+ keys=["parcel_id"], # or match=["id", "hash", "spatial"]
408
+ tolerance="0.5m",
409
+ ignore_properties=["updated_at"],
410
+ )
411
+
412
+ print(result.counts) # e.g. {'added': 1, 'removed': 1, 'modified': 1, 'moved': 1, 'unchanged': 2}
413
+ print(result.has_changes, result.bbox, result.area_delta)
414
+
415
+ for change in result.changes: # unchanged features are kept with keep_unchanged=True
416
+ print(change.status, change.key, change.matched_by)
417
+ for prop in change.properties:
418
+ print(" ", prop.op, prop.path, prop.old, "->", prop.new, prop.old_type, prop.new_type)
419
+ if change.geometry and change.geometry.changed:
420
+ print(" hausdorff", change.geometry.hausdorff, "m")
421
+
422
+ markdown = render(result, "md", max_rows=50)
423
+ html = render(result, "html", title="Parcel update 2026-11")
424
+ layer = diff_layer(result) # dict, ready for json.dump
425
+ payload = result.to_dict() # the JSON output as a dict
426
+ ```
427
+
428
+ `diff()` accepts paths, `"-"` (stdin), open files, parsed GeoJSON objects or plain
429
+ iterables of features. Lower-level helpers are public as well:
430
+ `compare_features`, `compare_geometry`, `diff_properties`, `hausdorff_distance`,
431
+ `haversine`, `geometry_area`, `geometry_length`, `geometry_hash`, `iter_features`,
432
+ `parse_tolerance`.
433
+
434
+ ## Large files
435
+
436
+ The reader decodes one feature at a time: the `features` array of a
437
+ FeatureCollection is streamed, as are GeoJSONSeq and newline-delimited files. The
438
+ old file is indexed by key; when the first strategy is `id`, `key` or `hash`, the new
439
+ file is streamed against that index and unchanged features are dropped as soon as
440
+ they are compared, so memory grows with the old file plus the changes. Geometry
441
+ metrics are computed only for geometries that actually changed. On a laptop, two
442
+ 66 MB FeatureCollections with 100 000 polygons each diff in about 8 seconds, most of
443
+ it JSON decoding.
444
+
445
+ ## Optional shapely extra
446
+
447
+ With `pip install "geojson-diff[shapely]"` and `--shapely` (`use_shapely=True`):
448
+
449
+ - with an exact tolerance (`0`), geometries are compared for topological equality
450
+ (`shapely.equals`), and
451
+ - moved polygons report `symmetric_difference_area` (square metres via a local
452
+ projection for longitude/latitude data).
453
+
454
+ ## Limitations
455
+
456
+ - The Hausdorff distance is measured from every vertex to the other geometry's
457
+ segments in both directions. It is exact for points; for lines and polygons it can
458
+ slightly underestimate when the farthest point lies inside an edge rather than at a
459
+ vertex.
460
+ - Areas use a spherical model (WGS84 semi-major axis), accurate to well under 1 % for
461
+ the parcel- to city-scale changes reviewed in practice.
462
+ - `moved` means "geometry changed beyond tolerance"; a reshaped polygon is `moved` too.
463
+
464
+ ## Development
465
+
466
+ ```bash
467
+ python -m pip install -e ".[dev,shapely]"
468
+ python -m pytest --cov=geojson_diff
469
+ python -m ruff check . && python -m ruff format --check src tests
470
+ python -m mypy src
471
+ ```
472
+
473
+ ## License and citation
474
+
475
+ MIT License - see [LICENSE](LICENSE). If you use `geojson-diff` in research, please
476
+ cite it using [CITATION.cff](CITATION.cff).
477
+
478
+ Author: Yusuf Eminoğlu
@@ -0,0 +1,24 @@
1
+ geojson_diff/__init__.py,sha256=dOEg1Pw1JxbULmu4N1sDGIjUbW_hHoX8Fr9Sr5Gk6tk,1386
2
+ geojson_diff/__main__.py,sha256=XkAN6lVXvAInTY92AAvrqWT5DXFt_jZNad0PKgMPl9U,87
3
+ geojson_diff/_shapely.py,sha256=2PhboAt6g9nMti4cHLmkFWxU_TdQrwwX_QprwSA0ZFA,2729
4
+ geojson_diff/cli.py,sha256=dvBbCKn6RLzq5VwgHq8EFeFm501fJ4K-sWbSjirUiNk,12946
5
+ geojson_diff/core.py,sha256=8q0biPJaQtkyXFulqQhCMFpP_6Gg3apqc-AjGLAC6Eo,23302
6
+ geojson_diff/geometry.py,sha256=JmPZ0bPgpQ6rzcZHwG5u6XDVmEy-PMaseOAfn9qbtM8,15358
7
+ geojson_diff/git.py,sha256=xFvW1sBvKOlvop-Ot9IPV0G6hmCdd56eiMma70BEn9c,4475
8
+ geojson_diff/io.py,sha256=4JGfrYv4F4WonSuUE8lT6opSweMT3sUWHJAGbwCwDaQ,9114
9
+ geojson_diff/matching.py,sha256=UABOR2v3YumHSpSHgXmeFBVujrcwLFNSw7R6iQ4n0MI,7489
10
+ geojson_diff/properties.py,sha256=S1br5awlPE3boUTiO1IqVV8oDg6Mg1pCfAf6xJgqpJc,6809
11
+ geojson_diff/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ geojson_diff/tolerance.py,sha256=4NXLeMhmoM48o3VkKfirpv6X56MfRfbUeOlGyHDMjbg,2709
13
+ geojson_diff/render/__init__.py,sha256=BPSFZzlgT6kOyERzfw4kX3RTJzLrRSe0jXLrUv7tqGk,5218
14
+ geojson_diff/render/html.py,sha256=qPJhWUWRsKukKfTSvfmiNG14c6X_qx_Lk66n2Iot4ZI,9175
15
+ geojson_diff/render/layer.py,sha256=Yis37Qisz7uBmVY9vSKAMcJN5EnbNYRpcPE2WQ4gHTY,3883
16
+ geojson_diff/render/machine.py,sha256=ujN85ejtKuoRe5e_gzksGX2aSBORjMYH7coc0WX1lDc,651
17
+ geojson_diff/render/markdown.py,sha256=I4OLKY2IQ5kla5Zy7SMN7ltRxEYMwMmFGhYSwjCOor0,4982
18
+ geojson_diff/render/text.py,sha256=HzsHINxBnZW6gbiPcXbSbTu5LxKH-kzFr1Q_GATj25E,2791
19
+ geojson_diff-0.1.0.dist-info/licenses/LICENSE,sha256=Z9meOk3opsRgHOqC5mA4B8NjZoU5WU1Kqhn82FL4ICs,1072
20
+ geojson_diff-0.1.0.dist-info/METADATA,sha256=4qsdgHG_2onZWKm9iWMf3lYmv2jqi1J7EsIDvmY0rJM,20378
21
+ geojson_diff-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
22
+ geojson_diff-0.1.0.dist-info/entry_points.txt,sha256=lbXwh6LungdWgTDcvpW07SfebZ2OwnUrVhME6J3_jtk,100
23
+ geojson_diff-0.1.0.dist-info/top_level.txt,sha256=NmzaTZ_Xyr3dSayx7gRNX3onkpg3V5Ih2jQgU5j1B-0,13
24
+ geojson_diff-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ geojson-diff = geojson_diff.cli:main
3
+ git-geojson-diff = geojson_diff.cli:git_main