avrokit 0.1.0__tar.gz → 0.1.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 (63) hide show
  1. {avrokit-0.1.0 → avrokit-0.1.2}/PKG-INFO +1 -1
  2. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/httpserver.py +4 -1
  3. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/tojson.py +14 -1
  4. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/url/base.py +34 -2
  5. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/url/file.py +10 -14
  6. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/url/google.py +1 -1
  7. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/url/http.py +1 -1
  8. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/url/s3.py +4 -2
  9. {avrokit-0.1.0 → avrokit-0.1.2}/pyproject.toml +1 -1
  10. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_tools_httpserver.py +29 -0
  11. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_tools_integration.py +70 -0
  12. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_url_file.py +13 -0
  13. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_url_google.py +19 -0
  14. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_url_s3.py +19 -0
  15. {avrokit-0.1.0 → avrokit-0.1.2}/.github/workflows/check.yml +0 -0
  16. {avrokit-0.1.0 → avrokit-0.1.2}/.github/workflows/release.yml +0 -0
  17. {avrokit-0.1.0 → avrokit-0.1.2}/.gitignore +0 -0
  18. {avrokit-0.1.0 → avrokit-0.1.2}/LICENSE +0 -0
  19. {avrokit-0.1.0 → avrokit-0.1.2}/LICENSES/Apache-2.0.txt +0 -0
  20. {avrokit-0.1.0 → avrokit-0.1.2}/Makefile +0 -0
  21. {avrokit-0.1.0 → avrokit-0.1.2}/README.md +0 -0
  22. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/__init__.py +0 -0
  23. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/asyncio/__init__.py +0 -0
  24. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/asyncio/reader.py +0 -0
  25. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/asyncio/writer.py +0 -0
  26. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/io/__init__.py +0 -0
  27. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/io/compact.py +0 -0
  28. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/io/reader.py +0 -0
  29. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/io/schema.py +0 -0
  30. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/io/writer.py +0 -0
  31. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/py.typed +0 -0
  32. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/__init__.py +0 -0
  33. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/__main__.py +0 -0
  34. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/base.py +0 -0
  35. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/cat.py +0 -0
  36. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/concat.py +0 -0
  37. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/count.py +0 -0
  38. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/filesort.py +0 -0
  39. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/fromparquet.py +0 -0
  40. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/getmeta.py +0 -0
  41. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/getschema.py +0 -0
  42. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/partition.py +0 -0
  43. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/repair.py +0 -0
  44. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/stats.py +0 -0
  45. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/tools/toparquet.py +0 -0
  46. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/url/__init__.py +0 -0
  47. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/url/factory.py +0 -0
  48. {avrokit-0.1.0 → avrokit-0.1.2}/avrokit/url/utils.py +0 -0
  49. {avrokit-0.1.0 → avrokit-0.1.2}/tests/__init__.py +0 -0
  50. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_additional_coverage.py +0 -0
  51. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_asyncio.py +0 -0
  52. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_exports.py +0 -0
  53. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_io.py +0 -0
  54. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_io_schema.py +0 -0
  55. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_plan_coverage.py +0 -0
  56. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_property_based.py +0 -0
  57. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_schema_advanced.py +0 -0
  58. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_tools_repair.py +0 -0
  59. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_url.py +0 -0
  60. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_url_contract.py +0 -0
  61. {avrokit-0.1.0 → avrokit-0.1.2}/tests/test_url_http.py +0 -0
  62. {avrokit-0.1.0 → avrokit-0.1.2}/uv.lock +0 -0
  63. {avrokit-0.1.0 → avrokit-0.1.2}/uv.lock.license +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avrokit
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python utilities for working with Avro data files
5
5
  Project-URL: Homepage, https://github.com/brandtg/avrokit
6
6
  Project-URL: Repository, https://github.com/brandtg/avrokit
@@ -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(json.dumps(record).encode("utf-8"))
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 expand(self) -> Sequence[URL]:
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 expand(self) -> Sequence[URL]:
62
+ def _expand(self) -> Sequence[URL]:
63
63
  with google_cloud_storage_client() as client:
64
64
  try:
65
65
  path = self.path
@@ -36,7 +36,7 @@ class HttpURL(URL):
36
36
  self._current_request_buffer: IO[Any] | None = None
37
37
 
38
38
  @override
39
- def expand(self) -> Sequence[URL]:
39
+ def _expand(self) -> Sequence[URL]:
40
40
  # N.b. no way to "expand" an HTTP URL, i.e. discover sub resources
41
41
  return [self]
42
42
 
@@ -34,7 +34,7 @@ class S3URL(URL):
34
34
  self._current_local_stream: IO[Any] | None = None
35
35
 
36
36
  @override
37
- def expand(self) -> Sequence[URL]:
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(S3URL(self._append_path(key), mode=self.mode))
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]
@@ -4,7 +4,7 @@
4
4
 
5
5
  [project]
6
6
  name = "avrokit"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Python utilities for working with Avro data files"
9
9
  authors = [{ name = "Greg Brandt", email = "brandt.greg@gmail.com" }]
10
10
  license = "Apache-2.0"
@@ -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()
@@ -144,3 +144,22 @@ 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() == []
@@ -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