Skip to content

Commit 814616d

Browse files
authored
Improve/revisit _setup_collect_fakemodule (#279)
Ref: pytest-dev#6803
1 parent 31bdacb commit 814616d

File tree

5 files changed

+118
-24
lines changed

5 files changed

+118
-24
lines changed

src/_pytest/compat.py

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535

3636
if TYPE_CHECKING:
37+
from types import ModuleType # noqa: F401 (used in type string)
3738
from typing import Type # noqa: F401 (used in type string)
3839

3940

@@ -336,28 +337,28 @@ def safe_isclass(obj: object) -> bool:
336337
return False
337338

338339

339-
COLLECT_FAKEMODULE_ATTRIBUTES = (
340-
"Collector",
341-
"Module",
342-
"Function",
343-
"Instance",
344-
"Session",
345-
"Item",
346-
"Class",
347-
"File",
348-
"_fillfuncargs",
349-
)
350-
351-
352-
def _setup_collect_fakemodule() -> None:
340+
def _setup_collect_fakemodule() -> "ModuleType":
341+
"""Setup pytest.collect fake module for backward compatibility."""
353342
from types import ModuleType
354-
import pytest
343+
import _pytest.nodes
344+
345+
collect_fakemodule_attributes = (
346+
("Collector", _pytest.nodes.Collector),
347+
("Module", _pytest.python.Module),
348+
("Function", _pytest.python.Function),
349+
("Instance", _pytest.python.Instance),
350+
("Session", _pytest.main.Session),
351+
("Item", _pytest.nodes.Item),
352+
("Class", _pytest.python.Class),
353+
("File", _pytest.nodes.File),
354+
("_fillfuncargs", _pytest.fixtures.fillfixtures),
355+
)
355356

356-
# Types ignored because the module is created dynamically.
357-
pytest.collect = ModuleType("pytest.collect") # type: ignore
358-
pytest.collect.__all__ = [] # type: ignore # used for setns
359-
for attr_name in COLLECT_FAKEMODULE_ATTRIBUTES:
360-
setattr(pytest.collect, attr_name, getattr(pytest, attr_name)) # type: ignore
357+
mod = ModuleType("pytest.collect")
358+
mod.__all__ = [] # type: ignore # used for setns (obsolete?)
359+
for attr_name, value in collect_fakemodule_attributes:
360+
setattr(mod, attr_name, value)
361+
return mod
361362

362363

363364
class CaptureIO(io.TextIOWrapper):

src/pytest/__init__.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""
22
pytest: unit and functional testing with Python.
33
"""
4+
import sys
5+
46
from _pytest import __version__
57
from _pytest.assertion import register_assert_rewrite
6-
from _pytest.compat import _setup_collect_fakemodule
78
from _pytest.config import cmdline
89
from _pytest.config import ExitCode
910
from _pytest.config import hookimpl
@@ -45,7 +46,6 @@
4546
from _pytest.warning_types import PytestUnknownMarkWarning
4647
from _pytest.warning_types import PytestWarning
4748

48-
4949
__all__ = [
5050
"__version__",
5151
"_fillfuncargs",
@@ -92,5 +92,18 @@
9292
]
9393

9494

95-
_setup_collect_fakemodule()
96-
del _setup_collect_fakemodule
95+
if sys.version_info >= (3, 7):
96+
97+
def __getattr__(name):
98+
if name == "collect":
99+
from _pytest.compat import _setup_collect_fakemodule
100+
101+
return _setup_collect_fakemodule()
102+
raise AttributeError(name)
103+
104+
105+
else:
106+
from _pytest.compat import _setup_collect_fakemodule
107+
108+
collect = _setup_collect_fakemodule()
109+
del _setup_collect_fakemodule

testing/conftest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,27 @@ def testdir(testdir: Testdir) -> Testdir:
165165
return testdir
166166

167167

168+
@pytest.fixture
169+
def symlink_or_skip():
170+
"""Return a function that creates a symlink or raises ``Skip``.
171+
172+
On Windows `os.symlink` is available, but normal users require special
173+
admin privileges to create symlinks.
174+
"""
175+
176+
def wrap_os_symlink(src, dst, *args, **kwargs):
177+
if os.path.islink(dst):
178+
return
179+
180+
try:
181+
os.symlink(src, dst, *args, **kwargs)
182+
except OSError as e:
183+
pytest.skip("os.symlink({!r}) failed: {!r}".format((src, dst), e))
184+
assert os.path.islink(dst)
185+
186+
return wrap_os_symlink
187+
188+
168189
@pytest.fixture(scope="session")
169190
def color_mapping():
170191
"""Returns a utility class which can replace keys in strings in the form "{NAME}"

testing/test_meta.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import _pytest
1212
import pytest
13+
from _pytest.pytester import Testdir
1314

1415
pytestmark = [pytest.mark.integration, pytest.mark.slow]
1516

@@ -34,3 +35,39 @@ def test_no_warnings(module):
3435
"-c", "import {}".format(module),
3536
))
3637
# fmt: on
38+
39+
40+
def test_pytest_collect_attribute(_sys_snapshot):
41+
from types import ModuleType
42+
43+
del sys.modules["pytest"]
44+
45+
import pytest
46+
47+
assert isinstance(pytest.collect, ModuleType)
48+
assert pytest.collect.Item is pytest.Item
49+
50+
with pytest.raises(ImportError):
51+
import pytest.collect
52+
53+
if sys.version_info >= (3, 7):
54+
with pytest.raises(AttributeError, match=r"^doesnotexist$"):
55+
pytest.doesnotexist
56+
else:
57+
with pytest.raises(AttributeError, match=r"doesnotexist"):
58+
pytest.doesnotexist
59+
60+
61+
def test_pytest_circular_import(testdir: Testdir, symlink_or_skip) -> None:
62+
"""Importing pytest should not import pytest itself."""
63+
import pytest
64+
import os.path
65+
66+
symlink_or_skip(os.path.dirname(pytest.__file__), "another")
67+
68+
del sys.modules["pytest"]
69+
70+
testdir.syspathinsert()
71+
import another # noqa: F401
72+
73+
assert "pytest" not in sys.modules

testing/test_own_conftest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import os
2+
3+
import pytest
4+
5+
6+
def test_symlink_or_skip(monkeypatch, tmpdir, symlink_or_skip):
7+
symlink_or_skip("src", "dst")
8+
assert os.path.islink("dst")
9+
10+
def oserror(src, dst):
11+
raise OSError("foo")
12+
13+
monkeypatch.setattr("os.symlink", oserror)
14+
15+
# Works with existing symlinks.
16+
symlink_or_skip("src", "dst")
17+
18+
with pytest.raises(
19+
pytest.skip.Exception,
20+
match=r"os\.symlink\(\('src', 'dst2'\)\) failed: OSError\('foo',?\)",
21+
):
22+
symlink_or_skip("src", "dst2")

0 commit comments

Comments
 (0)