geojson-diff 0.1.0__tar.gz
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-0.1.0/CHANGELOG.md +38 -0
- geojson_diff-0.1.0/CITATION.cff +21 -0
- geojson_diff-0.1.0/LICENSE +21 -0
- geojson_diff-0.1.0/MANIFEST.in +9 -0
- geojson_diff-0.1.0/PKG-INFO +478 -0
- geojson_diff-0.1.0/README.md +438 -0
- geojson_diff-0.1.0/examples/github-actions/geojson-diff.yml +72 -0
- geojson_diff-0.1.0/examples/parcels_v1.geojson +139 -0
- geojson_diff-0.1.0/examples/parcels_v2.geojson +121 -0
- geojson_diff-0.1.0/pyproject.toml +121 -0
- geojson_diff-0.1.0/setup.cfg +4 -0
- geojson_diff-0.1.0/src/geojson_diff/__init__.py +60 -0
- geojson_diff-0.1.0/src/geojson_diff/__main__.py +7 -0
- geojson_diff-0.1.0/src/geojson_diff/_shapely.py +80 -0
- geojson_diff-0.1.0/src/geojson_diff/cli.py +374 -0
- geojson_diff-0.1.0/src/geojson_diff/core.py +678 -0
- geojson_diff-0.1.0/src/geojson_diff/geometry.py +454 -0
- geojson_diff-0.1.0/src/geojson_diff/git.py +112 -0
- geojson_diff-0.1.0/src/geojson_diff/io.py +254 -0
- geojson_diff-0.1.0/src/geojson_diff/matching.py +218 -0
- geojson_diff-0.1.0/src/geojson_diff/properties.py +208 -0
- geojson_diff-0.1.0/src/geojson_diff/py.typed +0 -0
- geojson_diff-0.1.0/src/geojson_diff/render/__init__.py +146 -0
- geojson_diff-0.1.0/src/geojson_diff/render/html.py +237 -0
- geojson_diff-0.1.0/src/geojson_diff/render/layer.py +111 -0
- geojson_diff-0.1.0/src/geojson_diff/render/machine.py +20 -0
- geojson_diff-0.1.0/src/geojson_diff/render/markdown.py +143 -0
- geojson_diff-0.1.0/src/geojson_diff/render/text.py +85 -0
- geojson_diff-0.1.0/src/geojson_diff/tolerance.py +86 -0
- geojson_diff-0.1.0/src/geojson_diff.egg-info/PKG-INFO +478 -0
- geojson_diff-0.1.0/src/geojson_diff.egg-info/SOURCES.txt +40 -0
- geojson_diff-0.1.0/src/geojson_diff.egg-info/dependency_links.txt +1 -0
- geojson_diff-0.1.0/src/geojson_diff.egg-info/entry_points.txt +3 -0
- geojson_diff-0.1.0/src/geojson_diff.egg-info/requires.txt +9 -0
- geojson_diff-0.1.0/src/geojson_diff.egg-info/top_level.txt +1 -0
- geojson_diff-0.1.0/tests/conftest.py +56 -0
- geojson_diff-0.1.0/tests/test_cli_git.py +246 -0
- geojson_diff-0.1.0/tests/test_diff.py +315 -0
- geojson_diff-0.1.0/tests/test_geometry_properties.py +246 -0
- geojson_diff-0.1.0/tests/test_render.py +211 -0
- geojson_diff-0.1.0/tests/test_shapely_examples.py +66 -0
- geojson_diff-0.1.0/tests/test_tolerance_io.py +155 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
|
|
5
|
+
project uses [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-11-08
|
|
10
|
+
### Added
|
|
11
|
+
- **Diff engine** (`geojson_diff.diff`): compares two GeoJSON FeatureCollections,
|
|
12
|
+
GeoJSONSeq (RFC 8142) or newline-delimited GeoJSON files feature by feature and
|
|
13
|
+
classifies every feature as `added`, `removed`, `modified` (properties only),
|
|
14
|
+
`moved` (geometry changed beyond the tolerance) or `unchanged`.
|
|
15
|
+
- **Matching strategies**, chainable in order: feature `id`, property `key`(s)
|
|
16
|
+
(composite and dotted nested keys), normalised geometry `hash` and `spatial`
|
|
17
|
+
(centroid proximity plus attribute similarity with a deterministic greedy
|
|
18
|
+
assignment). `auto` uses `key` when keys are given, else `id` then `hash`.
|
|
19
|
+
- **Property diff**: nested objects and arrays with RFC 6901 paths, JSON type
|
|
20
|
+
tracking (`integer -> number`), `--strict-types`, ignored properties.
|
|
21
|
+
- **Geometry diff**: tolerance in metres (haversine) or degrees, Hausdorff
|
|
22
|
+
distance (pure Python, early-break), vertex added/removed/moved counts,
|
|
23
|
+
centroid shift, area and length deltas (spherical), bounding box of changes;
|
|
24
|
+
ring rotation, orientation and collinear extra vertices do not count as changes.
|
|
25
|
+
- **Outputs**: coloured terminal text, JSON-Patch-like machine output, Markdown
|
|
26
|
+
for pull-request comments (optional `geojson` map block), a GeoJSON diff layer
|
|
27
|
+
styled with simplestyle-spec properties and a self-contained HTML report with a
|
|
28
|
+
Leaflet before/after map. All data values are escaped.
|
|
29
|
+
- **Git integration**: `geojson-diff --textconv` and `geojson-diff --git`
|
|
30
|
+
(external diff driver) plus the `git-geojson-diff REV1 [REV2] PATH` helper.
|
|
31
|
+
- **CI**: exit codes 0 / 1 / 2, `--fail-on removed,moved:5`, `--exit-zero` and a
|
|
32
|
+
GitHub Actions example that posts the Markdown summary on pull requests.
|
|
33
|
+
- **Streaming reader**: features are decoded one at a time; with an indexable first
|
|
34
|
+
strategy the new file is streamed against an index of the old one.
|
|
35
|
+
- Optional `shapely` extra for topological equality and symmetric-difference area.
|
|
36
|
+
|
|
37
|
+
[Unreleased]: https://github.com/YusufEminoglu/geojson_diff_sdk/compare/v0.1.0...HEAD
|
|
38
|
+
[0.1.0]: https://github.com/YusufEminoglu/geojson_diff_sdk/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it as below."
|
|
3
|
+
authors:
|
|
4
|
+
- family-names: "Eminoğlu"
|
|
5
|
+
given-names: "Yusuf"
|
|
6
|
+
email: "yusufeminoglu@gmail.com"
|
|
7
|
+
orcid: "https://orcid.org/0009-0005-6000-2934"
|
|
8
|
+
title: "geojson-diff"
|
|
9
|
+
version: "0.1.0"
|
|
10
|
+
date-released: "2026-11-08"
|
|
11
|
+
license: "MIT"
|
|
12
|
+
repository-code: "https://github.com/YusufEminoglu/geojson_diff_sdk"
|
|
13
|
+
url: "https://pypi.org/project/geojson-diff/"
|
|
14
|
+
abstract: "Feature-level diffs of GeoJSON datasets for code review, pull requests and data pipelines, with tolerance-aware geometry comparison, typed property diffs, text/JSON/Markdown/GeoJSON/HTML reports, a git diff driver and CI exit codes."
|
|
15
|
+
keywords:
|
|
16
|
+
- geojson
|
|
17
|
+
- diff
|
|
18
|
+
- gis
|
|
19
|
+
- git
|
|
20
|
+
- code review
|
|
21
|
+
- data pipelines
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yusuf Eminoğlu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -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
|
+
[](https://github.com/YusufEminoglu/geojson_diff_sdk/actions/workflows/ci.yml)
|
|
44
|
+
[](https://pypi.org/project/geojson-diff/)
|
|
45
|
+
[](https://pypi.org/project/geojson-diff/)
|
|
46
|
+
[](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_v1.geojson → parcels_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
|