Skip to content

mark: expose Mark, MarkDecorator, MarkGenerator under pytest for typing purposes #8179

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Dec 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/7469.deprecation.rst
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions changelog/7469.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions doc/en/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ For example:
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
:meth:`Node.iter_markers <_pytest.nodes.Node.iter_markers>`. The ``mark`` object will have the following attributes:

Expand Down Expand Up @@ -849,21 +849,21 @@ Item
MarkDecorator
~~~~~~~~~~~~~

.. autoclass:: _pytest.mark.MarkDecorator
.. autoclass:: pytest.MarkDecorator()
:members:


MarkGenerator
~~~~~~~~~~~~~

.. autoclass:: _pytest.mark.MarkGenerator
.. autoclass:: pytest.MarkGenerator()
:members:


Mark
~~~~

.. autoclass:: _pytest.mark.structures.Mark
.. autoclass:: pytest.Mark()
:members:


Expand Down
2 changes: 1 addition & 1 deletion src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ def applymarker(self, marker: Union[str, MarkDecorator]) -> None:
on all function invocations.

: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(...)``.
"""
self.node.add_marker(marker)
Expand Down
88 changes: 57 additions & 31 deletions src/_pytest/mark/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from ..compat import NOTSET
from ..compat import NotSetType
from _pytest.config import Config
from _pytest.deprecated import check_ispytest
from _pytest.outcomes import fail
from _pytest.warning_types import PytestUnknownMarkWarning

Expand Down Expand Up @@ -200,21 +201,38 @@ def _for_parametrize(


@final
@attr.s(frozen=True)
@attr.s(frozen=True, init=False, auto_attribs=True)
class Mark:
#: Name of the mark.
name = attr.ib(type=str)
name: str
#: Positional arguments of the mark decorator.
args = attr.ib(type=Tuple[Any, ...])
args: Tuple[Any, ...]
#: Keyword arguments of the mark decorator.
kwargs = attr.ib(type=Mapping[str, Any])
kwargs: Mapping[str, Any]

#: 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.
_param_ids_generated = attr.ib(
type=Optional[Sequence[str]], default=None, repr=False
)
_param_ids_generated: Optional[Sequence[str]] = attr.ib(default=None, repr=False)
Copy link
Member

@RonnyPfannschmidt RonnyPfannschmidt Dec 20, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we introduce a metaclass that mangles the _ispytest in __call__ - making all those classes have strange ctors is a pain

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO I prefer the _is_pytest approach because it is more explicit... I think a metaclass introduces needless complexity just for this change.

Just stating my opinion though, not against changing it to a metaclass if Ran also prefers to do so.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel simillary to @nicoddemus, I think that while the _ispytest is a bit annoying, it is better in most other dimensions (like ease of understanding, greppability, ease of type checking, ease of bypassing when needed), so I prefer it.


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:
return "ids" in self.kwargs or len(self.args) >= 4
Expand Down Expand Up @@ -243,20 +261,21 @@ def combined_with(self, other: "Mark") -> "Mark":
self.args + other.args,
dict(self.kwargs, **other.kwargs),
param_ids_from=param_ids_from,
_ispytest=True,
)


# 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=Union[Callable[..., object], type])
Markable = TypeVar("Markable", bound=Union[Callable[..., object], type])


@attr.s
@attr.s(init=False, auto_attribs=True)
class MarkDecorator:
"""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
mark2 = pytest.mark.NAME(name1=value) # Parametrized MarkDecorator
Expand All @@ -267,7 +286,7 @@ class MarkDecorator:
def test_function():
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
additional keyword arguments, it attaches the mark to the class so it
Expand All @@ -276,19 +295,24 @@ def test_function():
2. If called with a single function as its only positional argument and
no additional keyword arguments, it attaches the mark to the function,
containing all the arguments already stored internally in the
MarkDecorator.
``MarkDecorator``.

3. When called in any other case, it returns a new MarkDecorator instance
with the original MarkDecorator's content updated with the arguments
passed to this call.
3. When called in any other case, it returns a new ``MarkDecorator``
instance with the original ``MarkDecorator``'s content updated with
the arguments passed to this call.

