vecorel-cli 0.3.1__tar.gz → 0.4.1__tar.gz

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