vecorel-cli 0.3.1__tar.gz → 0.4.0__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.0}/PKG-INFO +49 -2
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/README.md +48 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/pyproject.toml +1 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_convert_duckdb.py +152 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_create_stac.py +22 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_encoding_geoparquet.py +129 -0
- vecorel_cli-0.4.0/tests/test_merge.py +799 -0
- vecorel_cli-0.4.0/tests/test_ops.py +245 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_validate.py +80 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/duckdb.py +268 -84
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/create_geoparquet.py +3 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/create_stac.py +8 -3
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/base.py +20 -3
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/geojson.py +6 -3
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/geoparquet.py +51 -1
- vecorel_cli-0.4.0/vecorel_cli/merge.py +187 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/parquet/types.py +48 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/base.py +1 -1
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/geoparquet.py +41 -2
- vecorel_cli-0.4.0/vecorel_cli/vecorel/ops.py +300 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/schemas.py +21 -2
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/util.py +19 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0/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.0}/LICENSE +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/setup.cfg +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_convert.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_converters.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_create_geojson.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_create_geoparquet.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_deflate64.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_describe.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_download.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_encoding_auto.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_encoding_geojson.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_hilbert.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_improve.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_jsonschema.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_rename_extension.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_validate_schema.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/basecommand.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/logger.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/options.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/path_url.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/setup.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/util.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/const.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/admin.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/base.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/deflate64.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/flatdict.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/convert.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/converters.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/create_geojson.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/create_jsonschema.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/datasets/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/datasets/template.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/describe.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/auto.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/improve.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/jsonschema/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/jsonschema/template.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/parquet/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/parquet/geopandas.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/registry.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/rename_extension.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validate.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validate_schema.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/data.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/geojson.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/__init__.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/collection.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/extensions.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/hilbert.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/typing.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/version.py +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli.egg-info/SOURCES.txt +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli.egg-info/dependency_links.txt +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli.egg-info/entry_points.txt +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli.egg-info/requires.txt +0 -0
- {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/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.0
|
|
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,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
2
3
|
import sys
|
|
3
4
|
|
|
4
5
|
import geopandas as gpd
|
|
@@ -8,8 +9,9 @@ import pytest
|
|
|
8
9
|
import shapely
|
|
9
10
|
from loguru import logger
|
|
10
11
|
|
|
12
|
+
from vecorel_cli.conversion import duckdb as duckdb_module
|
|
11
13
|
from vecorel_cli.conversion.base import BaseConverter
|
|
12
|
-
from vecorel_cli.conversion.duckdb import DuckDBBaseConverter
|
|
14
|
+
from vecorel_cli.conversion.duckdb import DuckDBBaseConverter, _constants_table
|
|
13
15
|
from vecorel_cli.validate import ValidateData
|
|
14
16
|
from vecorel_cli.vecorel.hilbert import hilbert_keys_for_table
|
|
15
17
|
|
|
@@ -621,6 +623,127 @@ def test_merge_parquet_unions_parts_that_differ(tmp_folder):
|
|
|
621
623
|
assert sorted(x for x in table.column("id").to_pylist()) == ["0", "1"]
|
|
622
624
|
|
|
623
625
|
|
|
626
|
+
def test_merge_parquet_hydrates_constants_the_parts_disagree_on(tmp_folder):
|
|
627
|
+
"""Each part keeps its constants in its collection; one the parts disagree on
|
|
628
|
+
goes back into the rows, as `vec merge` does, and a shared one stays put."""
|
|
629
|
+
parts = []
|
|
630
|
+
for index, region in enumerate(("north", "south")):
|
|
631
|
+
gdf = gpd.GeoDataFrame(
|
|
632
|
+
{"id": [f"{index}"], "name": ["a"], "geometry": [shapely.box(index, 0, index + 1, 1)]},
|
|
633
|
+
crs="EPSG:4326",
|
|
634
|
+
)
|
|
635
|
+
src = tmp_folder / f"h_src_{index}.parquet"
|
|
636
|
+
gdf.to_parquet(src)
|
|
637
|
+
Part = type(
|
|
638
|
+
"Part",
|
|
639
|
+
(DuckDBBaseConverter,),
|
|
640
|
+
{
|
|
641
|
+
**CONFIG,
|
|
642
|
+
"column_additions": {"region": region, "country": "NL"},
|
|
643
|
+
"missing_schemas": {
|
|
644
|
+
"properties": {
|
|
645
|
+
"name": {"type": "string"},
|
|
646
|
+
"region": {"type": "string"},
|
|
647
|
+
"country": {"type": "string"},
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
)
|
|
652
|
+
part = tmp_folder / f"h_part_{index}.parquet"
|
|
653
|
+
Part().convert(part, input_files={str(src): src.name})
|
|
654
|
+
assert "region" in json.loads(pq.read_schema(part).metadata[b"collection"])
|
|
655
|
+
parts.append(part)
|
|
656
|
+
|
|
657
|
+
dest = tmp_folder / "hydrated.parquet"
|
|
658
|
+
Converter().merge_parquet(parts, dest)
|
|
659
|
+
|
|
660
|
+
table = pq.read_table(dest)
|
|
661
|
+
rows = dict(zip(table.column("id").to_pylist(), table.column("region").to_pylist()))
|
|
662
|
+
assert rows == {"0": "north", "1": "south"}
|
|
663
|
+
collection = json.loads(table.schema.metadata[b"collection"])
|
|
664
|
+
assert "region" not in collection
|
|
665
|
+
assert collection["country"] == "NL" and "country" not in table.schema.names
|
|
666
|
+
assert ValidateData().validate(dest, num=100, schema_map={}).errors == []
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _collection_part(folder, cid, ids, area=None):
|
|
670
|
+
gdf = gpd.GeoDataFrame(
|
|
671
|
+
{
|
|
672
|
+
"id": ids,
|
|
673
|
+
"name": ["a"] * len(ids),
|
|
674
|
+
"geometry": [shapely.box(i, 0, i + 1, 1) for i in range(len(ids))],
|
|
675
|
+
},
|
|
676
|
+
crs="EPSG:4326",
|
|
677
|
+
)
|
|
678
|
+
src = folder / f"c_src_{cid}_{len(ids)}.parquet"
|
|
679
|
+
gdf.to_parquet(src)
|
|
680
|
+
config = {**CONFIG, "id": cid}
|
|
681
|
+
if area is not None:
|
|
682
|
+
config["column_additions"] = {"area": area}
|
|
683
|
+
config["missing_schemas"] = {
|
|
684
|
+
"properties": {"name": {"type": "string"}, "area": {"type": "double"}}
|
|
685
|
+
}
|
|
686
|
+
part = folder / f"c_part_{cid}_{len(ids)}.parquet"
|
|
687
|
+
type("Part", (DuckDBBaseConverter,), config)().convert(part, input_files={str(src): src.name})
|
|
688
|
+
return part
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def test_merge_parquet_numbers_ids_per_collection(tmp_folder):
|
|
692
|
+
"""Ids only have to be unique per collection, so only repeats within one get numbered."""
|
|
693
|
+
parts = [
|
|
694
|
+
_collection_part(tmp_folder, "a", ["1", "2"]),
|
|
695
|
+
_collection_part(tmp_folder, "b", ["1", "2"]),
|
|
696
|
+
_collection_part(tmp_folder, "b", ["1"]),
|
|
697
|
+
]
|
|
698
|
+
dest = tmp_folder / "per_collection.parquet"
|
|
699
|
+
Converter().merge_parquet(parts, dest)
|
|
700
|
+
|
|
701
|
+
table = pq.read_table(dest, columns=["collection", "id"])
|
|
702
|
+
assert sorted(zip(table.column("collection").to_pylist(), table.column("id").to_pylist())) == [
|
|
703
|
+
("a", "1"),
|
|
704
|
+
("a", "2"),
|
|
705
|
+
("b", "1~1"),
|
|
706
|
+
("b", "1~2"),
|
|
707
|
+
("b", "2"),
|
|
708
|
+
]
|
|
709
|
+
assert ValidateData().validate(dest, num=100, schema_map={}).errors == []
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def test_merge_parquet_hydrates_nan_as_null(tmp_folder):
|
|
713
|
+
"""A NaN in the collection comes from a column without values, so it's a null."""
|
|
714
|
+
parts = [
|
|
715
|
+
_collection_part(tmp_folder, "a", ["1"], area=float("nan")),
|
|
716
|
+
_collection_part(tmp_folder, "b", ["2"], area=1.5),
|
|
717
|
+
]
|
|
718
|
+
dest = tmp_folder / "nan.parquet"
|
|
719
|
+
Converter().merge_parquet(parts, dest)
|
|
720
|
+
|
|
721
|
+
table = pq.read_table(dest, columns=["id", "area"])
|
|
722
|
+
assert dict(zip(table.column("id").to_pylist(), table.column("area").to_pylist())) == {
|
|
723
|
+
"1": None,
|
|
724
|
+
"2": 1.5,
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@pytest.mark.parametrize(
|
|
729
|
+
"value, dtype", [(300, "uint8"), ("not a date", "date-time")], ids=["overflow", "date-time"]
|
|
730
|
+
)
|
|
731
|
+
def test_constants_that_do_not_fit_their_type_are_reported(value, dtype, capsys):
|
|
732
|
+
log = Converter()
|
|
733
|
+
logger.remove()
|
|
734
|
+
logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
|
|
735
|
+
table = _constants_table({"x": value}, {"x": {"type": dtype}}, log=log, source="part.parquet")
|
|
736
|
+
|
|
737
|
+
# the value would fail the writer, so it's left empty
|
|
738
|
+
assert table.column("x").to_pylist() == [None]
|
|
739
|
+
message = f"x: Value {value!r} doesn't fit data type {dtype}"
|
|
740
|
+
out = capsys.readouterr().out
|
|
741
|
+
assert message in out and "(in part.parquet)" in out
|
|
742
|
+
|
|
743
|
+
with pytest.raises(ValueError, match=re.escape(message)):
|
|
744
|
+
_constants_table({"x": value}, {"x": {"type": dtype}}, log=log, strict=True)
|
|
745
|
+
|
|
746
|
+
|
|
624
747
|
def test_merge_parquet_checks_ids_that_convert_generated(tmp_folder, capsys):
|
|
625
748
|
"""Each part numbered its own rows from zero, so the merge has to check what
|
|
626
749
|
convert() is allowed to take for granted."""
|
|
@@ -764,3 +887,31 @@ def test_merge_parquet_keeps_a_crs_duckdb_would_drop(tmp_folder):
|
|
|
764
887
|
crs = geo["columns"][geo["primary_column"]]["crs"]
|
|
765
888
|
assert crs is not None, "the merged file lost the CRS of its parts"
|
|
766
889
|
assert "3857" in json.dumps(crs)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
@pytest.mark.parametrize("spatial_order", [True, False])
|
|
893
|
+
def test_duckdb_converter_gives_a_small_file_enough_row_groups(
|
|
894
|
+
tmp_folder, monkeypatch, spatial_order
|
|
895
|
+
):
|
|
896
|
+
from vecorel_cli.encoding.geoparquet import GeoParquet
|
|
897
|
+
|
|
898
|
+
# DuckDB builds row groups from 2048-row vectors, so the file has to be large enough
|
|
899
|
+
n = 25_000
|
|
900
|
+
monkeypatch.setattr(GeoParquet, "row_group_size", 20_000)
|
|
901
|
+
if not spatial_order:
|
|
902
|
+
# as for a CRS without an area of use
|
|
903
|
+
monkeypatch.setattr(duckdb_module, "hilbert_reference_bounds", lambda *args: None)
|
|
904
|
+
gdf = gpd.GeoDataFrame(
|
|
905
|
+
{
|
|
906
|
+
"id": [str(i) for i in range(n)],
|
|
907
|
+
"name": ["a"] * n,
|
|
908
|
+
"geometry": [shapely.box(i * 1e-5, 0, i * 1e-5 + 1e-5, 1e-5) for i in range(n)],
|
|
909
|
+
},
|
|
910
|
+
crs="EPSG:4326",
|
|
911
|
+
)
|
|
912
|
+
src = tmp_folder / "many.parquet"
|
|
913
|
+
gdf.to_parquet(src)
|
|
914
|
+
dest = tmp_folder / "many-out.parquet"
|
|
915
|
+
Converter().convert(dest, input_files={str(src): src.name})
|
|
916
|
+
|
|
917
|
+
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))
|
|
@@ -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
|