avrokit 0.1.3__tar.gz → 0.1.4__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.1.3 → avrokit-0.1.4}/PKG-INFO +28 -30
  2. {avrokit-0.1.3 → avrokit-0.1.4}/README.md +27 -29
  3. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/asyncio/writer.py +14 -0
  4. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/io/reader.py +2 -0
  5. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/io/writer.py +2 -0
  6. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/concat.py +4 -2
  7. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/filesort.py +1 -1
  8. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/repair.py +1 -0
  9. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/url/http.py +12 -1
  10. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/url/s3.py +11 -1
  11. {avrokit-0.1.3 → avrokit-0.1.4}/pyproject.toml +1 -1
  12. avrokit-0.1.4/uv.lock +1522 -0
  13. avrokit-0.1.3/uv.lock +0 -1533
  14. {avrokit-0.1.3 → avrokit-0.1.4}/.github/workflows/check.yml +0 -0
  15. {avrokit-0.1.3 → avrokit-0.1.4}/.github/workflows/release.yml +0 -0
  16. {avrokit-0.1.3 → avrokit-0.1.4}/.gitignore +0 -0
  17. {avrokit-0.1.3 → avrokit-0.1.4}/LICENSE +0 -0
  18. {avrokit-0.1.3 → avrokit-0.1.4}/LICENSES/Apache-2.0.txt +0 -0
  19. {avrokit-0.1.3 → avrokit-0.1.4}/Makefile +0 -0
  20. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/__init__.py +0 -0
  21. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/asyncio/__init__.py +0 -0
  22. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/asyncio/reader.py +0 -0
  23. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/io/__init__.py +0 -0
  24. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/io/compact.py +0 -0
  25. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/io/schema.py +0 -0
  26. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/py.typed +0 -0
  27. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/__init__.py +0 -0
  28. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/__main__.py +0 -0
  29. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/base.py +0 -0
  30. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/cat.py +0 -0
  31. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/count.py +0 -0
  32. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/fromparquet.py +0 -0
  33. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/getmeta.py +0 -0
  34. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/getschema.py +0 -0
  35. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/httpserver.py +0 -0
  36. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/partition.py +0 -0
  37. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/stats.py +0 -0
  38. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/tojson.py +0 -0
  39. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/tools/toparquet.py +0 -0
  40. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/url/__init__.py +0 -0
  41. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/url/base.py +0 -0
  42. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/url/factory.py +0 -0
  43. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/url/file.py +0 -0
  44. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/url/google.py +0 -0
  45. {avrokit-0.1.3 → avrokit-0.1.4}/avrokit/url/utils.py +0 -0
  46. {avrokit-0.1.3 → avrokit-0.1.4}/tests/__init__.py +0 -0
  47. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_additional_coverage.py +0 -0
  48. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_asyncio.py +0 -0
  49. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_exports.py +0 -0
  50. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_io.py +0 -0
  51. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_io_schema.py +0 -0
  52. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_plan_coverage.py +0 -0
  53. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_property_based.py +0 -0
  54. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_schema_advanced.py +0 -0
  55. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_tools_httpserver.py +0 -0
  56. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_tools_integration.py +0 -0
  57. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_tools_repair.py +0 -0
  58. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_url.py +0 -0
  59. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_url_contract.py +0 -0
  60. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_url_file.py +0 -0
  61. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_url_google.py +0 -0
  62. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_url_http.py +0 -0
  63. {avrokit-0.1.3 → avrokit-0.1.4}/tests/test_url_s3.py +0 -0
  64. {avrokit-0.1.3 → avrokit-0.1.4}/uv.lock.license +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avrokit
3
- Version: 0.1.3
3
+ Version: 0.1.4
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
@@ -116,24 +116,23 @@ avrokit concat file1.avro file2.avro s3://bucket/file3.avro output.avro
116
116
  from avrokit import avro_reader, avro_records, avro_writer, avro_schema, parse_url
117
117
 
118
118
  # Read records from any URL
119
- url = parse_url('gs://bucket/data.avro', mode='rb')
119
+ url = parse_url("gs://bucket/data.avro", mode="rb")
120
120
  for record in avro_records(url):
121
121
  print(record)
122
122
 
123
123
  # Write Avro data
124
- schema = avro_schema({
125
- 'type': 'record',
126
- 'name': 'User',
127
- 'fields': [
128
- {'name': 'name', 'type': 'string'},
129
- {'name': 'age', 'type': 'int'}
130
- ]
131
- })
132
-
133
- url = parse_url('output.avro', mode='wb')
124
+ schema = avro_schema(
125
+ {
126
+ "type": "record",
127
+ "name": "User",
128
+ "fields": [{"name": "name", "type": "string"}, {"name": "age", "type": "int"}],
129
+ }
130
+ )
131
+
132
+ url = parse_url("output.avro", mode="wb")
134
133
  with avro_writer(url, schema) as writer:
135
- writer.append({'name': 'Alice', 'age': 30})
136
- writer.append({'name': 'Bob', 'age': 25})
134
+ writer.append({"name": "Alice", "age": 30})
135
+ writer.append({"name": "Bob", "age": 25})
137
136
  ```
138
137
 
139
138
  ## Architecture
@@ -156,10 +155,10 @@ The `parse_url()` factory function automatically instantiates the correct URL cl
156
155
  from avrokit.url import parse_url
157
156
 
158
157
  # All of these return the appropriate URL subclass
159
- url1 = parse_url('file:///path/to/data.avro') # FileURL
160
- url2 = parse_url('gs://bucket/data.avro') # GCSURL
161
- url3 = parse_url('s3://bucket/data.avro') # S3URL
162
- url4 = parse_url('https://example.com/data.avro') # HTTPURL
158
+ url1 = parse_url("file:///path/to/data.avro") # FileURL
159
+ url2 = parse_url("gs://bucket/data.avro") # GCSURL
160
+ url3 = parse_url("s3://bucket/data.avro") # S3URL
161
+ url4 = parse_url("https://example.com/data.avro") # HTTPURL
163
162
  ```
164
163
 
165
164
  ### 2. I/O Layer (`avrokit.io`)
@@ -401,13 +400,13 @@ The GitHub Actions workflow will automatically build and publish to PyPI.
401
400
  from avrokit import PartitionedAvroReader, PartitionedAvroWriter, parse_url
402
401
 
403
402
  # Read from multiple files as single stream
404
- url = parse_url('data-*.avro', mode='rb')
403
+ url = parse_url("data-*.avro", mode="rb")
405
404
  with PartitionedAvroReader(url) as reader:
406
405
  for record in reader:
407
406
  process(record)
408
407
 
409
408
  # Write to multiple files (roll every 10000 records)
410
- url = parse_url('output/', mode='wb')
409
+ url = parse_url("output/", mode="wb")
411
410
  with PartitionedAvroWriter(url, schema, max_records=10000) as writer:
412
411
  for record in records:
413
412
  writer.append(record)
@@ -422,15 +421,12 @@ from avrokit import TimePartitionedAvroWriter, parse_url
422
421
  from datetime import datetime
423
422
 
424
423
  # Creates hourly files: output/2024/01/15/10.avro
425
- url = parse_url('output/', mode='wb')
424
+ url = parse_url("output/", mode="wb")
426
425
  with TimePartitionedAvroWriter(
427
- url,
428
- schema,
429
- time_granularity='hour',
430
- time_field='timestamp'
426
+ url, schema, time_granularity="hour", time_field="timestamp"
431
427
  ) as writer:
432
428
  for record in records:
433
- writer.append(record, timestamp=record['timestamp'])
429
+ writer.append(record, timestamp=record["timestamp"])
434
430
  ```
435
431
 
436
432
  ### Async Operations
@@ -440,8 +436,9 @@ import asyncio
440
436
  from avrokit.asyncio import DeferredAvroWriter
441
437
  from avrokit import parse_url, avro_schema
442
438
 
439
+
443
440
  async def write_async():
444
- url = parse_url('output.avro', mode='wb')
441
+ url = parse_url("output.avro", mode="wb")
445
442
  schema = avro_schema({...})
446
443
 
447
444
  async with DeferredAvroWriter(url, schema) as writer:
@@ -449,6 +446,7 @@ async def write_async():
449
446
  await writer.append(record)
450
447
  # Flushes happen automatically in background
451
448
 
449
+
452
450
  asyncio.run(write_async())
453
451
  ```
454
452
 
@@ -459,13 +457,13 @@ from avrokit import validate_avro_schema_evolution, read_avro_schema
459
457
  from avrokit.url import parse_url
460
458
 
461
459
  # Check if new schema is backward compatible
462
- reader_schema = read_avro_schema(parse_url('old.avro', 'rb'))
463
- writer_schema = read_avro_schema(parse_url('new.avro', 'rb'))
460
+ reader_schema = read_avro_schema(parse_url("old.avro", "rb"))
461
+ writer_schema = read_avro_schema(parse_url("new.avro", "rb"))
464
462
 
465
463
  is_valid = validate_avro_schema_evolution(
466
464
  reader_schema,
467
465
  writer_schema,
468
- strategy='backward' # or 'forward', 'full'
466
+ strategy="backward", # or 'forward', 'full'
469
467
  )
470
468
  ```
471
469
 
@@ -77,24 +77,23 @@ avrokit concat file1.avro file2.avro s3://bucket/file3.avro output.avro
77
77
  from avrokit import avro_reader, avro_records, avro_writer, avro_schema, parse_url
78
78
 
79
79
  # Read records from any URL
80
- url = parse_url('gs://bucket/data.avro', mode='rb')
80
+ url = parse_url("gs://bucket/data.avro", mode="rb")
81
81
  for record in avro_records(url):
82
82
  print(record)
83
83
 
84
84
  # Write Avro data
85
- schema = avro_schema({
86
- 'type': 'record',
87
- 'name': 'User',
88
- 'fields': [
89
- {'name': 'name', 'type': 'string'},
90
- {'name': 'age', 'type': 'int'}
91
- ]
92
- })
93
-
94
- url = parse_url('output.avro', mode='wb')
85
+ schema = avro_schema(
86
+ {
87
+ "type": "record",
88
+ "name": "User",
89
+ "fields": [{"name": "name", "type": "string"}, {"name": "age", "type": "int"}],
90
+ }
91
+ )
92
+
93
+ url = parse_url("output.avro", mode="wb")
95
94
  with avro_writer(url, schema) as writer:
96
- writer.append({'name': 'Alice', 'age': 30})
97
- writer.append({'name': 'Bob', 'age': 25})
95
+ writer.append({"name": "Alice", "age": 30})
96
+ writer.append({"name": "Bob", "age": 25})
98
97
  ```
99
98
 
100
99
  ## Architecture
@@ -117,10 +116,10 @@ The `parse_url()` factory function automatically instantiates the correct URL cl
117
116
  from avrokit.url import parse_url
118
117
 
119
118
  # All of these return the appropriate URL subclass
120
- url1 = parse_url('file:///path/to/data.avro') # FileURL
121
- url2 = parse_url('gs://bucket/data.avro') # GCSURL
122
- url3 = parse_url('s3://bucket/data.avro') # S3URL
123
- url4 = parse_url('https://example.com/data.avro') # HTTPURL
119
+ url1 = parse_url("file:///path/to/data.avro") # FileURL
120
+ url2 = parse_url("gs://bucket/data.avro") # GCSURL
121
+ url3 = parse_url("s3://bucket/data.avro") # S3URL
122
+ url4 = parse_url("https://example.com/data.avro") # HTTPURL
124
123
  ```
125
124
 
126
125
  ### 2. I/O Layer (`avrokit.io`)
@@ -362,13 +361,13 @@ The GitHub Actions workflow will automatically build and publish to PyPI.
362
361
  from avrokit import PartitionedAvroReader, PartitionedAvroWriter, parse_url
363
362
 
364
363
  # Read from multiple files as single stream
365
- url = parse_url('data-*.avro', mode='rb')
364
+ url = parse_url("data-*.avro", mode="rb")
366
365
  with PartitionedAvroReader(url) as reader:
367
366
  for record in reader:
368
367
  process(record)
369
368
 
370
369
  # Write to multiple files (roll every 10000 records)
371
- url = parse_url('output/', mode='wb')
370
+ url = parse_url("output/", mode="wb")
372
371
  with PartitionedAvroWriter(url, schema, max_records=10000) as writer:
373
372
  for record in records:
374
373
  writer.append(record)
@@ -383,15 +382,12 @@ from avrokit import TimePartitionedAvroWriter, parse_url
383
382
  from datetime import datetime
384
383
 
385
384
  # Creates hourly files: output/2024/01/15/10.avro
386
- url = parse_url('output/', mode='wb')
385
+ url = parse_url("output/", mode="wb")
387
386
  with TimePartitionedAvroWriter(
388
- url,
389
- schema,
390
- time_granularity='hour',
391
- time_field='timestamp'
387
+ url, schema, time_granularity="hour", time_field="timestamp"
392
388
  ) as writer:
393
389
  for record in records:
394
- writer.append(record, timestamp=record['timestamp'])
390
+ writer.append(record, timestamp=record["timestamp"])
395
391
  ```
