Skip to content

Commit 3c98e89

Browse files
feat(fixtures): add support .csv files for open_fixture (#615)
Add comprehensive CSV file support to the `open_fixture` and `open_fixture_async` functions, expanding the fixture loading capabilities beyond JSON to include comma-separated value files. Closes #536
1 parent 8916358 commit 3c98e89

5 files changed

Lines changed: 860 additions & 149 deletions

File tree

advanced_alchemy/utils/fixtures.py

Lines changed: 175 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import csv
12
import gzip
3+
import io
24
import zipfile
35
from functools import partial
46
from typing import TYPE_CHECKING, Any, Union
@@ -15,12 +17,16 @@
1517

1618

1719
def open_fixture(fixtures_path: "Union[Path, AsyncPath]", fixture_name: str) -> Any:
18-
"""Loads JSON file with the specified fixture name.
20+
"""Loads JSON or CSV file with the specified fixture name.
1921
20-
Supports plain JSON files, gzipped JSON files (.json.gz), and zipped JSON files (.json.zip).
22+
Supports plain files, gzipped files (.json.gz, .csv.gz), and zipped files (.json.zip, .csv.zip).
2123
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.
24+
decompression transparently. JSON files take priority over CSV files. Supports both
25+
lowercase and uppercase variations for better compatibility with database exports.
26+
27+
For CSV files, returns a list of dictionaries using csv.DictReader where each row
28+
becomes a dictionary with column headers as keys. Note that CSV values are always
29+
strings, unlike JSON which preserves data types.
2430
2531
Args:
2632
fixtures_path: The path to look for fixtures. Can be a :class:`pathlib.Path` or
@@ -30,48 +36,66 @@ def open_fixture(fixtures_path: "Union[Path, AsyncPath]", fixture_name: str) ->
3036
Raises:
3137
FileNotFoundError: If no fixture file is found with any supported extension.
3238
OSError: If there's an error reading or decompressing the file.
33-
ValueError: If the JSON content is invalid.
39+
ValueError: If the JSON content is invalid, or if a zip file doesn't contain
40+
the expected JSON/CSV files.
3441
zipfile.BadZipFile: If the zip file is corrupted.
3542
gzip.BadGzipFile: If the gzip file is corrupted.
43+
csv.Error: If the CSV content is malformed.
3644
3745
Returns:
38-
Any: The parsed JSON data from the fixture file.
46+
Any: The parsed data from the fixture file (JSON data or list of dictionaries from CSV).
3947
4048
Examples:
4149
>>> from pathlib import Path
4250
>>> fixtures_path = Path("./fixtures")
43-
>>> data = open_fixture(
44-
... fixtures_path, "users"
45-
... ) # loads users.json, users.json.gz, or users.json.zip
51+
52+
# Load JSON fixture
53+
>>> data = open_fixture(fixtures_path, "users")
54+
# loads users.json, users.json.gz, or users.json.zip
4655
>>> print(data)
4756
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
57+
58+
# Load CSV fixture
59+
>>> data = open_fixture(fixtures_path, "states")
60+
# loads states.csv, states.csv.gz, or states.csv.zip
61+
>>> print(data)
62+
[{"abbreviation": "AL", "name": "Alabama"}, {"abbreviation": "AK", "name": "Alaska"}]
4863
"""
4964
from pathlib import Path
5065

5166
base_path = Path(fixtures_path)
5267

5368
# Try different file extensions in order of preference
5469
# Include both case variations for better compatibility with database exports
70+
# JSON formats take priority over CSV for backward compatibility
5571
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"),
72+
(base_path / f"{fixture_name}.json", "json_plain"),
73+
(base_path / f"{fixture_name.upper()}.json.gz", "json_gzip"), # Uppercase first (common for exports)
74+
(base_path / f"{fixture_name}.json.gz", "json_gzip"),
75+
(base_path / f"{fixture_name.upper()}.json.zip", "json_zip"),
76+
(base_path / f"{fixture_name}.json.zip", "json_zip"),
77+
(base_path / f"{fixture_name}.csv", "csv_plain"),
78+
(base_path / f"{fixture_name.upper()}.csv", "csv_plain"), # Uppercase plain CSV
79+
(base_path / f"{fixture_name.upper()}.csv.gz", "csv_gzip"),
80+
(base_path / f"{fixture_name}.csv.gz", "csv_gzip"),
81+
(base_path / f"{fixture_name.upper()}.csv.zip", "csv_zip"),
82+
(base_path / f"{fixture_name}.csv.zip", "csv_zip"),
6183
]
6284

6385
for fixture_path, file_type in file_variants:
6486
if fixture_path.exists():
6587
try:
66-
f_data: str
67-
if file_type == "plain":
88+
# JSON handling
89+
if file_type == "json_plain":
6890
with fixture_path.open(mode="r", encoding="utf-8") as f:
6991
f_data = f.read()
70-
elif file_type == "gzip":
92+
return decode_json(f_data)
93+
if file_type == "json_gzip":
7194
with fixture_path.open(mode="rb") as f:
7295
compressed_data = f.read()
7396
f_data = gzip.decompress(compressed_data).decode("utf-8")
74-
elif file_type == "zip":
97+
return decode_json(f_data)
98+
if file_type == "json_zip":
7599
with zipfile.ZipFile(fixture_path, mode="r") as zf:
76100
# Look for JSON file inside zip
77101
json_files = [name for name in zf.namelist() if name.endswith(".json")]
@@ -84,27 +108,92 @@ def open_fixture(fixtures_path: "Union[Path, AsyncPath]", fixture_name: str) ->
84108

85109
with zf.open(json_file, mode="r") as f:
86110
f_data = f.read().decode("utf-8")
87-
else:
88-
continue # Skip unknown file types
111+
return decode_json(f_data)
112+
113+
# CSV handling
114+
if file_type == "csv_plain":
115+
with fixture_path.open(mode="r", encoding="utf-8-sig", newline="") as f:
116+
reader = csv.DictReader(f)
117+
return list(reader)
118+
if file_type == "csv_gzip":
119+
with fixture_path.open(mode="rb") as f:
120+
compressed_data = f.read()
121+
f_data = gzip.decompress(compressed_data).decode("utf-8-sig")
122+
reader = csv.DictReader(io.StringIO(f_data))
123+
return list(reader)
124+
if file_type == "csv_zip":
125+
with zipfile.ZipFile(fixture_path, mode="r") as zf:
126+
# Look for CSV file inside zip
127+
csv_files = [name for name in zf.namelist() if name.endswith(".csv")]
128+
if not csv_files:
129+
msg = f"No CSV files found in zip archive: {fixture_path}"
130+
raise ValueError(msg)
89131

90-
return decode_json(f_data)
132+
# Use the first CSV file found, or prefer one matching the fixture name
133+
csv_file = next((name for name in csv_files if name == f"{fixture_name}.csv"), csv_files[0])
134+
135+
with zf.open(csv_file, mode="r") as f:
136+
f_data = f.read().decode("utf-8-sig")
137+
reader = csv.DictReader(io.StringIO(f_data))
138+
return list(reader)
139+
continue # Skip unknown file types
91140
except (OSError, zipfile.BadZipFile, gzip.BadGzipFile) as exc:
92141
msg = f"Error reading fixture file {fixture_path}: {exc}"
93142
raise OSError(msg) from exc
94143

95144
# No valid fixture file found
96-
msg = f"Could not find the {fixture_name} fixture (tried .json, .json.gz, .json.zip with case variations)"
145+
msg = f"Could not find the {fixture_name} fixture (tried .json, .json.gz, .json.zip, .csv, .csv.gz, .csv.zip with case variations)"
97146
raise FileNotFoundError(msg)
98147

99148

149+
def _read_zip_file(path: "AsyncPath", name: str) -> str:
150+
"""Helper function to read JSON zip files."""
151+
with zipfile.ZipFile(str(path), mode="r") as zf:
152+
# Look for JSON file inside zip
153+
json_files = [file for file in zf.namelist() if file.endswith(".json")]
154+
if not json_files:
155+
error_msg = f"No JSON files found in zip archive: {path}"
156+
raise ValueError(error_msg)
157+
158+
# Use the first JSON file found, or prefer one matching the fixture name
159+
json_file = next((file for file in json_files if file == f"{name}.json"), json_files[0])
160+
161+
with zf.open(json_file, mode="r") as f:
162+
return f.read().decode("utf-8")
163+
164+
165+
def _read_csv_zip_file(path: "AsyncPath", name: str) -> "list[dict[str, Any]]":
166+
"""Helper function to read CSV zip files."""
167+
with zipfile.ZipFile(str(path), mode="r") as zf:
168+
# Look for CSV file inside zip
169+
csv_files = [file for file in zf.namelist() if file.endswith(".csv")]
170+
if not csv_files:
171+
error_msg = f"No CSV files found in zip archive: {path}"
172+
raise ValueError(error_msg)
173+
174+
# Use the first CSV file found, or prefer one matching the fixture name
175+
csv_file = next((file for file in csv_files if file == f"{name}.csv"), csv_files[0])
176+
177+
with zf.open(csv_file, mode="r") as f:
178+
f_data = f.read().decode("utf-8-sig")
179+
180+
reader = csv.DictReader(io.StringIO(f_data))
181+
return list(reader)
182+
183+
100184
async def open_fixture_async(fixtures_path: "Union[Path, AsyncPath]", fixture_name: str) -> Any:
101-
"""Loads JSON file with the specified fixture name asynchronously.
185+
"""Loads JSON or CSV file with the specified fixture name asynchronously.
102186
103-
Supports plain JSON files, gzipped JSON files (.json.gz), and zipped JSON files (.json.zip).
187+
Supports plain files, gzipped files (.json.gz, .csv.gz), and zipped files (.json.zip, .csv.zip).
104188
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.
189+
decompression transparently. JSON files take priority over CSV files. Supports both
190+
lowercase and uppercase variations for better compatibility with database exports.
191+
For compressed files and CSV parsing, operations are performed in a thread pool to
192+
avoid blocking the event loop.
193+
194+
For CSV files, returns a list of dictionaries using csv.DictReader where each row
195+
becomes a dictionary with column headers as keys. Note that CSV values are always
196+
strings, unlike JSON which preserves data types.
108197
109198
Args:
110199
fixtures_path: The path to look for fixtures. Can be a :class:`pathlib.Path` or
@@ -115,21 +204,30 @@ async def open_fixture_async(fixtures_path: "Union[Path, AsyncPath]", fixture_na
115204
MissingDependencyError: If the `anyio` library is not installed.
116205
FileNotFoundError: If no fixture file is found with any supported extension.
117206
OSError: If there's an error reading or decompressing the file.
118-
ValueError: If the JSON content is invalid.
207+
ValueError: If the JSON content is invalid, or if a zip file doesn't contain
208+
the expected JSON/CSV files.
119209
zipfile.BadZipFile: If the zip file is corrupted.
120210
gzip.BadGzipFile: If the gzip file is corrupted.
211+
csv.Error: If the CSV content is malformed.
121212
122213
Returns:
123-
Any: The parsed JSON data from the fixture file.
214+
Any: The parsed data from the fixture file (JSON data or list of dictionaries from CSV).
124215
125216
Examples:
126217
>>> from anyio import Path as AsyncPath
127218
>>> 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
219+
220+
# Load JSON fixture
221+
>>> data = await open_fixture_async(fixtures_path, "users")
222+
# loads users.json, users.json.gz, or users.json.zip
131223
>>> print(data)
132224
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
225+
226+
# Load CSV fixture
227+
>>> data = await open_fixture_async(fixtures_path, "states")
228+
# loads states.csv, states.csv.gz, or states.csv.zip
229+
>>> print(data)
230+
[{"abbreviation": "AL", "name": "Alabama"}, {"abbreviation": "AK", "name": "Alaska"}]
133231
"""
134232
try:
135233
from anyio import Path as AsyncPath
@@ -139,61 +237,76 @@ async def open_fixture_async(fixtures_path: "Union[Path, AsyncPath]", fixture_na
139237

140238
from advanced_alchemy.utils.sync_tools import async_
141239

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-
157240
base_path = AsyncPath(fixtures_path)
158241

159242
# Try different file extensions in order of preference
160243
# Include both case variations for better compatibility with database exports
244+
# JSON formats take priority over CSV for backward compatibility
161245
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"),
246+
(base_path / f"{fixture_name}.json", "json_plain"),
247+
(base_path / f"{fixture_name.upper()}.json.gz", "json_gzip"), # Uppercase first (common for exports)
248+
(base_path / f"{fixture_name}.json.gz", "json_gzip"),
249+
(base_path / f"{fixture_name.upper()}.json.zip", "json_zip"),
250+
(base_path / f"{fixture_name}.json.zip", "json_zip"),
251+
(base_path / f"{fixture_name}.csv", "csv_plain"),
252+
(base_path / f"{fixture_name.upper()}.csv", "csv_plain"), # Uppercase plain CSV
253+
(base_path / f"{fixture_name.upper()}.csv.gz", "csv_gzip"),
254+
(base_path / f"{fixture_name}.csv.gz", "csv_gzip"),
255+
(base_path / f"{fixture_name.upper()}.csv.zip", "csv_zip"),
256+
(base_path / f"{fixture_name}.csv.zip", "csv_zip"),
167257
]
168258

169259
for fixture_path, file_type in file_variants:
170260
if await fixture_path.exists():
171261
try:
172-
f_data: str
173-
if file_type == "plain":
262+
# JSON handling
263+
if file_type == "json_plain":
174264
async with await fixture_path.open(mode="r", encoding="utf-8") as f:
175265
f_data = await f.read()
176-
elif file_type == "gzip":
266+
return decode_json(f_data)
267+
if file_type == "json_gzip":
177268
# Read gzipped files using binary pattern
178269
async with await fixture_path.open(mode="rb") as f: # type: ignore[assignment]
179-
compressed_data: bytes = await f.read() # type: ignore[assignment]
270+
compressed_json: bytes = await f.read() # type: ignore[assignment]
180271

181272
# Decompress in thread pool to avoid blocking
182273
def _decompress_gzip(data: bytes) -> str:
183274
return gzip.decompress(data).decode("utf-8")
184275

185-
f_data = await async_(partial(_decompress_gzip, compressed_data))()
186-
elif file_type == "zip":
276+
f_data = await async_(partial(_decompress_gzip, compressed_json))()
277+
return decode_json(f_data)
278+
if file_type == "json_zip":
187279
# Read zipped files in thread pool to avoid blocking
188280
f_data = await async_(partial(_read_zip_file, fixture_path, fixture_name))()
189-
else:
190-
continue # Skip unknown file types
281+
return decode_json(f_data)
282+
283+
# CSV handling
284+
if file_type == "csv_plain":
285+
async with await fixture_path.open(mode="r", encoding="utf-8-sig") as f:
286+
f_data = await f.read()
287+
288+
# Parse CSV in thread pool to avoid blocking
289+
def _parse_csv(data: str) -> "list[dict[str, Any]]":
290+
reader = csv.DictReader(io.StringIO(data))
291+
return list(reader)
292+
293+
return await async_(partial(_parse_csv, f_data))()
294+
if file_type == "csv_gzip":
295+
async with await fixture_path.open(mode="rb") as f: # type: ignore[assignment]
296+
compressed_csv: bytes = await f.read() # type: ignore[assignment]
297+
298+
def _decompress_and_parse_csv(data: bytes) -> "list[dict[str, Any]]":
299+
decompressed = gzip.decompress(data).decode("utf-8-sig")
300+
reader = csv.DictReader(io.StringIO(decompressed))
301+
return list(reader)
191302

192-
return decode_json(f_data)
303+
return await async_(partial(_decompress_and_parse_csv, compressed_csv))()
304+
if file_type == "csv_zip":
305+
return await async_(partial(_read_csv_zip_file, fixture_path, fixture_name))()
193306
except (OSError, zipfile.BadZipFile, gzip.BadGzipFile) as exc:
194307
msg = f"Error reading fixture file {fixture_path}: {exc}"
195308
raise OSError(msg) from exc
196309

197310
# No valid fixture file found
198-
msg = f"Could not find the {fixture_name} fixture (tried .json, .json.gz, .json.zip with case variations)"
311+
msg = f"Could not find the {fixture_name} fixture (tried .json, .json.gz, .json.zip, .csv, .csv.gz, .csv.zip with case variations)"
199312
raise FileNotFoundError(msg)

0 commit comments

Comments
 (0)