vecorel-cli 0.3.1__tar.gz → 0.4.1__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.3.1/vecorel_cli.egg-info → vecorel_cli-0.4.1}/PKG-INFO +49 -2
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/README.md +48 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/pyproject.toml +1 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_convert_duckdb.py +195 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_create_stac.py +22 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_download.py +54 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_encoding_geoparquet.py +129 -0
- vecorel_cli-0.4.1/tests/test_merge.py +799 -0
- vecorel_cli-0.4.1/tests/test_ops.py +245 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_validate.py +80 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/conversion/base.py +8 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/conversion/duckdb.py +286 -90
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/create_geoparquet.py +3 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/create_stac.py +8 -3
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/encoding/base.py +20 -3
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/encoding/geojson.py +6 -3
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/encoding/geoparquet.py +51 -1
- vecorel_cli-0.4.1/vecorel_cli/merge.py +187 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/parquet/types.py +48 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/validation/base.py +1 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/validation/geoparquet.py +41 -2
- vecorel_cli-0.4.1/vecorel_cli/vecorel/ops.py +300 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/schemas.py +21 -2
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/util.py +31 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1/vecorel_cli.egg-info}/PKG-INFO +49 -2
- vecorel_cli-0.3.1/tests/test_merge.py +0 -56
- vecorel_cli-0.3.1/tests/test_ops.py +0 -94
- vecorel_cli-0.3.1/vecorel_cli/merge.py +0 -86
- vecorel_cli-0.3.1/vecorel_cli/vecorel/ops.py +0 -75
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/LICENSE +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/setup.cfg +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_convert.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_converters.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_create_geojson.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_create_geoparquet.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_deflate64.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_describe.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_encoding_auto.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_encoding_geojson.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_hilbert.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_improve.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_jsonschema.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_rename_extension.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/tests/test_validate_schema.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/basecommand.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/cli/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/cli/logger.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/cli/options.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/cli/path_url.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/cli/setup.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/cli/util.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/const.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/conversion/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/conversion/admin.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/conversion/deflate64.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/conversion/flatdict.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/convert.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/converters.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/create_geojson.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/create_jsonschema.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/datasets/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/datasets/template.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/describe.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/encoding/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/encoding/auto.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/improve.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/jsonschema/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/jsonschema/template.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/parquet/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/parquet/geopandas.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/registry.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/rename_extension.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/validate.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/validate_schema.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/validation/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/validation/data.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/validation/geojson.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/collection.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/extensions.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/hilbert.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/typing.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/version.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/SOURCES.txt +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/dependency_links.txt +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/entry_points.txt +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/requires.txt +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vecorel-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -167,7 +167,54 @@ Check `vec describe --help` for more details.
|
|
|
167
167
|
|
|
168
168
|
Merges multiple Vecorel datasets to a combined Vecorel dataset:
|
|
169
169
|
|
|
170
|
-
- `vec merge ec_ee.parquet ec_lv.parquet -o merged.parquet
|
|
170
|
+
- `vec merge ec_ee.parquet ec_lv.parquet -o merged.parquet`
|
|
171
|
+
- Only the core properties and some additional properties: `vec merge ec_ee.parquet ec_lv.parquet -o merged.parquet -i ec:hcat_name -i ec:hcat_code`
|
|
172
|
+
- All properties except for some: `vec merge ec_ee.parquet ec_lv.parquet -o merged.parquet -e ec:translated_name`
|
|
173
|
+
|
|
174
|
+
The merged dataset is in EPSG:4326 by default. Use `--crs` to choose another CRS,
|
|
175
|
+
or `--crs first` to keep the CRS of the first dataset.
|
|
176
|
+
|
|
177
|
+
Local GeoParquet files that are all in the target CRS are merged with DuckDB, so they don't need to fit into memory.
|
|
178
|
+
All other datasets (e.g. GeoJSON or datasets that need to be reprojected) are merged in memory.
|
|
179
|
+
Use `--engine` to choose the engine explicitly.
|
|
180
|
+
|
|
181
|
+
`-i` and `-e` apply to all properties, including collection-level metadata.
|
|
182
|
+
The geometry is required, so it can't be excluded.
|
|
183
|
+
Constants that differ between the datasets are moved from the collection metadata to the features.
|
|
184
|
+
The collection of the features is stored in a column if the datasets have multiple collections
|
|
185
|
+
or if a dataset has a collection column already, otherwise only in the collection metadata.
|
|
186
|
+
|
|
187
|
+
Geometries are merged as they are, except for the reprojection to the target CRS:
|
|
188
|
+
they are neither made valid nor converted to other geometry types (use `vec improve -g` for that),
|
|
189
|
+
and features with an empty or missing geometry are not dropped (see below).
|
|
190
|
+
The bounding boxes (`bbox`) are computed again for the merged GeoParquet file.
|
|
191
|
+
|
|
192
|
+
#### Strict and non-strict mode
|
|
193
|
+
|
|
194
|
+
By default, `vec merge` is strict: problems that make the merged dataset invalid are errors
|
|
195
|
+
and no dataset is written.
|
|
196
|
+
With `--no-strict`, `vec merge` is fail-safe: these problems are reported as warnings and
|
|
197
|
+
the dataset is written anyway. Check it with `vec validate` afterwards.
|
|
198
|
+
|
|
199
|
+
| Problem | Strict (default) | `--no-strict` |
|
|
200
|
+
| ------- | ---------------- | ------------- |
|
|
201
|
+
| A required property has no value for some features | Error | Warning, the property is written as nullable |
|
|
202
|
+
| An id repeats within a collection | Error | Warning |
|
|
203
|
+
| The collection of a feature can't be determined | Error | Warning, the collection is left empty |
|
|
204
|
+
| `-i` or `-e` removes a required property | Error | Warning |
|
|
205
|
+
| A required collection-only property differs between the datasets | Error | Warning, the property is removed |
|
|
206
|
+
| An optional collection-only property differs between the datasets | Warning, the property is removed | Warning, the property is removed |
|
|
207
|
+
| A feature has an empty or missing geometry | Error | Warning, the feature is kept |
|
|
208
|
+
| A collection-level value doesn't fit the data type of its schema | Error | Warning, the value is left empty |
|
|
209
|
+
| The datasets use different versions of the Vecorel specification or of an extension | Error, before any data is read | Error, before any data is read |
|
|
210
|
+
| The schemas of the datasets conflict | Error | Error |
|
|
211
|
+
|
|
212
|
+
Datasets with different versions of the Vecorel specification or of an extension can't be
|
|
213
|
+
merged, as the CLI can't upgrade them automatically yet.
|
|
214
|
+
|
|
215
|
+
The strict mode only checks what a merge can break or check with little effort.
|
|
216
|
+
It doesn't validate the values against the schemas, e.g. patterns or value ranges,
|
|
217
|
+
so a merged dataset is only valid if the values of the source datasets are valid.
|
|
171
218
|
|
|
172
219
|
Check `vec merge --help` for more details.
|
|
173
220
|
|
|
@@ -121,7 +121,54 @@ Check `vec describe --help` for more details.
|
|
|
121
121
|
|
|
122
122
|
Merges multiple Vecorel datasets to a combined Vecorel dataset:
|
|
123
123
|
|
|
124
|
-
- `vec merge ec_ee.parquet ec_lv.parquet -o merged.parquet
|
|
124
|
+
- `vec merge ec_ee.parquet ec_lv.parquet -o merged.parquet`
|
|
125
|
+
- Only the core properties and some additional properties: `vec merge ec_ee.parquet ec_lv.parquet -o merged.parquet -i ec:hcat_name -i ec:hcat_code`
|
|
126
|
+
- All properties except for some: `vec merge ec_ee.parquet ec_lv.parquet -o merged.parquet -e ec:translated_name`
|
|
127
|
+
|
|
128
|
+
The merged dataset is in EPSG:4326 by default. Use `--crs` to choose another CRS,
|
|
129
|
+
or `--crs first` to keep the CRS of the first dataset.
|
|
130
|
+
|
|
131
|
+
Local GeoParquet files that are all in the target CRS are merged with DuckDB, so they don't need to fit into memory.
|
|
132
|
+
All other datasets (e.g. GeoJSON or datasets that need to be reprojected) are merged in memory.
|
|
133
|
+
Use `--engine` to choose the engine explicitly.
|
|
134
|
+
|
|
135
|
+
`-i` and `-e` apply to all properties, including collection-level metadata.
|
|
136
|
+
The geometry is required, so it can't be excluded.
|
|
137
|
+
Constants that differ between the datasets are moved from the collection metadata to the features.
|
|
138
|
+
The collection of the features is stored in a column if the datasets have multiple collections
|
|
139
|
+
or if a dataset has a collection column already, otherwise only in the collection metadata.
|
|
140
|
+
|
|
141
|
+
Geometries are merged as they are, except for the reprojection to the target CRS:
|
|
142
|
+
they are neither made valid nor converted to other geometry types (use `vec improve -g` for that),
|
|
143
|
+
and features with an empty or missing geometry are not dropped (see below).
|
|
144
|
+
The bounding boxes (`bbox`) are computed again for the merged GeoParquet file.
|
|
145
|
+
|
|
146
|
+
#### Strict and non-strict mode
|
|
147
|
+
|
|
148
|
+
By default, `vec merge` is strict: problems that make the merged dataset invalid are errors
|
|
149
|
+
and no dataset is written.
|
|
150
|
+
With `--no-strict`, `vec merge` is fail-safe: these problems are reported as warnings and
|
|
151
|
+
the dataset is written anyway. Check it with `vec validate` afterwards.
|
|
152
|
+
|
|
153
|
+
| Problem | Strict (default) | `--no-strict` |
|
|
154
|
+
| ------- | ---------------- | ------------- |
|
|
155
|
+
| A required property has no value for some features | Error | Warning, the property is written as nullable |
|
|
156
|
+
| An id repeats within a collection | Error | Warning |
|
|
157
|
+
| The collection of a feature can't be determined | Error | Warning, the collection is left empty |
|
|
158
|
+
| `-i` or `-e` removes a required property | Error | Warning |
|
|
159
|
+
| A required collection-only property differs between the datasets | Error | Warning, the property is removed |
|
|
160
|
+
| An optional collection-only property differs between the datasets | Warning, the property is removed | Warning, the property is removed |
|
|
161
|
+
| A feature has an empty or missing geometry | Error | Warning, the feature is kept |
|
|
162
|
+
| A collection-level value doesn't fit the data type of its schema | Error | Warning, the value is left empty |
|
|
163
|
+
| The datasets use different versions of the Vecorel specification or of an extension | Error, before any data is read | Error, before any data is read |
|
|
164
|
+
| The schemas of the datasets conflict | Error | Error |
|
|
165
|
+
|
|
166
|
+
Datasets with different versions of the Vecorel specification or of an extension can't be
|
|
167
|
+
merged, as the CLI can't upgrade them automatically yet.
|
|
168
|
+
|
|
169
|
+
The strict mode only checks what a merge can break or check with little effort.
|
|
170
|
+
It doesn't validate the values against the schemas, e.g. patterns or value ranges,
|
|
171
|
+
so a merged dataset is only valid if the values of the source datasets are valid.
|
|
125
172
|
|
|
126
173
|
Check `vec merge --help` for more details.
|
|
127
174
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
2
3
|
import sys
|
|
4
|
+
import zipfile
|
|
3
5
|
|
|
4
6
|
import geopandas as gpd
|
|
5
7
|
import numpy as np
|
|
@@ -8,8 +10,9 @@ import pytest
|
|
|
8
10
|
import shapely
|
|
9
11
|
from loguru import logger
|
|
10
12
|
|
|
13
|
+
from vecorel_cli.conversion import duckdb as duckdb_module
|
|
11
14
|
from vecorel_cli.conversion.base import BaseConverter
|
|
12
|
-
from vecorel_cli.conversion.duckdb import DuckDBBaseConverter
|
|
15
|
+
from vecorel_cli.conversion.duckdb import DuckDBBaseConverter, _constants_table
|
|
13
16
|
from vecorel_cli.validate import ValidateData
|
|
14
17
|
from vecorel_cli.vecorel.hilbert import hilbert_keys_for_table
|
|
15
18
|
|
|
@@ -107,6 +110,48 @@ def test_duckdb_converter(tmp_folder):
|
|
|
107
110
|
assert validation.errors == []
|
|
108
111
|
|
|
109
112
|
|
|
113
|
+
def test_duckdb_converter_extracts_an_archive_without_a_cache(tmp_folder):
|
|
114
|
+
src = _source_file(tmp_folder)
|
|
115
|
+
archive = tmp_folder / "source.zip"
|
|
116
|
+
with zipfile.ZipFile(archive, "w") as zf:
|
|
117
|
+
zf.write(src, "source.parquet")
|
|
118
|
+
dest = tmp_folder / "converted.parquet"
|
|
119
|
+
|
|
120
|
+
Converter().convert(dest, input_files={str(archive): ["source.parquet"]})
|
|
121
|
+
|
|
122
|
+
assert sorted(gpd.read_parquet(dest)["id"]) == ["bowtie", "multi", "square", "with_z"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_duckdb_converter_ignores_hive_style_folders(tmp_folder):
|
|
126
|
+
source_folder = tmp_folder / "region=north"
|
|
127
|
+
source_folder.mkdir()
|
|
128
|
+
src = _source_file(source_folder)
|
|
129
|
+
dest_folder = tmp_folder / "year=2024"
|
|
130
|
+
dest_folder.mkdir()
|
|
131
|
+
dest = dest_folder / "converted.parquet"
|
|
132
|
+
|
|
133
|
+
Converter().convert(dest, input_files={src: "source.parquet"})
|
|
134
|
+
|
|
135
|
+
with pq.ParquetFile(dest) as pf:
|
|
136
|
+
names = pf.schema_arrow.names
|
|
137
|
+
assert "year" not in names
|
|
138
|
+
assert "region" not in names
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.parametrize(
|
|
142
|
+
"urls, in_place",
|
|
143
|
+
[
|
|
144
|
+
("https://host/data.parquet?token=abc", True),
|
|
145
|
+
("https://host/data.geoparquet", True),
|
|
146
|
+
({"https://host/x?id=1": "fields.parquet"}, True),
|
|
147
|
+
({"https://host/data.zip": ["fields.parquet"]}, False),
|
|
148
|
+
("https://host/data.fgb", False),
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
def test_only_parquet_is_read_in_place(urls, in_place):
|
|
152
|
+
assert duckdb_module._readable_in_place(urls) is in_place
|
|
153
|
+
|
|
154
|
+
|
|
110
155
|
def test_duckdb_converter_numbers_rows_without_an_id_mapping(tmp_folder):
|
|
111
156
|
src = _source_file(tmp_folder)
|
|
112
157
|
dest = tmp_folder / "converted.parquet"
|
|
@@ -621,6 +666,127 @@ def test_merge_parquet_unions_parts_that_differ(tmp_folder):
|
|
|
621
666
|
assert sorted(x for x in table.column("id").to_pylist()) == ["0", "1"]
|
|
622
667
|
|
|
623
668
|
|
|
669
|
+
def test_merge_parquet_hydrates_constants_the_parts_disagree_on(tmp_folder):
|
|
670
|
+
"""Each part keeps its constants in its collection; one the parts disagree on
|
|
671
|
+
goes back into the rows, as `vec merge` does, and a shared one stays put."""
|
|
672
|
+
parts = []
|
|
673
|
+
for index, region in enumerate(("north", "south")):
|
|
674
|
+
gdf = gpd.GeoDataFrame(
|
|
675
|
+
{"id": [f"{index}"], "name": ["a"], "geometry": [shapely.box(index, 0, index + 1, 1)]},
|
|
676
|
+
crs="EPSG:4326",
|
|
677
|
+
)
|
|
678
|
+
src = tmp_folder / f"h_src_{index}.parquet"
|
|
679
|
+
gdf.to_parquet(src)
|
|
680
|
+
Part = type(
|
|
681
|
+
"Part",
|
|
682
|
+
(DuckDBBaseConverter,),
|
|
683
|
+
{
|
|
684
|
+
**CONFIG,
|
|
685
|
+
"column_additions": {"region": region, "country": "NL"},
|
|
686
|
+
"missing_schemas": {
|
|
687
|
+
"properties": {
|
|
688
|
+
"name": {"type": "string"},
|
|
689
|
+
"region": {"type": "string"},
|
|
690
|
+
"country": {"type": "string"},
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
)
|
|
695
|
+
part = tmp_folder / f"h_part_{index}.parquet"
|
|
696
|
+
Part().convert(part, input_files={str(src): src.name})
|
|
697
|
+
assert "region" in json.loads(pq.read_schema(part).metadata[b"collection"])
|
|
698
|
+
parts.append(part)
|
|
699
|
+
|
|
700
|
+
dest = tmp_folder / "hydrated.parquet"
|
|
701
|
+
Converter().merge_parquet(parts, dest)
|
|
702
|
+
|
|
703
|
+
table = pq.read_table(dest)
|
|
704
|
+
rows = dict(zip(table.column("id").to_pylist(), table.column("region").to_pylist()))
|
|
705
|
+
assert rows == {"0": "north", "1": "south"}
|
|
706
|
+
collection = json.loads(table.schema.metadata[b"collection"])
|
|
707
|
+
assert "region" not in collection
|
|
708
|
+
assert collection["country"] == "NL" and "country" not in table.schema.names
|
|
709
|
+
assert ValidateData().validate(dest, num=100, schema_map={}).errors == []
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _collection_part(folder, cid, ids, area=None):
|
|
713
|
+
gdf = gpd.GeoDataFrame(
|
|
714
|
+
{
|
|
715
|
+
"id": ids,
|
|
716
|
+
"name": ["a"] * len(ids),
|
|
717
|
+
"geometry": [shapely.box(i, 0, i + 1, 1) for i in range(len(ids))],
|
|
718
|
+
},
|
|
719
|
+
crs="EPSG:4326",
|
|
720
|
+
)
|
|
721
|
+
src = folder / f"c_src_{cid}_{len(ids)}.parquet"
|
|
722
|
+
gdf.to_parquet(src)
|
|
723
|
+
config = {**CONFIG, "id": cid}
|
|
724
|
+
if area is not None:
|
|
725
|
+
config["column_additions"] = {"area": area}
|
|
726
|
+
config["missing_schemas"] = {
|
|
727
|
+
"properties": {"name": {"type": "string"}, "area": {"type": "double"}}
|
|
728
|
+
}
|
|
729
|
+
part = folder / f"c_part_{cid}_{len(ids)}.parquet"
|
|
730
|
+
type("Part", (DuckDBBaseConverter,), config)().convert(part, input_files={str(src): src.name})
|
|
731
|
+
return part
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def test_merge_parquet_numbers_ids_per_collection(tmp_folder):
|
|
735
|
+
"""Ids only have to be unique per collection, so only repeats within one get numbered."""
|
|
736
|
+
parts = [
|
|
737
|
+
_collection_part(tmp_folder, "a", ["1", "2"]),
|
|
738
|
+
_collection_part(tmp_folder, "b", ["1", "2"]),
|
|
739
|
+
_collection_part(tmp_folder, "b", ["1"]),
|
|
740
|
+
]
|
|
741
|
+
dest = tmp_folder / "per_collection.parquet"
|
|
742
|
+
Converter().merge_parquet(parts, dest)
|
|
743
|
+
|
|
744
|
+
table = pq.read_table(dest, columns=["collection", "id"])
|
|
745
|
+
assert sorted(zip(table.column("collection").to_pylist(), table.column("id").to_pylist())) == [
|
|
746
|
+
("a", "1"),
|
|
747
|
+
("a", "2"),
|
|
748
|
+
("b", "1~1"),
|
|
749
|
+
("b", "1~2"),
|
|
750
|
+
("b", "2"),
|
|
751
|
+
]
|
|
752
|
+
assert ValidateData().validate(dest, num=100, schema_map={}).errors == []
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def test_merge_parquet_hydrates_nan_as_null(tmp_folder):
|
|
756
|
+
"""A NaN in the collection comes from a column without values, so it's a null."""
|
|
757
|
+
parts = [
|
|
758
|
+
_collection_part(tmp_folder, "a", ["1"], area=float("nan")),
|
|
759
|
+
_collection_part(tmp_folder, "b", ["2"], area=1.5),
|
|
760
|
+
]
|
|
761
|
+
dest = tmp_folder / "nan.parquet"
|
|
762
|
+
Converter().merge_parquet(parts, dest)
|
|
763
|
+
|
|
764
|
+
table = pq.read_table(dest, columns=["id", "area"])
|
|
765
|
+
assert dict(zip(table.column("id").to_pylist(), table.column("area").to_pylist())) == {
|
|
766
|
+
"1": None,
|
|
767
|
+
"2": 1.5,
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
@pytest.mark.parametrize(
|
|
772
|
+
"value, dtype", [(300, "uint8"), ("not a date", "date-time")], ids=["overflow", "date-time"]
|
|
773
|
+
)
|
|
774
|
+
def test_constants_that_do_not_fit_their_type_are_reported(value, dtype, capsys):
|
|
775
|
+
log = Converter()
|
|
776
|
+
logger.remove()
|
|
777
|
+
logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
|
|
778
|
+
table = _constants_table({"x": value}, {"x": {"type": dtype}}, log=log, source="part.parquet")
|
|
779
|
+
|
|
780
|
+
# the value would fail the writer, so it's left empty
|
|
781
|
+
assert table.column("x").to_pylist() == [None]
|
|
782
|
+
message = f"x: Value {value!r} doesn't fit data type {dtype}"
|
|
783
|
+
out = capsys.readouterr().out
|
|
784
|
+
assert message in out and "(in part.parquet)" in out
|
|
785
|
+
|
|
786
|
+
with pytest.raises(ValueError, match=re.escape(message)):
|
|
787
|
+
_constants_table({"x": value}, {"x": {"type": dtype}}, log=log, strict=True)
|
|
788
|
+
|
|
789
|
+
|
|
624
790
|
def test_merge_parquet_checks_ids_that_convert_generated(tmp_folder, capsys):
|
|
625
791
|
"""Each part numbered its own rows from zero, so the merge has to check what
|
|
626
792
|
convert() is allowed to take for granted."""
|
|
@@ -764,3 +930,31 @@ def test_merge_parquet_keeps_a_crs_duckdb_would_drop(tmp_folder):
|
|
|
764
930
|
crs = geo["columns"][geo["primary_column"]]["crs"]
|
|
765
931
|
assert crs is not None, "the merged file lost the CRS of its parts"
|
|
766
932
|
assert "3857" in json.dumps(crs)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
@pytest.mark.parametrize("spatial_order", [True, False])
|
|
936
|
+
def test_duckdb_converter_gives_a_small_file_enough_row_groups(
|
|
937
|
+
tmp_folder, monkeypatch, spatial_order
|
|
938
|
+
):
|
|
939
|
+
from vecorel_cli.encoding.geoparquet import GeoParquet
|
|
940
|
+
|
|
941
|
+
# DuckDB builds row groups from 2048-row vectors, so the file has to be large enough
|
|
942
|
+
n = 25_000
|
|
943
|
+
monkeypatch.setattr(GeoParquet, "row_group_size", 20_000)
|
|
944
|
+
if not spatial_order:
|
|
945
|
+
# as for a CRS without an area of use
|
|
946
|
+
monkeypatch.setattr(duckdb_module, "hilbert_reference_bounds", lambda *args: None)
|
|
947
|
+
gdf = gpd.GeoDataFrame(
|
|
948
|
+
{
|
|
949
|
+
"id": [str(i) for i in range(n)],
|
|
950
|
+
"name": ["a"] * n,
|
|
951
|
+
"geometry": [shapely.box(i * 1e-5, 0, i * 1e-5 + 1e-5, 1e-5) for i in range(n)],
|
|
952
|
+
},
|
|
953
|
+
crs="EPSG:4326",
|
|
954
|
+
)
|
|
955
|
+
src = tmp_folder / "many.parquet"
|
|
956
|
+
gdf.to_parquet(src)
|
|
957
|
+
dest = tmp_folder / "many-out.parquet"
|
|
958
|
+
Converter().convert(dest, input_files={str(src): src.name})
|
|
959
|
+
|
|
960
|
+
assert pq.ParquetFile(dest).metadata.num_row_groups >= GeoParquet.min_row_groups
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
|
+
import geopandas as gpd
|
|
3
4
|
import pytest
|
|
5
|
+
from shapely.geometry import Polygon
|
|
4
6
|
|
|
5
7
|
from vecorel_cli.create_stac import CreateStacCollection
|
|
6
8
|
from vecorel_cli.registry import Registry
|
|
9
|
+
from vecorel_cli.vecorel.collection import Collection
|
|
7
10
|
from vecorel_cli.vecorel.util import load_file
|
|
8
11
|
|
|
9
12
|
files = [
|
|
@@ -55,3 +58,22 @@ def test_create_stac_collection(tmp_folder: Path, file: str, expected_file: str)
|
|
|
55
58
|
del expected["assets"]["data"]["processing:software"]["vecorel-cli"]
|
|
56
59
|
|
|
57
60
|
assert created_file == expected
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_bbox_is_the_extent_of_the_geometries():
|
|
64
|
+
"""In a projected CRS, the corners of the source bbox reach beyond the data."""
|
|
65
|
+
# a parcel in Saxony, in ETRS89 / UTM zone 33N
|
|
66
|
+
field = Polygon([(300000, 5600000), (500000, 5650000), (450000, 5700000), (320000, 5680000)])
|
|
67
|
+
gdf = gpd.GeoDataFrame({"id": ["1"]}, geometry=[field], crs="EPSG:25833")
|
|
68
|
+
collection = Collection(
|
|
69
|
+
{
|
|
70
|
+
"schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
|
|
71
|
+
"title": "t",
|
|
72
|
+
"description": "d",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
stac = CreateStacCollection().create(collection, gdf, "x.parquet")
|
|
77
|
+
|
|
78
|
+
expected = gdf.to_crs(epsg=4326).total_bounds
|
|
79
|
+
assert stac["extent"]["spatial"]["bbox"][0] == pytest.approx(list(expected))
|
|
@@ -7,7 +7,7 @@ from fsspec.implementations.local import LocalFileSystem
|
|
|
7
7
|
|
|
8
8
|
import vecorel_cli.conversion.base as base
|
|
9
9
|
from vecorel_cli.conversion.base import BaseConverter
|
|
10
|
-
from vecorel_cli.vecorel.util import stream_file
|
|
10
|
+
from vecorel_cli.vecorel.util import get_fs, merge_headers, stream_file
|
|
11
11
|
|
|
12
12
|
URI = "https://example.invalid/data/fields.gml"
|
|
13
13
|
|
|
@@ -192,3 +192,56 @@ def test_multivolume_7z_is_downloaded_and_extracted(explicit_cache, tmp_folder,
|
|
|
192
192
|
# every volume was cached under its own name
|
|
193
193
|
for part in parts:
|
|
194
194
|
assert (tmp_folder / part).exists()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_request_headers_go_with_every_download(tmp_folder, monkeypatch):
|
|
198
|
+
seen = []
|
|
199
|
+
source_fs = _FakeSourceFS(b"body", {})
|
|
200
|
+
|
|
201
|
+
def fake_get_fs(uri, **kwargs):
|
|
202
|
+
if str(uri).startswith(("http://", "https://")):
|
|
203
|
+
seen.append(kwargs.get("client_kwargs"))
|
|
204
|
+
return source_fs
|
|
205
|
+
return LocalFileSystem()
|
|
206
|
+
|
|
207
|
+
monkeypatch.setattr(base, "get_fs", fake_get_fs)
|
|
208
|
+
HeaderConverter = type(
|
|
209
|
+
"HeaderConverter", (BaseConverter,), {"request_headers": {"X-Test": "1"}}
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
HeaderConverter().download_files(URI, cache_folder=str(tmp_folder))
|
|
213
|
+
|
|
214
|
+
assert seen == [{"headers": {"X-Test": "1"}}]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_request_headers_merge_with_explicit_client_options(tmp_folder, monkeypatch):
|
|
218
|
+
seen = []
|
|
219
|
+
source_fs = _FakeSourceFS(b"body", {})
|
|
220
|
+
|
|
221
|
+
def fake_get_fs(uri, **kwargs):
|
|
222
|
+
if str(uri).startswith(("http://", "https://")):
|
|
223
|
+
seen.append(kwargs.get("client_kwargs"))
|
|
224
|
+
return source_fs
|
|
225
|
+
return LocalFileSystem()
|
|
226
|
+
|
|
227
|
+
monkeypatch.setattr(base, "get_fs", fake_get_fs)
|
|
228
|
+
HeaderConverter = type(
|
|
229
|
+
"HeaderConverter", (BaseConverter,), {"request_headers": {"X-Test": "1", "X-Kept": "k"}}
|
|
230
|
+
)
|
|
231
|
+
client_kwargs = {"trust_env": True, "headers": {"x-test": "2"}}
|
|
232
|
+
|
|
233
|
+
HeaderConverter().download_files(URI, cache_folder=str(tmp_folder), client_kwargs=client_kwargs)
|
|
234
|
+
|
|
235
|
+
assert seen == [{"trust_env": True, "headers": {"X-Kept": "k", "x-test": "2"}}]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_headers_merge_case_insensitively():
|
|
239
|
+
merged = merge_headers(
|
|
240
|
+
{"User-Agent": "default", "X-Test": "1"}, {"user-agent": "mine", "x-test": "2"}
|
|
241
|
+
)
|
|
242
|
+
assert merged == {"user-agent": "mine", "x-test": "2"}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_a_converter_user_agent_replaces_the_default():
|
|
246
|
+
fs = get_fs("https://example.invalid/x", client_kwargs={"headers": {"user-agent": "browser"}})
|
|
247
|
+
assert fs.client_kwargs["headers"] == {"user-agent": "browser"}
|
|
@@ -148,3 +148,132 @@ def test_read_keeps_integers_that_have_a_null(tmp_parquet_file):
|
|
|
148
148
|
from vecorel_cli.validate import ValidateData
|
|
149
149
|
|
|
150
150
|
assert ValidateData().validate(tmp_parquet_file).errors == []
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_write_keeps_all_null_columns_in_the_data(tmp_parquet_file):
|
|
154
|
+
# NaN and NA aren't valid JSON, so they can't move to the collection
|
|
155
|
+
from geopandas import GeoDataFrame
|
|
156
|
+
from shapely.geometry import box
|
|
157
|
+
|
|
158
|
+
gdf = GeoDataFrame(
|
|
159
|
+
{
|
|
160
|
+
"id": ["1", "2"],
|
|
161
|
+
"code": pd.array([None, None], dtype="Int64"),
|
|
162
|
+
"area": [np.nan, np.nan],
|
|
163
|
+
"name": [None, None],
|
|
164
|
+
"geometry": [box(0, 0, 1, 1), box(1, 0, 2, 1)],
|
|
165
|
+
},
|
|
166
|
+
crs="EPSG:4326",
|
|
167
|
+
)
|
|
168
|
+
gp = GeoParquet(tmp_parquet_file)
|
|
169
|
+
gp.set_collection(
|
|
170
|
+
{
|
|
171
|
+
"schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
|
|
172
|
+
"collection": "c",
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
gp.write(gdf)
|
|
176
|
+
|
|
177
|
+
collection = GeoParquet(tmp_parquet_file).get_collection()
|
|
178
|
+
assert not {"code", "area", "name"} & set(collection)
|
|
179
|
+
assert {"code", "area", "name"} <= set(pq.read_schema(tmp_parquet_file).names)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_get_pyarrow_type_keeps_the_schema():
|
|
183
|
+
from vecorel_cli.parquet.types import get_pyarrow_type
|
|
184
|
+
|
|
185
|
+
schema = {"type": "object", "patternProperties": {".*": {"type": "string"}}}
|
|
186
|
+
assert get_pyarrow_type(schema) == pa.map_(pa.string(), pa.string())
|
|
187
|
+
assert get_pyarrow_type(schema) == pa.map_(pa.string(), pa.string())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_constant_array_parses_dates_completely():
|
|
191
|
+
import datetime
|
|
192
|
+
|
|
193
|
+
import pytest
|
|
194
|
+
|
|
195
|
+
from vecorel_cli.parquet.types import constant_array
|
|
196
|
+
|
|
197
|
+
schema = {"type": "date"}
|
|
198
|
+
for value in ["2024-01-01", "2024-01-01T00:00:00Z"]:
|
|
199
|
+
assert constant_array(value, schema).to_pylist() == [datetime.date(2024, 1, 1)]
|
|
200
|
+
for value in ["2024-01-01-invalid", "2024-01-01T12:00:00Z"]:
|
|
201
|
+
with pytest.raises(ValueError):
|
|
202
|
+
constant_array(value, schema)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_write_moves_a_constant_date_to_the_collection(tmp_parquet_file):
|
|
206
|
+
import datetime
|
|
207
|
+
|
|
208
|
+
from geopandas import GeoDataFrame
|
|
209
|
+
from shapely.geometry import box
|
|
210
|
+
|
|
211
|
+
gdf = GeoDataFrame(
|
|
212
|
+
{
|
|
213
|
+
"id": ["1", "2"],
|
|
214
|
+
"d": [datetime.date(2024, 1, 1)] * 2,
|
|
215
|
+
"geometry": [box(0, 0, 1, 1), box(1, 0, 2, 1)],
|
|
216
|
+
},
|
|
217
|
+
crs="EPSG:4326",
|
|
218
|
+
)
|
|
219
|
+
gp = GeoParquet(tmp_parquet_file)
|
|
220
|
+
gp.set_collection(
|
|
221
|
+
{
|
|
222
|
+
"schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
|
|
223
|
+
"collection": "c",
|
|
224
|
+
"schemas:custom": {"properties": {"d": {"type": "date"}}},
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
gp.write(gdf)
|
|
228
|
+
|
|
229
|
+
# dates are stored as ISO 8601 strings at the collection level
|
|
230
|
+
assert GeoParquet(tmp_parquet_file).get_collection()["d"] == "2024-01-01"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_read_hydrates_array_and_object_constants(tmp_parquet_file):
|
|
234
|
+
from geopandas import GeoDataFrame
|
|
235
|
+
from shapely.geometry import box
|
|
236
|
+
|
|
237
|
+
gdf = GeoDataFrame(
|
|
238
|
+
{"id": ["1", "2", "3"], "geometry": [box(0, 0, 1, 1)] * 3},
|
|
239
|
+
crs="EPSG:4326",
|
|
240
|
+
)
|
|
241
|
+
gp = GeoParquet(tmp_parquet_file)
|
|
242
|
+
gp.set_collection(
|
|
243
|
+
{
|
|
244
|
+
"schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
|
|
245
|
+
"collection": "c",
|
|
246
|
+
"tags": ["a", "b"],
|
|
247
|
+
"attrs": {"k": "v"},
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
gp.write(gdf, dehydrate=False)
|
|
251
|
+
|
|
252
|
+
data = GeoParquet(tmp_parquet_file).read(hydrate=True)
|
|
253
|
+
assert list(data["tags"]) == [["a", "b"]] * 3
|
|
254
|
+
assert list(data["attrs"]) == [{"k": "v"}] * 3
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_a_small_file_still_gets_enough_row_groups(tmp_parquet_file, monkeypatch):
|
|
258
|
+
# with too few groups each one spans most of the file, so a reader can skip none
|
|
259
|
+
from geopandas import GeoDataFrame
|
|
260
|
+
from shapely.geometry import box
|
|
261
|
+
|
|
262
|
+
monkeypatch.setattr(GeoParquet, "row_group_size", 100)
|
|
263
|
+
monkeypatch.setattr(GeoParquet, "min_row_group_rows", 10)
|
|
264
|
+
gdf = GeoDataFrame(
|
|
265
|
+
{"id": [str(i) for i in range(500)], "geometry": [box(i, 0, i + 1, 1) for i in range(500)]},
|
|
266
|
+
crs="EPSG:4326",
|
|
267
|
+
)
|
|
268
|
+
gp = GeoParquet(tmp_parquet_file)
|
|
269
|
+
gp.set_collection(
|
|
270
|
+
{
|
|
271
|
+
"schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
|
|
272
|
+
"collection": "c",
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
gp.write(gdf)
|
|
276
|
+
|
|
277
|
+
assert pq.ParquetFile(tmp_parquet_file).metadata.num_row_groups == GeoParquet.min_row_groups
|
|
278
|
+
assert GeoParquet.row_group_size_for(5_000) == 100 # a large file keeps the cap
|
|
279
|
+
assert GeoParquet.row_group_size_for(20) == 10 # and a tiny one the floor
|