Note: The rules above prevent MarkDecorators from storing only a single
function or class reference as their positional argument with no
Note: The rules above prevent a ``MarkDecorator`` from storing only a
single function or class reference as its positional argument with no
additional keyword or positional arguments. You can work around this by
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
def name(self) -> str:
Expand All @@ -307,6 +331,7 @@ def kwargs(self) -> Mapping[str, Any]:

@property
def markname(self) -> str:
""":meta private:"""
return self.name # for backward-compat (2.4.1 had this attr)

def __repr__(self) -> str:
Expand All @@ -317,17 +342,15 @@ def with_args(self, *args: object, **kwargs: object) -> "MarkDecorator":

Unlike calling the MarkDecorator, with_args() can be used even
if the sole argument is a callable/class.

:rtype: MarkDecorator
"""
mark = Mark(self.name, args, kwargs)
return self.__class__(self.mark.combined_with(mark))
mark = Mark(self.name, args, kwargs, _ispytest=True)
return MarkDecorator(self.mark.combined_with(mark), _ispytest=True)

# Type ignored because the overloads overlap with an incompatible
# 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[misc]
def __call__(self, arg: Markable) -> Markable: # type: ignore[misc]
pass

@overload
Expand Down Expand Up @@ -386,7 +409,7 @@ def store_mark(obj, mark: Mark) -> None:

class _SkipMarkDecorator(MarkDecorator):
@overload # type: ignore[override,misc]
def __call__(self, arg: _Markable) -> _Markable:
def __call__(self, arg: Markable) -> Markable:
...

@overload
Expand All @@ -404,7 +427,7 @@ def __call__( # type: ignore[override]

class _XfailMarkDecorator(MarkDecorator):
@overload # type: ignore[override,misc]
def __call__(self, arg: _Markable) -> _Markable:
def __call__(self, arg: Markable) -> Markable:
...

@overload
Expand Down Expand Up @@ -465,9 +488,6 @@ def test_function():
applies a 'slowtest' :class:`Mark` on ``test_function``.
"""

_config: Optional[Config] = None
_markers: Set[str] = set()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ouch!


# See TYPE_CHECKING above.
if TYPE_CHECKING:
skip: _SkipMarkDecorator
Expand All @@ -477,7 +497,13 @@ def test_function():
usefixtures: _UsefixturesMarkDecorator
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:
"""Generate a new :class:`MarkDecorator` with the given name."""
if name[0] == "_":
raise AttributeError("Marker name must NOT start with underscore")

Expand Down Expand Up @@ -515,10 +541,10 @@ def __getattr__(self, name: str) -> MarkDecorator:
2,
)

return MarkDecorator(Mark(name, (), {}))
return MarkDecorator(Mark(name, (), {}, _ispytest=True), _ispytest=True)


MARK_GEN = MarkGenerator()
MARK_GEN = MarkGenerator(_ispytest=True)


@final
Expand Down
6 changes: 6 additions & 0 deletions src/pytest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
from _pytest.freeze_support import freeze_includes
from _pytest.logging import LogCaptureFixture
from _pytest.main import Session
from _pytest.mark import 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.monkeypatch import MonkeyPatch
from _pytest.nodes import Collector
Expand Down Expand Up @@ -89,6 +92,9 @@
"LogCaptureFixture",
"main",
"mark",
"Mark",
"MarkDecorator",
"MarkGenerator",
"Module",
"MonkeyPatch",
"Package",
Expand Down
4 changes: 2 additions & 2 deletions testing/test_mark.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def test_pytest_exists_in_namespace_all(self, attr: str, modulename: str) -> Non
assert attr in module.__all__ # type: ignore

def test_pytest_mark_notcallable(self) -> None:
mark = MarkGenerator()
mark = MarkGenerator(_ispytest=True)
with pytest.raises(TypeError):
mark() # type: ignore[operator]

Expand All @@ -40,7 +40,7 @@ class SomeClass:
assert pytest.mark.foo.with_args(SomeClass) is not SomeClass # type: ignore[comparison-overlap]

def test_pytest_mark_name_starts_with_underscore(self) -> None:
mark = MarkGenerator()
mark = MarkGenerator(_ispytest=True)
with pytest.raises(AttributeError):
mark._some_name

Expand Down