avrokit 0.0.7__tar.gz → 0.1.0__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.0}/Makefile +5 -4
  2. {avrokit-0.0.7 → avrokit-0.1.0}/PKG-INFO +7 -8
  3. {avrokit-0.0.7 → avrokit-0.1.0}/README.md +2 -6
  4. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/reader.py +2 -1
  5. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/writer.py +2 -7
  6. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/file.py +3 -1
  7. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/google.py +2 -1
  8. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/http.py +7 -3
  9. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/s3.py +2 -1
  10. {avrokit-0.0.7 → avrokit-0.1.0}/pyproject.toml +6 -2
  11. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_io_schema.py +223 -20
  12. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_tools_httpserver.py +49 -0
  13. avrokit-0.1.0/tests/test_url_contract.py +151 -0
  14. avrokit-0.1.0/tests/test_url_http.py +681 -0
  15. {avrokit-0.0.7 → avrokit-0.1.0}/uv.lock +259 -3
  16. avrokit-0.0.7/tests/test_url_http.py +0 -91
  17. {avrokit-0.0.7 → avrokit-0.1.0}/.github/workflows/check.yml +0 -0
  18. {avrokit-0.0.7 → avrokit-0.1.0}/.github/workflows/release.yml +0 -0
  19. {avrokit-0.0.7 → avrokit-0.1.0}/.gitignore +0 -0
  20. {avrokit-0.0.7 → avrokit-0.1.0}/LICENSE +0 -0
  21. {avrokit-0.0.7 → avrokit-0.1.0}/LICENSES/Apache-2.0.txt +0 -0
  22. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/__init__.py +0 -0
  23. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/asyncio/__init__.py +0 -0
  24. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/asyncio/reader.py +0 -0
  25. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/asyncio/writer.py +0 -0
  26. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/__init__.py +0 -0
  27. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/compact.py +0 -0
  28. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/schema.py +0 -0
  29. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/py.typed +0 -0
  30. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/__init__.py +0 -0
  31. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/__main__.py +0 -0
  32. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/base.py +0 -0
  33. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/cat.py +0 -0
  34. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/concat.py +0 -0
  35. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/count.py +0 -0
  36. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/filesort.py +0 -0
  37. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/fromparquet.py +0 -0
  38. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/getmeta.py +0 -0
  39. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/getschema.py +0 -0
  40. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/httpserver.py +0 -0
  41. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/partition.py +0 -0
  42. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/repair.py +0 -0
  43. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/stats.py +0 -0
  44. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/tojson.py +0 -0
  45. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/toparquet.py +0 -0
  46. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/__init__.py +0 -0
  47. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/base.py +0 -0
  48. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/factory.py +0 -0
  49. {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/utils.py +0 -0
  50. {avrokit-0.0.7 → avrokit-0.1.0}/tests/__init__.py +0 -0
  51. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_additional_coverage.py +0 -0
  52. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_asyncio.py +0 -0
  53. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_exports.py +0 -0
  54. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_io.py +0 -0
  55. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_plan_coverage.py +0 -0
  56. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_property_based.py +0 -0
  57. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_schema_advanced.py +0 -0
  58. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_tools_integration.py +0 -0
  59. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_tools_repair.py +0 -0
  60. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_url.py +0 -0
  61. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_url_file.py +0 -0
  62. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_url_google.py +0 -0
  63. {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_url_s3.py +0 -0
  64. {avrokit-0.0.7 → avrokit-0.1.0}/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.0
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
 
@@ -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
 
@@ -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
 
@@ -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
 
@@ -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
 
@@ -4,18 +4,20 @@
4
4
 
5
5
  [project]
6
6
  name = "avrokit"
7
- version = "0.0.7"
7
+ version = "0.1.0"
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"]
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
+ # SPDX-License-Identifier: Apache-2.0
6
+
5
7
  import pytest
6
8
  from avro.schema import RecordSchema
7
9
 
@@ -107,10 +109,10 @@ from avrokit.io.schema import add_avro_schema_fields, validate_avro_schema_evolu
107
109
  "fields": [
108
110
  {
109
111
  "name": "status",
112
+ # Added a new symbol 'PENDING'
110
113
  "type": {
111
114
  "type": "enum",
112
115
  "name": "Status",
113
- # Added a new symbol 'PENDING'
114
116
  "symbols": ["ACTIVE", "INACTIVE", "PENDING"],
115
117
  },
116
118
  }
@@ -123,21 +125,15 @@ from avrokit.io.schema import add_avro_schema_fields, validate_avro_schema_evolu
123
125
  "name": "Record",
124
126
  "type": "record",
125
127
  "fields": [
126
- {
127
- "name": "status",
128
- "type": ["null", "string"],
129
- }
128
+ {"name": "status", "type": ["null", "string"]},
130
129
  ],
131
130
  },
132
131
  {
133
132
  "name": "Record",
134
133
  "type": "record",
135
134
  "fields": [
136
- {
137
- "name": "status",
138
- # Added a new type 'int' to the union
139
- "type": ["null", "string", "int"],
140
- }
135
+ # Added a new type 'int' to the union
136
+ {"name": "status", "type": ["null", "string", "int"]},
141
137
  ],
142
138
  },
143
139
  ),
@@ -223,10 +219,10 @@ def test_validate_avro_schema_evolution_valid(schema_a: dict, schema_b: dict):
223
219
  "fields": [
224
220
  {
225
221
  "name": "status",
222
+ # Removed the symbol 'PENDING'
226
223
  "type": {
227
224
  "type": "enum",
228
225
  "name": "Status",
229
- # Removed the symbol 'PENDING'
230
226
  "symbols": ["ACTIVE", "INACTIVE"],
231
227
  },
232
228
  }
@@ -239,21 +235,15 @@ def test_validate_avro_schema_evolution_valid(schema_a: dict, schema_b: dict):
239
235
  "name": "Record",
240
236
  "type": "record",
241
237
  "fields": [
242
- {
243
- "name": "status",
244
- "type": ["null", "string", "int"],
245
- }
238
+ {"name": "status", "type": ["null", "string", "int"]},
246
239
  ],
247
240
  },
248
241
  {
249
242
  "name": "Record",
250
243
  "type": "record",
251
244
  "fields": [
252
- {
253
- "name": "status",
254
- # Removed the type 'int' from the union
255
- "type": ["null", "string"],
256
- }
245
+ # Removed the type 'int' from the union
246
+ {"name": "status", "type": ["null", "string"]},
257
247
  ],
258
248
  },
259
249
  ),
@@ -310,3 +300,216 @@ def test_add_avro_schema_fields():
310
300
  {"name": "address", "type": "string"},
311
301
  {"name": "phone", "type": "string"},
312
302
  ]
303
+
304
+
305
+ class TestSchemaEvolutionEdgeCases:
306
+ def test_enum_symbol_removal_raises_error(self):
307
+ """Test that narrowing enum symbols raises ValueError."""
308
+ schema_a = {
309
+ "name": "Record",
310
+ "type": "record",
311
+ "fields": [
312
+ {
313
+ "name": "status",
314
+ "type": {
315
+ "type": "enum",
316
+ "name": "Status",
317
+ "symbols": ["ACTIVE", "INACTIVE", "PENDING"],
318
+ },
319
+ },
320
+ ],
321
+ }
322
+ schema_b = {
323
+ "name": "Record",
324
+ "type": "record",
325
+ "fields": [
326
+ {
327
+ "name": "status",
328
+ "type": {"type": "enum", "name": "Status", "symbols": ["ACTIVE"]},
329
+ },
330
+ ],
331
+ }
332
+
333
+ with pytest.raises(ValueError, match="Field status enum has changed"):
334
+ validate_avro_schema_evolution(avro_schema(schema_a), avro_schema(schema_b))
335
+
336
+ def test_union_type_removal_raises_error(self):
337
+ """Test that removing types from union raises ValueError."""
338
+ schema_a = {
339
+ "name": "Record",
340
+ "type": "record",
341
+ "fields": [{"name": "status", "type": ["null", "string", "int"]}],
342
+ }
343
+ schema_b = {
344
+ "name": "Record",
345
+ "type": "record",
346
+ "fields": [{"name": "status", "type": ["null", "string"]}],
347
+ }
348
+
349
+ with pytest.raises(ValueError, match="Field status union has changed"):
350
+ validate_avro_schema_evolution(avro_schema(schema_a), avro_schema(schema_b))
351
+
352
+ def test_deeply_nested_record_addition(self):
353
+ """Test schema evolution through 3+ levels of nested records."""
354
+ schema_a = {
355
+ "name": "Record",
356
+ "type": "record",
357
+ "fields": [
358
+ {"name": "user_id", "type": "int"},
359
+ {
360
+ "name": "profile",
361
+ "type": {
362
+ "name": "Profile",
363
+ "type": "record",
364
+ "fields": [
365
+ {"name": "email", "type": "string"},
366
+ {
367
+ "name": "address",
368
+ "type": {
369
+ "name": "Address",
370
+ "type": "record",
371
+ "fields": [{"name": "city", "type": "string"}],
372
+ },
373
+ },
374
+ ],
375
+ },
376
+ },
377
+ ],
378
+ }
379
+
380
+ schema_b = {
381
+ "name": "Record",
382
+ "type": "record",
383
+ "fields": [
384
+ {"name": "user_id", "type": "int"},
385
+ {
386
+ "name": "profile",
387
+ "type": {
388
+ "name": "Profile",
389
+ "type": "record",
390
+ "fields": [
391
+ {"name": "email", "type": "string"},
392
+ {
393
+ "name": "address",
394
+ "type": {
395
+ "name": "Address",
396
+ "type": "record",
397
+ "fields": [{"name": "city", "type": "string"}],
398
+ },
399
+ },
400
+ {
401
+ "name": "country",
402
+ "type": ["null", "string"],
403
+ "default": None,
404
+ },
405
+ ],
406
+ },
407
+ },
408
+ ],
409
+ }
410
+
411
+ validate_avro_schema_evolution(avro_schema(schema_a), avro_schema(schema_b))
412
+
413
+ def test_deeply_nested_record_field_removal(self):
414
+ """Test that removing deeply nested field without default raises error."""
415
+ schema_a = {
416
+ "name": "Record",
417
+ "type": "record",
418
+ "fields": [
419
+ {"name": "user_id", "type": "int"},
420
+ {
421
+ "name": "profile",
422
+ "type": {
423
+ "name": "Profile",
424
+ "type": "record",
425
+ "fields": [{"name": "email", "type": "string"}],
426
+ },
427
+ },
428
+ ],
429
+ }
430
+
431
+ schema_b = {
432
+ "name": "Record",
433
+ "type": "record",
434
+ "fields": [
435
+ {"name": "user_id", "type": "int"},
436
+ {
437
+ "name": "profile",
438
+ "type": {
439
+ "name": "Profile",
440
+ "type": "record",
441
+ "fields": [],
442
+ },
443
+ },
444
+ ],
445
+ }
446
+
447
+ with pytest.raises(
448
+ ValueError, match="Field profile.email is missing a default value"
449
+ ):
450
+ validate_avro_schema_evolution(avro_schema(schema_a), avro_schema(schema_b))
451
+
452
+ def test_mixed_union_complex_type_changes(self):
453
+ """Test evolution of unions containing records and enums."""
454
+ schema_a = {
455
+ "name": "Record",
456
+ "type": "record",
457
+ "fields": [
458
+ {
459
+ "name": "value",
460
+ "type": [
461
+ "null",
462
+ {"type": "enum", "name": "Color", "symbols": ["RED", "GREEN"]},
463
+ ],
464
+ },
465
+ ],
466
+ }
467
+
468
+ schema_b = {
469
+ "name": "Record",
470
+ "type": "record",
471
+ "fields": [
472
+ {
473
+ "name": "value",
474
+ "type": [
475
+ "null",
476
+ {"type": "enum", "name": "Color", "symbols": ["RED"]},
477
+ ],
478
+ },
479
+ ],
480
+ }
481
+
482
+ with pytest.raises(ValueError, match="Field value union has changed"):
483
+ validate_avro_schema_evolution(avro_schema(schema_a), avro_schema(schema_b))
484
+
485
+ def test_field_type_change_to_non_union_raises_error(self):
486
+ """Test that changing field type to non-union raises error."""
487
+ schema_a = {
488
+ "name": "Record",
489
+ "type": "record",
490
+ "fields": [{"name": "count", "type": "int"}],
491
+ }
492
+ schema_b = {
493
+ "name": "Record",
494
+ "type": "record",
495
+ "fields": [{"name": "count", "type": "string"}],
496
+ }
497
+
498
+ with pytest.raises(ValueError, match="Field count type has changed"):
499
+ validate_avro_schema_evolution(avro_schema(schema_a), avro_schema(schema_b))
500
+
501
+ def test_field_type_change_to_union_without_null_raises_error(self):
502
+ """Test that changing to union without null raises error."""
503
+ schema_a = {
504
+ "name": "Record",
505
+ "type": "record",
506
+ "fields": [{"name": "count", "type": "int"}],
507
+ }
508
+ schema_b = {
509
+ "name": "Record",
510
+ "type": "record",
511
+ "fields": [{"name": "count", "type": ["string", "long"]}],
512
+ }
513
+
514
+ with pytest.raises(ValueError, match="Field count type has changed"):
515
+ validate_avro_schema_evolution(avro_schema(schema_a), avro_schema(schema_b))
@@ -2,6 +2,7 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
+ import concurrent.futures as futures
5
6
  import json
