Metadata-Version: 2.4
Name: geofix
Version: 0.1.1
Summary: Fast, dependency-light GeoJSON (RFC 7946) validator and repairer for local runs, pre-commit and CI.
Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/geofix_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/geofix_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/geofix_sdk/issues
Project-URL: Documentation, https://github.com/YusufEminoglu/geofix_sdk#readme
Project-URL: Changelog, https://github.com/YusufEminoglu/geofix_sdk/blob/main/CHANGELOG.md
Keywords: geojson,rfc7946,validation,validator,linter,repair,make-valid,gis,geospatial,topology,self-intersection,winding-order,pre-commit,github-actions,ci,geojsonseq,ndjson
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Software Development :: Testing
Classifier: Environment :: Console
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
Provides-Extra: shapely
Requires-Dist: shapely>=2.0.0; extra == "shapely"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Dynamic: license-file

# geofix

[![PyPI](https://img.shields.io/pypi/v/geofix.svg)](https://pypi.org/project/geofix/)
[![Python](https://img.shields.io/pypi/pyversions/geofix.svg)](https://pypi.org/project/geofix/)
[![CI](https://github.com/YusufEminoglu/geofix_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/geofix_sdk/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

**Fast, dependency-light GeoJSON validator and repairer** for the command line, pre-commit
hooks and CI. It checks files against [RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946)
(structure, coordinates, winding, bounding boxes) and polygon topology (self-intersections,
holes), tells you *where* each problem is, and can repair most of them, reporting every change
it makes.

- **Pure Python, no required dependencies** (only `tomli` on Python < 3.11 for reading
  `pyproject.toml`). Topology checks run without shapely or GEOS.
- **Streams large files**: a `FeatureCollection` is read one feature at a time, and
  newline-delimited GeoJSON (GeoJSONSeq / RFC 8142, `.geojsonl`, `.ndgeojson`) one record at a
  time.
- **Made for CI**: exit codes, `--format github` annotations, `--format json`,
  `--select`/`--ignore` rule codes, `[tool.geofix]` configuration, a pre-commit hook.
- **Explains CRS mistakes**: "this looks like EPSG:3857 Web Mercator (the centre would be
  lon 28.99762, lat 40.97734)", UTM/TM eastings, swapped `[lat, lon]` axes.
- **Repairs safely**: `geofix fix` never changes data without a recorded change, leaves valid
  files byte-for-byte untouched, and is idempotent.

```console
pip install geofix            # pure Python
pip install "geofix[shapely]"  # + shapely.make_valid for complex topology repairs
```

## Command line

### `geofix check`

```console
$ geofix check examples/bad.geojson
examples/bad.geojson:2:1: GF212 warning: exterior ring is clockwise [$.features[0].geometry.coordinates[0]]
    hint: Reverse the ring (ogr2ogr -lco RFC7946=YES writes the right order).
examples/bad.geojson:3:1: GF220 error: ring crosses itself at (29.025, 41.005) [$.features[1].geometry.coordinates[0]]
    hint: Split bow-ties into separate polygons or rebuild with shapely.make_valid.
examples/bad.geojson:4:1: GF109 warning: 1 consecutive duplicate position(s), first [29.05, 41] [$.features[2].geometry.coordinates[0][2]]
    hint: Remove the repeated positions.
examples/bad.geojson:4:1: GF210 error: ring is not closed: first position [29.04, 41], last [29.04, 41.01] [$.features[2].geometry.coordinates[0]]
    hint: Repeat the first position at the end of the ring.
examples/bad.geojson:4:1: GF108 info: 1 of 5 position(s) have more than 7 decimals [$.features[2].geometry.coordinates[0][3]]
    hint: Round coordinates; 6 decimals is ~10 cm, 7 is ~1 cm (RFC 7946 section 11.2).
examples/bad.geojson:5:1: GF006 warning: id 'parcel-3' is also used by $.features[2] [$.features[3].id]
    hint: Give every feature its own id, or drop ids that are not identifiers.
examples/bad.geojson:5:1: GF107 error: coordinates (x 3.228e+06..3.228e+06, y 5.009e+06..5.009e+06) look projected, not WGS 84 longitude/latitude: this looks like EPSG:3857 Web Mercator (the centre would be lon 28.99762, lat 40.97734) or a Gauss-Kruger/TM grid with zone-prefixed eastings [$.features[3].geometry.coordinates]
    hint: Reproject to EPSG:4326, e.g. ogr2ogr -t_srs EPSG:4326 -lco RFC7946=YES out.geojson in.geojson.
examples/bad.geojson:6:1: GF106 warning: latitude 129.0 is out of range while every longitude is within +/-90: positions look like [lat, lon] [$.features[4].geometry.coordinates[0]]
    hint: GeoJSON order is longitude first; swap x and y (EPSG:4326 axis order is a common cause).
examples/bad.geojson:7:1: GF200 warning: MultiPolygon has empty coordinates [$.features[5].geometry]
    hint: Remove the feature, or use "geometry": null for features without a location.
examples/bad.geojson:1:1: GF301 error: "bbox" [29, 41, 29.03, 41.01] does not contain the coordinates (extent [29, 41, 3228000.5, 5009000.25]) [$.bbox]
    hint: Recompute the bbox from the coordinates.

Found 10 issues (4 errors, 5 warnings, 1 info) in 1 of 1 file.
$ echo $?
1
```

Each line is `file:line:column: CODE severity: message [JSON path]`. The line is where the
feature (or record) starts; the JSON path points inside it. Directories are searched
recursively for `.geojson`, `.geojsonl`, `.geojsons`, `.geojsonseq`, `.ndgeojson` and
`.geo.json` files; `-` reads standard input.

Useful options: `--select GF2,GF3`, `--ignore GF007,GF108` (codes or prefixes, `ALL`),
`--fail-on error|warning|info`, `--precision N`, `--statistics`, `--no-hints`,
`--format text|json|github`.

### `geofix fix`

```console
$ geofix fix examples/bad.geojson -o fixed.geojson --no-hints
examples/bad.geojson: fixed 9 problems (GF108 x1, GF109 x1, GF200 x1, GF210 x1, GF212 x2, GF220 x2, GF301 x1)
examples/bad.geojson:5:1: GF006 warning: id 'parcel-3' is also used by $.features[2] [$.features[3].id]
examples/bad.geojson:5:1: GF107 error: coordinates (x 3.228e+06..3.228e+06, y 5.009e+06..5.009e+06) look projected, not WGS 84 longitude/latitude: this looks like EPSG:3857 Web Mercator (the centre would be lon 28.99762, lat 40.97734) or a Gauss-Kruger/TM grid with zone-prefixed eastings [$.features[3].geometry.coordinates]
examples/bad.geojson:6:1: GF106 warning: latitude 129.0 is out of range while every longitude is within +/-90: positions look like [lat, lon] [$.features[4].geometry.coordinates[0]]

Applied 9 fixes to 1 file. Found 3 remaining issues (1 error, 2 warnings) in 1 of 1 file.
```

What it did: rewound the clockwise shell, split the bow-tie into a two-part MultiPolygon,
removed the duplicate vertex, closed the open ring, rounded the over-precise coordinate,
removed the feature with an empty geometry and recomputed the collection `bbox`. It did
**not** guess a CRS or swap axes: those are reported for a human to decide.

- `geofix fix FILE` writes the result to standard output (the report goes to stderr);
  `-o OUT` writes a file; `-i/--in-place` rewrites inputs **only when something changed**;
  `--dry-run` reports what would change and exits 1 if anything would.
- `--show-changes` lists every change instead of a per-file summary;
  `--exit-nonzero-on-fix` exits 1 when a file was modified; `--no-shapely` keeps repairs pure
  Python.
- Collections are written one feature per line (readable diffs); `--indent N` pretty-prints
  single objects. Collection members keep their order, except `bbox`, which is written after
  `features` because it can only be computed once every feature has been repaired.
- Repairs run in this order: round (GF108), drop duplicates (GF109), close rings (GF210),
  drop degenerate holes/parts (GF211/GF214), topology (GF220-GF222), rewind (GF212/GF213),
  recompute bboxes (GF300-GF302). A second `geofix fix` on the output changes nothing.
- Rounding to the configured precision (7 decimals by default, ~1 cm) is a repair like any
  other: disable it with `--ignore GF108` or change it with `--precision 9`.

### `geofix stats` and `geofix rules`

```console
$ geofix stats examples/bad.geojson
examples/bad.geojson
  kind:            FeatureCollection
  size:            1.2 kB
  features:        6
  geometry types:  Polygon 3, Point 1, LineString 1, MultiPolygon 1
  null / empty:    0 / 1
  positions:       18 (0 with z)
  max decimals:    10
  bbox:            [29, 41, 3.228e+06, 5.009e+06]
  ids:             6 (5 unique)
  properties:      name (5)
  read in:         0.00 s
```

`geofix rules` lists every rule (`--format text|json|markdown`).

### Exit codes

| Status | Meaning |
|---|---|
| 0 | No issue at or above `--fail-on` (default `error`) |
| 1 | Issues at or above `--fail-on`; also `fix --dry-run` / `--exit-nonzero-on-fix` when a fix is (or was) needed |
| 2 | Usage or configuration error, or an input that cannot be read |

Warnings do not fail a run by default; use `--fail-on warning` (or `fail-on = "warning"` in
the configuration) for strict RFC 7946 conformance, e.g. ring winding.

## Rules

| Code | Name | Severity | Checks | `geofix fix` |
|---|---|---|---|---|
| GF000 | invalid-json | error | The input is not valid UTF-8 JSON. | report only |
| GF001 | invalid-type | error | Missing or unknown "type" member. | report only |
| GF002 | missing-member | error | A required member is missing (Feature "geometry"/"properties", FeatureCollection "features", geometry "coordinates", GeometryCollection "geometries"). | Adds "properties": {} or "geometry": null to features that lack them. |
| GF003 | invalid-member | error | A member has the wrong JSON type (e.g. "features" is not an array, a feature is not a Feature object, "geometry" is not an object or null). | report only |
| GF004 | invalid-properties | error | "properties" must be a JSON object or null. | report only |
| GF005 | invalid-id | error | Feature "id" must be a string or a number. | report only |
| GF006 | duplicate-id | warning | Feature "id" is not unique within the collection. | report only |
| GF007 | foreign-member | info | Member that RFC 7946 does not define for this object (a foreign member). | report only |
| GF008 | reserved-member | error | Member that changes GeoJSON semantics on this object type (RFC 7946 section 7.1), e.g. "coordinates" on a Feature or "properties" on a geometry. | report only |
| GF009 | crs-member | warning | "crs" member present; RFC 7946 removed it and requires WGS 84 longitude/latitude. | Removes "crs" when it already names WGS 84 (CRS84 / EPSG:4326). |
| GF010 | nested-geometry-collection | warning | GeometryCollection nested inside another GeometryCollection (RFC 7946 3.1.8 says SHOULD avoid). | report only |
| GF100 | invalid-coordinates | error | "coordinates" has the wrong nesting depth or holds non-numeric values. | report only |
| GF101 | position-too-short | error | Position has fewer than two numbers. | report only |
| GF102 | position-too-long | warning | Position has more than three numbers (RFC 7946 3.1.1: SHOULD NOT). | report only |
| GF103 | non-finite-coordinate | error | Coordinate is NaN or Infinity (non-standard JSON literals, or overflowing numbers). | report only |
| GF104 | longitude-out-of-range | error | Longitude outside [-180, 180]. | report only |
| GF105 | latitude-out-of-range | error | Latitude outside [-90, 90]. | report only |
| GF106 | swapped-axes | warning | Coordinates look like [lat, lon] instead of [lon, lat]. | report only |
| GF107 | projected-coordinates | error | Coordinates look projected (metres: EPSG:3857, UTM, national TM grids), not WGS 84. | report only |
| GF108 | excessive-precision | info | Coordinates carry more decimals than the configured precision (default 7, ~1 cm). | Rounds longitude/latitude to the configured precision. |
| GF109 | duplicate-vertex | warning | Consecutive duplicate positions in a line or ring. | Drops consecutive duplicate positions. |
| GF110 | mixed-dimensions | info | Geometry mixes 2D and 3D positions. | report only |
| GF200 | empty-geometry | warning | Geometry has no coordinates (or a GeometryCollection has no geometries). | Removes features whose geometry is empty from collections and sequences. |
| GF201 | null-geometry | info | Feature has "geometry": null (allowed, but many tools skip such features). | report only |
| GF202 | line-too-short | error | LineString has fewer than two positions. | report only |
| GF203 | zero-length-line | warning | LineString whose positions are all identical. | report only |
| GF210 | unclosed-ring | error | Linear ring's first and last positions differ. | Appends the first position to close the ring. |
| GF211 | ring-too-short | error | Linear ring has fewer than four positions. | Drops degenerate holes and polygon parts (always reported). |
| GF212 | exterior-clockwise | warning | Exterior ring is clockwise; RFC 7946 3.1.6 requires counter-clockwise. | Reverses the ring. |
| GF213 | hole-counterclockwise | warning | Interior ring (hole) is counter-clockwise; RFC 7946 3.1.6 requires clockwise. | Reverses the ring. |
| GF214 | zero-area-ring | error | Linear ring encloses no area (all positions collinear or collapsed). | Drops degenerate holes and polygon parts (always reported). |
| GF220 | self-intersection | error | Ring crosses or touches itself. | Splits single-crossing (bow-tie) rings in pure Python; other cases use shapely.make_valid when shapely is installed. |
| GF221 | hole-outside-shell | error | Hole is not inside its exterior ring (lies outside it or crosses it). | Rebuilt with shapely.make_valid when shapely is installed. |
| GF222 | holes-overlap | error | Two holes of the same polygon cross, overlap or nest. | Rebuilt with shapely.make_valid when shapely is installed. |
| GF230 | antimeridian-crossing | warning | Consecutive positions jump more than 180 degrees of longitude; the segment probably crosses the antimeridian. | report only |
| GF300 | invalid-bbox | error | "bbox" is not an array of 2*n numbers with min <= max (west > east is allowed across the antimeridian). | Recomputes the bbox from the coordinates. |
| GF301 | bbox-mismatch | error | "bbox" does not contain all coordinates of the object. | Recomputes the bbox from the coordinates. |
| GF302 | bbox-not-tight | info | "bbox" is larger than the extent of the coordinates. | Recomputes the bbox from the coordinates. |

Notes on the checks:

- **Topology** is planar, in the coordinate space of the file, like the OGC simple feature
  rules: within a ring any contact between non-adjacent edges is an error (crossing,
  touching, or a collinear spike); between rings, crossings and shared edges are errors,
  while a single touching point is allowed. Overlap between the *parts* of a MultiPolygon is
  not checked.
- **Antimeridian**: RFC 7946 3.1.9 asks for geometries crossing 180 degrees to be cut into
  multi-part geometries. GF230 flags segments that jump more than 180 degrees of longitude;
  geofix does not cut them (use `ogr2ogr -wrapdateline` or the `antimeridian` package). A
  `bbox` with west > east is accepted as an antimeridian-crossing box (RFC 7946 5.2).
- **CRS heuristics** (GF106/GF107) look at each geometry's extent. Values beyond +/-1000 are
  treated as metres; eastings of 100 000-900 000 m suggest UTM or a national TM grid (for
  example TUREF/TM), values within the Web Mercator square are back-projected so you can see
  where the data would land if it were EPSG:3857. `[lat, lon]` is only detectable when a
  latitude falls outside +/-90 while every longitude is within +/-90.
- **Precision** (GF108) counts decimals of the shortest representation of each longitude and
  latitude (elevations are not checked).

## Configuration

`geofix` reads `[tool.geofix]` from the nearest `pyproject.toml` (or `--config FILE`;
`--isolated` ignores it). Command-line options override it.

```toml
[tool.geofix]
select = ["ALL"]            # codes or prefixes, e.g. ["GF0", "GF2"]
ignore = ["GF007"]          # foreign members are fine for us
fail-on = "warning"         # error | warning | info
precision = 6               # GF108 threshold and fix rounding
shapely = true              # let `geofix fix` use shapely.make_valid when installed
format = "text"             # text | json | github
```

## pre-commit

```yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/YusufEminoglu/geofix_sdk
    rev: v0.1.0
    hooks:
      - id: geofix          # validate
      # - id: geofix-fix    # or repair in place (the commit stops if a file changed)
```

Both hooks run on `.geojson`, `.geojsonl`, `.geojsons`, `.geojsonseq` and `.ndgeojson` files
and honour `[tool.geofix]`. Pass options with `args`, e.g. `args: [--fail-on, warning]`.

## GitHub Actions

```yaml
# .github/workflows/geojson.yml
name: GeoJSON
on: [push, pull_request]
jobs:
  geofix:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-python@v6
        with:
          python-version: "3.13"
      - run: pip install geofix
      - run: geofix check --format github data/
```

`--format github` prints [workflow commands](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions)
so issues appear as annotations on the pull request diff, at the line where each feature
starts:

```text
::warning file=examples/bad.geojson,line=2,col=1,title=geofix GF212 (exterior-clockwise)::exterior ring is clockwise at $.features[0].geometry.coordinates[0]%0Ahint: Reverse the ring (ogr2ogr -lco RFC7946=YES writes the right order).
::error file=examples/bad.geojson,line=3,col=1,title=geofix GF220 (self-intersection)::ring crosses itself at (29.025, 41.005) at $.features[1].geometry.coordinates[0]%0Ahint: Split bow-ties into separate polygons or rebuild with shapely.make_valid.
```

## Python API

```python
import geofix

# Validate a parsed object, a JSON string/bytes, or a file (streamed).
issues = geofix.check({"type": "Point", "coordinates": [3228000.0, 5009000.0]})
issues = geofix.check_file("parcels.geojson", ignore=["GF007"], precision=6)
for issue in issues:
    print(issue.code, issue.severity, issue.line, issue.path, issue.message, issue.hint)

# Repair. The input is never modified; every modification is a Change.
result = geofix.fix(my_feature_collection)
result.data      # repaired object
result.changes   # [Change(code="GF212", path="$.features[0]...", message="rewound ...")]
result.issues    # what is still wrong after the repair

# Repair a file feature by feature (dst may equal src; None is a dry run; "-" is stdout).
result = geofix.fix_file("in.geojson", "out.geojson", use_shapely=False)

# Options can be shared.
opts = geofix.Options(select=["GF2", "GF3"], fail_on="warning", precision=7)
geofix.check_file("in.geojson", opts)

# Streaming building blocks.
with open("big.geojsonl", encoding="utf-8") as fh:
    for path, feature in geofix.iter_features(fh):
        ...

stats = geofix.stats_file("in.geojson")   # counts, bbox, property keys, max decimals
geofix.RULES["GF220"].summary             # rule registry
```

`geofix.Validator` and `geofix.Repairer` are the incremental engines behind these functions
(feed them features one at a time); `geofix.Reader` produces the streaming events.

## Performance

Pure Python 3.12 on an AMD Ryzen 4000-series laptop CPU (single thread, Windows), synthetic
files (numbers vary with hardware; the test-suite only asserts generous limits):

| File | Size | Positions | `check` | `fix --dry-run` |
|---|---|---|---|---|
| 100 000 convex polygons (10 vertices) | 37.6 MB | 1.10 M | 5.1 s | 5.5 s |
| 100 000 non-convex polygons, 10% with holes | 44.1 MB | 1.37 M | 7.9 s | 8.2 s |
| one ring, 1 000 000 vertices (ellipse) | 25.8 MB | 1.00 M | 4.6 s | 4.6 s |
| one ring, 1 000 000 vertices (wavy, 1 000 lobes) | 25.8 MB | 1.00 M | 8.1 s | 7.9 s |

Repairing every feature of the 100 000-polygon file (all clockwise and over-precise: 200 000
changes) and writing the result takes about 21 s; a second run finds nothing to do in 5.4 s.

How: positions are validated with C-level `map`/`itemgetter` passes; the precision test is a
regex over the `repr` of a whole coordinate list (exact `round()` only runs when it matches);
convex rings skip the segment search; larger rings are split into x/y-monotone chains (whose
own segments cannot meet) and only chains with overlapping boxes are bisected against each
other, as in GEOS, with a uniform grid for zig-zag data. Memory is bounded by the largest
single feature, not the file.

## How geofix compares

| | geofix | [@mapbox/geojsonhint](https://github.com/mapbox/geojsonhint) | [geojson](https://pypi.org/project/geojson/) (Python) | [shapely](https://pypi.org/project/shapely/) `is_valid` / `make_valid` | `ogr2ogr -lco RFC7946=YES` |
|---|---|---|---|---|---|
| Runtime | Python, no required deps | Node.js | Python | Python + GEOS (bundled in wheels) | GDAL |
| RFC 7946 structure checks | yes | yes | partial (`is_valid` / `errors()`) | no (geometry only) | no (it converts) |
| Winding order (right-hand rule) | check + fix | check | no | no (`orient` can fix) | writes CCW/CW |
| Self-intersections, holes | check; fix bow-ties, rest via shapely | no | no | yes (GEOS) | no |
| bbox, ids, foreign members, `crs` | yes | partly | no | no | writes a new file |
| Projected-CRS / swapped-axes hints | yes | no | no | no | n/a (reprojects when told the CRS) |
| Reports what was changed | yes (per change) | n/a | n/a | no | no |
| Streams large files / GeoJSONSeq | yes | no | no | n/a | yes |
| CI output (exit codes, GitHub annotations) | yes | CLI only | no | no | no |

Use shapely/GEOS when you need full OGC validity semantics on arbitrary geometries (geofix
calls `shapely.make_valid` for cases it cannot repair itself), and GDAL when you need to
reproject or convert formats; geofix is the lightweight gate that tells you which of those
you need, in a form CI understands.

## Development

```console
python -m pip install -e ".[dev,shapely]"
python -m pytest
python -m ruff check . && python -m ruff format --check src tests
python -m mypy src
```

## License

MIT, see [LICENSE](LICENSE). Citation metadata: [CITATION.cff](CITATION.cff).
