vecorel-cli 0.2.18__tar.gz → 0.2.19__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 (83) hide show
  1. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/PKG-INFO +3 -3
  2. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/pyproject.toml +4 -4
  3. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_convert.py +19 -0
  4. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_convert_duckdb.py +194 -0
  5. vecorel_cli-0.2.19/tests/test_download.py +59 -0
  6. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_encoding_geoparquet.py +25 -0
  7. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/conversion/base.py +28 -2
  8. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/conversion/duckdb.py +159 -48
  9. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/encoding/geoparquet.py +2 -1
  10. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/parquet/types.py +14 -0
  11. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/PKG-INFO +3 -3
  12. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/SOURCES.txt +1 -0
  13. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/requires.txt +2 -2
  14. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/LICENSE +0 -0
  15. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/README.md +0 -0
  16. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/setup.cfg +0 -0
  17. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_converters.py +0 -0
  18. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_create_geojson.py +0 -0
  19. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_create_geoparquet.py +0 -0
  20. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_create_stac.py +0 -0
  21. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_describe.py +0 -0
  22. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_encoding_auto.py +0 -0
  23. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_encoding_geojson.py +0 -0
  24. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_hilbert.py +0 -0
  25. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_improve.py +0 -0
  26. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_jsonschema.py +0 -0
  27. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_merge.py +0 -0
  28. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_ops.py +0 -0
  29. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_rename_extension.py +0 -0
  30. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_validate.py +0 -0
  31. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/tests/test_validate_schema.py +0 -0
  32. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/__init__.py +0 -0
  33. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/basecommand.py +0 -0
  34. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/cli/__init__.py +0 -0
  35. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/cli/logger.py +0 -0
  36. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/cli/options.py +0 -0
  37. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/cli/path_url.py +0 -0
  38. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/cli/setup.py +0 -0
  39. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/cli/util.py +0 -0
  40. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/const.py +0 -0
  41. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/conversion/__init__.py +0 -0
  42. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/conversion/admin.py +0 -0
  43. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/conversion/flatdict.py +0 -0
  44. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/convert.py +0 -0
  45. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/converters.py +0 -0
  46. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/create_geojson.py +0 -0
  47. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/create_geoparquet.py +0 -0
  48. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/create_jsonschema.py +0 -0
  49. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/create_stac.py +0 -0
  50. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/datasets/__init__.py +0 -0
  51. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/datasets/template.py +0 -0
  52. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/describe.py +0 -0
  53. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/encoding/__init__.py +0 -0
  54. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/encoding/auto.py +0 -0
  55. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/encoding/base.py +0 -0
  56. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/encoding/geojson.py +0 -0
  57. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/improve.py +0 -0
  58. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/jsonschema/__init__.py +0 -0
  59. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/jsonschema/template.py +0 -0
  60. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/merge.py +0 -0
  61. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/parquet/__init__.py +0 -0
  62. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/parquet/geopandas.py +0 -0
  63. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/registry.py +0 -0
  64. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/rename_extension.py +0 -0
  65. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/validate.py +0 -0
  66. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/validate_schema.py +0 -0
  67. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/validation/__init__.py +0 -0
  68. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/validation/base.py +0 -0
  69. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/validation/data.py +0 -0
  70. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/validation/geojson.py +0 -0
  71. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/validation/geoparquet.py +0 -0
  72. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/__init__.py +0 -0
  73. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/collection.py +0 -0
  74. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/extensions.py +0 -0
  75. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/hilbert.py +0 -0
  76. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/ops.py +0 -0
  77. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/schemas.py +0 -0
  78. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/typing.py +0 -0
  79. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/util.py +0 -0
  80. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli/vecorel/version.py +0 -0
  81. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/dependency_links.txt +0 -0
  82. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/vecorel_cli.egg-info/entry_points.txt +0 -0
  83. {vecorel_cli-0.2.18 → vecorel_cli-0.2.19}/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.18
3
+ Version: 0.2.19
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
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: pyyaml<7.0,>=6.0
20
20
  Requires-Dist: click<9.0,>=8.1
21
- Requires-Dist: geopandas<2.0,>=1.0.0
21
+ Requires-Dist: geopandas<2.0,>=1.1.0
22
22
  Requires-Dist: requests<3.0,>=2.30
23
23
  Requires-Dist: shapely<3.0,>=2.1
24
24
  Requires-Dist: numpy<3.0,>=2.0
@@ -26,7 +26,7 @@ Requires-Dist: pyarrow<24.0,>=21.0
26
26
  Requires-Dist: py7zr<2.0,>=1.0
27
27
  Requires-Dist: fsspec>=2025.7.0
28
28
  Requires-Dist: jsonschema[format]<5.0,>=4.20
29
- Requires-Dist: aiohttp<4.0,>=3.9
29
+ Requires-Dist: aiohttp<4.0,>=3.13.5
30
30
  Requires-Dist: yarl<2.0,>=1.20
31
31
  Requires-Dist: rarfile<5.0,>=4.0
32
32
  Requires-Dist: semantic-version<3.0,>=2.10.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vecorel-cli"
7
- version = "0.2.18"
7
+ version = "0.2.19"
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"
@@ -24,7 +24,7 @@ requires-python = ">=3.11"
24
24
  dependencies = [
25
25
  "pyyaml>=6.0,<7.0",
26
26
  "click>=8.1,<9.0",
27
- "geopandas>=1.0.0,<2.0",
27
+ "geopandas>=1.1.0,<2.0",
28
28
  "requests>=2.30,<3.0",
29
29
  "shapely>=2.1,<3.0",
30
30
  "numpy>=2.0,<3.0",
@@ -32,7 +32,7 @@ dependencies = [
32
32
  "py7zr>=1.0,<2.0",
33
33
  "fsspec>=2025.7.0",
34
34
  "jsonschema[format]>=4.20,<5.0",
35
- "aiohttp>=3.9,<4.0",
35
+ "aiohttp>=3.13.5,<4.0",
36
36
  "yarl>=1.20,<2.0",
37
37
  "rarfile>=4.0,<5.0",
38
38
  "semantic-version>=2.10.0,<3.0",
@@ -65,7 +65,7 @@ platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"]
65
65
  python = ">=3.11"
66
66
  pyyaml = ">=6.0,<7.0"
67
67
  click = ">=8.1,<9.0"
68
- geopandas = ">=1.0.0,<2.0"
68
+ geopandas = ">=1.1.0,<2.0"
69
69
  requests = ">=2.30,<3.0"
70
70
  shapely = ">=2.1,<3.0"
71
71
  numpy = ">=2.0,<3.0"
@@ -180,3 +180,22 @@ def test_data_access_exception(capsys, tmp_folder):
180
180
 
181
181
  assert isinstance(converter.converter, BaseConverter)
182
182
  assert converter.converter.data_access in out
183
+
184
+
185
+ @pytest.mark.parametrize("choice", ["example"])
186
+ def test_converter_can_keep_the_constants_in_columns(tmp_folder, monkeypatch, choice):
187
+ """A conversion that writes one part of a dataset cannot let constants move
188
+ into the collection metadata: constant is then judged over the part."""
189
+ import pyarrow.parquet as pq
190
+
191
+ from tests.datasets.example import Converter
192
+
193
+ dest = tmp_folder / "hydrated.parquet"
194
+ monkeypatch.setattr(Converter, "dehydrate", False)
195
+ ConvertData(choice).convert(dest, cache=(test_path / choice))
196
+
197
+ schema = pq.ParquetFile(dest).schema_arrow
198
+ collection = json.loads(schema.metadata[b"collection"])
199
+ for key in ("admin:country_code", "admin:subdivision_code", "determination_datetime"):
200
+ assert key in schema.names, f"{key} should have stayed a column"
201
+ assert key not in collection, f"{key} should not be in the collection metadata"
@@ -1,10 +1,12 @@
1
1
  import json
2
+ import sys
2
3
 
3
4
  import geopandas as gpd
4
5
  import numpy as np
5
6
  import pyarrow.parquet as pq
6
7
  import pytest
7
8
  import shapely
9
+ from loguru import logger
8
10
 
9
11
  from vecorel_cli.conversion.base import BaseConverter
10
12
  from vecorel_cli.conversion.duckdb import DuckDBBaseConverter
@@ -218,3 +220,195 @@ def test_codepath_parity(tmp_folder):
218
220
  for dest in (pandas_dest, duckdb_dest):
219
221
  validation = ValidateData().validate(dest, num=100, schema_map={})
220
222
  assert validation.errors == []
223
+
224
+
225
+ def test_merge_parquet(tmp_folder):
226
+ """Converted files combine into one, sorted over the whole set and
227
+ packaged like any other output."""
228
+ parts = []
229
+ for index, offset in enumerate((0, 20)):
230
+ gdf = gpd.GeoDataFrame(
231
+ {
232
+ "id": [f"{index}-{n}" for n in range(3)],
233
+ "name": ["a", "b", "c"],
234
+ "geometry": [shapely.box(offset + n, 0, offset + n + 1, 1) for n in range(3)],
235
+ },
236
+ crs="EPSG:4326",
237
+ )
238
+ src = tmp_folder / f"src_{index}.parquet"
239
+ gdf.to_parquet(src)
240
+ part = tmp_folder / f"part_{index}.parquet"
241
+ Converter().convert(part, input_files={str(src): src.name})
242
+ parts.append(part)
243
+
244
+ dest = tmp_folder / "merged.parquet"
245
+ Converter().merge_parquet(parts, dest)
246
+
247
+ result = gpd.read_parquet(dest)
248
+ assert sorted(result["id"]) == ["0-0", "0-1", "0-2", "1-0", "1-1", "1-2"]
249
+
250
+ with pq.ParquetFile(dest) as pf:
251
+ table = pf.read()
252
+ # sorted over the merged set, not per part
253
+ keys = hilbert_keys_for_table(table, "geometry", (-180.0, -90.0, 180.0, 90.0))
254
+ assert bool(np.all(keys[1:] >= keys[:-1]))
255
+ assert "bbox" in table.schema.names
256
+ assert b"collection" in table.schema.metadata
257
+
258
+ assert ValidateData().validate(dest, num=100, schema_map={}).errors == []
259
+
260
+
261
+ def test_merge_parquet_sees_an_id_that_repeats_across_parts(tmp_folder, capsys):
262
+ """A per-part check cannot see this; a check over the merge can."""
263
+ parts = []
264
+ for index in range(2):
265
+ gdf = gpd.GeoDataFrame(
266
+ {
267
+ "id": ["same", f"other-{index}"],
268
+ "name": ["a", "b"],
269
+ "geometry": [
270
+ shapely.box(index, 0, index + 1, 1),
271
+ shapely.box(index, 2, index + 1, 3),
272
+ ],
273
+ },
274
+ crs="EPSG:4326",
275
+ )
276
+ src = tmp_folder / f"dup_src_{index}.parquet"
277
+ gdf.to_parquet(src)
278
+ part = tmp_folder / f"dup_part_{index}.parquet"
279
+ Converter().convert(part, input_files={str(src): src.name})
280
+ parts.append(part)
281
+
282
+ # loguru's default sink is bound to stderr at import, so point it at stdout
283
+ logger.remove()
284
+ logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
285
+ Converter().merge_parquet(parts, tmp_folder / "dup_merged.parquet")
286
+ assert "'id' is not unique" in capsys.readouterr().out
287
+
288
+
289
+ def test_merge_parquet_rejects_empty_input(tmp_folder):
290
+ with pytest.raises(ValueError, match="No paths"):
291
+ Converter().merge_parquet([], tmp_folder / "nothing.parquet")
292
+
293
+
294
+ def test_duckdb_converter_can_keep_the_constants_in_columns(tmp_folder):
295
+ """A conversion that writes one part of a dataset cannot let constants move
296
+ into the collection metadata, in this codepath either."""
297
+ src = _source_file(tmp_folder)
298
+ dest = tmp_folder / "hydrated.parquet"
299
+
300
+ Hydrated = type(
301
+ "Hydrated",
302
+ (DuckDBBaseConverter,),
303
+ {
304
+ **CONFIG,
305
+ "dehydrate": False,
306
+ "column_additions": {"region": "north"},
307
+ "missing_schemas": {
308
+ "properties": {"name": {"type": "string"}, "region": {"type": "string"}}
309
+ },
310
+ },
311
+ )
312
+ Hydrated().convert(dest, input_files={src: "source.parquet"})
313
+
314
+ table = pq.read_table(dest)
315
+ assert "region" in table.schema.names, "the constant should have stayed a column"
316
+ assert set(table.column("region").to_pylist()) == {"north"}
317
+ assert "region" not in json.loads(table.schema.metadata[b"collection"])
318
+
319
+
320
+ def test_merge_parquet_refuses_parts_in_different_crs(tmp_folder):
321
+ parts = []
322
+ for index, crs in enumerate(("EPSG:4326", "EPSG:3857")):
323
+ gdf = gpd.GeoDataFrame(
324
+ {"id": [f"{index}"], "name": ["a"], "geometry": [shapely.box(0, 0, 1, 1)]},
325
+ crs="EPSG:4326",
326
+ ).to_crs(crs)
327
+ src = tmp_folder / f"crs_src_{index}.parquet"
328
+ gdf.to_parquet(src)
329
+ part = tmp_folder / f"crs_part_{index}.parquet"
330
+ Converter().convert(part, input_files={str(src): src.name})
331
+ parts.append(part)
332
+
333
+ with pytest.raises(ValueError, match="different coordinate reference systems"):
334
+ Converter().merge_parquet(parts, tmp_folder / "merged.parquet")
335
+
336
+
337
+ def test_merge_parquet_unions_parts_that_differ(tmp_folder):
338
+ """A converter drops a column a source file does not have, so two parts of one
339
+ dataset can differ; the merge must not fail on that."""
340
+ parts = []
341
+ for index, columns in enumerate(({"id": ["0"], "name": ["a"]}, {"id": ["1"]})):
342
+ gdf = gpd.GeoDataFrame(
343
+ {**columns, "geometry": [shapely.box(index, 0, index + 1, 1)]}, crs="EPSG:4326"
344
+ )
345
+ src = tmp_folder / f"u_src_{index}.parquet"
346
+ gdf.to_parquet(src)
347
+ part = tmp_folder / f"u_part_{index}.parquet"
348
+ Converter().convert(part, input_files={str(src): src.name})
349
+ parts.append(part)
350
+
351
+ dest = tmp_folder / "unioned.parquet"
352
+ Converter().merge_parquet(parts, dest)
353
+ table = pq.read_table(dest)
354
+ assert table.num_rows == 2
355
+ assert "name" in table.schema.names
356
+ assert sorted(x for x in table.column("id").to_pylist()) == ["0", "1"]
357
+
358
+
359
+ def test_merge_parquet_checks_ids_that_convert_generated(tmp_folder, capsys):
360
+ """Each part numbered its own rows from zero, so the merge has to check what
361
+ convert() is allowed to take for granted."""
362
+ IndexConverter = type("IndexConverter", (DuckDBBaseConverter,), {**CONFIG, "index_as_id": True})
363
+ parts = []
364
+ for index in range(2):
365
+ gdf = gpd.GeoDataFrame(
366
+ {
367
+ "name": ["a", "b"],
368
+ "geometry": [
369
+ shapely.box(index, 0, index + 1, 1),
370
+ shapely.box(index, 2, index + 1, 3),
371
+ ],
372
+ },
373
+ crs="EPSG:4326",
374
+ )
375
+ src = tmp_folder / f"idx_src_{index}.parquet"
376
+ gdf.to_parquet(src)
377
+ part = tmp_folder / f"idx_part_{index}.parquet"
378
+ IndexConverter().convert(part, input_files={str(src): src.name})
379
+ parts.append(part)
380
+
381
+ # both parts start at 0, so every id occurs twice
382
+ assert (
383
+ pq.read_table(parts[0]).column("id").to_pylist()
384
+ == pq.read_table(parts[1]).column("id").to_pylist()
385
+ )
386
+
387
+ logger.remove()
388
+ logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
389
+ IndexConverter().merge_parquet(parts, tmp_folder / "idx_merged.parquet")
390
+ assert "'id' is not unique" in capsys.readouterr().out
391
+
392
+
393
+ def test_merge_parquet_keeps_a_crs_duckdb_would_drop(tmp_folder):
394
+ """DuckDB before 1.5 writes no CRS into the merged file's metadata, so it has to
395
+ come from the parts. Checked with a CRS that is not the default."""
396
+ parts = []
397
+ for index in range(2):
398
+ gdf = gpd.GeoDataFrame(
399
+ {"id": [f"{index}"], "name": ["a"], "geometry": [shapely.box(index, 0, index + 1, 1)]},
400
+ crs="EPSG:4326",
401
+ ).to_crs("EPSG:3857")
402
+ src = tmp_folder / f"crs_keep_src_{index}.parquet"
403
+ gdf.to_parquet(src)
404
+ part = tmp_folder / f"crs_keep_part_{index}.parquet"
405
+ Converter().convert(part, input_files={str(src): src.name})
406
+ parts.append(part)
407
+
408
+ dest = tmp_folder / "crs_keep.parquet"
409
+ Converter().merge_parquet(parts, dest)
410
+
411
+ geo = json.loads(pq.read_table(dest).schema.metadata[b"geo"])
412
+ crs = geo["columns"][geo["primary_column"]]["crs"]
413
+ assert crs is not None, "the merged file lost the CRS of its parts"
414
+ assert "3857" in json.dumps(crs)
@@ -0,0 +1,59 @@
1
+ import pytest
2
+
3
+ import vecorel_cli.conversion.base as base
4
+ from vecorel_cli.conversion.base import BaseConverter
5
+
6
+ URI = "https://example.invalid/data/fields.gml"
7
+
8
+
9
+ def test_failed_download_leaves_no_cache_file(tmp_folder, monkeypatch):
10
+ """A download that raises must not leave a file behind that a later run
11
+ would treat as cached (#43)."""
12
+
13
+ def failing_stream(fs, src_uri, dst_file, chunk_size=None):
14
+ dst_file.write(b"partial")
15
+ raise RuntimeError("403 Forbidden")
16
+
17
+ monkeypatch.setattr(base, "stream_file", failing_stream)
18
+
19
+ with pytest.raises(RuntimeError, match="403"):
20
+ BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
21
+
22
+ assert list(tmp_folder.iterdir()) == [], "cache must be empty after a failed download"
23
+
24
+
25
+ def test_failed_download_is_retried_on_next_run(tmp_folder, monkeypatch):
26
+ calls = []
27
+
28
+ def stream(fs, src_uri, dst_file, chunk_size=None):
29
+ calls.append(src_uri)
30
+ if len(calls) == 1:
31
+ raise RuntimeError("timeout")
32
+ dst_file.write(b"<gml/>")
33
+
34
+ monkeypatch.setattr(base, "stream_file", stream)
35
+
36
+ with pytest.raises(RuntimeError):
37
+ BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
38
+
39
+ paths = BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
40
+
41
+ assert len(calls) == 2, "the second run must download again"
42
+ assert paths == [(str(tmp_folder / "fields.gml"), URI)]
43
+ assert (tmp_folder / "fields.gml").read_bytes() == b"<gml/>"
44
+ assert not (tmp_folder / "fields.gml.part").exists()
45
+
46
+
47
+ def test_successful_download_is_cached(tmp_folder, monkeypatch):
48
+ calls = []
49
+
50
+ def stream(fs, src_uri, dst_file, chunk_size=None):
51
+ calls.append(src_uri)
52
+ dst_file.write(b"<gml/>")
53
+
54
+ monkeypatch.setattr(base, "stream_file", stream)
55
+
56
+ BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
57
+ BaseConverter().download_files(URI, cache_folder=str(tmp_folder))
58
+
59
+ assert calls == [URI], "a completed download must be reused"
@@ -1,6 +1,8 @@
1
1
  import json
2
2
  from pathlib import Path
3
3
 
4
+ import numpy as np
5
+ import pandas as pd
4
6
  import pyarrow as pa
5
7
  import pyarrow.parquet as pq
6
8
  import pyarrow.types as pat
@@ -123,3 +125,26 @@ def test_postprocess(tmp_parquet_file):
123
125
  geo = json.loads(pf.metadata.metadata[b"geo"])
124
126
  assert geo["version"] == "1.0.0"
125
127
  assert "covering" not in geo["columns"]["geometry"]
128
+
129
+
130
+ def test_read_keeps_integers_that_have_a_null(tmp_parquet_file):
131
+ # pandas turns an integer column with a missing value into float64, after which
132
+ # every value in it reads as a float and validation rejects the column
133
+ src = pq.read_table("tests/data-files/inspire.parquet")
134
+ counts = pa.array([7] * (len(src) - 1) + [None], pa.uint32())
135
+ table = src.append_column(pa.field("count", pa.uint32(), nullable=True), counts)
136
+
137
+ metadata = dict(src.schema.metadata)
138
+ collection = json.loads(metadata[b"collection"])
139
+ collection["schemas:custom"]["properties"]["count"] = {"type": "uint32"}
140
+ metadata[b"collection"] = json.dumps(collection).encode("utf-8")
141
+ pq.write_table(table.replace_schema_metadata(metadata), tmp_parquet_file, store_schema=True)
142
+
143
+ data = GeoParquet(tmp_parquet_file).read()
144
+ assert str(data["count"].dtype) == "UInt32"
145
+ assert isinstance(data["count"].iloc[0], np.uint32)
146
+ assert pd.isna(data["count"].iloc[-1])
147
+
148
+ from vecorel_cli.validate import ValidateData
149
+
150
+ assert ValidateData().validate(tmp_parquet_file).errors == []
@@ -67,6 +67,12 @@ class BaseConverter(LoggerMixin):
67
67
  # missing geometry are always dropped, regardless of this share.
68
68
  max_dropped_share: float = 0.01
69
69
 
70
+ # Move properties that hold one value for every row into the collection metadata.
71
+ # A conversion that writes one part of a dataset must not: "one value for every
72
+ # row" is then judged over the part rather than over the dataset, so a property
73
+ # that varies between parts is lost.
74
+ dehydrate: bool = True
75
+
70
76
  def __init__(self, *args, **kwargs):
71
77
  super().__init__()
72
78
 
@@ -279,8 +285,7 @@ class BaseConverter(LoggerMixin):
279
285
  must_extract = is_archive and not os.path.exists(zip_folder)
280
286
 
281
287
  if (not is_archive or must_extract) and not cache_fs.exists(cache_file):
282
- with cache_fs.open(cache_file, mode="wb") as file:
283
- stream_file(source_fs, uri, file)
288
+ self._download_file(source_fs, uri, cache_fs, cache_file)
284
289
 
285
290
  if must_extract:
286
291
  if zipfile.is_zipfile(cache_file):
@@ -316,6 +321,26 @@ class BaseConverter(LoggerMixin):
316
321
 
317
322
  return paths
318
323
 
324
+ @staticmethod
325
+ def _download_file(source_fs, uri, cache_fs, cache_file):
326
+ """Stream a remote file into the cache.
327
+
328
+ The data goes to a `.part` file that is renamed only after a clean close,
329
+ so an interrupted download leaves nothing that a later run could mistake
330
+ for a cached file.
331
+ """
332
+ part_file = cache_file + ".part"
333
+ try:
334
+ with cache_fs.open(part_file, mode="wb") as file:
335
+ stream_file(source_fs, uri, file)
336
+ except BaseException:
337
+ try:
338
+ cache_fs.rm(part_file)
339
+ except FileNotFoundError:
340
+ pass
341
+ raise
342
+ cache_fs.mv(part_file, cache_file)
343
+
319
344
  def get_urls(self):
320
345
  urls = self.sources
321
346
  if not urls and self.variants:
@@ -587,6 +612,7 @@ class BaseConverter(LoggerMixin):
587
612
  pq.write(
588
613
  gdf,
589
614
  properties=columns,
615
+ dehydrate=self.dehydrate,
590
616
  compression=compression,
591
617
  compression_level=compression_level,
592
618
  geoparquet_version=geoparquet_version,
@@ -22,6 +22,29 @@ def _sql_path(path) -> str:
22
22
  return f"'{escaped}'"
23
23
 
24
24
 
25
+ # A COPY statement binds its own parameters before those of its subquery, so a value
26
+ # placed in the SELECT cannot be a bound parameter: it would be read as the output path.
27
+ def _sql_literal(value) -> str:
28
+ if value is None:
29
+ return "NULL"
30
+ if isinstance(value, bool):
31
+ return "TRUE" if value else "FALSE"
32
+ if isinstance(value, int):
33
+ return repr(value)
34
+ if isinstance(value, float):
35
+ if np.isnan(value):
36
+ return "'NaN'::DOUBLE"
37
+ if np.isposinf(value):
38
+ return "'Infinity'::DOUBLE"
39
+ if np.isneginf(value):
40
+ return "'-Infinity'::DOUBLE"
41
+ return repr(value)
42
+ if not isinstance(value, str):
43
+ raise ValueError(f"Cannot use {value!r} as a constant column; it is not a scalar")
44
+ escaped = value.replace("'", "''")
45
+ return f"'{escaped}'"
46
+
47
+
25
48
  def _normalize_crs(crs):
26
49
  """The comparable pyproj CRS for a GeoParquet crs value,
27
50
  which defaults to OGC:CRS84 when missing."""
@@ -51,13 +74,6 @@ class DuckDBBaseConverter(BaseConverter):
51
74
  original_geometries=False,
52
75
  **kwargs,
53
76
  ) -> str:
54
- geoparquet_version = geoparquet_version or "1.1.0"
55
- # Same packaging as the GeoDataFrame-based codepath
56
- compression = compression or "zstd"
57
- if compression == "zstd" and compression_level is None:
58
- compression_level = 15
59
- row_group_size = GeoParquet.row_group_size
60
-
61
77
  self.variant = variant
62
78
  cid = self.id.strip()
63
79
  if self.bbox is not None and len(self.bbox) != 4:
@@ -108,28 +124,7 @@ class DuckDBBaseConverter(BaseConverter):
108
124
  ).fetchall()
109
125
  }
