From b8fce36a0f556e0187f47136453eb74508a03f49 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 13 Sep 2026 10:05:16 +0200 Subject: [PATCH 1/7] mark: ignore non-keyword function attributes in -k matching The -k matcher scans a test function's __dict__ to support the legacy `test_fn.foo = True` keyword idiom, but that scan also picked up attributes nobody meant as keywords: pytest's own `pytestmark` storage, and the bookkeeping decorators leave behind (`__wrapped__` and the rest of what functools.wraps copies, lru_cache's `cache_parameters`). So `-k wrapped` selected every wraps-decorated test and `-k pytestmark` selected every directly marked one. Skip private and dunder names, plus a named set of known attributes. Part of #4569. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- changelog/4569.bugfix.rst | 1 + src/_pytest/mark/__init__.py | 22 ++++++++++++++++++++-- testing/test_mark.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 changelog/4569.bugfix.rst diff --git a/changelog/4569.bugfix.rst b/changelog/4569.bugfix.rst new file mode 100644 index 00000000000..97968182058 --- /dev/null +++ b/changelog/4569.bugfix.rst @@ -0,0 +1 @@ +:option:`-k` no longer matches attributes that end up in a test function's ``__dict__`` without being meant as keywords, such as pytest's own ``pytestmark`` attribute and the bookkeeping left behind by :func:`functools.wraps` and :func:`functools.lru_cache`. diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 73354506df3..a614d61cba6 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -150,6 +150,18 @@ def pytest_cmdline_main(config: Config) -> int | ExitCode | None: return None +#: Attributes which are never meaningful as keywords, but do end up in the +#: ``__dict__`` of a test function: pytest's own mark storage, and the +#: bookkeeping decorators leave behind (``functools.wraps`` copies ``__wrapped__`` +#: and friends, ``functools.lru_cache`` adds ``cache_parameters``, ...). +IGNORED_FUNCTION_ATTRIBUTES = frozenset({"pytestmark", "cache_parameters"}) + + +def _is_matchable_function_attribute(name: str) -> bool: + """Whether a test function attribute may be matched by ``-k``.""" + return not name.startswith("_") and name not in IGNORED_FUNCTION_ATTRIBUTES + + @dataclasses.dataclass class KeywordMatcher: """A matcher for keywords. @@ -190,10 +202,16 @@ def from_item(cls, item: Item) -> KeywordMatcher: # Add the names added as extra keywords to current or parent items. mapped_names.update(item.listextrakeywords()) - # Add the names attached to the current function through direct assignment. + # Add the names attached to the current function through direct + # assignment, ignoring the attributes that merely happen to live in the + # function's __dict__ without anyone meaning them as keywords. function_obj = getattr(item, "function", None) if function_obj: - mapped_names.update(function_obj.__dict__) + mapped_names.update( + name + for name in function_obj.__dict__ + if _is_matchable_function_attribute(name) + ) # Add the markers to the keywords as we no longer handle them correctly. mapped_names.update(mark.name for mark in item.iter_markers()) diff --git a/testing/test_mark.py b/testing/test_mark.py index 9698aca35a7..fa301548e03 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1057,6 +1057,36 @@ def test_one(): _passed, _skipped, failed = reprec.countoutcomes() assert failed == 1 + @pytest.mark.parametrize("keyword", ["pytestmark", "wrapped", "cache_parameters"]) + def test_no_match_on_ignored_function_attributes( + self, pytester: Pytester, keyword: str + ) -> None: + """`-k` ignores attributes that end up in a test function's __dict__ + without being meant as keywords (#4569).""" + pytester.makepyfile( + """ + import functools + import pytest + + def deco(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + return fn(*args, **kwargs) + return wrapper + + @pytest.mark.some_mark + def test_marked(): pass + + @deco + def test_decorated(): pass + + @functools.lru_cache + def test_cached(): pass + """ + ) + result = pytester.runpytest("-k", keyword) + result.assert_outcomes(deselected=3) + @pytest.mark.xfail def test_keyword_extra_dash(self, pytester: Pytester) -> None: p = pytester.makepyfile( From 589dec6e843a86990ad4a627c666c29df039e210 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 13 Sep 2026 10:06:33 +0200 Subject: [PATCH 2/7] nodes: store Mark, not MarkDecorator, in keywords from add_marker Marks applied during collection are stored in node.keywords as Mark objects; add_marker stored the MarkDecorator instead, so the value type depended on how the mark got there. Nothing inside pytest reads keyword values, and both types expose name/args/kwargs, so downstream `item.keywords["x"].args` keeps working. Part of #4569. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- changelog/4569.improvement.rst | 1 + src/_pytest/nodes.py | 2 +- testing/test_collection.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 changelog/4569.improvement.rst diff --git a/changelog/4569.improvement.rst b/changelog/4569.improvement.rst new file mode 100644 index 00000000000..c4bff2f36d3 --- /dev/null +++ b/changelog/4569.improvement.rst @@ -0,0 +1 @@ +:meth:`Node.add_marker <_pytest.nodes.Node.add_marker>` now stores a :class:`~pytest.Mark` in ``node.keywords``, matching what marks applied during collection store. Previously it stored a :class:`~pytest.MarkDecorator`. diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 76d480348de..78db989be5d 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -335,7 +335,7 @@ def add_marker(self, marker: str | MarkDecorator, append: bool = True) -> None: marker_ = getattr(MARK_GEN, marker) else: raise ValueError("is not a string or pytest.mark.* Marker") - self.keywords[marker_.name] = marker_ + self.keywords[marker_.name] = marker_.mark if append: self.own_markers.append(marker_.mark) else: diff --git a/testing/test_collection.py b/testing/test_collection.py index 093162ddec4..f7cdc60a1c3 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -973,6 +973,16 @@ def test_method(self): pass assert "bar" not in mod.keywords assert "baz" not in mod.keywords + def test_added_marks_added_to_keywords(self, pytester: Pytester) -> None: + """Dynamically added marks land in keywords as Mark objects, same as + marks applied during collection (#4569).""" + item = pytester.getitem("def test_method(): pass", "test_method") + item.add_marker("foo") + item.add_marker(pytest.mark.bar("arg", kwarg=1)) + + assert item.keywords["foo"] == pytest.mark.foo.mark + assert item.keywords["bar"] == pytest.mark.bar("arg", kwarg=1).mark + class TestCollectDirectoryHook: def test_custom_directory_example(self, pytester: Pytester) -> None: From 221e31b8daf18ecc1d71046d24ef040fbeaff370 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 13 Sep 2026 10:07:38 +0200 Subject: [PATCH 3/7] doc: say what -k actually matches The -k help text still described the pre-5.4 implementation ("a Python evaluable expression"), claimed matching is limited to test names and their parent classes, and never mentioned that marker names are keywords - which is the behaviour people trip over in #4569. Replace it with the real list of keyword sources, and state the substring/case-insensitive contrast with -m in both option blocks. Part of #4569. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- changelog/4569.doc.rst | 1 + doc/en/reference/reference.rst | 56 +++++++++++++++++++--------------- src/_pytest/mark/__init__.py | 22 ++++++------- 3 files changed, 43 insertions(+), 36 deletions(-) create mode 100644 changelog/4569.doc.rst diff --git a/changelog/4569.doc.rst b/changelog/4569.doc.rst new file mode 100644 index 00000000000..a54d4ea4f86 --- /dev/null +++ b/changelog/4569.doc.rst @@ -0,0 +1 @@ +The documentation of :option:`-k` no longer describes it as a Python expression matched against test names only: :ref:`select-tests`, the :option:`-k` reference entry and ``pytest --help`` now all say that a test's markers, attributes and extra keywords are matched as well. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 63c673547ef..f99fdfa217e 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -2870,17 +2870,24 @@ Test Selection .. option:: -k EXPRESSION - Only run tests which match the given substring expression. - An expression is a Python evaluable expression where all names are substring-matched against test names and their parent classes. + Only run tests which match the given keyword expression. + An expression is made of names combined with ``and``, ``or``, ``not`` and parentheses. + Each name is matched case-insensitively as a substring of any of the test's keywords. Examples:: - pytest -k "test_method or test_other" # matches names containing 'test_method' OR 'test_other' - pytest -k "not test_method" # matches names NOT containing 'test_method' + pytest -k "test_method or test_other" # matches keywords containing 'test_method' OR 'test_other' + pytest -k "not test_method" # matches keywords NOT containing 'test_method' pytest -k "not test_method and not test_other" # excludes both - The matching is case-insensitive. - Keywords are also matched to classes and functions containing extra names in their ``extra_keyword_matches`` set. + The keywords of a test are: + + * its own name, including any parametrization id; + * the names of its parent class, module and directories; + * the names of the markers applied to it or to any of its parents, as bare names: + unlike :option:`-m`, ``-k`` matches them as substrings and cannot match their arguments; + * attributes assigned directly to the test function, as in the legacy ``test_func.slow = True`` style; + * any names added to the :attr:`~_pytest.nodes.Node.extra_keyword_matches` set of it or of a parent. See :ref:`select-tests` for more information and examples. @@ -2891,9 +2898,12 @@ Test Selection Examples:: - pytest -m slow # run tests marked with @pytest.mark.slow - pytest -m "not slow" # run tests NOT marked slow - pytest -m "mark1 and not mark2" # run tests marked mark1 but not mark2 + pytest -m slow # run tests marked with @pytest.mark.slow + pytest -m "not slow" # run tests NOT marked slow + pytest -m "mark1 and not mark2" # run tests marked mark1 but not mark2 + pytest -m "device(serial='123')" # run tests marked device with that argument + + Marker names are matched exactly and case-sensitively. See :ref:`mark` for more information on markers. @@ -3441,21 +3451,19 @@ All the command-line flags can also be obtained by running ``pytest --help``:: file_or_dir general: - -k EXPRESSION Only run tests which match the given substring - expression. An expression is a Python evaluable - expression where all names are substring-matched - against test names and their parent classes. - Example: -k 'test_method or test_other' matches all - test functions and classes whose name contains - 'test_method' or 'test_other', while -k 'not - test_method' matches those that don't contain - 'test_method' in their names. -k 'not test_method - and not test_other' will eliminate the matches. - Additionally keywords are matched to classes and - functions containing extra names in their - 'extra_keyword_matches' set, as well as functions - which have names assigned directly to them. The - matching is case-insensitive. + -k EXPRESSION Only run tests matching the given keyword expression, + e.g. -k 'test_method or test_other', -k 'not (slow or + network)'. + Names in the expression are matched case-insensitively + as substrings of the test's keywords, which are: + - its own name, including any parametrization id + - the names of its class, module and directories + - the names of the markers applied to it or to its + parents + - attributes assigned directly to the test function + - any names in its 'extra_keyword_matches' set + Use -m to match marker names exactly, including their + arguments. -m MARKEXPR Only run tests matching given mark expression. For example: -m 'mark1 and not mark2'. --markers show markers (builtin, plugin and per-project ones). diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index a614d61cba6..36d56dadd53 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -94,18 +94,16 @@ def pytest_addoption(parser: Parser) -> None: dest="keyword", default="", metavar="EXPRESSION", - help="Only run tests which match the given substring expression. " - "An expression is a Python evaluable expression " - "where all names are substring-matched against test names " - "and their parent classes. Example: -k 'test_method or test_" - "other' matches all test functions and classes whose name " - "contains 'test_method' or 'test_other', while -k 'not test_method' " - "matches those that don't contain 'test_method' in their names. " - "-k 'not test_method and not test_other' will eliminate the matches. " - "Additionally keywords are matched to classes and functions " - "containing extra names in their 'extra_keyword_matches' set, " - "as well as functions which have names assigned directly to them. " - "The matching is case-insensitive.", + help="Only run tests matching the given keyword expression, " + "e.g. -k 'test_method or test_other', -k 'not (slow or network)'.\n" + "Names in the expression are matched case-insensitively as substrings " + "of the test's keywords, which are:\n" + "- its own name, including any parametrization id\n" + "- the names of its class, module and directories\n" + "- the names of the markers applied to it or to its parents\n" + "- attributes assigned directly to the test function\n" + "- any names in its 'extra_keyword_matches' set\n" + "Use -m to match marker names exactly, including their arguments.", ) group._addoption( # private to use reserved lower-case short option From a34b7be87c3b3e902d05af763727e81577214861 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 13 Sep 2026 10:08:15 +0200 Subject: [PATCH 4/7] doc: correct the keyword-related docstrings KeywordMatcher claimed it only matches Class and Function names, which stopped being true in 2.4.0; Function's `keywords` parameter claimed it feeds "-k" matching, which it does not; TestReport.keywords promised a name -> value mapping while the values are all 1, so annotate it `Literal[1]` and let the type checker hold that promise. `FixtureRequest.applymarker` spoke of "a single test function invocation" without saying what that is in contrast to; say that it is the request's node, and that decorating the function marks every invocation instead. Also state what Node.keywords is for, and that -k does not read it. Part of #4569. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- src/_pytest/fixtures.py | 9 +++++---- src/_pytest/mark/__init__.py | 16 +++++++++------- src/_pytest/nodes.py | 12 ++++++++++-- src/_pytest/python.py | 3 ++- src/_pytest/reports.py | 11 ++++++----- 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 7656fca2f5b..40366065d82 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -618,7 +618,7 @@ def path(self) -> Path: @property def keywords(self) -> MutableMapping[str, Any]: - """Keywords/markers dictionary for the underlying node.""" + """The :attr:`~_pytest.nodes.Node.keywords` of the underlying node.""" node: nodes.Node = self.node return node.keywords @@ -634,10 +634,11 @@ def addfinalizer(self, finalizer: Callable[[], object]) -> None: raise NotImplementedError() def applymarker(self, marker: str | MarkDecorator) -> None: - """Apply a marker to a single test function invocation. + """Apply a marker to the test(s) this fixture is running for. - This method is useful if you don't want to have a keyword/marker - on all function invocations. + Unlike decorating a test function, which marks every invocation of it, + this adds the marker to the request's ``node``: for a function-scoped + fixture, the single test invocation currently being set up. :param marker: An object created by a call to ``pytest.mark.NAME(...)``. diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 36d56dadd53..996322d93f1 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -162,17 +162,19 @@ def _is_matchable_function_attribute(name: str) -> bool: @dataclasses.dataclass class KeywordMatcher: - """A matcher for keywords. + """A matcher for keywords, as used by ``-k``. - Given a list of names, matches any substring of one of these names. The + Given a set of names, matches any substring of one of these names. The string inclusion check is case-insensitive. - Will match on the name of colitem, including the names of its parents. - Only matches names of items which are either a :class:`Class` or a - :class:`Function`. + The names are collected in :meth:`from_item` from the item and its + parents: their node names, the names of the markers in scope, the + attributes assigned to the test function, and the + :attr:`~_pytest.nodes.Node.extra_keyword_matches` sets. - Additionally, matches on names in the 'extra_keyword_matches' set of - any item, as well as names directly assigned to test functions. + Note that these names are collected independently of + :attr:`Node.keywords <_pytest.nodes.Node.keywords>`; writing into that + mapping does not affect ``-k``. """ __slots__ = ("_names",) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 78db989be5d..2b510907122 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -185,13 +185,21 @@ def __init__( self.path: pathlib.Path = path # The explicit annotation is to avoid publicly exposing NodeKeywords. - #: Keywords/markers collected from all scopes. + #: Mapping of the names collected for this node and its parents: the + #: node names themselves, the names of the markers applied to them + #: (mapping to the :class:`~pytest.Mark`), and, for a test function, + #: its attributes and parametrization id. + #: + #: Mostly useful for ``"markname" in item.keywords`` checks. Note that + #: this mapping is not what ``-k`` matches against, so writing to it + #: does not affect test selection, and that it is unrelated to + #: :attr:`extra_keyword_matches`. self.keywords: MutableMapping[str, Any] = NodeKeywords(self) #: The marker objects belonging to this node. self.own_markers: list[Mark] = [] - #: Allow adding of extra keywords to use for matching. + #: Extra names for ``-k`` to match this node and its children on. self.extra_keyword_matches: set[str] = set() if nodeid is not None: diff --git a/src/_pytest/python.py b/src/_pytest/python.py index bc3d55243f7..792d0d5b4f3 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1670,7 +1670,8 @@ class Function(PyobjMixin, nodes.Item): If given, the object which will be called when the Function is invoked, otherwise the callobj will be obtained from ``parent`` using ``originalname``. :param keywords: - Keywords bound to the function object for "-k" matching. + Extra entries for :attr:`~_pytest.nodes.Node.keywords`, taking + precedence over the function's attributes and markers. :param session: The pytest Session object. :param fixtureinfo: diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 35a212e8eab..722f71909d4 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -345,7 +345,7 @@ def __init__( self, nodeid: str | NodeId, location: tuple[str, int | None, str], - keywords: Mapping[str, Any], + keywords: Mapping[str, Literal[1]], outcome: Literal["passed", "failed", "skipped"], longrepr: ExceptionInfo[BaseException] | tuple[str, int, str] @@ -370,9 +370,10 @@ def __init__( #: The line number is 0-based. self.location: tuple[str, int | None, str] = location - #: A name -> value dictionary containing all keywords and - #: markers associated with a test invocation. - self.keywords: Mapping[str, Any] = keywords + #: The names in :attr:`Node.keywords <_pytest.nodes.Node.keywords>` + #: of the item, each mapping to ``1``: only the names survive into the + #: report, the values of the node keywords are not carried over. + self.keywords: Mapping[str, Literal[1]] = keywords #: Test outcome, always one of "passed", "failed", "skipped". self.outcome = outcome @@ -419,7 +420,7 @@ def from_item_and_call(cls, item: Item, call: CallInfo[None]) -> TestReport: duration = call.duration start = call.start stop = call.stop - keywords = {x: 1 for x in item.keywords} + keywords: Mapping[str, Literal[1]] = dict.fromkeys(item.keywords, 1) excinfo = call.excinfo sections = [] if not call.excinfo: From a360ca877b35385f8ba03de0328e3ea28f950599 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 13 Sep 2026 10:08:35 +0200 Subject: [PATCH 5/7] doc: make markers.rst the one explanation of -k The markers.rst section title and intro presented -k as matching test names, in contrast to -m matching markers, and only corrected itself in a trailing paragraph after three console dumps. usage.rst, the page the option reference links to, still described the pre-5.4 eval semantics and listed only filenames, classes and functions. Keep the keyword list where it was, after the examples -- the reader needs to see -k work before being told what else it looks at -- but make it a list, and give each entry a name from the example module rather than a description in the abstract. The example module already carries the point: test_send_http is marked webtest, so `-k webtest` selects a test whose name contains no "webtest", and `-k device` selects both device tests where the -m expression above tells their arguments apart. Label the section so the other pages can point at it. usage.rst is a getting-started page, so it keeps one sentence and the link; the option reference keeps the exhaustive list and links here for the examples. Part of #4569. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- doc/en/example/markers.rst | 42 ++++++++++++++++++++++++++++------ doc/en/how-to/usage.rst | 5 ++-- doc/en/reference/reference.rst | 2 +- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index c8e4172a696..d88c2c7c159 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -162,15 +162,16 @@ Or select multiple nodes: when running pytest with the ``-rf`` option. You can also construct Node IDs from the output of ``pytest --collect-only``. -Using ``-k expr`` to select tests based on their name +.. _`keyword expressions`: + +Using ``-k expr`` to select tests by keyword ------------------------------------------------------- .. versionadded:: 2.0/2.3.4 You can use the :option:`-k` command line option to specify an expression -which implements a substring match on the test names instead of the -exact match on markers that :option:`-m` provides. This makes it easy to -select tests based on their names: +which implements a substring match on the test's *keywords*, instead of the +exact match on markers that :option:`-m` provides. .. versionchanged:: 5.4 @@ -224,10 +225,37 @@ Or to select "http" and "quick" tests: You can use ``and``, ``or``, ``not`` and parentheses. +A test's own name is only one of its keywords. The others are: + +* the names of the test's parents, usually the file and class it is in + (``test_server.py``, ``TestClass``); +* the names of the markers applied to it or to its parents (``webtest``, + ``device``); +* attributes set on the test function, as in the legacy ``test_func.slow = True`` + style; +* any :attr:`extra keywords <_pytest.nodes.Node.extra_keyword_matches>` + explicitly added to it or to its parents. + +Marker names being keywords is why ``-k webtest`` selects ``test_send_http``, +whose name contains no ``webtest`` at all: + +.. code-block:: pytest + + $ pytest -v -k webtest + =========================== test session starts ============================ + platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python + cachedir: .pytest_cache + rootdir: /home/sweet/project + collecting ... collected 4 items / 3 deselected / 1 selected + + test_server.py::test_send_http PASSED [100%] + + ===================== 1 passed, 3 deselected in 0.12s ====================== -In addition to the test's name, :option:`-k` also matches the names of the test's parents (usually, the name of the file and class it's in), -attributes set on the test function, markers applied to it or its parents and any :attr:`extra keywords <_pytest.nodes.Node.extra_keyword_matches>` -explicitly added to it or its parents. +A marker's arguments are not keywords, though, and the match is a substring +one: ``-k device`` selects both ``device`` tests, where +:ref:`the -m expression above ` selects only +the one. Use :option:`-m` when you want markers and nothing else. Registering markers diff --git a/doc/en/how-to/usage.rst b/doc/en/how-to/usage.rst index 35b07bfe8c1..91f48ffe6fa 100644 --- a/doc/en/how-to/usage.rst +++ b/doc/en/how-to/usage.rst @@ -38,8 +38,9 @@ Pytest supports several ways to run and select tests from the command-line or fr pytest -k 'MyClass and not method' -This will run tests which contain names that match the given *string expression* (case-insensitive), -which can include Python operators that use filenames, class names and function names as variables. +This will run tests whose *keywords* match the given expression (case-insensitive). +A test's keywords are its own name, the names of the file and class it is in, the names of +its markers, and :ref:`a few more `. The example above will run ``TestMyClass.test_something`` but not ``TestMyClass.test_method_simple``. Use ``""`` instead of ``''`` in expression when running this on Windows diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index f99fdfa217e..54074262401 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -2889,7 +2889,7 @@ Test Selection * attributes assigned directly to the test function, as in the legacy ``test_func.slow = True`` style; * any names added to the :attr:`~_pytest.nodes.Node.extra_keyword_matches` set of it or of a parent. - See :ref:`select-tests` for more information and examples. + See :ref:`keyword expressions` for more information and examples. .. option:: -m MARKEXPR From 1f64b0da8377625a17d398597b5e50b2f235fa19 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 13 Sep 2026 10:08:55 +0200 Subject: [PATCH 6/7] doc: look up markers with get_closest_marker in the examples The --runslow and incremental examples tested for a marker with `"name" in item.keywords`, which is also true when a parent node happens to be named that, or when the test function carries an attribute of that name. These snippets are widely copied, so they are where the idiom of treating keywords as a mark lookup keeps coming from. Part of #4569. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- changelog/4569.doc.rst | 2 +- doc/en/example/simple.rst | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog/4569.doc.rst b/changelog/4569.doc.rst index a54d4ea4f86..e5dc171983d 100644 --- a/changelog/4569.doc.rst +++ b/changelog/4569.doc.rst @@ -1 +1 @@ -The documentation of :option:`-k` no longer describes it as a Python expression matched against test names only: :ref:`select-tests`, the :option:`-k` reference entry and ``pytest --help`` now all say that a test's markers, attributes and extra keywords are matched as well. +``pytest --help``, the :option:`-k` reference entry and :ref:`select-tests` no longer describe :option:`-k` as a Python expression matched against test names only; :ref:`keyword expressions` now shows marker names being matched as well. The examples that looked up a marker use :meth:`Node.get_closest_marker <_pytest.nodes.Node.get_closest_marker>` rather than ``item.keywords``. diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index dff53488c88..090a81575fa 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -274,7 +274,7 @@ line option to control skipping of ``pytest.mark.slow`` marked tests: return skip_slow = pytest.mark.skip(reason="need --runslow option to run") for item in items: - if "slow" in item.keywords: + if item.get_closest_marker("slow"): item.add_marker(skip_slow) We can now write a test module like this: @@ -560,7 +560,7 @@ an ``incremental`` marker which is to be used on classes: def pytest_runtest_makereport(item, call): - if "incremental" in item.keywords: + if item.get_closest_marker("incremental"): # incremental marker is used if call.excinfo is not None: # the test has failed @@ -581,7 +581,7 @@ an ``incremental`` marker which is to be used on classes: def pytest_runtest_setup(item): - if "incremental" in item.keywords: + if item.get_closest_marker("incremental"): # retrieve the class name of the test cls_name = str(item.cls) # check if a previous test has failed for this class From b34b66c1b2e9fc714e2b05f10883795fe6cecbc2 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 13 Sep 2026 10:09:46 +0200 Subject: [PATCH 7/7] testing: pin the keyword and mark selection behaviour Several long-standing behaviours had no test: -k matching marks from the module, class and base classes; -k ignoring marker arguments; -k seeing a mark added by a conftest collection hook; extra_keyword_matches on an item rather than a collector; and writing into item.keywords having no effect on either -k or -m, despite the 2.3.4 changelog claiming it "integrates with the -m option". Also enable the -k half of test_mark_expressions_no_smear, commented out since the marker transfer that made marks smear onto a shared base class was removed, and pin that -m is case sensitive. Part of #4569. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- testing/test_mark.py | 116 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 6 deletions(-) diff --git a/testing/test_mark.py b/testing/test_mark.py index fa301548e03..82a941df0d6 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -1158,6 +1158,106 @@ def get_collected_names(*args: str) -> list[str]: # do not collect anything based on names outside the collection tree assert get_collected_names("-k", pytester._name) == [] + def test_keyword_matches_marks_from_parents(self, pytester: Pytester) -> None: + """`-k` matches marker names from the module, class and base classes.""" + pytester.makepyfile( + """ + import pytest + pytestmark = pytest.mark.modmark + + @pytest.mark.basemark + class Base: + def test_inherited(self): pass + + @pytest.mark.classmark + class TestClass(Base): + def test_method(self): pass + + def test_toplevel(): pass + """ + ) + for keyword, selected in [ + ("modmark", 3), + ("classmark", 2), + ("basemark", 2), + ]: + result = pytester.runpytest("-k", keyword) + result.assert_outcomes(passed=selected, deselected=3 - selected) + + def test_keyword_does_not_match_mark_arguments(self, pytester: Pytester) -> None: + """`-k` matches marker names, not what the marker was called with.""" + pytester.makepyfile( + """ + import pytest + + @pytest.mark.mymark("someargument", somekwarg="anotherargument") + def test_one(): pass + """ + ) + for keyword in ["someargument", "anotherargument", "somekwarg"]: + result = pytester.runpytest("-k", keyword) + result.assert_outcomes(deselected=1) + + def test_keyword_matches_dynamically_added_mark(self, pytester: Pytester) -> None: + """A mark added in a conftest `pytest_collection_modifyitems` is seen by + `-k`, because conftest hook implementations run before the ones of the + mark plugin doing the deselection.""" + pytester.makeconftest( + """ + def pytest_collection_modifyitems(items): + for item in items: + if item.name == "test_one": + item.add_marker("addedmark") + """ + ) + pytester.makepyfile( + """ + def test_one(): pass + def test_two(): pass + """ + ) + result = pytester.runpytest("-k", "addedmark") + result.assert_outcomes(passed=1, deselected=1) + + def test_keyword_matches_item_extra_keyword_matches( + self, pytester: Pytester + ) -> None: + """`extra_keyword_matches` is honoured on the item itself, not just on + a parent collector.""" + pytester.makeconftest( + """ + def pytest_collection_modifyitems(items): + for item in items: + if item.name == "test_one": + item.extra_keyword_matches.add("extrakeyword") + """ + ) + pytester.makepyfile( + """ + def test_one(): pass + def test_two(): pass + """ + ) + result = pytester.runpytest("-k", "extrakeyword") + result.assert_outcomes(passed=1, deselected=1) + + @pytest.mark.parametrize("option", ["-k", "-m"]) + def test_writing_to_keywords_does_not_select( + self, pytester: Pytester, option: str + ) -> None: + """Writing into `item.keywords` does not make a test selectable: `-k` + collects its names separately, and `-m` only looks at markers.""" + pytester.makeconftest( + """ + def pytest_collection_modifyitems(items): + for item in items: + item.keywords["setviakeywords"] = True + """ + ) + pytester.makepyfile("def test_one(): pass") + result = pytester.runpytest(option, "setviakeywords") + result.assert_outcomes(deselected=1) + class TestMarkDecorator: @pytest.mark.parametrize( @@ -1317,12 +1417,16 @@ class TestBarClass(BaseTests): deselected_tests = dlist[0].items assert len(deselected_tests) == 1 - # todo: fixed - # keywords smear - expected behaviour - # reprec_keywords = pytester.inline_run("-k", "FOO") - # passed_k, skipped_k, failed_k = reprec_keywords.countoutcomes() - # assert passed_k == 2 - # assert skipped_k == failed_k == 0 + # Marks used to smear onto the shared base class function object, so that + # -k FOO matched both subclasses; it no longer does. + reprec_keywords = pytester.inline_run("-k", "FOO") + passed_k, skipped_k, failed_k = reprec_keywords.countoutcomes() + assert passed_k == 1 + assert skipped_k == failed_k == 0 + + # -m matches marker names exactly, so the case has to match too. + reprec_lower = pytester.inline_run("-m", "foo") + assert reprec_lower.countoutcomes() == [0, 0, 0] def test_addmarker_order(pytester) -> None: