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.
- {vecorel_cli-0.4.0/vecorel_cli.egg-info → vecorel_cli-0.4.2}/PKG-INFO +1 -1
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/pyproject.toml +1 -1
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_convert_duckdb.py +58 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_download.py +54 -1
- vecorel_cli-0.4.2/tests/test_per_file.py +95 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/base.py +22 -9
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/duckdb.py +29 -21
- vecorel_cli-0.4.2/vecorel_cli/conversion/per_file.py +53 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/util.py +23 -2
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2/vecorel_cli.egg-info}/PKG-INFO +1 -1
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli.egg-info/SOURCES.txt +2 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/LICENSE +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/README.md +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/setup.cfg +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_convert.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_converters.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_create_geojson.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_create_geoparquet.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_create_stac.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_deflate64.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_describe.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_encoding_auto.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_encoding_geojson.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_encoding_geoparquet.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_hilbert.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_improve.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_jsonschema.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_merge.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_ops.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_rename_extension.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_validate.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/tests/test_validate_schema.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/__init__.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/basecommand.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/__init__.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/logger.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/options.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/path_url.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/setup.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/cli/util.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/const.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/__init__.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/admin.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/deflate64.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/conversion/flatdict.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/convert.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/converters.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/create_geojson.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/create_geoparquet.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/create_jsonschema.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/create_stac.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/datasets/__init__.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/datasets/template.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/describe.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/__init__.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/auto.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/base.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/geojson.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/encoding/geoparquet.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/improve.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/jsonschema/__init__.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/jsonschema/template.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/merge.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/parquet/__init__.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/parquet/geopandas.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/parquet/types.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/registry.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/rename_extension.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validate.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validate_schema.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/__init__.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/base.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/data.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/geojson.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/validation/geoparquet.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/__init__.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/collection.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/extensions.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/hilbert.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/ops.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/schemas.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/typing.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli/vecorel/version.py +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli.egg-info/dependency_links.txt +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli.egg-info/entry_points.txt +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli.egg-info/requires.txt +0 -0
- {vecorel_cli-0.4.0 → vecorel_cli-0.4.2}/vecorel_cli.egg-info/top_level.txt +0 -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)",
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
134
|
-
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|