vecorel-cli 0.2.18__tar.gz → 0.2.20__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.2.20}/PKG-INFO +3 -3
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/pyproject.toml +4 -4
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_convert.py +69 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_convert_duckdb.py +194 -0
- vecorel_cli-0.2.20/tests/test_download.py +59 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_encoding_geoparquet.py +25 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/conversion/base.py +50 -7
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/conversion/duckdb.py +160 -49
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/encoding/geoparquet.py +2 -1
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/parquet/types.py +14 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli.egg-info/PKG-INFO +3 -3
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli.egg-info/SOURCES.txt +1 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli.egg-info/requires.txt +2 -2
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/LICENSE +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/README.md +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/setup.cfg +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_converters.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_create_geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_create_geoparquet.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_create_stac.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_describe.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_encoding_auto.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_encoding_geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_hilbert.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_improve.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_jsonschema.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_merge.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_ops.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_rename_extension.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_validate.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/tests/test_validate_schema.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/basecommand.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/cli/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/cli/logger.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/cli/options.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/cli/path_url.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/cli/setup.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/cli/util.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/const.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/conversion/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/conversion/admin.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/conversion/flatdict.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/convert.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/converters.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/create_geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/create_geoparquet.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/create_jsonschema.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/create_stac.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/datasets/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/datasets/template.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/describe.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/encoding/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/encoding/auto.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/encoding/base.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/encoding/geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/improve.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/jsonschema/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/jsonschema/template.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/merge.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/parquet/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/parquet/geopandas.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/registry.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/rename_extension.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/validate.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/validate_schema.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/validation/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/validation/base.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/validation/data.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/validation/geojson.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/validation/geoparquet.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/vecorel/__init__.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/vecorel/collection.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/vecorel/extensions.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/vecorel/hilbert.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/vecorel/ops.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/vecorel/schemas.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/vecorel/typing.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/vecorel/util.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli/vecorel/version.py +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli.egg-info/dependency_links.txt +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/vecorel_cli.egg-info/entry_points.txt +0 -0
- {vecorel_cli-0.2.18 → vecorel_cli-0.2.20}/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.20
|
|
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,7 +26,7 @@ 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
|
|
@@ -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.20"
|
|
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,7 +32,7 @@ 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",
|
|
@@ -65,7 +65,7 @@ platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"]
|
|
|
65
65
|
python = ">=3.11"
|
|
66
66
|
pyyaml = ">=6.0,<7.0"
|
|
67
67
|
click = ">=8.1,<9.0"
|
|
68
|
-
geopandas = ">=1.
|
|
68
|
+
geopandas = ">=1.1.0,<2.0"
|
|
69
69
|
requests = ">=2.30,<3.0"
|
|
70
70
|
shapely = ">=2.1,<3.0"
|
|
71
71
|
numpy = ">=2.0,<3.0"
|
|
@@ -180,3 +180,72 @@ 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"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_default_variant_is_chosen_before_get_urls():
|
|
205
|
+
"""A converter that overrides get_urls() must see the default variant, so the
|
|
206
|
+
default lives in convert() and not in the base get_urls() it replaces."""
|
|
207
|
+
|
|
208
|
+
class Converter(BaseConverter):
|
|
209
|
+
id = "variants"
|
|
210
|
+
# oldest first: the latest year is still the default
|
|
211
|
+
variants = {
|
|
212
|
+
"2024": "https://example.com/2024.gpkg",
|
|
213
|
+
"2025": "https://example.com/2025.gpkg",
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def get_urls(self):
|
|
217
|
+
# like the converters that look their files up by year
|
|
218
|
+
return {f"https://example.com/{self.variant}/": f"{self.variant}.gpkg"}
|
|
219
|
+
|
|
220
|
+
converter = Converter()
|
|
221
|
+
converter.select_variant(None)
|
|
222
|
+
assert converter.variant == "2025"
|
|
223
|
+
assert converter.get_urls() == {"https://example.com/2025/": "2025.gpkg"}
|
|
224
|
+
|
|
225
|
+
converter.select_variant("2024")
|
|
226
|
+
assert converter.variant == "2024"
|
|
227
|
+
assert converter.get_urls() == {"https://example.com/2024/": "2024.gpkg"}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_default_variant_is_the_first_declared_unless_the_variants_are_years():
|
|
231
|
+
class Converter(BaseConverter):
|
|
232
|
+
id = "variants"
|
|
233
|
+
variants = {"full": "https://example.com/full.gpkg", "2025": "https://example.com/2025"}
|
|
234
|
+
|
|
235
|
+
converter = Converter()
|
|
236
|
+
converter.select_variant(None)
|
|
237
|
+
assert converter.variant == "full"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_get_urls_rejects_an_unknown_variant():
|
|
241
|
+
class Converter(BaseConverter):
|
|
242
|
+
id = "variants"
|
|
243
|
+
variants = {"2025": "https://example.com/2025.gpkg"}
|
|
244
|
+
|
|
245
|
+
converter = Converter()
|
|
246
|
+
converter.select_variant("2025")
|
|
247
|
+
assert converter.get_urls() == "https://example.com/2025.gpkg"
|
|
248
|
+
|
|
249
|
+
converter.select_variant("1999")
|
|
250
|
+
with pytest.raises(ValueError, match="Unknown variant '1999'"):
|
|
251
|
+
converter.get_urls()
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import sys
|
|
2
3
|
|
|
3
4
|
import geopandas as gpd
|
|
4
5
|
import numpy as np
|
|
5
6
|
import pyarrow.parquet as pq
|
|
6
7
|
import pytest
|
|
7
8
|
import shapely
|
|
9
|
+
from loguru import logger
|
|
8
10
|
|
|
9
11
|
from vecorel_cli.conversion.base import BaseConverter
|
|
10
12
|
from vecorel_cli.conversion.duckdb import DuckDBBaseConverter
|
|
@@ -218,3 +220,195 @@ def test_codepath_parity(tmp_folder):
|
|
|
218
220
|
for dest in (pandas_dest, duckdb_dest):
|
|
219
221
|
validation = ValidateData().validate(dest, num=100, schema_map={})
|
|
220
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"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
4
6
|
import pyarrow as pa
|
|
5
7
|
import pyarrow.parquet as pq
|
|
6
8
|
import pyarrow.types as pat
|
|
@@ -123,3 +125,26 @@ def test_postprocess(tmp_parquet_file):
|
|
|
123
125
|
geo = json.loads(pf.metadata.metadata[b"geo"])
|
|
124
126
|
assert geo["version"] == "1.0.0"
|
|
125
127
|
assert "covering" not in geo["columns"]["geometry"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_read_keeps_integers_that_have_a_null(tmp_parquet_file):
|
|
131
|
+
# pandas turns an integer column with a missing value into float64, after which
|
|
132
|
+
# every value in it reads as a float and validation rejects the column
|
|
133
|
+
src = pq.read_table("tests/data-files/inspire.parquet")
|
|
134
|
+
counts = pa.array([7] * (len(src) - 1) + [None], pa.uint32())
|
|
135
|
+
table = src.append_column(pa.field("count", pa.uint32(), nullable=True), counts)
|
|
136
|
+
|
|
137
|
+
metadata = dict(src.schema.metadata)
|
|
138
|
+
collection = json.loads(metadata[b"collection"])
|
|
139
|
+
collection["schemas:custom"]["properties"]["count"] = {"type": "uint32"}
|
|
140
|
+
metadata[b"collection"] = json.dumps(collection).encode("utf-8")
|
|
141
|
+
pq.write_table(table.replace_schema_metadata(metadata), tmp_parquet_file, store_schema=True)
|
|
142
|
+
|
|
143
|
+
data = GeoParquet(tmp_parquet_file).read()
|
|
144
|
+
assert str(data["count"].dtype) == "UInt32"
|
|
145
|
+
assert isinstance(data["count"].iloc[0], np.uint32)
|
|
146
|
+
assert pd.isna(data["count"].iloc[-1])
|
|
147
|
+
|
|
148
|
+
from vecorel_cli.validate import ValidateData
|
|
149
|
+
|
|
150
|
+
assert ValidateData().validate(tmp_parquet_file).errors == []
|
|
@@ -4,6 +4,7 @@ import hashlib
|
|
|
4
4
|
import inspect
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
import sys
|
|
8
9
|
import tarfile
|
|
9
10
|
import zipfile
|
|
@@ -67,6 +68,12 @@ class BaseConverter(LoggerMixin):
|
|
|
67
68
|
# missing geometry are always dropped, regardless of this share.
|
|
68
69
|
max_dropped_share: float = 0.01
|
|
69
70
|
|
|
71
|
+
# Move properties that hold one value for every row into the collection metadata.
|
|
72
|
+
# A conversion that writes one part of a dataset must not: "one value for every
|
|
73
|
+
# row" is then judged over the part rather than over the dataset, so a property
|
|
74
|
+
# that varies between parts is lost.
|
|
75
|
+
dehydrate: bool = True
|
|
76
|
+
|
|
70
77
|
def __init__(self, *args, **kwargs):
|
|
71
78
|
super().__init__()
|
|
72
79
|
|
|
@@ -86,6 +93,24 @@ class BaseConverter(LoggerMixin):
|
|
|
86
93
|
"variants = {} when the inherited ones do not apply."
|
|
87
94
|
)
|
|
88
95
|
|
|
96
|
+
def select_variant(self, variant: Optional[str]) -> None:
|
|
97
|
+
"""Store the requested variant; without one, the default variant is used.
|
|
98
|
+
Done at the start of convert() rather than in get_urls(), so a converter that
|
|
99
|
+
overrides get_urls() does not have to repeat the default, and so the default is
|
|
100
|
+
also set when the user supplies the input files."""
|
|
101
|
+
self.variant = variant
|
|
102
|
+
if self.variants and self.variant is None:
|
|
103
|
+
self.variant = self.default_variant()
|
|
104
|
+
opts = ", ".join(self.variants)
|
|
105
|
+
self.warning(f"No variant given, choosing {self.variant} from {opts}")
|
|
106
|
+
|
|
107
|
+
def default_variant(self) -> str:
|
|
108
|
+
"""The latest year when the variants are years, otherwise the first declared."""
|
|
109
|
+
keys = list(self.variants)
|
|
110
|
+
if all(re.fullmatch(r"\d{4}", key) for key in keys):
|
|
111
|
+
return max(keys)
|
|
112
|
+
return keys[0]
|
|
113
|
+
|
|
89
114
|
def _check_id_mapping(self):
|
|
90
115
|
"""Warn before converting when nothing is mapped to the required `id` property.
|
|
91
116
|
Unmapped columns are dropped, which also removes the column filled by
|
|
@@ -279,8 +304,7 @@ class BaseConverter(LoggerMixin):
|
|
|
279
304
|
must_extract = is_archive and not os.path.exists(zip_folder)
|
|
280
305
|
|
|
281
306
|
if (not is_archive or must_extract) and not cache_fs.exists(cache_file):
|
|
282
|
-
|
|
283
|
-
stream_file(source_fs, uri, file)
|
|
307
|
+
self._download_file(source_fs, uri, cache_fs, cache_file)
|
|
284
308
|
|
|
285
309
|
if must_extract:
|
|
286
310
|
if zipfile.is_zipfile(cache_file):
|
|
@@ -316,16 +340,34 @@ class BaseConverter(LoggerMixin):
|
|
|
316
340
|
|
|
317
341
|
return paths
|
|
318
342
|
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _download_file(source_fs, uri, cache_fs, cache_file):
|
|
345
|
+
"""Stream a remote file into the cache.
|
|
346
|
+
|
|
347
|
+
The data goes to a `.part` file that is renamed only after a clean close,
|
|
348
|
+
so an interrupted download leaves nothing that a later run could mistake
|
|
349
|
+
for a cached file.
|
|
350
|
+
"""
|
|
351
|
+
part_file = cache_file + ".part"
|
|
352
|
+
try:
|
|
353
|
+
with cache_fs.open(part_file, mode="wb") as file:
|
|
354
|
+
stream_file(source_fs, uri, file)
|
|
355
|
+
except BaseException:
|
|
356
|
+
try:
|
|
357
|
+
cache_fs.rm(part_file)
|
|
358
|
+
except FileNotFoundError:
|
|
359
|
+
pass
|
|
360
|
+
raise
|
|
361
|
+
cache_fs.mv(part_file, cache_file)
|
|
362
|
+
|
|
319
363
|
def get_urls(self):
|
|
320
364
|
urls = self.sources
|
|
321
365
|
if not urls and self.variants:
|
|
322
|
-
|
|
323
|
-
if self.variant is None:
|
|
324
|
-
self.variant = next(iter(self.variants))
|
|
325
|
-
self.warning(f"Choosing first available variant {self.variant} from {opts}")
|
|
366
|
+
# the default variant is chosen in convert(), see select_variant()
|
|
326
367
|
if self.variant in self.variants:
|
|
327
368
|
urls = self.variants[self.variant]
|
|
328
369
|
else:
|
|
370
|
+
opts = ", ".join(self.variants)
|
|
329
371
|
raise ValueError(f"Unknown variant '{self.variant}', choose from {opts}")
|
|
330
372
|
return urls
|
|
331
373
|
|
|
@@ -458,7 +500,7 @@ class BaseConverter(LoggerMixin):
|
|
|
458
500
|
original_geometries=False,
|
|
459
501
|
**kwargs,
|
|
460
502
|
) -> str:
|
|
461
|
-
self.variant
|
|
503
|
+
self.select_variant(variant)
|
|
462
504
|
cid = self.id.strip()
|
|
463
505
|
if self.bbox is not None and len(self.bbox) != 4:
|
|
464
506
|
raise ValueError("If provided, the bounding box must consist of 4 numbers")
|
|
@@ -587,6 +629,7 @@ class BaseConverter(LoggerMixin):
|
|
|
587
629
|
pq.write(
|
|
588
630
|
gdf,
|
|
589
631
|
properties=columns,
|
|
632
|
+
dehydrate=self.dehydrate,
|
|
590
633
|
compression=compression,
|
|
591
634
|
compression_level=compression_level,
|
|
592
635
|
geoparquet_version=geoparquet_version,
|
|
@@ -22,6 +22,29 @@ def _sql_path(path) -> str:
|
|
|
22
22
|
return f"'{escaped}'"
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
# A COPY statement binds its own parameters before those of its subquery, so a value
|
|
26
|
+
# placed in the SELECT cannot be a bound parameter: it would be read as the output path.
|
|
27
|
+
def _sql_literal(value) -> str:
|
|
28
|
+
if value is None:
|
|
29
|
+
return "NULL"
|
|
30
|
+
if isinstance(value, bool):
|
|
31
|
+
return "TRUE" if value else "FALSE"
|
|
32
|
+
if isinstance(value, int):
|
|
33
|
+
return repr(value)
|
|
34
|
+
if isinstance(value, float):
|
|
35
|
+
if np.isnan(value):
|
|
36
|
+
return "'NaN'::DOUBLE"
|
|
37
|
+
if np.isposinf(value):
|
|
38
|
+
return "'Infinity'::DOUBLE"
|
|
39
|
+
if np.isneginf(value):
|
|
40
|
+
return "'-Infinity'::DOUBLE"
|
|
41
|
+
return repr(value)
|
|
42
|
+
if not isinstance(value, str):
|
|
43
|
+
raise ValueError(f"Cannot use {value!r} as a constant column; it is not a scalar")
|
|
44
|
+
escaped = value.replace("'", "''")
|
|
45
|
+
return f"'{escaped}'"
|
|
46
|
+
|
|
47
|
+
|
|
25
48
|
def _normalize_crs(crs):
|
|
26
49
|
"""The comparable pyproj CRS for a GeoParquet crs value,
|
|
27
50
|
which defaults to OGC:CRS84 when missing."""
|
|
@@ -51,14 +74,7 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
51
74
|
original_geometries=False,
|
|
52
75
|
**kwargs,
|
|
53
76
|
) -> str:
|
|
54
|
-
|
|
55
|
-
# Same packaging as the GeoDataFrame-based codepath
|
|
56
|
-
compression = compression or "zstd"
|
|
57
|
-
if compression == "zstd" and compression_level is None:
|
|
58
|
-
compression_level = 15
|
|
59
|
-
row_group_size = GeoParquet.row_group_size
|
|
60
|
-
|
|
61
|
-
self.variant = variant
|
|
77
|
+
self.select_variant(variant)
|
|
62
78
|
cid = self.id.strip()
|
|
63
79
|
if self.bbox is not None and len(self.bbox) != 4:
|
|
64
80
|
raise ValueError("If provided, the bounding box must consist of 4 numbers")
|
|
@@ -108,28 +124,7 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
108
124
|
).fetchall()
|
|
109
125
|
}
|
|
110
126
|
|
|
111
|
-
|
|
112
|
-
# sources for the Hilbert grid and the output metadata. The sources are
|
|
113
|
-
# combined without reprojection, so they must all use the same CRS.
|
|
114
|
-
source_crs = None
|
|
115
|
-
reference = None
|
|
116
|
-
for i, source in enumerate([sources] if isinstance(sources, str) else sources):
|
|
117
|
-
crs = None
|
|
118
|
-
row = con.execute(
|
|
119
|
-
"SELECT value FROM parquet_kv_metadata(?) WHERE key = 'geo'", [source]
|
|
120
|
-
).fetchone()
|
|
121
|
-
if row:
|
|
122
|
-
source_geo = json.loads(bytes(row[0]))
|
|
123
|
-
primary_column = source_geo.get("primary_column", "")
|
|
124
|
-
crs = source_geo.get("columns", {}).get(primary_column, {}).get("crs")
|
|
125
|
-
if i == 0:
|
|
126
|
-
source_crs = crs
|
|
127
|
-
reference = _normalize_crs(crs)
|
|
128
|
-
elif not _equal_crs(_normalize_crs(crs), reference):
|
|
129
|
-
raise ValueError(
|
|
130
|
-
f"The sources use different coordinate reference systems: {source} "
|
|
131
|
-
"differs from the first source. Reproject the sources to a common CRS."
|
|
132
|
-
)
|
|
127
|
+
source_crs = self._common_crs(con, [sources] if isinstance(sources, str) else sources)
|
|
133
128
|
selections = []
|
|
134
129
|
selected_targets = []
|
|
135
130
|
for k, v in self.columns.items():
|
|
@@ -150,10 +145,9 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
150
145
|
collection = self.create_collection(cid)
|
|
151
146
|
collection["collection"] = cid
|
|
152
147
|
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
# GeoDataFrame-based codepath
|
|
156
|
-
addition_params = []
|
|
148
|
+
# A constant the schema pins to the feature level becomes a literal column and one
|
|
149
|
+
# it pins to the collection goes there; the rest follow `dehydrate`, like the
|
|
150
|
+
# dehydration step of the GeoDataFrame-based codepath
|
|
157
151
|
if self.column_additions:
|
|
158
152
|
context = collection.get_collection_context()
|
|
159
153
|
for key, value in self.column_additions.items():
|
|
@@ -162,12 +156,12 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
162
156
|
keep = [i for i, t in enumerate(selected_targets) if t != key]
|
|
163
157
|
selections = [selections[i] for i in keep]
|
|
164
158
|
selected_targets = [selected_targets[i] for i in keep]
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
addition_params.append(value)
|
|
168
|
-
selected_targets.append(key)
|
|
169
|
-
else:
|
|
159
|
+
where = context.get(key)
|
|
160
|
+
if where is True or (where is None and self.dehydrate):
|
|
170
161
|
collection[key] = value
|
|
162
|
+
else:
|
|
163
|
+
selections.append(f'{_sql_literal(value)} as "{key}"')
|
|
164
|
+
selected_targets.append(key)
|
|
171
165
|
selection = ", ".join(selections)
|
|
172
166
|
|
|
173
167
|
filters = []
|
|
@@ -191,11 +185,6 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
191
185
|
if len(filters) > 0:
|
|
192
186
|
where = f"WHERE {' AND '.join(filters)}"
|
|
193
187
|
|
|
194
|
-
if isinstance(output_file, Path):
|
|
195
|
-
output_file = str(output_file)
|
|
196
|
-
|
|
197
|
-
collection_json = json.dumps(collection, cls=VecorelJSONEncoder).encode("utf-8")
|
|
198
|
-
|
|
199
188
|
if isinstance(sources, str):
|
|
200
189
|
sources_sql = _sql_path(sources)
|
|
201
190
|
else:
|
|
@@ -206,6 +195,89 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
206
195
|
{where}
|
|
207
196
|
"""
|
|
208
197
|
|
|
198
|
+
return self.write_query(
|
|
199
|
+
con,
|
|
200
|
+
source_query,
|
|
201
|
+
output_file,
|
|
202
|
+
collection,
|
|
203
|
+
targets=selected_targets,
|
|
204
|
+
source_crs=source_crs,
|
|
205
|
+
ids_are_generated=self.index_as_id,
|
|
206
|
+
compression=compression,
|
|
207
|
+
compression_level=compression_level,
|
|
208
|
+
geoparquet_version=geoparquet_version,
|
|
209
|
+
original_geometries=original_geometries,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _common_crs(self, con, sources: list):
|
|
213
|
+
"""The CRS the sources declare, refusing a set that does not agree on one.
|
|
214
|
+
|
|
215
|
+
They are combined without reprojection, and DuckDB before 1.5 drops the CRS
|
|
216
|
+
from the metadata, so it has to be read from the files themselves.
|
|
217
|
+
"""
|
|
218
|
+
source_crs = None
|
|
219
|
+
reference = None
|
|
220
|
+
for i, source in enumerate(sources):
|
|
221
|
+
crs = None
|
|
222
|
+
row = con.execute(
|
|
223
|
+
"SELECT value FROM parquet_kv_metadata(?) WHERE key = 'geo'", [source]
|
|
224
|
+
).fetchone()
|
|
225
|
+
if row:
|
|
226
|
+
geo = json.loads(bytes(row[0]))
|
|
227
|
+
primary = geo.get("primary_column", "")
|
|
228
|
+
crs = geo.get("columns", {}).get(primary, {}).get("crs")
|
|
229
|
+
if i == 0:
|
|
230
|
+
source_crs = crs
|
|
231
|
+
reference = _normalize_crs(crs)
|
|
232
|
+
elif not _equal_crs(_normalize_crs(crs), reference):
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"The sources use different coordinate reference systems: {source} "
|
|
235
|
+
"differs from the first source. Reproject the sources to a common CRS."
|
|
236
|
+
)
|
|
237
|
+
return source_crs
|
|
238
|
+
|
|
239
|
+
def write_query(
|
|
240
|
+
self,
|
|
241
|
+
con,
|
|
242
|
+
source_query: str,
|
|
243
|
+
output_file,
|
|
244
|
+
collection,
|
|
245
|
+
targets: list,
|
|
246
|
+
source_crs=None,
|
|
247
|
+
# convert() numbers the rows itself, so they are unique by construction; a merge
|
|
248
|
+
# combines parts that each started over, so it always has to check
|
|
249
|
+
ids_are_generated: bool = False,
|
|
250
|
+
compression: Optional[str] = None,
|
|
251
|
+
compression_level: Optional[int] = None,
|
|
252
|
+
geoparquet_version: Optional[str] = None,
|
|
253
|
+
original_geometries: bool = False,
|
|
254
|
+
) -> str:
|
|
255
|
+
"""Write the rows a SELECT returns as a Vecorel GeoParquet file: drop rows
|
|
256
|
+
that no required property or geometry survives, report an id that is not
|
|
257
|
+
unique, normalize the geometries, sort into Hilbert order and package.
|
|
258
|
+
|
|
259
|
+
`targets` names the properties the query returns, which is what the checks
|
|
260
|
+
run over.
|
|
261
|
+
"""
|
|
262
|
+
compression = compression or "zstd"
|
|
263
|
+
if compression == "zstd" and compression_level is None:
|
|
264
|
+
compression_level = 15
|
|
265
|
+
geoparquet_version = geoparquet_version or "1.1.0"
|
|
266
|
+
row_group_size = GeoParquet.row_group_size
|
|
267
|
+
if isinstance(output_file, Path):
|
|
268
|
+
output_file = str(output_file)
|
|
269
|
+
directory = os.path.dirname(output_file)
|
|
270
|
+
if directory:
|
|
271
|
+
os.makedirs(directory, exist_ok=True)
|
|
272
|
+
selected_targets = targets
|
|
273
|
+
collection_json = json.dumps(collection, cls=VecorelJSONEncoder).encode("utf-8")
|
|
274
|
+
|
|
275
|
+
# An external sort spills to disk; keep that next to the output, which is
|
|
276
|
+
# where there is room for it, rather than wherever DuckDB defaults to
|
|
277
|
+
con.execute(
|
|
278
|
+
f"SET temp_directory = {_sql_path(os.path.join(os.path.dirname(output_file) or '.', '.duckdb_tmp'))}"
|
|
279
|
+
)
|
|
280
|
+
|
|
209
281
|
# Same bounded null-value drop, empty-geometry drop and id uniqueness
|
|
210
282
|
# check as in the GeoDataFrame-based codepath, in one scan
|
|
211
283
|
schemas = collection.merge_schemas({})
|
|
@@ -221,7 +293,7 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
221
293
|
null_cond = " OR ".join(f'"{target}" IS NULL' for target in required)
|
|
222
294
|
stats.append(f"count(*) FILTER (WHERE {null_cond})")
|
|
223
295
|
# row numbers are unique by construction
|
|
224
|
-
check_ids = "id" in selected_targets and not
|
|
296
|
+
check_ids = "id" in selected_targets and not ids_are_generated
|
|
225
297
|
if check_ids:
|
|
226
298
|
stats.append('count("id")')
|
|
227
299
|
stats.append('count(DISTINCT "id")')
|
|
@@ -231,9 +303,7 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
231
303
|
stats.append(f"count(*) FILTER (WHERE {blank_cond})")
|
|
232
304
|
if len(stats) > 1:
|
|
233
305
|
values = list(
|
|
234
|
-
con.execute(
|
|
235
|
-
f"SELECT {', '.join(stats)} FROM ({source_query})", addition_params
|
|
236
|
-
).fetchone()
|
|
306
|
+
con.execute(f"SELECT {', '.join(stats)} FROM ({source_query})").fetchone()
|
|
237
307
|
)
|
|
238
308
|
total = values.pop(0)
|
|
239
309
|
invalid = values.pop(0) if null_cond else 0
|
|
@@ -301,7 +371,7 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
301
371
|
}}
|
|
302
372
|
)
|
|
303
373
|
""",
|
|
304
|
-
[
|
|
374
|
+
[output_file, compression, collection_json],
|
|
305
375
|
)
|
|
306
376
|
|
|
307
377
|
# Sort against the same CRS-derived Hilbert grid as the
|
|
@@ -343,6 +413,47 @@ class DuckDBBaseConverter(BaseConverter):
|
|
|
343
413
|
|
|
344
414
|
return output_file
|
|
345
415
|
|
|
416
|
+
def merge_parquet(self, paths: list, output_file, collection=None, **kwargs) -> str:
|
|
417
|
+
"""Combine Vecorel GeoParquet files into one, checked and sorted over the
|
|
418
|
+
whole set rather than per file.
|
|
419
|
+
|
|
420
|
+
The parts are written by a converter, so they need no column mapping and
|
|
421
|
+
their geometries are already valid polygons; pass `original_geometries=False`
|
|
422
|
+
to run the geometry step anyway.
|
|
423
|
+
"""
|
|
424
|
+
if not paths:
|
|
425
|
+
raise ValueError("No paths to merge")
|
|
426
|
+
paths = [str(path) for path in paths]
|
|
427
|
+
kwargs.setdefault("original_geometries", True)
|
|
428
|
+
|
|
429
|
+
con = duckdb.connect()
|
|
430
|
+
con.install_extension("spatial")
|
|
431
|
+
con.load_extension("spatial")
|
|
432
|
+
|
|
433
|
+
source_crs = self._common_crs(con, paths)
|
|
434
|
+
|
|
435
|
+
# union_by_name, because a converter drops a column a source file does not have,
|
|
436
|
+
# so two parts of one dataset can legitimately differ; the targets then come from
|
|
437
|
+
# the union rather than from whichever part happens to be first
|
|
438
|
+
sources = "[" + ",".join(_sql_path(path) for path in paths) + "]"
|
|
439
|
+
source_query = f"SELECT * FROM read_parquet({sources}, union_by_name=true)"
|
|
440
|
+
targets = [row[0] for row in con.execute(f"DESCRIBE {source_query}").fetchall()]
|
|
441
|
+
|
|
442
|
+
if collection is None:
|
|
443
|
+
from ..vecorel.ops import merge_collections
|
|
444
|
+
|
|
445
|
+
collection = merge_collections([GeoParquet(path).get_collection() for path in paths])
|
|
446
|
+
|
|
447
|
+
return self.write_query(
|
|
448
|
+
con,
|
|
449
|
+
source_query,
|
|
450
|
+
output_file,
|
|
451
|
+
collection,
|
|
452
|
+
targets=targets,
|
|
453
|
+
source_crs=source_crs,
|
|
454
|
+
**kwargs,
|
|
455
|
+
)
|
|
456
|
+
|
|
346
457
|
# Streams the Hilbert keys per row group to a sidecar file and reports
|
|
347
458
|
# whether the file is already sorted, so memory stays bounded
|
|
348
459
|
def _write_hilbert_keys(self, output_file, primary, bounds):
|
|
@@ -17,6 +17,7 @@ from ..const import GEOPARQUET_DEFAULT_VERSION, GEOPARQUET_VERSIONS
|
|
|
17
17
|
from ..encoding.geojson import VecorelJSONEncoder
|
|
18
18
|
from ..parquet.geopandas import to_parquet
|
|
19
19
|
from ..parquet.types import (
|
|
20
|
+
NULLABLE_INTEGERS,
|
|
20
21
|
get_geopandas_dtype,
|
|
21
22
|
get_pyarrow_field,
|
|
22
23
|
get_pyarrow_type,
|
|
@@ -516,7 +517,7 @@ class GeoParquet(BaseEncoding):
|
|
|
516
517
|
rows = next(pf.iter_batches(batch_size=num, columns=properties))
|
|
517
518
|
table = pa.Table.from_batches([rows])
|
|
518
519
|
|
|
519
|
-
gdf = _arrow_to_geopandas(table)
|
|
520
|
+
gdf = _arrow_to_geopandas(table, to_pandas_kwargs={"types_mapper": NULLABLE_INTEGERS.get})
|
|
520
521
|
|
|
521
522
|
if hydrate:
|
|
522
523
|
gdf = self.hydrate_from_collection(gdf, schema_map=schema_map)
|
|
@@ -6,6 +6,20 @@ import pyarrow as pa
|
|
|
6
6
|
import pyarrow.types as pat
|
|
7
7
|
from shapely.geometry.base import BaseGeometry
|
|
8
8
|
|
|
9
|
+
# pandas has no integer dtype that holds a null, so an integer column with one
|
|
10
|
+
# missing value comes back as float64 and every value in it then reads as a float.
|
|
11
|
+
# The nullable dtypes do hold one, so integers are read into those.
|
|
12
|
+
NULLABLE_INTEGERS = {
|
|
13
|
+
pa.int8(): pd.Int8Dtype(),
|
|
14
|
+
pa.uint8(): pd.UInt8Dtype(),
|
|
15
|
+
pa.int16(): pd.Int16Dtype(),
|
|
16
|
+
pa.uint16(): pd.UInt16Dtype(),
|
|
17
|
+
pa.int32(): pd.Int32Dtype(),
|
|
18
|
+
pa.uint32(): pd.UInt32Dtype(),
|
|
19
|
+
pa.int64(): pd.Int64Dtype(),
|
|
20
|
+
pa.uint64(): pd.UInt64Dtype(),
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
|
|
10
24
|
def is_enum(schema):
|
|
11
25
|
return isinstance(schema.get("enum"), list)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vecorel-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.20
|
|
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,7 +26,7 @@ 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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
pyyaml<7.0,>=6.0
|
|
2
2
|
click<9.0,>=8.1
|
|
3
|
-
geopandas<2.0,>=1.
|
|
3
|
+
geopandas<2.0,>=1.1.0
|
|
4
4
|
requests<3.0,>=2.30
|
|
5
5
|
shapely<3.0,>=2.1
|
|
6
6
|
numpy<3.0,>=2.0
|
|
@@ -8,7 +8,7 @@ pyarrow<24.0,>=21.0
|
|
|
8
8
|
py7zr<2.0,>=1.0
|
|
9
9
|
fsspec>=2025.7.0
|
|
10
10
|
jsonschema[format]<5.0,>=4.20
|
|
11
|
-
aiohttp<4.0,>=3.
|
|
11
|
+
aiohttp<4.0,>=3.13.5
|
|
12
12
|
yarl<2.0,>=1.20
|
|
13
13
|
rarfile<5.0,>=4.0
|
|
14
14
|
semantic-version<3.0,>=2.10.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|