110
126
 
111
- # DuckDB drops the CRS from the GeoParquet metadata, so take it from the
112
- # sources for the Hilbert grid and the output metadata. The sources are
113
- # combined without reprojection, so they must all use the same CRS.
114
- source_crs = None
115
- reference = None
116
- for i, source in enumerate([sources] if isinstance(sources, str) else sources):
117
- crs = None
118
- row = con.execute(
119
- "SELECT value FROM parquet_kv_metadata(?) WHERE key = 'geo'", [source]
120
- ).fetchone()
121
- if row:
122
- source_geo = json.loads(bytes(row[0]))
123
- primary_column = source_geo.get("primary_column", "")
124
- crs = source_geo.get("columns", {}).get(primary_column, {}).get("crs")
125
- if i == 0:
126
- source_crs = crs
127
- reference = _normalize_crs(crs)
128
- elif not _equal_crs(_normalize_crs(crs), reference):
129
- raise ValueError(
130
- f"The sources use different coordinate reference systems: {source} "
131
- "differs from the first source. Reproject the sources to a common CRS."
132
- )
127
+ source_crs = self._common_crs(con, [sources] if isinstance(sources, str) else sources)
133
128
  selections = []
134
129
  selected_targets = []
135
130
  for k, v in self.columns.items():
@@ -150,10 +145,9 @@ class DuckDBBaseConverter(BaseConverter):
150
145
  collection = self.create_collection(cid)
151
146
  collection["collection"] = cid
152
147
 
153
- # Constants pinned to the feature level become literal columns, all others
154
- # end up in the collection metadata (like the dehydration step of the
155
- # GeoDataFrame-based codepath)
156
- addition_params = []
148
+ # A constant the schema pins to the feature level becomes a literal column and one
149
+ # it pins to the collection goes there; the rest follow `dehydrate`, like the
150
+ # dehydration step of the GeoDataFrame-based codepath
157
151
  if self.column_additions:
158
152
  context = collection.get_collection_context()
159
153
  for key, value in self.column_additions.items():
@@ -162,12 +156,12 @@ class DuckDBBaseConverter(BaseConverter):
162
156
  keep = [i for i, t in enumerate(selected_targets) if t != key]
163
157
  selections = [selections[i] for i in keep]
164
158
  selected_targets = [selected_targets[i] for i in keep]
165
- if context.get(key) is False:
166
- selections.append(f'? as "{key}"')
167
- addition_params.append(value)
168
- selected_targets.append(key)
169
- else:
159
+ where = context.get(key)
160
+ if where is True or (where is None and self.dehydrate):
170
161
  collection[key] = value
162
+ else:
163
+ selections.append(f'{_sql_literal(value)} as "{key}"')
164
+ selected_targets.append(key)
171
165
  selection = ", ".join(selections)
