vecorel-cli 0.2.17__tar.gz → 0.3.0__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 (87) hide show
  1. {vecorel_cli-0.2.17/vecorel_cli.egg-info → vecorel_cli-0.3.0}/PKG-INFO +5 -3
  2. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/pyproject.toml +7 -4
  3. vecorel_cli-0.3.0/tests/test_convert.py +494 -0
  4. vecorel_cli-0.3.0/tests/test_convert_duckdb.py +766 -0
  5. vecorel_cli-0.3.0/tests/test_download.py +194 -0
  6. vecorel_cli-0.3.0/tests/test_encoding_geoparquet.py +150 -0
  7. vecorel_cli-0.3.0/tests/test_hilbert.py +89 -0
  8. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_improve.py +25 -1
  9. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/const.py +5 -0
  10. vecorel_cli-0.3.0/vecorel_cli/conversion/base.py +802 -0
  11. vecorel_cli-0.3.0/vecorel_cli/conversion/duckdb.py +696 -0
  12. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/encoding/base.py +10 -0
  13. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/encoding/geoparquet.py +239 -5
  14. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/improve.py +9 -1
  15. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/parquet/types.py +48 -0
  16. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/validate_schema.py +2 -1
  17. vecorel_cli-0.3.0/vecorel_cli/vecorel/hilbert.py +310 -0
  18. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/util.py +55 -2
  19. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0/vecorel_cli.egg-info}/PKG-INFO +5 -3
  20. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli.egg-info/SOURCES.txt +4 -0
  21. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli.egg-info/requires.txt +4 -2
  22. vecorel_cli-0.2.17/tests/test_convert.py +0 -182
  23. vecorel_cli-0.2.17/tests/test_encoding_geoparquet.py +0 -48
  24. vecorel_cli-0.2.17/vecorel_cli/conversion/base.py +0 -452
  25. vecorel_cli-0.2.17/vecorel_cli/vecorel/hilbert.py +0 -142
  26. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/LICENSE +0 -0
  27. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/README.md +0 -0
  28. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/setup.cfg +0 -0
  29. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_converters.py +0 -0
  30. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_create_geojson.py +0 -0
  31. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_create_geoparquet.py +0 -0
  32. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_create_stac.py +0 -0
  33. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_describe.py +0 -0
  34. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_encoding_auto.py +0 -0
  35. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_encoding_geojson.py +0 -0
  36. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_jsonschema.py +0 -0
  37. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_merge.py +0 -0
  38. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_ops.py +0 -0
  39. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_rename_extension.py +0 -0
  40. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_validate.py +0 -0
  41. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/tests/test_validate_schema.py +0 -0
  42. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/__init__.py +0 -0
  43. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/basecommand.py +0 -0
  44. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/cli/__init__.py +0 -0
  45. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/cli/logger.py +0 -0
  46. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/cli/options.py +0 -0
  47. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/cli/path_url.py +0 -0
  48. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/cli/setup.py +0 -0
  49. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/cli/util.py +0 -0
  50. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/conversion/__init__.py +0 -0
  51. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/conversion/admin.py +0 -0
  52. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/conversion/flatdict.py +0 -0
  53. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/convert.py +0 -0
  54. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/converters.py +0 -0
  55. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/create_geojson.py +0 -0
  56. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/create_geoparquet.py +0 -0
  57. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/create_jsonschema.py +0 -0
  58. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/create_stac.py +0 -0
  59. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/datasets/__init__.py +0 -0
  60. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/datasets/template.py +0 -0
  61. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/describe.py +0 -0
  62. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/encoding/__init__.py +0 -0
  63. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/encoding/auto.py +0 -0
  64. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/encoding/geojson.py +0 -0
  65. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/jsonschema/__init__.py +0 -0
  66. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/jsonschema/template.py +0 -0
  67. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/merge.py +0 -0
  68. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/parquet/__init__.py +0 -0
  69. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/parquet/geopandas.py +0 -0
  70. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/registry.py +0 -0
  71. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/rename_extension.py +0 -0
  72. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/validate.py +0 -0
  73. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/validation/__init__.py +0 -0
  74. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/validation/base.py +0 -0
  75. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/validation/data.py +0 -0
  76. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/validation/geojson.py +0 -0
  77. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/validation/geoparquet.py +0 -0
  78. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/__init__.py +0 -0
  79. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/collection.py +0 -0
  80. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/extensions.py +0 -0
  81. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/ops.py +0 -0
  82. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/schemas.py +0 -0
  83. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/typing.py +0 -0
  84. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/version.py +0 -0
  85. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli.egg-info/dependency_links.txt +0 -0
  86. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/vecorel_cli.egg-info/entry_points.txt +0 -0
  87. {vecorel_cli-0.2.17 → vecorel_cli-0.3.0}/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.3.0
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,20 +18,22 @@ 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
25
25
  Requires-Dist: pyarrow<24.0,>=21.0
