vecorel-cli 0.2.17__tar.gz → 0.2.18__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.
Files changed (84) hide show
  1. {vecorel_cli-0.2.17/vecorel_cli.egg-info → vecorel_cli-0.2.18}/PKG-INFO +2 -1
  2. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/pyproject.toml +2 -1
  3. vecorel_cli-0.2.18/tests/test_convert_duckdb.py +220 -0
  4. vecorel_cli-0.2.18/tests/test_encoding_geoparquet.py +125 -0
  5. vecorel_cli-0.2.18/tests/test_hilbert.py +89 -0
  6. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/const.py +5 -0
  7. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/conversion/base.py +157 -5
  8. vecorel_cli-0.2.18/vecorel_cli/conversion/duckdb.py +423 -0
  9. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/base.py +10 -0
  10. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/geoparquet.py +237 -4
  11. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/parquet/types.py +34 -0
  12. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validate_schema.py +2 -1
  13. vecorel_cli-0.2.18/vecorel_cli/vecorel/hilbert.py +310 -0
  14. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/util.py +4 -2
  15. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18/vecorel_cli.egg-info}/PKG-INFO +2 -1
  16. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/SOURCES.txt +3 -0
  17. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/requires.txt +1 -0
  18. vecorel_cli-0.2.17/tests/test_encoding_geoparquet.py +0 -48
  19. vecorel_cli-0.2.17/vecorel_cli/vecorel/hilbert.py +0 -142
  20. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/LICENSE +0 -0
  21. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/README.md +0 -0
  22. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/setup.cfg +0 -0
  23. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_convert.py +0 -0
  24. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_converters.py +0 -0
  25. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_create_geojson.py +0 -0
  26. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_create_geoparquet.py +0 -0
  27. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_create_stac.py +0 -0
  28. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_describe.py +0 -0
  29. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_encoding_auto.py +0 -0
  30. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_encoding_geojson.py +0 -0
  31. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_improve.py +0 -0
  32. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_jsonschema.py +0 -0
  33. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_merge.py +0 -0
  34. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_ops.py +0 -0
  35. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_rename_extension.py +0 -0
  36. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_validate.py +0 -0
  37. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_validate_schema.py +0 -0
  38. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/__init__.py +0 -0
  39. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/basecommand.py +0 -0
  40. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/__init__.py +0 -0
  41. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/logger.py +0 -0
  42. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/options.py +0 -0
  43. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/path_url.py +0 -0
  44. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/setup.py +0 -0
  45. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/util.py +0 -0
  46. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/conversion/__init__.py +0 -0
  47. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/conversion/admin.py +0 -0
  48. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/conversion/flatdict.py +0 -0
  49. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/convert.py +0 -0
  50. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/converters.py +0 -0
  51. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/create_geojson.py +0 -0
  52. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/create_geoparquet.py +0 -0
  53. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/create_jsonschema.py +0 -0
  54. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/create_stac.py +0 -0
  55. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/datasets/__init__.py +0 -0
  56. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/datasets/template.py +0 -0
  57. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/describe.py +0 -0
  58. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/__init__.py +0 -0
  59. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/auto.py +0 -0
  60. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/geojson.py +0 -0
  61. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/improve.py +0 -0
  62. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/jsonschema/__init__.py +0 -0
  63. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/jsonschema/template.py +0 -0
  64. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/merge.py +0 -0
  65. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/parquet/__init__.py +0 -0
  66. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/parquet/geopandas.py +0 -0
  67. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/registry.py +0 -0
  68. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/rename_extension.py +0 -0
  69. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validate.py +0 -0
  70. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/__init__.py +0 -0
  71. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/base.py +0 -0
  72. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/data.py +0 -0
  73. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/geojson.py +0 -0
  74. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/geoparquet.py +0 -0
  75. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/__init__.py +0 -0
  76. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/collection.py +0 -0
  77. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/extensions.py +0 -0
  78. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/ops.py +0 -0
  79. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/schemas.py +0 -0
  80. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/typing.py +0 -0
  81. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/version.py +0 -0
  82. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/dependency_links.txt +0 -0
  83. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/entry_points.txt +0 -0
  84. {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vecorel-cli
3
- Version: 0.2.17
3
+ Version: 0.2.18
4
4
  Summary: CLI tools such as validation and file format conversion for vecorel.
5
5
  Author: Matthias Mohr
6
6
  License-Expression: Apache-2.0
@@ -32,6 +32,7 @@ Requires-Dist: rarfile<5.0,>=4.0
32
32
  Requires-Dist: semantic-version<3.0,>=2.10.0
33
33
  Requires-Dist: json-stream<3.0,>=2.3.0
34
34
  Requires-Dist: loguru==0.7.3
35
+ Requires-Dist: duckdb<2.0,>=1.4
35
36
  Provides-Extra: s3
36
37
  Requires-Dist: s3fs>=2025.7.0; extra == "s3"
37
38
  Provides-Extra: gcs
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vecorel-cli"
7
- version = "0.2.17"
7
+ version = "0.2.18"
8
8
  description = "CLI tools such as validation and file format conversion for vecorel."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -38,6 +38,7 @@ dependencies = [
38
38
  "semantic-version>=2.10.0,<3.0",
39
39
  "json-stream>=2.3.0,<3.0",
40
40
  "loguru==0.7.3",
41
+ "duckdb>=1.4,<2.0",
41
42
  ]
42
43
 
43
44
  [project.optional-dependencies]
@@ -0,0 +1,220 @@
1
+ import json
2
+
3
+ import geopandas as gpd
4
+ import numpy as np
5
+ import pyarrow.parquet as pq
6
+ import pytest
7
+ import shapely
8
+
9
+ from vecorel_cli.conversion.base import BaseConverter
10
+ from vecorel_cli.conversion.duckdb import DuckDBBaseConverter
11
+ from vecorel_cli.validate import ValidateData
12
+ from vecorel_cli.vecorel.hilbert import hilbert_keys_for_table
13
+
14
+ # One shared converter configuration, so the two codepaths cannot drift apart.
15
+ # column_filters and column_migrations must stay out of it: they are Python
16
+ # callables in the GeoDataFrame-based codepath and SQL fragments in the
17
+ # DuckDB-based one.
18
+ CONFIG = {
19
+ "id": "test",
20
+ "short_name": "Test",
21
+ "title": "Test dataset",
22
+ "description": "Test dataset",
23
+ "license": "CC0-1.0",
24
+ "columns": {
25
+ "geometry": "geometry",
26
+ "id": "id",
27
+ "name": "name",
28
+ },
29
+ "missing_schemas": {
30
+ "properties": {
31
+ "name": {"type": "string"},
32
+ }
33
+ },
34
+ }
35
+
36
+ Converter = type("Converter", (DuckDBBaseConverter,), dict(CONFIG))
37
+ PandasConverter = type("PandasConverter", (BaseConverter,), dict(CONFIG))
38
+
39
+
40
+ def _source_file(folder):
41
+ """A source with everything the geometry handling must fix:
42
+ a multi-part geometry, an invalid bowtie, a Z polygon, and a point."""
43
+ square = shapely.Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])
44
+ multi = shapely.MultiPolygon(
45
+ [
46
+ shapely.Polygon([(2, 0), (2, 1), (3, 1), (3, 0)]),
47
+ shapely.Polygon([(4, 0), (4, 1), (5, 1), (5, 0)]),
48
+ ]
49
+ )
50
+ bowtie = shapely.Polygon([(6, 0), (7, 1), (7, 0), (6, 1)])
51
+ with_z = shapely.Polygon([(8, 0, 5), (8, 1, 5), (9, 1, 5), (9, 0, 5)])
52
+ point = shapely.Point(10, 0)
53
+
54
+ gdf = gpd.GeoDataFrame(
55
+ {
56
+ "id": ["square", "multi", "bowtie", "with_z", "point"],
57
+ "name": ["a", "b", "c", "d", "e"],
58
+ "geometry": [square, multi, bowtie, with_z, point],
59
+ },
60
+ crs="EPSG:4326",
61
+ )
62
+ path = folder / "source.parquet"
63
+ gdf.to_parquet(path)
64
+ return str(path)
65
+
66
+
67
+ def test_duckdb_converter(tmp_folder):
68
+ src = _source_file(tmp_folder)
69
+ dest = tmp_folder / "converted.parquet"
70
+
71
+ Converter().convert(dest, input_files={src: "source.parquet"})
72
+
73
+ result = gpd.read_parquet(dest)
74
+ # multi is split in two, bowtie is repaired into two valid polygons,
75
+ # the point is dropped and the Z dimension is removed
76
+ assert sorted(result["id"]) == ["bowtie", "bowtie", "multi", "multi", "square", "with_z"]
77
+ assert set(result.geometry.geom_type) == {"Polygon"}
78
+ assert result.geometry.is_valid.all()
79
+ assert not result.geometry.has_z.any()
80
+
81
+ with pq.ParquetFile(dest) as pf:
82
+ schema = pf.schema_arrow
83
+ field = schema.field("geometry")
84
+ assert str(field.type) == "binary"
85
+ assert not field.nullable
86
+ field = schema.field("id")
87
+ assert str(field.type) == "string"
88
+ assert not field.nullable
89
+ assert str(schema.field("name").type) == "string"
90
+ assert "bbox" in schema.names
91
+
92
+ # sorted against the CRS-derived Hilbert grid
93
+ with pq.ParquetFile(dest) as pf:
94
+ table = pf.read()
95
+ keys = hilbert_keys_for_table(table, "geometry", (-180.0, -90.0, 180.0, 90.0))
96
+ assert bool(np.all(keys[1:] >= keys[:-1]))
97
+
98
+ validation = ValidateData().validate(dest, num=100, schema_map={})
99
+ assert validation.errors == []
100
+
101
+
102
+ def test_duckdb_converter_index_as_id(tmp_folder):
103
+ src = _source_file(tmp_folder)
104
+ dest = tmp_folder / "converted.parquet"
105
+
106
+ IndexConverter = type("IndexConverter", (DuckDBBaseConverter,), {**CONFIG, "index_as_id": True})
107
+ IndexConverter().convert(dest, input_files={src: "source.parquet"})
108
+
109
+ result = gpd.read_parquet(dest)
110
+ # row numbers are assigned before geometries are split,
111
+ # so the parts of one source feature share an id (like the default codepath)
112
+ assert sorted(result["id"]) == ["0", "1", "1", "2", "2", "3"]
113
+
114
+
115
+ def test_duckdb_converter_source_crs(tmp_folder):
116
+ src1 = _source_file(tmp_folder)
117
+ dest = tmp_folder / "converted.parquet"
118
+
119
+ # the same CRS, declared as a differently rendered PROJJSON object
120
+ table = pq.read_table(src1)
121
+ metadata = dict(table.schema.metadata)
122
+ geo = json.loads(metadata[b"geo"])
123
+ crs = geo["columns"]["geometry"]["crs"]
124
+ crs.pop("scope", None)
125
+ crs.pop("area", None)
126
+ crs["$schema"] = "https://proj.org/schemas/v0.5/projjson.schema.json"
127
+ metadata[b"geo"] = json.dumps(geo).encode()
128
+ src2 = str(tmp_folder / "source2.parquet")
129
+ pq.write_table(table.replace_schema_metadata(metadata), src2)
130
+
131
+ Converter().convert(dest, input_files={src1: "a.parquet", src2: "b.parquet"})
132
+ assert len(gpd.read_parquet(dest)) == 12
133
+
134
+ src3 = str(tmp_folder / "source3.parquet")
135
+ gpd.read_parquet(src1).to_crs("EPSG:3857").to_parquet(src3)
136
+ with pytest.raises(ValueError, match="different coordinate reference"):
137
+ Converter().convert(dest, input_files={src1: "a.parquet", src3: "c.parquet"})
138
+
139
+
140
+ def test_duckdb_converter_original_geometries(tmp_folder):
141
+ src = _source_file(tmp_folder)
142
+ dest = tmp_folder / "converted.parquet"
143
+
144
+ Converter().convert(dest, input_files={src: "source.parquet"}, original_geometries=True)
145
+
146
+ result = gpd.read_parquet(dest)
147
+ assert len(result) == 5
148
+ assert set(result.geometry.geom_type) == {"Polygon", "MultiPolygon", "Point"}
149
+
150
+
151
+ def test_codepath_parity(tmp_folder):
152
+ """The GeoDataFrame-based and the DuckDB-based codepaths must produce
153
+ comparable files from the same source and converter configuration:
154
+ same schema, same rows in the same order, same key metadata, same packaging.
155
+ """
156
+ src = _source_file(tmp_folder)
157
+ kwargs = {
158
+ "input_files": {src: "source.parquet"},
159
+ "compression": "zstd",
160
+ "geoparquet_version": "1.1.0",
161
+ }
162
+ pandas_dest = tmp_folder / "pandas.parquet"
163
+ duckdb_dest = tmp_folder / "duckdb.parquet"
164
+ PandasConverter().convert(pandas_dest, **kwargs)
165
+ Converter().convert(duckdb_dest, **kwargs)
166
+
167
+ with pq.ParquetFile(pandas_dest) as pf:
168
+ pandas_schema = pf.schema_arrow
169
+ pandas_table = pf.read()
170
+ pandas_groups = pf.metadata.num_row_groups
171
+ pandas_compression = pf.metadata.row_group(0).column(0).compression
172
+ with pq.ParquetFile(duckdb_dest) as pf:
173
+ duckdb_schema = pf.schema_arrow
174
+ duckdb_table = pf.read()
175
+ duckdb_groups = pf.metadata.num_row_groups
176
+ duckdb_compression = pf.metadata.row_group(0).column(0).compression
177
+
178
+ # Same columns with the same types and nullability (the column order is
179
+ # allowed to differ)
180
+ assert sorted(pandas_schema.names) == sorted(duckdb_schema.names)
181
+ for name in pandas_schema.names:
182
+ f1, f2 = pandas_schema.field(name), duckdb_schema.field(name)
183
+ assert f1.type == f2.type, f"{name}: {f1.type} != {f2.type}"
184
+ assert f1.nullable == f2.nullable, f"{name}: nullability differs"
185
+
186
+ # Same rows in the same (Hilbert) order; the geometries must describe the
187
+ # same shapes, but the WKB may differ in vertex order (different GEOS builds)
188
+ assert pandas_table.num_rows == duckdb_table.num_rows
189
+ assert pandas_table["id"].to_pylist() == duckdb_table["id"].to_pylist()
190
+ assert pandas_table["name"].to_pylist() == duckdb_table["name"].to_pylist()
191
+ pandas_geoms = shapely.from_wkb(pandas_table["geometry"].to_pylist())
192
+ duckdb_geoms = shapely.from_wkb(duckdb_table["geometry"].to_pylist())
193
+ for g1, g2 in zip(pandas_geoms, duckdb_geoms):
194
+ assert shapely.equals(g1, g2), f"{shapely.to_wkt(g1)} != {shapely.to_wkt(g2)}"
195
+
196
+ # Same collection metadata and the same key GeoParquet metadata
197
+ pandas_collection = json.loads(pandas_schema.metadata[b"collection"])
198
+ duckdb_collection = json.loads(duckdb_schema.metadata[b"collection"])
199
+ assert pandas_collection == duckdb_collection
200
+
201
+ pandas_geo = json.loads(pandas_schema.metadata[b"geo"])
202
+ duckdb_geo = json.loads(duckdb_schema.metadata[b"geo"])
203
+ for key in ("version", "primary_column"):
204
+ assert pandas_geo[key] == duckdb_geo[key]
205
+ pandas_column = pandas_geo["columns"]["geometry"]
206
+ duckdb_column = duckdb_geo["columns"]["geometry"]
207
+ for key in ("encoding", "covering", "crs", "bbox"):
208
+ assert pandas_column.get(key) == duckdb_column.get(key), f"geo {key} differs"
209
+ assert sorted(pandas_column.get("geometry_types", [])) == sorted(
210
+ duckdb_column.get("geometry_types", [])
211
+ )
212
+
213
+ # Same packaging
214
+ assert pandas_compression == duckdb_compression
215
+ assert pandas_groups == duckdb_groups
216
+
217
+ # Both validate
218
+ for dest in (pandas_dest, duckdb_dest):
219
+ validation = ValidateData().validate(dest, num=100, schema_map={})
220
+ assert validation.errors == []
@@ -0,0 +1,125 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pyarrow as pa
5
+ import pyarrow.parquet as pq
6
+ import pyarrow.types as pat
7
+
8
+ from vecorel_cli.encoding.geoparquet import GeoParquet
9
+ from vecorel_cli.vecorel.collection import Collection
10
+
11
+
12
+ def test_init_paths(tmp_folder):
13
+ fpath = str(tmp_folder / "test.parquet")
14
+ ppath = Path(fpath)
15
+
16
+ assert GeoParquet(fpath).uri == ppath
17
+ assert GeoParquet(ppath).uri == ppath
18
+
19
+
20
+ def test_get_format():
21
+ assert (
22
+ GeoParquet("tests/data-files/inspire.parquet").get_format() == "GeoParquet, version 1.1.0"
23
+ )
24
+
25
+
26
+ def test_get_collection_exists():
27
+ geojson = GeoParquet("tests/data-files/inspire.parquet")
28
+
29
+ collection = geojson.get_collection()
30
+
31
+ assert isinstance(collection, dict)
32
+ fields = list(collection.keys())
33
+ fields.sort()
34
+ assert fields == ["collection", "determination_datetime", "schemas", "schemas:custom"]
35
+ assert "inspire" in collection["schemas"]
36
+
37
+
38
+ def test_get_collection_does_not_exist():
39
+ collection = GeoParquet("invalid.parquet").get_collection()
40
+ assert isinstance(collection, Collection)
41
+ assert collection.is_empty()
42
+
43
+
44
+ def test_get_collection_returns_existing(tmp_folder):
45
+ file_path = tmp_folder / "test.parquet"
46
+ test_collection = Collection({"test": "data"})
47
+
48
+ geojson = GeoParquet(file_path)
49
+ geojson.collection = test_collection
50
+
51
+ result = geojson.get_collection()
52
+ assert isinstance(result, Collection)
53
+ assert result == test_collection
54
+
55
+
56
+ def test_postprocess(tmp_parquet_file):
57
+ # Degrade a compliant file to what external tools such as DuckDB or GDAL may produce:
58
+ # large_string/large_binary columns, a naive microsecond timestamp, nullable columns,
59
+ # no bbox column, GeoParquet 1.0.0
60
+ src = pq.read_table("tests/data-files/inspire.parquet")
61
+ fields = []
62
+ arrays = []
63
+ for i, field in enumerate(src.schema):
64
+ if field.name == "bbox":
65
+ continue
66
+ if pat.is_string(field.type):
67
+ dtype = pa.large_string()
68
+ elif pat.is_binary(field.type):
69
+ dtype = pa.large_binary()
70
+ else:
71
+ dtype = field.type
72
+ fields.append(pa.field(field.name, dtype, nullable=True))
73
+ arrays.append(src.column(i).cast(dtype))
74
+ fields.append(pa.field("determination_datetime", pa.timestamp("us")))
75
+ arrays.append(pa.array([1672531200000000] * len(src), type=pa.timestamp("us")))
76
+
77
+ metadata = dict(src.schema.metadata)
78
+ geo = json.loads(metadata[b"geo"])
79
+ geo["version"] = "1.0.0"
80
+ geo["columns"]["geometry"].pop("covering", None)
81
+ metadata[b"geo"] = json.dumps(geo).encode("utf-8")
82
+ degraded = pa.table(arrays, schema=pa.schema(fields, metadata=metadata))
83
+ pq.write_table(degraded, tmp_parquet_file, store_schema=True)
84
+
85
+ gp = GeoParquet(tmp_parquet_file)
86
+ assert gp.postprocess(geoparquet_version="1.1.0", compression="zstd") is True
87
+ # a second run detects the compliant file and doesn't rewrite it
88
+ assert gp.postprocess(geoparquet_version="1.1.0", compression="zstd") is False
89
+ # unless a different compression is requested
90
+ assert gp.postprocess(geoparquet_version="1.1.0", compression="brotli") is True
91
+
92
+ with pq.ParquetFile(tmp_parquet_file) as result:
93
+ schema = result.schema_arrow
94
+ metadata = result.metadata.metadata
95
+
96
+ field = schema.field("geometry")
97
+ assert field.type == pa.binary()
98
+ assert not field.nullable
99
+ field = schema.field("id")
100
+ assert field.type == pa.string()
101
+ assert not field.nullable
102
+ assert schema.field("inspire:id").type == pa.string()
103
+ assert schema.field("determination_datetime").type == pa.timestamp("ms", tz="UTC")
104
+
105
+ bbox = schema.field("bbox")
106
+ assert pat.is_struct(bbox.type)
107
+ assert bbox.type.field("xmin").type == pa.float64()
108
+
109
+ geo = json.loads(metadata[b"geo"])
110
+ assert geo["version"] == "1.1.0"
111
+ assert geo["columns"]["geometry"]["covering"]["bbox"]["xmin"] == ["bbox", "xmin"]
112
+ assert b"collection" in metadata
113
+
114
+ # data is intact
115
+ data = pq.read_table(tmp_parquet_file)
116
+ assert data.num_rows == src.num_rows
117
+ assert data["id"].to_pylist() == src["id"].to_pylist()
118
+ assert data["geometry"].to_pylist() == src["geometry"].to_pylist()
119
+
120
+ # a downgrade removes the covering metadata, which only exists since GeoParquet 1.1
121
+ assert gp.postprocess(geoparquet_version="1.0.0", compression="brotli") is True
122
+ with pq.ParquetFile(tmp_parquet_file) as pf:
123
+ geo = json.loads(pf.metadata.metadata[b"geo"])
124
+ assert geo["version"] == "1.0.0"
125
+ assert "covering" not in geo["columns"]["geometry"]
@@ -0,0 +1,89 @@
1
+ import json
2
+
3
+ import numpy as np
4
+ import pyarrow as pa
5
+ import pyarrow.parquet as pq
6
+ import shapely
7
+
8
+ from vecorel_cli.vecorel.hilbert import (
9
+ bounds_array_for_table,
10
+ crs_total_bounds,
11
+ ensure_hilbert_sorted,
12
+ hilbert_keys_for_table,
13
+ hilbert_reference_bounds,
14
+ )
15
+
16
+ # A bare projection definition without an area of use,
17
+ # like the unnamed "MGI / Austria Lambert" in Austria's 2018 edition
18
+ CUSTOM_CRS = "+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +units=m"
19
+
20
+
21
+ def test_hilbert_reference_bounds_from_crs():
22
+ assert hilbert_reference_bounds("EPSG:4326") == (-180.0, -90.0, 180.0, 90.0)
23
+
24
+
25
+ def test_hilbert_reference_bounds_fallback():
26
+ fallback = [1.0, 2.0, 3.0, 4.0]
27
+ assert hilbert_reference_bounds(CUSTOM_CRS, fallback) == (1.0, 2.0, 3.0, 4.0)
28
+ assert hilbert_reference_bounds(CUSTOM_CRS) is None
29
+
30
+
31
+ def _make_table(points, with_bbox=True, metadata=None):
32
+ geometries = [shapely.Point(x, y).buffer(0.1) for x, y in points]
33
+ wkb = [shapely.to_wkb(g) for g in geometries]
34
+ bounds = shapely.bounds(np.array(geometries, dtype=object))
35
+ arrays = {
36
+ "id": pa.array([str(i) for i in range(len(points))], type=pa.string()),
37
+ "geometry": pa.array(wkb, type=pa.binary()),
38
+ }
39
+ if with_bbox:
40
+ arrays["bbox"] = pa.StructArray.from_arrays(
41
+ [bounds[:, 0], bounds[:, 1], bounds[:, 2], bounds[:, 3]],
42
+ names=["xmin", "ymin", "xmax", "ymax"],
43
+ )
44
+ table = pa.table(arrays)
45
+ if metadata:
46
+ table = table.replace_schema_metadata(metadata)
47
+ return table
48
+
49
+
50
+ def test_bounds_array_bbox_and_wkb_paths_agree():
51
+ points = [(0, 0), (10, 10), (5, 5)]
52
+ with_bbox = _make_table(points, with_bbox=True)
53
+ without_bbox = _make_table(points, with_bbox=False)
54
+
55
+ np.testing.assert_allclose(
56
+ bounds_array_for_table(with_bbox, "geometry"),
57
+ bounds_array_for_table(without_bbox, "geometry"),
58
+ )
59
+
60
+
61
+ def test_ensure_hilbert_sorted(tmp_folder):
62
+ path = str(tmp_folder / "unsorted.parquet")
63
+ # scattered, deliberately not in Hilbert order
64
+ points = [(30, 40), (-120, -30), (5, 5), (100, 60), (-60, 10)]
65
+ metadata = {
66
+ b"geo": json.dumps({"primary_column": "geometry", "columns": {"geometry": {}}}).encode(),
67
+ b"collection": b'{"schemas": {}}',
68
+ }
69
+ table = _make_table(points, metadata=metadata)
70
+ pq.write_table(table, path)
71
+
72
+ total_bounds = crs_total_bounds("EPSG:4326")
73
+ keys = hilbert_keys_for_table(table, "geometry", total_bounds)
74
+ assert not bool(np.all(keys[1:] >= keys[:-1])), "Test data must start unsorted"
75
+
76
+ assert ensure_hilbert_sorted(path, "geometry", total_bounds, "zstd") is True
77
+
78
+ result = pq.read_table(path)
79
+ sorted_keys = hilbert_keys_for_table(result, "geometry", total_bounds)
80
+ assert bool(np.all(sorted_keys[1:] >= sorted_keys[:-1]))
81
+ # data is intact, schema stays narrow, metadata is preserved
82
+ assert sorted(result["id"].to_pylist()) == sorted(table["id"].to_pylist())
83
+ assert result.schema.field("geometry").type == pa.binary()
84
+ assert result.schema.field("id").type == pa.string()
85
+ assert result.schema.metadata[b"geo"] == metadata[b"geo"]
86
+ assert result.schema.metadata[b"collection"] == metadata[b"collection"]
87
+
88
+ # a second run is a no-op
89
+ assert ensure_hilbert_sorted(path, "geometry", total_bounds, "zstd") is False
@@ -1,5 +1,10 @@
1
1
  SUPPORTED_PROTOCOLS = ["http", "https", "s3", "gs"]
