vecorel-cli 0.4.0__tar.gz → 0.4.2__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 (87) hide show
  1. {vecorel_cli-0.4.0/vecorel_cli.egg-info → vecorel_cli-0.4.2}/PKG-INFO +1 -1
  2. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/pyproject.toml +1 -1
  3. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_convert_duckdb.py +58 -0
  4. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_download.py +54 -1
  5. vecorel_cli-0.4.2/tests/test_per_file.py +95 -0
  6. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/base.py +22 -9
  7. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/duckdb.py +29 -21
  8. vecorel_cli-0.4.2/vecorel_cli/conversion/per_file.py +53 -0
  9. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/util.py +23 -2
  10. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2/vecorel_cli.egg-info}/PKG-INFO +1 -1
  11. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli.egg-info/SOURCES.txt +2 -0
  12. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/LICENSE +0 -0
  13. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/README.md +0 -0
  14. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/setup.cfg +0 -0
  15. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_convert.py +0 -0
  16. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_converters.py +0 -0
  17. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_create_geojson.py +0 -0
  18. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_create_geoparquet.py +0 -0
  19. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_create_stac.py +0 -0
  20. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_deflate64.py +0 -0
  21. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_describe.py +0 -0
  22. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_encoding_auto.py +0 -0
  23. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_encoding_geojson.py +0 -0
  24. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_encoding_geoparquet.py +0 -0
  25. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_hilbert.py +0 -0
  26. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_improve.py +0 -0
  27. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_jsonschema.py +0 -0
  28. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_merge.py +0 -0
  29. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_ops.py +0 -0
  30. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_rename_extension.py +0 -0
  31. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_validate.py +0 -0
  32. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_validate_schema.py +0 -0
  33. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/__init__.py +0 -0
  34. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/basecommand.py +0 -0
  35. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/__init__.py +0 -0
  36. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/logger.py +0 -0
  37. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/options.py +0 -0
  38. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/path_url.py +0 -0
  39. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/setup.py +0 -0
  40. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/util.py +0 -0
  41. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/const.py +0 -0
  42. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/__init__.py +0 -0
  43. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/admin.py +0 -0
  44. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/deflate64.py +0 -0
  45. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/flatdict.py +0 -0
  46. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/convert.py +0 -0
  47. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/converters.py +0 -0
  48. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/create_geojson.py +0 -0
  49. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/create_geoparquet.py +0 -0
  50. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/create_jsonschema.py +0 -0
  51. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/create_stac.py +0 -0
  52. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/datasets/__init__.py +0 -0
  53. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/datasets/template.py +0 -0
  54. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/describe.py +0 -0
  55. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/__init__.py +0 -0
  56. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/auto.py +0 -0
  57. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/base.py +0 -0
  58. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/geojson.py +0 -0
  59. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/geoparquet.py +0 -0
  60. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/improve.py +0 -0
  61. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/jsonschema/__init__.py +0 -0
  62. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/jsonschema/template.py +0 -0
  63. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/merge.py +0 -0
  64. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/parquet/__init__.py +0 -0
  65. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/parquet/geopandas.py +0 -0
  66. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/parquet/types.py +0 -0
  67. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/registry.py +0 -0
  68. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/rename_extension.py +0 -0
  69. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validate.py +0 -0
  70. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validate_schema.py +0 -0
  71. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/__init__.py +0 -0
  72. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/base.py +0 -0
  73. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/data.py +0 -0
  74. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/geojson.py +0 -0
  75. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/geoparquet.py +0 -0
  76. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/__init__.py +0 -0
  77. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/collection.py +0 -0
  78. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/extensions.py +0 -0
  79. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/hilbert.py +0 -0
  80. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/ops.py +0 -0
  81. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/schemas.py +0 -0
  82. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/typing.py +0 -0
  83. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/version.py +0 -0
  84. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli.egg-info/dependency_links.txt +0 -0
  85. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli.egg-info/entry_points.txt +0 -0
  86. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli.egg-info/requires.txt +0 -0
  87. {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/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.4.0
3
+ Version: 0.4.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vecorel-cli"
7
- version = "0.4.0"
7
+ version = "0.4.2"
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,6 +1,7 @@
1
1
  import json
2
2
  import re
3
3
  import sys
4
+ import zipfile
4
5
 
5
6
  import geopandas as gpd
6
7
  import numpy as np
@@ -9,6 +10,7 @@ import pytest
9
10
  import shapely
10
11
  from loguru import logger
11
12
 
13
+ from vecorel_cli.const import COMPRESSION_METHODS
12
14
  from vecorel_cli.conversion import duckdb as duckdb_module
13
15
  from vecorel_cli.conversion.base import BaseConverter
14
16
  from vecorel_cli.conversion.duckdb import DuckDBBaseConverter, _constants_table
@@ -109,6 +111,48 @@ def test_duckdb_converter(tmp_folder):
109
111
  assert validation.errors == []
110
112
 
111
113
 
114
+ def test_duckdb_converter_extracts_an_archive_without_a_cache(tmp_folder):
115
+ src = _source_file(tmp_folder)
116
+ archive = tmp_folder / "source.zip"
117
+ with zipfile.ZipFile(archive, "w") as zf:
118
+ zf.write(src, "source.parquet")
119
+ dest = tmp_folder / "converted.parquet"
120
+
121
+ Converter().convert(dest, input_files={str(archive): ["source.parquet"]})
122
+
123
+ assert sorted(gpd.read_parquet(dest)["id"]) == ["bowtie", "multi", "square", "with_z"]
124
+
125
+
126
+ def test_duckdb_converter_ignores_hive_style_folders(tmp_folder):
127
+ source_folder = tmp_folder / "region=north"
128
+ source_folder.mkdir()
129
+ src = _source_file(source_folder)
130
+ dest_folder = tmp_folder / "year=2024"
131
+ dest_folder.mkdir()
132
+ dest = dest_folder / "converted.parquet"
133
+
134
+ Converter().convert(dest, input_files={src: "source.parquet"})
135
+
136
+ with pq.ParquetFile(dest) as pf:
137
+ names = pf.schema_arrow.names
138
+ assert "year" not in names
139
+ assert "region" not in names
140
+
141
+
142
+ @pytest.mark.parametrize(
143
+ "urls, in_place",
144
+ [
145
+ ("https://host/data.parquet?token=abc", True),
146
+ ("https://host/data.geoparquet", True),
147
+ ({"https://host/x?id=1": "fields.parquet"}, True),
148
+ ({"https://host/data.zip": ["fields.parquet"]}, False),
149
+ ("https://host/data.fgb", False),
150
+ ],
151
+ )
152
+ def test_only_parquet_is_read_in_place(urls, in_place):
153
+ assert duckdb_module._readable_in_place(urls) is in_place
154
+
155
+
112
156
  def test_duckdb_converter_numbers_rows_without_an_id_mapping(tmp_folder):
113
157
  src = _source_file(tmp_folder)
114
158
  dest = tmp_folder / "converted.parquet"
@@ -520,6 +564,20 @@ def test_merge_parquet(tmp_folder):
520
564
  assert ValidateData().validate(dest, num=100, schema_map={}).errors == []
521
565
 
522
566
 
567
+ @pytest.mark.parametrize("compression", COMPRESSION_METHODS)
568
+ def test_every_compression_method_is_written(tmp_folder, compression):
569
+ src = _source_file(tmp_folder)
570
+ part = tmp_folder / "part.parquet"
571
+ Converter().convert(part, input_files={src: "source.parquet"}, compression=compression)
572
+ dest = tmp_folder / "merged.parquet"
573
+ Converter().merge_parquet([part], dest, compression=compression)
574
+
575
+ for path in (part, dest):
576
+ with pq.ParquetFile(path) as pf:
577
+ codec = pf.metadata.row_group(0).column(0).compression
578
+ assert codec == ("UNCOMPRESSED" if compression == "none" else compression.upper())
579
+
580
+
523
581
  def test_merge_parquet_sees_an_id_that_repeats_across_parts(tmp_folder, capsys):
524
582
  """A per-part check cannot see this; a check over the merge can."""
525
583
  parts = []
@@ -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"}
@@ -0,0 +1,95 @@
1
+ import io
2
+ import sys
3
+
4
+ import geopandas as gpd
5
+ import multivolumefile
6
+ import py7zr
7
+ import shapely
8
+ from loguru import logger
9
+
10
+ from vecorel_cli.conversion.base import BaseConverter
11
+ from vecorel_cli.conversion.per_file import PerFileConverterMixin
12
+
13
+
14
+ class Converter(PerFileConverterMixin, BaseConverter):
15
+ id = "test"
16
+ short_name = "Test"
17
+ title = "Test dataset"
18
+ description = "Test dataset"
19
+ license = "CC0-1.0"
20
+ variants = {"2024": None, "2025": None}
21
+ columns = {"geometry": "geometry", "id": "id", "region": "region"}
22
+ missing_schemas = {"properties": {"region": {"type": "string"}}}
23
+
24
+
25
+ def _source(folder, region):
26
+ gdf = gpd.GeoDataFrame(
27
+ {
28
+ "id": [f"{region}-1", f"{region}-2"],
29
+ "region": [region, region],
30
+ "geometry": [shapely.box(0, 0, 1, 1), shapely.box(2, 0, 3, 1)],
31
+ },
32
+ crs="EPSG:4326",
33
+ )
34
+ path = folder / f"{region}.parquet"
35
+ gdf.to_parquet(path)
36
+ return path
37
+
38
+
39
+ def _capture_log():
40
+ # loguru's default sink is bound to stderr at import, so point it at stdout
41
+ logger.remove()
42
+ logger.add(sys.stdout, format="{message}", level="DEBUG", colorize=False)
43
+
44
+
45
+ def test_each_source_is_converted_on_its_own_and_merged(tmp_path, capsys):
46
+ sources = [_source(tmp_path, region) for region in ("a", "b")]
47
+ output = tmp_path / "out" / "merged.parquet"
48
+ _capture_log()
49
+
50
+ Converter().convert(str(output), input_files={str(s): s.name for s in sources})
51
+
52
+ merged = gpd.read_parquet(output)
53
+ assert sorted(merged["id"]) == ["a-1", "a-2", "b-1", "b-2"]
54
+ # constant within each part, so it only survives the merge as a column
55
+ assert sorted(merged["region"].unique()) == ["a", "b"]
56
+ assert [p.name for p in output.parent.iterdir()] == ["merged.parquet"] # the parts are gone
57
+
58
+ log = capsys.readouterr().out
59
+ assert log.count("Using user provided input file(s)") == 1
60
+ assert log.count("No variant given") == 1
61
+
62
+
63
+ def test_a_single_source_is_converted_directly(tmp_path):
64
+ source = _source(tmp_path, "a")
65
+ output = tmp_path / "single.parquet"
66
+
67
+ Converter().convert(str(output), input_files={str(source): source.name})
68
+
69
+ assert sorted(gpd.read_parquet(output)["id"]) == ["a-1", "a-2"]
70
+
71
+
72
+ def test_the_volumes_of_a_7z_archive_are_converted_together(tmp_path):
73
+ archived = _source(tmp_path, "b")
74
+ measure = io.BytesIO()
75
+ with py7zr.SevenZipFile(measure, "w") as archive:
76
+ archive.write(archived, archived.name)
77
+ volume_size = len(measure.getvalue()) // 3 + 1
78
+ with multivolumefile.MultiVolume(
79
+ str(tmp_path / "b.7z"), mode="wb", volume=volume_size, ext_digits=3
80
+ ) as volume:
81
+ with py7zr.SevenZipFile(volume, "w") as archive:
82
+ archive.write(archived, archived.name)
83
+ volumes = sorted(tmp_path.glob("b.7z.*"))
84
+ assert len(volumes) > 1, "the archive must span several volumes for this test"
85
+ archived.unlink()
86
+
87
+ source = _source(tmp_path, "a")
88
+ output = tmp_path / "merged.parquet"
89
+ # only the first volume names the target file
90
+ input_files = {str(source): source.name}
91
+ input_files |= {str(v): ([archived.name] if i == 0 else []) for i, v in enumerate(volumes)}
92
+
93
+ Converter().convert(str(output), input_files=input_files)
94
+
95
+ assert sorted(gpd.read_parquet(output)["id"]) == ["a-1", "a-2", "b-1", "b-2"]
@@ -22,6 +22,7 @@ import py7zr
22
22
  import rarfile
23
23
  import shapely
24
24
  from fsspec import AbstractFileSystem
25
+ from fsspec.exceptions import FSTimeoutError
25
26
  from fsspec.implementations.local import LocalFileSystem
26
27
  from geopandas import GeoDataFrame
27
28
 
@@ -33,7 +34,7 @@ from ..vecorel.collection import Collection
33
34
  from ..vecorel.hilbert import hilbert_sort_geodataframe
34
35
  from ..vecorel.schemas import Schemas
35
36
  from ..vecorel.typing import Sources
36
- from ..vecorel.util import get_fs, name_from_uri, stream_file, suffix_duplicate_ids
37
+ from ..vecorel.util import get_fs, merge_headers, name_from_uri, stream_file, suffix_duplicate_ids
37
38
  from .deflate64 import deflate64_zip
38
39
  from .flatdict import FlatDict
39
40
 
@@ -58,6 +59,8 @@ class BaseConverter(LoggerMixin):
58
59
  data_access: str = ""
59
60
  open_options: dict = {}
60
61
  avoid_range_request: bool = False
62
+ # Sent with every download, e.g. a browser User-Agent for a server that refuses others
63
+ request_headers: dict[str, str] = {}
61
64
  variants: dict[str, Sources] = {}
62
65
  variant: Optional[str] = None
63
66
 
@@ -365,6 +368,11 @@ class BaseConverter(LoggerMixin):
365
368
 
366
369
  if self.avoid_range_request and "block_size" not in kwargs:
367
370
  kwargs["block_size"] = 0
371
+ if self.request_headers:
372
+ # headers passed in explicitly win over the converter's
373
+ client_kwargs = kwargs.get("client_kwargs") or {}
374
+ headers = merge_headers(self.request_headers, client_kwargs.get("headers", {}))
375
+ kwargs["client_kwargs"] = {**client_kwargs, "headers": headers}
368
376
 
369
377
  # Multi-volume 7z archives (.7z.001, .7z.002, ...) are one 7z stream split
370
378
  # across several URIs; they must be downloaded and extracted together.
@@ -436,11 +444,13 @@ class BaseConverter(LoggerMixin):
436
444
  try:
437
445
  with cache_fs.open(part_file, mode="wb") as file:
438
446
  stream_file(source_fs, uri, file)
439
- except BaseException:
447
+ except BaseException as e:
440
448
  try:
441
449
  cache_fs.rm(part_file)
442
450
  except FileNotFoundError:
443
451
  pass
452
+ if isinstance(e, FSTimeoutError):
453
+ raise TimeoutError(f"Download timed out: {uri}") from e
444
454
  raise
445
455
  cache_fs.mv(part_file, cache_file)
446
456
 
@@ -506,6 +516,15 @@ class BaseConverter(LoggerMixin):
506
516
  raise ValueError(f"Unknown variant '{self.variant}', choose from {opts}")
507
517
  return urls
508
518
 
519
+ def get_input_urls(self, input_files=None):
520
+ if input_files is not None and isinstance(input_files, dict) and len(input_files) > 0:
521
+ self.warning("Using user provided input file(s) instead of the pre-defined file(s)")
522
+ return input_files
523
+ urls = self.get_urls()
524
+ if urls is None:
525
+ raise ValueError("No input files provided")
526
+ return urls
527
+
509
528
  def get_data(
510
529
  self, paths: list[tuple[str, str]], **kwargs
511
530
  ) -> Generator[tuple[GeoDataFrame, str, str, Optional[str]]]:
@@ -648,13 +667,7 @@ class BaseConverter(LoggerMixin):
648
667
  if directory:
649
668
  os.makedirs(directory, exist_ok=True)
650
669
 
651
- if input_files is not None and isinstance(input_files, dict) and len(input_files) > 0:
652
- self.warning("Using user provided input file(s) instead of the pre-defined file(s)")
653
- urls = input_files
654
- else:
655
- urls = self.get_urls()
656
- if urls is None:
657
- raise ValueError("No input files provided")
670
+ urls = self.get_input_urls(input_files)
658
671
 
659
672
  self.info("Getting file(s) if not cached yet")
660
673
  paths = self.download_files(urls, cache)
@@ -21,7 +21,7 @@ from ..vecorel.ops import (
21
21
  report,
22
22
  warn_missing_required,
23
23
  )
24
- from ..vecorel.util import find_differing_crs
24
+ from ..vecorel.util import find_differing_crs, name_from_uri
25
25
  from .base import BaseConverter
26
26
 
27
27
 
@@ -32,6 +32,15 @@ def _sql_path(path) -> str:
32
32
  return f"'{escaped}'"
33
33
 
34
34
 
35
+ def _readable_in_place(urls) -> bool:
36
+ if isinstance(urls, str):
37
+ urls = {urls: name_from_uri(urls)}
38
+ return all(
39
+ isinstance(target, str) and target.lower().endswith((".parquet", ".geoparquet"))
40
+ for target in urls.values()
41
+ )
42
+
43
+
35
44
  def _sql_name(name) -> str:
36
45
  escaped = str(name).replace('"', '""')
37
46
  return f'"{escaped}"'
@@ -107,16 +116,12 @@ class DuckDBBaseConverter(BaseConverter):
107
116
  if directory:
108
117
  os.makedirs(directory, exist_ok=True)
109
118
 
110
- if input_files is not None and isinstance(input_files, dict) and len(input_files) > 0:
111
- self.warning("Using user provided input file(s) instead of the pre-defined file(s)")
112
- urls = input_files
113
- else:
114
- urls = self.get_urls()
115
- if urls is None:
116
- raise ValueError("No input files provided")
119
+ urls = self.get_input_urls(input_files)
117
120
 
118
121
  self.info("Getting file(s) if not cached yet")
119
- if cache:
122
+ # Without a cache DuckDB reads remote parquet files in place; anything else, such as
123
+ # an archive, is downloaded to a temporary folder as the GeoDataFrame codepath does
124
+ if cache or not _readable_in_place(urls):
120
125
  request_args = {}
121
126
  if self.avoid_range_request:
122
127
  request_args["block_size"] = 0
@@ -140,7 +145,8 @@ class DuckDBBaseConverter(BaseConverter):
140
145
  available = {
141
146
  row[0]: row[1]
142
147
  for row in con.execute(
143
- "DESCRIBE SELECT * FROM read_parquet(?, union_by_name=true)", [sources]
148
+ "DESCRIBE SELECT * FROM read_parquet(?, union_by_name=true, hive_partitioning=false)",
149
+ [sources],
144
150
  ).fetchall()
145
151
  }
146
152
 
@@ -190,7 +196,7 @@ class DuckDBBaseConverter(BaseConverter):
190
196
  # migrated type so integer-valued floats format the same way
191
197
  expr = f"({self.column_migrations[c]})"
192
198
  sql_type = con.execute(
193
- f"DESCRIBE SELECT {expr} AS part FROM read_parquet(?, union_by_name=true)",
199
+ f"DESCRIBE SELECT {expr} AS part FROM read_parquet(?, union_by_name=true, hive_partitioning=false)",
194
200
  [sources],
195
201
  ).fetchone()[1]
196
202
  parts.append(self._stringify_sql(expr, sql_type, c))
@@ -260,7 +266,7 @@ class DuckDBBaseConverter(BaseConverter):
260
266
  sources_sql = "[" + ",".join(_sql_path(path) for path in sources) + "]"
261
267
  source_query = f"""
262
268
  SELECT {selection}
263
- FROM read_parquet({sources_sql}, union_by_name=true)
269
+ FROM read_parquet({sources_sql}, union_by_name=true, hive_partitioning=false)
264
270
  {where}
265
271
  """
266
272
 
@@ -356,6 +362,8 @@ class DuckDBBaseConverter(BaseConverter):
356
362
  compression = compression or "zstd"
357
363
  if compression == "zstd" and compression_level is None:
358
364
  compression_level = 15
365
+ # DuckDB calls "none" "uncompressed"; the postprocess (Arrow) takes the original name
366
+ copy_compression = "uncompressed" if compression == "none" else compression
359
367
  geoparquet_version = geoparquet_version or "1.1.0"
360
368
  row_group_size = GeoParquet.row_group_size
361
369
  if isinstance(output_file, Path):
@@ -549,12 +557,12 @@ class DuckDBBaseConverter(BaseConverter):
549
557
  }}
550
558
  )
551
559
  """,
552
- [output_file, compression, collection_json],
560
+ [output_file, copy_compression, collection_json],
553
561
  )
554
562
 
555
563
  if "id" in selected_targets and suffix_duplicate_ids:
556
564
  self._suffix_duplicate_ids_in_file(
557
- con, output_file, compression, collection_json, row_group_size, per_collection
565
+ con, output_file, copy_compression, collection_json, row_group_size, per_collection
558
566
  )
559
567
 
560
568
  # Sort against the same CRS-derived Hilbert grid as the
@@ -579,7 +587,7 @@ class DuckDBBaseConverter(BaseConverter):
579
587
  con,
580
588
  output_file,
581
589
  keys_path,
582
- compression,
590
+ copy_compression,
583
591
  collection_json,
584
592
  final_group_size,
585
593
  )
@@ -618,7 +626,7 @@ class DuckDBBaseConverter(BaseConverter):
618
626
  f"""
619
627
  SELECT coalesce(sum(n), 0), coalesce(sum(n) FILTER (WHERE n > 1), 0)
620
628
  FROM (
621
- SELECT count(*) AS n FROM read_parquet({_sql_path(output_file)})
629
+ SELECT count(*) AS n FROM read_parquet({_sql_path(output_file)}, hive_partitioning=false)
622
630
  WHERE "id" IS NOT NULL GROUP BY {key}
623
631
  )
