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.
Files changed (81) hide show
  1. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/PKG-INFO +17 -8
  2. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/README.md +8 -6
  3. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/pyproject.toml +16 -7
  4. vecorel_cli-0.2.17/tests/test_convert.py +182 -0
  5. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_create_geoparquet.py +18 -0
  6. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_encoding_geojson.py +23 -0
  7. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_ops.py +37 -0
  8. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_validate_schema.py +20 -0
  9. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/basecommand.py +4 -0
  10. vecorel_cli-0.2.17/vecorel_cli/const.py +12 -0
  11. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/conversion/base.py +11 -1
  12. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/create_stac.py +1 -1
  13. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/geojson.py +8 -3
  14. vecorel_cli-0.2.17/vecorel_cli/vecorel/hilbert.py +142 -0
  15. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/ops.py +14 -5
  16. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/PKG-INFO +17 -8
  17. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/SOURCES.txt +1 -0
  18. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/requires.txt +11 -1
  19. vecorel_cli-0.2.15/tests/test_convert.py +0 -76
  20. vecorel_cli-0.2.15/vecorel_cli/const.py +0 -6
  21. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/LICENSE +0 -0
  22. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/setup.cfg +0 -0
  23. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_converters.py +0 -0
  24. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_create_geojson.py +0 -0
  25. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_create_stac.py +0 -0
  26. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_describe.py +0 -0
  27. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_encoding_auto.py +0 -0
  28. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_encoding_geoparquet.py +0 -0
  29. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_improve.py +0 -0
  30. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_jsonschema.py +0 -0
  31. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_merge.py +0 -0
  32. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_rename_extension.py +0 -0
  33. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/tests/test_validate.py +0 -0
  34. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/__init__.py +0 -0
  35. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/__init__.py +0 -0
  36. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/logger.py +0 -0
  37. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/options.py +0 -0
  38. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/path_url.py +0 -0
  39. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/setup.py +0 -0
  40. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/cli/util.py +0 -0
  41. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/conversion/__init__.py +0 -0
  42. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/conversion/admin.py +0 -0
  43. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/conversion/flatdict.py +0 -0
  44. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/convert.py +0 -0
  45. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/converters.py +0 -0
  46. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/create_geojson.py +0 -0
  47. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/create_geoparquet.py +0 -0
  48. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/create_jsonschema.py +0 -0
  49. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/datasets/__init__.py +0 -0
  50. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/datasets/template.py +0 -0
  51. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/describe.py +0 -0
  52. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/__init__.py +0 -0
  53. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/auto.py +0 -0
  54. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/base.py +0 -0
  55. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/encoding/geoparquet.py +0 -0
  56. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/improve.py +0 -0
  57. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/jsonschema/__init__.py +0 -0
  58. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/jsonschema/template.py +0 -0
  59. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/merge.py +0 -0
  60. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/parquet/__init__.py +0 -0
  61. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/parquet/geopandas.py +0 -0
  62. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/parquet/types.py +0 -0
  63. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/registry.py +0 -0
  64. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/rename_extension.py +0 -0
  65. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validate.py +0 -0
  66. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validate_schema.py +0 -0
  67. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/__init__.py +0 -0
  68. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/base.py +0 -0
  69. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/data.py +0 -0
  70. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/geojson.py +0 -0
  71. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/validation/geoparquet.py +0 -0
  72. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/__init__.py +0 -0
  73. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/collection.py +0 -0
  74. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/extensions.py +0 -0
  75. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/schemas.py +0 -0
  76. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/typing.py +0 -0
  77. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/util.py +0 -0
  78. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli/vecorel/version.py +0 -0
  79. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/dependency_links.txt +0 -0
  80. {vecorel_cli-0.2.15 → vecorel_cli-0.2.17}/vecorel_cli.egg-info/entry_points.txt +0 -0
  81. {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.15
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==2025.7.0
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 the appropriate feature group in `pixi.toml` (or `setup.py` for pip users)
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 the appropriate feature group in `pixi.toml` (or `setup.py` for pip users)
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.15"
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==2025.7.0",
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.cloud.dependencies]
85
- s3fs = "==2025.7.0"
86
- gcsfs = "==2025.7.0"
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
- cloud = {features = ["cloud"], solve-group = "default"}
92
- all = {features = ["dev", "cloud"], solve-group = "default"}
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
- gdf.sort_values("geometry", inplace=True, ignore_index=True)
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", "").strip()
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
- collection = Collection(
56
- {
57
- "schemas": schemas,
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.15
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==2025.7.0
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 the appropriate feature group in `pixi.toml` (or `setup.py` for pip users)
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==2025.7.0
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
@@ -1,6 +0,0 @@
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"
File without changes
File without changes