avrokit 0.0.7__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 (64) hide show
  1. {avrokit-0.0.7 → avrokit-0.1.2}/Makefile +5 -4
  2. {avrokit-0.0.7 → avrokit-0.1.2}/PKG-INFO +7 -8
  3. {avrokit-0.0.7 → avrokit-0.1.2}/README.md +2 -6
  4. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/io/reader.py +2 -1
  5. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/io/writer.py +2 -7
  6. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/httpserver.py +4 -1
  7. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/tojson.py +14 -1
  8. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/url/base.py +34 -2
  9. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/url/file.py +13 -15
  10. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/url/google.py +3 -2
  11. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/url/http.py +8 -4
  12. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/url/s3.py +6 -3
  13. {avrokit-0.0.7 → avrokit-0.1.2}/pyproject.toml +6 -2
  14. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_io_schema.py +223 -20
  15. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_tools_httpserver.py +78 -0
  16. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_tools_integration.py +70 -0
  17. avrokit-0.1.2/tests/test_url_contract.py +151 -0
  18. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_url_file.py +13 -0
  19. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_url_google.py +19 -0
  20. avrokit-0.1.2/tests/test_url_http.py +681 -0
  21. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_url_s3.py +19 -0
  22. {avrokit-0.0.7 → avrokit-0.1.2}/uv.lock +259 -3
  23. avrokit-0.0.7/tests/test_url_http.py +0 -91
  24. {avrokit-0.0.7 → avrokit-0.1.2}/.github/workflows/check.yml +0 -0
  25. {avrokit-0.0.7 → avrokit-0.1.2}/.github/workflows/release.yml +0 -0
  26. {avrokit-0.0.7 → avrokit-0.1.2}/.gitignore +0 -0
  27. {avrokit-0.0.7 → avrokit-0.1.2}/LICENSE +0 -0
  28. {avrokit-0.0.7 → avrokit-0.1.2}/LICENSES/Apache-2.0.txt +0 -0
  29. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/__init__.py +0 -0
  30. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/asyncio/__init__.py +0 -0
  31. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/asyncio/reader.py +0 -0
  32. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/asyncio/writer.py +0 -0
  33. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/io/__init__.py +0 -0
  34. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/io/compact.py +0 -0
  35. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/io/schema.py +0 -0
  36. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/py.typed +0 -0
  37. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/__init__.py +0 -0
  38. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/__main__.py +0 -0
  39. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/base.py +0 -0
  40. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/cat.py +0 -0
  41. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/concat.py +0 -0
  42. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/count.py +0 -0
  43. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/filesort.py +0 -0
  44. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/fromparquet.py +0 -0
  45. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/getmeta.py +0 -0
  46. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/getschema.py +0 -0
  47. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/partition.py +0 -0
  48. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/repair.py +0 -0
  49. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/stats.py +0 -0
  50. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/tools/toparquet.py +0 -0
  51. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/url/__init__.py +0 -0
  52. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/url/factory.py +0 -0
  53. {avrokit-0.0.7 → avrokit-0.1.2}/avrokit/url/utils.py +0 -0
  54. {avrokit-0.0.7 → avrokit-0.1.2}/tests/__init__.py +0 -0
  55. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_additional_coverage.py +0 -0
  56. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_asyncio.py +0 -0
  57. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_exports.py +0 -0
  58. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_io.py +0 -0
  59. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_plan_coverage.py +0 -0
  60. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_property_based.py +0 -0
  61. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_schema_advanced.py +0 -0
  62. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_tools_repair.py +0 -0
  63. {avrokit-0.0.7 → avrokit-0.1.2}/tests/test_url.py +0 -0
  64. {avrokit-0.0.7 → avrokit-0.1.2}/uv.lock.license +0 -0
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
- .PHONY: install test test-coverage lint lint-fix typecheck format build publish lock check license-check bump help
5
+ .PHONY: install test test-coverage lint fix typecheck format build publish lock check license-check bump help
6
6
 