624
632
  """
@@ -648,7 +656,7 @@ class DuckDBBaseConverter(BaseConverter):
648
656
  AS VARCHAR)
649
657
  ELSE CAST("id" AS VARCHAR)
650
658
  END AS "id")
651
- FROM read_parquet({_sql_path(output_file)}, file_row_number=true)
659
+ FROM read_parquet({_sql_path(output_file)}, file_row_number=true, hive_partitioning=false)
652
660
  ) TO ? (
653
661
  FORMAT parquet,
654
662
  ROW_GROUP_SIZE {row_group_size},
@@ -765,7 +773,7 @@ class DuckDBBaseConverter(BaseConverter):
765
773
  columns.append(_sql_name(name))
766
774
  per_part = per_part or coalesce or bool(constants)
767
775
  query = f"SELECT {', '.join(columns)}"
768
- source = f"read_parquet({_sql_path(path)})"
776
+ source = f"read_parquet({_sql_path(path)}, hive_partitioning=false)"
769
777
  if constants:
770
778
  # A registered table rather than SQL literals, so arrays, objects and
771
779
  # temporal values keep their types
@@ -783,7 +791,7 @@ class DuckDBBaseConverter(BaseConverter):
783
791
  # nothing to add to any part, so a single scan over all of them
784
792
  sources = "[" + ",".join(_sql_path(path) for path in paths) + "]"
785
793
  columns = ", ".join(_sql_name(name) for name in union)
786
- source_query = f"SELECT {columns} FROM read_parquet({sources}, union_by_name=true)"
794
+ source_query = f"SELECT {columns} FROM read_parquet({sources}, union_by_name=true, hive_partitioning=false)"
787
795
  targets = [row[0] for row in con.execute(f"DESCRIBE {source_query}").fetchall()]
788
796
 
789
797
  return self.write_query(
@@ -850,10 +858,10 @@ class DuckDBBaseConverter(BaseConverter):
850
858
  ):
851
859
  directory = os.path.dirname(output_file) or "."
852
860
  tmp_path = None
853
- query = f"SELECT d.* FROM read_parquet({_sql_path(output_file)}) d"
861
+ query = f"SELECT d.* FROM read_parquet({_sql_path(output_file)}, hive_partitioning=false) d"
854
862
  if keys_path:
855
863
  query += f"""
