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.
@@ -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)