From a99ca879e7c9db0ac91324e701275e9439cf7b73 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 21 Sep 2020 17:45:24 +0300 Subject: [PATCH] Mark some public and to-be-public classes as `@final` This indicates at least for people using type checkers that these classes are not designed for inheritance and we make no stability guarantees regarding inheritance of them. Currently this doesn't show up in the docs. Sphinx does actually support `@final`, however it only works when imported directly from `typing`, while we import from `_pytest.compat`. In the future there might also be a `@sealed` decorator which would cover some more cases. --- changelog/7780.improvement.rst | 3 +++ src/_pytest/_code/code.py | 2 ++ src/_pytest/_io/terminalwriter.py | 2 ++ src/_pytest/cacheprovider.py | 2 ++ src/_pytest/capture.py | 2 ++ src/_pytest/compat.py | 16 +++++++++++++++- src/_pytest/config/__init__.py | 5 +++++ src/_pytest/config/argparsing.py | 2 ++ src/_pytest/config/exceptions.py | 4 ++++ src/_pytest/fixtures.py | 5 +++++ src/_pytest/logging.py | 2 ++ src/_pytest/main.py | 2 ++ src/_pytest/mark/structures.py | 4 ++++ src/_pytest/monkeypatch.py | 2 ++ src/_pytest/pytester.py | 2 ++ src/_pytest/python.py | 3 +++ src/_pytest/python_api.py | 2 ++ src/_pytest/recwarn.py | 2 ++ src/_pytest/reports.py | 3 +++ src/_pytest/runner.py | 2 ++ src/_pytest/terminal.py | 2 ++ src/_pytest/tmpdir.py | 3 +++ src/_pytest/warning_types.py | 10 ++++++++++ 23 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 changelog/7780.improvement.rst diff --git a/changelog/7780.improvement.rst b/changelog/7780.improvement.rst new file mode 100644 index 00000000000..6651387b14e --- /dev/null +++ b/changelog/7780.improvement.rst @@ -0,0 +1,3 @@ +Public classes which are not designed to be inherited from are now marked `@final `_. +Code which inherits from these classes will trigger a type-checking (e.g. mypy) error, but will still work in runtime. +Currently the ``final`` designation does not appear in the API Reference but hopefully will in the future. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 98aea8c11ec..5063e660477 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -38,6 +38,7 @@ from _pytest._io.saferepr import safeformat from _pytest._io.saferepr import saferepr from _pytest.compat import ATTRS_EQ_FIELD +from _pytest.compat import final from _pytest.compat import get_real_func from _pytest.compat import overload from _pytest.compat import TYPE_CHECKING @@ -414,6 +415,7 @@ def recursionindex(self) -> Optional[int]: _E = TypeVar("_E", bound=BaseException, covariant=True) +@final @attr.s(repr=False) class ExceptionInfo(Generic[_E]): """Wraps sys.exc_info() objects and offers help for navigating the traceback.""" diff --git a/src/_pytest/_io/terminalwriter.py b/src/_pytest/_io/terminalwriter.py index 0afe4a0eda4..a9404ebcc16 100644 --- a/src/_pytest/_io/terminalwriter.py +++ b/src/_pytest/_io/terminalwriter.py @@ -7,6 +7,7 @@ from typing import TextIO from .wcwidth import wcswidth +from _pytest.compat import final # This code was initially copied from py 1.8.1, file _io/terminalwriter.py. @@ -36,6 +37,7 @@ def should_do_markup(file: TextIO) -> bool: ) +@final class TerminalWriter: _esctable = dict( black=30, diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index ba27735d039..b04305ed9d2 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -21,6 +21,7 @@ from .reports import CollectReport from _pytest import nodes from _pytest._io import TerminalWriter +from _pytest.compat import final from _pytest.compat import order_preserving_dict from _pytest.config import Config from _pytest.config import ExitCode @@ -50,6 +51,7 @@ """ +@final @attr.s class Cache: _cachedir = attr.ib(type=Path, repr=False) diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 3bf3bc923ef..2d2b392aba8 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -17,6 +17,7 @@ from typing import Union import pytest +from _pytest.compat import final from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.config.argparsing import Parser @@ -498,6 +499,7 @@ def writeorg(self, data): # pertinent parts of a namedtuple. If the mypy limitation is ever lifted, can # make it a namedtuple again. # [0]: https://github.com/python/mypy/issues/685 +@final @functools.total_ordering class CaptureResult(Generic[AnyStr]): """The result of :method:`CaptureFixture.readouterr`.""" diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 0c9f47de707..7eab2ea0c85 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -19,7 +19,6 @@ import attr -from _pytest._io.saferepr import saferepr from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME @@ -297,6 +296,8 @@ def get_real_func(obj): break obj = new_obj else: + from _pytest._io.saferepr import saferepr + raise ValueError( ("could not find real function of {start}\nstopped at {current}").format( start=saferepr(start_obj), current=saferepr(obj) @@ -357,6 +358,19 @@ def overload(f): # noqa: F811 return f +if TYPE_CHECKING: + if sys.version_info >= (3, 8): + from typing import final as final + else: + from typing_extensions import final as final +elif sys.version_info >= (3, 8): + from typing import final as final +else: + + def final(f): # noqa: F811 + return f + + if getattr(attr, "__version_info__", ()) >= (19, 2): ATTRS_EQ_FIELD = "eq" else: diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 088ec765e67..f89ed37027b 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -43,6 +43,7 @@ from _pytest._code import ExceptionInfo from _pytest._code import filter_traceback from _pytest._io import TerminalWriter +from _pytest.compat import final from _pytest.compat import importlib_metadata from _pytest.compat import TYPE_CHECKING from _pytest.outcomes import fail @@ -76,6 +77,7 @@ hookspec = HookspecMarker("pytest") +@final class ExitCode(enum.IntEnum): """Encodes the valid exit codes by pytest. @@ -322,6 +324,7 @@ def _prepareconfig( raise +@final class PytestPluginManager(PluginManager): """A :py:class:`pluggy.PluginManager ` with additional pytest-specific functionality: @@ -815,6 +818,7 @@ def _args_converter(args: Iterable[str]) -> Tuple[str, ...]: return tuple(args) +@final class Config: """Access to configuration values, pluginmanager and plugin hooks. @@ -825,6 +829,7 @@ class Config: invocation. """ + @final @attr.s(frozen=True) class InvocationParams: """Holds parameters passed during :func:`pytest.main`. diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 6c6feff4206..636021df455 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -16,6 +16,7 @@ import py import _pytest._io +from _pytest.compat import final from _pytest.compat import TYPE_CHECKING from _pytest.config.exceptions import UsageError @@ -26,6 +27,7 @@ FILE_OR_DIR = "file_or_dir" +@final class Parser: """Parser for command line arguments and ini-file values. diff --git a/src/_pytest/config/exceptions.py b/src/_pytest/config/exceptions.py index 95c412734be..4f1320e758d 100644 --- a/src/_pytest/config/exceptions.py +++ b/src/_pytest/config/exceptions.py @@ -1,3 +1,7 @@ +from _pytest.compat import final + + +@final class UsageError(Exception): """Error in pytest usage or invocation.""" diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 44f05d28fc8..f526f484b29 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -32,6 +32,7 @@ from _pytest._io import TerminalWriter from _pytest.compat import _format_args from _pytest.compat import _PytestWrapper +from _pytest.compat import final from _pytest.compat import get_real_func from _pytest.compat import get_real_method from _pytest.compat import getfuncargnames @@ -730,6 +731,7 @@ def __repr__(self) -> str: return "" % (self.node) +@final class SubRequest(FixtureRequest): """A sub request for handling getting a fixture from a test function/fixture.""" @@ -796,6 +798,7 @@ def scope2index(scope: str, descr: str, where: Optional[str] = None) -> int: ) +@final class FixtureLookupError(LookupError): """Could not return a requested fixture (missing or invalid).""" @@ -952,6 +955,7 @@ def _eval_scope_callable( return result +@final class FixtureDef(Generic[_FixtureValue]): """A container for a factory definition.""" @@ -1161,6 +1165,7 @@ def result(*args, **kwargs): return result +@final @attr.s(frozen=True) class FixtureFunctionMarker: scope = attr.ib(type="Union[_Scope, Callable[[str, Config], _Scope]]") diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 98386bacda0..c277ba5320c 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -19,6 +19,7 @@ from _pytest import nodes from _pytest._io import TerminalWriter from _pytest.capture import CaptureManager +from _pytest.compat import final from _pytest.compat import nullcontext from _pytest.config import _strtobool from _pytest.config import Config @@ -339,6 +340,7 @@ def handleError(self, record: logging.LogRecord) -> None: raise +@final class LogCaptureFixture: """Provides access and control of log capturing.""" diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 4e35990adb3..ef106c46a43 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -21,6 +21,7 @@ import _pytest._code from _pytest import nodes +from _pytest.compat import final from _pytest.compat import overload from _pytest.compat import TYPE_CHECKING from _pytest.config import Config @@ -435,6 +436,7 @@ def __missing__(self, path: Path) -> str: return r +@final class Session(nodes.FSCollector): Interrupted = Interrupted Failed = Failed diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 73e1f77ce74..39a2321b3ff 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -20,6 +20,7 @@ from .._code import getfslineno from ..compat import ascii_escaped +from ..compat import final from ..compat import NOTSET from ..compat import NotSetType from ..compat import overload @@ -199,6 +200,7 @@ def _for_parametrize( return argnames, parameters +@final @attr.s(frozen=True) class Mark: #: Name of the mark. @@ -452,6 +454,7 @@ def __call__( # type: ignore[override] ... +@final class MarkGenerator: """Factory for :class:`MarkDecorator` objects - exposed as a ``pytest.mark`` singleton instance. @@ -525,6 +528,7 @@ def __getattr__(self, name: str) -> MarkDecorator: # TODO(py36): inherit from typing.MutableMapping[str, Any]. +@final class NodeKeywords(collections.abc.MutableMapping): # type: ignore[type-arg] def __init__(self, node: "Node") -> None: self.node = node diff --git a/src/_pytest/monkeypatch.py b/src/_pytest/monkeypatch.py index 1f324986b68..bbd96779da5 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -14,6 +14,7 @@ from typing import Union import pytest +from _pytest.compat import final from _pytest.compat import overload from _pytest.fixtures import fixture from _pytest.pathlib import Path @@ -110,6 +111,7 @@ def __repr__(self) -> str: notset = Notset() +@final class MonkeyPatch: """Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes.""" diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 5d8a45ad702..d78062a86ce 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -28,6 +28,7 @@ from _pytest import timing from _pytest._code import Source from _pytest.capture import _get_multicapture +from _pytest.compat import final from _pytest.compat import overload from _pytest.compat import TYPE_CHECKING from _pytest.config import _PluggyPlugin @@ -597,6 +598,7 @@ def restore(self) -> None: sys.path[:], sys.meta_path[:] = self.__saved +@final class Testdir: """Temporary test directory with tools to test/run pytest itself. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index ea584f3644a..7d3e301c076 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -37,6 +37,7 @@ from _pytest._io import TerminalWriter from _pytest._io.saferepr import saferepr from _pytest.compat import ascii_escaped +from _pytest.compat import final from _pytest.compat import get_default_arg_names from _pytest.compat import get_real_func from _pytest.compat import getimfunc @@ -864,6 +865,7 @@ def hasnew(obj: object) -> bool: return False +@final class CallSpec2: def __init__(self, metafunc: "Metafunc") -> None: self.metafunc = metafunc @@ -924,6 +926,7 @@ def setmulti2( self.marks.extend(normalize_mark_list(marks)) +@final class Metafunc: """Objects passed to the :func:`pytest_generate_tests <_pytest.hookspec.pytest_generate_tests>` hook. diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index a1eb29e1aba..f5ad04a12c9 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -17,6 +17,7 @@ from typing import Union import _pytest._code +from _pytest.compat import final from _pytest.compat import overload from _pytest.compat import STRING_TYPES from _pytest.compat import TYPE_CHECKING @@ -699,6 +700,7 @@ def raises( # noqa: F811 raises.Exception = fail.Exception # type: ignore +@final class RaisesContext(Generic[_E]): def __init__( self, diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 3668de627e6..39d6de91455 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -13,6 +13,7 @@ from typing import TypeVar from typing import Union +from _pytest.compat import final from _pytest.compat import overload from _pytest.compat import TYPE_CHECKING from _pytest.fixtures import fixture @@ -228,6 +229,7 @@ def __exit__( self._entered = False +@final class WarningsChecker(WarningsRecorder): def __init__( self, diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 48caa6ceebe..c42f778ec40 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -26,6 +26,7 @@ from _pytest._code.code import ReprTraceback from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter +from _pytest.compat import final from _pytest.compat import TYPE_CHECKING from _pytest.config import Config from _pytest.nodes import Collector @@ -225,6 +226,7 @@ def _report_unserialization_failure( raise RuntimeError(stream.getvalue()) +@final class TestReport(BaseReport): """Basic test report object (also used for setup and teardown calls if they fail).""" @@ -333,6 +335,7 @@ def from_item_and_call(cls, item: Item, call: "CallInfo[None]") -> "TestReport": ) +@final class CollectReport(BaseReport): """Collection report object.""" diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 2dc940b395e..f29d356fe07 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -22,6 +22,7 @@ from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionInfo from _pytest._code.code import TerminalRepr +from _pytest.compat import final from _pytest.compat import TYPE_CHECKING from _pytest.config.argparsing import Parser from _pytest.nodes import Collector @@ -259,6 +260,7 @@ def call_runtest_hook( TResult = TypeVar("TResult", covariant=True) +@final @attr.s(repr=False) class CallInfo(Generic[TResult]): """Result/Exception info a function invocation. diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 59d6aa97d03..e059612c212 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -32,6 +32,7 @@ from _pytest._code import ExceptionInfo from _pytest._code.code import ExceptionRepr from _pytest._io.wcwidth import wcswidth +from _pytest.compat import final from _pytest.compat import order_preserving_dict from _pytest.compat import TYPE_CHECKING from _pytest.config import _PluggyPlugin @@ -309,6 +310,7 @@ def get_location(self, config: Config) -> Optional[str]: return None +@final class TerminalReporter: def __init__(self, config: Config, file: Optional[TextIO] = None) -> None: import _pytest.config diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 7eb19b59e57..eb8aa9f9104 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -13,11 +13,13 @@ from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir_with_cleanup from .pathlib import Path +from _pytest.compat import final from _pytest.config import Config from _pytest.fixtures import FixtureRequest from _pytest.monkeypatch import MonkeyPatch +@final @attr.s class TempPathFactory: """Factory for temporary directories under the common base temp directory. @@ -103,6 +105,7 @@ def getbasetemp(self) -> Path: return t +@final @attr.s class TempdirFactory: """Backward comptibility wrapper that implements :class:``py.path.local`` diff --git a/src/_pytest/warning_types.py b/src/_pytest/warning_types.py index c93b9604907..52e4d2b14cb 100644 --- a/src/_pytest/warning_types.py +++ b/src/_pytest/warning_types.py @@ -4,6 +4,7 @@ import attr +from _pytest.compat import final from _pytest.compat import TYPE_CHECKING if TYPE_CHECKING: @@ -16,36 +17,42 @@ class PytestWarning(UserWarning): __module__ = "pytest" +@final class PytestAssertRewriteWarning(PytestWarning): """Warning emitted by the pytest assert rewrite module.""" __module__ = "pytest" +@final class PytestCacheWarning(PytestWarning): """Warning emitted by the cache plugin in various situations.""" __module__ = "pytest" +@final class PytestConfigWarning(PytestWarning): """Warning emitted for configuration issues.""" __module__ = "pytest" +@final class PytestCollectionWarning(PytestWarning): """Warning emitted when pytest is not able to collect a file or symbol in a module.""" __module__ = "pytest" +@final class PytestDeprecationWarning(PytestWarning, DeprecationWarning): """Warning class for features that will be removed in a future version.""" __module__ = "pytest" +@final class PytestExperimentalApiWarning(PytestWarning, FutureWarning): """Warning category used to denote experiments in pytest. @@ -64,6 +71,7 @@ def simple(cls, apiname: str) -> "PytestExperimentalApiWarning": ) +@final class PytestUnhandledCoroutineWarning(PytestWarning): """Warning emitted for an unhandled coroutine. @@ -75,6 +83,7 @@ class PytestUnhandledCoroutineWarning(PytestWarning): __module__ = "pytest" +@final class PytestUnknownMarkWarning(PytestWarning): """Warning emitted on use of unknown markers. @@ -87,6 +96,7 @@ class PytestUnknownMarkWarning(PytestWarning): _W = TypeVar("_W", bound=PytestWarning) +@final @attr.s class UnformattedWarning(Generic[_W]): """A warning meant to be formatted during runtime.