Merge pull request #8179 from bluetech/typing-public-mark

mark: expose Mark, MarkDecorator, MarkGenerator under pytest for typing purposes
This commit is contained in:
Ran Benita 2020-12-22 16:32:02 +02:00 committed by GitHub
commit 813ce45985
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 89 additions and 38 deletions

View File

@ -0,0 +1,7 @@
Directly constructing the following classes is now deprecated:
- ``_pytest.mark.structures.Mark``
- ``_pytest.mark.structures.MarkDecorator``
- ``_pytest.mark.structures.MarkGenerator``
These have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 7.0.0.

View File

@ -0,0 +1,12 @@
The types of objects used in pytest's API are now exported so they may be used in type annotations.
The newly-exported types are:
- ``pytest.Mark`` for :class:`marks <pytest.Mark>`.
- ``pytest.MarkDecorator`` for :class:`mark decorators <pytest.MarkDecorator>`.
- ``pytest.MarkGenerator`` for the :class:`pytest.mark <pytest.MarkGenerator>` singleton.
Constructing them directly is not supported; they are only meant for use in type annotations.
Doing so will emit a deprecation warning, and may become a hard-error in pytest 7.0.
Subclassing them is also not supported. This is not currently enforced at runtime, but is detected by type-checkers such as mypy.

View File

@ -239,7 +239,7 @@ For example:
def test_function(): def test_function():
... ...
Will create and attach a :class:`Mark <_pytest.mark.structures.Mark>` object to the collected Will create and attach a :class:`Mark <pytest.Mark>` object to the collected
:class:`Item <pytest.Item>`, which can then be accessed by fixtures or hooks with :class:`Item <pytest.Item>`, which can then be accessed by fixtures or hooks with
:meth:`Node.iter_markers <_pytest.nodes.Node.iter_markers>`. The ``mark`` object will have the following attributes: :meth:`Node.iter_markers <_pytest.nodes.Node.iter_markers>`. The ``mark`` object will have the following attributes:
@ -849,21 +849,21 @@ Item
MarkDecorator MarkDecorator
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
.. autoclass:: _pytest.mark.MarkDecorator .. autoclass:: pytest.MarkDecorator()
:members: :members:
MarkGenerator MarkGenerator
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
.. autoclass:: _pytest.mark.MarkGenerator .. autoclass:: pytest.MarkGenerator()
:members: :members:
Mark Mark
~~~~ ~~~~
.. autoclass:: _pytest.mark.structures.Mark .. autoclass:: pytest.Mark()
:members: :members:

View File

@ -551,7 +551,7 @@ class FixtureRequest:
on all function invocations. on all function invocations.
:param marker: :param marker:
A :py:class:`_pytest.mark.MarkDecorator` object created by a call A :class:`pytest.MarkDecorator` object created by a call
to ``pytest.mark.NAME(...)``. to ``pytest.mark.NAME(...)``.
""" """
self.node.add_marker(marker) self.node.add_marker(marker)

View File

