From 9ae02e65ab1a7eac5118e5ced970f903d7970786 Mon Sep 17 00:00:00 2001 From: paneendrakumar0 Date: Fri, 16 Oct 2026 14:15:39 +0530 Subject: [PATCH 1/3] feat(mark): expose `pytest.ParameterSet` in public API (#13431) Expose ParameterSet publicly from pytest as the return type of pytest.param() for type annotations and isinstance checks. - Re-export ParameterSet in src/pytest/__init__.py and include in __all__ - Keep ParameterSet as a plain NamedTuple with values: Sequence[object] - Set ParameterSet.__module__ = "pytest" after class definition - Mark internal factory methods with :meta private: for Sphinx autodoc - Document pytest.ParameterSet in doc/en/reference/reference.rst under Objects - Add changelog entry and unit tests for public API and pickle roundtrip --- AUTHORS | 1 + changelog/13431.improvement.rst | 2 ++ doc/en/conf.py | 2 +- doc/en/reference/reference.rst | 6 ++++++ src/_pytest/mark/structures.py | 14 ++++++++++++-- src/pytest/__init__.py | 2 ++ testing/test_mark.py | 34 +++++++++++++++++++++++++++++++++ 7 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 changelog/13431.improvement.rst diff --git a/AUTHORS b/AUTHORS index bd1e27cb37d..3842077e7bc 100644 --- a/AUTHORS +++ b/AUTHORS @@ -382,6 +382,7 @@ Omri Golan Ondřej Súkup Onkesh Bansal Oscar Benjamin +Paneendra Kumar Parth Patel Patrick Hayes Patrick Lannigan diff --git a/changelog/13431.improvement.rst b/changelog/13431.improvement.rst new file mode 100644 index 00000000000..299642570d5 --- /dev/null +++ b/changelog/13431.improvement.rst @@ -0,0 +1,2 @@ +Exposed :class:`pytest.ParameterSet` as the type of the return value of :func:`pytest.param()`. +This can be used in type annotations and ``isinstance`` checks. diff --git a/doc/en/conf.py b/doc/en/conf.py index 6b83eb13f48..f5b2b64e097 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -67,6 +67,7 @@ ("py:class", "HookCaller"), ("py:class", "HookspecMarker"), ("py:exc", "PluginValidationError"), + ("py:class", "pluggy._compat.DistFacade"), # Might want to expose/TODO (https://github.com/pytest-dev/pytest/issues/7469) ("py:class", "ExceptionRepr"), ("py:class", "Exit"), @@ -78,7 +79,6 @@ ("py:class", "_pytest.fixtures.FixtureFunctionMarker"), ("py:class", "_pytest.fixtures.FixtureFunctionDefinition"), ("py:class", "_pytest.logging.LogCaptureHandler"), - ("py:class", "_pytest.mark.structures.ParameterSet"), # Intentionally undocumented/private ("py:class", "_pytest._code.code.Traceback"), ("py:class", "_pytest._py.path.LocalPath"), diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 4e0f40f4f93..83637258026 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1049,6 +1049,12 @@ OptionGroup .. autoclass:: pytest.OptionGroup() :members: +ParameterSet +~~~~~~~~~~~~ + +.. autoclass:: pytest.ParameterSet() + :members: + PytestPluginManager ~~~~~~~~~~~~~~~~~~~ diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 34a004f4881..48d29116e4f 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -81,9 +81,13 @@ def get_empty_parameterset_mark( class ParameterSet(NamedTuple): - """A set of values for a set of parameters along with associated marks and + """The return type of :func:`pytest.param`. + + A set of values for a set of parameters along with associated marks and an optional ID for the set. + .. versionadded:: 9.2 + Examples:: pytest.param(1, 2, 3) @@ -108,7 +112,7 @@ class ParameterSet(NamedTuple): # ParameterSet(values=(40, 2, 42), marks=(), id="everything") """ - values: Sequence[object | NotSetType] + values: Sequence[object] marks: Collection[MarkDecorator | Mark] id: str | _HiddenParam | None @@ -119,6 +123,7 @@ def param( marks: MarkDecorator | Collection[MarkDecorator | Mark] = (), id: str | _HiddenParam | None = None, ) -> ParameterSet: + """:meta private:""" if isinstance(marks, MarkDecorator): marks = (marks,) else: @@ -145,6 +150,8 @@ def extract_from( ) -> ParameterSet: """Extract from an object or objects. + :meta private: + :param parameterset: A legacy style parameterset that may or may not be a tuple, and may or may not be wrapped into a mess of mark objects. @@ -259,6 +266,9 @@ def _for_parametrize( return argnames, parameters +ParameterSet.__module__ = "pytest" + + @final @dataclasses.dataclass(frozen=True) class Mark: diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index f6ea24f95a1..ecd075ff5d3 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -42,6 +42,7 @@ from _pytest.mark import MarkDecorator from _pytest.mark import MarkGenerator from _pytest.mark import param +from _pytest.mark import ParameterSet from _pytest.monkeypatch import MonkeyPatch from _pytest.nodes import Collector from _pytest.nodes import Directory @@ -130,6 +131,7 @@ "MonkeyPatch", "OptionGroup", "Package", + "ParameterSet", "Parser", "PytestApproxDecimalToleranceWarning", "PytestAssertRewriteWarning", diff --git a/testing/test_mark.py b/testing/test_mark.py index 82a941df0d6..6694a905661 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1498,6 +1498,40 @@ def test_pytest_param_id_allows_none_or_string(s) -> None: assert pytest.param(id=s) +def test_parameterset_public_api() -> None: + from pytest import ParameterSet + + assert pytest.ParameterSet is ParameterSet + assert ParameterSet.__module__ == "pytest" + + p = pytest.param(1, 2, id="custom_id", marks=pytest.mark.skip) + assert isinstance(p, ParameterSet) + assert p.values == (1, 2) + assert len(p.marks) == 1 + (mark,) = p.marks + assert mark.name == "skip" + assert p.id == "custom_id" + + # Direct construction + direct = ParameterSet((1, 2), (), "custom_id") + assert isinstance(direct, ParameterSet) + assert direct.values == (1, 2) + assert direct.id == "custom_id" + + +def test_parameterset_pickle() -> None: + import pickle + + p = pytest.param("hello", 42, id="test_id") + pickled = pickle.dumps(p) + unpickled = pickle.loads(pickled) + + assert isinstance(unpickled, pytest.ParameterSet) + assert unpickled == p + assert unpickled.values == ("hello", 42) + assert unpickled.id == "test_id" + + @pytest.mark.parametrize("expr", ("NOT internal_err", "NOT (internal_err)", "bogus=")) def test_marker_expr_eval_failure_handling(pytester: Pytester, expr) -> None: foo = pytester.makepyfile( From 49fd4ff7fc8516747a65e589c7922fb9c6843d3b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:38:04 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/mark/structures.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 48d29116e4f..bbc36218f48 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -23,7 +23,6 @@ from .._code import getfslineno from ..compat import NOTSET -from ..compat import NotSetType from _pytest.compat import assert_never from _pytest.config import Config from _pytest.deprecated import check_ispytest From ff1b6f4822b61b306a665d21c70b382fecc1c24c Mon Sep 17 00:00:00 2001 From: paneendrakumar0 Date: Sun, 18 Oct 2026 19:46:33 +0530 Subject: [PATCH 3/3] docs(mark): address review feedback for pytest.ParameterSet --- doc/en/reference/reference.rst | 1 - src/_pytest/mark/structures.py | 50 ++++++++++++++++++---------------- testing/test_mark.py | 34 ----------------------- 3 files changed, 26 insertions(+), 59 deletions(-) diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 83637258026..87abcc5f74d 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1053,7 +1053,6 @@ ParameterSet ~~~~~~~~~~~~ .. autoclass:: pytest.ParameterSet() - :members: PytestPluginManager ~~~~~~~~~~~~~~~~~~~ diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index bbc36218f48..aaee55771ee 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -23,6 +23,7 @@ from .._code import getfslineno from ..compat import NOTSET +from ..compat import NotSetType from _pytest.compat import assert_never from _pytest.config import Config from _pytest.deprecated import check_ispytest @@ -86,32 +87,32 @@ class ParameterSet(NamedTuple): an optional ID for the set. .. versionadded:: 9.2 - - Examples:: - - pytest.param(1, 2, 3) - # ParameterSet(values=(1, 2, 3), marks=(), id=None) - - pytest.param("hello", id="greeting") - # ParameterSet(values=("hello",), marks=(), id="greeting") - - # Parameter set with marks - pytest.param(42, marks=pytest.mark.xfail) - # ParameterSet(values=(42,), marks=(MarkDecorator(...),), id=None) - - # From parametrize mark (parameter names + list of parameter sets) - pytest.mark.parametrize( - ("a", "b", "expected"), - [ - (1, 2, 3), - pytest.param(40, 2, 42, id="everything"), - ], - ) - # ParameterSet(values=(1, 2, 3), marks=(), id=None) - # ParameterSet(values=(40, 2, 42), marks=(), id="everything") """ - values: Sequence[object] + # Examples: + # + # pytest.param(1, 2, 3) + # # ParameterSet(values=(1, 2, 3), marks=(), id=None) + # + # pytest.param("hello", id="greeting") + # # ParameterSet(values=("hello",), marks=(), id="greeting") + # + # # Parameter set with marks + # pytest.param(42, marks=pytest.mark.xfail) + # # ParameterSet(values=(42,), marks=(MarkDecorator(...),), id=None) + # + # # From parametrize mark (parameter names + list of parameter sets) + # pytest.mark.parametrize( + # ("a", "b", "expected"), + # [ + # (1, 2, 3), + # pytest.param(40, 2, 42, id="everything"), + # ], + # ) + # # ParameterSet(values=(1, 2, 3), marks=(), id=None) + # # ParameterSet(values=(40, 2, 42), marks=(), id="everything") + + values: Sequence[object | NotSetType] marks: Collection[MarkDecorator | Mark] id: str | _HiddenParam | None @@ -265,6 +266,7 @@ def _for_parametrize( return argnames, parameters +# mypy doesn't like `__module__` defined inline in the NamedTuple. ParameterSet.__module__ = "pytest" diff --git a/testing/test_mark.py b/testing/test_mark.py index 6694a905661..82a941df0d6 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1498,40 +1498,6 @@ def test_pytest_param_id_allows_none_or_string(s) -> None: assert pytest.param(id=s) -def test_parameterset_public_api() -> None: - from pytest import ParameterSet - - assert pytest.ParameterSet is ParameterSet - assert ParameterSet.__module__ == "pytest" - - p = pytest.param(1, 2, id="custom_id", marks=pytest.mark.skip) - assert isinstance(p, ParameterSet) - assert p.values == (1, 2) - assert len(p.marks) == 1 - (mark,) = p.marks - assert mark.name == "skip" - assert p.id == "custom_id" - - # Direct construction - direct = ParameterSet((1, 2), (), "custom_id") - assert isinstance(direct, ParameterSet) - assert direct.values == (1, 2) - assert direct.id == "custom_id" - - -def test_parameterset_pickle() -> None: - import pickle - - p = pytest.param("hello", 42, id="test_id") - pickled = pickle.dumps(p) - unpickled = pickle.loads(pickled) - - assert isinstance(unpickled, pytest.ParameterSet) - assert unpickled == p - assert unpickled.values == ("hello", 42) - assert unpickled.id == "test_id" - - @pytest.mark.parametrize("expr", ("NOT internal_err", "NOT (internal_err)", "bogus=")) def test_marker_expr_eval_failure_handling(pytester: Pytester, expr) -> None: foo = pytester.makepyfile(