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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {vecorel_cli-0.3.1/vecorel_cli.egg-info → vecorel_cli-0.4.0}/PKG-INFO +49 -2
  2. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/README.md +48 -1
  3. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/pyproject.toml +1 -1
  4. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_convert_duckdb.py +152 -1
  5. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_create_stac.py +22 -0
  6. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_encoding_geoparquet.py +129 -0
  7. vecorel_cli-0.4.0/tests/test_merge.py +799 -0
  8. vecorel_cli-0.4.0/tests/test_ops.py +245 -0
  9. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_validate.py +80 -0
  10. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/duckdb.py +268 -84
  11. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/create_geoparquet.py +3 -1
  12. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/create_stac.py +8 -3
  13. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/base.py +20 -3
  14. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/geojson.py +6 -3
  15. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/geoparquet.py +51 -1
  16. vecorel_cli-0.4.0/vecorel_cli/merge.py +187 -0
  17. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/parquet/types.py +48 -1
  18. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/base.py +1 -1
  19. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/geoparquet.py +41 -2
  20. vecorel_cli-0.4.0/vecorel_cli/vecorel/ops.py +300 -0
  21. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/schemas.py +21 -2
  22. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/util.py +19 -0
  23. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0/vecorel_cli.egg-info}/PKG-INFO +49 -2
  24. vecorel_cli-0.3.1/tests/test_merge.py +0 -56
  25. vecorel_cli-0.3.1/tests/test_ops.py +0 -94
  26. vecorel_cli-0.3.1/vecorel_cli/merge.py +0 -86
  27. vecorel_cli-0.3.1/vecorel_cli/vecorel/ops.py +0 -75
  28. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/LICENSE +0 -0
  29. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/setup.cfg +0 -0
  30. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_convert.py +0 -0
  31. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_converters.py +0 -0
  32. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_create_geojson.py +0 -0
  33. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_create_geoparquet.py +0 -0
  34. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_deflate64.py +0 -0
  35. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_describe.py +0 -0
  36. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_download.py +0 -0
  37. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_encoding_auto.py +0 -0
  38. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_encoding_geojson.py +0 -0
  39. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_hilbert.py +0 -0
  40. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_improve.py +0 -0
  41. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_jsonschema.py +0 -0
  42. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_rename_extension.py +0 -0
  43. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/tests/test_validate_schema.py +0 -0
  44. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/__init__.py +0 -0
  45. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/basecommand.py +0 -0
  46. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/__init__.py +0 -0
  47. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/logger.py +0 -0
  48. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/options.py +0 -0
  49. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/path_url.py +0 -0
  50. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/setup.py +0 -0
  51. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/cli/util.py +0 -0
  52. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/const.py +0 -0
  53. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/__init__.py +0 -0
  54. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/admin.py +0 -0
  55. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/base.py +0 -0
  56. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/deflate64.py +0 -0
  57. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/conversion/flatdict.py +0 -0
  58. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/convert.py +0 -0
  59. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/converters.py +0 -0
  60. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/create_geojson.py +0 -0
  61. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/create_jsonschema.py +0 -0
  62. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/datasets/__init__.py +0 -0
  63. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/datasets/template.py +0 -0
  64. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/describe.py +0 -0
  65. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/__init__.py +0 -0
  66. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/encoding/auto.py +0 -0
  67. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/improve.py +0 -0
  68. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/jsonschema/__init__.py +0 -0
  69. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/jsonschema/template.py +0 -0
  70. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/parquet/__init__.py +0 -0
  71. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/parquet/geopandas.py +0 -0
  72. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/registry.py +0 -0
  73. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/rename_extension.py +0 -0
  74. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validate.py +0 -0
  75. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validate_schema.py +0 -0
  76. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/__init__.py +0 -0
  77. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/data.py +0 -0
  78. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/validation/geojson.py +0 -0
  79. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/__init__.py +0 -0
  80. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/collection.py +0 -0
  81. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/extensions.py +0 -0
  82. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/hilbert.py +0 -0
  83. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/typing.py +0 -0
  84. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli/vecorel/version.py +0 -0
  85. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli.egg-info/SOURCES.txt +0 -0
  86. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli.egg-info/dependency_links.txt +0 -0
  87. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli.egg-info/entry_points.txt +0 -0
  88. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli.egg-info/requires.txt +0 -0
  89. {vecorel_cli-0.3.1 → vecorel_cli-0.4.0}/vecorel_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vecorel-cli
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: CLI tools such as validation and file format conversion for vecorel.
5
5
  Author: Matthias Mohr
6
6
  License-Expression: Apache-2.0
@@ -167,7 +167,54 @@ Check `vec describe --help` for more details.
167
167
 
168
168
  Merges multiple Vecorel datasets to a combined Vecorel dataset:
169
169
 
170
- - `vec merge ec_ee.parquet ec_lv.parquet -o merged.parquet -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.0"
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,4 +1,5 @@
1
1
  import json
2
+ import re
2
3
  import sys
3
4
 
4
5
  import geopandas as gpd
@@ -8,8 +9,9 @@ import pytest
8
9
  import shapely
9
10
  from loguru import logger
10
11
 
12
+ from vecorel_cli.conversion import duckdb as duckdb_module
11
13
  from vecorel_cli.conversion.base import BaseConverter
12
- from vecorel_cli.conversion.duckdb import DuckDBBaseConverter
14
+ from vecorel_cli.conversion.duckdb import DuckDBBaseConverter, _constants_table
13
15
  from vecorel_cli.validate import ValidateData
14
16
  from vecorel_cli.vecorel.hilbert import hilbert_keys_for_table
15
17
 
@@ -621,6 +623,127 @@ def test_merge_parquet_unions_parts_that_differ(tmp_folder):
621
623
  assert sorted(x for x in table.column("id").to_pylist()) == ["0", "1"]
622
624
 
623
625
 
626
+ def test_merge_parquet_hydrates_constants_the_parts_disagree_on(tmp_folder):
627
+ """Each part keeps its constants in its collection; one the parts disagree on
628
+ goes back into the rows, as `vec merge` does, and a shared one stays put."""
629
+ parts = []
630
+ for index, region in enumerate(("north", "south")):
631
+ gdf = gpd.GeoDataFrame(
632
+ {"id": [f"{index}"], "name": ["a"], "geometry": [shapely.box(index, 0, index + 1, 1)]},
633
+ crs="EPSG:4326",
634
+ )
635
+ src = tmp_folder / f"h_src_{index}.parquet"
636
+ gdf.to_parquet(src)
637
+ Part = type(
638
+ "Part",
639
+ (DuckDBBaseConverter,),
640
+ {
641
+ **CONFIG,
642
+ "column_additions": {"region": region, "country": "NL"},
643
+ "missing_schemas": {
644
+ "properties": {
645
+ "name": {"type": "string"},
646
+ "region": {"type": "string"},
647
+ "country": {"type": "string"},
648
+ }
649
+ },
650
+ },
651
+ )
652
+ part = tmp_folder / f"h_part_{index}.parquet"
653
+ Part().convert(part, input_files={str(src): src.name})
654
+ assert "region" in json.loads(pq.read_schema(part).metadata[b"collection"])
655
+ parts.append(part)
656
+
657
+ dest = tmp_folder / "hydrated.parquet"
658
+ Converter().merge_parquet(parts, dest)
659
+
660
+ table = pq.read_table(dest)
661
+ rows = dict(zip(table.column("id").to_pylist(), table.column("region").to_pylist()))
662
+ assert rows == {"0": "north", "1": "south"}
663
+ collection = json.loads(table.schema.metadata[b"collection"])
664
+ assert "region" not in collection
665
+ assert collection["country"] == "NL" and "country" not in table.schema.names
666
+ assert ValidateData().validate(dest, num=100, schema_map={}).errors == []
667
+
668
+
669
+ def _collection_part(folder, cid, ids, area=None):
670
+ gdf = gpd.GeoDataFrame(
671
+ {
672
+ "id": ids,
673
+ "name": ["a"] * len(ids),
674
+ "geometry": [shapely.box(i, 0, i + 1, 1) for i in range(len(ids))],
675
+ },
676
+ crs="EPSG:4326",
677
+ )
678
+ src = folder / f"c_src_{cid}_{len(ids)}.parquet"
679
+ gdf.to_parquet(src)
680
+ config = {**CONFIG, "id": cid}
681
+ if area is not None:
682
+ config["column_additions"] = {"area": area}
683
+ config["missing_schemas"] = {
684
+ "properties": {"name": {"type": "string"}, "area": {"type": "double"}}
685
+ }
686
+ part = folder / f"c_part_{cid}_{len(ids)}.parquet"
687
+ type("Part", (DuckDBBaseConverter,), config)().convert(part, input_files={str(src): src.name})
688
+ return part
689
+
690
+
691
+ def test_merge_parquet_numbers_ids_per_collection(tmp_folder):
692
+ """Ids only have to be unique per collection, so only repeats within one get numbered."""
693
+ parts = [
694
+ _collection_part(tmp_folder, "a", ["1", "2"]),
695
+ _collection_part(tmp_folder, "b", ["1", "2"]),
696
+ _collection_part(tmp_folder, "b", ["1"]),
697
+ ]
698
+ dest = tmp_folder / "per_collection.parquet"
699
+ Converter().merge_parquet(parts, dest)
700
+
701
+ table = pq.read_table(dest, columns=["collection", "id"])
702
+ assert sorted(zip(table.column("collection").to_pylist(), table.column("id").to_pylist())) == [
703
+ ("a", "1"),
704
+ ("a", "2"),
705
+ ("b", "1~1"),
706
+ ("b", "1~2"),
707
+ ("b", "2"),
708
+ ]
709
+ assert ValidateData().validate(dest, num=100, schema_map={}).errors == []
710
+
711
+
712
+ def test_merge_parquet_hydrates_nan_as_null(tmp_folder):
713
+ """A NaN in the collection comes from a column without values, so it's a null."""
714
+ parts = [
715
+ _collection_part(tmp_folder, "a", ["1"], area=float("nan")),
716
+ _collection_part(tmp_folder, "b", ["2"], area=1.5),
717
+ ]
718
+ dest = tmp_folder / "nan.parquet"
719
+ Converter().merge_parquet(parts, dest)
720
+
721
+ table = pq.read_table(dest, columns=["id", "area"])
722
+ assert dict(zip(table.column("id").to_pylist(), table.column("area").to_pylist())) == {
723
+ "1": None,
724
+ "2": 1.5,
725
+ }
726
+
727
+
728
+ @pytest.mark.parametrize(
729
+ "value, dtype", [(300, "uint8"), ("not a date", "date-time")], ids=["overflow", "date-time"]
730
+ )
731
+ def test_constants_that_do_not_fit_their_type_are_reported(value, dtype, capsys):
732
+ log = Converter()
733
+ logger.remove()
734
+ logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
735
+ table = _constants_table({"x": value}, {"x": {"type": dtype}}, log=log, source="part.parquet")
736
+
737
+ # the value would fail the writer, so it's left empty
738
+ assert table.column("x").to_pylist() == [None]
739
+ message = f"x: Value {value!r} doesn't fit data type {dtype}"
740
+ out = capsys.readouterr().out
741
+ assert message in out and "(in part.parquet)" in out
742
+
743
+ with pytest.raises(ValueError, match=re.escape(message)):
744
+ _constants_table({"x": value}, {"x": {"type": dtype}}, log=log, strict=True)
745
+
746
+
624
747
  def test_merge_parquet_checks_ids_that_convert_generated(tmp_folder, capsys):
625
748
  """Each part numbered its own rows from zero, so the merge has to check what
626
749
  convert() is allowed to take for granted."""
@@ -764,3 +887,31 @@ def test_merge_parquet_keeps_a_crs_duckdb_would_drop(tmp_folder):
764
887
  crs = geo["columns"][geo["primary_column"]]["crs"]
765
888
  assert crs is not None, "the merged file lost the CRS of its parts"
766
889
  assert "3857" in json.dumps(crs)
890
+
891
+
892
+ @pytest.mark.parametrize("spatial_order", [True, False])
893
+ def test_duckdb_converter_gives_a_small_file_enough_row_groups(
894
+ tmp_folder, monkeypatch, spatial_order
895
+ ):
896
+ from vecorel_cli.encoding.geoparquet import GeoParquet
897
+
898
+ # DuckDB builds row groups from 2048-row vectors, so the file has to be large enough
899
+ n = 25_000
900
+ monkeypatch.setattr(GeoParquet, "row_group_size", 20_000)
901
+ if not spatial_order:
902
+ # as for a CRS without an area of use
903
+ monkeypatch.setattr(duckdb_module, "hilbert_reference_bounds", lambda *args: None)
904
+ gdf = gpd.GeoDataFrame(
905
+ {
906
+ "id": [str(i) for i in range(n)],
907
+ "name": ["a"] * n,
908
+ "geometry": [shapely.box(i * 1e-5, 0, i * 1e-5 + 1e-5, 1e-5) for i in range(n)],
909
+ },
910
+ crs="EPSG:4326",
911
+ )
912
+ src = tmp_folder / "many.parquet"
913
+ gdf.to_parquet(src)
914
+ dest = tmp_folder / "many-out.parquet"
915
+ Converter().convert(dest, input_files={str(src): src.name})
916
+
917
+ assert pq.ParquetFile(dest).metadata.num_row_groups >= GeoParquet.min_row_groups
@@ -1,9 +1,12 @@
1
1
  from pathlib import Path
2
2
 
3
+ import geopandas as gpd
3
4
  import pytest
5
+ from shapely.geometry import Polygon
4
6
 
5
7
  from vecorel_cli.create_stac import CreateStacCollection
6
8
  from vecorel_cli.registry import Registry
9
+ from vecorel_cli.vecorel.collection import Collection
7
10
  from vecorel_cli.vecorel.util import load_file
8
11
 
9
12
  files = [
@@ -55,3 +58,22 @@ def test_create_stac_collection(tmp_folder: Path, file: str, expected_file: str)
55
58
  del expected["assets"]["data"]["processing:software"]["vecorel-cli"]
56
59
 
57
60
  assert created_file == expected
61
+
62
+
63
+ def test_bbox_is_the_extent_of_the_geometries():
64
+ """In a projected CRS, the corners of the source bbox reach beyond the data."""
65
+ # a parcel in Saxony, in ETRS89 / UTM zone 33N
66
+ field = Polygon([(300000, 5600000), (500000, 5650000), (450000, 5700000), (320000, 5680000)])
67
+ gdf = gpd.GeoDataFrame({"id": ["1"]}, geometry=[field], crs="EPSG:25833")
68
+ collection = Collection(
69
+ {
70
+ "schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
71
+ "title": "t",
72
+ "description": "d",
73
+ }
74
+ )
75
+
76
+ stac = CreateStacCollection().create(collection, gdf, "x.parquet")
77
+
78
+ expected = gdf.to_crs(epsg=4326).total_bounds
79
+ assert stac["extent"]["spatial"]["bbox"][0] == pytest.approx(list(expected))
@@ -148,3 +148,132 @@ def test_read_keeps_integers_that_have_a_null(tmp_parquet_file):
148
148
  from vecorel_cli.validate import ValidateData
149
149
 
150
150
  assert ValidateData().validate(tmp_parquet_file).errors == []
151
+
152
+
153
+ def test_write_keeps_all_null_columns_in_the_data(tmp_parquet_file):
154
+ # NaN and NA aren't valid JSON, so they can't move to the collection
155
+ from geopandas import GeoDataFrame
156
+ from shapely.geometry import box
157
+
158
+ gdf = GeoDataFrame(
159
+ {
160
+ "id": ["1", "2"],
161
+ "code": pd.array([None, None], dtype="Int64"),
162
+ "area": [np.nan, np.nan],
163
+ "name": [None, None],
164
+ "geometry": [box(0, 0, 1, 1), box(1, 0, 2, 1)],
165
+ },
166
+ crs="EPSG:4326",
167
+ )
168
+ gp = GeoParquet(tmp_parquet_file)
169
+ gp.set_collection(
170
+ {
171
+ "schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
172
+ "collection": "c",
173
+ }
174
+ )
175
+ gp.write(gdf)
176
+
177
+ collection = GeoParquet(tmp_parquet_file).get_collection()
178
+ assert not {"code", "area", "name"} & set(collection)
179
+ assert {"code", "area", "name"} <= set(pq.read_schema(tmp_parquet_file).names)
180
+
181
+
182
+ def test_get_pyarrow_type_keeps_the_schema():
183
+ from vecorel_cli.parquet.types import get_pyarrow_type
184
+
185
+ schema = {"type": "object", "patternProperties": {".*": {"type": "string"}}}
186
+ assert get_pyarrow_type(schema) == pa.map_(pa.string(), pa.string())
187
+ assert get_pyarrow_type(schema) == pa.map_(pa.string(), pa.string())
188
+
189
+
190
+ def test_constant_array_parses_dates_completely():
191
+ import datetime
192
+
193
+ import pytest
194
+
195
+ from vecorel_cli.parquet.types import constant_array
196
+
197
+ schema = {"type": "date"}
198
+ for value in ["2024-01-01", "2024-01-01T00:00:00Z"]:
199
+ assert constant_array(value, schema).to_pylist() == [datetime.date(2024, 1, 1)]
200
+ for value in ["2024-01-01-invalid", "2024-01-01T12:00:00Z"]:
201
+ with pytest.raises(ValueError):
202
+ constant_array(value, schema)
203
+
204
+
205
+ def test_write_moves_a_constant_date_to_the_collection(tmp_parquet_file):
206
+ import datetime
207
+
208
+ from geopandas import GeoDataFrame
209
+ from shapely.geometry import box
210
+
211
+ gdf = GeoDataFrame(
212
+ {
213
+ "id": ["1", "2"],
214
+ "d": [datetime.date(2024, 1, 1)] * 2,
215
+ "geometry": [box(0, 0, 1, 1), box(1, 0, 2, 1)],
216
+ },
217
+ crs="EPSG:4326",
218
+ )
219
+ gp = GeoParquet(tmp_parquet_file)
220
+ gp.set_collection(
221
+ {
222
+ "schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
223
+ "collection": "c",
224
+ "schemas:custom": {"properties": {"d": {"type": "date"}}},
225
+ }
226
+ )
227
+ gp.write(gdf)
228
+
229
+ # dates are stored as ISO 8601 strings at the collection level
230
+ assert GeoParquet(tmp_parquet_file).get_collection()["d"] == "2024-01-01"
231
+
232
+
233
+ def test_read_hydrates_array_and_object_constants(tmp_parquet_file):
234
+ from geopandas import GeoDataFrame
235
+ from shapely.geometry import box
236
+
237
+ gdf = GeoDataFrame(
238
+ {"id": ["1", "2", "3"], "geometry": [box(0, 0, 1, 1)] * 3},
239
+ crs="EPSG:4326",
240
+ )
241
+ gp = GeoParquet(tmp_parquet_file)
242
+ gp.set_collection(
243
+ {
244
+ "schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
245
+ "collection": "c",
246
+ "tags": ["a", "b"],
247
+ "attrs": {"k": "v"},
248
+ }
249
+ )
250
+ gp.write(gdf, dehydrate=False)
251
+
252
+ data = GeoParquet(tmp_parquet_file).read(hydrate=True)
253
+ assert list(data["tags"]) == [["a", "b"]] * 3
254
+ assert list(data["attrs"]) == [{"k": "v"}] * 3
255
+
256
+
257
+ def test_a_small_file_still_gets_enough_row_groups(tmp_parquet_file, monkeypatch):
258
+ # with too few groups each one spans most of the file, so a reader can skip none
259
+ from geopandas import GeoDataFrame
260
+ from shapely.geometry import box
261
+
262
+ monkeypatch.setattr(GeoParquet, "row_group_size", 100)
263
+ monkeypatch.setattr(GeoParquet, "min_row_group_rows", 10)
264
+ gdf = GeoDataFrame(
265
+ {"id": [str(i) for i in range(500)], "geometry": [box(i, 0, i + 1, 1) for i in range(500)]},
266
+ crs="EPSG:4326",
267
+ )
268
+ gp = GeoParquet(tmp_parquet_file)
269
+ gp.set_collection(
270
+ {
271
+ "schemas": {"c": ["https://vecorel.org/specification/v0.1.0/schema.yaml"]},
272
+ "collection": "c",
273
+ }
274
+ )
275
+ gp.write(gdf)
276
+
277
+ assert pq.ParquetFile(tmp_parquet_file).metadata.num_row_groups == GeoParquet.min_row_groups
278
+ assert GeoParquet.row_group_size_for(5_000) == 100 # a large file keeps the cap
279
+ assert GeoParquet.row_group_size_for(20) == 10 # and a tiny one the floor