2
2
 
3
+ # Sent on every outgoing HTTP request. Some servers reject the default agents of
4
+ # the underlying libraries, e.g. geoportal.saarland.de answers 403 to any agent
5
+ # containing "aiohttp", which is what fsspec sends.
6
+ USER_AGENT = "vecorel-cli"
7
+
3
8
  COMPRESSION_METHODS = ["brotli", "gzip", "lz4", "snappy", "zstd", "none"]
4
9
 
5
10
  GEOPARQUET_VERSIONS = ["1.0.0", "1.1.0"]
@@ -62,6 +62,11 @@ class BaseConverter(LoggerMixin):
62
62
 
63
63
  index_as_id: bool = False
64
64
 
65
+ # Rows with null values in schema-required properties are dropped up to this
66
+ # share of all rows; above it the conversion fails. Rows with an empty or
67
+ # missing geometry are always dropped, regardless of this share.
68
+ max_dropped_share: float = 0.01
69
+
65
70
  def __init__(self, *args, **kwargs):
66
71
  super().__init__()
67
72
 
@@ -71,6 +76,148 @@ class BaseConverter(LoggerMixin):
71
76
  if not key.startswith("_") and isinstance(item, (list, dict, set)):
72
77
  setattr(self, key, copy(item))
73
78
 