@ -28,6 +28,7 @@ from ..compat import final
from ..compat import NOTSET from ..compat import NOTSET
from ..compat import NotSetType from ..compat import NotSetType
from _pytest.config import Config from _pytest.config import Config
from _pytest.deprecated import check_ispytest
from _pytest.outcomes import fail from _pytest.outcomes import fail
from _pytest.warning_types import PytestUnknownMarkWarning from _pytest.warning_types import PytestUnknownMarkWarning
@ -200,21 +201,38 @@ class ParameterSet(
@final @final
@attr.s(frozen=True) @attr.s(frozen=True, init=False, auto_attribs=True)
class Mark: class Mark:
#: Name of the mark. #: Name of the mark.
name = attr.ib(type=str) name: str
#: Positional arguments of the mark decorator. #: Positional arguments of the mark decorator.
args = attr.ib(type=Tuple[Any, ...]) args: Tuple[Any, ...]
#: Keyword arguments of the mark decorator. #: Keyword arguments of the mark decorator.
kwargs = attr.ib(type=Mapping[str, Any]) kwargs: Mapping[str, Any]
#: Source Mark for ids with parametrize Marks. #: Source Mark for ids with parametrize Marks.
_param_ids_from = attr.ib(type=Optional["Mark"], default=None, repr=False) _param_ids_from: Optional["Mark"] = attr.ib(default=None, repr=False)
#: Resolved/generated ids with parametrize Marks. #: Resolved/generated ids with parametrize Marks.
_param_ids_generated = attr.ib( _param_ids_generated: Optional[Sequence[str]] = attr.ib(default=None, repr=False)
type=Optional[Sequence[str]], default=None, repr=False
) def __init__(
self,
name: str,
args: Tuple[Any, ...],
kwargs: Mapping[str, Any],
param_ids_from: Optional["Mark"] = None,
param_ids_generated: Optional[Sequence[str]] = None,
*,
_ispytest: bool = False,
) -> None:
""":meta private:"""
check_ispytest(_ispytest)
# Weirdness to bypass frozen=True.
object.__setattr__(self, "name", name)
object.__setattr__(self, "args", args)
object.__setattr__(self, "kwargs", kwargs)
object.__setattr__(self, "_param_ids_from", param_ids_from)
object.__setattr__(self, "_param_ids_generated", param_ids_generated)
def _has_param_ids(self) -> bool: def _has_param_ids(self) -> bool:
return "ids" in self.kwargs or len(self.args) >= 4 return "ids" in self.kwargs or len(self.args) >= 4
@ -243,20 +261,21 @@ class Mark:
self.args + other.args, self.args + other.args,
dict(self.kwargs, **other.kwargs), dict(self.kwargs, **other.kwargs),
param_ids_from=param_ids_from, param_ids_from=param_ids_from,
_ispytest=True,
) )
# A generic parameter designating an object to which a Mark may # A generic parameter designating an object to which a Mark may
# be applied -- a test function (callable) or class. # be applied -- a test function (callable) or class.
# Note: a lambda is not allowed, but this can't be represented. # Note: a lambda is not allowed, but this can't be represented.
_Markable = TypeVar("_Markable", bound=Union[Callable[..., object], type]) Markable = TypeVar("Markable", bound=Union[Callable[..., object], type])
@attr.s @attr.s(init=False, auto_attribs=True)
class MarkDecorator: class MarkDecorator:
"""A decorator for applying a mark on test functions and classes. """A decorator for applying a mark on test functions and classes.
MarkDecorators are created with ``pytest.mark``:: ``MarkDecorators`` are created with ``pytest.mark``::
mark1 = pytest.mark.NAME # Simple MarkDecorator mark1 = pytest.mark.NAME # Simple MarkDecorator
mark2 = pytest.mark.NAME(name1=value) # Parametrized MarkDecorator mark2 = pytest.mark.NAME(name1=value) # Parametrized MarkDecorator
@ -267,7 +286,7 @@ class MarkDecorator:
def test_function(): def test_function():
pass pass
When a MarkDecorator is called it does the following: When a ``MarkDecorator`` is called, it does the following:
1. If called with a single class as its only positional argument and no 1. If called with a single class as its only positional argument and no
additional keyword arguments, it attaches the mark to the class so it additional keyword arguments, it attaches the mark to the class so it
@ -276,19 +295,24 @@ class MarkDecorator:
2. If called with a single function as its only positional argument and 2. If called with a single function as its only positional argument and
no additional keyword arguments, it attaches the mark to the function, no additional keyword arguments, it attaches the mark to the function,
containing all the arguments already stored internally in the containing all the arguments already stored internally in the
MarkDecorator. ``MarkDecorator``.
3. When called in any other case, it returns a new MarkDecorator instance 3. When called in any other case, it returns a new ``MarkDecorator``
with the original MarkDecorator's content updated with the arguments instance with the original ``MarkDecorator``'s content updated with
passed to this call. the arguments passed to this call.
Note: The rules above prevent MarkDecorators from storing only a single Note: The rules above prevent a ``MarkDecorator`` from storing only a
function or class reference as their positional argument with no single function or class reference as its positional argument with no
additional keyword or positional arguments. You can work around this by additional keyword or positional arguments. You can work around this by
using `with_args()`. using `with_args()`.
""" """
mark = attr.ib(type=Mark, validator=attr.validators.instance_of(Mark)) mark: Mark
def __init__(self, mark: Mark, *, _ispytest: bool = False) -> None:
""":meta private:"""
check_ispytest(_ispytest)
self.mark = mark
@property @property
def name(self) -> str: def name(self) -> str:
@ -307,6 +331,7 @@ class MarkDecorator:
@property @property
def markname(self) -> str: def markname(self) -> str:
""":meta private:"""
return self.name # for backward-compat (2.4.1 had this attr) return self.name # for backward-compat (2.4.1 had this attr)
def __repr__(self) -> str: def __repr__(self) -> str:
@ -317,17 +342,15 @@ class MarkDecorator:
Unlike calling the MarkDecorator, with_args() can be used even Unlike calling the MarkDecorator, with_args() can be used even
if the sole argument is a callable/class. if the sole argument is a callable/class.
:rtype: MarkDecorator
""" """
mark = Mark(self.name, args, kwargs) mark = Mark(self.name, args, kwargs, _ispytest=True)
return self.__class__(self.mark.combined_with(mark)) return MarkDecorator(self.mark.combined_with(mark), _ispytest=True)
# Type ignored because the overloads overlap with an incompatible # Type ignored because the overloads overlap with an incompatible
# return type. Not much we can do about that. Thankfully mypy picks # 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. # the first match so it works out even if we break the rules.
@overload @overload
def __call__(self, arg: _Markable) -> _Markable: # type: ignore[misc] def __call__(self, arg: Markable) -> Markable: # type: ignore[misc]
pass pass
@overload @overload
@ -386,7 +409,7 @@ if TYPE_CHECKING:
class _SkipMarkDecorator(MarkDecorator): class _SkipMarkDecorator(MarkDecorator):
@overload # type: ignore[override,misc] @overload # type: ignore[override,misc]
def __call__(self, arg: _Markable) -> _Markable: def __call__(self, arg: Markable) -> Markable:
... ...
@overload @overload
@ -404,7 +427,7 @@ if TYPE_CHECKING:
class _XfailMarkDecorator(MarkDecorator): class _XfailMarkDecorator(MarkDecorator):
@overload # type: ignore[override,misc] @overload # type: ignore[override,misc]
def __call__(self, arg: _Markable) -> _Markable: def __call__(self, arg: Markable) -> Markable:
... ...
@overload @overload
@ -465,9 +488,6 @@ class MarkGenerator:
applies a 'slowtest' :class:`Mark` on ``test_function``. applies a 'slowtest' :class:`Mark` on ``test_function``.
""" """
_config: Optional[Config] = None
_markers: Set[str] = set()
# See TYPE_CHECKING above. # See TYPE_CHECKING above.
if TYPE_CHECKING: if TYPE_CHECKING:
skip: _SkipMarkDecorator skip: _SkipMarkDecorator
@ -477,7 +497,13 @@ class MarkGenerator:
usefixtures: _UsefixturesMarkDecorator usefixtures: _UsefixturesMarkDecorator
filterwarnings: _FilterwarningsMarkDecorator filterwarnings: _FilterwarningsMarkDecorator
def __init__(self, *, _ispytest: bool = False) -> None:
check_ispytest(_ispytest)
self._config: Optional[Config] = None
self._markers: Set[str] = set()
def __getattr__(self, name: str) -> MarkDecorator: def __getattr__(self, name: str) -> MarkDecorator:
"""Generate a new :class:`MarkDecorator` with the given name."""
if name[0] == "_": if name[0] == "_":
raise AttributeError("Marker name must NOT start with underscore") raise AttributeError("Marker name must NOT start with underscore")
@ -515,10 +541,10 @@ class MarkGenerator:
2, 2,
) )
return MarkDecorator(Mark(name, (), {})) return MarkDecorator(Mark(name, (), {}, _ispytest=True), _ispytest=True)
MARK_GEN = MarkGenerator() MARK_GEN = MarkGenerator(_ispytest=True)
@final @final

