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.