vecorel-cli 0.2.16__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.16/vecorel_cli.egg-info → vecorel_cli-0.2.18}/PKG-INFO +2 -1
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/pyproject.toml +2 -1
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_convert.py +21 -0
- vecorel_cli-0.2.18/tests/test_convert_duckdb.py +220 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_encoding_geojson.py +23 -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.16 → vecorel_cli-0.2.18}/vecorel_cli/const.py +5 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/conversion/base.py +161 -5
- vecorel_cli-0.2.18/vecorel_cli/conversion/duckdb.py +423 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/base.py +10 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/geojson.py +8 -3
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/geoparquet.py +237 -4
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/parquet/types.py +34 -0
- {vecorel_cli-0.2.16 → 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.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/util.py +4 -2
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18/vecorel_cli.egg-info}/PKG-INFO +2 -1
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/SOURCES.txt +3 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/requires.txt +1 -0
- vecorel_cli-0.2.16/tests/test_encoding_geoparquet.py +0 -48
- vecorel_cli-0.2.16/vecorel_cli/vecorel/hilbert.py +0 -142
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/LICENSE +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/README.md +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/setup.cfg +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_converters.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_create_geojson.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_create_geoparquet.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_create_stac.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_describe.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_encoding_auto.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_improve.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_jsonschema.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_merge.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_ops.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_rename_extension.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_validate.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/tests/test_validate_schema.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/__init__.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/basecommand.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/__init__.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/logger.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/options.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/path_url.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/setup.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/cli/util.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/conversion/__init__.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/conversion/admin.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/conversion/flatdict.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/convert.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/converters.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/create_geojson.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/create_geoparquet.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/create_jsonschema.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/create_stac.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/datasets/__init__.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/datasets/template.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/describe.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/__init__.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/encoding/auto.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/improve.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/jsonschema/__init__.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/jsonschema/template.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/merge.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/parquet/__init__.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/parquet/geopandas.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/registry.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/rename_extension.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validate.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/__init__.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/base.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/data.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/geojson.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/validation/geoparquet.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/__init__.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/collection.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/extensions.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/ops.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/schemas.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/typing.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli/vecorel/version.py +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/dependency_links.txt +0 -0
- {vecorel_cli-0.2.16 → vecorel_cli-0.2.18}/vecorel_cli.egg-info/entry_points.txt +0 -0
- {vecorel_cli-0.2.16 → 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]
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import sys
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
@@ -146,6 +147,26 @@ def test_template_from_package_folder():
|
|
|
146
147
|
assert isinstance(converter, ConvertData), "Should succeed and not throw an exception"
|
|
147
148
|
|
|
148
149
|
|
|
150
|
+
@pytest.mark.parametrize("bom", [False, True], ids=["without-bom", "with-bom"])
|
|
151
|
+
def test_read_geojson_decodes_utf8(tmp_folder, cp1252_locale, bom):
|
|
152
|
+
file_path = tmp_folder / "umlaut.json"
|
|
153
|
+
feature = {
|
|
154
|
+
"type": "Feature",
|
|
155
|
+
"id": "1",
|
|
156
|
+
"properties": {"name": "Grünland"},
|
|
157
|
+
"geometry": {"type": "Point", "coordinates": [0, 0]},
|
|
158
|
+
}
|
|
159
|
+
file_path.write_text(
|
|
160
|
+
# ensure_ascii=False so the file really holds multi-byte UTF-8, not an escape sequence
|
|
161
|
+
json.dumps({"type": "FeatureCollection", "features": [feature]}, ensure_ascii=False),
|
|
162
|
+
encoding="utf-8-sig" if bom else "utf-8",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
gdf = BaseConverter().read_geojson(str(file_path))
|
|
166
|
+
|
|
167
|
+
assert gdf["name"].iloc[0] == "Grünland"
|
|
168
|
+
|
|
169
|
+
|
|
149
170
|
def test_data_access_exception(capsys, tmp_folder):
|
|
150
171
|
# todo: use fixture
|
|
151
172
|
logger.remove()
|
|
@@ -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 == []
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
|
|
3
4
|
import pytest
|
|
@@ -55,3 +56,25 @@ def test_get_collection_returns_existing(tmp_folder):
|
|
|
55
56
|
|
|
56
57
|
result = geojson.get_collection()
|
|
57
58
|
assert result == expected
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize("bom", [False, True], ids=["without-bom", "with-bom"])
|
|
62
|
+
def test_read_geojson_decodes_utf8(tmp_folder, cp1252_locale, bom):
|
|
63
|
+
feature = {
|
|
64
|
+
"type": "Feature",
|
|
65
|
+
"id": "1",
|
|
66
|
+
"properties": {"name": "Grünland"},
|
|
67
|
+
"geometry": {"type": "Point", "coordinates": [0, 0]},
|
|
68
|
+
}
|
|
69
|
+
file_path = tmp_folder / "umlaut.json"
|
|
70
|
+
file_path.write_text(
|
|
71
|
+
# ensure_ascii=False so the file really holds multi-byte UTF-8, not an escape sequence
|
|
72
|
+
json.dumps({"type": "FeatureCollection", "features": [feature]}, ensure_ascii=False),
|
|
73
|
+
encoding="utf-8-sig" if bom else "utf-8",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
geojson = GeoJSON(file_path)
|
|
77
|
+
|
|
78
|
+
assert geojson.read_geojson()["features"][0]["properties"]["name"] == "Grünland"
|
|
79
|
+
# read(num=...) takes the streaming branch, which opens the file separately
|
|
80
|
+
assert geojson.read(num=1)["name"].iloc[0] == "Grünland"
|
|
@@ -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"]
|