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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {vecorel_cli-0.4.0/vecorel_cli.egg-info → vecorel_cli-0.4.1}/PKG-INFO +1 -1
  2. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/pyproject.toml +1 -1
  3. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_convert_duckdb.py +43 -0
  4. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_download.py +54 -1
  5. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/conversion/base.py +8 -1
  6. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/conversion/duckdb.py +23 -11
  7. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/util.py +12 -1
  8. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1/vecorel_cli.egg-info}/PKG-INFO +1 -1
  9. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/LICENSE +0 -0
  10. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/README.md +0 -0
  11. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/setup.cfg +0 -0
  12. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_convert.py +0 -0
  13. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_converters.py +0 -0
  14. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_create_geojson.py +0 -0
  15. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_create_geoparquet.py +0 -0
  16. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_create_stac.py +0 -0
  17. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_deflate64.py +0 -0
  18. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_describe.py +0 -0
  19. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_encoding_auto.py +0 -0
  20. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_encoding_geojson.py +0 -0
  21. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_encoding_geoparquet.py +0 -0
  22. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_hilbert.py +0 -0
  23. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_improve.py +0 -0
  24. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_jsonschema.py +0 -0
  25. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_merge.py +0 -0
  26. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_ops.py +0 -0
  27. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_rename_extension.py +0 -0
  28. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_validate.py +0 -0
  29. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/tests/test_validate_schema.py +0 -0
  30. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/__init__.py +0 -0
  31. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/basecommand.py +0 -0
  32. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/cli/__init__.py +0 -0
  33. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/cli/logger.py +0 -0
  34. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/cli/options.py +0 -0
  35. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/cli/path_url.py +0 -0
  36. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/cli/setup.py +0 -0
  37. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/cli/util.py +0 -0
  38. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/const.py +0 -0
  39. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/conversion/__init__.py +0 -0
  40. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/conversion/admin.py +0 -0
  41. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/conversion/deflate64.py +0 -0
  42. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/conversion/flatdict.py +0 -0
  43. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/convert.py +0 -0
  44. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/converters.py +0 -0
  45. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/create_geojson.py +0 -0
  46. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/create_geoparquet.py +0 -0
  47. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/create_jsonschema.py +0 -0
  48. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/create_stac.py +0 -0
  49. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/datasets/__init__.py +0 -0
  50. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/datasets/template.py +0 -0
  51. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/describe.py +0 -0
  52. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/encoding/__init__.py +0 -0
  53. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/encoding/auto.py +0 -0
  54. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/encoding/base.py +0 -0
  55. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/encoding/geojson.py +0 -0
  56. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/encoding/geoparquet.py +0 -0
  57. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/improve.py +0 -0
  58. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/jsonschema/__init__.py +0 -0
  59. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/jsonschema/template.py +0 -0
  60. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/merge.py +0 -0
  61. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/parquet/__init__.py +0 -0
  62. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/parquet/geopandas.py +0 -0
  63. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/parquet/types.py +0 -0
  64. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/registry.py +0 -0
  65. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/rename_extension.py +0 -0
  66. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/validate.py +0 -0
  67. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/validate_schema.py +0 -0
  68. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/validation/__init__.py +0 -0
  69. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/validation/base.py +0 -0
  70. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/validation/data.py +0 -0
  71. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/validation/geojson.py +0 -0
  72. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/validation/geoparquet.py +0 -0
  73. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/__init__.py +0 -0
  74. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/collection.py +0 -0
  75. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/extensions.py +0 -0
  76. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/hilbert.py +0 -0
  77. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/ops.py +0 -0
  78. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/schemas.py +0 -0
  79. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/typing.py +0 -0
  80. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli/vecorel/version.py +0 -0
  81. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/SOURCES.txt +0 -0
  82. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/dependency_links.txt +0 -0
  83. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/entry_points.txt +0 -0
  84. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/requires.txt +0 -0
  85. {vecorel_cli-0.4.0 → vecorel_cli-0.4.1}/vecorel_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vecorel-cli
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: CLI tools such as validation and file format conversion for vecorel.
5
5
  Author: Matthias Mohr
6
6
  License-Expression: Apache-2.0
@@ -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.1"
8
8
  description = "CLI tools such as validation and file format conversion for vecorel."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,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
@@ -109,6 +110,48 @@ def test_duckdb_converter(tmp_folder):
109
110
  assert validation.errors == []
110
111
 
111
112
 
