avrokit 0.1.0__tar.gz → 0.1.3__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.
- {avrokit-0.1.0 → avrokit-0.1.3}/PKG-INFO +1 -1
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/httpserver.py +4 -1
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/tojson.py +14 -1
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/url/base.py +34 -2
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/url/file.py +10 -14
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/url/google.py +11 -2
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/url/http.py +1 -1
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/url/s3.py +4 -2
- {avrokit-0.1.0 → avrokit-0.1.3}/pyproject.toml +1 -1
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_tools_httpserver.py +29 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_tools_integration.py +70 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_url_file.py +13 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_url_google.py +67 -1
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_url_s3.py +19 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/uv.lock +1 -1
- {avrokit-0.1.0 → avrokit-0.1.3}/.github/workflows/check.yml +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/.github/workflows/release.yml +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/.gitignore +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/LICENSE +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/LICENSES/Apache-2.0.txt +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/Makefile +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/README.md +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/__init__.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/asyncio/__init__.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/asyncio/reader.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/asyncio/writer.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/io/__init__.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/io/compact.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/io/reader.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/io/schema.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/io/writer.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/py.typed +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/__init__.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/__main__.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/base.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/cat.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/concat.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/count.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/filesort.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/fromparquet.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/getmeta.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/getschema.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/partition.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/repair.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/stats.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/tools/toparquet.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/url/__init__.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/url/factory.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/avrokit/url/utils.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/__init__.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_additional_coverage.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_asyncio.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_exports.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_io.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_io_schema.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_plan_coverage.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_property_based.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_schema_advanced.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_tools_repair.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_url.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_url_contract.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/tests/test_url_http.py +0 -0
- {avrokit-0.1.0 → avrokit-0.1.3}/uv.lock.license +0 -0
|
@@ -17,6 +17,7 @@ from avro.io import DatumReader
|
|
|
17
17
|
|
|
18
18
|
from ..io import avro_reader, avro_schema, avro_writer, read_avro_schema
|
|
19
19
|
from ..url import URL, parse_url
|
|
20
|
+
from .tojson import AvroJsonEncoder
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger(__name__)
|
|
22
23
|
|
|
@@ -195,7 +196,9 @@ class AvroHTTPRequestHandler(BaseHTTPRequestHandler):
|
|
|
195
196
|
self.send_header("Content-type", "application/jsonl")
|
|
196
197
|
self.end_headers()
|
|
197
198
|
for record in reader:
|
|
198
|
-
self.wfile.write(
|
|
199
|
+
self.wfile.write(
|
|
200
|
+
json.dumps(record, cls=AvroJsonEncoder).encode("utf-8")
|
|
201
|
+
)
|
|
199
202
|
self.wfile.write(b"\n")
|
|
200
203
|
elif self._is_avro_binary(accept):
|
|
201
204
|
with data_url as f:
|
|
@@ -5,11 +5,24 @@
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import sys
|
|
8
|
+
from datetime import date, datetime, time
|
|
9
|
+
from decimal import Decimal
|
|
8
10
|
|
|
9
11
|
from ..io import avro_reader
|
|
10
12
|
from ..url import flatten_urls, parse_url
|
|
11
13
|
|
|
12
14
|
|
|
15
|
+
class AvroJsonEncoder(json.JSONEncoder):
|
|
16
|
+
"""Encodes Avro logical types (datetime, date, time, decimal) as JSON."""
|
|
17
|
+
|
|
18
|
+
def default(self, o: object) -> object:
|
|
19
|
+
if isinstance(o, datetime | date | time):
|
|
20
|
+
return o.isoformat()
|
|
21
|
+
if isinstance(o, Decimal):
|
|
22
|
+
return str(o)
|
|
23
|
+
return super().default(o)
|
|
24
|
+
|
|
25
|
+
|
|
13
26
|
class ToJsonTool:
|
|
14
27
|
"""
|
|
15
28
|
Dumps Avro data file(s) as JSON, record per line.
|
|
@@ -30,5 +43,5 @@ class ToJsonTool:
|
|
|
30
43
|
for url in urls:
|
|
31
44
|
with avro_reader(url) as reader:
|
|
32
45
|
for record in reader:
|
|
33
|
-
json.dump(record, sys.stdout)
|
|
46
|
+
json.dump(record, sys.stdout, cls=AvroJsonEncoder)
|
|
34
47
|
sys.stdout.write("\n")
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import fnmatch
|
|
7
8
|
from abc import ABC, abstractmethod
|
|
8
9
|
from collections.abc import Sequence
|
|
9
10
|
from typing import IO, Any
|
|
10
|
-
from urllib.parse import urlparse
|
|
11
|
+
from urllib.parse import urlparse, urlunparse
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class URL(ABC):
|
|
@@ -22,13 +23,44 @@ class URL(ABC):
|
|
|
22
23
|
def __str__(self) -> str:
|
|
23
24
|
return self.url
|
|
24
25
|
|
|
25
|
-
@abstractmethod
|
|
26
26
|
def expand(self) -> Sequence[URL]:
|
|
27
27
|
"""
|
|
28
28
|
Expands the URL into all the URLs that represent concrete resources.
|
|
29
|
+
|
|
30
|
+
If the final path element contains glob wildcards, the parent path is
|
|
31
|
+
expanded and the results are filtered client-side with fnmatch.
|
|
32
|
+
"""
|
|
33
|
+
if self._has_glob():
|
|
34
|
+
return self._expand_glob()
|
|
35
|
+
return self._expand()
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def _expand(self) -> Sequence[URL]:
|
|
39
|
+
"""
|
|
40
|
+
Expands the URL into all the URLs that represent concrete resources.
|
|
29
41
|
"""
|
|
30
42
|
...
|
|
31
43
|
|
|
44
|
+
def _has_glob(self) -> bool:
|
|
45
|
+
return any(c in self.parsed_url.path for c in "*?[")
|
|
46
|
+
|
|
47
|
+
def _expand_glob(self) -> Sequence[URL]:
|
|
48
|
+
path = self.parsed_url.path
|
|
49
|
+
wildcard_idx = min(idx for idx in (path.find(c) for c in "*?[") if idx != -1)
|
|
50
|
+
prefix = path[:wildcard_idx]
|
|
51
|
+
parent = self._with_path(prefix)
|
|
52
|
+
return [
|
|
53
|
+
url
|
|
54
|
+
for url in parent.expand()
|
|
55
|
+
if url.url != parent.url and fnmatch.fnmatch(url.parsed_url.path, path)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def _with_path(self, path: str) -> URL:
|
|
59
|
+
new_url = urlunparse(
|
|
60
|
+
(self.parsed_url.scheme, self.parsed_url.netloc, path, "", "", "")
|
|
61
|
+
)
|
|
62
|
+
return type(self)(new_url, mode=self.mode)
|
|
63
|
+
|
|
32
64
|
@abstractmethod
|
|
33
65
|
def delete(self) -> None:
|
|
34
66
|
"""
|
|
@@ -21,20 +21,7 @@ class FileURL(URL):
|
|
|
21
21
|
self.fh: IO[Any] | None = None
|
|
22
22
|
|
|
23
23
|
@override
|
|
24
|
-
def
|
|
25
|
-
# Check if the path contains wildcard characters
|
|
26
|
-
if "*" in self.path or "?" in self.path or "[" in self.path:
|
|
27
|
-
# Use glob to expand the pattern
|
|
28
|
-
matches = glob.glob(self.path)
|
|
29
|
-
if not matches:
|
|
30
|
-
# No matches found, return empty list
|
|
31
|
-
return []
|
|
32
|
-
# Return sorted list of FileURL objects for all matches
|
|
33
|
-
return [
|
|
34
|
-
FileURL(self.prefix + match, mode=self.mode)
|
|
35
|
-
for match in sorted(matches)
|
|
36
|
-
]
|
|
37
|
-
|
|
24
|
+
def _expand(self) -> Sequence[URL]:
|
|
38
25
|
# If it's a regular file or doesn't exist (no expansion needed)
|
|
39
26
|
if os.path.isfile(self.path) or not os.path.exists(self.path):
|
|
40
27
|
return [self]
|
|
@@ -48,6 +35,15 @@ class FileURL(URL):
|
|
|
48
35
|
)
|
|
49
36
|
return acc
|
|
50
37
|
|
|
38
|
+
@override
|
|
39
|
+
def _expand_glob(self) -> Sequence[URL]:
|
|
40
|
+
matches = glob.glob(self.path)
|
|
41
|
+
if not matches:
|
|
42
|
+
return []
|
|
43
|
+
return [
|
|
44
|
+
FileURL(self.prefix + match, mode=self.mode) for match in sorted(matches)
|
|
45
|
+
]
|
|
46
|
+
|
|
51
47
|
@override
|
|
52
48
|
def delete(self) -> None:
|
|
53
49
|
# If it's a wildcard pattern, expand and delete all matching files
|
|
@@ -59,7 +59,7 @@ class GoogleCloudStorageURL(URL):
|
|
|
59
59
|
return self.url == value.url
|
|
60
60
|
|
|
61
61
|
@override
|
|
62
|
-
def
|
|
62
|
+
def _expand(self) -> Sequence[URL]:
|
|
63
63
|
with google_cloud_storage_client() as client:
|
|
64
64
|
try:
|
|
65
65
|
path = self.path
|
|
@@ -152,7 +152,16 @@ class GoogleCloudStorageURL(URL):
|
|
|
152
152
|
self._current_remote.upload_from_filename(self._current_local.name)
|
|
153
153
|
finally:
|
|
154
154
|
# Cleanup resources
|
|
155
|
-
self._current_local
|
|
155
|
+
local = self._current_local
|
|
156
|
+
name = local.name
|
|
157
|
+
local.close()
|
|
158
|
+
# N.b. the temp file is created with delete=False so it survives an external
|
|
159
|
+
# close (e.g. DataFileWriter closing the stream before URL.close() uploads).
|
|
160
|
+
# Unlink it explicitly here to avoid leaking a file per open/close cycle.
|
|
161
|
+
try:
|
|
162
|
+
os.unlink(name)
|
|
163
|
+
except FileNotFoundError:
|
|
164
|
+
pass
|
|
156
165
|
self._current_client.close()
|
|
157
166
|
self._current_local = None
|
|
158
167
|
self._current_local_stream = None
|
|
@@ -34,7 +34,7 @@ class S3URL(URL):
|
|
|
34
34
|
self._current_local_stream: IO[Any] | None = None
|
|
35
35
|
|
|
36
36
|
@override
|
|
37
|
-
def
|
|
37
|
+
def _expand(self) -> Sequence[URL]:
|
|
38
38
|
with s3_client() as client:
|
|
39
39
|
path = self.path
|
|
40
40
|
if not path.endswith("/"):
|
|
@@ -49,7 +49,9 @@ class S3URL(URL):
|
|
|
49
49
|
for obj in page["Contents"]:
|
|
50
50
|
key = obj.get("Key")
|
|
51
51
|
if key:
|
|
52
|
-
urls.append(
|
|
52
|
+
urls.append(
|
|
53
|
+
S3URL(f"s3://{self.bucket}/{key}", mode=self.mode)
|
|
54
|
+
)
|
|
53
55
|
if not urls:
|
|
54
56
|
# N.b. we also include URLs that don't exist yet here because there is nothing to expand
|
|
55
57
|
return [self]
|
|
@@ -6,6 +6,7 @@ import concurrent.futures as futures
|
|
|
6
6
|
import json
|
|
7
7
|
import tempfile
|
|
8
8
|
import threading
|
|
9
|
+
from datetime import datetime, timezone
|
|
9
10
|
|
|
10
11
|
import pytest
|
|
11
12
|
import requests
|
|
@@ -99,6 +100,34 @@ class TestHttpServerTool:
|
|
|
99
100
|
assert isinstance(email, str)
|
|
100
101
|
assert "@" in email
|
|
101
102
|
|
|
103
|
+
def test_get_json_logical_types(self, tmpserver):
|
|
104
|
+
http_url, root_url = tmpserver
|
|
105
|
+
schema = avro_schema(
|
|
106
|
+
{
|
|
107
|
+
"type": "record",
|
|
108
|
+
"name": "Event",
|
|
109
|
+
"fields": [
|
|
110
|
+
{
|
|
111
|
+
"name": "created_at",
|
|
112
|
+
"type": {"type": "long", "logicalType": "timestamp-millis"},
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
with avro_writer(
|
|
118
|
+
root_url.with_path("logical.avro").with_mode("wb"), schema
|
|
119
|
+
) as writer:
|
|
120
|
+
writer.append(
|
|
121
|
+
{"created_at": datetime(2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc)}
|
|
122
|
+
)
|
|
123
|
+
res = requests.get(
|
|
124
|
+
f"{http_url}/logical.avro", headers={"Accept": "application/json"}
|
|
125
|
+
)
|
|
126
|
+
res.raise_for_status()
|
|
127
|
+
data = [json.loads(line) for line in res.content.decode("utf-8").splitlines()]
|
|
128
|
+
assert len(data) == 1
|
|
129
|
+
assert data[0]["created_at"] == "2025-01-02T03:04:05+00:00"
|
|
130
|
+
|
|
102
131
|
def test_get_avro(self, tmpserver):
|
|
103
132
|
http_url, _ = tmpserver
|
|
104
133
|
res = requests.get(
|
|
@@ -9,6 +9,8 @@ Integration tests for command-line tools in avrokit.
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
11
|
import tempfile
|
|
12
|
+
from datetime import date, datetime, timezone
|
|
13
|
+
from decimal import Decimal
|
|
12
14
|
from typing import cast
|
|
13
15
|
|
|
14
16
|
import pytest
|
|
@@ -445,6 +447,74 @@ class TestToJsonTool:
|
|
|
445
447
|
assert record["id"] == i
|
|
446
448
|
assert record["name"] == f"name_{i}"
|
|
447
449
|
|
|
450
|
+
def test_tojson_logical_types(self):
|
|
451
|
+
"""Test that Avro logical types (timestamps, dates, decimals) are serialized."""
|
|
452
|
+
schema = avro_schema(
|
|
453
|
+
{
|
|
454
|
+
"type": "record",
|
|
455
|
+
"name": "Test",
|
|
456
|
+
"fields": [
|
|
457
|
+
{"name": "id", "type": "int"},
|
|
458
|
+
{
|
|
459
|
+
"name": "created_at",
|
|
460
|
+
"type": {"type": "long", "logicalType": "timestamp-millis"},
|
|
461
|
+
},
|
|
462
|
+
{"name": "day", "type": {"type": "int", "logicalType": "date"}},
|
|
463
|
+
{
|
|
464
|
+
"name": "amount",
|
|
465
|
+
"type": {
|
|
466
|
+
"type": "bytes",
|
|
467
|
+
"logicalType": "decimal",
|
|
468
|
+
"precision": 10,
|
|
469
|
+
"scale": 2,
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
}
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
477
|
+
avro_file = os.path.join(tmpdir, "input.avro")
|
|
478
|
+
json_file = os.path.join(tmpdir, "output.json")
|
|
479
|
+
avro_url = parse_url(avro_file)
|
|
480
|
+
|
|
481
|
+
with avro_writer(avro_url.with_mode("wb"), schema) as writer:
|
|
482
|
+
writer.append(
|
|
483
|
+
{
|
|
484
|
+
"id": 1,
|
|
485
|
+
"created_at": datetime(
|
|
486
|
+
2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc
|
|
487
|
+
),
|
|
488
|
+
"day": date(2025, 1, 2),
|
|
489
|
+
"amount": Decimal("12.34"),
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
tool = ToJsonTool()
|
|
494
|
+
|
|
495
|
+
import argparse
|
|
496
|
+
|
|
497
|
+
args = argparse.Namespace(url=[avro_file])
|
|
498
|
+
|
|
499
|
+
with open(json_file, "w") as f:
|
|
500
|
+
import sys
|
|
501
|
+
|
|
502
|
+
old_stdout = sys.stdout
|
|
503
|
+
sys.stdout = f
|
|
504
|
+
try:
|
|
505
|
+
tool.run(args)
|
|
506
|
+
finally:
|
|
507
|
+
sys.stdout = old_stdout
|
|
508
|
+
|
|
509
|
+
with open(json_file) as f:
|
|
510
|
+
lines = f.readlines()
|
|
511
|
+
assert len(lines) == 1
|
|
512
|
+
record = json.loads(lines[0])
|
|
513
|
+
assert record["id"] == 1
|
|
514
|
+
assert record["created_at"] == "2025-01-02T03:04:05+00:00"
|
|
515
|
+
assert record["day"] == "2025-01-02"
|
|
516
|
+
assert record["amount"] == "12.34"
|
|
517
|
+
|
|
448
518
|
|
|
449
519
|
class TestGetSchemaTool:
|
|
450
520
|
"""Tests for GetSchema tool."""
|
|
@@ -36,6 +36,19 @@ class TestFileURL:
|
|
|
36
36
|
assert isinstance(expanded_urls[0], FileURL)
|
|
37
37
|
assert expanded_urls[0].url == f"file://{tmpdir}/file_0.txt"
|
|
38
38
|
|
|
39
|
+
def test_expand_glob(self, tmpdir):
|
|
40
|
+
url = FileURL(f"file://{tmpdir}/file_*.txt")
|
|
41
|
+
expanded_urls = url.expand()
|
|
42
|
+
assert len(expanded_urls) == 10
|
|
43
|
+
assert all(isinstance(u, FileURL) for u in expanded_urls)
|
|
44
|
+
assert [u.url for u in expanded_urls] == [
|
|
45
|
+
f"file://{tmpdir}/file_{i}.txt" for i in range(10)
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def test_expand_glob_no_match(self, tmpdir):
|
|
49
|
+
url = FileURL(f"file://{tmpdir}/nonexistent_*.txt")
|
|
50
|
+
assert url.expand() == []
|
|
51
|
+
|
|
39
52
|
def test_exists_directory(self, tmpdir):
|
|
40
53
|
url = FileURL(f"file://{tmpdir}")
|
|
41
54
|
assert url.exists()
|
|
@@ -13,7 +13,7 @@ from filelock import FileLock
|
|
|
13
13
|
|
|
14
14
|
from avrokit.io import avro_reader, avro_schema, avro_writer
|
|
15
15
|
from avrokit.url.factory import parse_url
|
|
16
|
-
from avrokit.url.google import google_cloud_storage_client
|
|
16
|
+
from avrokit.url.google import GoogleCloudStorageURL, google_cloud_storage_client
|
|
17
17
|
|
|
18
18
|
faker = Faker()
|
|
19
19
|
|
|
@@ -144,3 +144,69 @@ class TestGoogleCloudStorageURL:
|
|
|
144
144
|
# Delete the file
|
|
145
145
|
url.delete()
|
|
146
146
|
assert url.exists() is False
|
|
147
|
+
|
|
148
|
+
def test_expand_glob(self, gcs_bucket):
|
|
149
|
+
base = f"{gcs_bucket}/test/glob"
|
|
150
|
+
for i in range(5):
|
|
151
|
+
url = parse_url(f"{base}/part_{i}.avro")
|
|
152
|
+
if url.exists():
|
|
153
|
+
url.delete()
|
|
154
|
+
with url.with_mode("w") as f:
|
|
155
|
+
f.write(f"Content {i}")
|
|
156
|
+
# Also create a non-matching file
|
|
157
|
+
other = parse_url(f"{base}/other.txt")
|
|
158
|
+
with other.with_mode("w") as f:
|
|
159
|
+
f.write("other")
|
|
160
|
+
expanded = parse_url(f"{base}/*.avro").expand()
|
|
161
|
+
assert sorted(u.url for u in expanded) == [
|
|
162
|
+
f"{base}/part_{i}.avro" for i in range(5)
|
|
163
|
+
]
|
|
164
|
+
# No matches returns empty list
|
|
165
|
+
assert parse_url(f"{base}/nonexistent_*.avro").expand() == []
|
|
166
|
+
|
|
167
|
+
def test_no_temp_file_leak(self, monkeypatch):
|
|
168
|
+
class FakeBlob:
|
|
169
|
+
def __init__(self):
|
|
170
|
+
self.content = b""
|
|
171
|
+
|
|
172
|
+
def download_to_file(self, f):
|
|
173
|
+
f.write(self.content)
|
|
174
|
+
f.flush()
|
|
175
|
+
|
|
176
|
+
def upload_from_filename(self, name):
|
|
177
|
+
with open(name, "rb") as f:
|
|
178
|
+
self.content = f.read()
|
|
179
|
+
|
|
180
|
+
class FakeBucket:
|
|
181
|
+
def __init__(self):
|
|
182
|
+
self.blob_obj = FakeBlob()
|
|
183
|
+
|
|
184
|
+
def blob(self, path):
|
|
185
|
+
return self.blob_obj
|
|
186
|
+
|
|
187
|
+
class FakeClient:
|
|
188
|
+
def __init__(self):
|
|
189
|
+
self.bucket_obj = FakeBucket()
|
|
190
|
+
|
|
191
|
+
def bucket(self, name):
|
|
192
|
+
return self.bucket_obj
|
|
193
|
+
|
|
194
|
+
def close(self):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
fake_client = FakeClient()
|
|
198
|
+
monkeypatch.setattr("avrokit.url.google._create_client", lambda: fake_client)
|
|
199
|
+
|
|
200
|
+
tmpdir = tempfile.mkdtemp()
|
|
201
|
+
monkeypatch.setattr(tempfile, "tempdir", tmpdir)
|
|
202
|
+
|
|
203
|
+
url = GoogleCloudStorageURL("gs://test-bucket/test/leak.txt", mode="w")
|
|
204
|
+
with url as f:
|
|
205
|
+
f.write("hello")
|
|
206
|
+
assert fake_client.bucket_obj.blob_obj.content == b"hello"
|
|
207
|
+
assert os.listdir(tmpdir) == []
|
|
208
|
+
|
|
209
|
+
url = GoogleCloudStorageURL("gs://test-bucket/test/leak.txt", mode="r")
|
|
210
|
+
with url as f:
|
|
211
|
+
assert f.read() == "hello"
|
|
212
|
+
assert os.listdir(tmpdir) == []
|
|
@@ -58,6 +58,25 @@ class TestS3URL:
|
|
|
58
58
|
url.delete()
|
|
59
59
|
assert url.exists() is False
|
|
60
60
|
|
|
61
|
+
def test_expand_glob(self, s3_bucket):
|
|
62
|
+
base = f"{s3_bucket}/test/glob"
|
|
63
|
+
for i in range(5):
|
|
64
|
+
url = parse_url(f"{base}/part_{i}.avro")
|
|
65
|
+
if url.exists():
|
|
66
|
+
url.delete()
|
|
67
|
+
with url.with_mode("w") as f:
|
|
68
|
+
f.write(f"Content {i}")
|
|
69
|
+
# Also create a non-matching file
|
|
70
|
+
other = parse_url(f"{base}/other.txt")
|
|
71
|
+
with other.with_mode("w") as f:
|
|
72
|
+
f.write("other")
|
|
73
|
+
expanded = parse_url(f"{base}/*.avro").expand()
|
|
74
|
+
assert sorted(u.url for u in expanded) == [
|
|
75
|
+
f"{base}/part_{i}.avro" for i in range(5)
|
|
76
|
+
]
|
|
77
|
+
# No matches returns empty list
|
|
78
|
+
assert parse_url(f"{base}/nonexistent_*.avro").expand() == []
|
|
79
|
+
|
|
61
80
|
def test_avro(self, s3_bucket):
|
|
62
81
|
url = parse_url(f"{s3_bucket}/test/avro/file.avro")
|
|
63
82
|
# Clean up any existing file
|
|
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
|