This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -1,19 +1,16 @@
"""Generic mechanism for marking and selecting python functions."""
from __future__ import annotations
import collections
from collections.abc import Collection
from collections.abc import Iterable
from collections.abc import Set as AbstractSet
import dataclasses
from typing import AbstractSet
from typing import Collection
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
from typing import Union
from .expression import Expression
from .structures import _HiddenParam
from .expression import ParseError
from .structures import EMPTY_PARAMETERSET_OPTION
from .structures import get_empty_parameterset_mark
from .structures import HIDDEN_PARAM
from .structures import Mark
from .structures import MARK_GEN
from .structures import MarkDecorator
@@ -23,17 +20,14 @@ 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
if TYPE_CHECKING:
from _pytest.nodes import Item
__all__ = [
"HIDDEN_PARAM",
"MARK_GEN",
"Mark",
"MarkDecorator",
@@ -43,13 +37,13 @@ __all__ = [
]
old_mark_config_key = StashKey[Config | None]()
old_mark_config_key = StashKey[Optional[Config]]()
def param(
*values: object,
marks: MarkDecorator | Collection[MarkDecorator | Mark] = (),
id: str | _HiddenParam | None = None,
marks: Union[MarkDecorator, Collection[Union[MarkDecorator, Mark]]] = (),
id: Optional[str] = None,
) -> ParameterSet:
"""Specify a parameter in `pytest.mark.parametrize`_ calls or
:ref:`parametrized fixtures <fixture-parametrize-marks>`.
@@ -67,34 +61,22 @@ def param(
assert eval(test_input) == expected
:param values: Variable args of the values of the parameter set, in order.
:param marks:
A single mark or a list of marks to be applied to this parameter set.
:ref:`pytest.mark.usefixtures <pytest.mark.usefixtures ref>` cannot be added via this parameter.
:type id: str | Literal[pytest.HIDDEN_PARAM] | None
:param id:
The id to attribute to this parameter set.
.. versionadded:: 8.4
:ref:`hidden-param` means to hide the parameter set
from the test name. Can only be used at most 1 time, as
test names need to be unique.
:param marks: A single mark or a list of marks to be applied to this parameter set.
:param id: The id to attribute to this parameter set.
"""
return ParameterSet.param(*values, marks=marks, id=id)
def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("general")
group._addoption( # private to use reserved lower-case short option
group._addoption(
"-k",
action="store",
dest="keyword",
default="",
metavar="EXPRESSION",
help="Only run tests which match the given substring expression. "
"An expression is a Python evaluable expression "
"An expression is a Python evaluatable 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 "
@@ -107,7 +89,7 @@ def pytest_addoption(parser: Parser) -> None:
"The matching is case-insensitive.",
)
group._addoption( # private to use reserved lower-case short option
group._addoption(
"-m",
action="store",
dest="markexpr",
@@ -123,12 +105,12 @@ def pytest_addoption(parser: Parser) -> None:
help="show markers (builtin, plugin and per-project ones).",
)
parser.addini("markers", "Register new markers for test functions", "linelist")
parser.addini("markers", "Markers for test functions", "linelist")
parser.addini(EMPTY_PARAMETERSET_OPTION, "Default marker for empty parametersets")
@hookimpl(tryfirst=True)
def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
import _pytest.config
if config.option.markers:
@@ -138,7 +120,7 @@ def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
parts = line.split(":", 1)
name = parts[0]
rest = parts[1] if len(parts) == 2 else ""
tw.write(f"@pytest.mark.{name}:", bold=True)
tw.write("@pytest.mark.%s:" % name, bold=True)
tw.line(rest)
tw.line()
config._ensure_unconfigure()
@@ -167,22 +149,15 @@ class KeywordMatcher:
_names: AbstractSet[str]
@classmethod
def from_item(cls, item: Item) -> KeywordMatcher:
def from_item(cls, item: "Item") -> "KeywordMatcher":
mapped_names = set()
# Add the names of the current item and any parent items,
# except the Session and root Directory's which are not
# interesting for matching.
# Add the names of the current item and any parent items.
import pytest
for node in item.listchain():
if isinstance(node, pytest.Session):
continue
if isinstance(node, pytest.Directory) and isinstance(
node.parent, pytest.Session
):
continue
mapped_names.add(node.name)
if not isinstance(node, pytest.Session):
mapped_names.add(node.name)
# Add the names added as extra keywords to current or parent items.
mapped_names.update(item.listextrakeywords())
@@ -197,14 +172,17 @@ class KeywordMatcher:
return cls(mapped_names)
def __call__(self, subname: str, /, **kwargs: str | int | bool | None) -> bool:
if kwargs:
raise UsageError("Keyword expressions do not support call parameters.")
def __call__(self, subname: str) -> bool:
subname = subname.lower()
return any(subname in name.lower() for name in self._names)
names = (name.lower() for name in self._names)
for name in names:
if subname in name:
return True
return False
def deselect_by_keyword(items: list[Item], config: Config) -> None:
def deselect_by_keyword(items: "List[Item]", config: Config) -> None:
keywordexpr = config.option.keyword.lstrip()
if not keywordexpr:
return
@@ -231,37 +209,29 @@ class MarkMatcher:
Tries to match on any marker names, attached to the given colitem.
"""
__slots__ = ("own_mark_name_mapping",)
__slots__ = ("own_mark_names",)
own_mark_name_mapping: dict[str, list[Mark]]
own_mark_names: AbstractSet[str]
@classmethod
def from_markers(cls, markers: Iterable[Mark]) -> MarkMatcher:
mark_name_mapping = collections.defaultdict(list)
for mark in markers:
mark_name_mapping[mark.name].append(mark)
return cls(mark_name_mapping)
def from_item(cls, item: "Item") -> "MarkMatcher":
mark_names = {mark.name for mark in item.iter_markers()}
return cls(mark_names)
def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool:
if not (matches := self.own_mark_name_mapping.get(name, [])):
return False
for mark in matches: # pylint: disable=consider-using-any-or-all
if all(mark.kwargs.get(k, NOT_SET) == v for k, v in kwargs.items()):
return True
return False
def __call__(self, name: str) -> bool:
return name in self.own_mark_names
def deselect_by_mark(items: list[Item], config: Config) -> None:
def deselect_by_mark(items: "List[Item]", config: Config) -> None:
matchexpr = config.option.markexpr
if not matchexpr:
return
expr = _parse_expression(matchexpr, "Wrong expression passed to '-m'")
remaining: list[Item] = []
deselected: list[Item] = []
remaining: List[Item] = []
deselected: List[Item] = []
for item in items:
if expr.evaluate(MarkMatcher.from_markers(item.iter_markers())):
if expr.evaluate(MarkMatcher.from_item(item)):
remaining.append(item)
else:
deselected.append(item)
@@ -273,13 +243,11 @@ def deselect_by_mark(items: list[Item], config: Config) -> None:
def _parse_expression(expr: str, exc_message: str) -> Expression:
try:
return Expression.compile(expr)
except SyntaxError as e:
raise UsageError(
f"{exc_message}: {e.text}: at column {e.offset}: {e.msg}"
) from None
except ParseError as e:
raise UsageError(f"{exc_message}: {expr}: {e}") from None
def pytest_collection_modifyitems(items: list[Item], config: Config) -> None:
def pytest_collection_modifyitems(items: "List[Item]", config: Config) -> None:
deselect_by_keyword(items, config)
deselect_by_mark(items, config)
@@ -292,8 +260,8 @@ def pytest_configure(config: Config) -> None:
if empty_parameterset not in ("skip", "xfail", "fail_at_collect", None, ""):
raise UsageError(
f"{EMPTY_PARAMETERSET_OPTION!s} must be one of skip, xfail or fail_at_collect"
f" but it is {empty_parameterset!r}"
"{!s} must be one of skip, xfail or fail_at_collect"
" but it is {!r}".format(EMPTY_PARAMETERSET_OPTION, empty_parameterset)
)

View File

@@ -5,49 +5,40 @@ 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 kwargs?
not_expr: 'not' not_expr | '(' expr ')' | ident
ident: (\w|:|\+|-|\.|\[|\]|\\|/)+
kwargs: ('(' name '=' value ( ', ' name '=' value )* ')')
name: a valid ident, but not a reserved keyword
value: (unescaped) string literal | (-)?[0-9]+ | 'False' | 'True' | 'None'
The semantics are:
- Empty expression evaluates to False.
- ident evaluates to True or False according to a provided matcher function.
- ident with parentheses and keyword arguments evaluates to True or False according to a provided matcher function.
- ident evaluates to True of False according to a provided matcher function.
- or/and/not evaluate according to the usual boolean semantics.
"""
from __future__ import annotations
import ast
from collections.abc import Iterator
from collections.abc import Mapping
from collections.abc import Sequence
import dataclasses
import enum
import keyword
import re
import sys
import types
from typing import Final
from typing import final
from typing import Literal
from typing import Callable
from typing import Iterator
from typing import Mapping
from typing import NoReturn
from typing import overload
from typing import Protocol
from typing import Optional
from typing import Sequence
if sys.version_info >= (3, 8):
astNameConstant = ast.Constant
else:
astNameConstant = ast.NameConstant
__all__ = [
"Expression",
"ExpressionMatcher",
"ParseError",
]
FILE_NAME: Final = "<pytest match expression>"
class TokenType(enum.Enum):
LPAREN = "left parenthesis"
RPAREN = "right parenthesis"
@@ -56,24 +47,35 @@ class TokenType(enum.Enum):
NOT = "not"
IDENT = "identifier"
EOF = "end of input"
EQUAL = "="
STRING = "string literal"
COMMA = ","
@dataclasses.dataclass(frozen=True)
class Token:
__slots__ = ("pos", "type", "value")
__slots__ = ("type", "value", "pos")
type: TokenType
value: str
pos: int
class ParseError(Exception):
"""The expression contains invalid syntax.
:param column: The column in the line where the error occurred (1-based).
:param message: A description of the error.
"""
def __init__(self, column: int, message: str) -> None:
self.column = column
self.message = message
def __str__(self) -> str:
return f"at column {self.column}: {self.message}"
class Scanner:
__slots__ = ("current", "input", "tokens")
__slots__ = ("tokens", "current")
def __init__(self, input: str) -> None:
self.input = input
self.tokens = self.lex(input)
self.current = next(self.tokens)
@@ -88,27 +90,6 @@ 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]) in ("'", '"'):
end_quote_pos = input.find(quote_char, pos + 1)
if end_quote_pos == -1:
raise SyntaxError(
f'closing quote "{quote_char}" is missing',
(FILE_NAME, 1, pos + 1, input),
)
value = input[pos : end_quote_pos + 1]
if (backslash_pos := input.find("\\")) != -1:
raise SyntaxError(
r'escaping with "\" not supported in marker expression',
(FILE_NAME, 1, backslash_pos + 1, input),
)
yield Token(TokenType.STRING, value, pos)
pos += len(value)
else:
match = re.match(r"(:?\w|:|\+|-|\.|\[|\]|\\|/)+", input[pos:])
if match:
@@ -123,21 +104,13 @@ class Scanner:
yield Token(TokenType.IDENT, value, pos)
pos += len(value)
else:
raise SyntaxError(
raise ParseError(
pos + 1,
f'unexpected character "{input[pos]}"',
(FILE_NAME, 1, pos + 1, input),
)
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:
def accept(self, type: TokenType, *, reject: bool = False) -> Optional[Token]:
if self.current.type is type:
token = self.current
if token.type is not TokenType.EOF:
@@ -148,12 +121,12 @@ class Scanner:
return None
def reject(self, expected: Sequence[TokenType]) -> NoReturn:
raise SyntaxError(
raise ParseError(
self.current.pos + 1,
"expected {}; got {}".format(
" OR ".join(type.value for type in expected),
self.current.type.value,
),
(FILE_NAME, 1, self.current.pos + 1, self.input),
)
@@ -165,7 +138,7 @@ IDENT_PREFIX = "$"
def expression(s: Scanner) -> ast.Expression:
if s.accept(TokenType.EOF):
ret: ast.expr = ast.Constant(False)
ret: ast.expr = astNameConstant(False)
else:
ret = expr(s)
s.accept(TokenType.EOF, reject=True)
@@ -197,108 +170,18 @@ def not_expr(s: Scanner) -> ast.expr:
return ret
ident = s.accept(TokenType.IDENT)
if ident:
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
return ast.Name(IDENT_PREFIX + ident.value, ast.Load())
s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT))
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():
raise SyntaxError(
f"not a valid python identifier {keyword_name.value}",
(FILE_NAME, 1, keyword_name.pos + 1, s.input),
)
if keyword.iskeyword(keyword_name.value):
raise SyntaxError(
f"unexpected reserved python keyword `{keyword_name.value}`",
(FILE_NAME, 1, keyword_name.pos + 1, s.input),
)
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)
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 SyntaxError(
f'unexpected character/s "{value_token.value}"',
(FILE_NAME, 1, value_token.pos + 1, s.input),
)
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 ExpressionMatcher(Protocol):
"""A callable which, given an identifier and optional kwargs, should return
whether it matches in an :class:`Expression` evaluation.
Should be prepared to handle arbitrary strings as input.
If no kwargs are provided, the expression of the form `foo`.
If kwargs are provided, the expression is of the form `foo(1, b=True, "s")`.
If the expression is not supported (e.g. don't want to accept the kwargs
syntax variant), should raise :class:`~pytest.UsageError`.
Example::
def matcher(name: str, /, **kwargs: str | int | bool | None) -> bool:
# Match `cat`.
if name == "cat" and not kwargs:
return True
# Match `dog(barks=True)`.
if name == "dog" and kwargs == {"barks": False}:
return True
return False
"""
def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: ...
@dataclasses.dataclass
class MatcherNameAdapter:
matcher: ExpressionMatcher
name: str
def __bool__(self) -> bool:
return self.matcher(self.name)
def __call__(self, **kwargs: str | int | bool | None) -> bool:
return self.matcher(self.name, **kwargs)
class MatcherAdapter(Mapping[str, MatcherNameAdapter]):
class MatcherAdapter(Mapping[str, bool]):
"""Adapts a matcher function to a locals mapping as required by eval()."""
def __init__(self, matcher: ExpressionMatcher) -> None:
def __init__(self, matcher: Callable[[str], bool]) -> None:
self.matcher = matcher
def __getitem__(self, key: str) -> MatcherNameAdapter:
return MatcherNameAdapter(matcher=self.matcher, name=key[len(IDENT_PREFIX) :])
def __getitem__(self, key: str) -> bool:
return self.matcher(key[len(IDENT_PREFIX) :])
def __iter__(self) -> Iterator[str]:
raise NotImplementedError()
@@ -307,47 +190,39 @@ class MatcherAdapter(Mapping[str, MatcherNameAdapter]):
raise NotImplementedError()
@final
class Expression:
"""A compiled match expression as used by -k and -m.
The expression can be evaluated against different matchers.
"""
__slots__ = ("_code", "input")
__slots__ = ("code",)
def __init__(self, input: str, code: types.CodeType) -> None:
#: The original input line, as a string.
self.input: Final = input
self._code: Final = code
def __init__(self, code: types.CodeType) -> None:
self.code = code
@classmethod
def compile(cls, input: str) -> Expression:
def compile(self, input: str) -> "Expression":
"""Compile a match expression.
:param input: The input expression - one line.
:raises SyntaxError: If the expression is malformed.
"""
astexpr = expression(Scanner(input))
code = compile(
code: types.CodeType = compile(
astexpr,
filename="<pytest match expression>",
mode="eval",
)
return Expression(input, code)
return Expression(code)
def evaluate(self, matcher: ExpressionMatcher) -> bool:
def evaluate(self, matcher: Callable[[str], bool]) -> bool:
"""Evaluate the match expression.
:param matcher:
A callback which determines whether an identifier matches or not.
See the :class:`ExpressionMatcher` protocol for details and example.
Given an identifier, should return whether it matches or not.
Should be prepared to handle arbitrary strings as input.
:returns: Whether the expression matches or not.
:raises UsageError:
If the matcher doesn't support the expression. Cannot happen if the
matcher supports all expressions.
"""
return bool(eval(self._code, {"__builtins__": {}}, MatcherAdapter(matcher)))
ret: bool = eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher))
return ret

