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 000000000..6651387b1 --- /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 98aea8c11..5063e6604 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -38,6 +38,7 @@ from _pytest._io import TerminalWriter 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 @@ co_equal = compile( _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 0afe4a0ed..a9404ebcc 100644 --- a/src/_pytest/_io/terminalwriter.py +++ b/src/_pytest/_io/terminalwriter.py @@ -7,6 +7,7 @@ from typing import Sequence 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 ba27735d0..b04305ed9 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -21,6 +21,7 @@ from .pathlib import rm_rf 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 @@ Signature: 8a477f597d28d172789f06886806bc55 """ +@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 3bf3bc923..2d2b392ab 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -17,6 +17,7 @@ from typing import Tuple 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 @@ class FDCapture(FDCaptureBinary): # 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 0c9f47de7..7eab2ea0c 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -19,7 +19,6 @@ from typing import Union 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 @@ if sys.version_info < (3, 5, 2): 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 088ec765e..f89ed3702 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -43,6 +43,7 @@ from .findpaths import determine_setup 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 @@ hookimpl = HookimplMarker("pytest") 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 6c6feff42..636021df4 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -16,6 +16,7 @@ from typing import Union 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 @@ if TYPE_CHECKING: 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 95c412734..4f1320e75 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 44f05d28f..f526f484b 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -32,6 +32,7 @@ from _pytest._code.code import TerminalRepr 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 @@ class FixtureRequest: 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 wrap_function_to_error_out_if_called_directly(function, fixture_marker): 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 98386bacd..c277ba532 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -19,6 +19,7 @@ import pytest 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 @@ class LogCaptureHandler(logging.StreamHandler): raise +@final class LogCaptureFixture: """Provides access and control of log capturing.""" diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 4e35990ad..ef106c46a 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -21,6 +21,7 @@ import py 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 @@ class _bestrelpath_cache(Dict[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 73e1f77ce..39a2321b3 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -20,6 +20,7 @@ import attr 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 @@ class ParameterSet( return argnames, parameters +@final @attr.s(frozen=True) class Mark: #: Name of the mark. @@ -452,6 +454,7 @@ if TYPE_CHECKING: ... +@final class MarkGenerator: """Factory for :class:`MarkDecorator` objects - exposed as a ``pytest.mark`` singleton instance. @@ -525,6 +528,7 @@ MARK_GEN = MarkGenerator() # 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 1f324986b..bbd96779d 100644 --- a/src/_pytest/monkeypatch.py +++ b/src/_pytest/monkeypatch.py @@ -14,6 +14,7 @@ from typing import TypeVar 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 @@ class Notset: 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 5d8a45ad7..d78062a86 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -28,6 +28,7 @@ import pytest 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 @@ class SysPathsSnapshot: 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 ea584f364..7d3e301c0 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -37,6 +37,7 @@ from _pytest._code.code import TerminalRepr 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 @@ class CallSpec2: 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 a1eb29e1a..f5ad04a12 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -17,6 +17,7 @@ from typing import TypeVar 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 3668de627..39d6de914 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -13,6 +13,7 @@ from typing import Tuple 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 @@ class WarningsRecorder(warnings.catch_warnings): self._entered = False +@final class WarningsChecker(WarningsRecorder): def __init__( self, diff --git a/src/_pytest/reports.py b/src/_pytest/reports.py index 48caa6cee..c42f778ec 100644 --- a/src/_pytest/reports.py +++ b/src/_pytest/reports.py @@ -26,6 +26,7 @@ from _pytest._code.code import ReprLocals 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 @@ class TestReport(BaseReport): ) +@final class CollectReport(BaseReport): """Collection report object.""" diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index 2dc940b39..f29d356fe 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -22,6 +22,7 @@ from _pytest import timing 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 59d6aa97d..e059612c2 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -32,6 +32,7 @@ from _pytest import timing 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 @@ class WarningReport: 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 7eb19b59e..eb8aa9f91 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -13,11 +13,13 @@ from .pathlib import LOCK_TIMEOUT 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 @@ class TempPathFactory: 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 c93b96049..52e4d2b14 100644 --- a/src/_pytest/warning_types.py +++ b/src/_pytest/warning_types.py @@ -4,6 +4,7 @@ from typing import TypeVar 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 @@ class PytestExperimentalApiWarning(PytestWarning, FutureWarning): ) +@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.