396
392
 
397
393
  ### Async Operations
@@ -401,8 +397,9 @@ import asyncio
401
397
  from avrokit.asyncio import DeferredAvroWriter
402
398
  from avrokit import parse_url, avro_schema
403
399
 
400
+
404
401
  async def write_async():
405
- url = parse_url('output.avro', mode='wb')
402
+ url = parse_url("output.avro", mode="wb")
406
403
  schema = avro_schema({...})
407
404
 
408
405
  async with DeferredAvroWriter(url, schema) as writer:
@@ -410,6 +407,7 @@ async def write_async():
410
407
  await writer.append(record)
411
408
  # Flushes happen automatically in background
412
409
 
410
+
413
411
  asyncio.run(write_async())
414
412
  ```
415
413
 
@@ -420,13 +418,13 @@ from avrokit import validate_avro_schema_evolution, read_avro_schema
420
418
  from avrokit.url import parse_url
421
419
 
422
420
  # Check if new schema is backward compatible
423
- reader_schema = read_avro_schema(parse_url('old.avro', 'rb'))
424
- writer_schema = read_avro_schema(parse_url('new.avro', 'rb'))
421
+ reader_schema = read_avro_schema(parse_url("old.avro", "rb"))
422
+ writer_schema = read_avro_schema(parse_url("new.avro", "rb"))
425
423
 
426
424
  is_valid = validate_avro_schema_evolution(
427
425
  reader_schema,
428
426
  writer_schema,
429
- strategy='backward' # or 'forward', 'full'
427
+ strategy="backward", # or 'forward', 'full'
430
428
  )
431
429
  ```
432
430
 
@@ -43,6 +43,20 @@ class DeferredAvroWriter:
43
43
  self._writer_thread_done.set()
44
44
  self._writer_thread.join()
45
45
 
46
+ def close(self) -> None:
47
+ self.stop()
48
+ # Close the underlying writer if it supports it (e.g. DataFileWriter)
49
+ close_fn = getattr(self.writer, "close", None)
50
+ if close_fn:
51
+ close_fn()
52
+
53
+ def __enter__(self) -> "DeferredAvroWriter":
54
+ self.start()
55
+ return self
56
+
57
+ def __exit__(self, exc_type, exc_value, traceback):
58
+ self.close()
59
+
46
60
  def append(
47
61
  self, datum: object, block: bool = True, timeout: int | None = None
48
62
  ) -> None:
@@ -85,6 +85,8 @@ class PartitionedAvroReader:
85
85
  if self.current_reader:
86
86
  self.current_reader.close()
87
87
  self.current_reader = None
88
+ if self.current_url:
89
+ self.current_url.close()
88
90
  self.current_url_stream = None
89
91
  self.current_index = 0
90
92
 
@@ -128,6 +128,8 @@ class PartitionedAvroWriter:
128
128
  if self.current_writer:
129
129
  self.current_writer.close()
130
130
  self.current_writer = None
131
+ if self.current_url:
132
+ self.current_url.close()
131
133
  self.current_url_stream = None
132
134
  self.current_url = None
133
135
 
@@ -80,11 +80,13 @@ class ConcatTool:
80
80
 
81
81
  output_sync_marker = None
82
82
 
83
- with output_url.with_mode("wb").open() as out_f:
83
+ out_url = output_url.with_mode("wb")
84
+ with out_url as out_f:
84
85
  writer = BinaryEncoder(out_f)