7
7
  help: ## Display this help
8
8
  @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
@@ -25,9 +25,6 @@ test-coverage: ## Run tests with coverage report
25
25
  lint: ## Run ruff linter
26
26
  uv run ruff check .
27
27
 
28
- lint-fix: ## Run ruff linter and apply fixes
29
- uv run ruff check --fix .
30
-
31
28
  typecheck: ## Run type checker
32
29
  uv run ty check
33
30
 
@@ -40,6 +37,10 @@ bump: ## Bump version: make bump part=patch|minor|major
40
37
  lock: ## Update the lockfile
41
38
  uv lock
42
39
 
40
+ fix: ## Run ruff linter and apply fixes
41
+ uv run ruff format .
42
+ uv run ruff check --fix .
43
+
43
44
  check: lint typecheck format test license-check ## Run all checks (lint, typecheck, format, test)
44
45
 
45
46
  license-check: ## Check that all files have REUSE license headers
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avrokit
3
- Version: 0.0.7
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
@@ -12,10 +12,12 @@ Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: Apache Software License
14
14
  Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
15
17
  Classifier: Programming Language :: Python :: 3.12
16
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
19
  Classifier: Typing :: Typed
18
- Requires-Python: >=3.12
20
+ Requires-Python: >=3.10
19
21
  Requires-Dist: avro>=1.12.1
20
22
  Requires-Dist: boto3-stubs[s3]>=1.38.8
21
23
  Requires-Dist: datetime-truncate>=1.1.1
@@ -23,6 +25,7 @@ Requires-Dist: filelock>=3.18.0
23
25
  Requires-Dist: pyarrow>=22.0.0
24
26
  Requires-Dist: requests>=2.32.3
25
27
  Requires-Dist: types-requests>=2.32.0.20250328
28
+ Requires-Dist: typing-extensions>=4.12.0
26
29
  Provides-Extra: all
27
30
  Requires-Dist: boto3>=1.38.7; extra == 'all'
28
31
  Requires-Dist: google-cloud-storage>=3.6.0; extra == 'all'
@@ -80,7 +83,6 @@ pip install avrokit[all]
80
83
  ```bash
81
84
  git clone https://github.com/brandtg/avrokit.git
82
85
  cd avrokit
83
- poetry env use python3.12
84
86
  make install
85
87
  ```
86
88
 
@@ -321,15 +323,12 @@ avrokit toparquet INPUT.avro OUTPUT.parquet
321
323
  ### Prerequisites
322
324
 
323
325
  - Python 3.12+
324
- - Poetry
326
+ - uv
325
327
  - Docker (for GCS/S3 integration tests)
326
328
 
327
329
  ### Setup
328
330
 
329
331
  ```bash
330
- # Set up Python environment
331
- poetry env use python3.12
332
-
333
332
  # Install dependencies
334
333
  make install
335
334
 
@@ -347,7 +346,7 @@ make test
347
346
  make test-coverage
348
347
 
349
348
  # Run tests in parallel (default)
350
- poetry run pytest -n auto
349
+ uv run pytest -n auto
351
350
  ```
352
351
 
353
352
  ### Code Quality
@@ -44,7 +44,6 @@ pip install avrokit[all]
44
44
  ```bash
45
45
  git clone https://github.com/brandtg/avrokit.git
46
46
  cd avrokit
47
- poetry env use python3.12
48
47
  make install
49
48
  ```
50
49
 
@@ -285,15 +284,12 @@ avrokit toparquet INPUT.avro OUTPUT.parquet
285
284
  ### Prerequisites
286
285
 
287
286
  - Python 3.12+
288
- - Poetry
287
+ - uv
289
288
  - Docker (for GCS/S3 integration tests)
290
289
 
291
290
  ### Setup
292
291
 
293
292
  ```bash
294
- # Set up Python environment
295
- poetry env use python3.12
296
-
297
293
  # Install dependencies
298
294
  make install
299
295
 
@@ -311,7 +307,7 @@ make test
311
307
  make test-coverage
312
308
 
313
309
  # Run tests in parallel (default)
314
- poetry run pytest -n auto
310
+ uv run pytest -n auto
315
311
  ```