View File

@@ -1,37 +1,36 @@
# mypy: allow-untyped-defs
from __future__ import annotations
import collections.abc
from collections.abc import Callable
from collections.abc import Collection
from collections.abc import Iterable
from collections.abc import Iterator
from collections.abc import Mapping
from collections.abc import MutableMapping
from collections.abc import Sequence
import dataclasses
import enum
import inspect
import warnings
from typing import Any
from typing import final
from typing import Callable
from typing import Collection
from typing import Iterable
from typing import Iterator
from typing import List
from typing import Mapping
from typing import MutableMapping
from typing import NamedTuple
from typing import Optional
from typing import overload
from typing import Sequence
from typing import Set
from typing import Tuple
from typing import Type
from typing import TYPE_CHECKING
from typing import TypeVar
import warnings
from typing import Union
from .._code import getfslineno
from ..compat import ascii_escaped
from ..compat import final
from ..compat import NOTSET
from ..compat import NotSetType
from _pytest.config import Config
from _pytest.deprecated import check_ispytest
from _pytest.deprecated import MARKED_FIXTURE
from _pytest.outcomes import fail
from _pytest.raises import AbstractRaises
from _pytest.scope import _ScopeName
from _pytest.warning_types import PytestUnknownMarkWarning
if TYPE_CHECKING:
from ..nodes import Node
@@ -39,37 +38,33 @@ if TYPE_CHECKING:
EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark"
# Singleton type for HIDDEN_PARAM, as described in:
# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions
class _HiddenParam(enum.Enum):
token = 0
#: Can be used as a parameter set id to hide it from the test name.
HIDDEN_PARAM = _HiddenParam.token
def istestfunc(func) -> bool:
return callable(func) and getattr(func, "__name__", "<lambda>") != "<lambda>"
def get_empty_parameterset_mark(
config: Config, argnames: Sequence[str], func
) -> MarkDecorator:
) -> "MarkDecorator":
from ..nodes import Collector
argslisting = ", ".join(argnames)
fs, lineno = getfslineno(func)
reason = "got empty parameter set %r, function %s at %s:%d" % (
argnames,
func.__name__,
fs,
lineno,
)
_fs, lineno = getfslineno(func)
reason = f"got empty parameter set for ({argslisting})"
requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION)
if requested_mark in ("", None, "skip"):
mark = MARK_GEN.skip(reason=reason)
elif requested_mark == "xfail":
mark = MARK_GEN.xfail(reason=reason, run=False)
elif requested_mark == "fail_at_collect":
f_name = func.__name__
_, lineno = getfslineno(func)
raise Collector.CollectError(
f"Empty parameter set in '{func.__name__}' at line {lineno + 1}"
"Empty parameter set in '%s' at line %d" % (f_name, lineno + 1)
)
else:
raise LookupError(requested_mark)
@@ -77,68 +72,34 @@ def get_empty_parameterset_mark(
class ParameterSet(NamedTuple):
"""A set of values for a set of parameters along with associated marks and
an optional ID for the set.
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
values: Sequence[Union[object, NotSetType]]
marks: Collection[Union["MarkDecorator", "Mark"]]
id: Optional[str]
@classmethod
def param(
cls,
*values: object,
marks: MarkDecorator | Collection[MarkDecorator | Mark] = (),
id: str | _HiddenParam | None = None,
) -> ParameterSet:
marks: Union["MarkDecorator", Collection[Union["MarkDecorator", "Mark"]]] = (),
id: Optional[str] = None,
) -> "ParameterSet":
if isinstance(marks, MarkDecorator):
marks = (marks,)
else:
assert isinstance(marks, collections.abc.Collection)
if any(i.name == "usefixtures" for i in marks):
raise ValueError(
"pytest.param cannot add pytest.mark.usefixtures; see "
"https://docs.pytest.org/en/stable/reference/reference.html#pytest-param"
)
if id is not None:
if not isinstance(id, str) and id is not HIDDEN_PARAM:
raise TypeError(
"Expected id to be a string or a `pytest.HIDDEN_PARAM` sentinel, "
f"got {type(id)}: {id!r}",
)
if not isinstance(id, str):
raise TypeError(f"Expected id to be a string, got {type(id)}: {id!r}")
id = ascii_escaped(id)
return cls(values, marks, id)
@classmethod
def extract_from(
cls,
parameterset: ParameterSet | Sequence[object] | object,
parameterset: Union["ParameterSet", Sequence[object], object],
force_tuple: bool = False,
) -> ParameterSet:
) -> "ParameterSet":
"""Extract from an object or objects.
:param parameterset:
@@ -149,6 +110,7 @@ class ParameterSet(NamedTuple):
Enforce tuple wrapping so single argument tuple values
don't get decomposed and break tests.
"""
if isinstance(parameterset, cls):
return parameterset
if force_tuple:
@@ -163,11 +125,11 @@ class ParameterSet(NamedTuple):
@staticmethod
def _parse_parametrize_args(
argnames: str | Sequence[str],
argvalues: Iterable[ParameterSet | Sequence[object] | object],
argnames: Union[str, Sequence[str]],
argvalues: Iterable[Union["ParameterSet", Sequence[object], object]],
*args,
**kwargs,
) -> tuple[Sequence[str], bool]:
) -> Tuple[Sequence[str], bool]:
if isinstance(argnames, str):
argnames = [x.strip() for x in argnames.split(",") if x.strip()]
force_tuple = len(argnames) == 1
@@ -177,9 +139,9 @@ class ParameterSet(NamedTuple):
@staticmethod
def _parse_parametrize_parameters(
argvalues: Iterable[ParameterSet | Sequence[object] | object],
argvalues: Iterable[Union["ParameterSet", Sequence[object], object]],
force_tuple: bool,
) -> list[ParameterSet]:
) -> List["ParameterSet"]:
return [
ParameterSet.extract_from(x, force_tuple=force_tuple) for x in argvalues
]
@@ -187,12 +149,12 @@ class ParameterSet(NamedTuple):
@classmethod
def _for_parametrize(
cls,
argnames: str | Sequence[str],
argvalues: Iterable[ParameterSet | Sequence[object] | object],
argnames: Union[str, Sequence[str]],
argvalues: Iterable[Union["ParameterSet", Sequence[object], object]],
func,
config: Config,
nodeid: str,
) -> tuple[Sequence[str], list[ParameterSet]]:
) -> Tuple[Sequence[str], List["ParameterSet"]]:
argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues)
parameters = cls._parse_parametrize_parameters(argvalues, force_tuple)
del argvalues
@@ -222,9 +184,7 @@ class ParameterSet(NamedTuple):
# parameter set with NOTSET values, with the "empty parameter set" mark applied to it.
mark = get_empty_parameterset_mark(config, argnames, func)
parameters.append(
ParameterSet(
values=(NOTSET,) * len(argnames), marks=[mark], id="NOTSET"
)
ParameterSet(values=(NOTSET,) * len(argnames), marks=[mark], id=None)
)
return argnames, parameters
@@ -237,24 +197,24 @@ class Mark:
#: Name of the mark.
name: str
#: Positional arguments of the mark decorator.
args: tuple[Any, ...]
args: Tuple[Any, ...]
#: Keyword arguments of the mark decorator.
kwargs: Mapping[str, Any]
#: Source Mark for ids with parametrize Marks.
_param_ids_from: Mark | None = dataclasses.field(default=None, repr=False)
_param_ids_from: Optional["Mark"] = dataclasses.field(default=None, repr=False)
#: Resolved/generated ids with parametrize Marks.
_param_ids_generated: Sequence[str] | None = dataclasses.field(
_param_ids_generated: Optional[Sequence[str]] = dataclasses.field(
default=None, repr=False
)
def __init__(
self,
name: str,
args: tuple[Any, ...],
args: Tuple[Any, ...],
kwargs: Mapping[str, Any],
param_ids_from: Mark | None = None,
param_ids_generated: Sequence[str] | None = None,
param_ids_from: Optional["Mark"] = None,
param_ids_generated: Optional[Sequence[str]] = None,
*,
_ispytest: bool = False,
) -> None:
@@ -270,7 +230,7 @@ class Mark:
def _has_param_ids(self) -> bool:
return "ids" in self.kwargs or len(self.args) >= 4
def combined_with(self, other: Mark) -> Mark:
def combined_with(self, other: "Mark") -> "Mark":
"""Return a new Mark which is a combination of this
Mark and another Mark.
@@ -282,7 +242,7 @@ class Mark:
assert self.name == other.name
# Remember source of ids with parametrize Marks.
param_ids_from: Mark | None = None
param_ids_from: Optional[Mark] = None
if self.name == "parametrize":
if other._has_param_ids():
param_ids_from = other
@@ -301,7 +261,7 @@ class Mark:
# A generic parameter designating an object to which a Mark may
# be applied -- a test function (callable) or class.
# Note: a lambda is not allowed, but this can't be represented.
Markable = TypeVar("Markable", bound=Callable[..., object] | type)
Markable = TypeVar("Markable", bound=Union[Callable[..., object], type])
@dataclasses.dataclass
@@ -310,8 +270,8 @@ class MarkDecorator:
``MarkDecorators`` are created with ``pytest.mark``::
mark1 = pytest.mark.NAME # Simple MarkDecorator
mark2 = pytest.mark.NAME(name1=value) # Parametrized MarkDecorator
mark1 = pytest.mark.NAME # Simple MarkDecorator
mark2 = pytest.mark.NAME(name1=value) # Parametrized MarkDecorator
and can then be applied as decorators to test functions::
@@ -353,7 +313,7 @@ class MarkDecorator:
return self.mark.name
@property
def args(self) -> tuple[Any, ...]:
def args(self) -> Tuple[Any, ...]:
"""Alias for mark.args."""
return self.mark.args
@@ -367,7 +327,7 @@ class MarkDecorator:
""":meta private:"""
return self.name # for backward-compat (2.4.1 had this attr)
def with_args(self, *args: object, **kwargs: object) -> MarkDecorator:
def with_args(self, *args: object, **kwargs: object) -> "MarkDecorator":
"""Return a MarkDecorator with extra arguments added.
Unlike calling the MarkDecorator, with_args() can be used even
@@ -380,11 +340,11 @@ class MarkDecorator:
# return type. Not much we can do about that. Thankfully mypy picks
# the first match so it works out even if we break the rules.
@overload
def __call__(self, arg: Markable) -> Markable: # type: ignore[overload-overlap]
def __call__(self, arg: Markable) -> Markable: # type: ignore[misc]
pass
@overload
def __call__(self, *args: object, **kwargs: object) -> MarkDecorator:
def __call__(self, *args: object, **kwargs: object) -> "MarkDecorator":
pass
def __call__(self, *args: object, **kwargs: object):
@@ -392,22 +352,17 @@ class MarkDecorator:
if args and not kwargs:
func = args[0]
is_class = inspect.isclass(func)
# For staticmethods/classmethods, the marks are eventually fetched from the
# function object, not the descriptor, so unwrap.
unwrapped_func = func
if isinstance(func, staticmethod | classmethod):
unwrapped_func = func.__func__
if len(args) == 1 and (istestfunc(unwrapped_func) or is_class):
store_mark(unwrapped_func, self.mark, stacklevel=3)
if len(args) == 1 and (istestfunc(func) or is_class):
store_mark(func, self.mark)
return func
return self.with_args(*args, **kwargs)
def get_unpacked_marks(
obj: object | type,
obj: Union[object, type],
*,
consider_mro: bool = True,
) -> list[Mark]:
) -> List[Mark]:
"""Obtain the unpacked marks that are stored on an object.
If obj is a class and consider_mro is true, return marks applied to
@@ -437,7 +392,7 @@ def get_unpacked_marks(
def normalize_mark_list(
mark_list: Iterable[Mark | MarkDecorator],
mark_list: Iterable[Union[Mark, MarkDecorator]]
) -> Iterable[Mark]:
"""
Normalize an iterable of Mark or MarkDecorator objects into a list of marks
@@ -449,22 +404,16 @@ def normalize_mark_list(
for mark in mark_list:
mark_obj = getattr(mark, "mark", mark)
if not isinstance(mark_obj, Mark):
raise TypeError(f"got {mark_obj!r} instead of Mark")
raise TypeError(f"got {repr(mark_obj)} instead of Mark")
yield mark_obj
def store_mark(obj, mark: Mark, *, stacklevel: int = 2) -> None:
def store_mark(obj, mark: Mark) -> None:
"""Store a Mark on an object.
This is used to implement the Mark declarations/decorators correctly.
"""
assert isinstance(mark, Mark), mark
from ..fixtures import getfixturemarker
if getfixturemarker(obj) is not None:
warnings.warn(MARKED_FIXTURE, stacklevel=stacklevel)
# Always reassign name to avoid updating pytestmark in a reference that
# was only borrowed.
obj.pytestmark = [*get_unpacked_marks(obj, consider_mro=False), mark]
@@ -473,52 +422,59 @@ def store_mark(obj, mark: Mark, *, stacklevel: int = 2) -> None:
# Typing for builtin pytest marks. This is cheating; it gives builtin marks
# special privilege, and breaks modularity. But practicality beats purity...
if TYPE_CHECKING:
from _pytest.scope import _ScopeName
class _SkipMarkDecorator(MarkDecorator):
@overload # type: ignore[override,no-overload-impl]
def __call__(self, arg: Markable) -> Markable: ...
@overload # type: ignore[override,misc,no-overload-impl]
def __call__(self, arg: Markable) -> Markable:
...
@overload
def __call__(self, reason: str = ...) -> MarkDecorator: ...
def __call__(self, reason: str = ...) -> "MarkDecorator":
...
class _SkipifMarkDecorator(MarkDecorator):
def __call__( # type: ignore[override]
self,
condition: str | bool = ...,
*conditions: str | bool,
condition: Union[str, bool] = ...,
*conditions: Union[str, bool],
reason: str = ...,
) -> MarkDecorator: ...
) -> MarkDecorator:
...
class _XfailMarkDecorator(MarkDecorator):
@overload # type: ignore[override,no-overload-impl]
def __call__(self, arg: Markable) -> Markable: ...
@overload # type: ignore[override,misc,no-overload-impl]
def __call__(self, arg: Markable) -> Markable:
...
@overload
def __call__(
self,
condition: str | bool = False,
*conditions: str | bool,
condition: Union[str, bool] = ...,
*conditions: Union[str, bool],
reason: str = ...,
run: bool = ...,
raises: None
| type[BaseException]
| tuple[type[BaseException], ...]
| AbstractRaises[BaseException] = ...,
raises: Union[Type[BaseException], Tuple[Type[BaseException], ...]] = ...,
strict: bool = ...,
) -> MarkDecorator: ...
) -> MarkDecorator:
...
class _ParametrizeMarkDecorator(MarkDecorator):
def __call__( # type: ignore[override]
self,
argnames: str | Sequence[str],
argvalues: Iterable[ParameterSet | Sequence[object] | object],
argnames: Union[str, Sequence[str]],
argvalues: Iterable[Union[ParameterSet, Sequence[object], object]],
*,
indirect: bool | Sequence[str] = ...,
ids: Iterable[None | str | float | int | bool]
| Callable[[Any], object | None]
| None = ...,
scope: _ScopeName | None = ...,
) -> MarkDecorator: ...
indirect: Union[bool, Sequence[str]] = ...,
ids: Optional[
Union[
Iterable[Union[None, str, float, int, bool]],
Callable[[Any], Optional[object]],
]
] = ...,
scope: Optional[_ScopeName] = ...,
) -> MarkDecorator:
...
class _UsefixturesMarkDecorator(MarkDecorator):
def __call__(self, *fixtures: str) -> MarkDecorator: # type: ignore[override]
@@ -538,10 +494,9 @@ class MarkGenerator:
import pytest
@pytest.mark.slowtest
def test_function():
pass
pass
applies a 'slowtest' :class:`Mark` on ``test_function``.
"""
@@ -557,8 +512,8 @@ class MarkGenerator:
def __init__(self, *, _ispytest: bool = False) -> None:
check_ispytest(_ispytest)
self._config: Config | None = None
self._markers: set[str] = set()
self._config: Optional[Config] = None
self._markers: Set[str] = set()
def __getattr__(self, name: str) -> MarkDecorator:
"""Generate a new :class:`MarkDecorator` with the given name."""
@@ -580,24 +535,21 @@ class MarkGenerator:
# If the name is not in the set of known marks after updating,
# then it really is time to issue a warning or an error.
if name not in self._markers:
# Raise a specific error for common misspellings of "parametrize".
if name in ["parameterize", "parametrise", "parameterise"]:
__tracebackhide__ = True
fail(f"Unknown '{name}' mark, did you mean 'parametrize'?")
strict_markers = self._config.getini("strict_markers")
if strict_markers is None:
strict_markers = self._config.getini("strict")
if strict_markers:
if self._config.option.strict_markers or self._config.option.strict:
fail(
f"{name!r} not found in `markers` configuration option",
pytrace=False,
)
# Raise a specific error for common misspellings of "parametrize".
if name in ["parameterize", "parametrise", "parameterise"]:
__tracebackhide__ = True
fail(f"Unknown '{name}' mark, did you mean 'parametrize'?")
warnings.warn(
f"Unknown pytest.mark.{name} - is this a typo? You can register "
"Unknown pytest.mark.%s - is this a typo? You can register "
"custom marks to avoid this warning - for details, see "
"https://docs.pytest.org/en/stable/how-to/mark.html",
"https://docs.pytest.org/en/stable/how-to/mark.html" % name,
PytestUnknownMarkWarning,
2,
)
@@ -610,9 +562,9 @@ MARK_GEN = MarkGenerator(_ispytest=True)
@final
class NodeKeywords(MutableMapping[str, Any]):
__slots__ = ("_markers", "node", "parent")
__slots__ = ("node", "parent", "_markers")
def __init__(self, node: Node) -> None:
def __init__(self, node: "Node") -> None:
self.node = node
self.parent = node.parent
self._markers = {node.name: True}
@@ -632,13 +584,15 @@ class NodeKeywords(MutableMapping[str, Any]):
# below and use the collections.abc fallback, but that would be slow.
def __contains__(self, key: object) -> bool:
return key in self._markers or (
self.parent is not None and key in self.parent.keywords
return (
key in self._markers
or self.parent is not None
and key in self.parent.keywords
)
def update( # type: ignore[override]
self,
other: Mapping[str, Any] | Iterable[tuple[str, Any]] = (),
other: Union[Mapping[str, Any], Iterable[Tuple[str, Any]]] = (),
**kwds: Any,
) -> None:
self._markers.update(other)