pytest-datadriver 0.1.0__py3-none-any.whl
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.
- pytest_datadriver/__init__.py +28 -0
- pytest_datadriver/constants.py +61 -0
- pytest_datadriver/exceptions.py +52 -0
- pytest_datadriver/exporters/__init__.py +60 -0
- pytest_datadriver/exporters/base.py +63 -0
- pytest_datadriver/exporters/json_exporter.py +24 -0
- pytest_datadriver/exporters/yaml_exporter.py +32 -0
- pytest_datadriver/extractor.py +389 -0
- pytest_datadriver/loaders/__init__.py +49 -0
- pytest_datadriver/loaders/base.py +35 -0
- pytest_datadriver/loaders/json_loader.py +40 -0
- pytest_datadriver/loaders/yaml_loader.py +135 -0
- pytest_datadriver/models.py +76 -0
- pytest_datadriver/plugin.py +254 -0
- pytest_datadriver/resolver.py +205 -0
- pytest_datadriver-0.1.0.dist-info/METADATA +199 -0
- pytest_datadriver-0.1.0.dist-info/RECORD +20 -0
- pytest_datadriver-0.1.0.dist-info/WHEEL +5 -0
- pytest_datadriver-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_datadriver-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""AST-based parameter extractor for pytest test files.
|
|
2
|
+
|
|
3
|
+
Parses Python test files to extract @pytest.mark.parametrize decorators,
|
|
4
|
+
function signatures, and test metadata marks (caseNo, caseName, allure.title).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from pytest_datadriver.constants import AUTO_CASE_NO_PREFIX, MARK_CASE_NAME, MARK_CASE_NO
|
|
13
|
+
from pytest_datadriver.exceptions import DuplicateModuleError, ExtractionError
|
|
14
|
+
from pytest_datadriver.models import CaseEntry, ExportContext, ExportFilter, ModuleMatchMode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ParamExtractor:
|
|
18
|
+
"""Extract test parameters from Python test files using AST analysis."""
|
|
19
|
+
|
|
20
|
+
def extract(self, context: ExportContext) -> List[CaseEntry]:
|
|
21
|
+
"""Scan test directory and extract parameters from all test files.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
context: ExportContext with configuration options.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
List of CaseEntry objects extracted from test files.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ExtractionError: If a file cannot be parsed.
|
|
31
|
+
"""
|
|
32
|
+
test_path = os.path.abspath(context.test_path)
|
|
33
|
+
if not os.path.isdir(test_path):
|
|
34
|
+
raise ExtractionError(test_path, "Test directory does not exist")
|
|
35
|
+
|
|
36
|
+
all_cases: List[CaseEntry] = []
|
|
37
|
+
|
|
38
|
+
# Track filenames for duplicate detection (when using filename matching)
|
|
39
|
+
filename_to_paths: dict = {}
|
|
40
|
+
|
|
41
|
+
for root, dirs, files in os.walk(test_path):
|
|
42
|
+
for filename in files:
|
|
43
|
+
if not filename.endswith(".py") or filename.startswith("__"):
|
|
44
|
+
continue
|
|
45
|
+
filepath = os.path.join(root, filename)
|
|
46
|
+
|
|
47
|
+
# Check for duplicate filenames when using filename matching
|
|
48
|
+
if context.module_match == ModuleMatchMode.FILENAME:
|
|
49
|
+
if filename not in filename_to_paths:
|
|
50
|
+
filename_to_paths[filename] = []
|
|
51
|
+
filename_to_paths[filename].append(filepath)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
cases = self._extract_file(filepath, context.test_path)
|
|
55
|
+
all_cases.extend(cases)
|
|
56
|
+
except SyntaxError as e:
|
|
57
|
+
raise ExtractionError(filepath, f"Syntax error: {e}") from e
|
|
58
|
+
|
|
59
|
+
# Raise error if duplicate filenames detected with filename matching
|
|
60
|
+
if context.module_match == ModuleMatchMode.FILENAME:
|
|
61
|
+
for fname, paths in filename_to_paths.items():
|
|
62
|
+
if len(paths) > 1:
|
|
63
|
+
raise DuplicateModuleError(fname, paths)
|
|
64
|
+
|
|
65
|
+
return all_cases
|
|
66
|
+
|
|
67
|
+
def _extract_file(self, filepath: str, base_path: str) -> List[CaseEntry]:
|
|
68
|
+
"""Extract parameters from a single Python file.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
filepath: Absolute path to the Python file.
|
|
72
|
+
base_path: Base test directory path for relative module calculation.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of CaseEntry objects from this file.
|
|
76
|
+
"""
|
|
77
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
78
|
+
source = f.read()
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
tree = ast.parse(source, filename=filepath)
|
|
82
|
+
except SyntaxError:
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
cases: List[CaseEntry] = []
|
|
86
|
+
rel_module = os.path.relpath(filepath, os.path.abspath(base_path))
|
|
87
|
+
# Convert to forward slash for cross-platform consistency
|
|
88
|
+
rel_module = rel_module.replace("\\", "/")
|
|
89
|
+
|
|
90
|
+
for node in ast.walk(tree):
|
|
91
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
92
|
+
func_cases = self._extract_function(node, rel_module, filepath)
|
|
93
|
+
cases.extend(func_cases)
|
|
94
|
+
|
|
95
|
+
return cases
|
|
96
|
+
|
|
97
|
+
def _extract_function(
|
|
98
|
+
self, node: ast.FunctionDef, module: str, filepath: str
|
|
99
|
+
) -> List[CaseEntry]:
|
|
100
|
+
"""Extract parameters and metadata from a single test function.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
node: AST FunctionDef node.
|
|
104
|
+
module: Relative module path.
|
|
105
|
+
filepath: Absolute file path.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of CaseEntry objects for this function.
|
|
109
|
+
"""
|
|
110
|
+
func_name = node.name
|
|
111
|
+
|
|
112
|
+
# Skip non-test functions (only process test_* functions)
|
|
113
|
+
if not func_name.startswith("test_"):
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
# Extract parametrize decorators
|
|
117
|
+
parametrize_sets = self._extract_parametrize(node)
|
|
118
|
+
|
|
119
|
+
if not parametrize_sets:
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
# Extract caseNo and caseName marks
|
|
123
|
+
case_no_marks = self._extract_mark_values(node, MARK_CASE_NO)
|
|
124
|
+
case_name_marks = self._extract_mark_values(node, MARK_CASE_NAME)
|
|
125
|
+
allure_title = self._extract_allure_title(node)
|
|
126
|
+
|
|
127
|
+
# Extract function signature parameters (excluding self/cls)
|
|
128
|
+
func_params = self._extract_func_params(node)
|
|
129
|
+
|
|
130
|
+
# Identify fixture params: function signature params not in any parametrize argnames
|
|
131
|
+
all_argnames = set()
|
|
132
|
+
for pset in parametrize_sets:
|
|
133
|
+
all_argnames.update(pset["argnames"])
|
|
134
|
+
fixture_params = [p for p in func_params if p not in all_argnames]
|
|
135
|
+
|
|
136
|
+
# Flatten Cartesian product
|
|
137
|
+
flattened = self._flatten_cartesian(parametrize_sets)
|
|
138
|
+
|
|
139
|
+
# Build case entries
|
|
140
|
+
cases: List[CaseEntry] = []
|
|
141
|
+
for idx, flat_set in enumerate(flattened):
|
|
142
|
+
# Assign case_no
|
|
143
|
+
if idx < len(case_no_marks):
|
|
144
|
+
case_no = case_no_marks[idx]
|
|
145
|
+
else:
|
|
146
|
+
case_no = self._generate_case_no(module, func_name, idx)
|
|
147
|
+
|
|
148
|
+
# Assign case_name
|
|
149
|
+
if idx < len(case_name_marks):
|
|
150
|
+
case_name = case_name_marks[idx]
|
|
151
|
+
elif allure_title:
|
|
152
|
+
case_name = allure_title
|
|
153
|
+
else:
|
|
154
|
+
case_name = f"{func_name}[{case_no}]"
|
|
155
|
+
|
|
156
|
+
case = CaseEntry(
|
|
157
|
+
case_no=case_no,
|
|
158
|
+
case_name=case_name,
|
|
159
|
+
module=module,
|
|
160
|
+
function=func_name,
|
|
161
|
+
params=flat_set,
|
|
162
|
+
fixtures=fixture_params,
|
|
163
|
+
marks=list(set(case_no_marks + case_name_marks)),
|
|
164
|
+
source_location=f"{module}:{func_name}",
|
|
165
|
+
)
|
|
166
|
+
cases.append(case)
|
|
167
|
+
|
|
168
|
+
return cases
|
|
169
|
+
|
|
170
|
+
def _extract_parametrize(self, node: ast.FunctionDef) -> List[dict]:
|
|
171
|
+
"""Extract @pytest.mark.parametrize decorators from a function.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
List of dicts with 'argnames' (list of str) and 'argvalues' (list of tuples).
|
|
175
|
+
"""
|
|
176
|
+
parametrize_sets = []
|
|
177
|
+
|
|
178
|
+
for decorator in node.decorator_list:
|
|
179
|
+
call = self._resolve_decorator_call(decorator)
|
|
180
|
+
if call is None:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
func_path = self._get_attribute_path(call.func)
|
|
184
|
+
if not func_path or "pytest.mark.parametrize" not in func_path:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if len(call.args) < 2:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
# Extract argnames (first argument)
|
|
191
|
+
argnames = self._eval_ast_node(call.args[0])
|
|
192
|
+
if isinstance(argnames, str):
|
|
193
|
+
argnames = [a.strip() for a in argnames.split(",")]
|
|
194
|
+
elif not isinstance(argnames, list):
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
# Extract argvalues (second argument)
|
|
198
|
+
argvalues = self._eval_ast_node(call.args[1])
|
|
199
|
+
if not isinstance(argvalues, list):
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
parametrize_sets.append({
|
|
203
|
+
"argnames": argnames,
|
|
204
|
+
"argvalues": argvalues,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return parametrize_sets
|
|
208
|
+
|
|
209
|
+
def _flatten_cartesian(self, parametrize_sets: List[dict]) -> List[dict]:
|
|
210
|
+
"""Flatten Cartesian product of stacked parametrize decorators.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
parametrize_sets: List of parametrize dicts in definition order.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
List of dicts, each representing a single flattened parameter set.
|
|
217
|
+
"""
|
|
218
|
+
if not parametrize_sets:
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
# Start with the first parametrize set
|
|
222
|
+
result = []
|
|
223
|
+
for values in parametrize_sets[0]["argvalues"]:
|
|
224
|
+
params = {}
|
|
225
|
+
if isinstance(values, (list, tuple)):
|
|
226
|
+
for i, name in enumerate(parametrize_sets[0]["argnames"]):
|
|
227
|
+
if i < len(values):
|
|
228
|
+
params[name] = values[i]
|
|
229
|
+
else:
|
|
230
|
+
# Single value for single argname
|
|
231
|
+
params[parametrize_sets[0]["argnames"][0]] = values
|
|
232
|
+
result.append(params)
|
|
233
|
+
|
|
234
|
+
# Combine with remaining parametrize sets
|
|
235
|
+
for pset in parametrize_sets[1:]:
|
|
236
|
+
new_result = []
|
|
237
|
+
for existing_params in result:
|
|
238
|
+
for values in pset["argvalues"]:
|
|
239
|
+
combined = dict(existing_params)
|
|
240
|
+
if isinstance(values, (list, tuple)):
|
|
241
|
+
for i, name in enumerate(pset["argnames"]):
|
|
242
|
+
if i < len(values):
|
|
243
|
+
combined[name] = values[i]
|
|
244
|
+
else:
|
|
245
|
+
combined[pset["argnames"][0]] = values
|
|
246
|
+
new_result.append(combined)
|
|
247
|
+
result = new_result
|
|
248
|
+
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
def _extract_mark_values(self, node: ast.FunctionDef, mark_name: str) -> List[str]:
|
|
252
|
+
"""Extract values from @pytest.mark.<mark_name>(...) decorators.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
node: AST FunctionDef node.
|
|
256
|
+
mark_name: The mark name to look for (e.g., 'caseNo', 'caseName').
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List of mark values (string arguments).
|
|
260
|
+
"""
|
|
261
|
+
values = []
|
|
262
|
+
for decorator in node.decorator_list:
|
|
263
|
+
call = self._resolve_decorator_call(decorator)
|
|
264
|
+
if call is None:
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
func_path = self._get_attribute_path(call.func)
|
|
268
|
+
if not func_path:
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
# Match pytest.mark.<mark_name>(...)
|
|
272
|
+
if func_path == f"pytest.mark.{mark_name}":
|
|
273
|
+
for arg in call.args:
|
|
274
|
+
val = self._eval_ast_node(arg)
|
|
275
|
+
if val is not None:
|
|
276
|
+
values.append(str(val))
|
|
277
|
+
|
|
278
|
+
return values
|
|
279
|
+
|
|
280
|
+
def _extract_allure_title(self, node: ast.FunctionDef) -> Optional[str]:
|
|
281
|
+
"""Extract @allure.title(...) value.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
The title string, or None if not present.
|
|
285
|
+
"""
|
|
286
|
+
for decorator in node.decorator_list:
|
|
287
|
+
call = self._resolve_decorator_call(decorator)
|
|
288
|
+
if call is None:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
func_path = self._get_attribute_path(call.func)
|
|
292
|
+
if func_path == "allure.title":
|
|
293
|
+
if call.args:
|
|
294
|
+
val = self._eval_ast_node(call.args[0])
|
|
295
|
+
return str(val) if val is not None else None
|
|
296
|
+
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
def _extract_func_params(self, node: ast.FunctionDef) -> List[str]:
|
|
300
|
+
"""Extract function signature parameter names (excluding self/cls).
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
List of parameter names.
|
|
304
|
+
"""
|
|
305
|
+
params = []
|
|
306
|
+
for arg in node.args.args:
|
|
307
|
+
if arg.arg in ("self", "cls"):
|
|
308
|
+
continue
|
|
309
|
+
params.append(arg.arg)
|
|
310
|
+
return params
|
|
311
|
+
|
|
312
|
+
def _generate_case_no(self, module: str, func_name: str, idx: int) -> str:
|
|
313
|
+
"""Generate an auto case_no for a test function without caseNo marks.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
module: Module path.
|
|
317
|
+
func_name: Function name.
|
|
318
|
+
idx: Index in the flattened parameter set.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Auto-generated case_no string.
|
|
322
|
+
"""
|
|
323
|
+
safe_module = re.sub(r"[^a-zA-Z0-9]", "-", module.replace(".py", ""))
|
|
324
|
+
return f"{AUTO_CASE_NO_PREFIX}{safe_module}-{func_name}-{idx}"
|
|
325
|
+
|
|
326
|
+
# ---- AST utility methods ----
|
|
327
|
+
|
|
328
|
+
def _resolve_decorator_call(self, decorator):
|
|
329
|
+
"""Resolve decorator to the underlying Call node."""
|
|
330
|
+
if isinstance(decorator, ast.Call):
|
|
331
|
+
return decorator
|
|
332
|
+
# Decorator might be wrapped, e.g., @pytest.mark.caseNo(...) without ()
|
|
333
|
+
# ast treats decorators differently; try to unwrap
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
def _get_attribute_path(self, node) -> Optional[str]:
|
|
337
|
+
"""Get the dotted attribute path from an AST node (e.g., 'pytest.mark.parametrize')."""
|
|
338
|
+
if isinstance(node, ast.Attribute):
|
|
339
|
+
parts = []
|
|
340
|
+
current = node
|
|
341
|
+
while isinstance(current, ast.Attribute):
|
|
342
|
+
parts.append(current.attr)
|
|
343
|
+
current = current.value
|
|
344
|
+
if isinstance(current, ast.Name):
|
|
345
|
+
parts.append(current.id)
|
|
346
|
+
return ".".join(reversed(parts))
|
|
347
|
+
elif isinstance(node, ast.Name):
|
|
348
|
+
return node.id
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
def _eval_ast_node(self, node) -> Any:
|
|
352
|
+
"""Safely evaluate a simple AST node to its Python value.
|
|
353
|
+
|
|
354
|
+
Supports: constants, lists, tuples, strings, numbers, booleans, None.
|
|
355
|
+
"""
|
|
356
|
+
if isinstance(node, ast.Constant):
|
|
357
|
+
return node.value
|
|
358
|
+
elif isinstance(node, ast.List):
|
|
359
|
+
return [self._eval_ast_node(elt) for elt in node.elts]
|
|
360
|
+
elif isinstance(node, ast.Tuple):
|
|
361
|
+
return tuple(self._eval_ast_node(elt) for elt in node.elts)
|
|
362
|
+
elif isinstance(node, ast.Name):
|
|
363
|
+
if node.id == "True":
|
|
364
|
+
return True
|
|
365
|
+
elif node.id == "False":
|
|
366
|
+
return False
|
|
367
|
+
elif node.id == "None":
|
|
368
|
+
return None
|
|
369
|
+
elif isinstance(node, ast.UnaryOp):
|
|
370
|
+
if isinstance(node.op, ast.USub) and isinstance(node.operand, ast.Constant):
|
|
371
|
+
return -node.operand.value
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def should_include_case(case: CaseEntry, export_filter: ExportFilter) -> bool:
|
|
376
|
+
"""Check if a case should be included based on export filter.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
case: CaseEntry to check.
|
|
380
|
+
export_filter: Filter mode.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
True if the case should be included.
|
|
384
|
+
"""
|
|
385
|
+
if export_filter == ExportFilter.ALL:
|
|
386
|
+
return True
|
|
387
|
+
elif export_filter == ExportFilter.CASE_ONLY:
|
|
388
|
+
return not case.case_no.startswith(AUTO_CASE_NO_PREFIX)
|
|
389
|
+
return True
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Data loader factory with auto-detection of file format."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
from pytest_datadriver.loaders.base import DataLoader
|
|
6
|
+
from pytest_datadriver.loaders.yaml_loader import YamlLoader
|
|
7
|
+
from pytest_datadriver.loaders.json_loader import JsonLoader
|
|
8
|
+
from pytest_datadriver.models import CaseEntry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_loader(file_path: str) -> DataLoader:
|
|
12
|
+
"""Create a data loader based on file extension.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
file_path: Path to the data file.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
A DataLoader instance suitable for the file format.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ValueError: If the file format is not supported.
|
|
22
|
+
"""
|
|
23
|
+
fmt = DataLoader.detect_format(file_path)
|
|
24
|
+
if fmt == "yaml":
|
|
25
|
+
return YamlLoader()
|
|
26
|
+
elif fmt == "json":
|
|
27
|
+
return JsonLoader()
|
|
28
|
+
else:
|
|
29
|
+
raise ValueError(f"Unsupported format '{fmt}' for file: {file_path}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_data_file(file_path: str) -> Dict[str, List[CaseEntry]]:
|
|
33
|
+
"""Load a data file, auto-detecting format from extension.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
file_path: Path to the data file.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Dict mapping "module::function" -> list of CaseEntry objects.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
DataFileNotFoundError: If the file does not exist.
|
|
43
|
+
DataFileFormatError: If the file has invalid syntax.
|
|
44
|
+
"""
|
|
45
|
+
loader = create_loader(file_path)
|
|
46
|
+
return loader.load(file_path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["DataLoader", "YamlLoader", "JsonLoader", "create_loader", "load_data_file"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Abstract base class for data loaders."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Dict, List
|
|
5
|
+
|
|
6
|
+
from pytest_datadriver.models import CaseEntry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DataLoader(ABC):
|
|
10
|
+
"""Abstract base class for loading data files into CaseEntry objects."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def load(self, file_path: str) -> Dict[str, List[CaseEntry]]:
|
|
14
|
+
"""
|
|
15
|
+
Load a data file and return a unified internal data structure.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Dict mapping "module_path::function_name" -> list of CaseEntry objects.
|
|
19
|
+
"""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def _build_key(self, module: str, function: str) -> str:
|
|
23
|
+
"""Build a lookup key from module path and function name."""
|
|
24
|
+
return f"{module}::{function}"
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def detect_format(file_path: str) -> str:
|
|
28
|
+
"""Detect file format from extension."""
|
|
29
|
+
lower = file_path.lower()
|
|
30
|
+
if lower.endswith((".yaml", ".yml")):
|
|
31
|
+
return "yaml"
|
|
32
|
+
elif lower.endswith(".json"):
|
|
33
|
+
return "json"
|
|
34
|
+
else:
|
|
35
|
+
raise ValueError(f"Unsupported file format: {file_path}. Supported: .yaml, .yml, .json")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""JSON data loader supporting both case-first and function-first layouts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
|
|
7
|
+
from pytest_datadriver.exceptions import DataFileFormatError
|
|
8
|
+
from pytest_datadriver.loaders.base import DataLoader
|
|
9
|
+
from pytest_datadriver.loaders.yaml_loader import YamlLoader
|
|
10
|
+
from pytest_datadriver.models import CaseEntry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsonLoader(DataLoader):
|
|
14
|
+
"""Load test case data from JSON files."""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self._yaml_loader = YamlLoader()
|
|
18
|
+
|
|
19
|
+
def load(self, file_path: str) -> Dict[str, List[CaseEntry]]:
|
|
20
|
+
"""Load JSON data file and return unified internal structure."""
|
|
21
|
+
if not os.path.isfile(file_path):
|
|
22
|
+
raise DataFileFormatError(file_path, "File does not exist")
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
26
|
+
raw_data = json.load(f)
|
|
27
|
+
except json.JSONDecodeError as e:
|
|
28
|
+
raise DataFileFormatError(file_path, str(e)) from e
|
|
29
|
+
|
|
30
|
+
if raw_data is None:
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
if not isinstance(raw_data, dict):
|
|
34
|
+
raise DataFileFormatError(
|
|
35
|
+
file_path,
|
|
36
|
+
f"Expected a dict/object at top level, got {type(raw_data).__name__}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Reuse YAML loader's parsing logic on the dict
|
|
40
|
+
return self._yaml_loader._parse(raw_data, file_path)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""YAML data loader supporting both case-first and function-first layouts."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Dict, List
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from pytest_datadriver.exceptions import DataFileFormatError, DuplicateModuleError
|
|
9
|
+
from pytest_datadriver.loaders.base import DataLoader
|
|
10
|
+
from pytest_datadriver.models import CaseEntry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class YamlLoader(DataLoader):
|
|
14
|
+
"""Load test case data from YAML files."""
|
|
15
|
+
|
|
16
|
+
def load(self, file_path: str) -> Dict[str, List[CaseEntry]]:
|
|
17
|
+
"""Load YAML data file and return unified internal structure."""
|
|
18
|
+
if not os.path.isfile(file_path):
|
|
19
|
+
raise DataFileFormatError(file_path, "File does not exist")
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
23
|
+
raw_data = yaml.safe_load(f)
|
|
24
|
+
except yaml.YAMLError as e:
|
|
25
|
+
raise DataFileFormatError(file_path, str(e)) from e
|
|
26
|
+
|
|
27
|
+
if raw_data is None:
|
|
28
|
+
# Empty file
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
if not isinstance(raw_data, dict):
|
|
32
|
+
raise DataFileFormatError(
|
|
33
|
+
file_path,
|
|
34
|
+
f"Expected a dict/mapping at top level, got {type(raw_data).__name__}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Detect layout: case-first has case_no keys, function-first has module:function keys
|
|
38
|
+
return self._parse(raw_data, file_path)
|
|
39
|
+
|
|
40
|
+
def _parse(self, raw_data: dict, file_path: str) -> Dict[str, List[CaseEntry]]:
|
|
41
|
+
"""Parse raw data, auto-detecting layout."""
|
|
42
|
+
result: Dict[str, List[CaseEntry]] = {}
|
|
43
|
+
|
|
44
|
+
# Try to detect layout from first key
|
|
45
|
+
first_key = next(iter(raw_data.keys()), None)
|
|
46
|
+
if first_key is None:
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
# function-first: keys look like "module:function" or "module::function"
|
|
50
|
+
if self._looks_like_function_key(first_key):
|
|
51
|
+
self._parse_function_first(raw_data, result)
|
|
52
|
+
else:
|
|
53
|
+
# case-first: keys are case_no identifiers
|
|
54
|
+
self._parse_case_first(raw_data, result)
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
def _looks_like_function_key(self, key: str) -> bool:
|
|
59
|
+
"""Check if a key looks like a module:function reference."""
|
|
60
|
+
return ":" in key or ".py" in key
|
|
61
|
+
|
|
62
|
+
def _parse_case_first(self, raw_data: dict, result: Dict[str, List[CaseEntry]]) -> None:
|
|
63
|
+
"""Parse case-first layout: top-level keys are case_no."""
|
|
64
|
+
for case_no, entry_data in raw_data.items():
|
|
65
|
+
if not isinstance(entry_data, dict):
|
|
66
|
+
raise DataFileFormatError(
|
|
67
|
+
"", f"Expected dict for case '{case_no}', got {type(entry_data).__name__}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
module = entry_data.get("module", "")
|
|
71
|
+
function = entry_data.get("function", "")
|
|
72
|
+
case_name = entry_data.get("case_name", case_no)
|
|
73
|
+
params = entry_data.get("params", {})
|
|
74
|
+
fixtures = entry_data.get("fixtures", [])
|
|
75
|
+
marks = entry_data.get("marks", [])
|
|
76
|
+
|
|
77
|
+
case_entry = CaseEntry(
|
|
78
|
+
case_no=str(case_no),
|
|
79
|
+
case_name=str(case_name),
|
|
80
|
+
module=str(module),
|
|
81
|
+
function=str(function),
|
|
82
|
+
params=params if isinstance(params, dict) else {},
|
|
83
|
+
fixtures=list(fixtures) if isinstance(fixtures, list) else [],
|
|
84
|
+
marks=list(marks) if isinstance(marks, list) else [],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
key = self._build_key(module, function)
|
|
88
|
+
if key not in result:
|
|
89
|
+
result[key] = []
|
|
90
|
+
result[key].append(case_entry)
|
|
91
|
+
|
|
92
|
+
def _parse_function_first(self, raw_data: dict, result: Dict[str, List[CaseEntry]]) -> None:
|
|
93
|
+
"""Parse function-first layout: top-level keys are module:function references."""
|
|
94
|
+
for func_key, cases in raw_data.items():
|
|
95
|
+
# Parse "module:function" or "module::function" format
|
|
96
|
+
if "::" in func_key:
|
|
97
|
+
module, function = func_key.rsplit("::", 1)
|
|
98
|
+
elif ":" in func_key:
|
|
99
|
+
module, function = func_key.rsplit(":", 1)
|
|
100
|
+
else:
|
|
101
|
+
raise DataFileFormatError(
|
|
102
|
+
"", f"Invalid function-first key '{func_key}': expected 'module:function' format"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if not isinstance(cases, list):
|
|
106
|
+
raise DataFileFormatError(
|
|
107
|
+
"", f"Expected list for '{func_key}', got {type(cases).__name__}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
for case_data in cases:
|
|
111
|
+
if not isinstance(case_data, dict):
|
|
112
|
+
raise DataFileFormatError(
|
|
113
|
+
"", f"Expected dict in case list for '{func_key}', got {type(case_data).__name__}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
case_no = case_data.get("case_no", "")
|
|
117
|
+
case_name = case_data.get("case_name", case_no)
|
|
118
|
+
params = case_data.get("params", {})
|
|
119
|
+
fixtures = case_data.get("fixtures", [])
|
|
120
|
+
marks = case_data.get("marks", [])
|
|
121
|
+
|
|
122
|
+
case_entry = CaseEntry(
|
|
123
|
+
case_no=str(case_no),
|
|
124
|
+
case_name=str(case_name),
|
|
125
|
+
module=str(module),
|
|
126
|
+
function=str(function),
|
|
127
|
+
params=params if isinstance(params, dict) else {},
|
|
128
|
+
fixtures=list(fixtures) if isinstance(fixtures, list) else [],
|
|
129
|
+
marks=list(marks) if isinstance(marks, list) else [],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
key = self._build_key(module, function)
|
|
133
|
+
if key not in result:
|
|
134
|
+
result[key] = []
|
|
135
|
+
result[key].append(case_entry)
|