Skip to content

Commit 489b052

Browse files
authored
feat: gzip and zipped fixture support (#500)
Enables loading of zipped or gzipped fixture files
1 parent 2e7c3ea commit 489b052

File tree

11 files changed

+1837
-1010
lines changed

11 files changed

+1837
-1010
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,6 @@ cython_debug/
173173
.cursorignore
174174
.zed
175175
.cursor
176+
CLAUDE.md
177+
.gitignore
178+
.todos

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222
- id: unasyncd
2323
additional_dependencies: ["ruff"]
2424
- repo: https://github.com/charliermarsh/ruff-pre-commit
25-
rev: "v0.12.1"
25+
rev: "v0.12.7"
2626
hooks:
2727
# Run the linter.
2828
- id: ruff

advanced_alchemy/filters.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) ->
422422
:class:`sqlalchemy.sql.expression.Select`: SQLAlchemy SELECT statement
423423
"""
424424
if isinstance(statement, Select):
425-
return cast("StatementTypeT", statement.limit(self.limit).offset(self.offset))
425+
return statement.limit(self.limit).offset(self.offset)
426426
return statement
427427

428428

@@ -468,8 +468,8 @@ def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) ->
468468
return statement
469469
field = self._get_instrumented_attr(model, self.field_name)
470470
if self.sort_order == "desc":
471-
return cast("StatementTypeT", statement.order_by(field.desc()))
472-
return cast("StatementTypeT", statement.order_by(field.asc()))
471+
return statement.order_by(field.desc())
472+
return statement.order_by(field.asc())
473473

474474

475475
@dataclass

advanced_alchemy/repository/_util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,4 @@ def _order_by_attribute(
338338
"""
339339
if not isinstance(statement, Select):
340340
return statement
341-
return cast("StatementTypeT", statement.order_by(field.desc() if is_desc else field.asc()))
341+
return statement.order_by(field.desc() if is_desc else field.asc())