113
+ def test_duckdb_converter_extracts_an_archive_without_a_cache(tmp_folder):
114
+ src = _source_file(tmp_folder)
115
+ archive = tmp_folder / "source.zip"
116
+ with zipfile.ZipFile(archive, "w") as zf:
117
+ zf.write(src, "source.parquet")
118
+ dest = tmp_folder / "converted.parquet"
119
+
120
+ Converter().convert(dest, input_files={str(archive): ["source.parquet"]})
121
+
122
+ assert sorted(gpd.read_parquet(dest)["id"]) == ["bowtie", "multi", "square", "with_z"]
123
+
124
+
125
+ def test_duckdb_converter_ignores_hive_style_folders(tmp_folder):
126
+ source_folder = tmp_folder / "region=north"
127
+ source_folder.mkdir()
128
+ src = _source_file(source_folder)
129
+ dest_folder = tmp_folder / "year=2024"
130
+ dest_folder.mkdir()
131
+ dest = dest_folder / "converted.parquet"
132
+
133
+ Converter().convert(dest, input_files={src: "source.parquet"})
134
+
135
+ with pq.ParquetFile(dest) as pf:
136
+ names = pf.schema_arrow.names
137
+ assert "year" not in names
138
+ assert "region" not in names
139
+
140
+
141
+ @pytest.mark.parametrize(
142
+ "urls, in_place",
143
+ [
144
+ ("https://host/data.parquet?token=abc", True),
145
+ ("https://host/data.geoparquet", True),
146
+ ({"https://host/x?id=1": "fields.parquet"}, True),
147
+ ({"https://host/data.zip": ["fields.parquet"]}, False),
148
+ ("https://host/data.fgb", False),
149
+ ],
150
+ )
151
+ def test_only_parquet_is_read_in_place(urls, in_place):
152
+ assert duckdb_module._readable_in_place(urls) is in_place
153
+
154
+
112
155
  def test_duckdb_converter_numbers_rows_without_an_id_mapping(tmp_folder):
113
156
  src = _source_file(tmp_folder)
114
157
  dest = tmp_folder / "converted.parquet"
@@ -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"}
@@ -33,7 +33,7 @@ from ..vecorel.collection import Collection
33
33
  from ..vecorel.hilbert import hilbert_sort_geodataframe
34
34
  from ..vecorel.schemas import Schemas
35
35
  from ..vecorel.typing import Sources
36
- from ..vecorel.util import get_fs, name_from_uri, stream_file, suffix_duplicate_ids
36
+ from ..vecorel.util import get_fs, merge_headers, name_from_uri, stream_file, suffix_duplicate_ids
37
37
  from .deflate64 import deflate64_zip
38
38
  from .flatdict import FlatDict
39
39
 
@@ -58,6 +58,8 @@ class BaseConverter(LoggerMixin):
58
58
  data_access: str = ""
59
59
  open_options: dict = {}
60
60
  avoid_range_request: bool = False
61
+ # Sent with every download, e.g. a browser User-Agent for a server that refuses others
62
+ request_headers: dict[str, str] = {}
61
63
  variants: dict[str, Sources] = {}
62
64
  variant: Optional[str] = None
63
65
 
@@ -365,6 +367,11 @@ class BaseConverter(LoggerMixin):
365
367
 
366
368
  if self.avoid_range_request and "block_size" not in kwargs:
367
369
  kwargs["block_size"] = 0
370
+ if self.request_headers:
371
+ # headers passed in explicitly win over the converter's
372
+ client_kwargs = kwargs.get("client_kwargs") or {}
373
+ headers = merge_headers(self.request_headers, client_kwargs.get("headers", {}))
374
+ kwargs["client_kwargs"] = {**client_kwargs, "headers": headers}
368
375
 
369
376
  # Multi-volume 7z archives (.7z.001, .7z.002, ...) are one 7z stream split
370
377
  # across several URIs; they must be downloaded and extracted together.
@@ -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}"'
@@ -116,7 +125,9 @@ class DuckDBBaseConverter(BaseConverter):
116
125
  raise ValueError("No input files provided")
117
126
 
118
127
  self.info("Getting file(s) if not cached yet")
119
- if cache:
128
+ # Without a cache DuckDB reads remote parquet files in place; anything else, such as
129
+ # an archive, is downloaded to a temporary folder as the GeoDataFrame codepath does
130
+ if cache or not _readable_in_place(urls):
120
131
  request_args = {}
121
132
  if self.avoid_range_request:
122
133
  request_args["block_size"] = 0
@@ -140,7 +151,8 @@ class DuckDBBaseConverter(BaseConverter):
140
151
  available = {
141
152
  row[0]: row[1]
142
153
  for row in con.execute(
143
- "DESCRIBE SELECT * FROM read_parquet(?, union_by_name=true)", [sources]
154
+ "DESCRIBE SELECT * FROM read_parquet(?, union_by_name=true, hive_partitioning=false)",
155
+ [sources],
144
156
  ).fetchall()
145
157
  }
146
158
 
@@ -190,7 +202,7 @@ class DuckDBBaseConverter(BaseConverter):
190
202
  # migrated type so integer-valued floats format the same way
191
203
  expr = f"({self.column_migrations[c]})"
192
204
  sql_type = con.execute(
193
- f"DESCRIBE SELECT {expr} AS part FROM read_parquet(?, union_by_name=true)",
205
+ f"DESCRIBE SELECT {expr} AS part FROM read_parquet(?, union_by_name=true, hive_partitioning=false)",
194
206
  [sources],
195
207
  ).fetchone()[1]
196
208
  parts.append(self._stringify_sql(expr, sql_type, c))
@@ -260,7 +272,7 @@ class DuckDBBaseConverter(BaseConverter):
260
272
  sources_sql = "[" + ",".join(_sql_path(path) for path in sources) + "]"