172
166
 
173
167
  filters = []
@@ -191,11 +185,6 @@ class DuckDBBaseConverter(BaseConverter):
191
185
  if len(filters) > 0:
192
186
  where = f"WHERE {' AND '.join(filters)}"
193
187
 
194
- if isinstance(output_file, Path):
195
- output_file = str(output_file)
196
-
197
- collection_json = json.dumps(collection, cls=VecorelJSONEncoder).encode("utf-8")
198
-
199
188
  if isinstance(sources, str):
200
189
  sources_sql = _sql_path(sources)
201
190
  else:
@@ -206,6 +195,89 @@ class DuckDBBaseConverter(BaseConverter):
206
195
  {where}
207
196
  """
208
197
 
198
+ return self.write_query(
199
+ con,
200
+ source_query,
201
+ output_file,
202
+ collection,
203
+ targets=selected_targets,
204
+ source_crs=source_crs,
205
+ ids_are_generated=self.index_as_id,
206
+ compression=compression,
207
+ compression_level=compression_level,
208
+ geoparquet_version=geoparquet_version,
209
+ original_geometries=original_geometries,
210
+ )
211
+
212
+ def _common_crs(self, con, sources: list):
213
+ """The CRS the sources declare, refusing a set that does not agree on one.
214
+
215
+ They are combined without reprojection, and DuckDB before 1.5 drops the CRS
216
+ from the metadata, so it has to be read from the files themselves.
217
+ """
218
+ source_crs = None
219
+ reference = None
220
+ for i, source in enumerate(sources):
221
+ crs = None
222
+ row = con.execute(
223
+ "SELECT value FROM parquet_kv_metadata(?) WHERE key = 'geo'", [source]
224
+ ).fetchone()
225
+ if row:
226
+ geo = json.loads(bytes(row[0]))
227
+ primary = geo.get("primary_column", "")
228
+ crs = geo.get("columns", {}).get(primary, {}).get("crs")
229
+ if i == 0:
230
+ source_crs = crs
231
+ reference = _normalize_crs(crs)
232
+ elif not _equal_crs(_normalize_crs(crs), reference):
233
+ raise ValueError(
234
+ f"The sources use different coordinate reference systems: {source} "
235
+ "differs from the first source. Reproject the sources to a common CRS."
236
+ )
237
+ return source_crs
238
+
239
+ def write_query(
240
+ self,
241
+ con,
242
+ source_query: str,
243
+ output_file,
244
+ collection,
245
+ targets: list,
246
+ source_crs=None,
247
+ # convert() numbers the rows itself, so they are unique by construction; a merge
248
+ # combines parts that each started over, so it always has to check
249
+ ids_are_generated: bool = False,
250
+ compression: Optional[str] = None,
251
+ compression_level: Optional[int] = None,
252
+ geoparquet_version: Optional[str] = None,
253
+ original_geometries: bool = False,
254
+ ) -> str:
255
+ """Write the rows a SELECT returns as a Vecorel GeoParquet file: drop rows
256
+ that no required property or geometry survives, report an id that is not
257
+ unique, normalize the geometries, sort into Hilbert order and package.
258
+
259
+ `targets` names the properties the query returns, which is what the checks
260
+ run over.
261
+ """
262
+ compression = compression or "zstd"
263
+ if compression == "zstd" and compression_level is None:
264
+ compression_level = 15
265
+ geoparquet_version = geoparquet_version or "1.1.0"
266
+ row_group_size = GeoParquet.row_group_size
267
+ if isinstance(output_file, Path):
268
+ output_file = str(output_file)
269
+ directory = os.path.dirname(output_file)
270
+ if directory:
271
+ os.makedirs(directory, exist_ok=True)
272
+ selected_targets = targets
273
+ collection_json = json.dumps(collection, cls=VecorelJSONEncoder).encode("utf-8")
274
+
275
+ # An external sort spills to disk; keep that next to the output, which is
276
+ # where there is room for it, rather than wherever DuckDB defaults to
277
+ con.execute(
278
+ f"SET temp_directory = {_sql_path(os.path.join(os.path.dirname(output_file) or '.', '.duckdb_tmp'))}"
279
+ )
280
+
209
281
  # Same bounded null-value drop, empty-geometry drop and id uniqueness
210
282
  # check as in the GeoDataFrame-based codepath, in one scan
211
283
  schemas = collection.merge_schemas({})
@@ -221,7 +293,7 @@ class DuckDBBaseConverter(BaseConverter):
221
293
  null_cond = " OR ".join(f'"{target}" IS NULL' for target in required)
222
294
  stats.append(f"count(*) FILTER (WHERE {null_cond})")
223
295
  # row numbers are unique by construction
224
- check_ids = "id" in selected_targets and not self.index_as_id
296
+ check_ids = "id" in selected_targets and not ids_are_generated
225
297
  if check_ids:
226
298
  stats.append('count("id")')
227
299
  stats.append('count(DISTINCT "id")')
@@ -231,9 +303,7 @@ class DuckDBBaseConverter(BaseConverter):
231
303
  stats.append(f"count(*) FILTER (WHERE {blank_cond})")
232
304
  if len(stats) > 1:
233
305
  values = list(
234
- con.execute(
235
- f"SELECT {', '.join(stats)} FROM ({source_query})", addition_params
236
- ).fetchone()
306
+ con.execute(f"SELECT {', '.join(stats)} FROM ({source_query})").fetchone()
237
307
  )
238
308
  total = values.pop(0)
239
309
  invalid = values.pop(0) if null_cond else 0
@@ -301,7 +371,7 @@ class DuckDBBaseConverter(BaseConverter):
301
371
  }}
302
372
  )
303
373
  """,
