vecorel-cli 0.2.17__tar.gz → 0.2.19__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 (85) hide show
  1. {vecorel_cli-0.2.17/vecorel_cli.egg-info → vecorel_cli-0.2.19}/PKG-INFO +4 -3
  2. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/pyproject.toml +5 -4
  3. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_convert.py +19 -0
  4. vecorel_cli-0.2.19/tests/test_convert_duckdb.py +414 -0
  5. vecorel_cli-0.2.19/tests/test_download.py +59 -0
  6. vecorel_cli-0.2.19/tests/test_encoding_geoparquet.py +150 -0
  7. vecorel_cli-0.2.19/tests/test_hilbert.py +89 -0
  8. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/const.py +5 -0
  9. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/conversion/base.py +185 -7
  10. vecorel_cli-0.2.19/vecorel_cli/conversion/duckdb.py +534 -0
  11. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/base.py +10 -0
  12. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/geoparquet.py +239 -5
  13. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/parquet/types.py +48 -0
  14. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validate_schema.py +2 -1
  15. vecorel_cli-0.2.19/vecorel_cli/vecorel/hilbert.py +310 -0
  16. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/util.py +4 -2
  17. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19/vecorel_cli.egg-info}/PKG-INFO +4 -3
  18. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/SOURCES.txt +4 -0
  19. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/requires.txt +3 -2
  20. vecorel_cli-0.2.17/tests/test_encoding_geoparquet.py +0 -48
  21. vecorel_cli-0.2.17/vecorel_cli/vecorel/hilbert.py +0 -142
  22. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/LICENSE +0 -0
  23. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/README.md +0 -0
  24. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/setup.cfg +0 -0
  25. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_converters.py +0 -0
  26. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_create_geojson.py +0 -0
  27. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_create_geoparquet.py +0 -0
  28. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_create_stac.py +0 -0
  29. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_describe.py +0 -0
  30. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_encoding_auto.py +0 -0
  31. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_encoding_geojson.py +0 -0
  32. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_improve.py +0 -0
  33. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_jsonschema.py +0 -0
  34. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_merge.py +0 -0
  35. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_ops.py +0 -0
  36. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_rename_extension.py +0 -0
  37. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_validate.py +0 -0
  38. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_validate_schema.py +0 -0
  39. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/__init__.py +0 -0
  40. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/basecommand.py +0 -0
  41. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/__init__.py +0 -0
  42. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/logger.py +0 -0
  43. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/options.py +0 -0
  44. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/path_url.py +0 -0
  45. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/setup.py +0 -0
  46. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/util.py +0 -0
  47. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/conversion/__init__.py +0 -0
  48. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/conversion/admin.py +0 -0
  49. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/conversion/flatdict.py +0 -0
  50. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/convert.py +0 -0
  51. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/converters.py +0 -0
  52. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/create_geojson.py +0 -0
  53. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/create_geoparquet.py +0 -0
  54. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/create_jsonschema.py +0 -0
  55. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/create_stac.py +0 -0
  56. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/datasets/__init__.py +0 -0
  57. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/datasets/template.py +0 -0
  58. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/describe.py +0 -0
  59. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/__init__.py +0 -0
  60. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/auto.py +0 -0
  61. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/geojson.py +0 -0
  62. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/improve.py +0 -0
  63. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/jsonschema/__init__.py +0 -0
  64. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/jsonschema/template.py +0 -0
  65. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/merge.py +0 -0
  66. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/parquet/__init__.py +0 -0
  67. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/parquet/geopandas.py +0 -0
  68. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/registry.py +0 -0
  69. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/rename_extension.py +0 -0
  70. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validate.py +0 -0
  71. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/__init__.py +0 -0
  72. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/base.py +0 -0
  73. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/data.py +0 -0
  74. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/geojson.py +0 -0
  75. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/geoparquet.py +0 -0
  76. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/__init__.py +0 -0
  77. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/collection.py +0 -0
  78. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/extensions.py +0 -0
  79. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/ops.py +0 -0
  80. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/schemas.py +0 -0
  81. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/typing.py +0 -0
  82. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/version.py +0 -0
  83. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/dependency_links.txt +0 -0
  84. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/entry_points.txt +0 -0
  85. {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/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.19
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
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: pyyaml<7.0,>=6.0
20
20
  Requires-Dist: click<9.0,>=8.1
21
- Requires-Dist: geopandas<2.0,>=1.0.0
21
+ Requires-Dist: geopandas<2.0,>=1.1.0
22
22
  Requires-Dist: requests<3.0,>=2.30
23
23
  Requires-Dist: shapely<3.0,>=2.1
24
24
  Requires-Dist: numpy<3.0,>=2.0
@@ -26,12 +26,13 @@ Requires-Dist: pyarrow<24.0,>=21.0
26
26
  Requires-Dist: py7zr<2.0,>=1.0
27
27
  Requires-Dist: fsspec>=2025.7.0
28
28
  Requires-Dist: jsonschema[format]<5.0,>=4.20
29
- Requires-Dist: aiohttp<4.0,>=3.9
29
+ Requires-Dist: aiohttp<4.0,>=3.13.5
30
30
  Requires-Dist: yarl<2.0,>=1.20
31
31
  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.19"
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"
@@ -24,7 +24,7 @@ requires-python = ">=3.11"
24
24
  dependencies = [
25
25
  "pyyaml>=6.0,<7.0",
26
26
  "click>=8.1,<9.0",
27
- "geopandas>=1.0.0,<2.0",
27
+ "geopandas>=1.1.0,<2.0",
28
28
  "requests>=2.30,<3.0",
29
29
  "shapely>=2.1,<3.0",
30
30
  "numpy>=2.0,<3.0",
@@ -32,12 +32,13 @@ dependencies = [
32
32
  "py7zr>=1.0,<2.0",
33
33
  "fsspec>=2025.7.0",
34
34
  "jsonschema[format]>=4.20,<5.0",
35
- "aiohttp>=3.9,<4.0",
35
+ "aiohttp>=3.13.5,<4.0",
36
36
  "yarl>=1.20,<2.0",
37
37
  "rarfile>=4.0,<5.0",
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]
@@ -64,7 +65,7 @@ platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"]
64
65
  python = ">=3.11"
65
66
  pyyaml = ">=6.0,<7.0"
66
67
  click = ">=8.1,<9.0"
67
- geopandas = ">=1.0.0,<2.0"
68
+ geopandas = ">=1.1.0,<2.0"
68
69
  requests = ">=2.30,<3.0"
69
70
  shapely = ">=2.1,<3.0"
70
71
  numpy = ">=2.0,<3.0"
@@ -180,3 +180,22 @@ def test_data_access_exception(capsys, tmp_folder):
180
180
 
181
181
  assert isinstance(converter.converter, BaseConverter)
182
182
  assert converter.converter.data_access in out
183
+
184
+
185
+ @pytest.mark.parametrize("choice", ["example"])
186
+ def test_converter_can_keep_the_constants_in_columns(tmp_folder, monkeypatch, choice):
187
+ """A conversion that writes one part of a dataset cannot let constants move
188
+ into the collection metadata: constant is then judged over the part."""
189
+ import pyarrow.parquet as pq
190
+
191
+ from tests.datasets.example import Converter
192
+
193
+ dest = tmp_folder / "hydrated.parquet"
194
+ monkeypatch.setattr(Converter, "dehydrate", False)
195
+ ConvertData(choice).convert(dest, cache=(test_path / choice))
196
+
197
+ schema = pq.ParquetFile(dest).schema_arrow
198
+ collection = json.loads(schema.metadata[b"collection"])
199
+ for key in ("admin:country_code", "admin:subdivision_code", "determination_datetime"):
200
+ assert key in schema.names, f"{key} should have stayed a column"
201
+ assert key not in collection, f"{key} should not be in the collection metadata"
@@ -0,0 +1,414 @@
1
+ import json
2
+ import sys
3
+
4
+ import geopandas as gpd
5
+ import numpy as np
6
+ import pyarrow.parquet as pq
7
+ import pytest
8
+ import shapely
9
+ from loguru import logger
10
+
11
+ from vecorel_cli.conversion.base import BaseConverter
12
+ from vecorel_cli.conversion.duckdb import DuckDBBaseConverter
13
+ from vecorel_cli.validate import ValidateData
14
+ from vecorel_cli.vecorel.hilbert import hilbert_keys_for_table
15
+
16
+ # One shared converter configuration, so the two codepaths cannot drift apart.
17
+ # column_filters and column_migrations must stay out of it: they are Python
18
+ # callables in the GeoDataFrame-based codepath and SQL fragments in the
19
+ # DuckDB-based one.
20
+ CONFIG = {
21
+ "id": "test",
22
+ "short_name": "Test",
23
+ "title": "Test dataset",
24
+ "description": "Test dataset",
25
+ "license": "CC0-1.0",
26
+ "columns": {
27
+ "geometry": "geometry",
28
+ "id": "id",
29
+ "name": "name",
30
+ },
31
+ "missing_schemas": {
32
+ "properties": {
33
+ "name": {"type": "string"},
34
+ }
35
+ },
36
+ }
37
+
38
+ Converter = type("Converter", (DuckDBBaseConverter,), dict(CONFIG))
39
+ PandasConverter = type("PandasConverter", (BaseConverter,), dict(CONFIG))
40
+
41
+
42
+ def _source_file(folder):
43
+ """A source with everything the geometry handling must fix:
44
+ a multi-part geometry, an invalid bowtie, a Z polygon, and a point."""
45
+ square = shapely.Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])
46
+ multi = shapely.MultiPolygon(
47
+ [
48
+ shapely.Polygon([(2, 0), (2, 1), (3, 1), (3, 0)]),
49
+ shapely.Polygon([(4, 0), (4, 1), (5, 1), (5, 0)]),
50
+ ]
51
+ )
52
+ bowtie = shapely.Polygon([(6, 0), (7, 1), (7, 0), (6, 1)])
53
+ with_z = shapely.Polygon([(8, 0, 5), (8, 1, 5), (9, 1, 5), (9, 0, 5)])
54
+ point = shapely.Point(10, 0)
55
+
56
+ gdf = gpd.GeoDataFrame(
57
+ {
58
+ "id": ["square", "multi", "bowtie", "with_z", "point"],
59
+ "name": ["a", "b", "c", "d", "e"],
60
+ "geometry": [square, multi, bowtie, with_z, point],
61
+ },
62
+ crs="EPSG:4326",
63
+ )
64
+ path = folder / "source.parquet"
65
+ gdf.to_parquet(path)
66
+ return str(path)
67
+
68
+
69
+ def test_duckdb_converter(tmp_folder):
70
+ src = _source_file(tmp_folder)
71
+ dest = tmp_folder / "converted.parquet"
72
+
73
+ Converter().convert(dest, input_files={src: "source.parquet"})
74
+
75
+ result = gpd.read_parquet(dest)
76
+ # multi is split in two, bowtie is repaired into two valid polygons,
77
+ # the point is dropped and the Z dimension is removed
78
+ assert sorted(result["id"]) == ["bowtie", "bowtie", "multi", "multi", "square", "with_z"]
79
+ assert set(result.geometry.geom_type) == {"Polygon"}
80
+ assert result.geometry.is_valid.all()
81
+ assert not result.geometry.has_z.any()
82
+
83
+ with pq.ParquetFile(dest) as pf:
84
+ schema = pf.schema_arrow
85
+ field = schema.field("geometry")
86
+ assert str(field.type) == "binary"
87
+ assert not field.nullable
88
+ field = schema.field("id")
89
+ assert str(field.type) == "string"
90
+ assert not field.nullable
91
+ assert str(schema.field("name").type) == "string"
92
+ assert "bbox" in schema.names
93
+
94
+ # sorted against the CRS-derived Hilbert grid
95
+ with pq.ParquetFile(dest) as pf:
96
+ table = pf.read()
97
+ keys = hilbert_keys_for_table(table, "geometry", (-180.0, -90.0, 180.0, 90.0))
98
+ assert bool(np.all(keys[1:] >= keys[:-1]))
99
+
100
+ validation = ValidateData().validate(dest, num=100, schema_map={})
101
+ assert validation.errors == []
102
+
103
+
104
+ def test_duckdb_converter_index_as_id(tmp_folder):
105
+ src = _source_file(tmp_folder)
106
+ dest = tmp_folder / "converted.parquet"
107
+
108
+ IndexConverter = type("IndexConverter", (DuckDBBaseConverter,), {**CONFIG, "index_as_id": True})
109
+ IndexConverter().convert(dest, input_files={src: "source.parquet"})
110
+
111
+ result = gpd.read_parquet(dest)
112
+ # row numbers are assigned before geometries are split,
113
+ # so the parts of one source feature share an id (like the default codepath)
114
+ assert sorted(result["id"]) == ["0", "1", "1", "2", "2", "3"]
115
+
116
+
117
+ def test_duckdb_converter_source_crs(tmp_folder):
118
+ src1 = _source_file(tmp_folder)
119
+ dest = tmp_folder / "converted.parquet"
120
+
121
+ # the same CRS, declared as a differently rendered PROJJSON object
122
+ table = pq.read_table(src1)
123
+ metadata = dict(table.schema.metadata)
124
+ geo = json.loads(metadata[b"geo"])
125
+ crs = geo["columns"]["geometry"]["crs"]
126
+ crs.pop("scope", None)
127
+ crs.pop("area", None)
128
+ crs["$schema"] = "https://proj.org/schemas/v0.5/projjson.schema.json"
129
+ metadata[b"geo"] = json.dumps(geo).encode()
130
+ src2 = str(tmp_folder / "source2.parquet")
131
+ pq.write_table(table.replace_schema_metadata(metadata), src2)
132
+
133
+ Converter().convert(dest, input_files={src1: "a.parquet", src2: "b.parquet"})
134
+ assert len(gpd.read_parquet(dest)) == 12
135
+
136
+ src3 = str(tmp_folder / "source3.parquet")
137
+ gpd.read_parquet(src1).to_crs("EPSG:3857").to_parquet(src3)
138
+ with pytest.raises(ValueError, match="different coordinate reference"):
139
+ Converter().convert(dest, input_files={src1: "a.parquet", src3: "c.parquet"})
140
+
141
+
142
+ def test_duckdb_converter_original_geometries(tmp_folder):
143
+ src = _source_file(tmp_folder)
144
+ dest = tmp_folder / "converted.parquet"
145
+
146
+ Converter().convert(dest, input_files={src: "source.parquet"}, original_geometries=True)
147
+
148
+ result = gpd.read_parquet(dest)
149
+ assert len(result) == 5
150
+ assert set(result.geometry.geom_type) == {"Polygon", "MultiPolygon", "Point"}
151
+
152
+
153
+ def test_codepath_parity(tmp_folder):
154
+ """The GeoDataFrame-based and the DuckDB-based codepaths must produce
155
+ comparable files from the same source and converter configuration:
156
+ same schema, same rows in the same order, same key metadata, same packaging.
157
+ """
158
+ src = _source_file(tmp_folder)
159
+ kwargs = {
160
+ "input_files": {src: "source.parquet"},
161
+ "compression": "zstd",
162
+ "geoparquet_version": "1.1.0",
163
+ }
164
+ pandas_dest = tmp_folder / "pandas.parquet"
165
+ duckdb_dest = tmp_folder / "duckdb.parquet"
166
+ PandasConverter().convert(pandas_dest, **kwargs)
167
+ Converter().convert(duckdb_dest, **kwargs)
168
+
169
+ with pq.ParquetFile(pandas_dest) as pf:
170
+ pandas_schema = pf.schema_arrow
171
+ pandas_table = pf.read()
172
+ pandas_groups = pf.metadata.num_row_groups
173
+ pandas_compression = pf.metadata.row_group(0).column(0).compression
174
+ with pq.ParquetFile(duckdb_dest) as pf:
175
+ duckdb_schema = pf.schema_arrow
176
+ duckdb_table = pf.read()
177
+ duckdb_groups = pf.metadata.num_row_groups
178
+ duckdb_compression = pf.metadata.row_group(0).column(0).compression
179
+
180
+ # Same columns with the same types and nullability (the column order is
181
+ # allowed to differ)
182
+ assert sorted(pandas_schema.names) == sorted(duckdb_schema.names)
183
+ for name in pandas_schema.names:
184
+ f1, f2 = pandas_schema.field(name), duckdb_schema.field(name)
185
+ assert f1.type == f2.type, f"{name}: {f1.type} != {f2.type}"
186
+ assert f1.nullable == f2.nullable, f"{name}: nullability differs"
187
+
188
+ # Same rows in the same (Hilbert) order; the geometries must describe the
189
+ # same shapes, but the WKB may differ in vertex order (different GEOS builds)
190
+ assert pandas_table.num_rows == duckdb_table.num_rows
191
+ assert pandas_table["id"].to_pylist() == duckdb_table["id"].to_pylist()
192
+ assert pandas_table["name"].to_pylist() == duckdb_table["name"].to_pylist()
193
+ pandas_geoms = shapely.from_wkb(pandas_table["geometry"].to_pylist())
194
+ duckdb_geoms = shapely.from_wkb(duckdb_table["geometry"].to_pylist())
195
+ for g1, g2 in zip(pandas_geoms, duckdb_geoms):
196
+ assert shapely.equals(g1, g2), f"{shapely.to_wkt(g1)} != {shapely.to_wkt(g2)}"
197
+
198
+ # Same collection metadata and the same key GeoParquet metadata
199
+ pandas_collection = json.loads(pandas_schema.metadata[b"collection"])
200
+ duckdb_collection = json.loads(duckdb_schema.metadata[b"collection"])
201
+ assert pandas_collection == duckdb_collection
202
+
203
+ pandas_geo = json.loads(pandas_schema.metadata[b"geo"])
204
+ duckdb_geo = json.loads(duckdb_schema.metadata[b"geo"])
205
+ for key in ("version", "primary_column"):
206
+ assert pandas_geo[key] == duckdb_geo[key]
207
+ pandas_column = pandas_geo["columns"]["geometry"]
208
+ duckdb_column = duckdb_geo["columns"]["geometry"]
209
+ for key in ("encoding", "covering", "crs", "bbox"):
210
+ assert pandas_column.get(key) == duckdb_column.get(key), f"geo {key} differs"
211
+ assert sorted(pandas_column.get("geometry_types", [])) == sorted(
212
+ duckdb_column.get("geometry_types", [])
213
+ )
214
+
215
+ # Same packaging
216
+ assert pandas_compression == duckdb_compression
217
+ assert pandas_groups == duckdb_groups
218
+
219
+ # Both validate
220
+ for dest in (pandas_dest, duckdb_dest):
221
+ validation = ValidateData().validate(dest, num=100, schema_map={})
222
+ assert validation.errors == []
223
+
224
+
225
+ def test_merge_parquet(tmp_folder):
226
+ """Converted files combine into one, sorted over the whole set and
227
+ packaged like any other output."""
228
+ parts = []
229
+ for index, offset in enumerate((0, 20)):
230
+ gdf = gpd.GeoDataFrame(
231
+ {
232
+ "id": [f"{index}-{n}" for n in range(3)],
233
+ "name": ["a", "b", "c"],
234
+ "geometry": [shapely.box(offset + n, 0, offset + n + 1, 1) for n in range(3)],
235
+ },
236
+ crs="EPSG:4326",
237
+ )
238
+ src = tmp_folder / f"src_{index}.parquet"
239
+ gdf.to_parquet(src)
240
+ part = tmp_folder / f"part_{index}.parquet"
241
+ Converter().convert(part, input_files={str(src): src.name})
242
+ parts.append(part)
243
+
244
+ dest = tmp_folder / "merged.parquet"
245
+ Converter().merge_parquet(parts, dest)
246
+
247
+ result = gpd.read_parquet(dest)
248
+ assert sorted(result["id"]) == ["0-0", "0-1", "0-2", "1-0", "1-1", "1-2"]
249
+
250
+ with pq.ParquetFile(dest) as pf:
251
+ table = pf.read()
252
+ # sorted over the merged set, not per part
253
+ keys = hilbert_keys_for_table(table, "geometry", (-180.0, -90.0, 180.0, 90.0))
254
+ assert bool(np.all(keys[1:] >= keys[:-1]))
255
+ assert "bbox" in table.schema.names
256
+ assert b"collection" in table.schema.metadata
257
+
258
+ assert ValidateData().validate(dest, num=100, schema_map={}).errors == []
259
+
260
+
261
+ def test_merge_parquet_sees_an_id_that_repeats_across_parts(tmp_folder, capsys):
262
+ """A per-part check cannot see this; a check over the merge can."""
263
+ parts = []
264
+ for index in range(2):
265
+ gdf = gpd.GeoDataFrame(
266
+ {
267
+ "id": ["same", f"other-{index}"],
268
+ "name": ["a", "b"],
269
+ "geometry": [
270
+ shapely.box(index, 0, index + 1, 1),
271
+ shapely.box(index, 2, index + 1, 3),
272
+ ],
273
+ },
274
+ crs="EPSG:4326",
275
+ )
276
+ src = tmp_folder / f"dup_src_{index}.parquet"
277
+ gdf.to_parquet(src)
278
+ part = tmp_folder / f"dup_part_{index}.parquet"
279
+ Converter().convert(part, input_files={str(src): src.name})
280
+ parts.append(part)
281
+
282
+ # loguru's default sink is bound to stderr at import, so point it at stdout
283
+ logger.remove()
284
+ logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
285
+ Converter().merge_parquet(parts, tmp_folder / "dup_merged.parquet")
286
+ assert "'id' is not unique" in capsys.readouterr().out
287
+
288
+
289
+ def test_merge_parquet_rejects_empty_input(tmp_folder):
290
+ with pytest.raises(ValueError, match="No paths"):
291
+ Converter().merge_parquet([], tmp_folder / "nothing.parquet")
292
+
293
+
294
+ def test_duckdb_converter_can_keep_the_constants_in_columns(tmp_folder):
295
+ """A conversion that writes one part of a dataset cannot let constants move
296
+ into the collection metadata, in this codepath either."""
297
+ src = _source_file(tmp_folder)
298
+ dest = tmp_folder / "hydrated.parquet"
299
+
300
+ Hydrated = type(
301
+ "Hydrated",
302
+ (DuckDBBaseConverter,),
303
+ {
304
+ **CONFIG,
305
+ "dehydrate": False,
306
+ "column_additions": {"region": "north"},
307
+ "missing_schemas": {
308
+ "properties": {"name": {"type": "string"}, "region": {"type": "string"}}
309
+ },
310
+ },
311
+ )
312
+ Hydrated().convert(dest, input_files={src: "source.parquet"})
313
+
314
+ table = pq.read_table(dest)
315
+ assert "region" in table.schema.names, "the constant should have stayed a column"
316
+ assert set(table.column("region").to_pylist()) == {"north"}
317
+ assert "region" not in json.loads(table.schema.metadata[b"collection"])
318
+
319
+
320
+ def test_merge_parquet_refuses_parts_in_different_crs(tmp_folder):
321
+ parts = []
322
+ for index, crs in enumerate(("EPSG:4326", "EPSG:3857")):
323
+ gdf = gpd.GeoDataFrame(
324
+ {"id": [f"{index}"], "name": ["a"], "geometry": [shapely.box(0, 0, 1, 1)]},
325
+ crs="EPSG:4326",
326
+ ).to_crs(crs)
327
+ src = tmp_folder / f"crs_src_{index}.parquet"
328
+ gdf.to_parquet(src)
329
+ part = tmp_folder / f"crs_part_{index}.parquet"
330
+ Converter().convert(part, input_files={str(src): src.name})
331
+ parts.append(part)
332
+
333
+ with pytest.raises(ValueError, match="different coordinate reference systems"):
334
+ Converter().merge_parquet(parts, tmp_folder / "merged.parquet")
335
+
336
+
337
+ def test_merge_parquet_unions_parts_that_differ(tmp_folder):
338
+ """A converter drops a column a source file does not have, so two parts of one
339
+ dataset can differ; the merge must not fail on that."""
340
+ parts = []
341
+ for index, columns in enumerate(({"id": ["0"], "name": ["a"]}, {"id": ["1"]})):
342
+ gdf = gpd.GeoDataFrame(
343
+ {**columns, "geometry": [shapely.box(index, 0, index + 1, 1)]}, crs="EPSG:4326"
344
+ )
345
+ src = tmp_folder / f"u_src_{index}.parquet"
346
+ gdf.to_parquet(src)
347
+ part = tmp_folder / f"u_part_{index}.parquet"
348
+ Converter().convert(part, input_files={str(src): src.name})
349
+ parts.append(part)
350
+
351
+ dest = tmp_folder / "unioned.parquet"
352
+ Converter().merge_parquet(parts, dest)
353
+ table = pq.read_table(dest)
354
+ assert table.num_rows == 2
355
+ assert "name" in table.schema.names
356
+ assert sorted(x for x in table.column("id").to_pylist()) == ["0", "1"]
357
+
358
+
359
+ def test_merge_parquet_checks_ids_that_convert_generated(tmp_folder, capsys):
360
+ """Each part numbered its own rows from zero, so the merge has to check what
361
+ convert() is allowed to take for granted."""
362
+ IndexConverter = type("IndexConverter", (DuckDBBaseConverter,), {**CONFIG, "index_as_id": True})
363
+ parts = []
364
+ for index in range(2):
365
+ gdf = gpd.GeoDataFrame(
366
+ {
367
+ "name": ["a", "b"],
368
+ "geometry": [
369
+ shapely.box(index, 0, index + 1, 1),
370
+ shapely.box(index, 2, index + 1, 3),
371
+ ],
372
+ },
373
+ crs="EPSG:4326",
374
+ )
375
+ src = tmp_folder / f"idx_src_{index}.parquet"
376
+ gdf.to_parquet(src)
377
+ part = tmp_folder / f"idx_part_{index}.parquet"
378
+ IndexConverter().convert(part, input_files={str(src): src.name})
379
+ parts.append(part)
380
+
381
+ # both parts start at 0, so every id occurs twice
382
+ assert (
383
+ pq.read_table(parts[0]).column("id").to_pylist()
384
+ == pq.read_table(parts[1]).column("id").to_pylist()
385
+ )
386
+
387
+ logger.remove()
388
+ logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
389
+ IndexConverter().merge_parquet(parts, tmp_folder / "idx_merged.parquet")
390
+ assert "'id' is not unique" in capsys.readouterr().out
391
+
392
+
393
+ def test_merge_parquet_keeps_a_crs_duckdb_would_drop(tmp_folder):
394
+ """DuckDB before 1.5 writes no CRS into the merged file's metadata, so it has to
395
+ come from the parts. Checked with a CRS that is not the default."""
396
+ parts = []
397
+ for index in range(2):
398
+ gdf = gpd.GeoDataFrame(
399
+ {"id": [f"{index}"], "name": ["a"], "geometry": [shapely.box(index, 0, index + 1, 1)]},
400
+ crs="EPSG:4326",
401
+ ).to_crs("EPSG:3857")
402
+ src = tmp_folder / f"crs_keep_src_{index}.parquet"
403
+ gdf.to_parquet(src)
404
+ part = tmp_folder / f"crs_keep_part_{index}.parquet"
405
+ Converter().convert(part, input_files={str(src): src.name})
406
+ parts.append(part)
407
+
408
+ dest = tmp_folder / "crs_keep.parquet"
409
+ Converter().merge_parquet(parts, dest)
410
+
411
+ geo = json.loads(pq.read_table(dest).schema.metadata[b"geo"])
412
+ crs = geo["columns"][geo["primary_column"]]["crs"]
413
+ assert crs is not None, "the merged file lost the CRS of its parts"
414
+ assert "3857" in json.dumps(crs)
@@ -0,0 +1,59 @@
1
+ import pytest
2
+
3
+ import vecorel_cli.conversion.base as base
4
+ from vecorel_cli.conversion.base import BaseConverter
5
+
6
+ URI = "https://example.invalid/data/fields.gml"
7
+
8
+
9
+ def test_failed_download_leaves_no_cache_file(tmp_folder, monkeypatch):
10
+ """A download that raises must not leave a file behind that a later run
11
+ would treat as cached (#43)."""
12
+
13
+ def failing_stream(fs, src_uri, dst_file, chunk_size=None):
14
+ dst_file.write(b"partial")
15
+ raise RuntimeError("403 Forbidden")
16
+
17
+ monkeypatch.setattr(base, "stream_file", failing_stream)
18
+
19
+ with pytest.raises(RuntimeError, match="403"):
20
+ BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
21
+
22
+ assert list(tmp_folder.iterdir()) == [], "cache must be empty after a failed download"
23
+
24
+
25
+ def test_failed_download_is_retried_on_next_run(tmp_folder, monkeypatch):
26
+ calls = []
27
+
28
+ def stream(fs, src_uri, dst_file, chunk_size=None):
29
+ calls.append(src_uri)
30
+ if len(calls) == 1:
31
+ raise RuntimeError("timeout")
32
+ dst_file.write(b"<gml/>")
33
+
34
+ monkeypatch.setattr(base, "stream_file", stream)
35
+
36
+ with pytest.raises(RuntimeError):
37
+ BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
38
+
39
+ paths = BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
40
+
41
+ assert len(calls) == 2, "the second run must download again"
42
+ assert paths == [(str(tmp_folder / "fields.gml"), URI)]
43
+ assert (tmp_folder / "fields.gml").read_bytes() == b"<gml/>"
44
+ assert not (tmp_folder / "fields.gml.part").exists()
45
+
46
+
47
+ def test_successful_download_is_cached(tmp_folder, monkeypatch):
48
+ calls = []
49
+
50
+ def stream(fs, src_uri, dst_file, chunk_size=None):
51
+ calls.append(src_uri)
52
+ dst_file.write(b"<gml/>")
53
+
54
+ monkeypatch.setattr(base, "stream_file", stream)
55
+
56
+ BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
57
+ BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
58
+
59
+ assert calls == [URI], "a completed download must be reused"