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.
- {vecorel_cli-0.2.17/vecorel_cli.egg-info → vecorel_cli-0.2.19}/PKG-INFO +4 -3
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/pyproject.toml +5 -4
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_convert.py +19 -0
- vecorel_cli-0.2.19/tests/test_convert_duckdb.py +414 -0
- vecorel_cli-0.2.19/tests/test_download.py +59 -0
- vecorel_cli-0.2.19/tests/test_encoding_geoparquet.py +150 -0
- vecorel_cli-0.2.19/tests/test_hilbert.py +89 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/const.py +5 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/conversion/base.py +185 -7
- vecorel_cli-0.2.19/vecorel_cli/conversion/duckdb.py +534 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/base.py +10 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/geoparquet.py +239 -5
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/parquet/types.py +48 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validate_schema.py +2 -1
- vecorel_cli-0.2.19/vecorel_cli/vecorel/hilbert.py +310 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/util.py +4 -2
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19/vecorel_cli.egg-info}/PKG-INFO +4 -3
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/SOURCES.txt +4 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/requires.txt +3 -2
- vecorel_cli-0.2.17/tests/test_encoding_geoparquet.py +0 -48
- vecorel_cli-0.2.17/vecorel_cli/vecorel/hilbert.py +0 -142
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/LICENSE +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/README.md +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/setup.cfg +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_converters.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_create_geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_create_geoparquet.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_create_stac.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_describe.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_encoding_auto.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_encoding_geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_improve.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_jsonschema.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_merge.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_ops.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_rename_extension.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_validate.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/tests/test_validate_schema.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/basecommand.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/logger.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/options.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/path_url.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/setup.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/cli/util.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/conversion/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/conversion/admin.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/conversion/flatdict.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/convert.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/converters.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/create_geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/create_geoparquet.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/create_jsonschema.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/create_stac.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/datasets/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/datasets/template.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/describe.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/auto.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/encoding/geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/improve.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/jsonschema/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/jsonschema/template.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/merge.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/parquet/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/parquet/geopandas.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/registry.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/rename_extension.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validate.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/base.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/data.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/validation/geoparquet.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/collection.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/extensions.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/ops.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/schemas.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/typing.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/version.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/dependency_links.txt +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|