304
- [*addition_params, output_file, compression, collection_json],
374
+ [output_file, compression, collection_json],
305
375
  )
306
376
 
307
377
  # Sort against the same CRS-derived Hilbert grid as the
@@ -343,6 +413,47 @@ class DuckDBBaseConverter(BaseConverter):
343
413
 
344
414
  return output_file
345
415
 
416
+ def merge_parquet(self, paths: list, output_file, collection=None, **kwargs) -> str:
417
+ """Combine Vecorel GeoParquet files into one, checked and sorted over the
418
+ whole set rather than per file.
419
+
420
+ The parts are written by a converter, so they need no column mapping and
421
+ their geometries are already valid polygons; pass `original_geometries=False`
422
+ to run the geometry step anyway.
423
+ """
424
+ if not paths:
425
+ raise ValueError("No paths to merge")
426
+ paths = [str(path) for path in paths]
427
+ kwargs.setdefault("original_geometries", True)
428
+
429
+ con = duckdb.connect()
430
+ con.install_extension("spatial")
431
+ con.load_extension("spatial")
432
+
433
+ source_crs = self._common_crs(con, paths)
434
+
435
+ # union_by_name, because a converter drops a column a source file does not have,
436
+ # so two parts of one dataset can legitimately differ; the targets then come from
437
+ # the union rather than from whichever part happens to be first
438
+ sources = "[" + ",".join(_sql_path(path) for path in paths) + "]"
439
+ source_query = f"SELECT * FROM read_parquet({sources}, union_by_name=true)"
440
+ targets = [row[0] for row in con.execute(f"DESCRIBE {source_query}").fetchall()]
441
+
442
+ if collection is None:
443
+ from ..vecorel.ops import merge_collections
444
+
445
+ collection = merge_collections([GeoParquet(path).get_collection() for path in paths])
446
+
447
+ return self.write_query(
448
+ con,
449
+ source_query,
450
+ output_file,
451
+ collection,
452
+ targets=targets,
453
+ source_crs=source_crs,
454
+ **kwargs,
455
+ )
456
+
346
457
  # Streams the Hilbert keys per row group to a sidecar file and reports