856
- POSITIONAL JOIN read_parquet({_sql_path(keys_path)}) k
864
+ POSITIONAL JOIN read_parquet({_sql_path(keys_path)}, hive_partitioning=false) k
857
865
  ORDER BY k.hilbert, k.ordinal
858
866
  """
859
867
  try:
@@ -0,0 +1,53 @@
1
+ import os
2
+ from tempfile import TemporaryDirectory
3
+
4
+ from .duckdb import DuckDBBaseConverter
5
+
6
+
7
+ class PerFileConverterMixin:
8
+ """Converts each source on its own and merges the parts, for sources that do not fit
9
+ into memory together. The ids must be unique across the sources, and the parts need
10
+ about the size of the output in free disk space next to it."""
11
+
12
+ _part_urls = None
13
+
14
+ def get_input_urls(self, input_files=None):
15
+ if self._part_urls is not None:
16
+ return self._part_urls
17
+ return super().get_input_urls(input_files)
18
+
19
+ def convert(self, output_file, cache=None, input_files=None, variant=None, **kwargs) -> str:
20
+ self.select_variant(variant)
21
+ urls = self.get_input_urls(input_files)
22
+ sources = self._split_sources(urls)
23
+ # the variant is passed on as selected, so that its default is not reported per part
24
+ try:
25
+ if len(sources) <= 1:
26
+ self._part_urls = urls
27
+ return super().convert(output_file, cache=cache, variant=self.variant, **kwargs)
28
+
29
+ directory = os.path.dirname(output_file) or "."
30
+ os.makedirs(directory, exist_ok=True)
31
+ with TemporaryDirectory(dir=directory) as parts_folder:
32
+ parts = []
33
+ for index, source in enumerate(sources):
34
+ self.info(f"Converting source {index + 1}/{len(sources)}: {next(iter(source))}")
35
+ part = os.path.join(parts_folder, f"part_{index}.parquet")
36
+ self._part_urls = source
37
+ super().convert(part, cache=cache, variant=self.variant, **kwargs)
38
+ parts.append(part)
39
+
40
+ self.info(f"Merging {len(parts)} parts into {output_file}")
41
+ merge_options = ("compression", "compression_level", "geoparquet_version")
42
+ return DuckDBBaseConverter().merge_parquet(
43
+ parts, output_file, **{k: kwargs[k] for k in merge_options if k in kwargs}
44
+ )
45
+ finally:
46
+ self._part_urls = None
47
+
48
+ def _split_sources(self, urls) -> list:
49
+ if isinstance(urls, str):
50
+ return [urls]
51
+ # the volumes of a multi-volume 7z archive can only be extracted together
52
+ regular, volume_groups = self._group_multivolume_7z(urls)
53
+ return [{uri: target} for uri, target in regular.items()] + list(volume_groups.values())
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from typing import Optional, Union
6
6
  from urllib.parse import urlparse
7
7
 
8
+ import aiohttp
8
9
  import yaml
9
10
  from fsspec import AbstractFileSystem
10
11
  from fsspec.implementations.http import HTTPFileSystem
@@ -120,6 +121,21 @@ def _expected_content_length(f) -> Optional[int]:
120
121
  return None
121
122
 
122
123
 
124
+ def merge_headers(*headers: dict) -> dict:
125
+ """Merge HTTP headers; names are case-insensitive, and a later one replaces an earlier."""
126
+ merged = {}
127
+ for group in headers:
128
+ for name, value in group.items():
129
+ for existing in [k for k in merged if k.lower() == name.lower()]:
130
+ del merged[existing]
131
+ merged[name] = value
132
+ return merged
133
+
134
+
135
+ HTTP_CONNECT_TIMEOUT = 60
136
+ HTTP_READ_TIMEOUT = 300
137
+
138
+
123
139
  def get_fs(url_or_path: Union[str, Path, URL], **kwargs) -> AbstractFileSystem:
124
140
  """Choose fsspec filesystem by sniffing input url"""
125
141
  if isinstance(url_or_path, Path):
@@ -130,8 +146,13 @@ def get_fs(url_or_path: Union[str, Path, URL], **kwargs) -> AbstractFileSystem:
130
146
 
131
147
  if parsed.scheme in ("http", "https"):
132
148
  client_kwargs = kwargs.pop("client_kwargs", {})
133
- headers = {"User-Agent": USER_AGENT, **client_kwargs.get("headers", {})}
134
- return HTTPFileSystem(client_kwargs={**client_kwargs, "headers": headers}, **kwargs)
149
+ headers = merge_headers({"User-Agent": USER_AGENT}, client_kwargs.get("headers", {}))
150
+ timeout = client_kwargs.get("timeout") or aiohttp.ClientTimeout(
151
+ total=None, sock_connect=HTTP_CONNECT_TIMEOUT, sock_read=HTTP_READ_TIMEOUT
152
+ )
153
+ return HTTPFileSystem(
154
+ client_kwargs={**client_kwargs, "headers": headers, "timeout": timeout}, **kwargs
155
+ )
135
156
 
136
157
  if parsed.scheme == "s3":
137
158
  from s3fs import S3FileSystem
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vecorel-cli
3
- Version: 0.4.0
3
+ Version: 0.4.2
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,6 +18,7 @@ tests/test_improve.py
18
18
  tests/test_jsonschema.py
19
19
  tests/test_merge.py
20
20
  tests/test_ops.py
21
+ tests/test_per_file.py
21
22
  tests/test_rename_extension.py
22
23
  tests/test_validate.py
23
24
  tests/test_validate_schema.py
@@ -55,6 +56,7 @@ vecorel_cli/conversion/base.py
55
56
  vecorel_cli/conversion/deflate64.py
56
57
  vecorel_cli/conversion/duckdb.py
57
58
  vecorel_cli/conversion/flatdict.py
59
+ vecorel_cli/conversion/per_file.py
58
60
  vecorel_cli/datasets/__init__.py
59
61
  vecorel_cli/datasets/template.py
60
62
  vecorel_cli/encoding/__init__.py
File without changes
File without changes
File without changes