261
273
  source_query = f"""
262
274
  SELECT {selection}
263
- FROM read_parquet({sources_sql}, union_by_name=true)
275
+ FROM read_parquet({sources_sql}, union_by_name=true, hive_partitioning=false)
264
276
  {where}
265
277
  """
266
278
 
@@ -618,7 +630,7 @@ class DuckDBBaseConverter(BaseConverter):
618
630
  f"""
619
631
  SELECT coalesce(sum(n), 0), coalesce(sum(n) FILTER (WHERE n > 1), 0)
620
632
  FROM (
621
- SELECT count(*) AS n FROM read_parquet({_sql_path(output_file)})
633
+ SELECT count(*) AS n FROM read_parquet({_sql_path(output_file)}, hive_partitioning=false)
622
634
  WHERE "id" IS NOT NULL GROUP BY {key}
623
635
  )
624
636
  """
@@ -648,7 +660,7 @@ class DuckDBBaseConverter(BaseConverter):
648
660
  AS VARCHAR)
649
661
  ELSE CAST("id" AS VARCHAR)
650
662
  END AS "id")
651
- FROM read_parquet({_sql_path(output_file)}, file_row_number=true)
663
+ FROM read_parquet({_sql_path(output_file)}, file_row_number=true, hive_partitioning=false)
652
664
  ) TO ? (
653
665
  FORMAT parquet,
654
666
  ROW_GROUP_SIZE {row_group_size},
@@ -765,7 +777,7 @@ class DuckDBBaseConverter(BaseConverter):
765
777
  columns.append(_sql_name(name))
766
778
  per_part = per_part or coalesce or bool(constants)
767
779
  query = f"SELECT {', '.join(columns)}"
768
- source = f"read_parquet({_sql_path(path)})"
780
+ source = f"read_parquet({_sql_path(path)}, hive_partitioning=false)"
769
781
  if constants:
770
782
  # A registered table rather than SQL literals, so arrays, objects and
771
783
  # temporal values keep their types
@@ -783,7 +795,7 @@ class DuckDBBaseConverter(BaseConverter):
783
795
  # nothing to add to any part, so a single scan over all of them
784
796
  sources = "[" + ",".join(_sql_path(path) for path in paths) + "]"
785
797
  columns = ", ".join(_sql_name(name) for name in union)
786
- source_query = f"SELECT {columns} FROM read_parquet({sources}, union_by_name=true)"
798
+ source_query = f"SELECT {columns} FROM read_parquet({sources}, union_by_name=true, hive_partitioning=false)"
787
799
  targets = [row[0] for row in con.execute(f"DESCRIBE {source_query}").fetchall()]
788
800
 
789
801
  return self.write_query(
@@ -850,10 +862,10 @@ class DuckDBBaseConverter(BaseConverter):
850
862
  ):
851
863
  directory = os.path.dirname(output_file) or "."
852
864
  tmp_path = None
853
- query = f"SELECT d.* FROM read_parquet({_sql_path(output_file)}) d"
865
+ query = f"SELECT d.* FROM read_parquet({_sql_path(output_file)}, hive_partitioning=false) d"
854
866
  if keys_path:
855
867
  query += f"""
856
- POSITIONAL JOIN read_parquet({_sql_path(keys_path)}) k
868
+ POSITIONAL JOIN read_parquet({_sql_path(keys_path)}, hive_partitioning=false) k
857
869
  ORDER BY k.hilbert, k.ordinal
858
870
  """
859
871
  try:
@@ -120,6 +120,17 @@ def _expected_content_length(f) -> Optional[int]:
120
120
  return None
121
121
 
122
122
 
123
+ def merge_headers(*headers: dict) -> dict:
124
+ """Merge HTTP headers; names are case-insensitive, and a later one replaces an earlier."""
125
+ merged = {}
126
+ for group in headers:
127
+ for name, value in group.items():
128
+ for existing in [k for k in merged if k.lower() == name.lower()]:
129
+ del merged[existing]
130
+ merged[name] = value
131
+ return merged
132
+
133
+
123
134
  def get_fs(url_or_path: Union[str, Path, URL], **kwargs) -> AbstractFileSystem:
124
135
  """Choose fsspec filesystem by sniffing input url"""
125
136
  if isinstance(url_or_path, Path):
@@ -130,7 +141,7 @@ def get_fs(url_or_path: Union[str, Path, URL], **kwargs) -> AbstractFileSystem:
130
141
 
131
142
  if parsed.scheme in ("http", "https"):
132
143
  client_kwargs = kwargs.pop("client_kwargs", {})
133
- headers = {"User-Agent": USER_AGENT, **client_kwargs.get("headers", {})}
144
+ headers = merge_headers({"User-Agent": USER_AGENT}, client_kwargs.get("headers", {}))
134
145
  return HTTPFileSystem(client_kwargs={**client_kwargs, "headers": headers}, **kwargs)
135
146
 
136
147
  if parsed.scheme == "s3":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vecorel-cli
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: CLI tools such as validation and file format conversion for vecorel.
5
5
  Author: Matthias Mohr
6
6
  License-Expression: Apache-2.0
File without changes
File without changes
File without changes