Skip to content

Commit 6de68a9

Browse files
feat: 改进parametrize_fixture/parametrize_test,并且共享装饰器参数值处理器
- 新增 argvalues.py 共享装饰器参数值处理器 - 增强 parametrize_fixture 功能: - 支持可调用的参数值源 - 支持可选的cache=True / lazy=True参数 - 避免将cache/lazy参数转发到pytest.fixture - 与@pytest.fixture结合使用时发出警告 - test_decorators_extended.py中增加测试用例 - 修改 parametrize_test,以保留显式参数名并正确处理fixture值 - 更新 processor.py 中的参数化处理器: - 区分fixture注入与fixture作为数据源的情况 -支持parametrize_fixture元数据和fixture params参数 - 审核更新 tests/integration/test_combo/test_combo_01/下的集成测试用例: - test_gen.py - test_param_fix.py - test_param_test.py - test_pytest.py
1 parent 7673ee5 commit 6de68a9

9 files changed

Lines changed: 428 additions & 223 deletions

File tree

src/dynamic_params/engine/parametrize/processor.py

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ def process(self, metafunc: Metafunc) -> None:
5959

6060
# Get the underlying function
6161
underlying = getattr(argvalues, "__wrapped__", None) or argvalues
62+
63+
# Check if fixture was created by parametrize_fixture decorator
64+
# Check both the fixture function itself and the underlying function
65+
fixture_parametrize_config = getattr(
66+
argvalues, "_dynamic_fixture_parametrize", None
67+
) or getattr(underlying, "_dynamic_fixture_parametrize", None)
68+
6269
# Check if fixture has params= defined (parametrized fixture)
6370
fixture_marker = (
6471
getattr(argvalues, "__pytestfixturefunction__", None)
@@ -67,14 +74,36 @@ def process(self, metafunc: Metafunc) -> None:
6774
)
6875
fixture_params = getattr(fixture_marker, "params", None)
6976

