From f1e6fdcddbfe8991935685ccc5049dd957ec4382 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sun, 27 Sep 2020 22:20:31 +0300 Subject: [PATCH] Export types of builtin fixture for type annotations In order to allow users to type annotate fixtures they request, the types need to be imported from the `pytest` namespace. They are/were always available to import from the `_pytest` namespace, but that is not guaranteed to be stable. These types are only exported for the purpose of typing. Specifically, the following are *not* public: - Construction (`__init__`) - Subclassing - staticmethods and classmethods We try to combat them being used anyway by: - Marking the classes as `@final` when possible (already done). - Not documenting private stuff in the API Reference. - Using `_`-prefixed names or marking as `:meta private:` for private stuff. - Adding a keyword-only `_ispytest=False` to private constructors, warning if False, and changing pytest itself to pass True. In the future it will (hopefully) become a hard error. Hopefully that will be enough. --- changelog/7469.deprecation.rst | 18 ++++++++ changelog/7469.improvement.rst | 23 ++++++++++ doc/en/reference.rst | 77 +++++++++++++--------------------- src/_pytest/cacheprovider.py | 55 +++++++++++++++++------- src/_pytest/capture.py | 18 ++++---- src/_pytest/deprecated.py | 26 ++++++++++++ src/_pytest/doctest.py | 2 +- src/_pytest/fixtures.py | 15 +++++-- src/_pytest/logging.py | 6 ++- src/_pytest/pytester.py | 18 +++++--- src/_pytest/python.py | 2 +- src/_pytest/recwarn.py | 15 ++++--- src/_pytest/tmpdir.py | 61 +++++++++++++++++++-------- src/pytest/__init__.py | 18 ++++++++ testing/deprecated_test.py | 14 +++++++ testing/python/fixtures.py | 22 +++++----- testing/test_cacheprovider.py | 6 +-- testing/test_recwarn.py | 16 +++---- testing/test_tmpdir.py | 6 ++- 19 files changed, 292 insertions(+), 126 deletions(-) create mode 100644 changelog/7469.deprecation.rst create mode 100644 changelog/7469.improvement.rst diff --git a/changelog/7469.deprecation.rst b/changelog/7469.deprecation.rst new file mode 100644 index 000000000..67d0b2bba --- /dev/null +++ b/changelog/7469.deprecation.rst @@ -0,0 +1,18 @@ +Directly constructing/calling the following classes/functions is now deprecated: + +- ``_pytest.cacheprovider.Cache`` +- ``_pytest.cacheprovider.Cache.for_config()`` +- ``_pytest.cacheprovider.Cache.clear_cache()`` +- ``_pytest.cacheprovider.Cache.cache_dir_from_config()`` +- ``_pytest.capture.CaptureFixture`` +- ``_pytest.fixtures.FixtureRequest`` +- ``_pytest.fixtures.SubRequest`` +- ``_pytest.logging.LogCaptureFixture`` +- ``_pytest.pytester.Pytester`` +- ``_pytest.pytester.Testdir`` +- ``_pytest.recwarn.WarningsRecorder`` +- ``_pytest.recwarn.WarningsChecker`` +- ``_pytest.tmpdir.TempPathFactory`` +- ``_pytest.tmpdir.TempdirFactory`` + +These have always been considered private, but now issue a deprecation warning, which may become a hard error in pytest 7.0.0. diff --git a/changelog/7469.improvement.rst b/changelog/7469.improvement.rst new file mode 100644 index 000000000..cbd75f054 --- /dev/null +++ b/changelog/7469.improvement.rst @@ -0,0 +1,23 @@ +It is now possible to construct a :class:`MonkeyPatch` object directly as ``pytest.MonkeyPatch()``, +in cases when the :fixture:`monkeypatch` fixture cannot be used. Previously some users imported it +from the private `_pytest.monkeypatch.MonkeyPatch` namespace. + +The types of builtin pytest fixtures are now exported so they may be used in type annotations of test functions. +The newly-exported types are: + +- ``pytest.FixtureRequest`` for the :fixture:`request` fixture. +- ``pytest.Cache`` for the :fixture:`cache` fixture. +- ``pytest.CaptureFixture[str]`` for the :fixture:`capfd` and :fixture:`capsys` fixtures. +- ``pytest.CaptureFixture[bytes]`` for the :fixture:`capfdbinary` and :fixture:`capsysbinary` fixtures. +- ``pytest.LogCaptureFixture`` for the :fixture:`caplog` fixture. +- ``pytest.Pytester`` for the :fixture:`pytester` fixture. +- ``pytest.Testdir`` for the :fixture:`testdir` fixture. +- ``pytest.TempdirFactory`` for the :fixture:`tmpdir_factory` fixture. +- ``pytest.TempPathFactory`` for the :fixture:`tmp_path_factory` fixture. +- ``pytest.MonkeyPatch`` for the :fixture:`monkeypatch` fixture. +- ``pytest.WarningsRecorder`` for the :fixture:`recwarn` fixture. + +Constructing them is not supported (except for `MonkeyPatch`); 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. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index cbe89fe0b..6973043cc 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -304,11 +304,10 @@ request ``pytestconfig`` into your fixture and get it with ``pytestconfig.cache` Under the hood, the cache plugin uses the simple ``dumps``/``loads`` API of the :py:mod:`json` stdlib module. -.. currentmodule:: _pytest.cacheprovider +``config.cache`` is an instance of :class:`pytest.Cache`: -.. automethod:: Cache.get -.. automethod:: Cache.set -.. automethod:: Cache.makedir +.. autoclass:: pytest.Cache() + :members: .. fixture:: capsys @@ -318,12 +317,10 @@ capsys **Tutorial**: :doc:`capture`. -.. currentmodule:: _pytest.capture - -.. autofunction:: capsys() +.. autofunction:: _pytest.capture.capsys() :no-auto-options: - Returns an instance of :py:class:`CaptureFixture`. + Returns an instance of :class:`CaptureFixture[str] `. Example: @@ -334,7 +331,7 @@ capsys captured = capsys.readouterr() assert captured.out == "hello\n" -.. autoclass:: CaptureFixture() +.. autoclass:: pytest.CaptureFixture() :members: @@ -345,10 +342,10 @@ capsysbinary **Tutorial**: :doc:`capture`. -.. autofunction:: capsysbinary() +.. autofunction:: _pytest.capture.capsysbinary() :no-auto-options: - Returns an instance of :py:class:`CaptureFixture`. + Returns an instance of :class:`CaptureFixture[bytes] `. Example: @@ -367,10 +364,10 @@ capfd **Tutorial**: :doc:`capture`. -.. autofunction:: capfd() +.. autofunction:: _pytest.capture.capfd() :no-auto-options: - Returns an instance of :py:class:`CaptureFixture`. + Returns an instance of :class:`CaptureFixture[str] `. Example: @@ -389,10 +386,10 @@ capfdbinary **Tutorial**: :doc:`capture`. -.. autofunction:: capfdbinary() +.. autofunction:: _pytest.capture.capfdbinary() :no-auto-options: - Returns an instance of :py:class:`CaptureFixture`. + Returns an instance of :class:`CaptureFixture[bytes] `. Example: @@ -433,7 +430,7 @@ request The ``request`` fixture is a special fixture providing information of the requesting test function. -.. autoclass:: _pytest.fixtures.FixtureRequest() +.. autoclass:: pytest.FixtureRequest() :members: @@ -475,9 +472,9 @@ caplog .. autofunction:: _pytest.logging.caplog() :no-auto-options: - Returns a :class:`_pytest.logging.LogCaptureFixture` instance. + Returns a :class:`pytest.LogCaptureFixture` instance. -.. autoclass:: _pytest.logging.LogCaptureFixture +.. autoclass:: pytest.LogCaptureFixture() :members: @@ -504,9 +501,7 @@ pytester .. versionadded:: 6.2 -.. currentmodule:: _pytest.pytester - -Provides a :class:`Pytester` instance that can be used to run and test pytest itself. +Provides a :class:`~pytest.Pytester` instance that can be used to run and test pytest itself. It provides an empty directory where pytest can be executed in isolation, and contains facilities to write tests, configuration files, and match against expected output. @@ -519,16 +514,16 @@ To use it, include in your topmost ``conftest.py`` file: -.. autoclass:: Pytester() +.. autoclass:: pytest.Pytester() :members: -.. autoclass:: RunResult() +.. autoclass:: _pytest.pytester.RunResult() :members: -.. autoclass:: LineMatcher() +.. autoclass:: _pytest.pytester.LineMatcher() :members: -.. autoclass:: HookRecorder() +.. autoclass:: _pytest.pytester.HookRecorder() :members: .. fixture:: testdir @@ -541,7 +536,7 @@ legacy ``py.path.local`` objects instead when applicable. New code should avoid using :fixture:`testdir` in favor of :fixture:`pytester`. -.. autoclass:: Testdir() +.. autoclass:: pytest.Testdir() :members: @@ -552,12 +547,10 @@ recwarn **Tutorial**: :ref:`assertwarnings` -.. currentmodule:: _pytest.recwarn - -.. autofunction:: recwarn() +.. autofunction:: _pytest.recwarn.recwarn() :no-auto-options: -.. autoclass:: WarningsRecorder() +.. autoclass:: pytest.WarningsRecorder() :members: Each recorded warning is an instance of :class:`warnings.WarningMessage`. @@ -574,13 +567,11 @@ tmp_path **Tutorial**: :doc:`tmpdir` -.. currentmodule:: _pytest.tmpdir - -.. autofunction:: tmp_path() +.. autofunction:: _pytest.tmpdir.tmp_path() :no-auto-options: -.. fixture:: tmp_path_factory +.. fixture:: _pytest.tmpdir.tmp_path_factory tmp_path_factory ~~~~~~~~~~~~~~~~ @@ -589,12 +580,9 @@ tmp_path_factory .. _`tmp_path_factory factory api`: -``tmp_path_factory`` instances have the following methods: +``tmp_path_factory`` is an instance of :class:`~pytest.TempPathFactory`: -.. currentmodule:: _pytest.tmpdir - -.. automethod:: TempPathFactory.mktemp -.. automethod:: TempPathFactory.getbasetemp +.. autoclass:: pytest.TempPathFactory() .. fixture:: tmpdir @@ -604,9 +592,7 @@ tmpdir **Tutorial**: :doc:`tmpdir` -.. currentmodule:: _pytest.tmpdir - -.. autofunction:: tmpdir() +.. autofunction:: _pytest.tmpdir.tmpdir() :no-auto-options: @@ -619,12 +605,9 @@ tmpdir_factory .. _`tmpdir factory api`: -``tmpdir_factory`` instances have the following methods: +``tmp_path_factory`` is an instance of :class:`~pytest.TempdirFactory`: -.. currentmodule:: _pytest.tmpdir - -.. automethod:: TempdirFactory.mktemp -.. automethod:: TempdirFactory.getbasetemp +.. autoclass:: pytest.TempdirFactory() .. _`hook-reference`: diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 1689b9a41..03acd0310 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -25,6 +25,7 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config.argparsing import Parser +from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session @@ -53,7 +54,7 @@ Signature: 8a477f597d28d172789f06886806bc55 @final -@attr.s +@attr.s(init=False) class Cache: _cachedir = attr.ib(type=Path, repr=False) _config = attr.ib(type=Config, repr=False) @@ -64,26 +65,52 @@ class Cache: # sub-directory under cache-dir for values created by "set" _CACHE_PREFIX_VALUES = "v" - @classmethod - def for_config(cls, config: Config) -> "Cache": - cachedir = cls.cache_dir_from_config(config) - if config.getoption("cacheclear") and cachedir.is_dir(): - cls.clear_cache(cachedir) - return cls(cachedir, config) + def __init__( + self, cachedir: Path, config: Config, *, _ispytest: bool = False + ) -> None: + check_ispytest(_ispytest) + self._cachedir = cachedir + self._config = config @classmethod - def clear_cache(cls, cachedir: Path) -> None: - """Clear the sub-directories used to hold cached directories and values.""" + def for_config(cls, config: Config, *, _ispytest: bool = False) -> "Cache": + """Create the Cache instance for a Config. + + :meta private: + """ + check_ispytest(_ispytest) + cachedir = cls.cache_dir_from_config(config, _ispytest=True) + if config.getoption("cacheclear") and cachedir.is_dir(): + cls.clear_cache(cachedir, _ispytest=True) + return cls(cachedir, config, _ispytest=True) + + @classmethod + def clear_cache(cls, cachedir: Path, _ispytest: bool = False) -> None: + """Clear the sub-directories used to hold cached directories and values. + + :meta private: + """ + check_ispytest(_ispytest) for prefix in (cls._CACHE_PREFIX_DIRS, cls._CACHE_PREFIX_VALUES): d = cachedir / prefix if d.is_dir(): rm_rf(d) @staticmethod - def cache_dir_from_config(config: Config) -> Path: + def cache_dir_from_config(config: Config, *, _ispytest: bool = False) -> Path: + """Get the path to the cache directory for a Config. + + :meta private: + """ + check_ispytest(_ispytest) return resolve_from_str(config.getini("cache_dir"), config.rootpath) - def warn(self, fmt: str, **args: object) -> None: + def warn(self, fmt: str, *, _ispytest: bool = False, **args: object) -> None: + """Issue a cache warning. + + :meta private: + """ + check_ispytest(_ispytest) import warnings from _pytest.warning_types import PytestCacheWarning @@ -152,7 +179,7 @@ class Cache: cache_dir_exists_already = self._cachedir.exists() path.parent.mkdir(exist_ok=True, parents=True) except OSError: - self.warn("could not create cache path {path}", path=path) + self.warn("could not create cache path {path}", path=path, _ispytest=True) return if not cache_dir_exists_already: self._ensure_supporting_files() @@ -160,7 +187,7 @@ class Cache: try: f = path.open("w") except OSError: - self.warn("cache could not write path {path}", path=path) + self.warn("cache could not write path {path}", path=path, _ispytest=True) else: with f: f.write(data) @@ -469,7 +496,7 @@ def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]: @hookimpl(tryfirst=True) def pytest_configure(config: Config) -> None: - config.cache = Cache.for_config(config) + config.cache = Cache.for_config(config, _ispytest=True) config.pluginmanager.register(LFPlugin(config), "lfplugin") config.pluginmanager.register(NFPlugin(config), "nfplugin") diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 1c3a2b819..086302658 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -21,6 +21,7 @@ from _pytest.compat import final from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser +from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.fixtures import SubRequest from _pytest.nodes import Collector @@ -826,10 +827,13 @@ class CaptureManager: class CaptureFixture(Generic[AnyStr]): - """Object returned by the :py:func:`capsys`, :py:func:`capsysbinary`, - :py:func:`capfd` and :py:func:`capfdbinary` fixtures.""" + """Object returned by the :fixture:`capsys`, :fixture:`capsysbinary`, + :fixture:`capfd` and :fixture:`capfdbinary` fixtures.""" - def __init__(self, captureclass, request: SubRequest) -> None: + def __init__( + self, captureclass, request: SubRequest, *, _ispytest: bool = False + ) -> None: + check_ispytest(_ispytest) self.captureclass = captureclass self.request = request self._capture: Optional[MultiCapture[AnyStr]] = None @@ -904,7 +908,7 @@ def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]: ``out`` and ``err`` will be ``text`` objects. """ capman = request.config.pluginmanager.getplugin("capturemanager") - capture_fixture = CaptureFixture[str](SysCapture, request) + capture_fixture = CaptureFixture[str](SysCapture, request, _ispytest=True) capman.set_fixture(capture_fixture) capture_fixture._start() yield capture_fixture @@ -921,7 +925,7 @@ def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, ``out`` and ``err`` will be ``bytes`` objects. """ capman = request.config.pluginmanager.getplugin("capturemanager") - capture_fixture = CaptureFixture[bytes](SysCaptureBinary, request) + capture_fixture = CaptureFixture[bytes](SysCaptureBinary, request, _ispytest=True) capman.set_fixture(capture_fixture) capture_fixture._start() yield capture_fixture @@ -938,7 +942,7 @@ def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]: ``out`` and ``err`` will be ``text`` objects. """ capman = request.config.pluginmanager.getplugin("capturemanager") - capture_fixture = CaptureFixture[str](FDCapture, request) + capture_fixture = CaptureFixture[str](FDCapture, request, _ispytest=True) capman.set_fixture(capture_fixture) capture_fixture._start() yield capture_fixture @@ -955,7 +959,7 @@ def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, N ``out`` and ``err`` will be ``byte`` objects. """ capman = request.config.pluginmanager.getplugin("capturemanager") - capture_fixture = CaptureFixture[bytes](FDCaptureBinary, request) + capture_fixture = CaptureFixture[bytes](FDCaptureBinary, request, _ispytest=True) capman.set_fixture(capture_fixture) capture_fixture._start() yield capture_fixture diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 2e9154e83..19b31d665 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -8,6 +8,8 @@ All constants defined in this module should be either instances of :class:`PytestWarning`, or :class:`UnformattedWarning` in case of warnings which need to format their messages. """ +from warnings import warn + from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import UnformattedWarning @@ -59,3 +61,27 @@ FSCOLLECTOR_GETHOOKPROXY_ISINITPATH = PytestDeprecationWarning( STRICT_OPTION = PytestDeprecationWarning( "The --strict option is deprecated, use --strict-markers instead." ) + +PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.") + + +# You want to make some `__init__` or function "private". +# +# def my_private_function(some, args): +# ... +# +# Do this: +# +# def my_private_function(some, args, *, _ispytest: bool = False): +# check_ispytest(_ispytest) +# ... +# +# Change all internal/allowed calls to +# +# my_private_function(some, args, _ispytest=True) +# +# All other calls will get the default _ispytest=False and trigger +# the warning (possibly error in the future). +def check_ispytest(ispytest: bool) -> None: + if not ispytest: + warn(PRIVATE, stacklevel=3) diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index fd9434a92..64e8f0e0e 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -563,7 +563,7 @@ def _setup_fixtures(doctest_item: DoctestItem) -> FixtureRequest: doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined] node=doctest_item, func=func, cls=None, funcargs=False ) - fixture_request = FixtureRequest(doctest_item) + fixture_request = FixtureRequest(doctest_item, _ispytest=True) fixture_request._fillfixtures() return fixture_request diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index cef998c03..273bcafd3 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -49,6 +49,7 @@ from _pytest.compat import safe_getattr from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config.argparsing import Parser +from _pytest.deprecated import check_ispytest from _pytest.deprecated import FILLFUNCARGS from _pytest.deprecated import YIELD_FIXTURE from _pytest.mark import Mark @@ -367,7 +368,7 @@ def _fill_fixtures_impl(function: "Function") -> None: assert function.parent is not None fi = fm.getfixtureinfo(function.parent, function.obj, None) function._fixtureinfo = fi - request = function._request = FixtureRequest(function) + request = function._request = FixtureRequest(function, _ispytest=True) request._fillfixtures() # Prune out funcargs for jstests. newfuncargs = {} @@ -429,7 +430,8 @@ class FixtureRequest: indirectly. """ - def __init__(self, pyfuncitem) -> None: + def __init__(self, pyfuncitem, *, _ispytest: bool = False) -> None: + check_ispytest(_ispytest) self._pyfuncitem = pyfuncitem #: Fixture for which this request is being performed. self.fixturename: Optional[str] = None @@ -674,7 +676,9 @@ class FixtureRequest: if paramscopenum is not None: scope = scopes[paramscopenum] - subrequest = SubRequest(self, scope, param, param_index, fixturedef) + subrequest = SubRequest( + self, scope, param, param_index, fixturedef, _ispytest=True + ) # Check if a higher-level scoped fixture accesses a lower level one. subrequest._check_scope(argname, self.scope, scope) @@ -751,7 +755,10 @@ class SubRequest(FixtureRequest): param, param_index: int, fixturedef: "FixtureDef[object]", + *, + _ispytest: bool = False, ) -> None: + check_ispytest(_ispytest) self._parent_request = request self.fixturename = fixturedef.argname if param is not NOTSET: @@ -769,6 +776,8 @@ class SubRequest(FixtureRequest): return f"" def addfinalizer(self, finalizer: Callable[[], object]) -> None: + """Add finalizer/teardown function to be called after the last test + within the requesting test context finished execution.""" self._fixturedef.addfinalizer(finalizer) def _schedule_finalizers( diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 2f5da8e7a..2e4847328 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -27,6 +27,7 @@ from _pytest.config import create_terminal_writer from _pytest.config import hookimpl from _pytest.config import UsageError from _pytest.config.argparsing import Parser +from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session @@ -346,7 +347,8 @@ class LogCaptureHandler(logging.StreamHandler): class LogCaptureFixture: """Provides access and control of log capturing.""" - def __init__(self, item: nodes.Node) -> None: + def __init__(self, item: nodes.Node, *, _ispytest: bool = False) -> None: + check_ispytest(_ispytest) self._item = item self._initial_handler_level: Optional[int] = None # Dict of log name -> log level. @@ -482,7 +484,7 @@ def caplog(request: FixtureRequest) -> Generator[LogCaptureFixture, None, None]: * caplog.record_tuples -> list of (logger_name, level, message) tuples * caplog.clear() -> clear captured records and formatted log output string """ - result = LogCaptureFixture(request.node) + result = LogCaptureFixture(request.node, _ispytest=True) yield result result._finalize() diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 158b71b3a..20ea71edc 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -48,6 +48,7 @@ from _pytest.config import hookimpl from _pytest.config import main from _pytest.config import PytestPluginManager from _pytest.config.argparsing import Parser +from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.main import Session @@ -454,7 +455,7 @@ def pytester(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> "Pyt It is particularly useful for testing plugins. It is similar to the :fixture:`tmp_path` fixture but provides methods which aid in testing pytest itself. """ - return Pytester(request, tmp_path_factory) + return Pytester(request, tmp_path_factory, _ispytest=True) @fixture @@ -465,7 +466,7 @@ def testdir(pytester: "Pytester") -> "Testdir": New code should avoid using :fixture:`testdir` in favor of :fixture:`pytester`. """ - return Testdir(pytester) + return Testdir(pytester, _ispytest=True) @fixture @@ -648,8 +649,13 @@ class Pytester: pass def __init__( - self, request: FixtureRequest, tmp_path_factory: TempPathFactory + self, + request: FixtureRequest, + tmp_path_factory: TempPathFactory, + *, + _ispytest: bool = False, ) -> None: + check_ispytest(_ispytest) self._request = request self._mod_collections: WeakKeyDictionary[ Collector, List[Union[Item, Collector]] @@ -1480,7 +1486,7 @@ class LineComp: @final -@attr.s(repr=False, str=False) +@attr.s(repr=False, str=False, init=False) class Testdir: """ Similar to :class:`Pytester`, but this class works with legacy py.path.local objects instead. @@ -1495,7 +1501,9 @@ class Testdir: TimeoutExpired = Pytester.TimeoutExpired Session = Pytester.Session - _pytester: Pytester = attr.ib() + def __init__(self, pytester: Pytester, *, _ispytest: bool = False) -> None: + check_ispytest(_ispytest) + self._pytester = pytester @property def tmpdir(self) -> py.path.local: diff --git a/src/_pytest/python.py b/src/_pytest/python.py index a3eaa5823..e48e7531c 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1620,7 +1620,7 @@ class Function(PyobjMixin, nodes.Item): def _initrequest(self) -> None: self.funcargs: Dict[str, object] = {} - self._request = fixtures.FixtureRequest(self) + self._request = fixtures.FixtureRequest(self, _ispytest=True) @property def function(self): diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 49f1e5902..58b449114 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -16,6 +16,7 @@ from typing import TypeVar from typing import Union from _pytest.compat import final +from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.outcomes import fail @@ -30,7 +31,7 @@ def recwarn() -> Generator["WarningsRecorder", None, None]: See http://docs.python.org/library/warnings.html for information on warning categories. """ - wrec = WarningsRecorder() + wrec = WarningsRecorder(_ispytest=True) with wrec: warnings.simplefilter("default") yield wrec @@ -142,14 +143,14 @@ def warns( msg += ", ".join(sorted(kwargs)) msg += "\nUse context-manager form instead?" raise TypeError(msg) - return WarningsChecker(expected_warning, match_expr=match) + return WarningsChecker(expected_warning, match_expr=match, _ispytest=True) else: func = args[0] if not callable(func): raise TypeError( "{!r} object (type: {}) must be callable".format(func, type(func)) ) - with WarningsChecker(expected_warning): + with WarningsChecker(expected_warning, _ispytest=True): return func(*args[1:], **kwargs) @@ -159,7 +160,8 @@ class WarningsRecorder(warnings.catch_warnings): Adapted from `warnings.catch_warnings`. """ - def __init__(self) -> None: + def __init__(self, *, _ispytest: bool = False) -> None: + check_ispytest(_ispytest) # Type ignored due to the way typeshed handles warnings.catch_warnings. super().__init__(record=True) # type: ignore[call-arg] self._entered = False @@ -232,8 +234,11 @@ class WarningsChecker(WarningsRecorder): Union[Type[Warning], Tuple[Type[Warning], ...]] ] = None, match_expr: Optional[Union[str, Pattern[str]]] = None, + *, + _ispytest: bool = False, ) -> None: - super().__init__() + check_ispytest(_ispytest) + super().__init__(_ispytest=True) msg = "exceptions must be derived from Warning, not %s" if expected_warning is None: diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 4ca1dd6e1..e62d08db5 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -14,37 +14,56 @@ from .pathlib import make_numbered_dir from .pathlib import make_numbered_dir_with_cleanup from _pytest.compat import final from _pytest.config import Config +from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.fixtures import FixtureRequest from _pytest.monkeypatch import MonkeyPatch @final -@attr.s +@attr.s(init=False) class TempPathFactory: """Factory for temporary directories under the common base temp directory. The base directory can be configured using the ``--basetemp`` option. """ - _given_basetemp = attr.ib( - type=Optional[Path], - # Use os.path.abspath() to get absolute path instead of resolve() as it - # does not work the same in all platforms (see #4427). - # Path.absolute() exists, but it is not public (see https://bugs.python.org/issue25012). - # Ignore type because of https://github.com/python/mypy/issues/6172. - converter=attr.converters.optional( - lambda p: Path(os.path.abspath(str(p))) # type: ignore - ), - ) + _given_basetemp = attr.ib(type=Optional[Path]) _trace = attr.ib() - _basetemp = attr.ib(type=Optional[Path], default=None) + _basetemp = attr.ib(type=Optional[Path]) + + def __init__( + self, + given_basetemp: Optional[Path], + trace, + basetemp: Optional[Path] = None, + *, + _ispytest: bool = False, + ) -> None: + check_ispytest(_ispytest) + if given_basetemp is None: + self._given_basetemp = None + else: + # Use os.path.abspath() to get absolute path instead of resolve() as it + # does not work the same in all platforms (see #4427). + # Path.absolute() exists, but it is not public (see https://bugs.python.org/issue25012). + self._given_basetemp = Path(os.path.abspath(str(given_basetemp))) + self._trace = trace + self._basetemp = basetemp @classmethod - def from_config(cls, config: Config) -> "TempPathFactory": - """Create a factory according to pytest configuration.""" + def from_config( + cls, config: Config, *, _ispytest: bool = False, + ) -> "TempPathFactory": + """Create a factory according to pytest configuration. + + :meta private: + """ + check_ispytest(_ispytest) return cls( - given_basetemp=config.option.basetemp, trace=config.trace.get("tmpdir") + given_basetemp=config.option.basetemp, + trace=config.trace.get("tmpdir"), + _ispytest=True, ) def _ensure_relative_to_basetemp(self, basename: str) -> str: @@ -104,13 +123,19 @@ class TempPathFactory: @final -@attr.s +@attr.s(init=False) class TempdirFactory: """Backward comptibility wrapper that implements :class:``py.path.local`` for :class:``TempPathFactory``.""" _tmppath_factory = attr.ib(type=TempPathFactory) + def __init__( + self, tmppath_factory: TempPathFactory, *, _ispytest: bool = False + ) -> None: + check_ispytest(_ispytest) + self._tmppath_factory = tmppath_factory + def mktemp(self, basename: str, numbered: bool = True) -> py.path.local: """Same as :meth:`TempPathFactory.mktemp`, but returns a ``py.path.local`` object.""" return py.path.local(self._tmppath_factory.mktemp(basename, numbered).resolve()) @@ -139,8 +164,8 @@ def pytest_configure(config: Config) -> None: to the tmpdir_factory session fixture. """ mp = MonkeyPatch() - tmppath_handler = TempPathFactory.from_config(config) - t = TempdirFactory(tmppath_handler) + tmppath_handler = TempPathFactory.from_config(config, _ispytest=True) + t = TempdirFactory(tmppath_handler, _ispytest=True) config._cleanup.append(mp.undo) mp.setattr(config, "_tmp_path_factory", tmppath_handler, raising=False) mp.setattr(config, "_tmpdirhandler", t, raising=False) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index d7a5b2299..8af095ea8 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -3,6 +3,8 @@ from . import collect from _pytest import __version__ from _pytest.assertion import register_assert_rewrite +from _pytest.cacheprovider import Cache +from _pytest.capture import CaptureFixture from _pytest.config import cmdline from _pytest.config import console_main from _pytest.config import ExitCode @@ -14,8 +16,10 @@ from _pytest.debugging import pytestPDB as __pytestPDB from _pytest.fixtures import _fillfuncargs from _pytest.fixtures import fixture from _pytest.fixtures import FixtureLookupError +from _pytest.fixtures import FixtureRequest from _pytest.fixtures import yield_fixture from _pytest.freeze_support import freeze_includes +from _pytest.logging import LogCaptureFixture from _pytest.main import Session from _pytest.mark import MARK_GEN as mark from _pytest.mark import param @@ -28,6 +32,8 @@ from _pytest.outcomes import fail from _pytest.outcomes import importorskip from _pytest.outcomes import skip from _pytest.outcomes import xfail +from _pytest.pytester import Pytester +from _pytest.pytester import Testdir from _pytest.python import Class from _pytest.python import Function from _pytest.python import Instance @@ -36,7 +42,10 @@ from _pytest.python import Package from _pytest.python_api import approx from _pytest.python_api import raises from _pytest.recwarn import deprecated_call +from _pytest.recwarn import WarningsRecorder from _pytest.recwarn import warns +from _pytest.tmpdir import TempdirFactory +from _pytest.tmpdir import TempPathFactory from _pytest.warning_types import PytestAssertRewriteWarning from _pytest.warning_types import PytestCacheWarning from _pytest.warning_types import PytestCollectionWarning @@ -53,6 +62,8 @@ __all__ = [ "__version__", "_fillfuncargs", "approx", + "Cache", + "CaptureFixture", "Class", "cmdline", "collect", @@ -65,6 +76,7 @@ __all__ = [ "File", "fixture", "FixtureLookupError", + "FixtureRequest", "freeze_includes", "Function", "hookimpl", @@ -72,6 +84,7 @@ __all__ = [ "importorskip", "Instance", "Item", + "LogCaptureFixture", "main", "mark", "Module", @@ -84,6 +97,7 @@ __all__ = [ "PytestConfigWarning", "PytestDeprecationWarning", "PytestExperimentalApiWarning", + "Pytester", "PytestUnhandledCoroutineWarning", "PytestUnknownMarkWarning", "PytestWarning", @@ -92,7 +106,11 @@ __all__ = [ "Session", "set_trace", "skip", + "TempPathFactory", + "Testdir", + "TempdirFactory", "UsageError", + "WarningsRecorder", "warns", "xfail", "yield_fixture", diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 0d1b58ad1..d213414ee 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -123,3 +123,17 @@ def test_yield_fixture_is_deprecated() -> None: @pytest.yield_fixture def fix(): assert False + + +def test_private_is_deprecated() -> None: + class PrivateInit: + def __init__(self, foo: int, *, _ispytest: bool = False) -> None: + deprecated.check_ispytest(_ispytest) + + with pytest.warns( + pytest.PytestDeprecationWarning, match="private pytest class or function" + ): + PrivateInit(10) + + # Doesn't warn. + PrivateInit(10, _ispytest=True) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index a5637b476..94547dd24 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -621,7 +621,7 @@ class TestRequestBasic: def test_func(something): pass """ ) - req = fixtures.FixtureRequest(item) + req = fixtures.FixtureRequest(item, _ispytest=True) assert req.function == item.obj assert req.keywords == item.keywords assert hasattr(req.module, "test_func") @@ -661,7 +661,9 @@ class TestRequestBasic: ) (item1,) = testdir.genitems([modcol]) assert item1.name == "test_method" - arg2fixturedefs = fixtures.FixtureRequest(item1)._arg2fixturedefs + arg2fixturedefs = fixtures.FixtureRequest( + item1, _ispytest=True + )._arg2fixturedefs assert len(arg2fixturedefs) == 1 assert arg2fixturedefs["something"][0].argname == "something" @@ -910,7 +912,7 @@ class TestRequestBasic: def test_request_getmodulepath(self, testdir): modcol = testdir.getmodulecol("def test_somefunc(): pass") (item,) = testdir.genitems([modcol]) - req = fixtures.FixtureRequest(item) + req = fixtures.FixtureRequest(item, _ispytest=True) assert req.fspath == modcol.fspath def test_request_fixturenames(self, testdir): @@ -1052,7 +1054,7 @@ class TestRequestMarking: pass """ ) - req1 = fixtures.FixtureRequest(item1) + req1 = fixtures.FixtureRequest(item1, _ispytest=True) assert "xfail" not in item1.keywords req1.applymarker(pytest.mark.xfail) assert "xfail" in item1.keywords @@ -3882,7 +3884,7 @@ class TestScopeOrdering: """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "m1 f1".split() def test_func_closure_with_native_fixtures(self, testdir, monkeypatch) -> None: @@ -3928,7 +3930,7 @@ class TestScopeOrdering: """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) # order of fixtures based on their scope and position in the parameter list assert ( request.fixturenames == "s1 my_tmpdir_factory p1 m1 f1 f2 my_tmpdir".split() @@ -3954,7 +3956,7 @@ class TestScopeOrdering: """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "m1 f1".split() def test_func_closure_scopes_reordered(self, testdir): @@ -3987,7 +3989,7 @@ class TestScopeOrdering: """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "s1 m1 c1 f2 f1".split() def test_func_closure_same_scope_closer_root_first(self, testdir): @@ -4027,7 +4029,7 @@ class TestScopeOrdering: } ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "p_sub m_conf m_sub m_test f1".split() def test_func_closure_all_scopes_complex(self, testdir): @@ -4071,7 +4073,7 @@ class TestScopeOrdering: """ ) items, _ = testdir.inline_genitems() - request = FixtureRequest(items[0]) + request = FixtureRequest(items[0], _ispytest=True) assert request.fixturenames == "s1 p1 m1 m2 c1 f2 f1".split() def test_multiple_packages(self, testdir): diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 37253b8b5..ccc7304b0 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -1156,7 +1156,7 @@ def test_gitignore(testdir): from _pytest.cacheprovider import Cache config = testdir.parseconfig() - cache = Cache.for_config(config) + cache = Cache.for_config(config, _ispytest=True) cache.set("foo", "bar") msg = "# Created by pytest automatically.\n*\n" gitignore_path = cache._cachedir.joinpath(".gitignore") @@ -1178,7 +1178,7 @@ def test_does_not_create_boilerplate_in_existing_dirs(testdir): """ ) config = testdir.parseconfig() - cache = Cache.for_config(config) + cache = Cache.for_config(config, _ispytest=True) cache.set("foo", "bar") assert os.path.isdir("v") # cache contents @@ -1192,7 +1192,7 @@ def test_cachedir_tag(testdir): from _pytest.cacheprovider import CACHEDIR_TAG_CONTENT config = testdir.parseconfig() - cache = Cache.for_config(config) + cache = Cache.for_config(config, _ispytest=True) cache.set("foo", "bar") cachedir_tag_path = cache._cachedir.joinpath("CACHEDIR.TAG") assert cachedir_tag_path.read_bytes() == CACHEDIR_TAG_CONTENT diff --git a/testing/test_recwarn.py b/testing/test_recwarn.py index f61f8586f..05970061e 100644 --- a/testing/test_recwarn.py +++ b/testing/test_recwarn.py @@ -28,7 +28,7 @@ def test_recwarn_functional(testdir) -> None: class TestWarningsRecorderChecker: def test_recording(self) -> None: - rec = WarningsRecorder() + rec = WarningsRecorder(_ispytest=True) with rec: assert not rec.list warnings.warn_explicit("hello", UserWarning, "xyz", 13) @@ -45,7 +45,7 @@ class TestWarningsRecorderChecker: def test_warn_stacklevel(self) -> None: """#4243""" - rec = WarningsRecorder() + rec = WarningsRecorder(_ispytest=True) with rec: warnings.warn("test", DeprecationWarning, 2) @@ -53,21 +53,21 @@ class TestWarningsRecorderChecker: from _pytest.recwarn import WarningsChecker with pytest.raises(TypeError): - WarningsChecker(5) # type: ignore + WarningsChecker(5, _ispytest=True) # type: ignore[arg-type] with pytest.raises(TypeError): - WarningsChecker(("hi", RuntimeWarning)) # type: ignore + WarningsChecker(("hi", RuntimeWarning), _ispytest=True) # type: ignore[arg-type] with pytest.raises(TypeError): - WarningsChecker([DeprecationWarning, RuntimeWarning]) # type: ignore + WarningsChecker([DeprecationWarning, RuntimeWarning], _ispytest=True) # type: ignore[arg-type] def test_invalid_enter_exit(self) -> None: # wrap this test in WarningsRecorder to ensure warning state gets reset - with WarningsRecorder(): + with WarningsRecorder(_ispytest=True): with pytest.raises(RuntimeError): - rec = WarningsRecorder() + rec = WarningsRecorder(_ispytest=True) rec.__exit__(None, None, None) # can't exit before entering with pytest.raises(RuntimeError): - rec = WarningsRecorder() + rec = WarningsRecorder(_ispytest=True) with rec: with rec: pass # can't enter twice diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index bd6e7b968..1df0e2207 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -48,7 +48,9 @@ class FakeConfig: class TestTempdirHandler: def test_mktemp(self, tmp_path): config = cast(Config, FakeConfig(tmp_path)) - t = TempdirFactory(TempPathFactory.from_config(config)) + t = TempdirFactory( + TempPathFactory.from_config(config, _ispytest=True), _ispytest=True + ) tmp = t.mktemp("world") assert tmp.relto(t.getbasetemp()) == "world0" tmp = t.mktemp("this") @@ -61,7 +63,7 @@ class TestTempdirHandler: """#4425""" monkeypatch.chdir(tmp_path) config = cast(Config, FakeConfig("hello")) - t = TempPathFactory.from_config(config) + t = TempPathFactory.from_config(config, _ispytest=True) assert t.getbasetemp().resolve() == (tmp_path / "hello").resolve()