26
26
  Requires-Dist: py7zr<2.0,>=1.0
27
+ Requires-Dist: multivolumefile<1.0,>=0.2.3
27
28
  Requires-Dist: fsspec>=2025.7.0
28
29
  Requires-Dist: jsonschema[format]<5.0,>=4.20
29
- Requires-Dist: aiohttp<4.0,>=3.9
30
+ Requires-Dist: aiohttp<4.0,>=3.13.5
30
31
  Requires-Dist: yarl<2.0,>=1.20
31
32
  Requires-Dist: rarfile<5.0,>=4.0
32
33
  Requires-Dist: semantic-version<3.0,>=2.10.0
33
34
  Requires-Dist: json-stream<3.0,>=2.3.0
34
35
  Requires-Dist: loguru==0.7.3
36
+ Requires-Dist: duckdb<2.0,>=1.4
35
37
  Provides-Extra: s3
36
38
  Requires-Dist: s3fs>=2025.7.0; extra == "s3"
37
39
  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.3.0"
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,20 +24,22 @@ 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",
31
31
  "pyarrow>=21.0,<24.0",
32
32
  "py7zr>=1.0,<2.0",
33
+ "multivolumefile>=0.2.3,<1.0",
33
34
  "fsspec>=2025.7.0",
34
35
  "jsonschema[format]>=4.20,<5.0",
35
- "aiohttp>=3.9,<4.0",
36
+ "aiohttp>=3.13.5,<4.0",
36
37
  "yarl>=1.20,<2.0",
37
38
  "rarfile>=4.0,<5.0",
38
39
  "semantic-version>=2.10.0,<3.0",
39
40
  "json-stream>=2.3.0,<3.0",
40
41
  "loguru==0.7.3",
42
+ "duckdb>=1.4,<2.0",
41
43
  ]
42
44
 
43
45
  [project.optional-dependencies]
@@ -64,12 +66,13 @@ platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"]
64
66
  python = ">=3.11"
65
67
  pyyaml = ">=6.0,<7.0"
66
68
  click = ">=8.1,<9.0"
67
- geopandas = ">=1.0.0,<2.0"
69
+ geopandas = ">=1.1.0,<2.0"
68
70
  requests = ">=2.30,<3.0"
69
71
  shapely = ">=2.1,<3.0"
70
72
  numpy = ">=2.0,<3.0"
71
73
  pyarrow = ">=21.0,<24.0"
72
74
  py7zr = ">=1.0,<2.0"
75
+ multivolumefile = ">=0.2.3,<1.0"
73
76
  unrar = ">=0.4"
74
77
 
75
78
  [tool.pixi.pypi-dependencies]
