vecorel-cli 0.2.15__tar.gz → 0.2.17__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.15 → vecorel_cli-0.2.17}/PKG-INFO +17 -8
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/README.md +8 -6
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/pyproject.toml +16 -7
- vecorel_cli-0.2.17/tests/test_convert.py +182 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_create_geoparquet.py +18 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_encoding_geojson.py +23 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_ops.py +37 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_validate_schema.py +20 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/basecommand.py +4 -0
- vecorel_cli-0.2.17/vecorel_cli/const.py +12 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/conversion/base.py +11 -1
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/create_stac.py +1 -1
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/geojson.py +8 -3
- vecorel_cli-0.2.17/vecorel_cli/vecorel/hilbert.py +142 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/ops.py +14 -5
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/PKG-INFO +17 -8
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/SOURCES.txt +1 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/requires.txt +11 -1
- vecorel_cli-0.2.15/tests/test_convert.py +0 -76
- vecorel_cli-0.2.15/vecorel_cli/const.py +0 -6
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/LICENSE +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/setup.cfg +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_converters.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_create_geojson.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_create_stac.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_describe.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_encoding_auto.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_encoding_geoparquet.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_improve.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_jsonschema.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_merge.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_rename_extension.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_validate.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/__init__.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/__init__.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/logger.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/options.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/path_url.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/setup.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/util.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/conversion/__init__.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/conversion/admin.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/conversion/flatdict.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/convert.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/converters.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/create_geojson.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/create_geoparquet.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/create_jsonschema.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/datasets/__init__.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/datasets/template.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/describe.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/__init__.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/auto.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/base.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/geoparquet.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/improve.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/jsonschema/__init__.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/jsonschema/template.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/merge.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/parquet/__init__.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/parquet/geopandas.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/parquet/types.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/registry.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/rename_extension.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validate.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validate_schema.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/__init__.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/base.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/data.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/geojson.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/geoparquet.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/__init__.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/collection.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/extensions.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/schemas.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/typing.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/util.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/version.py +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/dependency_links.txt +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/entry_points.txt +0 -0
- {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/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.17
|
|
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
|
|
@@ -24,7 +24,7 @@ Requires-Dist: shapely<3.0,>=2.1
|
|
|
24
24
|
Requires-Dist: numpy<3.0,>=2.0
|
|
25
25
|
Requires-Dist: pyarrow<24.0,>=21.0
|
|
26
26
|
Requires-Dist: py7zr<2.0,>=1.0
|
|
27
|
-
Requires-Dist: fsspec
|
|
27
|
+
Requires-Dist: fsspec>=2025.7.0
|
|
28
28
|
Requires-Dist: jsonschema[format]<5.0,>=4.20
|
|
29
29
|
Requires-Dist: aiohttp<4.0,>=3.9
|
|
30
30
|
Requires-Dist: yarl<2.0,>=1.20
|
|
@@ -32,6 +32,13 @@ 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
|
+
Provides-Extra: s3
|
|
36
|
+
Requires-Dist: s3fs>=2025.7.0; extra == "s3"
|
|
37
|
+
Provides-Extra: gcs
|
|
38
|
+
Requires-Dist: gcsfs>=2025.7.0; extra == "gcs"
|
|
39
|
+
Provides-Extra: cloud
|
|
40
|
+
Requires-Dist: s3fs>=2025.7.0; extra == "cloud"
|
|
41
|
+
Requires-Dist: gcsfs>=2025.7.0; extra == "cloud"
|
|
35
42
|
Dynamic: license-file
|
|
36
43
|
|
|
37
44
|
# Vecorel CLI
|
|
@@ -65,12 +72,14 @@ pixi install
|
|
|
65
72
|
pixi run vec
|
|
66
73
|
```
|
|
67
74
|
|
|
68
|
-
#### Using pip
|
|
75
|
+
#### Using pip or uv
|
|
69
76
|
|
|
70
77
|
Alternatively, you can install from PyPI with **Python 3.11** or any later version:
|
|
71
78
|
|
|
72
79
|
```bash
|
|
73
80
|
pip install vecorel-cli
|
|
81
|
+
# or
|
|
82
|
+
uv pip install vecorel-cli
|
|
74
83
|
```
|
|
75
84
|
|
|
76
85
|
### Execute a command
|
|
@@ -115,9 +124,9 @@ Check `vec validate --help` for more details.
|
|
|
115
124
|
The validator also supports remote files.
|
|
116
125
|
|
|
117
126
|
- `http://` or `https://`: no further configuration is needed.
|
|
118
|
-
- `s3://`: With Pixi, run `pixi install -e s3` or with pip, run `pip install vecorel-cli[s3]` and you may need to set environment variables.
|
|
127
|
+
- `s3://`: With Pixi, run `pixi install -e s3` or with pip/uv, run `pip install vecorel-cli[s3]` and you may need to set environment variables.
|
|
119
128
|
Refer to the [s3fs credentials documentation](https://s3fs.readthedocs.io/en/latest/#credentials) for how to define credentials.
|
|
120
|
-
- `gs://`: With Pixi, run `pixi install -e gcs` or with pip, run `pip install vecorel-cli[gcs]`.
|
|
129
|
+
- `gs://`: With Pixi, run `pixi install -e gcs` or with pip/uv, run `pip install vecorel-cli[gcs]`.
|
|
121
130
|
By default, `gcsfs` will attempt to use your default gcloud credentials or, attempt to get credentials from the google metadata service, or fall back to anonymous access.
|
|
122
131
|
|
|
123
132
|
### Create Vecorel GeoParquet from GeoJSON
|
|
@@ -237,14 +246,14 @@ pixi install -e dev
|
|
|
237
246
|
pixi run install-dev
|
|
238
247
|
|
|
239
248
|
# Run tests
|
|
240
|
-
pixi run test
|
|
249
|
+
pixi run -e dev test
|
|
241
250
|
|
|
242
251
|
# Format and lint code
|
|
243
252
|
pixi run format
|
|
244
253
|
pixi run lint
|
|
245
254
|
|
|
246
255
|
# Run all checks (lint, format, test)
|
|
247
|
-
pixi run check
|
|
256
|
+
pixi run -e dev check
|
|
248
257
|
|
|
249
258
|
# Install and run pre-commit
|
|
250
259
|
pixi run pre-commit-install
|
|
@@ -257,6 +266,6 @@ The following high-level description gives an idea how to implement a converter
|
|
|
257
266
|
|
|
258
267
|
1. Create a new file in `vecorel_cli/datasets` based on the `template.py`
|
|
259
268
|
2. Fill in the required variables / test it / run it
|
|
260
|
-
3. Add missing dependencies into
|
|
269
|
+
3. Add missing dependencies into `pyproject.toml` (and the appropriate pixi feature group if needed)
|
|
261
270
|
4. Add the converter to the list above
|
|
262
271
|
5. Create a PR to submit your converter for review
|
|
@@ -29,12 +29,14 @@ pixi install
|
|
|
29
29
|
pixi run vec
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
#### Using pip
|
|
32
|
+
#### Using pip or uv
|
|
33
33
|
|
|
34
34
|
Alternatively, you can install from PyPI with **Python 3.11** or any later version:
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
pip install vecorel-cli
|
|
38
|
+
# or
|
|
39
|
+
uv pip install vecorel-cli
|
|
38
40
|
```
|
|
39
41
|
|
|
40
42
|
### Execute a command
|
|
@@ -79,9 +81,9 @@ Check `vec validate --help` for more details.
|
|
|
79
81
|
The validator also supports remote files.
|
|
80
82
|
|
|
81
83
|
- `http://` or `https://`: no further configuration is needed.
|
|
82
|
-
- `s3://`: With Pixi, run `pixi install -e s3` or with pip, run `pip install vecorel-cli[s3]` and you may need to set environment variables.
|
|
84
|
+
- `s3://`: With Pixi, run `pixi install -e s3` or with pip/uv, run `pip install vecorel-cli[s3]` and you may need to set environment variables.
|
|
83
85
|
Refer to the [s3fs credentials documentation](https://s3fs.readthedocs.io/en/latest/#credentials) for how to define credentials.
|
|
84
|
-
- `gs://`: With Pixi, run `pixi install -e gcs` or with pip, run `pip install vecorel-cli[gcs]`.
|
|
86
|
+
- `gs://`: With Pixi, run `pixi install -e gcs` or with pip/uv, run `pip install vecorel-cli[gcs]`.
|
|
85
87
|
By default, `gcsfs` will attempt to use your default gcloud credentials or, attempt to get credentials from the google metadata service, or fall back to anonymous access.
|
|
86
88
|
|
|
87
89
|
### Create Vecorel GeoParquet from GeoJSON
|
|
@@ -201,14 +203,14 @@ pixi install -e dev
|
|
|
201
203
|
pixi run install-dev
|
|
202
204
|
|
|
203
205
|
# Run tests
|
|
204
|
-
pixi run test
|
|
206
|
+
pixi run -e dev test
|
|
205
207
|
|
|
206
208
|
# Format and lint code
|
|
207
209
|
pixi run format
|
|
208
210
|
pixi run lint
|
|
209
211
|
|
|
210
212
|
# Run all checks (lint, format, test)
|
|
211
|
-
pixi run check
|
|
213
|
+
pixi run -e dev check
|
|
212
214
|
|
|
213
215
|
# Install and run pre-commit
|
|
214
216
|
pixi run pre-commit-install
|
|
@@ -221,6 +223,6 @@ The following high-level description gives an idea how to implement a converter
|
|
|
221
223
|
|
|
222
224
|
1. Create a new file in `vecorel_cli/datasets` based on the `template.py`
|
|
223
225
|
2. Fill in the required variables / test it / run it
|
|
224
|
-
3. Add missing dependencies into
|
|
226
|
+
3. Add missing dependencies into `pyproject.toml` (and the appropriate pixi feature group if needed)
|
|
225
227
|
4. Add the converter to the list above
|
|
226
228
|
5. Create a PR to submit your converter for review
|
|
@@ -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.17"
|
|
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"
|
|
@@ -30,7 +30,7 @@ dependencies = [
|
|
|
30
30
|
"numpy>=2.0,<3.0",
|
|
31
31
|
"pyarrow>=21.0,<24.0",
|
|
32
32
|
"py7zr>=1.0,<2.0",
|
|
33
|
-
"fsspec
|
|
33
|
+
"fsspec>=2025.7.0",
|
|
34
34
|
"jsonschema[format]>=4.20,<5.0",
|
|
35
35
|
"aiohttp>=3.9,<4.0",
|
|
36
36
|
"yarl>=1.20,<2.0",
|
|
@@ -40,6 +40,11 @@ dependencies = [
|
|
|
40
40
|
"loguru==0.7.3",
|
|
41
41
|
]
|
|
42
42
|
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
s3 = ["s3fs>=2025.7.0"]
|
|
45
|
+
gcs = ["gcsfs>=2025.7.0"]
|
|
46
|
+
cloud = ["s3fs>=2025.7.0", "gcsfs>=2025.7.0"]
|
|
47
|
+
|
|
43
48
|
[project.scripts]
|
|
44
49
|
vec = "vecorel_cli.cli.setup:run"
|
|
45
50
|
|
|
@@ -81,15 +86,19 @@ GitPython = ">=3.0.0,<4.0.0"
|
|
|
81
86
|
[tool.pixi.feature.dev.pypi-dependencies]
|
|
82
87
|
build = ">=1.0.0,<2.0.0"
|
|
83
88
|
|
|
84
|
-
[tool.pixi.feature.
|
|
85
|
-
s3fs = "
|
|
86
|
-
|
|
89
|
+
[tool.pixi.feature.s3.dependencies]
|
|
90
|
+
s3fs = ">=2025.7.0"
|
|
91
|
+
|
|
92
|
+
[tool.pixi.feature.gcs.dependencies]
|
|
93
|
+
gcsfs = ">=2025.7.0"
|
|
87
94
|
|
|
88
95
|
[tool.pixi.environments]
|
|
89
96
|
default = {solve-group = "default"}
|
|
90
97
|
dev = {features = ["dev"], solve-group = "default"}
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
s3 = {features = ["s3"], solve-group = "default"}
|
|
99
|
+
gcs = {features = ["gcs"], solve-group = "default"}
|
|
100
|
+
cloud = {features = ["s3", "gcs"], solve-group = "default"}
|
|
101
|
+
all = {features = ["dev", "s3", "gcs"], solve-group = "default"}
|
|
93
102
|
|
|
94
103
|
[tool.pixi.tasks]
|
|
95
104
|
# Development tasks
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from vecorel_cli.conversion.base import BaseConverter
|
|
11
|
+
from vecorel_cli.convert import ConvertData
|
|
12
|
+
from vecorel_cli.registry import Registry
|
|
13
|
+
from vecorel_cli.validate import ValidateData
|
|
14
|
+
from vecorel_cli.vecorel.hilbert import crs_total_bounds, hilbert_distances_from_bounds
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(autouse=True)
|
|
18
|
+
def registry_reset():
|
|
19
|
+
ignored = Registry.ignored_datasets
|
|
20
|
+
src_package = Registry.src_package
|
|
21
|
+
Registry.src_package = "tests"
|
|
22
|
+
yield
|
|
23
|
+
Registry.ignore_datasets = ignored
|
|
24
|
+
Registry.src_package = src_package
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
test_path = Path("tests/data-files/convert")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize("choice", ["example"])
|
|
31
|
+
def test_converter(tmp_folder, choice):
|
|
32
|
+
dest = tmp_folder / "converted.parquet"
|
|
33
|
+
converter = ConvertData(choice)
|
|
34
|
+
converter.convert(dest, cache=(test_path / choice))
|
|
35
|
+
|
|
36
|
+
assert dest.exists(), f"Expected file {dest} to be created."
|
|
37
|
+
|
|
38
|
+
# todo: Validation works, but fails for the created file
|
|
39
|
+
validator = ValidateData()
|
|
40
|
+
validation_result = validator.validate(
|
|
41
|
+
dest,
|
|
42
|
+
num=100,
|
|
43
|
+
schema_map={},
|
|
44
|
+
)
|
|
45
|
+
assert validation_result.errors == []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("choice", ["example"])
|
|
49
|
+
def test_converter_hilbert_curve_order(tmp_folder, choice):
|
|
50
|
+
"""The converter must emit rows in Hilbert-curve order against the CRS's
|
|
51
|
+
total bounds. This is what makes per-partition outputs of the same dataset
|
|
52
|
+
mergeable without re-sorting: they all share a CRS-derived Hilbert grid."""
|
|
53
|
+
dest = tmp_folder / "converted.parquet"
|
|
54
|
+
ConvertData(choice).convert(dest, cache=(test_path / choice))
|
|
55
|
+
|
|
56
|
+
gdf = gpd.read_parquet(dest)
|
|
57
|
+
assert len(gdf) > 1, "Need at least 2 rows to verify ordering."
|
|
58
|
+
|
|
59
|
+
total_bounds = crs_total_bounds(gdf.crs)
|
|
60
|
+
bounds = gdf.geometry.bounds.to_numpy(dtype=np.float64, copy=False)
|
|
61
|
+
keys = hilbert_distances_from_bounds(bounds, total_bounds)
|
|
62
|
+
|
|
63
|
+
# NB: keys are uint64 — np.diff would wrap descents into huge positives
|
|
64
|
+
# and never flag them; compare adjacent keys directly instead.
|
|
65
|
+
descents = keys[1:] < keys[:-1]
|
|
66
|
+
if np.any(descents):
|
|
67
|
+
bad = int(np.argmax(descents))
|
|
68
|
+
raise AssertionError(
|
|
69
|
+
"Output rows are not in non-decreasing Hilbert order against the "
|
|
70
|
+
f"CRS total bounds ({total_bounds}).\n"
|
|
71
|
+
f" First out-of-order pair at index {bad}->{bad + 1}: "
|
|
72
|
+
f"key {int(keys[bad])} > {int(keys[bad + 1])}."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_crs_total_bounds_geographic():
|
|
77
|
+
# Geographic CRS without a more restrictive area_of_use falls back to world.
|
|
78
|
+
assert crs_total_bounds("EPSG:4326") == (-180.0, -90.0, 180.0, 90.0)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_crs_total_bounds_etrs89():
|
|
82
|
+
# EPSG:4258 is geographic with a European area_of_use; the returned bounds
|
|
83
|
+
# must reflect that AoU rather than world bounds (otherwise Hilbert keys
|
|
84
|
+
# for European data would all be quantised into one tiny cell).
|
|
85
|
+
bounds = crs_total_bounds("EPSG:4258")
|
|
86
|
+
assert bounds[0] > -180.0 and bounds[2] < 180.0, bounds
|
|
87
|
+
assert bounds[1] > -90.0 and bounds[3] < 90.0, bounds
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_hilbert_distances_match_public_geopandas_api():
|
|
91
|
+
# hilbert_distances_from_bounds exists for callers that hold bbox columns
|
|
92
|
+
# without decoded geometry; it must stay identical to the public
|
|
93
|
+
# GeoSeries.hilbert_distance, which guards its private-API internals.
|
|
94
|
+
from shapely.geometry import box
|
|
95
|
+
|
|
96
|
+
rng = np.random.default_rng(42)
|
|
97
|
+
xs = rng.uniform(4.0, 6.0, 50)
|
|
98
|
+
ys = rng.uniform(51.0, 53.0, 50)
|
|
99
|
+
geoms = [box(x, y, x + 0.01, y + 0.01) for x, y in zip(xs, ys)]
|
|
100
|
+
gdf = gpd.GeoDataFrame(geometry=geoms, crs="EPSG:4326")
|
|
101
|
+
total = crs_total_bounds(gdf.crs)
|
|
102
|
+
public = gdf.geometry.hilbert_distance(total_bounds=list(total), level=16).to_numpy()
|
|
103
|
+
ours = hilbert_distances_from_bounds(
|
|
104
|
+
gdf.geometry.bounds.to_numpy(dtype=np.float64), total, level=16
|
|
105
|
+
)
|
|
106
|
+
assert np.array_equal(public.astype(np.uint64), ours)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_crs_total_bounds_antimeridian():
|
|
110
|
+
# NZGD2000 / NZTM: the area of use spans the antimeridian (west > east);
|
|
111
|
+
# the projected bounds must still be finite and non-degenerate.
|
|
112
|
+
xmin, ymin, xmax, ymax = crs_total_bounds("EPSG:2193")
|
|
113
|
+
assert xmax > xmin and ymax > ymin
|
|
114
|
+
assert all(np.isfinite(v) for v in (xmin, ymin, xmax, ymax))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_hilbert_sort_falls_back_without_area_of_use():
|
|
118
|
+
# A custom projected CRS without area_of_use must not fail the conversion;
|
|
119
|
+
# the sorter falls back to the dataset's own bounds.
|
|
120
|
+
from shapely.geometry import Point
|
|
121
|
+
|
|
122
|
+
from vecorel_cli.vecorel.hilbert import hilbert_sort_geodataframe
|
|
123
|
+
|
|
124
|
+
crs = "+proj=tmerc +lat_0=0 +lon_0=9 +k=1 +x_0=0 +y_0=0 +ellps=WGS84 +units=m +no_defs"
|
|
125
|
+
gdf = gpd.GeoDataFrame(geometry=[Point(0, 0), Point(1000, 1000), Point(10, 10)], crs=crs)
|
|
126
|
+
out = hilbert_sort_geodataframe(gdf)
|
|
127
|
+
assert len(out) == 3
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_not_existing_converter(tmp_folder):
|
|
131
|
+
with pytest.raises(Exception, match="Converter 'not_existing' not found"):
|
|
132
|
+
converter = ConvertData("not_existing")
|
|
133
|
+
converter.convert(tmp_folder / "converted.parquet")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.mark.parametrize("choice", ["invalid_syntax", "invalid_name"])
|
|
137
|
+
def test_invalid_converter(tmp_folder, choice):
|
|
138
|
+
with pytest.raises(Exception, match=f"Converter for '{choice}' not available or faulty:"):
|
|
139
|
+
converter = ConvertData(choice)
|
|
140
|
+
converter.convert(tmp_folder / "converted.parquet")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_template_from_package_folder():
|
|
144
|
+
Registry.src_package = "vecorel_cli"
|
|
145
|
+
Registry.ignored_datasets = []
|
|
146
|
+
converter = ConvertData("template")
|
|
147
|
+
assert isinstance(converter, ConvertData), "Should succeed and not throw an exception"
|
|
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
|
+
|
|
170
|
+
def test_data_access_exception(capsys, tmp_folder):
|
|
171
|
+
# todo: use fixture
|
|
172
|
+
logger.remove()
|
|
173
|
+
logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
|
|
174
|
+
|
|
175
|
+
with pytest.raises(Exception, match="Please provide the input data."):
|
|
176
|
+
converter = ConvertData("data_access")
|
|
177
|
+
converter.convert(tmp_folder / "converted.parquet")
|
|
178
|
+
|
|
179
|
+
out, err = capsys.readouterr()
|
|
180
|
+
|
|
181
|
+
assert isinstance(converter.converter, BaseConverter)
|
|
182
|
+
assert converter.converter.data_access in out
|
|
@@ -40,3 +40,21 @@ def test_create_geoparquet_invalid_file(tmp_folder):
|
|
|
40
40
|
gp.create("invalid.parquet", out)
|
|
41
41
|
with pytest.raises(FileNotFoundError):
|
|
42
42
|
gp.create(["invalid.json"], out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_create_geoparquet_collection_only_properties(tmp_parquet_file: Path):
|
|
46
|
+
# https://github.com/vecorel/cli/issues/26
|
|
47
|
+
inputs = ["tests/data-files/collection-only.json"]
|
|
48
|
+
creator = CreateGeoParquet()
|
|
49
|
+
creator.create(inputs, tmp_parquet_file)
|
|
50
|
+
assert tmp_parquet_file.exists()
|
|
51
|
+
|
|
52
|
+
gp = GeoParquet(tmp_parquet_file)
|
|
53
|
+
|
|
54
|
+
collection = gp.get_collection()
|
|
55
|
+
assert collection.get("source_name") == "Example Source"
|
|
56
|
+
|
|
57
|
+
data = gp.read()
|
|
58
|
+
assert len(data) == 2
|
|
59
|
+
# collection-only properties must not be added as columns
|
|
60
|
+
assert "source_name" not in data.columns
|
|
@@ -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"
|
|
@@ -55,3 +55,40 @@ def test_merge_collections():
|
|
|
55
55
|
},
|
|
56
56
|
}
|
|
57
57
|
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_merge_collections_keeps_collection_properties():
|
|
61
|
+
# https://github.com/vecorel/cli/issues/26
|
|
62
|
+
schemas = {"c1": ["https://example.com/schema1.yaml"]}
|
|
63
|
+
collection1 = Collection(
|
|
64
|
+
{
|
|
65
|
+
"schemas": schemas,
|
|
66
|
+
"source_name": "Example",
|
|
67
|
+
"source_version": "1.0",
|
|
68
|
+
"only_in_first": "x",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
collection2 = Collection(
|
|
72
|
+
{
|
|
73
|
+
"schemas": schemas,
|
|
74
|
+
"source_name": "Example",
|
|
75
|
+
"source_version": "2.0",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
merged = merge_collections([collection1])
|
|
80
|
+
assert merged.get("source_name") == "Example"
|
|
81
|
+
assert merged.get("source_version") == "1.0"
|
|
82
|
+
assert merged.get("only_in_first") == "x"
|
|
83
|
+
|
|
84
|
+
merged = merge_collections([collection1, collection2])
|
|
85
|
+
# present in all collections with the same value => kept
|
|
86
|
+
assert merged.get("source_name") == "Example"
|
|
87
|
+
# conflicting values => dropped
|
|
88
|
+
assert "source_version" not in merged
|
|
89
|
+
# missing in one collection => dropped
|
|
90
|
+
assert "only_in_first" not in merged
|
|
91
|
+
|
|
92
|
+
merged = merge_collections([collection1], properties=["only_in_first"])
|
|
93
|
+
assert "source_name" not in merged
|
|
94
|
+
assert merged.get("only_in_first") == "x"
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
from click.testing import CliRunner
|
|
1
2
|
from jsonschema.exceptions import ValidationError
|
|
2
3
|
|
|
3
4
|
from vecorel_cli.validate_schema import ValidateSchema
|
|
4
5
|
|
|
5
6
|
|
|
7
|
+
def get_cli_command():
|
|
8
|
+
cmd = ValidateSchema.get_cli_command(ValidateSchema)
|
|
9
|
+
for arg in ValidateSchema.get_cli_args().values():
|
|
10
|
+
cmd = arg(cmd)
|
|
11
|
+
return cmd
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
def test_validate_schema_valid():
|
|
7
15
|
path = "tests/data-files/sdl/inspire-schema.yaml"
|
|
8
16
|
cmd = ValidateSchema()
|
|
@@ -20,3 +28,15 @@ def test_validate_schema_invalid():
|
|
|
20
28
|
error = result[0]
|
|
21
29
|
assert isinstance(error, ValidationError)
|
|
22
30
|
assert error.message.startswith("'STRING' is not one of [")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_validate_schema_cli_valid_exit_code():
|
|
34
|
+
result = CliRunner().invoke(get_cli_command(), ["tests/data-files/sdl/inspire-schema.yaml"])
|
|
35
|
+
assert result.exit_code == 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_validate_schema_cli_invalid_exit_code():
|
|
39
|
+
result = CliRunner().invoke(
|
|
40
|
+
get_cli_command(), ["tests/data-files/sdl/inspire-schema-invalid.yaml"]
|
|
41
|
+
)
|
|
42
|
+
assert result.exit_code != 0
|
|
@@ -61,6 +61,10 @@ class BaseCommand(LoggerMixin):
|
|
|
61
61
|
self.exception(e)
|
|
62
62
|
sys.exit(1)
|
|
63
63
|
|
|
64
|
+
# Exit with a non-zero exit code if the runnable reported a failure
|
|
65
|
+
if result is False:
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
|
|
64
68
|
# Report command as finished
|
|
65
69
|
if self.cmd_final_report:
|
|
66
70
|
if isinstance(result, Path):
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
SUPPORTED_PROTOCOLS = ["http", "https", "s3", "gs"]
|
|
2
|
+
|
|
3
|
+
COMPRESSION_METHODS = ["brotli", "gzip", "lz4", "snappy", "zstd", "none"]
|
|
4
|
+
|
|
5
|
+
GEOPARQUET_VERSIONS = ["1.0.0", "1.1.0"]
|
|
6
|
+
GEOPARQUET_DEFAULT_VERSION = "1.1.0"
|
|
7
|
+
|
|
8
|
+
# Default level used when sorting features along a Hilbert curve. A level of N
|
|
9
|
+
# splits each axis of the reference bounds into 2**N cells; 16 gives a
|
|
10
|
+
# 65,536 x 65,536 grid, fine enough for world-scale references at sub-km
|
|
11
|
+
# resolution near the equator.
|
|
12
|
+
HILBERT_DEFAULT_LEVEL = 16
|
|
@@ -24,8 +24,10 @@ from geopandas import GeoDataFrame
|
|
|
24
24
|
|
|
25
25
|
from ..cli.logger import LoggerMixin
|
|
26
26
|
from ..cli.util import display_pandas_unrestricted
|
|
27
|
+
from ..encoding.geojson import READ_ENCODING
|
|
27
28
|
from ..encoding.geoparquet import GeoParquet
|
|
28
29
|
from ..vecorel.collection import Collection
|
|
30
|
+
from ..vecorel.hilbert import hilbert_sort_geodataframe
|
|
29
31
|
from ..vecorel.schemas import Schemas
|
|
30
32
|
from ..vecorel.typing import Sources
|
|
31
33
|
from ..vecorel.util import get_fs, name_from_uri, stream_file
|
|
@@ -217,6 +219,9 @@ class BaseConverter(LoggerMixin):
|
|
|
217
219
|
yield GeoDataFrame(data), path, uri, layer
|
|
218
220
|
|
|
219
221
|
def read_geojson(self, path, **kwargs):
|
|
222
|
+
# open_options is shared with gpd.read_parquet() and gpd.read_file(), so a converter
|
|
223
|
+
# cannot set a default encoding there without breaking the other two readers.
|
|
224
|
+
kwargs.setdefault("encoding", READ_ENCODING)
|
|
220
225
|
with open(path, **kwargs) as f:
|
|
221
226
|
obj = json.load(f)
|
|
222
227
|
|
|
@@ -408,7 +413,12 @@ class BaseConverter(LoggerMixin):
|
|
|
408
413
|
self.info("Removing Z geometry dimension")
|
|
409
414
|
gdf.geometry = gdf.geometry.force_2d()
|
|
410
415
|
|
|
411
|
-
|
|
416
|
+
# Sort by Hilbert distance against the CRS's total bounds. This gives
|
|
417
|
+
# row groups good spatial locality, and — crucially — produces the same
|
|
418
|
+
# ordering for any independently-converted partition of the same dataset
|
|
419
|
+
# (they share a CRS, therefore a Hilbert reference grid), so per-file
|
|
420
|
+
# outputs can be merged later without re-sorting.
|
|
421
|
+
gdf = hilbert_sort_geodataframe(gdf)
|
|
412
422
|
|
|
413
423
|
# 8. Remove all columns that are not listed
|
|
414
424
|
drop_columns = list(set(gdf.columns) - set(actual_columns.values()))
|
|
@@ -212,7 +212,7 @@ class CreateStacCollection(BaseCommand):
|
|
|
212
212
|
)
|
|
213
213
|
|
|
214
214
|
# Add provider information
|
|
215
|
-
provider = collection.get("provider"
|
|
215
|
+
provider = (collection.get("provider") or "").strip()
|
|
216
216
|
if len(provider) > 0:
|
|
217
217
|
provider_name, provider_url = parse_link_str(provider)
|
|
218
218
|
stac["providers"] = [
|
|
@@ -17,6 +17,11 @@ from ..vecorel.util import load_file, to_iso8601
|
|
|
17
17
|
from ..vecorel.version import vecorel_version
|
|
18
18
|
from .base import BaseEncoding
|
|
19
19
|
|
|
20
|
+
# GeoJSON is UTF-8 (RFC 8259, section 8.1). Reading tolerates a byte order mark, which some
|
|
21
|
+
# producers emit; writing must not add one, so the two differ.
|
|
22
|
+
READ_ENCODING = "utf-8-sig"
|
|
23
|
+
WRITE_ENCODING = "utf-8"
|
|
24
|
+
|
|
20
25
|
|
|
21
26
|
class GeoJSON(BaseEncoding):
|
|
22
27
|
feature_properties = {"type", "id", "geometry", "bbox", "properties"}
|
|
@@ -152,7 +157,7 @@ class GeoJSON(BaseEncoding):
|
|
|
152
157
|
enforce_featurecollection: bool = False,
|
|
153
158
|
) -> Union[FeatureCollection, Feature]:
|
|
154
159
|
# num only applies to FeatureCollections
|
|
155
|
-
with open(self.uri, "r") as f:
|
|
160
|
+
with open(self.uri, "r", encoding=READ_ENCODING) as f:
|
|
156
161
|
obj = json.load(f)
|
|
157
162
|
|
|
158
163
|
if not isinstance(obj, dict):
|
|
@@ -233,7 +238,7 @@ class GeoJSON(BaseEncoding):
|
|
|
233
238
|
schema_map: SchemaMapping = {},
|
|
234
239
|
properties: Optional[list[str]] = None,
|
|
235
240
|
) -> GeoDataFrame:
|
|
236
|
-
with open(self.uri, "r") as f:
|
|
241
|
+
with open(self.uri, "r", encoding=READ_ENCODING) as f:
|
|
237
242
|
stream = json_stream.load(f)
|
|
238
243
|
data = {
|
|
239
244
|
"id": [],
|
|
@@ -291,7 +296,7 @@ class GeoJSON(BaseEncoding):
|
|
|
291
296
|
return gdf
|
|
292
297
|
|
|
293
298
|
def _write_json(self, obj, path, indent=None) -> bool:
|
|
294
|
-
with open(path, "w") as f:
|
|
299
|
+
with open(path, "w", encoding=WRITE_ENCODING) as f:
|
|
295
300
|
json.dump(obj, f, allow_nan=False, indent=indent, cls=VecorelJSONEncoder)
|
|
296
301
|
return True
|
|
297
302
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Hilbert curve sorting against CRS-stable reference bounds.
|
|
2
|
+
|
|
3
|
+
Sorting by Hilbert distance gives row groups good spatial locality, which
|
|
4
|
+
improves bbox-pushdown query performance and downstream compression. We
|
|
5
|
+
deliberately sort against the *CRS's* declared extent rather than the
|
|
6
|
+
dataframe's own bbox: that way, two independently produced files (for
|
|
7
|
+
example two provincial parts that will be merged later) end up using the
|
|
8
|
+
same Hilbert reference grid, and their orderings are comparable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
from ..const import HILBERT_DEFAULT_LEVEL
|
|
17
|
+
|
|
18
|
+
# Geographic world bounds, used as the fallback when a CRS exposes no
|
|
19
|
+
# `area_of_use` (e.g. some custom or aggregated CRSs).
|
|
20
|
+
_GEOGRAPHIC_WORLD_BOUNDS = (-180.0, -90.0, 180.0, 90.0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def crs_total_bounds(crs) -> tuple[float, float, float, float]:
|
|
24
|
+
"""Return a stable, deterministic ``(xmin, ymin, xmax, ymax)`` covering the
|
|
25
|
+
CRS's valid extent expressed in the CRS's own coordinate units.
|
|
26
|
+
|
|
27
|
+
The CRS's ``area_of_use`` (which pyproj always reports as WGS84 degrees,
|
|
28
|
+
regardless of the CRS's own units or prime meridian) is projected onto the
|
|
29
|
+
CRS axes, sampled along a 25x25 grid to capture curvature. Antimeridian-
|
|
30
|
+
spanning areas of use (``west > east``, e.g. New Zealand) are sampled
|
|
31
|
+
across the wrapped longitude span.
|
|
32
|
+
|
|
33
|
+
The result is independent of any particular dataset: it depends only on
|
|
34
|
+
the CRS. Two independently-converted parts of the same dataset therefore
|
|
35
|
+
share the same Hilbert reference grid and their orderings can be merged
|
|
36
|
+
without recomputation.
|
|
37
|
+
|
|
38
|
+
Raises ``ValueError`` for a projected CRS without a declared area of use;
|
|
39
|
+
callers that can tolerate a dataset-dependent grid should catch it and
|
|
40
|
+
substitute their own reference extent.
|
|
41
|
+
"""
|
|
42
|
+
from pyproj import CRS, Transformer
|
|
43
|
+
|
|
44
|
+
if crs is None:
|
|
45
|
+
return _GEOGRAPHIC_WORLD_BOUNDS
|
|
46
|
+
|
|
47
|
+
c = CRS.from_user_input(crs)
|
|
48
|
+
aou = c.area_of_use
|
|
49
|
+
|
|
50
|
+
if aou is None:
|
|
51
|
+
if c.is_geographic:
|
|
52
|
+
return _GEOGRAPHIC_WORLD_BOUNDS
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Cannot derive total bounds for projected CRS {crs!r}: no area_of_use defined"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
west, south, east, north = (
|
|
58
|
+
float(aou.west),
|
|
59
|
+
float(aou.south),
|
|
60
|
+
float(aou.east),
|
|
61
|
+
float(aou.north),
|
|
62
|
+
)
|
|
63
|
+
# Antimeridian-spanning area of use: sample across the wrapped span.
|
|
64
|
+
# pyproj accepts longitudes beyond 180 and wraps them itself.
|
|
65
|
+
if west > east:
|
|
66
|
+
east += 360.0
|
|
67
|
+
|
|
68
|
+
# Sample the (WGS84) area of use into the target CRS. This also applies to
|
|
69
|
+
# geographic target CRSs: their own axes may use different units (grads)
|
|
70
|
+
# or a non-Greenwich prime meridian, so the AoU degrees cannot be returned
|
|
71
|
+
# verbatim.
|
|
72
|
+
transformer = Transformer.from_crs("EPSG:4326", c, always_xy=True)
|
|
73
|
+
n = 25
|
|
74
|
+
xs = np.linspace(west, east, n)
|
|
75
|
+
ys = np.linspace(south, north, n)
|
|
76
|
+
grid_x, grid_y = np.meshgrid(xs, ys)
|
|
77
|
+
px, py = transformer.transform(grid_x.ravel(), grid_y.ravel())
|
|
78
|
+
px = np.asarray(px, dtype=np.float64)
|
|
79
|
+
py = np.asarray(py, dtype=np.float64)
|
|
80
|
+
mask = np.isfinite(px) & np.isfinite(py)
|
|
81
|
+
if not mask.any():
|
|
82
|
+
raise ValueError(f"Could not project area_of_use into CRS {crs!r}")
|
|
83
|
+
return (
|
|
84
|
+
float(px[mask].min()),
|
|
85
|
+
float(py[mask].min()),
|
|
86
|
+
float(px[mask].max()),
|
|
87
|
+
float(py[mask].max()),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def hilbert_distances_from_bounds(
|
|
92
|
+
bounds: np.ndarray,
|
|
93
|
+
total_bounds: tuple[float, float, float, float],
|
|
94
|
+
level: int = HILBERT_DEFAULT_LEVEL,
|
|
95
|
+
) -> np.ndarray:
|
|
96
|
+
"""Compute per-row Hilbert distances from feature bbox rows + CRS bounds.
|
|
97
|
+
|
|
98
|
+
``bounds`` must be an ``(N, 4)`` float64 array of ``[xmin, ymin, xmax, ymax]``
|
|
99
|
+
per feature. Returns a ``(N,)`` uint64 array of Hilbert keys; sorting by
|
|
100
|
+
this key gives the Hilbert-curve ordering at the given resolution.
|
|
101
|
+
|
|
102
|
+
This is the bbox-column twin of the public
|
|
103
|
+
``GeoSeries.hilbert_distance(total_bounds=..., level=...)`` (for callers
|
|
104
|
+
that hold covering bboxes without decoded geometry, e.g. streaming
|
|
105
|
+
parquet merges); a test asserts the two stay identical, guarding the
|
|
106
|
+
private-API import below against geopandas changes.
|
|
107
|
+
"""
|
|
108
|
+
from geopandas.tools.hilbert_curve import _continuous_to_discrete_coords, _encode
|
|
109
|
+
|
|
110
|
+
x, y = _continuous_to_discrete_coords(bounds, level, list(total_bounds))
|
|
111
|
+
return _encode(level, x, y).astype(np.uint64, copy=False)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def hilbert_sort_geodataframe(gdf, level: int = HILBERT_DEFAULT_LEVEL):
|
|
115
|
+
"""Return a copy of ``gdf`` sorted by Hilbert distance against the CRS's
|
|
116
|
+
total bounds. The original index is reset.
|
|
117
|
+
|
|
118
|
+
If the CRS provides no stable extent (custom projected CRS without an
|
|
119
|
+
area of use), the dataframe's own bounds are used instead: the order is
|
|
120
|
+
still spatially local, but not comparable across independently-produced
|
|
121
|
+
files.
|
|
122
|
+
"""
|
|
123
|
+
if gdf.empty:
|
|
124
|
+
return gdf.reset_index(drop=True)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
total = crs_total_bounds(gdf.crs)
|
|
128
|
+
except ValueError as e:
|
|
129
|
+
logger.warning(f"{e}; sorting against the dataset's own bounds instead")
|
|
130
|
+
total = tuple(float(v) for v in gdf.total_bounds)
|
|
131
|
+
|
|
132
|
+
# public geopandas API; level 16 = a 65,536 x 65,536 reference grid
|
|
133
|
+
keys = gdf.geometry.hilbert_distance(total_bounds=list(total), level=level).to_numpy()
|
|
134
|
+
order = np.argsort(keys, kind="stable")
|
|
135
|
+
return gdf.iloc[order].reset_index(drop=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
__all__ = [
|
|
139
|
+
"crs_total_bounds",
|
|
140
|
+
"hilbert_distances_from_bounds",
|
|
141
|
+
"hilbert_sort_geodataframe",
|
|
142
|
+
]
|
|
@@ -45,22 +45,31 @@ def merge(
|
|
|
45
45
|
def merge_collections(collections: list[Collection], properties=None) -> Collection:
|
|
46
46
|
schemas = Schemas()
|
|
47
47
|
custom_schemas = VecorelSchema()
|
|
48
|
+
handled_separately = ("schemas", "schemas:custom")
|
|
48
49
|
|
|
50
|
+
# Additional collection metadata (e.g. collection-only properties) is kept
|
|
51
|
+
# if it's present in all collections with the same value, i.e. it still
|
|
52
|
+
# applies to the merged dataset as a whole.
|
|
53
|
+
other_props = None
|
|
49
54
|
for collection in collections:
|
|
50
55
|
schemas.add_all(collection.get_schemas())
|
|
51
56
|
|
|
52
57
|
custom = collection.get_custom_schemas()
|
|
53
58
|
custom_schemas.merge(custom)
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
props = {k: v for k, v in collection.items() if k not in handled_separately}
|
|
61
|
+
if other_props is None:
|
|
62
|
+
other_props = props
|
|
63
|
+
else:
|
|
64
|
+
other_props = {k: v for k, v in other_props.items() if k in props and props[k] == v}
|
|
60
65
|
|
|
66
|
+
if other_props is None:
|
|
67
|
+
other_props = {}
|
|
61
68
|
if properties is not None:
|
|
69
|
+
other_props = {k: v for k, v in other_props.items() if k in properties}
|
|
62
70
|
custom_schemas = custom_schemas.pick(properties)
|
|
63
71
|
|
|
72
|
+
collection = Collection({"schemas": schemas, **other_props})
|
|
64
73
|
collection.set_custom_schemas(custom_schemas)
|
|
65
74
|
|
|
66
75
|
return collection
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vecorel-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.17
|
|
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
|
|
@@ -24,7 +24,7 @@ Requires-Dist: shapely<3.0,>=2.1
|
|
|
24
24
|
Requires-Dist: numpy<3.0,>=2.0
|
|
25
25
|
Requires-Dist: pyarrow<24.0,>=21.0
|
|
26
26
|
Requires-Dist: py7zr<2.0,>=1.0
|
|
27
|
-
Requires-Dist: fsspec
|
|
27
|
+
Requires-Dist: fsspec>=2025.7.0
|
|
28
28
|
Requires-Dist: jsonschema[format]<5.0,>=4.20
|
|
29
29
|
Requires-Dist: aiohttp<4.0,>=3.9
|
|
30
30
|
Requires-Dist: yarl<2.0,>=1.20
|
|
@@ -32,6 +32,13 @@ 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
|
+
Provides-Extra: s3
|
|
36
|
+
Requires-Dist: s3fs>=2025.7.0; extra == "s3"
|
|
37
|
+
Provides-Extra: gcs
|
|
38
|
+
Requires-Dist: gcsfs>=2025.7.0; extra == "gcs"
|
|
39
|
+
Provides-Extra: cloud
|
|
40
|
+
Requires-Dist: s3fs>=2025.7.0; extra == "cloud"
|
|
41
|
+
Requires-Dist: gcsfs>=2025.7.0; extra == "cloud"
|
|
35
42
|
Dynamic: license-file
|
|
36
43
|
|
|
37
44
|
# Vecorel CLI
|
|
@@ -65,12 +72,14 @@ pixi install
|
|
|
65
72
|
pixi run vec
|
|
66
73
|
```
|
|
67
74
|
|
|
68
|
-
#### Using pip
|
|
75
|
+
#### Using pip or uv
|
|
69
76
|
|
|
70
77
|
Alternatively, you can install from PyPI with **Python 3.11** or any later version:
|
|
71
78
|
|
|
72
79
|
```bash
|
|
73
80
|
pip install vecorel-cli
|
|
81
|
+
# or
|
|
82
|
+
uv pip install vecorel-cli
|
|
74
83
|
```
|
|
75
84
|
|
|
76
85
|
### Execute a command
|
|
@@ -115,9 +124,9 @@ Check `vec validate --help` for more details.
|
|
|
115
124
|
The validator also supports remote files.
|
|
116
125
|
|
|
117
126
|
- `http://` or `https://`: no further configuration is needed.
|
|
118
|
-
- `s3://`: With Pixi, run `pixi install -e s3` or with pip, run `pip install vecorel-cli[s3]` and you may need to set environment variables.
|
|
127
|
+
- `s3://`: With Pixi, run `pixi install -e s3` or with pip/uv, run `pip install vecorel-cli[s3]` and you may need to set environment variables.
|
|
119
128
|
Refer to the [s3fs credentials documentation](https://s3fs.readthedocs.io/en/latest/#credentials) for how to define credentials.
|
|
120
|
-
- `gs://`: With Pixi, run `pixi install -e gcs` or with pip, run `pip install vecorel-cli[gcs]`.
|
|
129
|
+
- `gs://`: With Pixi, run `pixi install -e gcs` or with pip/uv, run `pip install vecorel-cli[gcs]`.
|
|
121
130
|
By default, `gcsfs` will attempt to use your default gcloud credentials or, attempt to get credentials from the google metadata service, or fall back to anonymous access.
|
|
122
131
|
|
|
123
132
|
### Create Vecorel GeoParquet from GeoJSON
|
|
@@ -237,14 +246,14 @@ pixi install -e dev
|
|
|
237
246
|
pixi run install-dev
|
|
238
247
|
|
|
239
248
|
# Run tests
|
|
240
|
-
pixi run test
|
|
249
|
+
pixi run -e dev test
|
|
241
250
|
|
|
242
251
|
# Format and lint code
|
|
243
252
|
pixi run format
|
|
244
253
|
pixi run lint
|
|
245
254
|
|
|
246
255
|
# Run all checks (lint, format, test)
|
|
247
|
-
pixi run check
|
|
256
|
+
pixi run -e dev check
|
|
248
257
|
|
|
249
258
|
# Install and run pre-commit
|
|
250
259
|
pixi run pre-commit-install
|
|
@@ -257,6 +266,6 @@ The following high-level description gives an idea how to implement a converter
|
|
|
257
266
|
|
|
258
267
|
1. Create a new file in `vecorel_cli/datasets` based on the `template.py`
|
|
259
268
|
2. Fill in the required variables / test it / run it
|
|
260
|
-
3. Add missing dependencies into
|
|
269
|
+
3. Add missing dependencies into `pyproject.toml` (and the appropriate pixi feature group if needed)
|
|
261
270
|
4. Add the converter to the list above
|
|
262
271
|
5. Create a PR to submit your converter for review
|
|
@@ -69,6 +69,7 @@ vecorel_cli/validation/geoparquet.py
|
|
|
69
69
|
vecorel_cli/vecorel/__init__.py
|
|
70
70
|
vecorel_cli/vecorel/collection.py
|
|
71
71
|
vecorel_cli/vecorel/extensions.py
|
|
72
|
+
vecorel_cli/vecorel/hilbert.py
|
|
72
73
|
vecorel_cli/vecorel/ops.py
|
|
73
74
|
vecorel_cli/vecorel/schemas.py
|
|
74
75
|
vecorel_cli/vecorel/typing.py
|
|
@@ -6,7 +6,7 @@ shapely<3.0,>=2.1
|
|
|
6
6
|
numpy<3.0,>=2.0
|
|
7
7
|
pyarrow<24.0,>=21.0
|
|
8
8
|
py7zr<2.0,>=1.0
|
|
9
|
-
fsspec
|
|
9
|
+
fsspec>=2025.7.0
|
|
10
10
|
jsonschema[format]<5.0,>=4.20
|
|
11
11
|
aiohttp<4.0,>=3.9
|
|
12
12
|
yarl<2.0,>=1.20
|
|
@@ -14,3 +14,13 @@ rarfile<5.0,>=4.0
|
|
|
14
14
|
semantic-version<3.0,>=2.10.0
|
|
15
15
|
json-stream<3.0,>=2.3.0
|
|
16
16
|
loguru==0.7.3
|
|
17
|
+
|
|
18
|
+
[cloud]
|
|
19
|
+
s3fs>=2025.7.0
|
|
20
|
+
gcsfs>=2025.7.0
|
|
21
|
+
|
|
22
|
+
[gcs]
|
|
23
|
+
gcsfs>=2025.7.0
|
|
24
|
+
|
|
25
|
+
[s3]
|
|
26
|
+
s3fs>=2025.7.0
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import sys
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
from loguru import logger
|
|
6
|
-
|
|
7
|
-
from vecorel_cli.conversion.base import BaseConverter
|
|
8
|
-
from vecorel_cli.convert import ConvertData
|
|
9
|
-
from vecorel_cli.registry import Registry
|
|
10
|
-
from vecorel_cli.validate import ValidateData
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@pytest.fixture(autouse=True)
|
|
14
|
-
def registry_reset():
|
|
15
|
-
ignored = Registry.ignored_datasets
|
|
16
|
-
src_package = Registry.src_package
|
|
17
|
-
Registry.src_package = "tests"
|
|
18
|
-
yield
|
|
19
|
-
Registry.ignore_datasets = ignored
|
|
20
|
-
Registry.src_package = src_package
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
test_path = Path("tests/data-files/convert")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@pytest.mark.parametrize("choice", ["example"])
|
|
27
|
-
def test_converter(tmp_folder, choice):
|
|
28
|
-
dest = tmp_folder / "converted.parquet"
|
|
29
|
-
converter = ConvertData(choice)
|
|
30
|
-
converter.convert(dest, cache=(test_path / choice))
|
|
31
|
-
|
|
32
|
-
assert dest.exists(), f"Expected file {dest} to be created."
|
|
33
|
-
|
|
34
|
-
# todo: Validation works, but fails for the created file
|
|
35
|
-
validator = ValidateData()
|
|
36
|
-
validation_result = validator.validate(
|
|
37
|
-
dest,
|
|
38
|
-
num=100,
|
|
39
|
-
schema_map={},
|
|
40
|
-
)
|
|
41
|
-
assert validation_result.errors == []
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_not_existing_converter(tmp_folder):
|
|
45
|
-
with pytest.raises(Exception, match="Converter 'not_existing' not found"):
|
|
46
|
-
converter = ConvertData("not_existing")
|
|
47
|
-
converter.convert(tmp_folder / "converted.parquet")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@pytest.mark.parametrize("choice", ["invalid_syntax", "invalid_name"])
|
|
51
|
-
def test_invalid_converter(tmp_folder, choice):
|
|
52
|
-
with pytest.raises(Exception, match=f"Converter for '{choice}' not available or faulty:"):
|
|
53
|
-
converter = ConvertData(choice)
|
|
54
|
-
converter.convert(tmp_folder / "converted.parquet")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_template_from_package_folder():
|
|
58
|
-
Registry.src_package = "vecorel_cli"
|
|
59
|
-
Registry.ignored_datasets = []
|
|
60
|
-
converter = ConvertData("template")
|
|
61
|
-
assert isinstance(converter, ConvertData), "Should succeed and not throw an exception"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_data_access_exception(capsys, tmp_folder):
|
|
65
|
-
# todo: use fixture
|
|
66
|
-
logger.remove()
|
|
67
|
-
logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
|
|
68
|
-
|
|
69
|
-
with pytest.raises(Exception, match="Please provide the input data."):
|
|
70
|
-
converter = ConvertData("data_access")
|
|
71
|
-
converter.convert(tmp_folder / "converted.parquet")
|
|
72
|
-
|
|
73
|
-
out, err = capsys.readouterr()
|
|
74
|
-
|
|
75
|
-
assert isinstance(converter.converter, BaseConverter)
|
|
76
|
-
assert converter.converter.data_access in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|