vecorel-cli 0.2.18__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.
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/PKG-INFO +4 -3
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/pyproject.toml +6 -4
- vecorel_cli-0.3.0/tests/test_convert.py +494 -0
- vecorel_cli-0.3.0/tests/test_convert_duckdb.py +766 -0
- vecorel_cli-0.3.0/tests/test_download.py +194 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_encoding_geoparquet.py +25 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_improve.py +25 -1
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/conversion/base.py +268 -70
- vecorel_cli-0.3.0/vecorel_cli/conversion/duckdb.py +696 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/encoding/geoparquet.py +2 -1
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/improve.py +9 -1
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/parquet/types.py +14 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/util.py +51 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli.egg-info/PKG-INFO +4 -3
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli.egg-info/SOURCES.txt +1 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli.egg-info/requires.txt +3 -2
- vecorel_cli-0.2.18/tests/test_convert.py +0 -182
- vecorel_cli-0.2.18/tests/test_convert_duckdb.py +0 -220
- vecorel_cli-0.2.18/vecorel_cli/conversion/duckdb.py +0 -423
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/LICENSE +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/README.md +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/setup.cfg +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_converters.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_create_geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_create_geoparquet.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_create_stac.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_describe.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_encoding_auto.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_encoding_geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_hilbert.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_jsonschema.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_merge.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_ops.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_rename_extension.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_validate.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/tests/test_validate_schema.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/basecommand.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/cli/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/cli/logger.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/cli/options.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/cli/path_url.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/cli/setup.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/cli/util.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/const.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/conversion/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/conversion/admin.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/conversion/flatdict.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/convert.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/converters.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/create_geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/create_geoparquet.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/create_jsonschema.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/create_stac.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/datasets/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/datasets/template.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/describe.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/encoding/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/encoding/auto.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/encoding/base.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/encoding/geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/jsonschema/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/jsonschema/template.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/merge.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/parquet/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/parquet/geopandas.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/registry.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/rename_extension.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/validate.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/validate_schema.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/validation/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/validation/base.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/validation/data.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/validation/geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/validation/geoparquet.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/collection.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/extensions.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/hilbert.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/ops.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/schemas.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/typing.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli/vecorel/version.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli.egg-info/dependency_links.txt +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.3.0}/vecorel_cli.egg-info/entry_points.txt +0 -0
- {vecorel_cli-0.2.18 → 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.
|
|
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,15 +18,16 @@ 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.
|
|
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.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vecorel-cli"
|
|
7
|
-
version = "0.
|
|
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,15 +24,16 @@ requires-python = ">=3.11"
|
|
|
24
24
|
dependencies = [
|
|
25
25
|
"pyyaml>=6.0,<7.0",
|
|
26
26
|
"click>=8.1,<9.0",
|
|
27
|
-
"geopandas>=1.
|
|
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.
|
|
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",
|
|
@@ -65,12 +66,13 @@ platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"]
|
|
|
65
66
|
python = ">=3.11"
|
|
66
67
|
pyyaml = ">=6.0,<7.0"
|
|
67
68
|
click = ">=8.1,<9.0"
|
|
68
|
-
geopandas = ">=1.
|
|
69
|
+
geopandas = ">=1.1.0,<2.0"
|
|
69
70
|
requests = ">=2.30,<3.0"
|
|
70
71
|
shapely = ">=2.1,<3.0"
|
|
71
72
|
numpy = ">=2.0,<3.0"
|
|
72
73
|
pyarrow = ">=21.0,<24.0"
|
|
73
74
|
py7zr = ">=1.0,<2.0"
|
|
75
|
+
multivolumefile = ">=0.2.3,<1.0"
|
|
74
76
|
unrar = ">=0.4"
|
|
75
77
|
|
|
76
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()
|