6
7
  import tempfile
7
8
  import threading
@@ -158,3 +159,51 @@ class TestHttpServerTool:
158
159
  # Check that the file is gone
159
160
  res = requests.get(f"{http_url}/newfile.avro")
160
161
  assert res.status_code == 404
162
+
163
+ def test_invalid_accept_header(self, tmpserver):
164
+ http_url, _ = tmpserver
165
+ res = requests.get(
166
+ f"{http_url}/test.avro", headers={"Accept": "application/unknown"}
167
+ )
168
+ assert res.status_code in [406, 200]
169
+
170
+ def test_concurrent_requests(self, tmpserver):
171
+ http_url, _ = tmpserver
172
+
173
+ def make_request():
174
+ res = requests.get(
175
+ f"{http_url}/test.avro", headers={"Accept": "application/json"}
176
+ )
177
+ data = [
178
+ json.loads(line) for line in res.content.decode("utf-8").splitlines()
179
+ ]
180
+ return len(data)
181
+
182
+ with futures.ThreadPoolExecutor(max_workers=10) as executor:
183
+ results = list(executor.map(lambda _: make_request(), range(5)))
184
+
185
+ assert all(isinstance(r, int) for r in results), (
186
+ "All results should be integers"
187
+ )
188
+ assert len(results) == 5, f"Expected 5 results, got {len(results)}"
189
+ expected_count = 10
190
+ for result in results:
191
+ assert result == expected_count, (
192
+ f"Expected count {expected_count}, got {result}"
193
+ )
194
+
195
+ def test_file_not_found(self, tmpserver):
196
+ http_url, _ = tmpserver
197
+ res = requests.get(f"{http_url}/nonexistent.avro")
198
+ assert res.status_code == 404
199
+
200
+ def test_put_invalid_schema_json(self, tmpserver):
201
+ http_url, root_url = tmpserver
202
+ res = requests.put(f"{http_url}/invalid_file.avro", json={"type": "recordy"})
203
+ assert res.status_code in [400, 500]
204
+
205
+
206
+ def test_post_to_nonexistent_file(tmpserver):
207
+ http_url, root_url = tmpserver
208
+ res = requests.post(f"{http_url}/newfile.avro", json={"name": "test"})
209
+ assert res.status_code in [201, 405, 404]