1+ import csv
12import gzip
3+ import io
24import zipfile
35from functools import partial
46from typing import TYPE_CHECKING , Any , Union
1517
1618
1719def 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+
100184async 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