From 15c33fbaa3e8569af0efa7532ac59f5f0fb3ba4a Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Thu, 20 Jun 2024 23:09:14 +0200 Subject: [PATCH 01/15] feat: support keyword arguments in marker expressions Fixes #12281 --- src/_pytest/mark/__init__.py | 34 +++++++-- src/_pytest/mark/expression.py | 110 ++++++++++++++++++++++++--- testing/test_mark.py | 48 ++++++++++++ testing/test_mark_expression.py | 129 +++++++++++++++++++++++++++++++- 4 files changed, 304 insertions(+), 17 deletions(-) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index b8a309215..ae6940a62 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import collections import dataclasses from typing import AbstractSet from typing import Collection @@ -181,7 +182,9 @@ class KeywordMatcher: return cls(mapped_names) - def __call__(self, subname: str) -> bool: + def __call__(self, subname: str, /, **kwargs: object) -> bool: + if kwargs: + raise UsageError("Keyword expressions do not support call parameters.") subname = subname.lower() names = (name.lower() for name in self._names) @@ -211,6 +214,9 @@ def deselect_by_keyword(items: list[Item], config: Config) -> None: items[:] = remaining +NOT_NONE_SENTINEL = object() + + @dataclasses.dataclass class MarkMatcher: """A matcher for markers which are present. @@ -218,17 +224,31 @@ class MarkMatcher: Tries to match on any marker names, attached to the given colitem. """ - __slots__ = ("own_mark_names",) + __slots__ = ("own_mark_name_mapping",) - own_mark_names: AbstractSet[str] + own_mark_name_mapping: dict[str, list[Mark]] @classmethod def from_item(cls, item: Item) -> MarkMatcher: - mark_names = {mark.name for mark in item.iter_markers()} - return cls(mark_names) + mark_name_mapping = collections.defaultdict(list) + for mark in item.iter_markers(): + mark_name_mapping[mark.name].append(mark) + return cls(mark_name_mapping) - def __call__(self, name: str) -> bool: - return name in self.own_mark_names + def __call__(self, name: str, /, **kwargs: object) -> bool: + if not (matches := self.own_mark_name_mapping.get(name, [])): + return False + + if not kwargs: + return True + + for mark in matches: + if all( + mark.kwargs.get(k, NOT_NONE_SENTINEL) == v for k, v in kwargs.items() + ): + return True + + return False def deselect_by_mark(items: list[Item], config: Config) -> None: diff --git a/src/_pytest/mark/expression.py b/src/_pytest/mark/expression.py index e65b02858..16883c6b7 100644 --- a/src/_pytest/mark/expression.py +++ b/src/_pytest/mark/expression.py @@ -5,7 +5,8 @@ The grammar is: expression: expr? EOF expr: and_expr ('or' and_expr)* and_expr: not_expr ('and' not_expr)* -not_expr: 'not' not_expr | '(' expr ')' | ident +not_expr: 'not' not_expr | '(' expr ')' | ident ( '(' name '=' value ( ', ' name '=' value )* ')')* + ident: (\w|:|\+|-|\.|\[|\]|\\|/)+ The semantics are: @@ -20,12 +21,13 @@ from __future__ import annotations import ast import dataclasses import enum +import keyword import re import types -from typing import Callable from typing import Iterator from typing import Mapping from typing import NoReturn +from typing import Protocol from typing import Sequence @@ -43,6 +45,9 @@ class TokenType(enum.Enum): NOT = "not" IDENT = "identifier" EOF = "end of input" + EQUAL = "=" + STRING = "str" + COMMA = "," @dataclasses.dataclass(frozen=True) @@ -86,6 +91,27 @@ class Scanner: elif input[pos] == ")": yield Token(TokenType.RPAREN, ")", pos) pos += 1 + elif input[pos] == "=": + yield Token(TokenType.EQUAL, "=", pos) + pos += 1 + elif input[pos] == ",": + yield Token(TokenType.COMMA, ",", pos) + pos += 1 + elif (quote_char := input[pos]) == "'" or input[pos] == '"': + quote_position = input[pos + 1 :].find(quote_char) + if quote_position == -1: + raise ParseError( + pos + 1, + f'closing quote "{quote_char}" is missing', + ) + value = input[pos : pos + 2 + quote_position] + if "\\" in value: + raise ParseError( + pos + 1, + "escaping not supported in marker expression", + ) + yield Token(TokenType.STRING, value, pos) + pos += len(value) else: match = re.match(r"(:?\w|:|\+|-|\.|\[|\]|\\|/)+", input[pos:]) if match: @@ -166,18 +192,84 @@ def not_expr(s: Scanner) -> ast.expr: return ret ident = s.accept(TokenType.IDENT) if ident: - return ast.Name(IDENT_PREFIX + ident.value, ast.Load()) + name = ast.Name(IDENT_PREFIX + ident.value, ast.Load()) + if s.accept(TokenType.LPAREN): + ret = ast.Call(func=name, args=[], keywords=all_kwargs(s)) + s.accept(TokenType.RPAREN, reject=True) + else: + ret = name + return ret + s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT)) -class MatcherAdapter(Mapping[str, bool]): +BUILTIN_MATCHERS = {"True": True, "False": False, "None": None} + + +def single_kwarg(s: Scanner) -> ast.keyword: + keyword_name = s.accept(TokenType.IDENT, reject=True) + assert keyword_name is not None # for mypy + if not keyword_name.value.isidentifier() or keyword.iskeyword(keyword_name.value): + raise ParseError( + keyword_name.pos + 1, + f'unexpected character/s "{keyword_name.value}"', + ) + s.accept(TokenType.EQUAL, reject=True) + + if value_token := s.accept(TokenType.STRING): + value: str | int | bool | None = value_token.value[1:-1] # strip quotes + else: + value_token = s.accept(TokenType.IDENT, reject=True) + assert value_token is not None # for mypy + if ( + (number := value_token.value).isdigit() + or number.startswith("-") + and number[1:].isdigit() + ): + value = int(number) + elif value_token.value in BUILTIN_MATCHERS: + value = BUILTIN_MATCHERS[value_token.value] + else: + raise ParseError( + value_token.pos + 1, + f'unexpected character/s "{value_token.value}"', + ) + + ret = ast.keyword(keyword_name.value, ast.Constant(value)) + return ret + + +def all_kwargs(s: Scanner) -> list[ast.keyword]: + ret = [single_kwarg(s)] + while s.accept(TokenType.COMMA): + ret.append(single_kwarg(s)) + return ret + + +class MatcherCall(Protocol): + def __call__(self, name: str, /, **kwargs: object) -> bool: ... + + +@dataclasses.dataclass +class MatcherNameAdapter: + matcher: MatcherCall + name: str + + def __bool__(self) -> bool: + return self.matcher(self.name) + + def __call__(self, **kwargs: object) -> bool: + return self.matcher(self.name, **kwargs) + + +class MatcherAdapter(Mapping[str, MatcherNameAdapter]): """Adapts a matcher function to a locals mapping as required by eval().""" - def __init__(self, matcher: Callable[[str], bool]) -> None: + def __init__(self, matcher: MatcherCall) -> None: self.matcher = matcher - def __getitem__(self, key: str) -> bool: - return self.matcher(key[len(IDENT_PREFIX) :]) + def __getitem__(self, key: str) -> MatcherNameAdapter: + return MatcherNameAdapter(matcher=self.matcher, name=key[len(IDENT_PREFIX) :]) def __iter__(self) -> Iterator[str]: raise NotImplementedError() @@ -211,7 +303,7 @@ class Expression: ) return Expression(code) - def evaluate(self, matcher: Callable[[str], bool]) -> bool: + def evaluate(self, matcher: MatcherCall) -> bool: """Evaluate the match expression. :param matcher: @@ -220,5 +312,5 @@ class Expression: :returns: Whether the expression matches or not. """ - ret: bool = eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher)) + ret: bool = bool(eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher))) return ret diff --git a/testing/test_mark.py b/testing/test_mark.py index 090e10ee9..721bb71d3 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -233,6 +233,54 @@ def test_mark_option( assert passed_str == expected_passed +@pytest.mark.parametrize( + ("expr", "expected_passed"), + [ # TODO: improve/sort out + ("car(color='red')", ["test_one"]), + ("car(color='red') or car(color='blue')", ["test_one", "test_two"]), + ("car and not car(temp=5)", ["test_one", "test_three"]), + ("car(temp=4)", ["test_one"]), + ("car(temp=4) or car(temp=5)", ["test_one", "test_two"]), + ("car(temp=4) and car(temp=5)", []), + ("car(temp=-5)", ["test_three"]), + ("car(ac=True)", ["test_one"]), + ("car(ac=False)", ["test_two"]), + ("car(ac=None)", ["test_three"]), # test NOT_NONE_SENTINEL + ], + ids=str, +) +def test_mark_option_with_kwargs( + expr: str, expected_passed: list[str | None], pytester: Pytester +) -> None: + pytester.makepyfile( + """ + import pytest + @pytest.mark.car + @pytest.mark.car(ac=True) + @pytest.mark.car(temp=4) + @pytest.mark.car(color="red") + def test_one(): + pass + @pytest.mark.car + @pytest.mark.car(ac=False) + @pytest.mark.car(temp=5) + @pytest.mark.car(color="blue") + def test_two(): + pass + @pytest.mark.car + @pytest.mark.car(ac=None) + @pytest.mark.car(temp=-5) + def test_three(): + pass + + """ + ) + rec = pytester.inline_run("-m", expr) + passed, skipped, fail = rec.listoutcomes() + passed_str = [x.nodeid.split("::")[-1] for x in passed] + assert passed_str == expected_passed + + @pytest.mark.parametrize( ("expr", "expected_passed"), [("interface", ["test_interface"]), ("not interface", ["test_nointer"])], diff --git a/testing/test_mark_expression.py b/testing/test_mark_expression.py index 5bce004cb..0c1e73809 100644 --- a/testing/test_mark_expression.py +++ b/testing/test_mark_expression.py @@ -1,14 +1,19 @@ from __future__ import annotations +import collections from typing import Callable +from typing import cast +from _pytest.mark import MarkMatcher +from _pytest.mark import structures from _pytest.mark.expression import Expression +from _pytest.mark.expression import MatcherCall from _pytest.mark.expression import ParseError import pytest def evaluate(input: str, matcher: Callable[[str], bool]) -> bool: - return Expression.compile(input).evaluate(matcher) + return Expression.compile(input).evaluate(cast(MatcherCall, matcher)) def test_empty_is_false() -> None: @@ -153,6 +158,8 @@ def test_syntax_errors(expr: str, column: int, message: str) -> None: "1234", "1234abcd", "1234and", + "1234or", + "1234not", "notandor", "not_and_or", "not[and]or", @@ -195,3 +202,123 @@ def test_valid_idents(ident: str) -> None: def test_invalid_idents(ident: str) -> None: with pytest.raises(ParseError): evaluate(ident, lambda ident: True) + + +@pytest.mark.parametrize( + "expr, expected_error_msg", + ( + ("mark(1=2)", 'unexpected character/s "1"'), + ("mark(/=2)", 'unexpected character/s "/"'), + ("mark(True=False)", 'unexpected character/s "True"'), + ("mark(def=False)", 'unexpected character/s "def"'), + ("mark(class=False)", 'unexpected character/s "class"'), + ("mark(if=False)", 'unexpected character/s "if"'), + ("mark(else=False)", 'unexpected character/s "else"'), + ("mark(1)", 'unexpected character/s "1"'), + ("mark(var:=False", 'unexpected character/s "var:"'), + ("mark(valid=False, def=1)", 'unexpected character/s "def"'), + ("mark(var==", "expected identifier; got ="), + ("mark(var=none)", 'unexpected character/s "none"'), + ("mark(var=1.1)", 'unexpected character/s "1.1"'), + ("mark(var)", "expected =; got right parenthesis"), + ("mark(var=')", """closing quote "'" is missing"""), + ('mark(var=")', 'closing quote """ is missing'), + ("""mark(var="')""", 'closing quote """ is missing'), + ("""mark(var='")""", """closing quote "'" is missing"""), + (r"mark(var='\hugo')", "escaping not supported in marker expression"), + ), +) +def test_invalid_kwarg_name_or_value( # TODO: move to `test_syntax_errors` ? + expr: str, expected_error_msg: str, mark_matcher: MarkMatcher +) -> None: + with pytest.raises(ParseError, match=expected_error_msg): + assert evaluate(expr, mark_matcher) + + +@pytest.fixture(scope="session") +def mark_matcher() -> MarkMatcher: + markers = [] + mark_name_mapping = collections.defaultdict(list) + + def create_marker(name: str, kwargs: dict[str, object]) -> structures.Mark: + return structures.Mark(name=name, args=tuple(), kwargs=kwargs, _ispytest=True) + + markers.append(create_marker("number_mark", {"a": 1, "b": 2, "c": 3, "d": 999_999})) + markers.append( + create_marker("builtin_matchers_mark", {"x": True, "y": False, "z": None}) + ) + markers.append( + create_marker( + "str_mark", + {"m": "M", "space": "with space", "aaאבגדcc": "aaאבגדcc", "אבגד": "אבגד"}, + ) + ) + + for marker in markers: + mark_name_mapping[marker.name].append(marker) + + return MarkMatcher(mark_name_mapping) + + +@pytest.mark.parametrize( + "expr, expected", + ( + # happy cases + ("number_mark(a=1)", True), + ("number_mark(b=2)", True), + ("number_mark(a=1,b=2)", True), + ("number_mark(a=1, b=2)", True), + ("number_mark(d=999999)", True), + ("number_mark(a = 1,b= 2, c = 3)", True), + # sad cases + ("number_mark(a=6)", False), + ("number_mark(b=6)", False), + ("number_mark(a=1,b=6)", False), + ("number_mark(a=6,b=2)", False), + ("number_mark(a = 1,b= 2, c = 6)", False), + ("number_mark(a='1')", False), + ), +) +def test_keyword_expressions_with_numbers( + expr: str, expected: bool, mark_matcher: MarkMatcher +) -> None: + assert evaluate(expr, mark_matcher) is expected + + +@pytest.mark.parametrize( + "expr, expected", + ( + ("builtin_matchers_mark(x=True)", True), + ("builtin_matchers_mark(x=False)", False), + ("builtin_matchers_mark(y=True)", False), + ("builtin_matchers_mark(y=False)", True), + ("builtin_matchers_mark(z=None)", True), + ("builtin_matchers_mark(z=False)", False), + ("builtin_matchers_mark(z=True)", False), + ("builtin_matchers_mark(z=0)", False), + ("builtin_matchers_mark(z=1)", False), + ), +) +def test_builtin_matchers_keyword_expressions( # TODO: naming when decided + expr: str, expected: bool, mark_matcher: MarkMatcher +) -> None: + assert evaluate(expr, mark_matcher) is expected + + +@pytest.mark.parametrize( + "expr, expected", + ( + ("str_mark(m='M')", True), + ('str_mark(m="M")', True), + ("str_mark(aaאבגדcc='aaאבגדcc')", True), + ("str_mark(אבגד='אבגד')", True), + ("str_mark(space='with space')", True), + ("str_mark(m='wrong')", False), + ("str_mark(aaאבגדcc='wrong')", False), + ("str_mark(אבגד='wrong')", False), + ), +) +def test_str_keyword_expressions( + expr: str, expected: bool, mark_matcher: MarkMatcher +) -> None: + assert evaluate(expr, mark_matcher) is expected From 04f457c4f4fb38ea7ba8c88cd3a15fdc83722840 Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Thu, 20 Jun 2024 23:05:22 +0200 Subject: [PATCH 02/15] style: use `@overload` to get rid of mypy only assertions --- src/_pytest/mark/expression.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/_pytest/mark/expression.py b/src/_pytest/mark/expression.py index 16883c6b7..39d262c80 100644 --- a/src/_pytest/mark/expression.py +++ b/src/_pytest/mark/expression.py @@ -25,8 +25,10 @@ import keyword import re import types from typing import Iterator +from typing import Literal from typing import Mapping from typing import NoReturn +from typing import overload from typing import Protocol from typing import Sequence @@ -132,6 +134,14 @@ class Scanner: ) yield Token(TokenType.EOF, "", pos) + @overload + def accept(self, type: TokenType, *, reject: Literal[True]) -> Token: ... + + @overload + def accept( + self, type: TokenType, *, reject: Literal[False] = False + ) -> Token | None: ... + def accept(self, type: TokenType, *, reject: bool = False) -> Token | None: if self.current.type is type: token = self.current @@ -208,7 +218,6 @@ BUILTIN_MATCHERS = {"True": True, "False": False, "None": None} def single_kwarg(s: Scanner) -> ast.keyword: keyword_name = s.accept(TokenType.IDENT, reject=True) - assert keyword_name is not None # for mypy if not keyword_name.value.isidentifier() or keyword.iskeyword(keyword_name.value): raise ParseError( keyword_name.pos + 1, @@ -220,7 +229,6 @@ def single_kwarg(s: Scanner) -> ast.keyword: value: str | int | bool | None = value_token.value[1:-1] # strip quotes else: value_token = s.accept(TokenType.IDENT, reject=True) - assert value_token is not None # for mypy if ( (number := value_token.value).isdigit() or number.startswith("-") From 1cc35ecc3fdf28c201fbaf775945bae24be17ce7 Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Thu, 20 Jun 2024 23:23:02 +0200 Subject: [PATCH 03/15] test: add empty string keyword argument marker test cases --- testing/test_mark_expression.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/testing/test_mark_expression.py b/testing/test_mark_expression.py index 0c1e73809..e7ecaa7db 100644 --- a/testing/test_mark_expression.py +++ b/testing/test_mark_expression.py @@ -250,7 +250,13 @@ def mark_matcher() -> MarkMatcher: markers.append( create_marker( "str_mark", - {"m": "M", "space": "with space", "aaאבגדcc": "aaאבגדcc", "אבגד": "אבגד"}, + { + "m": "M", + "space": "with space", + "aaאבגדcc": "aaאבגדcc", + "אבגד": "אבגד", + "empty": "", + }, ) ) @@ -313,9 +319,13 @@ def test_builtin_matchers_keyword_expressions( # TODO: naming when decided ("str_mark(aaאבגדcc='aaאבגדcc')", True), ("str_mark(אבגד='אבגד')", True), ("str_mark(space='with space')", True), + ("str_mark(empty='')", True), + ('str_mark(empty="")', True), ("str_mark(m='wrong')", False), ("str_mark(aaאבגדcc='wrong')", False), ("str_mark(אבגד='wrong')", False), + ("str_mark(m='')", False), + ('str_mark(m="")', False), ), ) def test_str_keyword_expressions( From f4897391ec65b6f7b162d93b7b48a6587f9b659b Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Fri, 21 Jun 2024 14:04:42 +0200 Subject: [PATCH 04/15] refactor(mark): use existing `NOT_SET` sentinel --- src/_pytest/mark/__init__.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index ae6940a62..ddfa8355a 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -22,6 +22,7 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import UsageError +from _pytest.config.argparsing import NOT_SET from _pytest.config.argparsing import Parser from _pytest.stash import StashKey @@ -214,9 +215,6 @@ def deselect_by_keyword(items: list[Item], config: Config) -> None: items[:] = remaining -NOT_NONE_SENTINEL = object() - - @dataclasses.dataclass class MarkMatcher: """A matcher for markers which are present. @@ -243,9 +241,7 @@ class MarkMatcher: return True for mark in matches: - if all( - mark.kwargs.get(k, NOT_NONE_SENTINEL) == v for k, v in kwargs.items() - ): + if all(mark.kwargs.get(k, NOT_SET) == v for k, v in kwargs.items()): return True return False From 1e7eb20347819eb86cd22be998fef0dc50db47da Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Fri, 21 Jun 2024 12:07:31 +0200 Subject: [PATCH 05/15] perf(expression): improve string lexing & error messages --- src/_pytest/mark/expression.py | 23 ++++++++++++++--------- testing/test_mark_expression.py | 28 ++++++++++++++++------------ 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/_pytest/mark/expression.py b/src/_pytest/mark/expression.py index 39d262c80..c1770c197 100644 --- a/src/_pytest/mark/expression.py +++ b/src/_pytest/mark/expression.py @@ -99,18 +99,18 @@ class Scanner: elif input[pos] == ",": yield Token(TokenType.COMMA, ",", pos) pos += 1 - elif (quote_char := input[pos]) == "'" or input[pos] == '"': - quote_position = input[pos + 1 :].find(quote_char) - if quote_position == -1: + elif (quote_char := input[pos]) in ("'", '"'): + end_quote_pos = input.find(quote_char, pos + 1) + if end_quote_pos == -1: raise ParseError( pos + 1, f'closing quote "{quote_char}" is missing', ) - value = input[pos : pos + 2 + quote_position] - if "\\" in value: + value = input[pos : end_quote_pos + 1] + if (backslash_pos := input.find("\\")) != -1: raise ParseError( - pos + 1, - "escaping not supported in marker expression", + backslash_pos + 1, + r'escaping with "\" not supported in marker expression', ) yield Token(TokenType.STRING, value, pos) pos += len(value) @@ -218,10 +218,15 @@ BUILTIN_MATCHERS = {"True": True, "False": False, "None": None} def single_kwarg(s: Scanner) -> ast.keyword: keyword_name = s.accept(TokenType.IDENT, reject=True) - if not keyword_name.value.isidentifier() or keyword.iskeyword(keyword_name.value): + if not keyword_name.value.isidentifier(): raise ParseError( keyword_name.pos + 1, - f'unexpected character/s "{keyword_name.value}"', + f"not a valid python identifier {keyword_name.value}", + ) + if keyword.iskeyword(keyword_name.value): + raise ParseError( + keyword_name.pos + 1, + f"unexpected reserved python keyword `{keyword_name.value}`", ) s.accept(TokenType.EQUAL, reject=True) diff --git a/testing/test_mark_expression.py b/testing/test_mark_expression.py index e7ecaa7db..3c42cc967 100644 --- a/testing/test_mark_expression.py +++ b/testing/test_mark_expression.py @@ -207,25 +207,29 @@ def test_invalid_idents(ident: str) -> None: @pytest.mark.parametrize( "expr, expected_error_msg", ( - ("mark(1=2)", 'unexpected character/s "1"'), - ("mark(/=2)", 'unexpected character/s "/"'), - ("mark(True=False)", 'unexpected character/s "True"'), - ("mark(def=False)", 'unexpected character/s "def"'), - ("mark(class=False)", 'unexpected character/s "class"'), - ("mark(if=False)", 'unexpected character/s "if"'), - ("mark(else=False)", 'unexpected character/s "else"'), - ("mark(1)", 'unexpected character/s "1"'), - ("mark(var:=False", 'unexpected character/s "var:"'), - ("mark(valid=False, def=1)", 'unexpected character/s "def"'), + ("mark(True=False)", "unexpected reserved python keyword `True`"), + ("mark(def=False)", "unexpected reserved python keyword `def`"), + ("mark(class=False)", "unexpected reserved python keyword `class`"), + ("mark(if=False)", "unexpected reserved python keyword `if`"), + ("mark(else=False)", "unexpected reserved python keyword `else`"), + ("mark(valid=False, def=1)", "unexpected reserved python keyword `def`"), + ("mark(1)", "not a valid python identifier 1"), + ("mark(var:=False", "not a valid python identifier var:"), + ("mark(1=2)", "not a valid python identifier 1"), + ("mark(/=2)", "not a valid python identifier /"), ("mark(var==", "expected identifier; got ="), + ("mark(var)", "expected =; got right parenthesis"), ("mark(var=none)", 'unexpected character/s "none"'), ("mark(var=1.1)", 'unexpected character/s "1.1"'), - ("mark(var)", "expected =; got right parenthesis"), ("mark(var=')", """closing quote "'" is missing"""), ('mark(var=")', 'closing quote """ is missing'), ("""mark(var="')""", 'closing quote """ is missing'), ("""mark(var='")""", """closing quote "'" is missing"""), - (r"mark(var='\hugo')", "escaping not supported in marker expression"), + ( + r"mark(var='\hugo')", + r'escaping with "\\" not supported in marker expression', + ), + ("mark(empty_list=[])", r'unexpected character/s "\[\]"'), ), ) def test_invalid_kwarg_name_or_value( # TODO: move to `test_syntax_errors` ? From 6dd8ad60a49d3ba1e4071cf40a955c050ad78b08 Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Fri, 21 Jun 2024 14:41:22 +0200 Subject: [PATCH 06/15] refactor(MarkMatcher): replace `from_item` with `from_markers` method --- src/_pytest/mark/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index ddfa8355a..dccc6c529 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -6,6 +6,7 @@ import collections import dataclasses from typing import AbstractSet from typing import Collection +from typing import Iterable from typing import Optional from typing import TYPE_CHECKING @@ -227,9 +228,9 @@ class MarkMatcher: own_mark_name_mapping: dict[str, list[Mark]] @classmethod - def from_item(cls, item: Item) -> MarkMatcher: + def from_markers(cls, markers: Iterable[Mark]) -> MarkMatcher: mark_name_mapping = collections.defaultdict(list) - for mark in item.iter_markers(): + for mark in markers: mark_name_mapping[mark.name].append(mark) return cls(mark_name_mapping) @@ -256,7 +257,7 @@ def deselect_by_mark(items: list[Item], config: Config) -> None: remaining: list[Item] = [] deselected: list[Item] = [] for item in items: - if expr.evaluate(MarkMatcher.from_item(item)): + if expr.evaluate(MarkMatcher.from_markers(item.iter_markers())): remaining.append(item) else: deselected.append(item) From 3921d94316866954db9b60f05e6d7c627cd20bbf Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Fri, 21 Jun 2024 14:45:27 +0200 Subject: [PATCH 07/15] test: use new `MarkMatcher.from_markers` method & register test markers --- pyproject.toml | 3 +++ testing/test_mark_expression.py | 36 ++++++++------------------------- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 14f69bc34..e0ed2b900 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -358,6 +358,9 @@ markers = [ "foo", "bar", "baz", + "number_mark", + "builtin_matchers_mark", + "str_mark", # conftest.py reorders tests moving slow ones to the end of the list "slow", # experimental mark for all tests using pexpect diff --git a/testing/test_mark_expression.py b/testing/test_mark_expression.py index 3c42cc967..c31ab4470 100644 --- a/testing/test_mark_expression.py +++ b/testing/test_mark_expression.py @@ -1,11 +1,9 @@ from __future__ import annotations -import collections from typing import Callable from typing import cast from _pytest.mark import MarkMatcher -from _pytest.mark import structures from _pytest.mark.expression import Expression from _pytest.mark.expression import MatcherCall from _pytest.mark.expression import ParseError @@ -241,33 +239,15 @@ def test_invalid_kwarg_name_or_value( # TODO: move to `test_syntax_errors` ? @pytest.fixture(scope="session") def mark_matcher() -> MarkMatcher: - markers = [] - mark_name_mapping = collections.defaultdict(list) + markers = [ + pytest.mark.number_mark(a=1, b=2, c=3, d=999_999).mark, + pytest.mark.builtin_matchers_mark(x=True, y=False, z=None).mark, + pytest.mark.str_mark( + m="M", space="with space", empty="", aaאבגדcc="aaאבגדcc", אבגד="אבגד" + ).mark, + ] - def create_marker(name: str, kwargs: dict[str, object]) -> structures.Mark: - return structures.Mark(name=name, args=tuple(), kwargs=kwargs, _ispytest=True) - - markers.append(create_marker("number_mark", {"a": 1, "b": 2, "c": 3, "d": 999_999})) - markers.append( - create_marker("builtin_matchers_mark", {"x": True, "y": False, "z": None}) - ) - markers.append( - create_marker( - "str_mark", - { - "m": "M", - "space": "with space", - "aaאבגדcc": "aaאבגדcc", - "אבגד": "אבגד", - "empty": "", - }, - ) - ) - - for marker in markers: - mark_name_mapping[marker.name].append(marker) - - return MarkMatcher(mark_name_mapping) + return MarkMatcher.from_markers(markers) @pytest.mark.parametrize( From 7c7c36d7e07134242b2af4c8a291268a94cd4619 Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Fri, 21 Jun 2024 15:11:56 +0200 Subject: [PATCH 08/15] test(test_mark.py): add sad case that `-k` doesn't support keyword expressions --- testing/test_mark.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/testing/test_mark.py b/testing/test_mark.py index 721bb71d3..6a94cc9f7 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -420,6 +420,10 @@ def test_parametrize_with_module(pytester: Pytester) -> None: "not or", "at column 5: expected not OR left parenthesis OR identifier; got or", ), + ( + "nonexistent_mark(non_supported='kwarg')", + "Keyword expressions do not support call parameters", + ), ], ) def test_keyword_option_wrong_arguments( From 598d881c9c21e47c00755b9a40e66aca8eec17d1 Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Fri, 21 Jun 2024 15:57:13 +0200 Subject: [PATCH 09/15] docs: document keyword argument support in marker expressions --- doc/en/example/markers.rst | 24 ++++++++++++++++++++++++ doc/en/how-to/usage.rst | 10 +++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index c04d2a078..33c5cf95f 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -25,10 +25,12 @@ You can "mark" a test function with custom metadata like this: pass # perform some webtest test for your app + @pytest.mark.device(serial="123") def test_something_quick(): pass + @pytest.mark.device(serial="abc") def test_another(): pass @@ -71,6 +73,28 @@ Or the inverse, running all tests except the webtest ones: ===================== 3 passed, 1 deselected in 0.12s ====================== +.. _`marker_keyword_expression_example`: + +Additionally, you can restrict a test run to only run tests matching one or multiple marker +keyword arguments, e.g. to run only tests marked with ``device`` and the specific ``serial="123"``: + +.. code-block:: pytest + + $ pytest -v -m 'device(serial="123")' + =========================== test session starts ============================ + platform linux -- Python 3.x.y, pytest-8.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_something_quick PASSED [100%] + + ===================== 1 passed, 3 deselected in 0.12s ====================== + +.. note:: Only keyword argument matching is supported in marker expressions. + +.. note:: Only ``int``, (unescaped) ``str``, ``bool`` & ``None`` values are supported in marker expressions. + Selecting tests based on their node ID -------------------------------------- diff --git a/doc/en/how-to/usage.rst b/doc/en/how-to/usage.rst index 705fa009e..05ee04600 100644 --- a/doc/en/how-to/usage.rst +++ b/doc/en/how-to/usage.rst @@ -76,11 +76,19 @@ Specifying a specific parametrization of a test: **Run tests by marker expressions** +To run all tests which are decorated with the ``@pytest.mark.slow`` decorator: + .. code-block:: bash pytest -m slow -Will run all tests which are decorated with the ``@pytest.mark.slow`` decorator. + +To run all tests which are decorated with the annotated ``@pytest.mark.slow(phase=1)`` decorator, +with the ``phase`` keyword argument set to ``1``: + +.. code-block:: bash + + pytest -m slow(phase=1) For more information see :ref:`marks `. From 9cf9cfabcb2dfa41c9f168259e84b7d658cbf690 Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Fri, 21 Jun 2024 16:23:16 +0200 Subject: [PATCH 10/15] docs(12281.feature.rst): add changelog fragment --- changelog/12281.feature.rst | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/12281.feature.rst diff --git a/changelog/12281.feature.rst b/changelog/12281.feature.rst new file mode 100644 index 000000000..e5dd63824 --- /dev/null +++ b/changelog/12281.feature.rst @@ -0,0 +1,8 @@ +Added support for keyword matching in marker expressions. + +Now tests can be selected by marker keyword arguments. +Supported values are ``int``, (unescaped) ``str``, ``bool`` & ``None``. + +See :ref:`marker examples ` for more information. + +-- by :user:`lovetheguitar`. From c3e898353bda6fb7954e1634a0cadddd1c73b1a4 Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Fri, 21 Jun 2024 16:31:30 +0200 Subject: [PATCH 11/15] docs(AUTHORS): add myself as contributor --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 4d34d2ba9..5ae80eb02 100644 --- a/AUTHORS +++ b/AUTHORS @@ -245,6 +245,7 @@ Levon Saldamli Lewis Cowles Llandy Riveron Del Risco Loic Esteve +lovetheguitar Lukas Bednar Luke Murphy Maciek Fijalkowski From b1255a9aae1bd94d8c226b207c38942c857d4794 Mon Sep 17 00:00:00 2001 From: lovetheguitar Date: Fri, 21 Jun 2024 17:11:39 +0200 Subject: [PATCH 12/15] style(mark): type hint `**kwargs` as `str | int | bool | None` --- src/_pytest/mark/__init__.py | 4 ++-- src/_pytest/mark/expression.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index dccc6c529..702732c97 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -184,7 +184,7 @@ class KeywordMatcher: return cls(mapped_names) - def __call__(self, subname: str, /, **kwargs: object) -> bool: + def __call__(self, subname: str, /, **kwargs: str | int | bool | None) -> bool: if kwargs: raise UsageError("Keyword expressions do not support call parameters.") subname = subname.lower() @@ -234,7 +234,7 @@ class MarkMatcher: mark_name_mapping[mark.name].append(mark) return cls(mark_name_mapping) - def __call__(self, name: str, /, **kwargs: object) -> bool: + def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: if not (matches := self.own_mark_name_mapping.get(name, [])): return False diff --git a/src/_pytest/mark/expression.py b/src/_pytest/mark/expression.py index c1770c197..3f4071dce 100644 --- a/src/_pytest/mark/expression.py +++ b/src/_pytest/mark/expression.py @@ -260,7 +260,7 @@ def all_kwargs(s: Scanner) -> list[ast.keyword]: class MatcherCall(Protocol): - def __call__(self, name: str, /, **kwargs: object) -> bool: ... + def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: ... @dataclasses.dataclass @@ -271,7 +271,7 @@ class MatcherNameAdapter: def __bool__(self) -> bool: return self.matcher(self.name) - def __call__(self, **kwargs: object) -> bool: + def __call__(self, **kwargs: str | int | bool | None) -> bool: return self.matcher(self.name, **kwargs) From 24450e33e364f1aa7f4e2cc3e029e7d1b186774b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sviatoslav=20Sydorenko=20=28=D0=A1=D0=B2=D1=8F=D1=82=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B0=D0=B2=20=D0=A1=D0=B8=D0=B4=D0=BE=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE=29?= Date: Fri, 21 Jun 2024 22:05:43 +0200 Subject: [PATCH 13/15] =?UTF-8?q?=F0=9F=93=9D=20Use=20explicit=20RST=20rol?= =?UTF-8?q?es=20for=20built-in=20types=20in=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog/12281.feature.rst | 2 +- doc/en/example/markers.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/12281.feature.rst b/changelog/12281.feature.rst index e5dd63824..bcf882c51 100644 --- a/changelog/12281.feature.rst +++ b/changelog/12281.feature.rst @@ -1,7 +1,7 @@ Added support for keyword matching in marker expressions. Now tests can be selected by marker keyword arguments. -Supported values are ``int``, (unescaped) ``str``, ``bool`` & ``None``. +Supported values are :class:`int`, (unescaped) :class:`str`, :class:`bool` & :data:`None`. See :ref:`marker examples ` for more information. diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index 33c5cf95f..159ff2cd1 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -93,7 +93,7 @@ keyword arguments, e.g. to run only tests marked with ``device`` and the specifi .. note:: Only keyword argument matching is supported in marker expressions. -.. note:: Only ``int``, (unescaped) ``str``, ``bool`` & ``None`` values are supported in marker expressions. +.. note:: Only :class:`int`, (unescaped) :class:`str`, :class:`bool` & :data:`None` values are supported in marker expressions. Selecting tests based on their node ID -------------------------------------- From 329662e2eca801be53678fde7e1ef23583eb5fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sviatoslav=20Sydorenko=20=28=D0=A1=D0=B2=D1=8F=D1=82=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B0=D0=B2=20=D0=A1=D0=B8=D0=B4=D0=BE=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE=29?= Date: Fri, 21 Jun 2024 22:06:33 +0200 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=93=9D=20Drop=20stray=20trailing=20?= =?UTF-8?q?period=20from=20the=20change=20note=20byline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog/12281.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/12281.feature.rst b/changelog/12281.feature.rst index bcf882c51..c6e8e3b30 100644 --- a/changelog/12281.feature.rst +++ b/changelog/12281.feature.rst @@ -5,4 +5,4 @@ Supported values are :class:`int`, (unescaped) :class:`str`, :class:`bool` & :da See :ref:`marker examples ` for more information. --- by :user:`lovetheguitar`. +-- by :user:`lovetheguitar` From 75a2225ed1d387e6a224be9b0f9d7297bfb08284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sviatoslav=20Sydorenko=20=28=D0=A1=D0=B2=D1=8F=D1=82=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B0=D0=B2=20=D0=A1=D0=B8=D0=B4=D0=BE=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE=29?= Date: Fri, 21 Jun 2024 22:09:45 +0200 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=94=A5=20Drop=20the=20missing=20kwa?= =?UTF-8?q?rgs=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/_pytest/mark/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 702732c97..a4f942c5a 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -238,9 +238,6 @@ class MarkMatcher: if not (matches := self.own_mark_name_mapping.get(name, [])): return False - if not kwargs: - return True - for mark in matches: if all(mark.kwargs.get(k, NOT_SET) == v for k, v in kwargs.items()): return True