vecorel-cli 0.2.16__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.16/vecorel_cli.egg-info → vecorel_cli-0.2.18}/PKG-INFO +2 -1
  2. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/pyproject.toml +2 -1
  3. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_convert.py +21 -0
  4. vecorel_cli-0.2.18/tests/test_convert_duckdb.py +220 -0
  5. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_encoding_geojson.py +23 -0
  6. vecorel_cli-0.2.18/tests/test_encoding_geoparquet.py +125 -0
  7. vecorel_cli-0.2.18/tests/test_hilbert.py +89 -0
  8. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/const.py +5 -0
  9. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/conversion/base.py +161 -5
  10. vecorel_cli-0.2.18/vecorel_cli/conversion/duckdb.py +423 -0
  11. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/base.py +10 -0
  12. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/geojson.py +8 -3
  13. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/geoparquet.py +237 -4
  14. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/parquet/types.py +34 -0
  15. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validate_schema.py +2 -1
  16. vecorel_cli-0.2.18/vecorel_cli/vecorel/hilbert.py +310 -0
  17. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/util.py +4 -2
  18. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18/vecorel_cli.egg-info}/PKG-INFO +2 -1
  19. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/SOURCES.txt +3 -0
  20. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/requires.txt +1 -0
  21. vecorel_cli-0.2.16/tests/test_encoding_geoparquet.py +0 -48
  22. vecorel_cli-0.2.16/vecorel_cli/vecorel/hilbert.py +0 -142
  23. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/LICENSE +0 -0
  24. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/README.md +0 -0
  25. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/setup.cfg +0 -0
  26. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_converters.py +0 -0
  27. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_create_geojson.py +0 -0
  28. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_create_geoparquet.py +0 -0
  29. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_create_stac.py +0 -0
  30. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_describe.py +0 -0
  31. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_encoding_auto.py +0 -0
  32. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_improve.py +0 -0
  33. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_jsonschema.py +0 -0
  34. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_merge.py +0 -0
  35. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_ops.py +0 -0
  36. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_rename_extension.py +0 -0
  37. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_validate.py +0 -0
  38. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_validate_schema.py +0 -0
  39. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/__init__.py +0 -0
  40. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/basecommand.py +0 -0
  41. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/__init__.py +0 -0
  42. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/logger.py +0 -0
  43. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/options.py +0 -0
  44. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/path_url.py +0 -0
  45. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/setup.py +0 -0
  46. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/util.py +0 -0
  47. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/conversion/__init__.py +0 -0
  48. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/conversion/admin.py +0 -0
  49. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/conversion/flatdict.py +0 -0
  50. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/convert.py +0 -0
  51. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/converters.py +0 -0
  52. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/create_geojson.py +0 -0
  53. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/create_geoparquet.py +0 -0
  54. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/create_jsonschema.py +0 -0
  55. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/create_stac.py +0 -0
  56. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/datasets/__init__.py +0 -0
  57. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/datasets/template.py +0 -0
  58. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/describe.py +0 -0
  59. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/__init__.py +0 -0
  60. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/auto.py +0 -0
  61. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/improve.py +0 -0
  62. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/jsonschema/__init__.py +0 -0
  63. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/jsonschema/template.py +0 -0
  64. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/merge.py +0 -0
  65. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/parquet/__init__.py +0 -0
  66. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/parquet/geopandas.py +0 -0
  67. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/registry.py +0 -0
  68. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/rename_extension.py +0 -0
  69. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validate.py +0 -0
  70. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/__init__.py +0 -0
  71. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/base.py +0 -0
  72. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/data.py +0 -0
  73. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/geojson.py +0 -0
  74. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/geoparquet.py +0 -0
  75. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/__init__.py +0 -0
  76. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/collection.py +0 -0
  77. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/extensions.py +0 -0
  78. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/ops.py +0 -0
  79. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/schemas.py +0 -0
  80. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/typing.py +0 -0
  81. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/version.py +0 -0
  82. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/dependency_links.txt +0 -0
  83. {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/entry_points.txt +0 -0
  84. {vecorel_cli-0.2.16 → 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.16
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.16"
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]
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import sys
2
3
  from pathlib import Path
3
4
 
@@ -146,6 +147,26 @@ def test_template_from_package_folder():
146
147
  assert isinstance(converter, ConvertData), "Should succeed and not throw an exception"
147
148
 
148
149
 
150
+ @pytest.mark.parametrize("bom", [False, True], ids=["without-bom", "with-bom"])
151
+ def test_read_geojson_decodes_utf8(tmp_folder, cp1252_locale, bom):
152
+ file_path = tmp_folder / "umlaut.json"
153
+ feature = {
154
+ "type": "Feature",
155
+ "id": "1",
156
+ "properties": {"name": "Grünland"},
157
+ "geometry": {"type": "Point", "coordinates": [0, 0]},
158
+ }
159
+ file_path.write_text(
160
+ # ensure_ascii=False so the file really holds multi-byte UTF-8, not an escape sequence
161
+ json.dumps({"type": "FeatureCollection", "features": [feature]}, ensure_ascii=False),
162
+ encoding="utf-8-sig" if bom else "utf-8",
163
+ )
164
+
165
+ gdf = BaseConverter().read_geojson(str(file_path))
166
+
167
+ assert gdf["name"].iloc[0] == "Grünland"
168
+
169
+
149
170
  def test_data_access_exception(capsys, tmp_folder):
150
171
  # todo: use fixture
151
172
  logger.remove()
@@ -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 == []
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from pathlib import Path
2
3
 
3
4
  import pytest
@@ -55,3 +56,25 @@ def test_get_collection_returns_existing(tmp_folder):
55
56
 
56
57
  result = geojson.get_collection()
57
58
  assert result == expected
59
+
60
+
61
+ @pytest.mark.parametrize("bom", [False, True], ids=["without-bom", "with-bom"])
62
+ def test_read_geojson_decodes_utf8(tmp_folder, cp1252_locale, bom):
63
+ feature = {
64
+ "type": "Feature",
65
+ "id": "1",
66
+ "properties": {"name": "Grünland"},
67
+ "geometry": {"type": "Point", "coordinates": [0, 0]},
68
+ }
69
+ file_path = tmp_folder / "umlaut.json"
70
+ file_path.write_text(
71
+ # ensure_ascii=False so the file really holds multi-byte UTF-8, not an escape sequence
72
+ json.dumps({"type": "FeatureCollection", "features": [feature]}, ensure_ascii=False),
73
+ encoding="utf-8-sig" if bom else "utf-8",
74
+ )
75
+
76
+ geojson = GeoJSON(file_path)
77
+
78
+ assert geojson.read_geojson()["features"][0]["properties"]["name"] == "Grünland"
79
+ # read(num=...) takes the streaming branch, which opens the file separately
80
+ assert geojson.read(num=1)["name"].iloc[0] == "Grünland"
@@ -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"]