@@ -0,0 +1,494 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import geopandas as gpd
6
+ import numpy as np
7
+ import pytest
8
+ from loguru import logger
9
+
10
+ from vecorel_cli.conversion.base import BaseConverter
11
+ from vecorel_cli.convert import ConvertData
12
+ from vecorel_cli.registry import Registry
13
+ from vecorel_cli.validate import ValidateData
14
+ from vecorel_cli.vecorel.hilbert import crs_total_bounds, hilbert_distances_from_bounds
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def registry_reset():
19
+ ignored = Registry.ignored_datasets
20
+ src_package = Registry.src_package
21
+ Registry.src_package = "tests"
22
+ yield
23
+ Registry.ignore_datasets = ignored
24
+ Registry.src_package = src_package
25
+
26
+
27
+ test_path = Path("tests/data-files/convert")
28
+
29
+
30
+ @pytest.mark.parametrize("choice", ["example"])
31
+ def test_converter(tmp_folder, choice):
32
+ dest = tmp_folder / "converted.parquet"
33
+ converter = ConvertData(choice)
34
+ converter.convert(dest, cache=(test_path / choice))
35
+
36
+ assert dest.exists(), f"Expected file {dest} to be created."
37
+
38
+ # todo: Validation works, but fails for the created file
39
+ validator = ValidateData()
40
+ validation_result = validator.validate(
41
+ dest,
42
+ num=100,
43
+ schema_map={},
44
+ )
45
+ assert validation_result.errors == []
46
+
47
+
48
+ @pytest.mark.parametrize("choice", ["example"])
49
+ def test_converter_hilbert_curve_order(tmp_folder, choice):
50
+ """The converter must emit rows in Hilbert-curve order against the CRS's
51
+ total bounds. This is what makes per-partition outputs of the same dataset
52
+ mergeable without re-sorting: they all share a CRS-derived Hilbert grid."""
53
+ dest = tmp_folder / "converted.parquet"
54
+ ConvertData(choice).convert(dest, cache=(test_path / choice))
55
+
56
+ gdf = gpd.read_parquet(dest)
57
+ assert len(gdf) > 1, "Need at least 2 rows to verify ordering."
58
+
59
+ total_bounds = crs_total_bounds(gdf.crs)
60
+ bounds = gdf.geometry.bounds.to_numpy(dtype=np.float64, copy=False)
61
+ keys = hilbert_distances_from_bounds(bounds, total_bounds)
62
+
63
+ # NB: keys are uint64 — np.diff would wrap descents into huge positives
64
+ # and never flag them; compare adjacent keys directly instead.
65
+ descents = keys[1:] < keys[:-1]
66
+ if np.any(descents):
67
+ bad = int(np.argmax(descents))
68
+ raise AssertionError(
69
+ "Output rows are not in non-decreasing Hilbert order against the "
70
+ f"CRS total bounds ({total_bounds}).\n"
71
+ f" First out-of-order pair at index {bad}->{bad + 1}: "
72
+ f"key {int(keys[bad])} > {int(keys[bad + 1])}."
73
+ )
74
+
75
+
76
+ def test_crs_total_bounds_geographic():
77
+ # Geographic CRS without a more restrictive area_of_use falls back to world.
78
+ assert crs_total_bounds("EPSG:4326") == (-180.0, -90.0, 180.0, 90.0)
79
+
80
+
81
+ def test_crs_total_bounds_etrs89():
82
+ # EPSG:4258 is geographic with a European area_of_use; the returned bounds
83
+ # must reflect that AoU rather than world bounds (otherwise Hilbert keys
84
+ # for European data would all be quantised into one tiny cell).
85
+ bounds = crs_total_bounds("EPSG:4258")
86
+ assert bounds[0] > -180.0 and bounds[2] < 180.0, bounds
87
+ assert bounds[1] > -90.0 and bounds[3] < 90.0, bounds
88
+
89
+
90
+ def test_hilbert_distances_match_public_geopandas_api():
91
+ # hilbert_distances_from_bounds exists for callers that hold bbox columns
92
+ # without decoded geometry; it must stay identical to the public
93
+ # GeoSeries.hilbert_distance, which guards its private-API internals.
94
+ from shapely.geometry import box
95
+
96
+ rng = np.random.default_rng(42)
97
+ xs = rng.uniform(4.0, 6.0, 50)
98
+ ys = rng.uniform(51.0, 53.0, 50)
99
+ geoms = [box(x, y, x + 0.01, y + 0.01) for x, y in zip(xs, ys)]
100
+ gdf = gpd.GeoDataFrame(geometry=geoms, crs="EPSG:4326")
101
+ total = crs_total_bounds(gdf.crs)
102
+ public = gdf.geometry.hilbert_distance(total_bounds=list(total), level=16).to_numpy()
103
+ ours = hilbert_distances_from_bounds(
104
+ gdf.geometry.bounds.to_numpy(dtype=np.float64), total, level=16
105
+ )
106
+ assert np.array_equal(public.astype(np.uint64), ours)
107
+
108
+
109
+ def test_crs_total_bounds_antimeridian():
110
+ # NZGD2000 / NZTM: the area of use spans the antimeridian (west > east);
111
+ # the projected bounds must still be finite and non-degenerate.
112
+ xmin, ymin, xmax, ymax = crs_total_bounds("EPSG:2193")
113
+ assert xmax > xmin and ymax > ymin
114
+ assert all(np.isfinite(v) for v in (xmin, ymin, xmax, ymax))
115
+
116
+
117
+ def test_hilbert_sort_falls_back_without_area_of_use():
118
+ # A custom projected CRS without area_of_use must not fail the conversion;
119
+ # the sorter falls back to the dataset's own bounds.
120
+ from shapely.geometry import Point
121
+
122
+ from vecorel_cli.vecorel.hilbert import hilbert_sort_geodataframe
123
+
124
+ crs = "+proj=tmerc +lat_0=0 +lon_0=9 +k=1 +x_0=0 +y_0=0 +ellps=WGS84 +units=m +no_defs"
125
+ gdf = gpd.GeoDataFrame(geometry=[Point(0, 0), Point(1000, 1000), Point(10, 10)], crs=crs)
126
+ out = hilbert_sort_geodataframe(gdf)
127
+ assert len(out) == 3
128
+
129
+
130
+ CONFIG = {
131
+ "id": "test",
132
+ "short_name": "Test",
133
+ "title": "Test dataset",
134
+ "description": "Test dataset",
135
+ "license": "CC0-1.0",
136
+ "columns": {
137
+ "geometry": "geometry",
138
+ "id": "id",
139
+ "name": "name",
140
+ },
141
+ "missing_schemas": {
142
+ "properties": {
143
+ "name": {"type": "string"},
144
+ }
145
+ },
146
+ }
147
+
148
+
149
+ def test_rows_are_numbered_when_no_column_is_mapped_to_id(tmp_folder):
150
+ """A converter that maps nothing to `id` gets the row number as id, counted
151
+ over all source files (vecorel/cli#47). A source column named `id` that is
152
+ mapped to another target must not be overwritten by the numbering."""
153
+ import shapely
154
+
155
+ files = {}
156
+ for index in range(2):
157
+ gdf = gpd.GeoDataFrame(
158
+ {
159
+ "id": [f"legacy-{index}-1", f"legacy-{index}-2"],
160
+ "name": ["a", "b"],
161
+ "geometry": [
162
+ shapely.box(index * 4, 0, index * 4 + 1, 1),
163
+ shapely.box(index * 4 + 2, 0, index * 4 + 3, 1),
164
+ ],
165
+ },
166
+ crs="EPSG:4326",
167
+ )
168
+ path = tmp_folder / f"src_{index}.parquet"
169
+ gdf.to_parquet(path)
170
+ files[str(path)] = path.name
171
+
172
+ Converter = type(
173
+ "IndexConverter",
174
+ (BaseConverter,),
175
+ {
176
+ **CONFIG,
177
+ "columns": {"geometry": "geometry", "name": "name", "id": "legacy"},
178
+ "missing_schemas": {
179
+ "properties": {"name": {"type": "string"}, "legacy": {"type": "string"}}
180
+ },
181
+ },
182
+ )
183
+ dest = tmp_folder / "converted.parquet"
184
+ Converter().convert(dest, input_files=files)
185
+
186
+ result = gpd.read_parquet(dest).sort_values("id")
187
+ assert result["id"].tolist() == ["0", "1", "2", "3"]
188
+ assert result["legacy"].tolist() == ["legacy-0-1", "legacy-0-2", "legacy-1-1", "legacy-1-2"]
189
+
190
+
191
+ def test_id_is_composed_from_id_columns(tmp_folder):
192
+ """`id_columns` joins the named columns into `id` after the column migrations
193
+ ran; integer-typed float columns must not render as '4.0'. It also wins over
194
+ a mapping to `id` (often inherited), which would otherwise produce two
195
+ columns named `id` after the rename — without overwriting a source column
196
+ named `id` that is mapped to another target."""
197
+ import shapely
198
+
199
+ gdf = gpd.GeoDataFrame(
200
+ {
201
+ "id": ["source-1", "source-2"],
202
+ "region": ["A", "B"],
203
+ "block": [7.0, 40.0], # float-typed integers, as nullable int columns often read
204
+ "name": ["a", "b"],
205
+ "geometry": [shapely.box(0, 0, 1, 1), shapely.box(2, 0, 3, 1)],
206
+ },
207
+ crs="EPSG:4326",
208
+ )
209
+ src = tmp_folder / "source.parquet"
210
+ gdf.to_parquet(src)
211
+
212
+ Converter = type(
213
+ "ComposedConverter",
214
+ (BaseConverter,),
215
+ {
216
+ **CONFIG,
217
+ # the mapping to `id` must lose against id_columns, but `source_id`
218
+ # must still receive the source's own `id` values
219
+ "columns": {**CONFIG["columns"], "id": ("id", "source_id")},
220
+ "missing_schemas": {
221
+ "properties": {"name": {"type": "string"}, "source_id": {"type": "string"}}
222
+ },
223
+ "id_columns": ("campaign", "region", "block"),
224
+ "id_separator": ":",
225
+ "column_additions": {"campaign": 2026.0}, # must format as 2026, not 2026.0
226
+ },
227
+ )
228
+ dest = tmp_folder / "converted.parquet"
229
+ Converter().convert(dest, input_files={str(src): "source.parquet"})
230
+
231
+ result = gpd.read_parquet(dest).sort_values("id")
232
+ assert result["id"].tolist() == ["2026:A:7", "2026:B:40"]
233
+ assert result["source_id"].tolist() == ["source-1", "source-2"]
234
+
235
+
236
+ def test_preserve_source_id_avoids_taken_names():
237
+ """The name the source id moves to must be free in the frame and the mapping."""
238
+ import shapely
239
+
240
+ gdf = gpd.GeoDataFrame(
241
+ {"id": ["a"], "__source_id": ["b"], "geometry": [shapely.box(0, 0, 1, 1)]},
242
+ crs="EPSG:4326",
243
+ )
244
+ columns = {"id": "legacy", "__source_id": "other", "geometry": "geometry"}
245
+
246
+ gdf, columns = BaseConverter()._preserve_source_id(gdf, columns)
247
+
248
+ assert not gdf.columns.duplicated().any()
249
+ assert columns["__source_id"] == "other"
250
+ assert columns["__source_id_"] == "legacy"
251
+ assert gdf["__source_id_"].tolist() == ["a"]
252
+ assert gdf["__source_id"].tolist() == ["b"]
253
+
254
+
255
+ def test_suffix_duplicate_ids_avoids_existing_ids_and_compares_as_strings():
256
+ """A numbered id must not collide with an id the source already carries
257
+ (x, x, x~1), and repeats hidden by mixed types (1 vs "1") must be caught:
258
+ the writer merges both into the string "1"."""
259
+ import shapely
260
+
261
+ from vecorel_cli.vecorel.util import suffix_duplicate_ids
262
+
263
+ box = shapely.box(0, 0, 1, 1)
264
+ gdf = gpd.GeoDataFrame({"id": ["x", "x", "x~1"], "geometry": [box] * 3}, crs="EPSG:4326")
265
+ gdf, count = suffix_duplicate_ids(gdf)
266
+ assert count == 2
267
+ assert gdf["id"].is_unique, gdf["id"].tolist()
268
+
269
+ gdf = gpd.GeoDataFrame({"id": [1, "1"], "geometry": [box] * 2}, crs="EPSG:4326")
270
+ gdf, count = suffix_duplicate_ids(gdf)
271
+ assert count == 2
272
+ assert sorted(gdf["id"]) == ["1~1", "1~2"]
273
+
274
+
275
+ def test_features_keep_their_row_when_repaired(tmp_folder):
276
+ """Multi-part geometries are not split into one row per polygon: features keep
277
+ the geometry modeling of the source, so their ids and attributes stay 1:1 with
278
+ it. A repaired invalid polygon stays one feature as a MultiPolygon, and rows
279
+ without a polygonal geometry are dropped."""
280
+ import shapely
281
+
282
+ gdf = gpd.GeoDataFrame(
283
+ {
284
+ "id": [
285
+ "square",
286
+ "multi",
287
+ "bowtie",
288
+ "point",
289
+ "collection",
290
+ "debris",
291
+ "overlap",
292
+ "nested",
293
+ ],
294
+ "name": ["a", "b", "c", "d", "e", "f", "g", "h"],
295
+ "geometry": [
296
+ shapely.Polygon([(0, 0), (0, 1), (1, 1), (1, 0)]),
297
+ shapely.MultiPolygon(
298
+ [
299
+ shapely.Polygon([(2, 0), (2, 1), (3, 1), (3, 0)]),
300
+ shapely.Polygon([(4, 0), (4, 1), (5, 1), (5, 0)]),
301
+ ]
302
+ ),
303
+ shapely.Polygon([(6, 0), (7, 1), (7, 0), (6, 1)]),
304
+ shapely.Point(10, 0),
305
+ # what make_valid() can emit: the polygonal part must survive alone
306
+ shapely.GeometryCollection(
307
+ [
308
+ shapely.Polygon([(8, 0), (8, 1), (9, 1), (9, 0)]),
309
+ shapely.LineString([(8, 2), (9, 2)]),
310
+ ]
311
+ ),
312
+ shapely.GeometryCollection([shapely.LineString([(10, 2), (11, 2)])]),
313
+ # members of a collection may overlap; the row must survive as a
314
+ # valid geometry instead of becoming an invalid MultiPolygon
315
+ shapely.GeometryCollection([shapely.box(12, 0, 14, 2), shapely.box(13, 1, 15, 3)]),
316
+ shapely.GeometryCollection(
317
+ [
318
+ shapely.GeometryCollection([shapely.box(16, 0, 17, 1)]),
319
+ shapely.LineString([(16, 2), (17, 2)]),
320
+ ]
321
+ ),
322
+ ],
323
+ },
324
+ crs="EPSG:4326",
325
+ )
326
+ src = tmp_folder / "source.parquet"
327
+ gdf.to_parquet(src)
328
+
329
+ Converter = type("KeepConverter", (BaseConverter,), dict(CONFIG))
330
+ dest = tmp_folder / "converted.parquet"
331
+ Converter().convert(dest, input_files={str(src): "source.parquet"})
332
+
333
+ result = gpd.read_parquet(dest)
334
+ types = dict(zip(result["id"], result.geometry.geom_type))
335
+ assert sorted(types) == ["bowtie", "collection", "multi", "nested", "overlap", "square"]
336
+ assert types["square"] == "Polygon"
337
+ assert types["multi"] == "MultiPolygon"
338
+ assert types["bowtie"] == "MultiPolygon"
339
+ assert types["collection"] == "MultiPolygon"
340
+ assert types["nested"] == "MultiPolygon"
341
+ assert result.geometry.is_valid.all()
342
+
343
+
344
+ def test_duplicate_ids_get_numbered(tmp_folder):
345
+ """id must be unique within a file; when the source repeats ids (here across
346
+ two input files), the repeats get a ~<n> suffix (vecorel/cli#47)."""
347
+ import shapely
348
+
349
+ files = {}
350
+ for index in range(2):
351
+ gdf = gpd.GeoDataFrame(
352
+ {
353
+ "id": ["same", f"other-{index}"],
354
+ "name": ["a", "b"],
355
+ "geometry": [
356
+ shapely.box(index * 4, 0, index * 4 + 1, 1),
357
+ shapely.box(index * 4 + 2, 0, index * 4 + 3, 1),
358
+ ],
359
+ },
360
+ crs="EPSG:4326",
361
+ )
362
+ path = tmp_folder / f"dup_src_{index}.parquet"
363
+ gdf.to_parquet(path)
364
+ files[str(path)] = path.name
365
+
366
+ Converter = type("DupConverter", (BaseConverter,), dict(CONFIG))
367
+ dest = tmp_folder / "converted.parquet"
368
+ Converter().convert(dest, input_files=files)
369
+
370
+ assert sorted(gpd.read_parquet(dest)["id"]) == ["other-0", "other-1", "same~1", "same~2"]
371
+
372
+
373
+ def test_not_existing_converter(tmp_folder):
374
+ with pytest.raises(Exception, match="Converter 'not_existing' not found"):
375
+ converter = ConvertData("not_existing")
376
+ converter.convert(tmp_folder / "converted.parquet")
377
+
378
+
379
+ @pytest.mark.parametrize("choice", ["invalid_syntax", "invalid_name"])
380
+ def test_invalid_converter(tmp_folder, choice):
381
+ with pytest.raises(Exception, match=f"Converter for '{choice}' not available or faulty:"):
382
+ converter = ConvertData(choice)
383
+ converter.convert(tmp_folder / "converted.parquet")
384
+
385
+
386
+ def test_template_from_package_folder():
387
+ Registry.src_package = "vecorel_cli"
388
+ Registry.ignored_datasets = []
389
+ converter = ConvertData("template")
390
+ assert isinstance(converter, ConvertData), "Should succeed and not throw an exception"
391
+
392
+
393
+ @pytest.mark.parametrize("bom", [False, True], ids=["without-bom", "with-bom"])
394
+ def test_read_geojson_decodes_utf8(tmp_folder, cp1252_locale, bom):
395
+ file_path = tmp_folder / "umlaut.json"
396
+ feature = {
397
+ "type": "Feature",
398
+ "id": "1",
399
+ "properties": {"name": "Grünland"},
400
+ "geometry": {"type": "Point", "coordinates": [0, 0]},
401
+ }
402
+ file_path.write_text(
403
+ # ensure_ascii=False so the file really holds multi-byte UTF-8, not an escape sequence
404
+ json.dumps({"type": "FeatureCollection", "features": [feature]}, ensure_ascii=False),
405
+ encoding="utf-8-sig" if bom else "utf-8",
406
+ )
407
+
408
+ gdf = BaseConverter().read_geojson(str(file_path))
409
+
410
+ assert gdf["name"].iloc[0] == "Grünland"
411
+
412
+
413
+ def test_data_access_exception(capsys, tmp_folder):
414
+ # todo: use fixture
415
+ logger.remove()
416
+ logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
417
+
418
+ with pytest.raises(Exception, match="Please provide the input data."):
419
+ converter = ConvertData("data_access")
420
+ converter.convert(tmp_folder / "converted.parquet")
421
+
422
+ out, err = capsys.readouterr()
423
+
424
+ assert isinstance(converter.converter, BaseConverter)
425
+ assert converter.converter.data_access in out
426
+
427
+
428
+ @pytest.mark.parametrize("choice", ["example"])
429
+ def test_converter_can_keep_the_constants_in_columns(tmp_folder, monkeypatch, choice):
430
+ """A conversion that writes one part of a dataset cannot let constants move
431
+ into the collection metadata: constant is then judged over the part."""
432
+ import pyarrow.parquet as pq
433
+
434
+ from tests.datasets.example import Converter
435
+
436
+ dest = tmp_folder / "hydrated.parquet"
437
+ monkeypatch.setattr(Converter, "dehydrate", False)
438
+ ConvertData(choice).convert(dest, cache=(test_path / choice))
439
+
440
+ schema = pq.ParquetFile(dest).schema_arrow
441
+ collection = json.loads(schema.metadata[b"collection"])
442
+ for key in ("admin:country_code", "admin:subdivision_code", "determination_datetime"):
443
+ assert key in schema.names, f"{key} should have stayed a column"
444
+ assert key not in collection, f"{key} should not be in the collection metadata"
445
+
446
+
447
+ def test_default_variant_is_chosen_before_get_urls():
448
+ """A converter that overrides get_urls() must see the default variant, so the
449
+ default lives in convert() and not in the base get_urls() it replaces."""
450
+
451
+ class Converter(BaseConverter):
452
+ id = "variants"
453
+ # oldest first: the latest year is still the default
454
+ variants = {
455
+ "2024": "https://example.com/2024.gpkg",
456
+ "2025": "https://example.com/2025.gpkg",
457
+ }
458
+
459
+ def get_urls(self):
460
+ # like the converters that look their files up by year
461
+ return {f"https://example.com/{self.variant}/": f"{self.variant}.gpkg"}
462
+
463
+ converter = Converter()
464
+ converter.select_variant(None)
465
+ assert converter.variant == "2025"
466
+ assert converter.get_urls() == {"https://example.com/2025/": "2025.gpkg"}
467
+
468
+ converter.select_variant("2024")
469
+ assert converter.variant == "2024"
470
+ assert converter.get_urls() == {"https://example.com/2024/": "2024.gpkg"}
471
+
472
+
473
+ def test_default_variant_is_the_first_declared_unless_the_variants_are_years():
474
+ class Converter(BaseConverter):
475
+ id = "variants"
476
+ variants = {"full": "https://example.com/full.gpkg", "2025": "https://example.com/2025"}
477
+
478
+ converter = Converter()
479
+ converter.select_variant(None)
480
+ assert converter.variant == "full"
481
+
482
+
483
+ def test_get_urls_rejects_an_unknown_variant():
484
+ class Converter(BaseConverter):
485
+ id = "variants"
486
+ variants = {"2025": "https://example.com/2025.gpkg"}
487
+
488
+ converter = Converter()
489
+ converter.select_variant("2025")
490
+ assert converter.get_urls() == "https://example.com/2025.gpkg"
491
+
492
+ converter.select_variant("1999")
493
+ with pytest.raises(ValueError, match="Unknown variant '1999'"):
494
+ converter.get_urls()