View File

@ -21,7 +21,10 @@ from _pytest.fixtures import yield_fixture
from _pytest.freeze_support import freeze_includes from _pytest.freeze_support import freeze_includes
from _pytest.logging import LogCaptureFixture from _pytest.logging import LogCaptureFixture
from _pytest.main import Session from _pytest.main import Session
from _pytest.mark import Mark
from _pytest.mark import MARK_GEN as mark from _pytest.mark import MARK_GEN as mark
from _pytest.mark import MarkDecorator
from _pytest.mark import MarkGenerator
from _pytest.mark import param from _pytest.mark import param
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
from _pytest.nodes import Collector from _pytest.nodes import Collector
@ -89,6 +92,9 @@ __all__ = [
"LogCaptureFixture", "LogCaptureFixture",
"main", "main",
"mark", "mark",
"Mark",
"MarkDecorator",
"MarkGenerator",
"Module", "Module",
"MonkeyPatch", "MonkeyPatch",
"Package", "Package",

View File

@ -21,7 +21,7 @@ class TestMark:
assert attr in module.__all__ # type: ignore assert attr in module.__all__ # type: ignore
def test_pytest_mark_notcallable(self) -> None: def test_pytest_mark_notcallable(self) -> None:
mark = MarkGenerator() mark = MarkGenerator(_ispytest=True)
with pytest.raises(TypeError): with pytest.raises(TypeError):
mark() # type: ignore[operator] mark() # type: ignore[operator]
@ -40,7 +40,7 @@ class TestMark:
assert pytest.mark.foo.with_args(SomeClass) is not SomeClass # type: ignore[comparison-overlap] assert pytest.mark.foo.with_args(SomeClass) is not SomeClass # type: ignore[comparison-overlap]
def test_pytest_mark_name_starts_with_underscore(self) -> None: def test_pytest_mark_name_starts_with_underscore(self) -> None:
mark = MarkGenerator() mark = MarkGenerator(_ispytest=True)
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
mark._some_name mark._some_name