vecorel-cli 0.2.17__tar.gz → 0.2.18__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.18}/PKG-INFO +2 -1
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/pyproject.toml +2 -1
- vecorel_cli-0.2.18/tests/test_convert_duckdb.py +220 -0
- vecorel_cli-0.2.18/tests/test_encoding_geoparquet.py +125 -0
- vecorel_cli-0.2.18/tests/test_hilbert.py +89 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/const.py +5 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/conversion/base.py +157 -5
- vecorel_cli-0.2.18/vecorel_cli/conversion/duckdb.py +423 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/base.py +10 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/geoparquet.py +237 -4
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/parquet/types.py +34 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validate_schema.py +2 -1
- vecorel_cli-0.2.18/vecorel_cli/vecorel/hilbert.py +310 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/util.py +4 -2
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18/vecorel_cli.egg-info}/PKG-INFO +2 -1
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/SOURCES.txt +3 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/requires.txt +1 -0
- 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.18}/LICENSE +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/README.md +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/setup.cfg +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_convert.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_converters.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_create_geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_create_geoparquet.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_create_stac.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_describe.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_encoding_auto.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_encoding_geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_improve.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_jsonschema.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_merge.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_ops.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_rename_extension.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_validate.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/tests/test_validate_schema.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/basecommand.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/logger.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/options.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/path_url.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/setup.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/cli/util.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/conversion/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/conversion/admin.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/conversion/flatdict.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/convert.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/converters.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/create_geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/create_geoparquet.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/create_jsonschema.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/create_stac.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/datasets/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/datasets/template.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/describe.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/auto.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/encoding/geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/improve.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/jsonschema/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/jsonschema/template.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/merge.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/parquet/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/parquet/geopandas.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/registry.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/rename_extension.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validate.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/base.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/data.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/geojson.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/validation/geoparquet.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/__init__.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/collection.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/extensions.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/ops.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/schemas.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/typing.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/version.py +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/dependency_links.txt +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/entry_points.txt +0 -0
- {vecorel_cli-0.2.17 → vecorel_cli-0.2.18}/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.18
|
|
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
|
|
@@ -32,6 +32,7 @@ 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.18"
|
|
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"
|
|
@@ -38,6 +38,7 @@ dependencies = [
|
|
|
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]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import geopandas as gpd
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pyarrow.parquet as pq
|
|
6
|
+
import pytest
|
|
7
|
+
import shapely
|
|
8
|
+
|
|
9
|
+
from vecorel_cli.conversion.base import BaseConverter
|
|
10
|
+
from vecorel_cli.conversion.duckdb import DuckDBBaseConverter
|
|
11
|
+
from vecorel_cli.validate import ValidateData
|
|
12
|
+
from vecorel_cli.vecorel.hilbert import hilbert_keys_for_table
|
|
13
|
+
|
|
14
|
+
# One shared converter configuration, so the two codepaths cannot drift apart.
|
|
15
|
+
# column_filters and column_migrations must stay out of it: they are Python
|
|
16
|
+
# callables in the GeoDataFrame-based codepath and SQL fragments in the
|
|
17
|
+
# DuckDB-based one.
|
|
18
|
+
CONFIG = {
|
|
19
|
+
"id": "test",
|
|
20
|
+
"short_name": "Test",
|
|
21
|
+
"title": "Test dataset",
|
|
22
|
+
"description": "Test dataset",
|
|
23
|
+
"license": "CC0-1.0",
|
|
24
|
+
"columns": {
|
|
25
|
+
"geometry": "geometry",
|
|
26
|
+
"id": "id",
|
|
27
|
+
"name": "name",
|
|
28
|
+
},
|
|
29
|
+
"missing_schemas": {
|
|
30
|
+
"properties": {
|
|
31
|
+
"name": {"type": "string"},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Converter = type("Converter", (DuckDBBaseConverter,), dict(CONFIG))
|
|
37
|
+
PandasConverter = type("PandasConverter", (BaseConverter,), dict(CONFIG))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _source_file(folder):
|
|
41
|
+
"""A source with everything the geometry handling must fix:
|
|
42
|
+
a multi-part geometry, an invalid bowtie, a Z polygon, and a point."""
|
|
43
|
+
square = shapely.Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])
|
|
44
|
+
multi = shapely.MultiPolygon(
|
|
45
|
+
[
|
|
46
|
+
shapely.Polygon([(2, 0), (2, 1), (3, 1), (3, 0)]),
|
|
47
|
+
shapely.Polygon([(4, 0), (4, 1), (5, 1), (5, 0)]),
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
bowtie = shapely.Polygon([(6, 0), (7, 1), (7, 0), (6, 1)])
|
|
51
|
+
with_z = shapely.Polygon([(8, 0, 5), (8, 1, 5), (9, 1, 5), (9, 0, 5)])
|
|
52
|
+
point = shapely.Point(10, 0)
|
|
53
|
+
|
|
54
|
+
gdf = gpd.GeoDataFrame(
|
|
55
|
+
{
|
|
56
|
+
"id": ["square", "multi", "bowtie", "with_z", "point"],
|
|
57
|
+
"name": ["a", "b", "c", "d", "e"],
|
|
58
|
+
"geometry": [square, multi, bowtie, with_z, point],
|
|
59
|
+
},
|
|
60
|
+
crs="EPSG:4326",
|
|
61
|
+
)
|
|
62
|
+
path = folder / "source.parquet"
|
|
63
|
+
gdf.to_parquet(path)
|
|
64
|
+
return str(path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_duckdb_converter(tmp_folder):
|
|
68
|
+
src = _source_file(tmp_folder)
|
|
69
|
+
dest = tmp_folder / "converted.parquet"
|
|
70
|
+
|
|
71
|
+
Converter().convert(dest, input_files={src: "source.parquet"})
|
|
72
|
+
|
|
73
|
+
result = gpd.read_parquet(dest)
|
|
74
|
+
# multi is split in two, bowtie is repaired into two valid polygons,
|
|
75
|
+
# the point is dropped and the Z dimension is removed
|
|
76
|
+
assert sorted(result["id"]) == ["bowtie", "bowtie", "multi", "multi", "square", "with_z"]
|
|
77
|
+
assert set(result.geometry.geom_type) == {"Polygon"}
|
|
78
|
+
assert result.geometry.is_valid.all()
|
|
79
|
+
assert not result.geometry.has_z.any()
|
|
80
|
+
|
|
81
|
+
with pq.ParquetFile(dest) as pf:
|
|
82
|
+
schema = pf.schema_arrow
|
|
83
|
+
field = schema.field("geometry")
|
|
84
|
+
assert str(field.type) == "binary"
|
|
85
|
+
assert not field.nullable
|
|
86
|
+
field = schema.field("id")
|
|
87
|
+
assert str(field.type) == "string"
|
|
88
|
+
assert not field.nullable
|
|
89
|
+
assert str(schema.field("name").type) == "string"
|
|
90
|
+
assert "bbox" in schema.names
|
|
91
|
+
|
|
92
|
+
# sorted against the CRS-derived Hilbert grid
|
|
93
|
+
with pq.ParquetFile(dest) as pf:
|
|
94
|
+
table = pf.read()
|
|
95
|
+
keys = hilbert_keys_for_table(table, "geometry", (-180.0, -90.0, 180.0, 90.0))
|
|
96
|
+
assert bool(np.all(keys[1:] >= keys[:-1]))
|
|
97
|
+
|
|
98
|
+
validation = ValidateData().validate(dest, num=100, schema_map={})
|
|
99
|
+
assert validation.errors == []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_duckdb_converter_index_as_id(tmp_folder):
|
|
103
|
+
src = _source_file(tmp_folder)
|
|
104
|
+
dest = tmp_folder / "converted.parquet"
|
|
105
|
+
|
|
106
|
+
IndexConverter = type("IndexConverter", (DuckDBBaseConverter,), {**CONFIG, "index_as_id": True})
|
|
107
|
+
IndexConverter().convert(dest, input_files={src: "source.parquet"})
|
|
108
|
+
|
|
109
|
+
result = gpd.read_parquet(dest)
|
|
110
|
+
# row numbers are assigned before geometries are split,
|
|
111
|
+
# so the parts of one source feature share an id (like the default codepath)
|
|
112
|
+
assert sorted(result["id"]) == ["0", "1", "1", "2", "2", "3"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_duckdb_converter_source_crs(tmp_folder):
|
|
116
|
+
src1 = _source_file(tmp_folder)
|
|
117
|
+
dest = tmp_folder / "converted.parquet"
|
|
118
|
+
|
|
119
|
+
# the same CRS, declared as a differently rendered PROJJSON object
|
|
120
|
+
table = pq.read_table(src1)
|
|
121
|
+
metadata = dict(table.schema.metadata)
|
|
122
|
+
geo = json.loads(metadata[b"geo"])
|
|
123
|
+
crs = geo["columns"]["geometry"]["crs"]
|
|
124
|
+
crs.pop("scope", None)
|
|
125
|
+
crs.pop("area", None)
|
|
126
|
+
crs["$schema"] = "https://proj.org/schemas/v0.5/projjson.schema.json"
|
|
127
|
+
metadata[b"geo"] = json.dumps(geo).encode()
|
|
128
|
+
src2 = str(tmp_folder / "source2.parquet")
|
|
129
|
+
pq.write_table(table.replace_schema_metadata(metadata), src2)
|
|
130
|
+
|
|
131
|
+
Converter().convert(dest, input_files={src1: "a.parquet", src2: "b.parquet"})
|
|
132
|
+
assert len(gpd.read_parquet(dest)) == 12
|
|
133
|
+
|
|
134
|
+
src3 = str(tmp_folder / "source3.parquet")
|
|
135
|
+
gpd.read_parquet(src1).to_crs("EPSG:3857").to_parquet(src3)
|
|
136
|
+
with pytest.raises(ValueError, match="different coordinate reference"):
|
|
137
|
+
Converter().convert(dest, input_files={src1: "a.parquet", src3: "c.parquet"})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_duckdb_converter_original_geometries(tmp_folder):
|
|
141
|
+
src = _source_file(tmp_folder)
|
|
142
|
+
dest = tmp_folder / "converted.parquet"
|
|
143
|
+
|
|
144
|
+
Converter().convert(dest, input_files={src: "source.parquet"}, original_geometries=True)
|
|
145
|
+
|
|
146
|
+
result = gpd.read_parquet(dest)
|
|
147
|
+
assert len(result) == 5
|
|
148
|
+
assert set(result.geometry.geom_type) == {"Polygon", "MultiPolygon", "Point"}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_codepath_parity(tmp_folder):
|
|
152
|
+
"""The GeoDataFrame-based and the DuckDB-based codepaths must produce
|
|
153
|
+
comparable files from the same source and converter configuration:
|
|
154
|
+
same schema, same rows in the same order, same key metadata, same packaging.
|
|
155
|
+
"""
|
|
156
|
+
src = _source_file(tmp_folder)
|
|
157
|
+
kwargs = {
|
|
158
|
+
"input_files": {src: "source.parquet"},
|
|
159
|
+
"compression": "zstd",
|
|
160
|
+
"geoparquet_version": "1.1.0",
|
|
161
|
+
}
|
|
162
|
+
pandas_dest = tmp_folder / "pandas.parquet"
|
|
163
|
+
duckdb_dest = tmp_folder / "duckdb.parquet"
|
|
164
|
+
PandasConverter().convert(pandas_dest, **kwargs)
|
|
165
|
+
Converter().convert(duckdb_dest, **kwargs)
|
|
166
|
+
|
|
167
|
+
with pq.ParquetFile(pandas_dest) as pf:
|
|
168
|
+
pandas_schema = pf.schema_arrow
|
|
169
|
+
pandas_table = pf.read()
|
|
170
|
+
pandas_groups = pf.metadata.num_row_groups
|
|
171
|
+
pandas_compression = pf.metadata.row_group(0).column(0).compression
|
|
172
|
+
with pq.ParquetFile(duckdb_dest) as pf:
|
|
173
|
+
duckdb_schema = pf.schema_arrow
|
|
174
|
+
duckdb_table = pf.read()
|
|
175
|
+
duckdb_groups = pf.metadata.num_row_groups
|
|
176
|
+
duckdb_compression = pf.metadata.row_group(0).column(0).compression
|
|
177
|
+
|
|
178
|
+
# Same columns with the same types and nullability (the column order is
|
|
179
|
+
# allowed to differ)
|
|
180
|
+
assert sorted(pandas_schema.names) == sorted(duckdb_schema.names)
|
|
181
|
+
for name in pandas_schema.names:
|
|
182
|
+
f1, f2 = pandas_schema.field(name), duckdb_schema.field(name)
|
|
183
|
+
assert f1.type == f2.type, f"{name}: {f1.type} != {f2.type}"
|
|
184
|
+
assert f1.nullable == f2.nullable, f"{name}: nullability differs"
|
|
185
|
+
|
|
186
|
+
# Same rows in the same (Hilbert) order; the geometries must describe the
|
|
187
|
+
# same shapes, but the WKB may differ in vertex order (different GEOS builds)
|
|
188
|
+
assert pandas_table.num_rows == duckdb_table.num_rows
|
|
189
|
+
assert pandas_table["id"].to_pylist() == duckdb_table["id"].to_pylist()
|
|
190
|
+
assert pandas_table["name"].to_pylist() == duckdb_table["name"].to_pylist()
|
|
191
|
+
pandas_geoms = shapely.from_wkb(pandas_table["geometry"].to_pylist())
|
|
192
|
+
duckdb_geoms = shapely.from_wkb(duckdb_table["geometry"].to_pylist())
|
|
193
|
+
for g1, g2 in zip(pandas_geoms, duckdb_geoms):
|
|
194
|
+
assert shapely.equals(g1, g2), f"{shapely.to_wkt(g1)} != {shapely.to_wkt(g2)}"
|
|
195
|
+
|
|
196
|
+
# Same collection metadata and the same key GeoParquet metadata
|
|
197
|
+
pandas_collection = json.loads(pandas_schema.metadata[b"collection"])
|
|
198
|
+
duckdb_collection = json.loads(duckdb_schema.metadata[b"collection"])
|
|
199
|
+
assert pandas_collection == duckdb_collection
|
|
200
|
+
|
|
201
|
+
pandas_geo = json.loads(pandas_schema.metadata[b"geo"])
|
|
202
|
+
duckdb_geo = json.loads(duckdb_schema.metadata[b"geo"])
|
|
203
|
+
for key in ("version", "primary_column"):
|
|
204
|
+
assert pandas_geo[key] == duckdb_geo[key]
|
|
205
|
+
pandas_column = pandas_geo["columns"]["geometry"]
|
|
206
|
+
duckdb_column = duckdb_geo["columns"]["geometry"]
|
|
207
|
+
for key in ("encoding", "covering", "crs", "bbox"):
|
|
208
|
+
assert pandas_column.get(key) == duckdb_column.get(key), f"geo {key} differs"
|
|
209
|
+
assert sorted(pandas_column.get("geometry_types", [])) == sorted(
|
|
210
|
+
duckdb_column.get("geometry_types", [])
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Same packaging
|
|
214
|
+
assert pandas_compression == duckdb_compression
|
|
215
|
+
assert pandas_groups == duckdb_groups
|
|
216
|
+
|
|
217
|
+
# Both validate
|
|
218
|
+
for dest in (pandas_dest, duckdb_dest):
|
|
219
|
+
validation = ValidateData().validate(dest, num=100, schema_map={})
|
|
220
|
+
assert validation.errors == []
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pyarrow as pa
|
|
5
|
+
import pyarrow.parquet as pq
|
|
6
|
+
import pyarrow.types as pat
|
|
7
|
+
|
|
8
|
+
from vecorel_cli.encoding.geoparquet import GeoParquet
|
|
9
|
+
from vecorel_cli.vecorel.collection import Collection
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_init_paths(tmp_folder):
|
|
13
|
+
fpath = str(tmp_folder / "test.parquet")
|
|
14
|
+
ppath = Path(fpath)
|
|
15
|
+
|
|
16
|
+
assert GeoParquet(fpath).uri == ppath
|
|
17
|
+
assert GeoParquet(ppath).uri == ppath
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_get_format():
|
|
21
|
+
assert (
|
|
22
|
+
GeoParquet("tests/data-files/inspire.parquet").get_format() == "GeoParquet, version 1.1.0"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_get_collection_exists():
|
|
27
|
+
geojson = GeoParquet("tests/data-files/inspire.parquet")
|
|
28
|
+
|
|
29
|
+
collection = geojson.get_collection()
|
|
30
|
+
|
|
31
|
+
assert isinstance(collection, dict)
|
|
32
|
+
fields = list(collection.keys())
|
|
33
|
+
fields.sort()
|
|
34
|
+
assert fields == ["collection", "determination_datetime", "schemas", "schemas:custom"]
|
|
35
|
+
assert "inspire" in collection["schemas"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_get_collection_does_not_exist():
|
|
39
|
+
collection = GeoParquet("invalid.parquet").get_collection()
|
|
40
|
+
assert isinstance(collection, Collection)
|
|
41
|
+
assert collection.is_empty()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_get_collection_returns_existing(tmp_folder):
|
|
45
|
+
file_path = tmp_folder / "test.parquet"
|
|
46
|
+
test_collection = Collection({"test": "data"})
|
|
47
|
+
|
|
48
|
+
geojson = GeoParquet(file_path)
|
|
49
|
+
geojson.collection = test_collection
|
|
50
|
+
|
|
51
|
+
result = geojson.get_collection()
|
|
52
|
+
assert isinstance(result, Collection)
|
|
53
|
+
assert result == test_collection
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_postprocess(tmp_parquet_file):
|
|
57
|
+
# Degrade a compliant file to what external tools such as DuckDB or GDAL may produce:
|
|
58
|
+
# large_string/large_binary columns, a naive microsecond timestamp, nullable columns,
|
|
59
|
+
# no bbox column, GeoParquet 1.0.0
|
|
60
|
+
src = pq.read_table("tests/data-files/inspire.parquet")
|
|
61
|
+
fields = []
|
|
62
|
+
arrays = []
|
|
63
|
+
for i, field in enumerate(src.schema):
|
|
64
|
+
if field.name == "bbox":
|
|
65
|
+
continue
|
|
66
|
+
if pat.is_string(field.type):
|
|
67
|
+
dtype = pa.large_string()
|
|
68
|
+
elif pat.is_binary(field.type):
|
|
69
|
+
dtype = pa.large_binary()
|
|
70
|
+
else:
|
|
71
|
+
dtype = field.type
|
|
72
|
+
fields.append(pa.field(field.name, dtype, nullable=True))
|
|
73
|
+
arrays.append(src.column(i).cast(dtype))
|
|
74
|
+
fields.append(pa.field("determination_datetime", pa.timestamp("us")))
|
|
75
|
+
arrays.append(pa.array([1672531200000000] * len(src), type=pa.timestamp("us")))
|
|
76
|
+
|
|
77
|
+
metadata = dict(src.schema.metadata)
|
|
78
|
+
geo = json.loads(metadata[b"geo"])
|
|
79
|
+
geo["version"] = "1.0.0"
|
|
80
|
+
geo["columns"]["geometry"].pop("covering", None)
|
|
81
|
+
metadata[b"geo"] = json.dumps(geo).encode("utf-8")
|
|
82
|
+
degraded = pa.table(arrays, schema=pa.schema(fields, metadata=metadata))
|
|
83
|
+
pq.write_table(degraded, tmp_parquet_file, store_schema=True)
|
|
84
|
+
|
|
85
|
+
gp = GeoParquet(tmp_parquet_file)
|
|
86
|
+
assert gp.postprocess(geoparquet_version="1.1.0", compression="zstd") is True
|
|
87
|
+
# a second run detects the compliant file and doesn't rewrite it
|
|
88
|
+
assert gp.postprocess(geoparquet_version="1.1.0", compression="zstd") is False
|
|
89
|
+
# unless a different compression is requested
|
|
90
|
+
assert gp.postprocess(geoparquet_version="1.1.0", compression="brotli") is True
|
|
91
|
+
|
|
92
|
+
with pq.ParquetFile(tmp_parquet_file) as result:
|
|
93
|
+
schema = result.schema_arrow
|
|
94
|
+
metadata = result.metadata.metadata
|
|
95
|
+
|
|
96
|
+
field = schema.field("geometry")
|
|
97
|
+
assert field.type == pa.binary()
|
|
98
|
+
assert not field.nullable
|
|
99
|
+
field = schema.field("id")
|
|
100
|
+
assert field.type == pa.string()
|
|
101
|
+
assert not field.nullable
|
|
102
|
+
assert schema.field("inspire:id").type == pa.string()
|
|
103
|
+
assert schema.field("determination_datetime").type == pa.timestamp("ms", tz="UTC")
|
|
104
|
+
|
|
105
|
+
bbox = schema.field("bbox")
|
|
106
|
+
assert pat.is_struct(bbox.type)
|
|
107
|
+
assert bbox.type.field("xmin").type == pa.float64()
|
|
108
|
+
|
|
109
|
+
geo = json.loads(metadata[b"geo"])
|
|
110
|
+
assert geo["version"] == "1.1.0"
|
|
111
|
+
assert geo["columns"]["geometry"]["covering"]["bbox"]["xmin"] == ["bbox", "xmin"]
|
|
112
|
+
assert b"collection" in metadata
|
|
113
|
+
|
|
114
|
+
# data is intact
|
|
115
|
+
data = pq.read_table(tmp_parquet_file)
|
|
116
|
+
assert data.num_rows == src.num_rows
|
|
117
|
+
assert data["id"].to_pylist() == src["id"].to_pylist()
|
|
118
|
+
assert data["geometry"].to_pylist() == src["geometry"].to_pylist()
|
|
119
|
+
|
|
120
|
+
# a downgrade removes the covering metadata, which only exists since GeoParquet 1.1
|
|
121
|
+
assert gp.postprocess(geoparquet_version="1.0.0", compression="brotli") is True
|
|
122
|
+
with pq.ParquetFile(tmp_parquet_file) as pf:
|
|
123
|
+
geo = json.loads(pf.metadata.metadata[b"geo"])
|
|
124
|
+
assert geo["version"] == "1.0.0"
|
|
125
|
+
assert "covering" not in geo["columns"]["geometry"]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pyarrow as pa
|
|
5
|
+
import pyarrow.parquet as pq
|
|
6
|
+
import shapely
|
|
7
|
+
|
|
8
|
+
from vecorel_cli.vecorel.hilbert import (
|
|
9
|
+
bounds_array_for_table,
|
|
10
|
+
crs_total_bounds,
|
|
11
|
+
ensure_hilbert_sorted,
|
|
12
|
+
hilbert_keys_for_table,
|
|
13
|
+
hilbert_reference_bounds,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# A bare projection definition without an area of use,
|
|
17
|
+
# like the unnamed "MGI / Austria Lambert" in Austria's 2018 edition
|
|
18
|
+
CUSTOM_CRS = "+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +units=m"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_hilbert_reference_bounds_from_crs():
|
|
22
|
+
assert hilbert_reference_bounds("EPSG:4326") == (-180.0, -90.0, 180.0, 90.0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_hilbert_reference_bounds_fallback():
|
|
26
|
+
fallback = [1.0, 2.0, 3.0, 4.0]
|
|
27
|
+
assert hilbert_reference_bounds(CUSTOM_CRS, fallback) == (1.0, 2.0, 3.0, 4.0)
|
|
28
|
+
assert hilbert_reference_bounds(CUSTOM_CRS) is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _make_table(points, with_bbox=True, metadata=None):
|
|
32
|
+
geometries = [shapely.Point(x, y).buffer(0.1) for x, y in points]
|
|
33
|
+
wkb = [shapely.to_wkb(g) for g in geometries]
|
|
34
|
+
bounds = shapely.bounds(np.array(geometries, dtype=object))
|
|
35
|
+
arrays = {
|
|
36
|
+
"id": pa.array([str(i) for i in range(len(points))], type=pa.string()),
|
|
37
|
+
"geometry": pa.array(wkb, type=pa.binary()),
|
|
38
|
+
}
|
|
39
|
+
if with_bbox:
|
|
40
|
+
arrays["bbox"] = pa.StructArray.from_arrays(
|
|
41
|
+
[bounds[:, 0], bounds[:, 1], bounds[:, 2], bounds[:, 3]],
|
|
42
|
+
names=["xmin", "ymin", "xmax", "ymax"],
|
|
43
|
+
)
|
|
44
|
+
table = pa.table(arrays)
|
|
45
|
+
if metadata:
|
|
46
|
+
table = table.replace_schema_metadata(metadata)
|
|
47
|
+
return table
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_bounds_array_bbox_and_wkb_paths_agree():
|
|
51
|
+
points = [(0, 0), (10, 10), (5, 5)]
|
|
52
|
+
with_bbox = _make_table(points, with_bbox=True)
|
|
53
|
+
without_bbox = _make_table(points, with_bbox=False)
|
|
54
|
+
|
|
55
|
+
np.testing.assert_allclose(
|
|
56
|
+
bounds_array_for_table(with_bbox, "geometry"),
|
|
57
|
+
bounds_array_for_table(without_bbox, "geometry"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_ensure_hilbert_sorted(tmp_folder):
|
|
62
|
+
path = str(tmp_folder / "unsorted.parquet")
|
|
63
|
+
# scattered, deliberately not in Hilbert order
|
|
64
|
+
points = [(30, 40), (-120, -30), (5, 5), (100, 60), (-60, 10)]
|
|
65
|
+
metadata = {
|
|
66
|
+
b"geo": json.dumps({"primary_column": "geometry", "columns": {"geometry": {}}}).encode(),
|
|
67
|
+
b"collection": b'{"schemas": {}}',
|
|
68
|
+
}
|
|
69
|
+
table = _make_table(points, metadata=metadata)
|
|
70
|
+
pq.write_table(table, path)
|
|
71
|
+
|
|
72
|
+
total_bounds = crs_total_bounds("EPSG:4326")
|
|
73
|
+
keys = hilbert_keys_for_table(table, "geometry", total_bounds)
|
|
74
|
+
assert not bool(np.all(keys[1:] >= keys[:-1])), "Test data must start unsorted"
|
|
75
|
+
|
|
76
|
+
assert ensure_hilbert_sorted(path, "geometry", total_bounds, "zstd") is True
|
|
77
|
+
|
|
78
|
+
result = pq.read_table(path)
|
|
79
|
+
sorted_keys = hilbert_keys_for_table(result, "geometry", total_bounds)
|
|
80
|
+
assert bool(np.all(sorted_keys[1:] >= sorted_keys[:-1]))
|
|
81
|
+
# data is intact, schema stays narrow, metadata is preserved
|
|
82
|
+
assert sorted(result["id"].to_pylist()) == sorted(table["id"].to_pylist())
|
|
83
|
+
assert result.schema.field("geometry").type == pa.binary()
|
|
84
|
+
assert result.schema.field("id").type == pa.string()
|
|
85
|
+
assert result.schema.metadata[b"geo"] == metadata[b"geo"]
|
|
86
|
+
assert result.schema.metadata[b"collection"] == metadata[b"collection"]
|
|
87
|
+
|
|
88
|
+
# a second run is a no-op
|
|
89
|
+
assert ensure_hilbert_sorted(path, "geometry", total_bounds, "zstd") is False
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
SUPPORTED_PROTOCOLS = ["http", "https", "s3", "gs"]
|
|
2
2
|
|
|
3
|
+
# Sent on every outgoing HTTP request. Some servers reject the default agents of
|
|
4
|
+
# the underlying libraries, e.g. geoportal.saarland.de answers 403 to any agent
|
|
5
|
+
# containing "aiohttp", which is what fsspec sends.
|
|
6
|
+
USER_AGENT = "vecorel-cli"
|
|
7
|
+
|
|
3
8
|
COMPRESSION_METHODS = ["brotli", "gzip", "lz4", "snappy", "zstd", "none"]
|
|
4
9
|
|
|
5
10
|
GEOPARQUET_VERSIONS = ["1.0.0", "1.1.0"]
|
|
@@ -62,6 +62,11 @@ class BaseConverter(LoggerMixin):
|
|
|
62
62
|
|
|
63
63
|
index_as_id: bool = False
|
|
64
64
|
|
|
65
|
+
# Rows with null values in schema-required properties are dropped up to this
|
|
66
|
+
# share of all rows; above it the conversion fails. Rows with an empty or
|
|
67
|
+
# missing geometry are always dropped, regardless of this share.
|
|
68
|
+
max_dropped_share: float = 0.01
|
|
69
|
+
|
|
65
70
|
def __init__(self, *args, **kwargs):
|
|
66
71
|
super().__init__()
|
|
67
72
|
|
|
@@ -71,6 +76,148 @@ class BaseConverter(LoggerMixin):
|
|
|
71
76
|
if not key.startswith("_") and isinstance(item, (list, dict, set)):
|
|
72
77
|
setattr(self, key, copy(item))
|
|
73
78
|
|
|
79
|
+
def _require_one_source_of_urls(self):
|
|
80
|
+
"""Fail when both `sources` and `variants` are declared:
|
|
81
|
+
`sources` wins and every `--variant` would silently convert the same file."""
|
|
82
|
+
if self.sources and self.variants:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"{type(self).__name__} declares both sources and variants; sources wins "
|
|
85
|
+
"and every --variant would convert the same file. Drop sources, or set "
|
|
86
|
+
"variants = {} when the inherited ones do not apply."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _check_id_mapping(self):
|
|
90
|
+
"""Warn before converting when nothing is mapped to the required `id` property.
|
|
91
|
+
Unmapped columns are dropped, which also removes the column filled by
|
|
92
|
+
`index_as_id` unless the converter maps `"id": "id"`."""
|
|
93
|
+
targets = set()
|
|
94
|
+
for value in list(self.columns.values()) + list(self.column_additions or {}):
|
|
95
|
+
targets.update(value if isinstance(value, (list, tuple)) else [value])
|
|
96
|
+
if "id" not in targets:
|
|
97
|
+
hint = (
|
|
98
|
+
' — `index_as_id = True` is set, so add \'"id": "id"\' to columns'
|
|
99
|
+
if self.index_as_id
|
|
100
|
+
else " — map a unique source column to it, or set index_as_id = True"
|
|
101
|
+
' and add \'"id": "id"\' to columns'
|
|
102
|
+
)
|
|
103
|
+
self.warning(f"{type(self).__name__} maps no column to 'id'{hint}")
|
|
104
|
+
|
|
105
|
+
def _check_unique_ids(self, gdf, columns):
|
|
106
|
+
"""Warn when the column that becomes `id` does not identify a feature.
|
|
107
|
+
Runs before geometries are exploded, so it judges what the converter
|
|
108
|
+
assigned rather than the split parts of one source feature. Null ids
|
|
109
|
+
are not counted here; they are dropped under a bounded rule afterwards.
|
|
110
|
+
"""
|
|
111
|
+
sources = [
|
|
112
|
+
k for k, v in columns.items() if "id" in (v if isinstance(v, (list, tuple)) else [v])
|
|
113
|
+
]
|
|
114
|
+
column = next(
|
|
115
|
+
(c for c in sources if c in gdf.columns), "id" if "id" in gdf.columns else None
|
|
116
|
+
)
|
|
117
|
+
if column is None:
|
|
118
|
+
self.warning(
|
|
119
|
+
f"{type(self).__name__}: none of the columns mapped to 'id' "
|
|
120
|
+
f"({', '.join(sources) or 'none'}) is in this source; it has "
|
|
121
|
+
f"{', '.join(sorted(gdf.columns)[:12])}"
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
ids = gdf[column].dropna()
|
|
125
|
+
if ids.is_unique:
|
|
126
|
+
return
|
|
127
|
+
counts = ids.value_counts()
|
|
128
|
+
duplicated = int(len(ids) - len(counts))
|
|
129
|
+
worst = int(counts.iloc[0])
|
|
130
|
+
self.warning(
|
|
131
|
+
f"{type(self).__name__}: '{column}' is not unique — {duplicated:,} of {len(ids):,} "
|
|
132
|
+
f"rows repeat an id (one appears {worst:,} times), so it cannot be `id`. Map a column "
|
|
133
|
+
"that identifies a feature, build one from the source's key columns, or use the row "
|
|
134
|
+
"index (index_as_id) only when the conversion reads a single file."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _drop_incomplete_rows(self, gdf, columns):
|
|
138
|
+
"""Drop rows that can never validate. Rows with null values in a
|
|
139
|
+
schema-required property are dropped up to ``max_dropped_share``;
|
|
140
|
+
above it the conversion fails, as quietly dropping large parts of a
|
|
141
|
+
dataset would hide that the converter needs fixing (and the writer
|
|
142
|
+
rejects nulls in the non-nullable required fields anyway). Rows with
|
|
143
|
+
an empty or missing geometry are always dropped: they cannot survive
|
|
144
|
+
the geometry processing anyway and would break the Hilbert sort."""
|
|
145
|
+
collection = self.create_collection(self.id.strip())
|
|
146
|
+
schemas = collection.merge_schemas({})
|
|
147
|
+
collection_only = set(collection.get_collection_only_properties())
|
|
148
|
+
required = [
|
|
149
|
+
r for r in schemas.get("required", []) if r != "geometry" and r not in collection_only
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
# One combined mask, so max_dropped_share bounds the total share
|
|
153
|
+
invalid = pd.Series(False, index=gdf.index)
|
|
154
|
+
reasons = []
|
|
155
|
+
|
|
156
|
+
# This runs before columns are renamed, so look up the source column
|
|
157
|
+
for key in required:
|
|
158
|
+
for src, dst in columns.items():
|
|
159
|
+
targets = dst if isinstance(dst, (list, tuple)) else [dst]
|
|
160
|
+
if key in targets and src in gdf.columns:
|
|
161
|
+
nulls = gdf[src].isna()
|
|
162
|
+
if nulls.any():
|
|
163
|
+
reasons.append(f"{int(nulls.sum())} without a value for {key} ({src})")
|
|
164
|
+
invalid |= nulls
|
|
165
|
+
|
|
166
|
+
if invalid.any():
|
|
167
|
+
share = invalid.mean()
|
|
168
|
+
details = "; ".join(reasons)
|
|
169
|
+
if share > self.max_dropped_share:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"{int(invalid.sum())} of {len(gdf)} rows ({share:.1%}) have no value for "
|
|
172
|
+
f"a required property ({details}); fix the converter instead of dropping them"
|
|
173
|
+
)
|
|
174
|
+
self.warning(
|
|
175
|
+
f"Dropping {int(invalid.sum())} of {len(gdf)} rows that can never "
|
|
176
|
+
f"validate ({details})"
|
|
177
|
+
)
|
|
178
|
+
gdf = gdf[~invalid]
|
|
179
|
+
|
|
180
|
+
if gdf.active_geometry_name is not None:
|
|
181
|
+
geom = gdf.geometry
|
|
182
|
+
blank = geom.isna() | geom.is_empty
|
|
183
|
+
if blank.any():
|
|
184
|
+
share = blank.mean()
|
|
185
|
+
message = (
|
|
186
|
+
f"Dropping {int(blank.sum())} of {len(gdf)} rows with an empty "
|
|
187
|
+
f"or missing geometry"
|
|
188
|
+
)
|
|
189
|
+
if share > self.max_dropped_share:
|
|
190
|
+
message += f" ({share:.1%}, exceeds max_dropped_share) — fix the converter"
|
|
191
|
+
self.warning(message)
|
|
192
|
+
gdf = gdf[~blank]
|
|
193
|
+
|
|
194
|
+
return gdf
|
|
195
|
+
|
|
196
|
+
def _prewarm_schemas(self):
|
|
197
|
+
"""Fetch every schema this conversion will need upfront, with retries,
|
|
198
|
+
so a transient schema-host blip cannot kill the conversion at the very
|
|
199
|
+
last step. load_file caches per process."""
|
|
200
|
+
import time
|
|
201
|
+
|
|
202
|
+
from ..vecorel.util import load_file
|
|
203
|
+
|
|
204
|
+
uris = set(self.extensions)
|
|
205
|
+
uris.add(Schemas.get_core_uri())
|
|
206
|
+
attempts = 8
|
|
207
|
+
for uri in sorted(uris):
|
|
208
|
+
for attempt in range(attempts):
|
|
209
|
+
try:
|
|
210
|
+
load_file(uri)
|
|
211
|
+
break
|
|
212
|
+
except Exception as e:
|
|
213
|
+
if attempt == attempts - 1:
|
|
214
|
+
raise RuntimeError(
|
|
215
|
+
f"Cannot load schema {uri} after {attempts} attempts: {e}"
|
|
216
|
+
) from e
|
|
217
|
+
self.warning(f"Schema fetch failed ({uri}), retrying: {str(e)[:100]}")
|
|
218
|
+
# ~4 min of tolerance: schema host outages have outlasted a 30 s budget
|
|
219
|
+
time.sleep(min(2**attempt * 2, 60))
|
|
220
|
+
|
|
74
221
|
def migrate(self, gdf) -> GeoDataFrame:
|
|
75
222
|
return gdf
|
|
76
223
|
|
|
@@ -316,6 +463,10 @@ class BaseConverter(LoggerMixin):
|
|
|
316
463
|
if self.bbox is not None and len(self.bbox) != 4:
|
|
317
464
|
raise ValueError("If provided, the bounding box must consist of 4 numbers")
|
|
318
465
|
|
|
466
|
+
self._check_id_mapping()
|
|
467
|
+
self._require_one_source_of_urls()
|
|
468
|
+
self._prewarm_schemas()
|
|
469
|
+
|
|
319
470
|
# Create output folder if it doesn't exist
|
|
320
471
|
directory = os.path.dirname(output_file)
|
|
321
472
|
if directory:
|
|
@@ -360,10 +511,6 @@ class BaseConverter(LoggerMixin):
|
|
|
360
511
|
gdf[key] = value
|
|
361
512
|
columns[key] = key
|
|
362
513
|
|
|
363
|
-
# Add collection ID
|
|
364
|
-
columns["collection"] = "collection"
|
|
365
|
-
gdf["collection"] = cid
|
|
366
|
-
|
|
367
514
|
# 4. Run column migrations
|
|
368
515
|
if self.column_migrations:
|
|
369
516
|
self.info("Applying column migrations")
|
|
@@ -375,6 +522,9 @@ class BaseConverter(LoggerMixin):
|
|
|
375
522
|
|
|
376
523
|
gdf = self.post_migrate(gdf)
|
|
377
524
|
|
|
525
|
+
self._check_unique_ids(gdf, columns)
|
|
526
|
+
gdf = self._drop_incomplete_rows(gdf, columns)
|
|
527
|
+
|
|
378
528
|
if hash_before != self._hash_df(gdf.head()):
|
|
379
529
|
self.info("GeoDataFrame after migrations and filters:")
|
|
380
530
|
self.info(gdf.head().to_string())
|
|
@@ -430,7 +580,9 @@ class BaseConverter(LoggerMixin):
|
|
|
430
580
|
self.info("Creating GeoParquet file: " + str(output_file))
|
|
431
581
|
columns = list(actual_columns.values())
|
|
432
582
|
pq = GeoParquet(output_file)
|
|
433
|
-
|
|
583
|
+
collection = self.create_collection(cid)
|
|
584
|
+
collection["collection"] = cid
|
|
585
|
+
pq.set_collection(collection)
|
|
434
586
|
|
|
435
587
|
pq.write(
|
|
436
588
|
gdf,
|