Metadata-Version: 2.4
Name: gpkglite
Version: 0.1.1
Summary: Read, write and validate OGC GeoPackage files in pure Python: no GDAL, standard library only.
Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/gpkglite_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/gpkglite_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/gpkglite_sdk/issues
Project-URL: Changelog, https://github.com/YusufEminoglu/gpkglite_sdk/blob/main/CHANGELOG.md
Keywords: geopackage,gpkg,gis,geospatial,sqlite,wkb,geojson,ogc,vector,rtree,spatial-index,serverless,pyodide,no-gdal,pure-python
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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 :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: geo
Requires-Dist: shapely>=2.0.0; extra == "geo"
Requires-Dist: geopandas>=0.12.0; extra == "geo"
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

# gpkglite

**Read, write and validate OGC GeoPackage files in pure Python. No GDAL, no compiled
dependencies, standard library only (`sqlite3`, `struct`, `json`).**

[![CI](https://github.com/YusufEminoglu/gpkglite_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/gpkglite_sdk/actions/workflows/ci.yml)
![Python](https://img.shields.io/badge/python-3.9%20%7C%203.11%20%7C%203.13-blue)
![License](https://img.shields.io/badge/license-MIT-green)

GeoPackage (`.gpkg`) is the OGC standard container for vector data: an SQLite database
with a few well-defined tables and a binary geometry encoding. Reading or writing one
usually means installing GDAL (via `fiona`, `pyogrio`, `geopandas` or `osgeo`). That is
heavy, or impossible, in:

- serverless functions (AWS Lambda, Cloud Functions) with tight package size limits,
- slim Docker images and CI jobs,
- Pyodide / JupyterLite in the browser,
- Windows machines without OSGeo4W or conda,
- libraries that want to accept a `.gpkg` without pulling GDAL into their dependency tree.

`gpkglite` is a small, typed, dependency-free implementation of the parts of
[OGC 12-128r18 (GeoPackage 1.3.1)](https://www.geopackage.org/spec131/) that vector
workflows need. Files it writes open in GDAL/OGR, QGIS, geopandas/pyogrio and fiona,
and it reads files written by them.

## Installation

```bash
pip install gpkglite            # standard library only
pip install "gpkglite[geo]"     # + shapely / geopandas interop helpers
```

## Quick start

```python
import gpkglite

# write
with gpkglite.create("cities.gpkg") as gpkg:
    layer = gpkg.create_layer(
        "cities", "POINT", srs_id=4326,
        fields={"name": "TEXT", "population": "INTEGER", "capital": "BOOLEAN"},
    )
    layer.insert({"type": "Point", "coordinates": [28.97, 41.01]},
                 {"name": "Istanbul", "population": 15_600_000, "capital": False})
    layer.insert({"type": "Point", "coordinates": [32.85, 39.93]},
                 {"name": "Ankara", "population": 5_700_000, "capital": True})

# read
with gpkglite.open("cities.gpkg") as gpkg:
    for info in gpkg.layers():
        print(info.name, info.geometry_type, info.srs_id, info.bounds)

    cities = gpkg["cities"]
    print(cities.fields)            # {'name': 'TEXT', 'population': 'INTEGER', 'capital': 'BOOLEAN'}
    print(cities.crs.authority)     # 'EPSG:4326'
    for feature in cities.features(where="population > ?", params=[10_000_000]):
        print(feature["properties"]["name"], feature["geometry"]["coordinates"])

# check it
report = gpkglite.validate("cities.gpkg")
print(report)                      # cities.gpkg: valid GeoPackage 1.3.1 (0 errors, ...)
```

## Features

### Reading

- `gpkg.layers()`: every `gpkg_contents` entry (features and attribute tables) with
  geometry column, geometry type, Z/M flags, SRS id and extent.
- `layer.columns`, `layer.fields`, `layer.schema`: column names and GeoPackage types.
- `gpkg.srs(id)`, `gpkg.list_srs()`, `layer.crs`: `gpkg_spatial_ref_sys` records,
  including the WKT2 `definition_12_063` column when the CRS WKT extension is present.
- `layer.features(...)` yields GeoJSON-like `Feature` dicts; `layer.rows(...)` yields flat
  dicts. Both accept:
  - `where` + `params`: an SQL boolean expression with `?` or `:name` placeholders,
  - `bbox=(minx, miny, maxx, maxy)`: uses the `rtree_<table>_<column>` index when present,
    followed by an exact envelope test,
  - `limit`, `offset`, `order_by`, `columns`,
  - `geometry_format="geojson" | "wkb" | "gpkg" | "shapely"`,
  - `on_error="raise" | "null" | "skip"` for geometries gpkglite cannot decode.
- `layer.count(...)`, `layer.get(fid)`, `layer.compute_bounds()`, `layer.to_geojson()`.

### Geometry encoding

- StandardGeoPackageBinary headers: magic, version, flags, both byte orders, all envelope
  types (0 none, 1 XY, 2 XYZ, 3 XYM, 4 XYZM), the empty-geometry flag, SRS id.
- ISO WKB (and PostGIS extended WKB on input) for Point, LineString, Polygon, MultiPoint,
  MultiLineString, MultiPolygon and GeometryCollection, in XY, XYZ, XYM and XYZM,
  either byte order, nested collections, empty geometries (NaN points).
- Curve and surface types (CircularString, CompoundCurve, CurvePolygon, MultiCurve,
  MultiSurface, PolyhedralSurface, TIN, Triangle) and ExtendedGeoPackageBinary raise
  `UnsupportedGeometryError` with a readable message. Their raw WKB is still available
  with `geometry_format="wkb"`, and their header envelope still drives `bbox` filters.
- Low-level API: `gpkglite.wkb.loads/dumps`, `gpkglite.encode_geometry`,
  `gpkglite.decode_geometry`, `gpkglite.wkt.dumps`.

Geometries are plain dictionaries. Positions have 2, 3 or 4 numbers; a measured geometry
carries `"dims": "XYM"` (or `"XYZM"`) so it can be told apart from XYZ:

```python
{"type": "LineString", "coordinates": [[0, 0, 12.5], [10, 0, 13.0]]}             # XYZ
{"type": "Point", "coordinates": [1, 2, 0.75], "dims": "XYM"}                     # XYM
```

Any object with `__geo_interface__` (shapely, `geojson`, ...) is accepted as input.

### Writing

- `gpkglite.create(path)`: `application_id = 'GPKG'`, `user_version = 10301`, the required
  `gpkg_spatial_ref_sys` rows (-1, 0, EPSG:4326), `gpkg_contents`,
  `gpkg_geometry_columns`. `create(path, wkt2=True)` adds the CRS WKT extension.
- `gpkg.add_srs(...)`: built-in definitions for EPSG 4326, 3857, 4258, 4269 and all
  WGS 84 / UTM zones (32601-32660, 32701-32760); any other CRS with an explicit WKT.
- `gpkg.create_layer(name, geometry_type, srs_id, fields, z=0|1|2, m=0|1|2,
  spatial_index=True)` and `gpkg.create_table(name, fields)` for attribute-only tables.
  Field types: `BOOLEAN, TINYINT, SMALLINT, MEDIUMINT, INT, INTEGER, FLOAT, DOUBLE, REAL,
  TEXT, TEXT(n), BLOB, BLOB(n), DATE, DATETIME`, or Python types (`int`, `str`, `bool`,
  `float`, `bytes`, `datetime.date`, `datetime.datetime`).
- `layer.insert(...)`, `layer.write_features(...)` (one transaction), `layer.update(...)`,
  `layer.delete(fid)`, `layer.delete_where(...)`, `layer.add_field(...)`. Geometry type
  and Z/M constraints of the layer are enforced; `date`/`datetime` values are stored as
  ISO 8601 UTC text, `bool` as 0/1, `dict`/`list` as JSON text.
- The `gpkg_contents` extent grows on every write; `layer.update_extent()` recomputes it.
- RTree spatial index (`rtree_<t>_<c>`) with the standard triggers, registered in
  `gpkg_extensions`. GeoPackage 1.3 trigger set for new files; the 1.4 set
  (`update5`-`update7`) when extending a 1.4 file written by GDAL.
  `ST_MinX/MaxX/MinY/MaxY`, `ST_IsEmpty` and `ST_SRID` are registered on every
  connection so these triggers work from Python.
- Metadata extension: `gpkg.add_metadata(...)`, `gpkg.metadata()`.
- `gpkg.transaction()` groups several operations atomically.

### Validation

`gpkglite.validate(path)` returns a `ValidationReport` (`report.ok`, `report.errors`,
`report.warnings`, `report.as_dict()`), with requirement numbers from the specification:

- SQLite header, `application_id`, `user_version`, `integrity_check`, `foreign_key_check`;
- structure of `gpkg_spatial_ref_sys`, `gpkg_contents`, `gpkg_geometry_columns`,
  `gpkg_extensions`, `gpkg_metadata`, `gpkg_metadata_reference`; mandatory SRS rows;
- references between contents, geometry columns and SRS; timestamps; column types;
  integer primary keys;
- every geometry blob: header, envelope code, ExtendedGeoPackageBinary, SRS id, empty flag,
  WKB, declared geometry type, Z/M constraints, envelope matching the coordinates;
- the RTree extension: registration, columns, triggers for the file's version, and that
  the index holds exactly the non-empty geometries.

### Conversion helpers

```python
gpkglite.from_geojson("roads.geojson", "out.gpkg")                 # layer "roads"
gpkglite.from_csv("stations.csv", "out.gpkg", x="lon", y="lat")    # POINT layer
doc = gpkglite.to_geojson("out.gpkg", "roads", "roads.geojson")
text = gpkglite.to_csv("out.gpkg", "stations", geometry="xy")

# with the [geo] extra
gdf = gpkglite.to_geodataframe("out.gpkg", "roads")
gpkglite.from_geodataframe(gdf.to_crs(32635), "out.gpkg", "roads_utm")
```

`from_geojson` infers the schema and geometry type (mixing `Polygon` and `MultiPolygon`
gives a `MULTIPOLYGON` layer) and honours the legacy `crs` member.

### Command line

```bash
gpkglite info data.gpkg                 # layers, schema, CRS, extent, extensions (--json)
gpkglite dump data.gpkg --layer roads --format geojson
gpkglite dump data.gpkg -l roads -f csv --where "type = 'primary'" --bbox 29,41,29.1,41.1
gpkglite dump data.gpkg -l roads -f jsonl --limit 100 --offset 200 -o page3.jsonl
gpkglite validate data.gpkg             # exit status 1 when invalid (--json)
gpkglite from-geojson in.geojson -o out.gpkg [--layer name] [--srs-id 3857]
gpkglite from-csv points.csv -o out.gpkg -x lon -y lat
```

## Interoperability

The test-suite cross-checks with GDAL:

- files written by gpkglite are read by `pyogrio` (GDAL 3.12 wheel) and, when available,
  by `ogrinfo` and GDAL's `validate_gpkg.py`;
- the committed fixtures in `tests/data` were written by `ogr2ogr` (GDAL 3.13,
  GeoPackage 1.4) and cover all geometry types, Z/M, empty and NULL geometries,
  DATE/DATETIME/BOOLEAN fields and curves; gpkglite reads them and writes into them
  through GDAL's 1.4 RTree triggers;
- QGIS 3 (OGR provider) loads every layer type gpkglite writes.

## Scope and limitations

- Vector features and attribute tables only: no tiles, gridded coverage, or related
  tables extensions.
- Curve geometries are recognised but not decoded.
- No coordinate transformation: coordinates are stored in the layer CRS as given.
- `where` and `order_by` are inserted into SQL verbatim; never build them from untrusted
  input. Bind values through `params`.
- SQLite must provide the R\*Tree module for spatial indexes (true for CPython on
  Windows, macOS and mainstream Linux distributions, and for Pyodide).

## Development

```bash
pip install -e ".[dev,geo]"
pytest --cov=gpkglite
ruff check . && ruff format --check src tests && mypy src
```

Set `GPKGLITE_OGRINFO` (path to `ogrinfo`) and `GPKGLITE_VALIDATE_GPKG` (command that runs
GDAL's `validate_gpkg.py`) to enable the optional GDAL cross-validation tests.

## License

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