316
312
 
317
313
  ### Code Quality
@@ -6,10 +6,11 @@ from __future__ import annotations
6
6
 
7
7
  from collections.abc import Generator, Iterator, Sequence
8
8
  from contextlib import contextmanager
9
- from typing import IO, Any, Self, cast
9
+ from typing import IO, Any, cast
10
10
 
11
11
  from avro.datafile import DataFileReader
12
12
  from avro.io import DatumReader
13
+ from typing_extensions import Self
13
14
 
14
15
  from ..url import URL
15
16
 
@@ -10,18 +10,13 @@ from collections.abc import Generator, Mapping, Sequence
10
10
  from contextlib import contextmanager
11
11
  from datetime import datetime
12
12
  from itertools import groupby
13
- from typing import (
14
- IO,
15
- Any,
16
- Literal,
17
- Protocol,
18
- Self,
19
- )
13
+ from typing import IO, Any, Literal, Protocol
20
14
 
21
15
  from avro.datafile import NULL_CODEC, DataFileWriter
22
16
  from avro.io import DatumWriter
23
17
  from avro.schema import Schema
24
18
  from datetime_truncate import truncate as truncate_datetime
19
+ from typing_extensions import Self
25
20
 
26
21
  from avrokit.url.utils import flatten_urls
27
22
 
