gpkglite 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.
gpkglite/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """gpkglite: read and write OGC GeoPackage files with the Python standard library.
2
+
3
+ No GDAL, no compiled dependencies: only :mod:`sqlite3`, :mod:`struct` and
4
+ :mod:`json`. Geometries are GeoJSON-like dictionaries.
5
+
6
+ Quick start::
7
+
8
+ import gpkglite
9
+
10
+ with gpkglite.create("cities.gpkg") as gpkg:
11
+ layer = gpkg.create_layer("cities", "POINT", 4326, {"name": "TEXT", "pop": "INTEGER"})
12
+ layer.insert({"type": "Point", "coordinates": [28.97, 41.01]}, {"name": "Istanbul", "pop": 15_600_000})
13
+
14
+ with gpkglite.open("cities.gpkg") as gpkg:
15
+ for feature in gpkg["cities"].features(where="pop > ?", params=[1_000_000]):
16
+ print(feature["properties"]["name"], feature["geometry"]["coordinates"])
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ from . import binary, wkb, wkt
24
+ from .binary import GeoPackageGeometry
25
+ from .binary import decode as decode_geometry
26
+ from .binary import encode as encode_geometry
27
+ from .convert import (
28
+ from_csv,
29
+ from_geodataframe,
30
+ from_geojson,
31
+ infer_fields,
32
+ infer_geometry_type,
33
+ to_csv,
34
+ to_geodataframe,
35
+ to_geojson,
36
+ )
37
+ from .crs import SpatialRefSys
38
+ from .errors import (
39
+ GeometryError,
40
+ GeoPackageError,
41
+ GpkgliteError,
42
+ LayerNotFoundError,
43
+ UnsupportedGeometryError,
44
+ )
45
+ from .geopackage import Column, GeoPackage, Layer, LayerInfo, create, open
46
+ from .validate import Issue, ValidationReport, validate
47
+
48
+ __all__ = [
49
+ "__version__",
50
+ "Column",
51
+ "GeoPackage",
52
+ "GeoPackageError",
53
+ "GeoPackageGeometry",
54
+ "GeometryError",
55
+ "GpkgliteError",
56
+ "Issue",
57
+ "Layer",
58
+ "LayerInfo",
59
+ "LayerNotFoundError",
60
+ "SpatialRefSys",
61
+ "UnsupportedGeometryError",
62
+ "ValidationReport",
63
+ "binary",
64
+ "create",
65
+ "decode_geometry",
66
+ "encode_geometry",
67
+ "from_csv",
68
+ "from_geodataframe",
69
+ "from_geojson",
70
+ "infer_fields",
71
+ "infer_geometry_type",
72
+ "open",
73
+ "to_csv",
74
+ "to_geodataframe",
75
+ "to_geojson",
76
+ "validate",
77
+ "wkb",
78
+ "wkt",
79
+ ]
gpkglite/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m gpkglite`` entry point."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
gpkglite/_sql.py ADDED
@@ -0,0 +1,187 @@
1
+ """SQL definitions from OGC 12-128r18 (Annex C) and the RTree extension (Annex F.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ #: ``PRAGMA application_id`` value: the ASCII bytes "GPKG".
8
+ APPLICATION_ID = 0x47504B47
9
+ #: ``PRAGMA user_version`` written by gpkglite: GeoPackage 1.3.1 (12-128r18).
10
+ USER_VERSION = 10301
11
+
12
+ TIMESTAMP_DEFAULT = "strftime('%Y-%m-%dT%H:%M:%fZ','now')"
13
+
14
+ RTREE_EXTENSION = "http://www.geopackage.org/spec120/#extension_rtree"
15
+ METADATA_EXTENSION = "http://www.geopackage.org/spec120/#extension_metadata"
16
+ CRS_WKT_EXTENSION = "http://www.geopackage.org/spec120/#extension_crs_wkt"
17
+
18
+ SPATIAL_REF_SYS = """
19
+ CREATE TABLE gpkg_spatial_ref_sys (
20
+ srs_name TEXT NOT NULL,
21
+ srs_id INTEGER NOT NULL PRIMARY KEY,
22
+ organization TEXT NOT NULL,
23
+ organization_coordsys_id INTEGER NOT NULL,
24
+ definition TEXT NOT NULL,
25
+ description TEXT{extra}
26
+ )"""
27
+
28
+ CONTENTS = f"""
29
+ CREATE TABLE gpkg_contents (
30
+ table_name TEXT NOT NULL PRIMARY KEY,
31
+ data_type TEXT NOT NULL,
32
+ identifier TEXT UNIQUE,
33
+ description TEXT DEFAULT '',
34
+ last_change DATETIME NOT NULL DEFAULT ({TIMESTAMP_DEFAULT}),
35
+ min_x DOUBLE,
36
+ min_y DOUBLE,
37
+ max_x DOUBLE,
38
+ max_y DOUBLE,
39
+ srs_id INTEGER,
40
+ CONSTRAINT fk_gc_r_srs_id FOREIGN KEY (srs_id) REFERENCES gpkg_spatial_ref_sys(srs_id)
41
+ )"""
42
+
43
+ GEOMETRY_COLUMNS = """
44
+ CREATE TABLE gpkg_geometry_columns (
45
+ table_name TEXT NOT NULL,
46
+ column_name TEXT NOT NULL,
47
+ geometry_type_name TEXT NOT NULL,
48
+ srs_id INTEGER NOT NULL,
49
+ z TINYINT NOT NULL,
50
+ m TINYINT NOT NULL,
51
+ CONSTRAINT pk_geom_cols PRIMARY KEY (table_name, column_name),
52
+ CONSTRAINT uk_gc_table_name UNIQUE (table_name),
53
+ CONSTRAINT fk_gc_tn FOREIGN KEY (table_name) REFERENCES gpkg_contents(table_name),
54
+ CONSTRAINT fk_gc_srs FOREIGN KEY (srs_id) REFERENCES gpkg_spatial_ref_sys (srs_id)
55
+ )"""
56
+
57
+ EXTENSIONS = """
58
+ CREATE TABLE gpkg_extensions (
59
+ table_name TEXT,
60
+ column_name TEXT,
61
+ extension_name TEXT NOT NULL,
62
+ definition TEXT NOT NULL,
63
+ scope TEXT NOT NULL,
64
+ CONSTRAINT ge_tce UNIQUE (table_name, column_name, extension_name)
65
+ )"""
66
+
67
+ METADATA = """
68
+ CREATE TABLE gpkg_metadata (
69
+ id INTEGER CONSTRAINT m_pk PRIMARY KEY ASC NOT NULL,
70
+ md_scope TEXT NOT NULL DEFAULT 'dataset',
71
+ md_standard_uri TEXT NOT NULL,
72
+ mime_type TEXT NOT NULL DEFAULT 'text/xml',
73
+ metadata TEXT NOT NULL DEFAULT ''
74
+ )"""
75
+
76
+ METADATA_REFERENCE = f"""
77
+ CREATE TABLE gpkg_metadata_reference (
78
+ reference_scope TEXT NOT NULL,
79
+ table_name TEXT,
80
+ column_name TEXT,
81
+ row_id_value INTEGER,
82
+ timestamp DATETIME NOT NULL DEFAULT ({TIMESTAMP_DEFAULT}),
83
+ md_file_id INTEGER NOT NULL,
84
+ md_parent_id INTEGER,
85
+ CONSTRAINT crmr_mfi_fk FOREIGN KEY (md_file_id) REFERENCES gpkg_metadata(id),
86
+ CONSTRAINT crmr_mpi_fk FOREIGN KEY (md_parent_id) REFERENCES gpkg_metadata(id)
87
+ )"""
88
+
89
+
90
+ def quote_ident(name: str) -> str:
91
+ """Quote an SQL identifier (table or column name)."""
92
+ return '"' + str(name).replace('"', '""') + '"'
93
+
94
+
95
+ def rtree_name(table: str, column: str) -> str:
96
+ return f"rtree_{table}_{column}"
97
+
98
+
99
+ def _bbox_values(c: str) -> str:
100
+ return f"ST_MinX(NEW.{c}), ST_MaxX(NEW.{c}), ST_MinY(NEW.{c}), ST_MaxY(NEW.{c})"
101
+
102
+
103
+ def rtree_triggers(table: str, column: str, fid: str, version: int = USER_VERSION) -> List[str]:
104
+ """``CREATE TRIGGER`` statements maintaining ``rtree_<table>_<column>``.
105
+
106
+ GeoPackage 1.4 (``user_version >= 10400``) replaced the ``update1`` trigger
107
+ with ``update6``/``update7`` and renamed ``update3`` to ``update5``; earlier
108
+ versions use ``insert``, ``update1``-``update4`` and ``delete``.
109
+ """
110
+ base = rtree_name(table, column)
111
+ rt = quote_ident(base)
112
+ t = quote_ident(table)
113
+ c = quote_ident(column)
114
+ i = quote_ident(fid)
115
+ vals = _bbox_values(c)
116
+
117
+ def name(suffix: str) -> str:
118
+ return quote_ident(f"{base}_{suffix}")
119
+
120
+ present = f"(NEW.{c} NOTNULL AND NOT ST_IsEmpty(NEW.{c}))"
121
+ absent = f"(NEW.{c} ISNULL OR ST_IsEmpty(NEW.{c}))"
122
+ insert = (
123
+ f"CREATE TRIGGER {name('insert')} AFTER INSERT ON {t} "
124
+ f"WHEN (new.{c} NOT NULL AND NOT ST_IsEmpty(NEW.{c})) "
125
+ f"BEGIN INSERT OR REPLACE INTO {rt} VALUES (NEW.{i}, {vals}); END"
126
+ )
127
+ same_id_absent = (
128
+ f"CREATE TRIGGER {name('update2')} AFTER UPDATE OF {c} ON {t} "
129
+ f"WHEN OLD.{i} = NEW.{i} AND {absent} "
130
+ f"BEGIN DELETE FROM {rt} WHERE id = OLD.{i}; END"
131
+ )
132
+ new_id_absent = (
133
+ f"CREATE TRIGGER {name('update4')} AFTER UPDATE ON {t} "
134
+ f"WHEN OLD.{i} != NEW.{i} AND {absent} "
135
+ f"BEGIN DELETE FROM {rt} WHERE id IN (OLD.{i}, NEW.{i}); END"
136
+ )
137
+ delete = (
138
+ f"CREATE TRIGGER {name('delete')} AFTER DELETE ON {t} "
139
+ f"WHEN old.{c} NOT NULL "
140
+ f"BEGIN DELETE FROM {rt} WHERE id = OLD.{i}; END"
141
+ )
142
+
143
+ def new_id_present(suffix: str) -> str:
144
+ return (
145
+ f"CREATE TRIGGER {name(suffix)} AFTER UPDATE ON {t} "
146
+ f"WHEN OLD.{i} != NEW.{i} AND {present} "
147
+ f"BEGIN DELETE FROM {rt} WHERE id = OLD.{i}; "
148
+ f"INSERT OR REPLACE INTO {rt} VALUES (NEW.{i}, {vals}); END"
149
+ )
150
+
151
+ if version < 10400:
152
+ update1 = (
153
+ f"CREATE TRIGGER {name('update1')} AFTER UPDATE OF {c} ON {t} "
154
+ f"WHEN OLD.{i} = NEW.{i} AND {present} "
155
+ f"BEGIN INSERT OR REPLACE INTO {rt} VALUES (NEW.{i}, {vals}); END"
156
+ )
157
+ return [insert, update1, same_id_absent, new_id_present("update3"), new_id_absent, delete]
158
+
159
+ old_present = f"(OLD.{c} NOTNULL AND NOT ST_IsEmpty(OLD.{c}))"
160
+ old_absent = f"(OLD.{c} ISNULL OR ST_IsEmpty(OLD.{c}))"
161
+ update6 = (
162
+ f"CREATE TRIGGER {name('update6')} AFTER UPDATE OF {c} ON {t} "
163
+ f"WHEN OLD.{i} = NEW.{i} AND {present} AND {old_present} "
164
+ f"BEGIN UPDATE {rt} SET minx = ST_MinX(NEW.{c}), maxx = ST_MaxX(NEW.{c}), "
165
+ f"miny = ST_MinY(NEW.{c}), maxy = ST_MaxY(NEW.{c}) WHERE id = NEW.{i}; END"
166
+ )
167
+ update7 = (
168
+ f"CREATE TRIGGER {name('update7')} AFTER UPDATE OF {c} ON {t} "
169
+ f"WHEN OLD.{i} = NEW.{i} AND {present} AND {old_absent} "
170
+ f"BEGIN INSERT INTO {rt} VALUES (NEW.{i}, {vals}); END"
171
+ )
172
+ return [
173
+ insert,
174
+ same_id_absent,
175
+ new_id_absent,
176
+ new_id_present("update5"),
177
+ update6,
178
+ update7,
179
+ delete,
180
+ ]
181
+
182
+
183
+ #: Trigger name suffixes expected for a given GeoPackage version.
184
+ def rtree_trigger_suffixes(version: int) -> List[str]:
185
+ if version < 10400:
186
+ return ["insert", "update1", "update2", "update3", "update4", "delete"]
187
+ return ["insert", "update2", "update4", "update5", "update6", "update7", "delete"]
gpkglite/binary.py ADDED
@@ -0,0 +1,219 @@
1
+ """StandardGeoPackageBinary encoding and decoding (OGC 12-128r18, clause 2.1.3).
2
+
3
+ Layout of a geometry blob::
4
+
5
+ magic 2 bytes "GP"
6
+ version 1 byte 0 (version 1)
7
+ flags 1 byte bit 0: header byte order (1 = little endian)
8
+ bits 1-3: envelope contents indicator (0-4)
9
+ bit 4: empty geometry flag
10
+ bit 5: 0 = StandardGeoPackageBinary, 1 = Extended
11
+ srs_id int32
12
+ envelope 0/32/48/48/64 bytes of doubles
13
+ geometry ISO WKB
14
+
15
+ Envelope indicator codes: 0 = none, 1 = [minx, maxx, miny, maxy],
16
+ 2 = + [minz, maxz], 3 = + [minm, maxm], 4 = + [minz, maxz, minm, maxm].
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import struct
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Dict, Optional, Tuple, Union
25
+
26
+ from . import wkb as _wkb
27
+ from .errors import GeometryError, UnsupportedGeometryError
28
+
29
+ __all__ = [
30
+ "ENVELOPE_SIZES",
31
+ "ENVELOPE_NAMES",
32
+ "GeoPackageGeometry",
33
+ "Header",
34
+ "decode",
35
+ "decode_header",
36
+ "encode",
37
+ ]
38
+
39
+ #: Number of doubles stored for each envelope indicator code.
40
+ ENVELOPE_SIZES: Dict[int, int] = {0: 0, 1: 4, 2: 6, 3: 6, 4: 8}
41
+ #: Accepted names for the ``envelope`` argument of :func:`encode`.
42
+ ENVELOPE_NAMES: Dict[str, int] = {"none": 0, "xy": 1, "xyz": 2, "xym": 3, "xyzm": 4}
43
+
44
+ MAGIC = b"GP"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Header:
49
+ """Parsed GeoPackage binary header."""
50
+
51
+ version: int
52
+ flags: int
53
+ byte_order: str # "<" or ">"
54
+ envelope_type: int
55
+ empty: bool
56
+ extended: bool
57
+ srs_id: int
58
+ envelope: Tuple[float, ...]
59
+ size: int # header length in bytes, i.e. offset of the WKB payload
60
+
61
+ @property
62
+ def envelope_dict(self) -> Dict[str, float]:
63
+ """The envelope as ``{"minx": ..., "maxx": ..., ...}``."""
64
+ keys = {
65
+ 0: (),
66
+ 1: ("minx", "maxx", "miny", "maxy"),
67
+ 2: ("minx", "maxx", "miny", "maxy", "minz", "maxz"),
68
+ 3: ("minx", "maxx", "miny", "maxy", "minm", "maxm"),
69
+ 4: ("minx", "maxx", "miny", "maxy", "minz", "maxz", "minm", "maxm"),
70
+ }[self.envelope_type]
71
+ return dict(zip(keys, self.envelope))
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class GeoPackageGeometry:
76
+ """A decoded GeoPackage geometry blob."""
77
+
78
+ header: Header
79
+ wkb: bytes = field(repr=False)
80
+ geometry: Optional[Dict[str, Any]] # None only when decode(..., parse_wkb=False)
81
+
82
+ @property
83
+ def srs_id(self) -> int:
84
+ return self.header.srs_id
85
+
86
+ @property
87
+ def empty(self) -> bool:
88
+ return self.header.empty
89
+
90
+ @property
91
+ def bounds(self) -> Optional[Tuple[float, float, float, float]]:
92
+ """2D bounds ``(minx, miny, maxx, maxy)`` from the header envelope or WKB."""
93
+ env = self.header.envelope
94
+ if self.header.envelope_type and not any(math.isnan(v) for v in env[:4]):
95
+ return (env[0], env[2], env[1], env[3])
96
+ if self.geometry is None:
97
+ return _wkb.bounds(_wkb.loads(self.wkb))
98
+ return _wkb.bounds(self.geometry)
99
+
100
+
101
+ def decode_header(blob: Union[bytes, bytearray, memoryview]) -> Header:
102
+ """Parse only the header of a GeoPackage geometry blob."""
103
+ data = bytes(blob)
104
+ if len(data) < 8:
105
+ raise GeometryError(f"GeoPackage geometry blob too short ({len(data)} bytes)")
106
+ if data[:2] != MAGIC:
107
+ raise GeometryError(f"bad GeoPackage geometry magic {data[:2]!r}, expected b'GP'")
108
+ version = data[2]
109
+ if version != 0:
110
+ raise GeometryError(f"unsupported GeoPackage binary version {version}")
111
+ flags = data[3]
112
+ if flags & 0xC0:
113
+ raise GeometryError(f"reserved flag bits set in GeoPackage header flags {flags:#04x}")
114
+ bo = "<" if flags & 1 else ">"
115
+ env_type = (flags >> 1) & 0x07
116
+ if env_type not in ENVELOPE_SIZES:
117
+ raise GeometryError(f"invalid envelope contents indicator code {env_type}")
118
+ empty = bool(flags & 0x10)
119
+ extended = bool(flags & 0x20)
120
+ (srs_id,) = struct.unpack_from(bo + "i", data, 4)
121
+ n = ENVELOPE_SIZES[env_type]
122
+ size = 8 + 8 * n
123
+ if len(data) < size:
124
+ raise GeometryError("GeoPackage geometry blob truncated inside the envelope")
125
+ env = struct.unpack_from(f"{bo}{n}d", data, 8) if n else ()
126
+ return Header(version, flags, bo, env_type, empty, extended, int(srs_id), env, size)
127
+
128
+
129
+ def decode(blob: Union[bytes, bytearray, memoryview], parse_wkb: bool = True) -> GeoPackageGeometry:
130
+ """Decode a GeoPackage geometry blob.
131
+
132
+ Args:
133
+ blob: the BLOB stored in a feature table geometry column.
134
+ parse_wkb: when ``False`` only the header is parsed and
135
+ :attr:`GeoPackageGeometry.geometry` is ``None``.
136
+
137
+ Raises:
138
+ GeometryError: malformed header or WKB.
139
+ UnsupportedGeometryError: ExtendedGeoPackageBinary or curve geometry.
140
+ """
141
+ data = bytes(blob)
142
+ header = decode_header(data)
143
+ if header.extended:
144
+ raise UnsupportedGeometryError(
145
+ "ExtendedGeoPackageBinary geometries (flag bit 5) carry extension-specific "
146
+ "encodings that gpkglite does not decode"
147
+ )
148
+ payload = data[header.size :]
149
+ geometry = _wkb.loads(payload) if parse_wkb else None
150
+ return GeoPackageGeometry(header=header, wkb=payload, geometry=geometry)
151
+
152
+
153
+ def encode(
154
+ geom: Any,
155
+ srs_id: int = 0,
156
+ envelope: Union[str, int] = "auto",
157
+ byte_order: str = "<",
158
+ dims: Optional[str] = None,
159
+ ) -> bytes:
160
+ """Encode a geometry as a StandardGeoPackageBinary blob.
161
+
162
+ Args:
163
+ geom: geometry dictionary, ``__geo_interface__`` object, or raw WKB
164
+ ``bytes`` (re-wrapped without re-encoding).
165
+ srs_id: value of the header ``srs_id`` field.
166
+ envelope: ``"auto"`` (no envelope for points, which the spec
167
+ recommends, XY envelope otherwise), ``"none"``, ``"xy"``, ``"xyz"``,
168
+ ``"xym"``, ``"xyzm"`` or an indicator code 0-4. Empty geometries are
169
+ always written without an envelope and with the empty flag set.
170
+ byte_order: byte order of header and WKB, ``"<"`` or ``">"``.
171
+ dims: force the coordinate dimension of the WKB.
172
+ """
173
+ if byte_order not in ("<", ">"):
174
+ raise ValueError("byte_order must be '<' or '>'")
175
+ if isinstance(geom, (bytes, bytearray, memoryview)):
176
+ payload = bytes(geom)
177
+ g = _wkb.loads(payload)
178
+ else:
179
+ g = _wkb.normalize(geom)
180
+ if dims is not None:
181
+ g["dims"] = dims.upper()
182
+ payload = _wkb.dumps(g, byte_order=byte_order)
183
+ empty = _wkb.is_empty(g)
184
+ env_type = _envelope_code(envelope, g, empty)
185
+ env_values: Tuple[float, ...] = ()
186
+ if env_type:
187
+ full = _wkb.envelope(g)
188
+ assert full is not None # non-empty
189
+ has_z, has_m = _wkb.geometry_dims(g)
190
+ if env_type in (2, 4) and not has_z:
191
+ raise GeometryError("a Z envelope was requested for a geometry without Z")
192
+ if env_type in (3, 4) and not has_m:
193
+ raise GeometryError("an M envelope was requested for a geometry without M")
194
+ xy = full[:4]
195
+ z = full[4:6] if has_z else ()
196
+ m = full[-2:] if has_m else ()
197
+ env_values = xy + {1: (), 2: z, 3: m, 4: z + m}[env_type]
198
+ flags = (1 if byte_order == "<" else 0) | (env_type << 1) | (0x10 if empty else 0)
199
+ head = MAGIC + struct.pack(byte_order + "BBi", 0, flags, int(srs_id))
200
+ if env_values:
201
+ head += struct.pack(f"{byte_order}{len(env_values)}d", *env_values)
202
+ return head + payload
203
+
204
+
205
+ def _envelope_code(envelope: Union[str, int], g: Dict[str, Any], empty: bool) -> int:
206
+ if empty:
207
+ return 0
208
+ if isinstance(envelope, int) and not isinstance(envelope, bool):
209
+ if envelope not in ENVELOPE_SIZES:
210
+ raise ValueError(f"invalid envelope indicator code {envelope}")
211
+ return envelope
212
+ key = str(envelope).lower()
213
+ if key == "auto":
214
+ return 0 if g["type"] == "Point" else 1
215
+ if key not in ENVELOPE_NAMES:
216
+ raise ValueError(
217
+ f"invalid envelope {envelope!r}; expected auto, {', '.join(ENVELOPE_NAMES)}"
218
+ )
219
+ return ENVELOPE_NAMES[key]