347
458
  # whether the file is already sorted, so memory stays bounded
348
459
  def _write_hilbert_keys(self, output_file, primary, bounds):
@@ -17,6 +17,7 @@ from ..const import GEOPARQUET_DEFAULT_VERSION, GEOPARQUET_VERSIONS
17
17
  from ..encoding.geojson import VecorelJSONEncoder
18
18
  from ..parquet.geopandas import to_parquet
19
19
  from ..parquet.types import (
20
+ NULLABLE_INTEGERS,
20
21
  get_geopandas_dtype,
21
22
  get_pyarrow_field,
22
23
  get_pyarrow_type,
@@ -516,7 +517,7 @@ class GeoParquet(BaseEncoding):
516
517
  rows = next(pf.iter_batches(batch_size=num, columns=properties))
517
518
  table = pa.Table.from_batches([rows])
518
519
 
519
- gdf = _arrow_to_geopandas(table)
520
+ gdf = _arrow_to_geopandas(table, to_pandas_kwargs={"types_mapper": NULLABLE_INTEGERS.get})
520
521
 
521
522
  if hydrate:
522
523
  gdf = self.hydrate_from_collection(gdf, schema_map=schema_map)
@@ -6,6 +6,20 @@ import pyarrow as pa
6
6
  import pyarrow.types as pat
7
7
  from shapely.geometry.base import BaseGeometry
8
8
 
9
+ # pandas has no integer dtype that holds a null, so an integer column with one
10
+ # missing value comes back as float64 and every value in it then reads as a float.
11
+ # The nullable dtypes do hold one, so integers are read into those.
12
+ NULLABLE_INTEGERS = {
13
+ pa.int8(): pd.Int8Dtype(),
14
+ pa.uint8(): pd.UInt8Dtype(),
15
+ pa.int16(): pd.Int16Dtype(),
16
+ pa.uint16(): pd.UInt16Dtype(),
17
+ pa.int32(): pd.Int32Dtype(),
18
+ pa.uint32(): pd.UInt32Dtype(),
19
+ pa.int64(): pd.Int64Dtype(),
20
+ pa.uint64(): pd.UInt64Dtype(),
21
+ }
22
+
9
23
 
10
24
  def is_enum(schema):
11
25
  return isinstance(schema.get("enum"), list)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vecorel-cli
3
- Version: 0.2.18
3
+ Version: 0.2.19
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
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: pyyaml<7.0,>=6.0
20
20
  Requires-Dist: click<9.0,>=8.1
21
- Requires-Dist: geopandas<2.0,>=1.0.0
21
+ Requires-Dist: geopandas<2.0,>=1.1.0
22
22
  Requires-Dist: requests<3.0,>=2.30
23
23
  Requires-Dist: shapely<3.0,>=2.1
24
24
  Requires-Dist: numpy<3.0,>=2.0
@@ -26,7 +26,7 @@ Requires-Dist: pyarrow<24.0,>=21.0
26
26
  Requires-Dist: py7zr<2.0,>=1.0
27
27
  Requires-Dist: fsspec>=2025.7.0
28
28
  Requires-Dist: jsonschema[format]<5.0,>=4.20
29
- Requires-Dist: aiohttp<4.0,>=3.9
29
+ Requires-Dist: aiohttp<4.0,>=3.13.5
30
30
  Requires-Dist: yarl<2.0,>=1.20
31
31
  Requires-Dist: rarfile<5.0,>=4.0
32
32
  Requires-Dist: semantic-version<3.0,>=2.10.0
@@ -8,6 +8,7 @@ tests/test_create_geojson.py
8
8
  tests/test_create_geoparquet.py
9
9
  tests/test_create_stac.py
10
10
  tests/test_describe.py
11
+ tests/test_download.py
11
12
  tests/test_encoding_auto.py
12
13
  tests/test_encoding_geojson.py
13
14
  tests/test_encoding_geoparquet.py
@@ -1,6 +1,6 @@
1
1
  pyyaml<7.0,>=6.0
2
2
  click<9.0,>=8.1
3
- geopandas<2.0,>=1.0.0
3
+ geopandas<2.0,>=1.1.0
4
4
  requests<3.0,>=2.30
5
5
  shapely<3.0,>=2.1
6
6
  numpy<3.0,>=2.0
@@ -8,7 +8,7 @@ pyarrow<24.0,>=21.0
8
8
  py7zr<2.0,>=1.0
9
9
  fsspec>=2025.7.0
10
10
  jsonschema[format]<5.0,>=4.20
11
- aiohttp<4.0,>=3.9
11
+ aiohttp<4.0,>=3.13.5
12
12
  yarl<2.0,>=1.20
13
13
  rarfile<5.0,>=4.0
14
14
  semantic-version<3.0,>=2.10.0
File without changes
File without changes
File without changes