70-
if fixture_params is not None:
71-
# Fixture has params= - each param becomes one test case value
72-
remaining.append(
73-
{
74-
"argnames": argnames_str,
75-
"argvalues": list(fixture_params),
76-
}
77-
)
77+
if fixture_parametrize_config is not None or fixture_params is not None:
78+
# Fixture was created by parametrize_fixture or has params=
79+
# If the requested argname matches the fixture name, inject it.
80+
# Otherwise use the fixture as a source of values.
81+
should_inject_fixture = fixture_name in argnames
82+
if should_inject_fixture:
83+
if fixture_name and fixture_name not in metafunc.fixturenames:
84+
metafunc.fixturenames.append(fixture_name)
85+
else:
86+
if fixture_params is not None:
87+
remaining.append(
88+
{
89+
"argnames": argnames_str,
90+
"argvalues": list(fixture_params),
91+
}
92+
)
93+
else:
94+
try:
95+
result = underlying()
96+
if not isinstance(result, list):
97+
result = [result]
98+
remaining.append(
99+
{
100+
"argnames": argnames_str,
101+
"argvalues": result,
102+
}
103+
)
104+
except Exception:
105+
if fixture_name and fixture_name not in metafunc.fixturenames:
106+
metafunc.fixturenames.append(fixture_name)
78107
elif not params or params == ["request"]:
79108
# Fixture takes no args - call directly to get values
80109
if not params:
@@ -263,23 +292,33 @@ def generate_param_combinations(
263292
else:
264293
# If argvalues is not a list, resolve it and use it directly
265294
# This supports direct function/generator references
266-
resolved_value = self._resolve_value(argvalues, {}, metafunc)
267-
# If resolved_value is a list (e.g., from a generator), expand it into separate test cases
268-
if isinstance(resolved_value, list):
269-
# Each item in the list becomes a separate test case
270-
# For single parameter, use the item directly
271-
# For multiple parameters, wrap in tuple
272-
for item in resolved_value:
273-
if len(argnames) == 1:
274-
# Single parameter, use item directly
275-
resolved_argvalues.append(item)
276-
else:
277-
# Multiple parameters, wrap in list/tuple
278-
resolved_argvalues.append(
279-
item if isinstance(item, (list, tuple)) else [item]
280-
)
295+
# Check if argvalues is a fixture function
296+
if self._is_fixture(argvalues):
297+
# Fixture function - let pytest inject it
298+
fixture_name = getattr(argvalues, "__name__", None)
299+
if fixture_name and fixture_name not in metafunc.fixturenames:
300+
metafunc.fixturenames.append(fixture_name)
301+
# Use fixture name as parameter value (pytest will replace it)
302+
# For now, use a placeholder - pytest will inject the actual fixture value
303+
resolved_argvalues.append([argvalues])
281304
else:
282-
resolved_argvalues.append([resolved_value])
305+
resolved_value = self._resolve_value(argvalues, {}, metafunc)
306+
# If resolved_value is a list (e.g., from a generator), expand it into separate test cases
307+
if isinstance(resolved_value, list):
308+
# Each item in the list becomes a separate test case
309+
# For single parameter, use the item directly
310+
# For multiple parameters, wrap in tuple
311+
for item in resolved_value:
312+
if len(argnames) == 1:
313+
# Single parameter, use item directly
314+
resolved_argvalues.append(item)
315+
else:
316+
# Multiple parameters, wrap in list/tuple
317+
resolved_argvalues.append(
318+
item if isinstance(item, (list, tuple)) else [item]
319+
)
320+
else:
321+
resolved_argvalues.append([resolved_value])
283322

284323
processed_parametrizations.append(
285324
{"argnames": argnames, "argvalues": resolved_argvalues}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import inspect
2+
from typing import Any, Dict, Optional
3+
4+
5+
def _is_pytest_fixture(obj: Any) -> bool:
6+
return (
7+
hasattr(obj, "__pytestfixturefunction__")
8+
or hasattr(obj, "_fixture_function_marker")
9+
or hasattr(obj, "_pytestfixturefunction")
10+
)
11+
12+
13+
def process_argvalues(argvalues: Any, options: Optional[Dict[str, Any]] = None) -> Any:
14+
"""Unified argvalues processing used by decorators.
15+
16+
Behavior:
17+
- generator objects/functions -> evaluated to list (unless wrapped as GeneratorBase)
18+
- callable fixtures -> returned as-is (pytest fixtures)
19+
- callable normal functions -> if `cache`/`lazy` in options -> wrapped into
20+
GeneratorBase/LazyGenerator; otherwise called and result returned (convert
21+
generator results to list)
22+
23+
options: dict that may contain `cache`, `lazy`, `scope` keys.
24+
"""
25+
opts = options or {}
26+
cache = opts.get("cache", False)
27+
lazy = opts.get("lazy", False)
28+
scope = opts.get("scope", "function")
29+
30+
# generator object
31+
if inspect.isgenerator(argvalues):
32+
return list(argvalues)
33+
34+
# callable factory or fixture
35+
if callable(argvalues) and not isinstance(argvalues, (list, tuple, type)):
36+
# detect pytest fixture
37+
if _is_pytest_fixture(argvalues):
38+
return argvalues
39+
40+
# if already a GeneratorBase instance, return as-is
41+
try:
42+
from ..engine.generator.base import GeneratorBase
43+
except Exception:
44+
GeneratorBase = None
45+
46+
if GeneratorBase is not None and isinstance(argvalues, GeneratorBase):
47+
return argvalues
48+
49+
# respect cache/lazy options: wrap as GeneratorBase/LazyGenerator
50+
if cache or lazy:
51+
if lazy:
52+
from ..engine.generator.lazy import LazyGenerator
53+
54+
return LazyGenerator(argvalues, scope=scope, cache=cache)
55+
else:
56+
# fallback to base generator
57+
from ..engine.generator.base import GeneratorBase as GB
58+
59+
return GB(argvalues, scope=scope, cache=cache)
60+
61+
# generator function (call to get generator)
62+
if inspect.isgeneratorfunction(argvalues):
63+
return list(argvalues())
64+
65+
# otherwise call the factory to obtain values
66+
result = argvalues()
67+
if inspect.isgenerator(result):
68+
return list(result)
69+
return result
70+
71+
return argvalues
72+
73+
74+
def is_pytest_fixture(obj: Any) -> bool:
75+
return _is_pytest_fixture(obj)
Lines changed: 110 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,138 @@
11
# Fixture parametrization decorator
22

3+
import inspect
4+
import warnings
35
from typing import Any, Callable, List
46

57
import pytest
68

9+
from ..argvalues import process_argvalues
10+
711

812
def parametrize_fixture(argnames: str, argvalues: List[Any], **kwargs: Any) -> Callable:
913
"""Decorator for parametrizing fixtures
1014
11-
This decorator allows you to parameterize fixtures with dynamic parameters,
12-
including support for generators and DynRef references.
15+
This decorator creates a parametrized fixture with the name specified in argnames,
16+
and the decorated function receives this fixture as a parameter.
1317
1418
Args:
15-
argnames: Comma-separated string of parameter names
16-
argvalues: List of parameter values
19+
argnames: Comma-separated string of parameter names (these become fixture names)
20+
argvalues: List of parameter values, generator object, or callable that returns a list
1721
**kwargs: Additional keyword arguments
1822
1923
Returns:
2024
Decorator function
25+
26+
Example:
27+
@parametrize_fixture("value", [1, 2, 3])
28+
def my_fixture(value):
29+
return value * 2
30+
31+
def test_example(my_fixture):
32+
assert my_fixture in [2, 4, 6]
33+
34+
# Optional performance settings:
35+
@parametrize_fixture("value", get_values, cache=True, scope="session")
36+
def my_cached_fixture(value):
37+
return value
38+
39+
@parametrize_fixture("value", get_values, lazy=True)
40+
def my_lazy_fixture(value):
41+
return value
2142
"""
2243

2344
def decorator(func: Callable) -> Callable:
24-
# First apply the pytest fixture decorator
25-
# Check both old and new pytest attribute names for fixture detection
45+
# Process argvalues - use shared processor; this also supports optional
46+
# cache/lazy when provided in kwargs (backwards compatible if not set)
47+
processed_argvalues = process_argvalues(argvalues, kwargs)
48+
49+
# Warn when @pytest.fixture is combined with @parametrize_fixture,
50+
# because the two decorators have overlapping semantics and may be
51+
# ambiguous.
2652
is_fixture = (
2753
hasattr(func, "__pytestfixturefunction__")
2854
or hasattr(func, "_fixture_function_marker")
2955
or hasattr(func, "_pytestfixturefunction")
3056
)
57+
if is_fixture:
58+
warnings.warn(
59+
"Combining @pytest.fixture with @parametrize_fixture is not supported "
60+
"and may produce undefined behavior.",
61+
UserWarning,
62+
)
63+
64+
# Store parametrization info on the function for processor to use
65+
if not hasattr(func, "_dynamic_fixture_parametrize"):
66+
func._dynamic_fixture_parametrize = []
67+
68+
func._dynamic_fixture_parametrize.append({
69+
"argnames": argnames,
70+
"argvalues": processed_argvalues,
71+
**kwargs
72+
})
73+
74+
# Parse argnames to get the fixture names
75+
fixture_names = [name.strip() for name in argnames.split(",")]
76+
77+
# Create parametrized fixtures for each argname
78+
# Each fixture will be parametrized with the corresponding values
79+
for i, fixture_name in enumerate(fixture_names):
80+
# Create a simple fixture that yields the parametrized value
81+
# The fixture will be parametrized via pytest_generate_tests hook
82+
fixture_func = _create_parametrized_fixture(fixture_name, processed_argvalues, **kwargs)
83+
84+
# Register the fixture in the module's global namespace
85+
# This makes it available to other fixtures and tests
86+
module = inspect.getmodule(func)
87+
if module and not hasattr(module, fixture_name):
88+
setattr(module, fixture_name, fixture_func)
89+
90+
# Apply pytest.fixture decorator to the function if it is not already
91+
# a fixture. Filter cache/lazy keys before passing kwargs to pytest.
92+
fixture_kwargs = {
93+
k: v for k, v in kwargs.items() if k not in ("cache", "lazy")
94+
}
3195
if not is_fixture:
32-
func = pytest.fixture(**kwargs)(func)
33-
34-
# Note: In pytest 9+, marks on fixtures have no effect and raise a warning
35-
# We skip adding the mark for fixtures to avoid the warning
36-
# pytest.mark.dynamic_parametrize(
37-
# argnames=argnames,
38-
# argvalues=argvalues,
39-
# **kwargs
40-
# )(func)
96+
func = pytest.fixture(**fixture_kwargs)(func)
97+
4198
return func
4299

43100
return decorator
101+
102+
103+
def _create_parametrized_fixture(name: str, values: List[Any], **kwargs: Any):
104+
"""Create a parametrized fixture
105+
106+
Args:
107+
name: Fixture name
108+
values: List of parameter values
109+
**kwargs: Additional keyword arguments for pytest.fixture
110+
111+
Returns:
112+
A pytest fixture function
113+
"""
114+
# Create a fixture that receives request.param and returns it
115+
def parametrized_fixture(request):
116+
return request.param
117+
118+
parametrized_fixture.__name__ = name
119+
120+
# If values is a GeneratorBase-like instance, pass it directly to pytest.fixture
121+
# so pytest can iterate it lazily. Do not execute it eagerly here.
122+
try:
123+
from ...engine.generator.base import GeneratorBase
124+
except Exception:
125+
GeneratorBase = None
126+
127+
if GeneratorBase is not None and isinstance(values, GeneratorBase):
128+
params = values
129+
else:
130+
params = values
131+
132+
fixture_kwargs = {
133+
k: v for k, v in kwargs.items() if k not in ("cache", "lazy")
134+
}
135+
return pytest.fixture(params=params, name=name, **fixture_kwargs)(parametrized_fixture)
136+
137+
138+
# processing delegated to public.argvalues.process_argvalues

0 commit comments

Comments
 (0)