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.
- {avrokit-0.0.7 → avrokit-0.1.0}/Makefile +5 -4
- {avrokit-0.0.7 → avrokit-0.1.0}/PKG-INFO +7 -8
- {avrokit-0.0.7 → avrokit-0.1.0}/README.md +2 -6
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/reader.py +2 -1
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/writer.py +2 -7
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/file.py +3 -1
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/google.py +2 -1
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/http.py +7 -3
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/s3.py +2 -1
- {avrokit-0.0.7 → avrokit-0.1.0}/pyproject.toml +6 -2
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_io_schema.py +223 -20
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_tools_httpserver.py +49 -0
- avrokit-0.1.0/tests/test_url_contract.py +151 -0
- avrokit-0.1.0/tests/test_url_http.py +681 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/uv.lock +259 -3
- avrokit-0.0.7/tests/test_url_http.py +0 -91
- {avrokit-0.0.7 → avrokit-0.1.0}/.github/workflows/check.yml +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/.github/workflows/release.yml +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/.gitignore +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/LICENSE +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/LICENSES/Apache-2.0.txt +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/__init__.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/asyncio/__init__.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/asyncio/reader.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/asyncio/writer.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/__init__.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/compact.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/io/schema.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/py.typed +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/__init__.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/__main__.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/base.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/cat.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/concat.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/count.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/filesort.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/fromparquet.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/getmeta.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/getschema.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/httpserver.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/partition.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/repair.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/stats.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/tojson.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/tools/toparquet.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/__init__.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/base.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/factory.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/avrokit/url/utils.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/__init__.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_additional_coverage.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_asyncio.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_exports.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_io.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_plan_coverage.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_property_based.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_schema_advanced.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_tools_integration.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_tools_repair.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_url.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_url_file.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_url_google.py +0 -0
- {avrokit-0.0.7 → avrokit-0.1.0}/tests/test_url_s3.py +0 -0
- {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
|
|
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
|
|
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.
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
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
|
+
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
|
-
|
|
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
|
-
|
|
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]
|