advanced_alchemy/service/_util.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -243,16 +243,13 @@ def to_schema(
243243
)
244244
if MSGSPEC_INSTALLED and issubclass(schema_type, Struct):
245245
if not isinstance(data, Sequence):
246-
return cast(
247-
"ModelDTOT",
248-
convert(
249-
obj=data,
250-
type=schema_type,
251-
from_attributes=True,
252-
dec_hook=partial(
253-
_default_msgspec_deserializer,
254-
type_decoders=DEFAULT_TYPE_DECODERS,
255-
),
246+
return convert(
247+
obj=data,
248+
type=schema_type,
249+
from_attributes=True,
250+
dec_hook=partial(
251+
_default_msgspec_deserializer,
252+
type_decoders=DEFAULT_TYPE_DECODERS,
256253
),
257254
)
258255
limit_offset = find_filter(LimitOffset, filters=filters)

advanced_alchemy/types/mutables.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,15 @@ def remove(self, i: "T") -> None:
9595

9696
def clear(self) -> None:
9797
self._pending_removed.update(self)
98-
list.clear(self) # type: ignore[arg-type] # pyright: ignore[reportUnknownMemberType]
98+
list.clear(self) # pyright: ignore[reportUnknownMemberType]
9999
self.changed()
100100

101101
def sort(self, **kw: "Any") -> None:
102102
list.sort(self, **kw) # pyright: ignore[reportUnknownMemberType]
103103
self.changed()
104104

105105
def reverse(self) -> None:
106-
list.reverse(self) # type: ignore[arg-type] # pyright: ignore[reportUnknownMemberType]
106+
list.reverse(self) # pyright: ignore[reportUnknownMemberType]
107107
self.changed()
108108

109109
def _finalize_pending(self) -> None:

advanced_alchemy/utils/fixtures.py

Lines changed: 158 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import gzip
2+
import zipfile
3+
from functools import partial
14
from typing import TYPE_CHECKING, Any, Union
25

36
from advanced_alchemy._serialization import decode_json
@@ -12,53 +15,185 @@
1215

1316

1417
def open_fixture(fixtures_path: "Union[Path, AsyncPath]", fixture_name: str) -> Any:
15-
"""Loads JSON file with the specified fixture name
18+
"""Loads JSON file with the specified fixture name.
19+
20+
Supports plain JSON files, gzipped JSON files (.json.gz), and zipped JSON files (.json.zip).
21+
The function automatically detects the file format based on file extension and handles
22+
decompression transparently. Supports both lowercase and uppercase variations for better
23+
compatibility with database exports.
1624
1725
Args:
18-
fixtures_path: :class:`pathlib.Path` | :class:`anyio.Path` The path to look for fixtures
19-
fixture_name (str): The fixture name to load.
26+
fixtures_path: The path to look for fixtures. Can be a :class:`pathlib.Path` or
27+
:class:`anyio.Path` instance.
28+
fixture_name: The fixture name to load (without file extension).
2029
2130
Raises:
22-
:class:`FileNotFoundError`: Fixtures not found.
31+
FileNotFoundError: If no fixture file is found with any supported extension.
32+
OSError: If there's an error reading or decompressing the file.
33+
ValueError: If the JSON content is invalid.
34+
zipfile.BadZipFile: If the zip file is corrupted.
35+
gzip.BadGzipFile: If the gzip file is corrupted.
2336
2437
Returns:
25-
Any: The parsed JSON data
38+
Any: The parsed JSON data from the fixture file.
39+
40+
Examples:
41+
>>> from pathlib import Path
42+
>>> fixtures_path = Path("./fixtures")
43+
>>> data = open_fixture(
44+
... fixtures_path, "users"
45+
... ) # loads users.json, users.json.gz, or users.json.zip
46+
>>> print(data)
47+
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
2648
"""
2749
from pathlib import Path
2850

29-
fixture = Path(fixtures_path / f"{fixture_name}.json")
30-
if fixture.exists():
31-
with fixture.open(mode="r", encoding="utf-8") as f:
32-
f_data = f.read()
33-
return decode_json(f_data)
34-
msg = f"Could not find the {fixture_name} fixture"
51+
base_path = Path(fixtures_path)
52+
53+
# Try different file extensions in order of preference
54+
# Include both case variations for better compatibility with database exports
55+
file_variants = [
56+
(base_path / f"{fixture_name}.json", "plain"),
57+
(base_path / f"{fixture_name.upper()}.json.gz", "gzip"), # Uppercase first (common for exports)
58+
(base_path / f"{fixture_name}.json.gz", "gzip"),
59+
(base_path / f"{fixture_name.upper()}.json.zip", "zip"),
60+
(base_path / f"{fixture_name}.json.zip", "zip"),
61+
]
62+
63+
for fixture_path, file_type in file_variants:
64+
if fixture_path.exists():
65+
try:
66+
f_data: str
67+
if file_type == "plain":
68+
with fixture_path.open(mode="r", encoding="utf-8") as f:
69+
f_data = f.read()
70+
elif file_type == "gzip":
71+
with fixture_path.open(mode="rb") as f:
72+
compressed_data = f.read()
73+
f_data = gzip.decompress(compressed_data).decode("utf-8")
74+
elif file_type == "zip":
75+
with zipfile.ZipFile(fixture_path, mode="r") as zf:
76+
# Look for JSON file inside zip
77+
json_files = [name for name in zf.namelist() if name.endswith(".json")]
78+
if not json_files:
79+
msg = f"No JSON files found in zip archive: {fixture_path}"
80+
raise ValueError(msg)
81+
82+
# Use the first JSON file found, or prefer one matching the fixture name
83+
json_file = next((name for name in json_files if name == f"{fixture_name}.json"), json_files[0])
84+
85+
with zf.open(json_file, mode="r") as f:
86+
f_data = f.read().decode("utf-8")
87+
else:
88+
continue # Skip unknown file types
89+
90+
return decode_json(f_data)
91+
except (OSError, zipfile.BadZipFile, gzip.BadGzipFile) as exc:
92+
msg = f"Error reading fixture file {fixture_path}: {exc}"
93+
raise OSError(msg) from exc
94+
95+
# No valid fixture file found
96+
msg = f"Could not find the {fixture_name} fixture (tried .json, .json.gz, .json.zip with case variations)"
3597
raise FileNotFoundError(msg)
3698

3799

38100
async def open_fixture_async(fixtures_path: "Union[Path, AsyncPath]", fixture_name: str) -> Any:
39-
"""Loads JSON file with the specified fixture name
101+
"""Loads JSON file with the specified fixture name asynchronously.
102+
103+
Supports plain JSON files, gzipped JSON files (.json.gz), and zipped JSON files (.json.zip).
104+
The function automatically detects the file format based on file extension and handles
105+
decompression transparently. Supports both lowercase and uppercase variations for better
106+
compatibility with database exports. For compressed files, decompression is performed
107+
synchronously in a thread pool to avoid blocking the event loop.
40108
41109
Args:
42-
fixtures_path: :class:`pathlib.Path` | :class:`anyio.Path` The path to look for fixtures
43-
fixture_name (str): The fixture name to load.
110+
fixtures_path: The path to look for fixtures. Can be a :class:`pathlib.Path` or
111+
:class:`anyio.Path` instance.
112+
fixture_name: The fixture name to load (without file extension).
44113
45114
Raises:
46-
:class:`~advanced_alchemy.exceptions.MissingDependencyError`: The `anyio` library is required to use this function.
47-
:class:`FileNotFoundError`: Fixtures not found.
115+
MissingDependencyError: If the `anyio` library is not installed.
116+
FileNotFoundError: If no fixture file is found with any supported extension.
117+
OSError: If there's an error reading or decompressing the file.
118+
ValueError: If the JSON content is invalid.
119+
zipfile.BadZipFile: If the zip file is corrupted.
120+
gzip.BadGzipFile: If the gzip file is corrupted.
48121
49122
Returns:
50-
Any: The parsed JSON data
123+
Any: The parsed JSON data from the fixture file.
124+
125+
Examples:
126+
>>> from anyio import Path as AsyncPath
127+
>>> fixtures_path = AsyncPath("./fixtures")
128+
>>> data = await open_fixture_async(
129+
... fixtures_path, "users"
130+
... ) # loads users.json, users.json.gz, or users.json.zip
131+
>>> print(data)
132+
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
51133
"""
52134
try:
53135
from anyio import Path as AsyncPath
54136
except ImportError as exc:
55137
msg = "The `anyio` library is required to use this function. Please install it with `pip install anyio`."
56138
raise MissingDependencyError(msg) from exc
57139

58-
fixture = AsyncPath(fixtures_path / f"{fixture_name}.json")
59-
if await fixture.exists():
60-
async with await fixture.open(mode="r", encoding="utf-8") as f:
61-
f_data = await f.read()
62-
return decode_json(f_data)
63-
msg = f"Could not find the {fixture_name} fixture"
140+
from advanced_alchemy.utils.sync_tools import async_
141+
142+
def _read_zip_file(path: "AsyncPath", name: str) -> str:
143+
"""Helper function to read zip files."""
144+
with zipfile.ZipFile(str(path), mode="r") as zf:
145+
# Look for JSON file inside zip
146+
json_files = [file for file in zf.namelist() if file.endswith(".json")]
147+
if not json_files:
148+
error_msg = f"No JSON files found in zip archive: {path}"
149+
raise ValueError(error_msg)
150+
151+
# Use the first JSON file found, or prefer one matching the fixture name
152+
json_file = next((file for file in json_files if file == f"{name}.json"), json_files[0])
153+
154+
with zf.open(json_file, mode="r") as f:
155+
return f.read().decode("utf-8")
156+
157+
base_path = AsyncPath(fixtures_path)
158+
159+
# Try different file extensions in order of preference
160+
# Include both case variations for better compatibility with database exports
161+
file_variants = [
162+
(base_path / f"{fixture_name}.json", "plain"),
163+
(base_path / f"{fixture_name.upper()}.json.gz", "gzip"), # Uppercase first (common for exports)
164+
(base_path / f"{fixture_name}.json.gz", "gzip"),
165+
(base_path / f"{fixture_name.upper()}.json.zip", "zip"),
166+
(base_path / f"{fixture_name}.json.zip", "zip"),
167+
]
168+
169+
for fixture_path, file_type in file_variants:
170+
if await fixture_path.exists():
171+
try:
172+
f_data: str
173+
if file_type == "plain":
174+
async with await fixture_path.open(mode="r", encoding="utf-8") as f:
175+
f_data = await f.read()
176+
elif file_type == "gzip":
177+
# Read gzipped files using binary pattern
178+
async with await fixture_path.open(mode="rb") as f: # type: ignore[assignment]
179+
compressed_data: bytes = await f.read() # type: ignore[assignment]
180+
181+
# Decompress in thread pool to avoid blocking
182+
def _decompress_gzip(data: bytes) -> str:
183+
return gzip.decompress(data).decode("utf-8")
184+
185+
f_data = await async_(partial(_decompress_gzip, compressed_data))()
186+
elif file_type == "zip":
187+
# Read zipped files in thread pool to avoid blocking
188+
f_data = await async_(partial(_read_zip_file, fixture_path, fixture_name))()
189+
else:
190+
continue # Skip unknown file types
191+
192+
return decode_json(f_data)
193+
except (OSError, zipfile.BadZipFile, gzip.BadGzipFile) as exc:
194+
msg = f"Error reading fixture file {fixture_path}: {exc}"
195+
raise OSError(msg) from exc
196+
197+
# No valid fixture file found
198+
msg = f"Could not find the {fixture_name} fixture (tried .json, .json.gz, .json.zip with case variations)"
64199
raise FileNotFoundError(msg)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ classmethod-decorators = [
376376
"RUF029",
377377
"DOC",
378378
"UP007",
379+
"ASYNC230",
379380
]
380381

381382
[tool.ruff.lint.flake8-tidy-imports]

tests/integration/test_file_object.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import Optional
66

77
import pytest
8-
from minio import Minio
8+
from minio import Minio # type: ignore[import-untyped]
99
from pytest_databases.docker.minio import MinioService
1010
from sqlalchemy import Engine, String, create_engine, event
1111
from sqlalchemy.exc import InvalidRequestError

0 commit comments

Comments
 (0)