79
+ def _require_one_source_of_urls(self):
80
+ """Fail when both `sources` and `variants` are declared:
81
+ `sources` wins and every `--variant` would silently convert the same file."""
82
+ if self.sources and self.variants:
83
+ raise ValueError(
84
+ f"{type(self).__name__} declares both sources and variants; sources wins "
85
+ "and every --variant would convert the same file. Drop sources, or set "
86
+ "variants = {} when the inherited ones do not apply."
87
+ )
88
+
89
+ def _check_id_mapping(self):
90
+ """Warn before converting when nothing is mapped to the required `id` property.
91
+ Unmapped columns are dropped, which also removes the column filled by
92
+ `index_as_id` unless the converter maps `"id": "id"`."""
93
+ targets = set()
94
+ for value in list(self.columns.values()) + list(self.column_additions or {}):
95
+ targets.update(value if isinstance(value, (list, tuple)) else [value])
96
+ if "id" not in targets:
97
+ hint = (
98
+ ' — `index_as_id = True` is set, so add \'"id": "id"\' to columns'
99
+ if self.index_as_id
100
+ else " — map a unique source column to it, or set index_as_id = True"
101
+ ' and add \'"id": "id"\' to columns'
102
+ )
103
+ self.warning(f"{type(self).__name__} maps no column to 'id'{hint}")
104
+
105
+ def _check_unique_ids(self, gdf, columns):
106
+ """Warn when the column that becomes `id` does not identify a feature.
107
+ Runs before geometries are exploded, so it judges what the converter
108
+ assigned rather than the split parts of one source feature. Null ids
109
+ are not counted here; they are dropped under a bounded rule afterwards.
110
+ """
111
+ sources = [
112
+ k for k, v in columns.items() if "id" in (v if isinstance(v, (list, tuple)) else [v])
113
+ ]
114
+ column = next(
115
+ (c for c in sources if c in gdf.columns), "id" if "id" in gdf.columns else None
116
+ )
117
+ if column is None:
118
+ self.warning(
119
+ f"{type(self).__name__}: none of the columns mapped to 'id' "
120
+ f"({', '.join(sources) or 'none'}) is in this source; it has "
121
+ f"{', '.join(sorted(gdf.columns)[:12])}"
122
+ )
123
+ return
124
+ ids = gdf[column].dropna()
125
+ if ids.is_unique:
126
+ return
127
+ counts = ids.value_counts()
128
+ duplicated = int(len(ids) - len(counts))
129
+ worst = int(counts.iloc[0])
130
+ self.warning(
131
+ f"{type(self).__name__}: '{column}' is not unique — {duplicated:,} of {len(ids):,} "
132
+ f"rows repeat an id (one appears {worst:,} times), so it cannot be `id`. Map a column "
133
+ "that identifies a feature, build one from the source's key columns, or use the row "
134
+ "index (index_as_id) only when the conversion reads a single file."
135
+ )
136
+
137
+ def _drop_incomplete_rows(self, gdf, columns):
138
+ """Drop rows that can never validate. Rows with null values in a
139
+ schema-required property are dropped up to ``max_dropped_share``;
140
+ above it the conversion fails, as quietly dropping large parts of a
141
+ dataset would hide that the converter needs fixing (and the writer
142
+ rejects nulls in the non-nullable required fields anyway). Rows with
143
+ an empty or missing geometry are always dropped: they cannot survive
144
+ the geometry processing anyway and would break the Hilbert sort."""
145
+ collection = self.create_collection(self.id.strip())
146
+ schemas = collection.merge_schemas({})
147
+ collection_only = set(collection.get_collection_only_properties())
148
+ required = [
149
+ r for r in schemas.get("required", []) if r != "geometry" and r not in collection_only
150
+ ]
151
+
152
+ # One combined mask, so max_dropped_share bounds the total share
153
+ invalid = pd.Series(False, index=gdf.index)
154
+ reasons = []
155
+
156
+ # This runs before columns are renamed, so look up the source column
157
+ for key in required:
158
+ for src, dst in columns.items():
159
+ targets = dst if isinstance(dst, (list, tuple)) else [dst]
160
+ if key in targets and src in gdf.columns:
161
+ nulls = gdf[src].isna()
162
+ if nulls.any():
163
+ reasons.append(f"{int(nulls.sum())} without a value for {key} ({src})")
164
+ invalid |= nulls
165
+
166
+ if invalid.any():
167
+ share = invalid.mean()
168
+ details = "; ".join(reasons)
169
+ if share > self.max_dropped_share:
170
+ raise ValueError(
171
+ f"{int(invalid.sum())} of {len(gdf)} rows ({share:.1%}) have no value for "
172
+ f"a required property ({details}); fix the converter instead of dropping them"
173
+ )
174
+ self.warning(
175
+ f"Dropping {int(invalid.sum())} of {len(gdf)} rows that can never "
176
+ f"validate ({details})"
177
+ )
178
+ gdf = gdf[~invalid]
179
+
180
+ if gdf.active_geometry_name is not None:
181
+ geom = gdf.geometry
182
+ blank = geom.isna() | geom.is_empty
183
+ if blank.any():
184
+ share = blank.mean()
185
+ message = (
186
+ f"Dropping {int(blank.sum())} of {len(gdf)} rows with an empty "
187
+ f"or missing geometry"
188
+ )
189
+ if share > self.max_dropped_share:
190
+ message += f" ({share:.1%}, exceeds max_dropped_share) — fix the converter"
191
+ self.warning(message)
192
+ gdf = gdf[~blank]
193
+
194
+ return gdf
195
+
196
+ def _prewarm_schemas(self):
197
+ """Fetch every schema this conversion will need upfront, with retries,
198
+ so a transient schema-host blip cannot kill the conversion at the very
199
+ last step. load_file caches per process."""
200
+ import time
201
+
202
+ from ..vecorel.util import load_file
203
+
204
+ uris = set(self.extensions)
205
+ uris.add(Schemas.get_core_uri())
206
+ attempts = 8
207
+ for uri in sorted(uris):
208
+ for attempt in range(attempts):
209
+ try:
210
+ load_file(uri)
211
+ break
212
+ except Exception as e:
213
+ if attempt == attempts - 1:
214
+ raise RuntimeError(
215
+ f"Cannot load schema {uri} after {attempts} attempts: {e}"
216
+ ) from e
217
+ self.warning(f"Schema fetch failed ({uri}), retrying: {str(e)[:100]}")
218
+ # ~4 min of tolerance: schema host outages have outlasted a 30 s budget
219
+ time.sleep(min(2**attempt * 2, 60))
220
+
74
221
  def migrate(self, gdf) -> GeoDataFrame:
75
222
  return gdf
76
223
 
@@ -316,6 +463,10 @@ class BaseConverter(LoggerMixin):
316
463
  if self.bbox is not None and len(self.bbox) != 4:
317
464
  raise ValueError("If provided, the bounding box must consist of 4 numbers")
318
465
 
466
+ self._check_id_mapping()
467
+ self._require_one_source_of_urls()
468
+ self._prewarm_schemas()
469
+
319
470
  # Create output folder if it doesn't exist
320
471
  directory = os.path.dirname(output_file)
321
472
  if directory:
@@ -360,10 +511,6 @@ class BaseConverter(LoggerMixin):
360
511
  gdf[key] = value
361
512
  columns[key] = key
362
513
 
363
- # Add collection ID
364
- columns["collection"] = "collection"
365
- gdf["collection"] = cid
366
-
367
514
  # 4. Run column migrations
368
515
  if self.column_migrations:
369
516
  self.info("Applying column migrations")
@@ -375,6 +522,9 @@ class BaseConverter(LoggerMixin):
375
522
 
376
523
  gdf = self.post_migrate(gdf)
377
524
 
525
+ self._check_unique_ids(gdf, columns)
526
+ gdf = self._drop_incomplete_rows(gdf, columns)
527
+
378
528
  if hash_before != self._hash_df(gdf.head()):
379
529
  self.info("GeoDataFrame after migrations and filters:")
380
530
  self.info(gdf.head().to_string())
@@ -430,7 +580,9 @@ class BaseConverter(LoggerMixin):
430
580
  self.info("Creating GeoParquet file: " + str(output_file))
431
581
  columns = list(actual_columns.values())
432
582
  pq = GeoParquet(output_file)
433
- pq.set_collection(self.create_collection(cid))
583
+ collection = self.create_collection(cid)
584
+ collection["collection"] = cid
585
+ pq.set_collection(collection)
434
586
 
435
587
  pq.write(
436
588
  gdf,