85
86
 
86
87
  for i, url in enumerate(input_urls):
87
- with url.with_mode("rb").open() as in_f:
88
+ in_url = url.with_mode("rb")
89
+ with in_url as in_f:
88
90
  # Initialize DataFileReader to parse the header/metadata and
89
91
  # position the file pointer at the start of the first block.
90
92
  reader = DataFileReader(in_f, DatumReader())
@@ -90,7 +90,7 @@ class FileSortTool:
90
90
  schema: Schema,
91
91
  root: str,
92
92
  batch_id: int,
93
- records: list[object],
93
+ records: list[dict[str, Any]],
94
94
  sort_key: Callable[[Any], Any],
95
95
  reverse: bool = False,
96
96
  ) -> None:
@@ -147,6 +147,7 @@ class RepairTool:
147
147
  if dry_run:
148
148
  tmp = tempfile.NamedTemporaryFile(delete=False)
149
149
  output_url = parse_url(tmp.name, mode="wb")
150
+ tmp.close() # Close the handle; the URL will open it fresh
150
151
  logger.debug("Using temporary file %s for dry run", output_url)
151
152
  # Initialize the output file with the same schema and codec
152
153
  with avro_writer(output_url, schema, codec=codec) as writer:
@@ -3,6 +3,7 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
5
  import io
6
+ import os
6
7
  import tempfile
7
8
  from collections.abc import Sequence
8
9
  from typing import IO, Any
@@ -34,6 +35,7 @@ class HttpURL(URL):
34
35
  self.content_type = content_type
35
36
  self._current_response: requests.Response | None = None
36
37
  self._current_request_buffer: IO[Any] | None = None
38
+ self._current_request_temp_path: str | None = None
37
39
 
38
40
  @override
39
41
  def _expand(self) -> Sequence[URL]:
@@ -84,9 +86,11 @@ class HttpURL(URL):
84
86
  if self.spill_request_to_file:
85
87
  # Use a temporary file to store the request body
86
88
  buf = tempfile.NamedTemporaryFile(delete=False)
89
+ self._current_request_temp_path = buf.name
87
90
  else:
88
91
  # Use an in-memory buffer
89
92
  buf = io.BytesIO()
93
+ self._current_request_temp_path = None
90
94
  # Wrap in TextIOWrapper if not binary mode
91
95
  if "b" not in self.mode:
92
96
  buf = io.TextIOWrapper(buf, encoding="utf-8")
@@ -112,8 +116,15 @@ class HttpURL(URL):
112
116
  headers={"Content-Type": self.content_type},
113
117
  )
114
118
  res.raise_for_status()
115
- # Close the buffer (will delete temp file if used)
119
+ # Close the buffer
116
120
  self._current_request_buffer.close()
121
+ # Unlink temp file if used
122
+ if self._current_request_temp_path is not None:
123
+ try:
124
+ os.unlink(self._current_request_temp_path)
125
+ except FileNotFoundError:
126
+ pass
127
+ self._current_request_temp_path = None
117
128
 
118
129
  @override
119
130
  def with_mode(self, mode) -> URL:
@@ -3,6 +3,7 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
5
  import io
6
+ import os
6
7
  import tempfile
7
8
  from collections.abc import Sequence
8
9
  from contextlib import contextmanager
@@ -125,7 +126,16 @@ class S3URL(URL):
125
126
  self._current_client.upload_fileobj(f, self.bucket, self.path)
126
127
  finally:
127
128
  # Cleanup resources
128
- self._current_local.close()
129
+ local = self._current_local
130
+ name = local.name
131
+ local.close()
132
+ # N.b. the temp file is created with delete=False so it survives an external
133
+ # close (e.g. DataFileWriter closing the stream before URL.close() uploads).
134
+ # Unlink it explicitly here to avoid leaking a file per open/close cycle.
135
+ try:
136
+ os.unlink(name)
137
+ except FileNotFoundError:
138
+ pass
129
139
  self._current_client.close()
130
140
  self._current_local = None
131
141
  self._current_client = None
@@ -4,7 +4,7 @@
4
4
 
5
5
  [project]
6
6
  name = "avrokit"
7
- version = "0.1.3"
7
+ version = "0.1.4"
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"