@@ -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
  """
@@ -6,7 +6,9 @@ import glob
6
6
  import os
7
7
  import shutil
8
8
  from collections.abc import Sequence
9
- from typing import IO, Any, override
9
+ from typing import IO, Any
10
+
11
+ from typing_extensions import override
10
12
 
11
13
  from .base import URL
12
14
 
@@ -19,20 +21,7 @@ class FileURL(URL):
19
21
  self.fh: IO[Any] | None = None
20
22
 
21
23
  @override
22
- def expand(self) -> Sequence[URL]:
23
- # Check if the path contains wildcard characters
24
- if "*" in self.path or "?" in self.path or "[" in self.path:
25
- # Use glob to expand the pattern
26
- matches = glob.glob(self.path)
27
- if not matches:
28
- # No matches found, return empty list
29
- return []
30
- # Return sorted list of FileURL objects for all matches
31
- return [
32
- FileURL(self.prefix + match, mode=self.mode)
33
- for match in sorted(matches)
34
- ]
35
-
24
+ def _expand(self) -> Sequence[URL]:
36
25
  # If it's a regular file or doesn't exist (no expansion needed)
37
26
  if os.path.isfile(self.path) or not os.path.exists(self.path):
38
27
  return [self]
@@ -46,6 +35,15 @@ class FileURL(URL):
46
35
  )
47
36
  return acc
48
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
+
49
47
  @override
50
48
  def delete(self) -> None:
51
49
  # If it's a wildcard pattern, expand and delete all matching files
@@ -7,11 +7,12 @@ import os
7
7
  import tempfile
8
8
  from collections.abc import Generator, Sequence
9
9
  from contextlib import contextmanager
10
- from typing import IO, Any, cast, override
10
+ from typing import IO, Any, cast
11
11
 
12
12
  from google.api_core.exceptions import NotFound
13
13
  from google.auth.credentials import AnonymousCredentials
14
14
  from google.cloud import storage
15
+ from typing_extensions import override
15
16
 
16
17
  from .base import URL
17
18
 
@@ -58,7 +59,7 @@ class GoogleCloudStorageURL(URL):
58
59
  return self.url == value.url
59
60
 
60
61
  @override
61
- def expand(self) -> Sequence[URL]:
62
+ def _expand(self) -> Sequence[URL]:
62
63
  with google_cloud_storage_client() as client:
63
64
  try:
64
65
  path = self.path
@@ -5,9 +5,10 @@
5
5
  import io
6
6
  import tempfile
7
7
  from collections.abc import Sequence
8
- from typing import IO, Any, override
8
+ from typing import IO, Any
9
9
 
10
10
  import requests
11
+ from typing_extensions import override
11
12
 
12
13
  from .base import URL
13
14
 
@@ -35,7 +36,7 @@ class HttpURL(URL):
35
36
  self._current_request_buffer: IO[Any] | None = None
36
37
 
37
38
  @override
38
- def expand(self) -> Sequence[URL]:
39
+ def _expand(self) -> Sequence[URL]:
39
40
  # N.b. no way to "expand" an HTTP URL, i.e. discover sub resources
40
41
  return [self]
41
42
 
@@ -57,9 +58,12 @@ class HttpURL(URL):
57
58
  try:
58
59
  response = requests.head(self.url, timeout=5)
59
60
  if response.ok:
60
- return int(response.headers.get("Content-Length", 0))
61
+ content_length = response.headers.get("Content-Length")
62
+ if content_length is not None:
63
+ return int(content_length)
64
+ return 0
61
65
  return 0
62
- except requests.RequestException:
66
+ except (requests.RequestException, ValueError):
63
67
  return 0
64
68
 
65
69
  @override
@@ -6,10 +6,11 @@ import io
6
6
  import tempfile
7
7
  from collections.abc import Sequence
8
8
  from contextlib import contextmanager
9
- from typing import IO, Any, cast, override
9
+ from typing import IO, Any, cast
10
10
 
11
11
  import boto3
12
12
  from mypy_boto3_s3 import S3Client
13
+ from typing_extensions import override
13
14
 
14
15
  from .base import URL
15
16
 
@@ -33,7 +34,7 @@ class S3URL(URL):
33
34
  self._current_local_stream: IO[Any] | None = None
34
35
 
35
36
  @override
36
- def expand(self) -> Sequence[URL]:
37
+ def _expand(self) -> Sequence[URL]:
37
38
  with s3_client() as client:
38
39
  path = self.path
39
40
  if not path.endswith("/"):
@@ -48,7 +49,9 @@ class S3URL(URL):
48
49
  for obj in page["Contents"]:
49
50
  key = obj.get("Key")
50
51
  if key:
51
- urls.append(S3URL(self._append_path(key), mode=self.mode))
52
+ urls.append(
53
+ S3URL(f"s3://{self.bucket}/{key}", mode=self.mode)
54
+ )
52
55
  if not urls:
53
56
  # N.b. we also include URLs that don't exist yet here because there is nothing to expand
54
57
  return [self]
@@ -4,18 +4,20 @@
4
4
 
5
5
  [project]
6
6
  name = "avrokit"
7
- version = "0.0.7"
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"
11
11
  readme = "README.md"
12
- requires-python = ">=3.12"
12
+ requires-python = ">=3.10"
13
13
  keywords = ["avro", "data", "serialization", "gcs", "s3", "parquet"]
14
14
  classifiers = [
15
15
  "Development Status :: 3 - Alpha",
16
16
  "Intended Audience :: Developers",
17
17
  "License :: OSI Approved :: Apache Software License",
18
18
  "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
19
21
  "Programming Language :: Python :: 3.12",
20
22
  "Topic :: Software Development :: Libraries :: Python Modules",
21
23
  "Typing :: Typed",
@@ -28,6 +30,7 @@ dependencies = [
28
30
  "boto3-stubs[s3]>=1.38.8",
29
31
  "types-requests>=2.32.0.20250328",
30
32
  "pyarrow>=22.0.0",
33
+ "typing-extensions>=4.12.0",
31
34
  ]
32
35
 
33
36
  [project.optional-dependencies]
@@ -66,6 +69,7 @@ filterwarnings = ["ignore::DeprecationWarning:botocore.*"]
66
69
 
67
70
  [tool.ruff]
68
71
  exclude = []
72
+ target-version = "py310"
69
73
 
70
74
  [tool.ruff.lint]
71
75
  select = ["E", "F", "I", "UP"]