From 30a7a68e3596f730af42c28e00099db5528bb4e7 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 24 Feb 2020 02:56:50 +0100 Subject: [PATCH 01/10] Remove dynamic setup of `pytest.collect` fake module Make it a real module instead. This was initially added in 9b58d6eaca (https://github.com/pytest-dev/pytest/pull/2315), when removing support for `pytest_namespace`. Not sure if it ever worked "properly", but currently `import pytest.collect` and `from pytest.collect import File` do not work (but with this patch). This is only relevant for (backward) compatibility, but the more it appears to make sense to move it into a lazily loaded, separate file. --- src/_pytest/compat.py | 24 ------------------------ src/pytest/__init__.py | 5 ----- src/pytest/collect.py | 10 ++++++++++ 3 files changed, 10 insertions(+), 29 deletions(-) create mode 100644 src/pytest/collect.py diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 1845d9d91ef..8aff8d57da4 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -336,30 +336,6 @@ def safe_isclass(obj: object) -> bool: return False -COLLECT_FAKEMODULE_ATTRIBUTES = ( - "Collector", - "Module", - "Function", - "Instance", - "Session", - "Item", - "Class", - "File", - "_fillfuncargs", -) - - -def _setup_collect_fakemodule() -> None: - from types import ModuleType - import pytest - - # Types ignored because the module is created dynamically. - pytest.collect = ModuleType("pytest.collect") # type: ignore - pytest.collect.__all__ = [] # type: ignore # used for setns - for attr_name in COLLECT_FAKEMODULE_ATTRIBUTES: - setattr(pytest.collect, attr_name, getattr(pytest, attr_name)) # type: ignore - - class CaptureIO(io.TextIOWrapper): def __init__(self) -> None: super().__init__(io.BytesIO(), encoding="UTF-8", newline="", write_through=True) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 33bc3d0fbe5..1aea7c4c0e7 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -4,7 +4,6 @@ """ from _pytest import __version__ from _pytest.assertion import register_assert_rewrite -from _pytest.compat import _setup_collect_fakemodule from _pytest.config import cmdline from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -93,7 +92,3 @@ "xfail", "yield_fixture", ] - - -_setup_collect_fakemodule() -del _setup_collect_fakemodule diff --git a/src/pytest/collect.py b/src/pytest/collect.py new file mode 100644 index 00000000000..fce42de5a8e --- /dev/null +++ b/src/pytest/collect.py @@ -0,0 +1,10 @@ +"""Fake module for backward compatibility.""" +from . import _fillfuncargs # noqa: F401 +from . import Class # noqa: F401 +from . import Collector # noqa: F401 +from . import File # noqa: F401 +from . import Function # noqa: F401 +from . import Instance # noqa: F401 +from . import Item # noqa: F401 +from . import Module # noqa: F401 +from . import Session # noqa: F401 From 9815b6953d7964569329fc1c22fe805afbb30651 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Mon, 24 Feb 2020 04:20:44 +0100 Subject: [PATCH 02/10] test --- testing/test_meta.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/testing/test_meta.py b/testing/test_meta.py index ffc8fd38aba..e081e2fd56b 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -13,11 +13,17 @@ def _modules(): - return sorted( - n - for _, n, _ in pkgutil.walk_packages( - _pytest.__path__, prefix=_pytest.__name__ + "." + extra = [ + "pytest.collect", + ] + return ( + sorted( + n + for _, n, _ in pkgutil.walk_packages( + _pytest.__path__, prefix=_pytest.__name__ + "." + ) ) + + extra ) From 8a8b1f67a8b14d892f0ba42f01df5a01bb8923f9 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 26 Feb 2020 15:44:50 +0100 Subject: [PATCH 03/10] eager import --- src/pytest/__init__.py | 2 ++ testing/test_meta.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 1aea7c4c0e7..dab6f543675 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -44,6 +44,7 @@ from _pytest.warning_types import PytestUnhandledCoroutineWarning from _pytest.warning_types import PytestUnknownMarkWarning from _pytest.warning_types import PytestWarning +from pytest import collect set_trace = __pytestPDB.set_trace @@ -54,6 +55,7 @@ "approx", "Class", "cmdline", + "collect", "Collector", "deprecated_call", "exit", diff --git a/testing/test_meta.py b/testing/test_meta.py index e081e2fd56b..1dade035dea 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -39,3 +39,11 @@ def test_no_warnings(module): "-c", "import {}".format(module), )) # fmt: on + + +def test_pytest_collect_attribute(_sys_snapshot): + del sys.modules["pytest"] + + import pytest + + assert pytest.collect From d18a7d7bc086fd87d561fa409ff21c9bed8abe89 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 26 Feb 2020 17:18:46 +0100 Subject: [PATCH 04/10] Keep _setup_collect_fakemodule, but via __getattr__ --- src/_pytest/compat.py | 26 ++++++++++++++++++++++++++ src/pytest/__init__.py | 9 +++++++-- src/pytest/collect.py | 10 ---------- testing/test_meta.py | 22 +++++++++++----------- 4 files changed, 44 insertions(+), 23 deletions(-) delete mode 100644 src/pytest/collect.py diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 8aff8d57da4..043def9efd9 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: + from types import ModuleType # noqa: F401 (used in type string) from typing import Type # noqa: F401 (used in type string) @@ -336,6 +337,31 @@ def safe_isclass(obj: object) -> bool: return False +COLLECT_FAKEMODULE_ATTRIBUTES = ( + "Collector", + "Module", + "Function", + "Instance", + "Session", + "Item", + "Class", + "File", + "_fillfuncargs", +) + + +def _setup_collect_fakemodule() -> "ModuleType": + from types import ModuleType + import pytest + + # Types ignored because the module is created dynamically. + mod = ModuleType("pytest.collect") # type: ignore + mod.__all__ = [] # type: ignore # used for setns + for attr_name in COLLECT_FAKEMODULE_ATTRIBUTES: + setattr(mod, attr_name, getattr(pytest, attr_name)) # type: ignore + return mod + + class CaptureIO(io.TextIOWrapper): def __init__(self) -> None: super().__init__(io.BytesIO(), encoding="UTF-8", newline="", write_through=True) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index dab6f543675..aa2157e046f 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -44,7 +44,6 @@ from _pytest.warning_types import PytestUnhandledCoroutineWarning from _pytest.warning_types import PytestUnknownMarkWarning from _pytest.warning_types import PytestWarning -from pytest import collect set_trace = __pytestPDB.set_trace @@ -55,7 +54,6 @@ "approx", "Class", "cmdline", - "collect", "Collector", "deprecated_call", "exit", @@ -94,3 +92,10 @@ "xfail", "yield_fixture", ] + + +def __getattr__(name): + if name == "collect": + from _pytest.compat import _setup_collect_fakemodule + + return _setup_collect_fakemodule() diff --git a/src/pytest/collect.py b/src/pytest/collect.py deleted file mode 100644 index fce42de5a8e..00000000000 --- a/src/pytest/collect.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Fake module for backward compatibility.""" -from . import _fillfuncargs # noqa: F401 -from . import Class # noqa: F401 -from . import Collector # noqa: F401 -from . import File # noqa: F401 -from . import Function # noqa: F401 -from . import Instance # noqa: F401 -from . import Item # noqa: F401 -from . import Module # noqa: F401 -from . import Session # noqa: F401 diff --git a/testing/test_meta.py b/testing/test_meta.py index 1dade035dea..3ac2269fdcc 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -13,17 +13,11 @@ def _modules(): - extra = [ - "pytest.collect", - ] - return ( - sorted( - n - for _, n, _ in pkgutil.walk_packages( - _pytest.__path__, prefix=_pytest.__name__ + "." - ) + return sorted( + n + for _, n, _ in pkgutil.walk_packages( + _pytest.__path__, prefix=_pytest.__name__ + "." ) - + extra ) @@ -42,8 +36,14 @@ def test_no_warnings(module): def test_pytest_collect_attribute(_sys_snapshot): + from types import ModuleType + del sys.modules["pytest"] import pytest - assert pytest.collect + assert isinstance(pytest.collect, ModuleType) + assert pytest.collect.Item is pytest.Item + + with pytest.raises(ImportError): + import pytest.collect From 0089e55fa4f7e04b99ea271a73aff391b95badc9 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Wed, 26 Feb 2020 18:34:27 +0100 Subject: [PATCH 05/10] raise AttributeError --- src/pytest/__init__.py | 1 + testing/test_meta.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index aa2157e046f..5bb9cbb9349 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -99,3 +99,4 @@ def __getattr__(name): from _pytest.compat import _setup_collect_fakemodule return _setup_collect_fakemodule() + raise AttributeError(name) diff --git a/testing/test_meta.py b/testing/test_meta.py index 3ac2269fdcc..7b6c5c25fc5 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -47,3 +47,6 @@ def test_pytest_collect_attribute(_sys_snapshot): with pytest.raises(ImportError): import pytest.collect + + with pytest.raises(AttributeError, match=r"^doesnotexist$"): + pytest.doesnotexist From d371bad39e8121fbac1200479b249699bf012b14 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 27 Feb 2020 00:38:24 +0100 Subject: [PATCH 06/10] __getattr__ with py37+ --- src/pytest/__init__.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 5bb9cbb9349..6ea2cf4cf45 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -2,6 +2,8 @@ """ pytest: unit and functional testing with Python. """ +import sys + from _pytest import __version__ from _pytest.assertion import register_assert_rewrite from _pytest.config import cmdline @@ -45,7 +47,6 @@ from _pytest.warning_types import PytestUnknownMarkWarning from _pytest.warning_types import PytestWarning - set_trace = __pytestPDB.set_trace __all__ = [ @@ -94,9 +95,18 @@ ] -def __getattr__(name): - if name == "collect": - from _pytest.compat import _setup_collect_fakemodule +if sys.version_info >= (3, 7): + + def __getattr__(name): + if name == "collect": + from _pytest.compat import _setup_collect_fakemodule + + return _setup_collect_fakemodule() + raise AttributeError(name) + + +else: + from _pytest.compat import _setup_collect_fakemodule - return _setup_collect_fakemodule() - raise AttributeError(name) + collect = _setup_collect_fakemodule() + del _setup_collect_fakemodule From a762bc5e390b27f6cbd0df8dad9f8654a3876d35 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 27 Feb 2020 16:31:05 +0100 Subject: [PATCH 07/10] _setup_collect_fakemodule: use references, avoid circular import --- src/_pytest/compat.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 043def9efd9..0688c2c0ecb 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -337,28 +337,27 @@ def safe_isclass(obj: object) -> bool: return False -COLLECT_FAKEMODULE_ATTRIBUTES = ( - "Collector", - "Module", - "Function", - "Instance", - "Session", - "Item", - "Class", - "File", - "_fillfuncargs", -) - - def _setup_collect_fakemodule() -> "ModuleType": + """Setup pytest.collect fake module for backward compatibility.""" from types import ModuleType - import pytest + import _pytest.nodes + + collect_fakemodule_attributes = ( + ("Collector", _pytest.nodes.Collector), + ("Module", _pytest.python.Module), + ("Function", _pytest.python.Function), + ("Instance", _pytest.python.Instance), + ("Session", _pytest.main.Session), + ("Item", _pytest.nodes.Item), + ("Class", _pytest.python.Class), + ("File", _pytest.nodes.File), + ("_fillfuncargs", _pytest.fixtures.fillfixtures), + ) - # Types ignored because the module is created dynamically. - mod = ModuleType("pytest.collect") # type: ignore - mod.__all__ = [] # type: ignore # used for setns - for attr_name in COLLECT_FAKEMODULE_ATTRIBUTES: - setattr(mod, attr_name, getattr(pytest, attr_name)) # type: ignore + mod = ModuleType("pytest.collect") + mod.__all__ = [] # type: ignore # used for setns (obsolete?) + for attr_name, value in collect_fakemodule_attributes: + setattr(mod, attr_name, value) return mod From 8cbb5ea114c22b192c218964c6a3deb1ae59de24 Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 27 Feb 2020 17:09:59 +0100 Subject: [PATCH 08/10] testing/conftest.py: add symlink_or_skip Taken out of https://github.com/pytest-dev/pytest/pull/6733, returning for existing symlink (should not skip). --- testing/conftest.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/testing/conftest.py b/testing/conftest.py index 90cdcb869fd..f03835e19b2 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,3 +1,4 @@ +import os import re import sys from typing import List @@ -136,6 +137,27 @@ def testdir(testdir: Testdir) -> Testdir: return testdir +@pytest.fixture +def symlink_or_skip(): + """Return a function that creates a symlink or raises ``Skip``. + + On Windows `os.symlink` is available, but normal users require special + admin privileges to create symlinks. + """ + + def wrap_os_symlink(src, dst, *args, **kwargs): + if os.path.islink(dst): + return + + try: + os.symlink(src, dst, *args, **kwargs) + except OSError as e: + pytest.skip("os.symlink({!r}) failed: {!r}".format((src, dst), e)) + assert os.path.islink(dst) + + return wrap_os_symlink + + @pytest.fixture(scope="session") def color_mapping(): """Returns a utility class which can replace keys in strings in the form "{NAME}" From 65610d11e36fe285d1b9ae676260bd7698a6f62b Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 27 Feb 2020 17:10:55 +0100 Subject: [PATCH 09/10] Add test_pytest_circular_import --- testing/test_meta.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/testing/test_meta.py b/testing/test_meta.py index 7b6c5c25fc5..486927c33d5 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -10,6 +10,7 @@ import _pytest import pytest +from _pytest.pytester import Testdir def _modules(): @@ -50,3 +51,18 @@ def test_pytest_collect_attribute(_sys_snapshot): with pytest.raises(AttributeError, match=r"^doesnotexist$"): pytest.doesnotexist + + +def test_pytest_circular_import(testdir: Testdir, symlink_or_skip) -> None: + """Importing pytest should not import pytest itself.""" + import pytest + import os.path + + symlink_or_skip(os.path.dirname(pytest.__file__), "another") + + del sys.modules["pytest"] + + testdir.syspathinsert() + import another # noqa: F401 + + assert "pytest" not in sys.modules From b7e4375978c2675ec860d05aced194ac55f1237a Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Thu, 27 Feb 2020 17:16:23 +0100 Subject: [PATCH 10/10] Fix test_pytest_collect_attribute for older Python --- testing/test_meta.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/testing/test_meta.py b/testing/test_meta.py index 486927c33d5..6dbc47ddc41 100644 --- a/testing/test_meta.py +++ b/testing/test_meta.py @@ -49,8 +49,12 @@ def test_pytest_collect_attribute(_sys_snapshot): with pytest.raises(ImportError): import pytest.collect - with pytest.raises(AttributeError, match=r"^doesnotexist$"): - pytest.doesnotexist + if sys.version_info >= (3, 7): + with pytest.raises(AttributeError, match=r"^doesnotexist$"): + pytest.doesnotexist + else: + with pytest.raises(AttributeError, match=r"doesnotexist"): + pytest.doesnotexist def test_pytest_circular_import(testdir: Testdir, symlink_or_skip) -> None: