test_ok2/testing/test_pathlib.py

1434 lines
50 KiB
Python

# mypy: allow-untyped-defs
import errno
import importlib.abc
import importlib.machinery
import os.path
from pathlib import Path
import pickle
import shutil
import sys
from textwrap import dedent
from types import ModuleType
from typing import Any
from typing import Generator
from typing import Iterator
from typing import Optional
from typing import Sequence
from typing import Tuple
import unittest.mock
from _pytest.monkeypatch import MonkeyPatch
from _pytest.pathlib import bestrelpath
from _pytest.pathlib import commonpath
from _pytest.pathlib import compute_module_name
from _pytest.pathlib import CouldNotResolvePathError
from _pytest.pathlib import ensure_deletable
from _pytest.pathlib import fnmatch_ex
from _pytest.pathlib import get_extended_length_path_str
from _pytest.pathlib import get_lock_path
from _pytest.pathlib import import_path
from _pytest.pathlib import ImportMode
from _pytest.pathlib import ImportPathMismatchError
from _pytest.pathlib import insert_missing_modules
from _pytest.pathlib import is_importable
from _pytest.pathlib import maybe_delete_a_numbered_dir
from _pytest.pathlib import module_name_from_path
from _pytest.pathlib import resolve_package_path
from _pytest.pathlib import resolve_pkg_root_and_module_name
from _pytest.pathlib import safe_exists
from _pytest.pathlib import symlink_or_skip
from _pytest.pathlib import visit
from _pytest.pytester import Pytester
from _pytest.pytester import RunResult
from _pytest.tmpdir import TempPathFactory
import pytest
@pytest.fixture(autouse=True)
def autouse_pytester(pytester: Pytester) -> None:
"""
Fixture to make pytester() being autouse for all tests in this module.
pytester makes sure to restore sys.path to its previous state, and many tests in this module
import modules and change sys.path because of that, so common module names such as "test" or "test.conftest"
end up leaking to tests in other modules.
Note: we might consider extracting the sys.path restoration aspect into its own fixture, and apply it
to the entire test suite always.
"""
class TestFNMatcherPort:
"""Test our port of py.common.FNMatcher (fnmatch_ex)."""
if sys.platform == "win32":
drv1 = "c:"
drv2 = "d:"
else:
drv1 = "/c"
drv2 = "/d"
@pytest.mark.parametrize(
"pattern, path",
[
("*.py", "foo.py"),
("*.py", "bar/foo.py"),
("test_*.py", "foo/test_foo.py"),
("tests/*.py", "tests/foo.py"),
(f"{drv1}/*.py", f"{drv1}/foo.py"),
(f"{drv1}/foo/*.py", f"{drv1}/foo/foo.py"),
("tests/**/test*.py", "tests/foo/test_foo.py"),
("tests/**/doc/test*.py", "tests/foo/bar/doc/test_foo.py"),
("tests/**/doc/**/test*.py", "tests/foo/doc/bar/test_foo.py"),
],
)
def test_matching(self, pattern: str, path: str) -> None:
assert fnmatch_ex(pattern, path)
def test_matching_abspath(self) -> None:
abspath = os.path.abspath(os.path.join("tests/foo.py"))
assert fnmatch_ex("tests/foo.py", abspath)
@pytest.mark.parametrize(
"pattern, path",
[
("*.py", "foo.pyc"),
("*.py", "foo/foo.pyc"),
("tests/*.py", "foo/foo.py"),
(f"{drv1}/*.py", f"{drv2}/foo.py"),
(f"{drv1}/foo/*.py", f"{drv2}/foo/foo.py"),
("tests/**/test*.py", "tests/foo.py"),
("tests/**/test*.py", "foo/test_foo.py"),
("tests/**/doc/test*.py", "tests/foo/bar/doc/foo.py"),
("tests/**/doc/test*.py", "tests/foo/bar/test_foo.py"),
],
)
def test_not_matching(self, pattern: str, path: str) -> None:
assert not fnmatch_ex(pattern, path)
@pytest.fixture(params=[True, False])
def ns_param(request: pytest.FixtureRequest) -> bool:
"""
Simple parametrized fixture for tests which call import_path() with consider_namespace_packages
using True and False.
"""
return bool(request.param)
class TestImportPath:
"""
Most of the tests here were copied from py lib's tests for "py.local.path.pyimport".
Having our own pyimport-like function is inline with removing py.path dependency in the future.
"""
@pytest.fixture(scope="session")
def path1(self, tmp_path_factory: TempPathFactory) -> Generator[Path, None, None]:
path = tmp_path_factory.mktemp("path")
self.setuptestfs(path)
yield path
assert path.joinpath("samplefile").exists()
@pytest.fixture(autouse=True)
def preserve_sys(self):
with unittest.mock.patch.dict(sys.modules):
with unittest.mock.patch.object(sys, "path", list(sys.path)):
yield
def setuptestfs(self, path: Path) -> None:
# print "setting up test fs for", repr(path)
samplefile = path / "samplefile"
samplefile.write_text("samplefile\n", encoding="utf-8")
execfile = path / "execfile"
execfile.write_text("x=42", encoding="utf-8")
execfilepy = path / "execfile.py"
execfilepy.write_text("x=42", encoding="utf-8")
d = {1: 2, "hello": "world", "answer": 42}
path.joinpath("samplepickle").write_bytes(pickle.dumps(d, 1))
sampledir = path / "sampledir"
sampledir.mkdir()
sampledir.joinpath("otherfile").touch()
otherdir = path / "otherdir"
otherdir.mkdir()
otherdir.joinpath("__init__.py").touch()
module_a = otherdir / "a.py"
module_a.write_text("from .b import stuff as result\n", encoding="utf-8")
module_b = otherdir / "b.py"
module_b.write_text('stuff="got it"\n', encoding="utf-8")
module_c = otherdir / "c.py"
module_c.write_text(
dedent(
"""
import pluggy;
import otherdir.a
value = otherdir.a.result
"""
),
encoding="utf-8",
)
module_d = otherdir / "d.py"
module_d.write_text(
dedent(
"""
import pluggy;
from otherdir import a
value2 = a.result
"""
),
encoding="utf-8",
)
def test_smoke_test(self, path1: Path, ns_param: bool) -> None:
obj = import_path(
path1 / "execfile.py", root=path1, consider_namespace_packages=ns_param
)
assert obj.x == 42
assert obj.__name__ == "execfile"
def test_import_path_missing_file(self, path1: Path, ns_param: bool) -> None:
with pytest.raises(ImportPathMismatchError):
import_path(
path1 / "sampledir", root=path1, consider_namespace_packages=ns_param
)
def test_renamed_dir_creates_mismatch(
self, tmp_path: Path, monkeypatch: MonkeyPatch, ns_param: bool
) -> None:
tmp_path.joinpath("a").mkdir()
p = tmp_path.joinpath("a", "test_x123.py")
p.touch()
import_path(p, root=tmp_path, consider_namespace_packages=ns_param)
tmp_path.joinpath("a").rename(tmp_path.joinpath("b"))
with pytest.raises(ImportPathMismatchError):
import_path(
tmp_path.joinpath("b", "test_x123.py"),
root=tmp_path,
consider_namespace_packages=ns_param,
)
# Errors can be ignored.
monkeypatch.setenv("PY_IGNORE_IMPORTMISMATCH", "1")
import_path(
tmp_path.joinpath("b", "test_x123.py"),
root=tmp_path,
consider_namespace_packages=ns_param,
)
# PY_IGNORE_IMPORTMISMATCH=0 does not ignore error.
monkeypatch.setenv("PY_IGNORE_IMPORTMISMATCH", "0")
with pytest.raises(ImportPathMismatchError):
import_path(
tmp_path.joinpath("b", "test_x123.py"),
root=tmp_path,
consider_namespace_packages=ns_param,
)
def test_messy_name(self, tmp_path: Path, ns_param: bool) -> None:
# https://bitbucket.org/hpk42/py-trunk/issue/129
path = tmp_path / "foo__init__.py"
path.touch()
module = import_path(path, root=tmp_path, consider_namespace_packages=ns_param)
assert module.__name__ == "foo__init__"
def test_dir(self, tmp_path: Path, ns_param: bool) -> None:
p = tmp_path / "hello_123"
p.mkdir()
p_init = p / "__init__.py"
p_init.touch()
m = import_path(p, root=tmp_path, consider_namespace_packages=ns_param)
assert m.__name__ == "hello_123"
m = import_path(p_init, root=tmp_path, consider_namespace_packages=ns_param)
assert m.__name__ == "hello_123"
def test_a(self, path1: Path, ns_param: bool) -> None:
otherdir = path1 / "otherdir"
mod = import_path(
otherdir / "a.py", root=path1, consider_namespace_packages=ns_param
)
assert mod.result == "got it"
assert mod.__name__ == "otherdir.a"
def test_b(self, path1: Path, ns_param: bool) -> None:
otherdir = path1 / "otherdir"
mod = import_path(
otherdir / "b.py", root=path1, consider_namespace_packages=ns_param
)
assert mod.stuff == "got it"
assert mod.__name__ == "otherdir.b"
def test_c(self, path1: Path, ns_param: bool) -> None:
otherdir = path1 / "otherdir"
mod = import_path(
otherdir / "c.py", root=path1, consider_namespace_packages=ns_param
)
assert mod.value == "got it"
def test_d(self, path1: Path, ns_param: bool) -> None:
otherdir = path1 / "otherdir"
mod = import_path(
otherdir / "d.py", root=path1, consider_namespace_packages=ns_param
)
assert mod.value2 == "got it"
def test_import_after(self, tmp_path: Path, ns_param: bool) -> None:
tmp_path.joinpath("xxxpackage").mkdir()
tmp_path.joinpath("xxxpackage", "__init__.py").touch()
mod1path = tmp_path.joinpath("xxxpackage", "module1.py")
mod1path.touch()
mod1 = import_path(
mod1path, root=tmp_path, consider_namespace_packages=ns_param
)
assert mod1.__name__ == "xxxpackage.module1"
from xxxpackage import module1
assert module1 is mod1
def test_check_filepath_consistency(
self, monkeypatch: MonkeyPatch, tmp_path: Path, ns_param: bool
) -> None:
name = "pointsback123"
p = tmp_path.joinpath(name + ".py")
p.touch()
with monkeypatch.context() as mp:
for ending in (".pyc", ".pyo"):
mod = ModuleType(name)
pseudopath = tmp_path.joinpath(name + ending)
pseudopath.touch()
mod.__file__ = str(pseudopath)
mp.setitem(sys.modules, name, mod)
newmod = import_path(
p, root=tmp_path, consider_namespace_packages=ns_param
)
assert mod == newmod
mod = ModuleType(name)
pseudopath = tmp_path.joinpath(name + "123.py")
pseudopath.touch()
mod.__file__ = str(pseudopath)
monkeypatch.setitem(sys.modules, name, mod)
with pytest.raises(ImportPathMismatchError) as excinfo:
import_path(p, root=tmp_path, consider_namespace_packages=ns_param)
modname, modfile, orig = excinfo.value.args
assert modname == name
assert modfile == str(pseudopath)
assert orig == p
assert issubclass(ImportPathMismatchError, ImportError)
def test_ensuresyspath_append(self, tmp_path: Path, ns_param: bool) -> None:
root1 = tmp_path / "root1"
root1.mkdir()
file1 = root1 / "x123.py"
file1.touch()
assert str(root1) not in sys.path
import_path(
file1, mode="append", root=tmp_path, consider_namespace_packages=ns_param
)
assert str(root1) == sys.path[-1]
assert str(root1) not in sys.path[:-1]
def test_invalid_path(self, tmp_path: Path, ns_param: bool) -> None:
with pytest.raises(ImportError):
import_path(
tmp_path / "invalid.py",
root=tmp_path,
consider_namespace_packages=ns_param,
)
@pytest.fixture
def simple_module(
self, tmp_path: Path, request: pytest.FixtureRequest
) -> Iterator[Path]:
name = f"mymod_{request.node.name}"
fn = tmp_path / f"_src/tests/{name}.py"
fn.parent.mkdir(parents=True)
fn.write_text("def foo(x): return 40 + x", encoding="utf-8")
module_name = module_name_from_path(fn, root=tmp_path)
yield fn
sys.modules.pop(module_name, None)
def test_importmode_importlib(
self,
simple_module: Path,
tmp_path: Path,
request: pytest.FixtureRequest,
ns_param: bool,
) -> None:
"""`importlib` mode does not change sys.path."""
module = import_path(
simple_module,
mode="importlib",
root=tmp_path,
consider_namespace_packages=ns_param,
)
assert module.foo(2) == 42
assert str(simple_module.parent) not in sys.path
assert module.__name__ in sys.modules
assert module.__name__ == f"_src.tests.mymod_{request.node.name}"
assert "_src" in sys.modules
assert "_src.tests" in sys.modules
def test_remembers_previous_imports(
self, simple_module: Path, tmp_path: Path, ns_param: bool
) -> None:
"""`importlib` mode called remembers previous module (#10341, #10811)."""
module1 = import_path(
simple_module,
mode="importlib",
root=tmp_path,
consider_namespace_packages=ns_param,
)
module2 = import_path(
simple_module,
mode="importlib",
root=tmp_path,
consider_namespace_packages=ns_param,
)
assert module1 is module2
def test_no_meta_path_found(
self,
simple_module: Path,
monkeypatch: MonkeyPatch,
tmp_path: Path,
ns_param: bool,
) -> None:
"""Even without any meta_path should still import module."""
monkeypatch.setattr(sys, "meta_path", [])
module = import_path(
simple_module,
mode="importlib",
root=tmp_path,
consider_namespace_packages=ns_param,
)
assert module.foo(2) == 42
# mode='importlib' fails if no spec is found to load the module
import importlib.util
# Force module to be re-imported.
del sys.modules[module.__name__]
monkeypatch.setattr(
importlib.util, "spec_from_file_location", lambda *args: None
)
with pytest.raises(ImportError):
import_path(
simple_module,
mode="importlib",
root=tmp_path,
consider_namespace_packages=False,
)
def test_resolve_package_path(tmp_path: Path) -> None:
pkg = tmp_path / "pkg1"
pkg.mkdir()
(pkg / "__init__.py").touch()
(pkg / "subdir").mkdir()
(pkg / "subdir/__init__.py").touch()
assert resolve_package_path(pkg) == pkg
assert resolve_package_path(pkg / "subdir/__init__.py") == pkg
def test_package_unimportable(tmp_path: Path) -> None:
pkg = tmp_path / "pkg1-1"
pkg.mkdir()
pkg.joinpath("__init__.py").touch()
subdir = pkg / "subdir"
subdir.mkdir()
(pkg / "subdir/__init__.py").touch()
assert resolve_package_path(subdir) == subdir
xyz = subdir / "xyz.py"
xyz.touch()
assert resolve_package_path(xyz) == subdir
assert not resolve_package_path(pkg)
def test_access_denied_during_cleanup(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
"""Ensure that deleting a numbered dir does not fail because of OSErrors (#4262)."""
path = tmp_path / "temp-1"
path.mkdir()
def renamed_failed(*args):
raise OSError("access denied")
monkeypatch.setattr(Path, "rename", renamed_failed)
lock_path = get_lock_path(path)
maybe_delete_a_numbered_dir(path)
assert not lock_path.is_file()
def test_long_path_during_cleanup(tmp_path: Path) -> None:
"""Ensure that deleting long path works (particularly on Windows (#6775))."""
path = (tmp_path / ("a" * 250)).resolve()
if sys.platform == "win32":
# make sure that the full path is > 260 characters without any
# component being over 260 characters
assert len(str(path)) > 260
extended_path = "\\\\?\\" + str(path)
else:
extended_path = str(path)
os.mkdir(extended_path)
assert os.path.isdir(extended_path)
maybe_delete_a_numbered_dir(path)
assert not os.path.isdir(extended_path)
def test_get_extended_length_path_str() -> None:
assert get_extended_length_path_str(r"c:\foo") == r"\\?\c:\foo"
assert get_extended_length_path_str(r"\\share\foo") == r"\\?\UNC\share\foo"
assert get_extended_length_path_str(r"\\?\UNC\share\foo") == r"\\?\UNC\share\foo"
assert get_extended_length_path_str(r"\\?\c:\foo") == r"\\?\c:\foo"
def test_suppress_error_removing_lock(tmp_path: Path) -> None:
"""ensure_deletable should be resilient if lock file cannot be removed (#5456, #7491)"""
path = tmp_path / "dir"
path.mkdir()
lock = get_lock_path(path)
lock.touch()
mtime = lock.stat().st_mtime
with unittest.mock.patch.object(Path, "unlink", side_effect=OSError) as m:
assert not ensure_deletable(
path, consider_lock_dead_if_created_before=mtime + 30
)
assert m.call_count == 1
assert lock.is_file()
with unittest.mock.patch.object(Path, "is_file", side_effect=OSError) as m:
assert not ensure_deletable(
path, consider_lock_dead_if_created_before=mtime + 30
)
assert m.call_count == 1
assert lock.is_file()
# check now that we can remove the lock file in normal circumstances
assert ensure_deletable(path, consider_lock_dead_if_created_before=mtime + 30)
assert not lock.is_file()
def test_bestrelpath() -> None:
curdir = Path("/foo/bar/baz/path")
assert bestrelpath(curdir, curdir) == "."
assert bestrelpath(curdir, curdir / "hello" / "world") == "hello" + os.sep + "world"
assert bestrelpath(curdir, curdir.parent / "sister") == ".." + os.sep + "sister"
assert bestrelpath(curdir, curdir.parent) == ".."
assert bestrelpath(curdir, Path("hello")) == "hello"
def test_commonpath() -> None:
path = Path("/foo/bar/baz/path")
subpath = path / "sampledir"
assert commonpath(path, subpath) == path
assert commonpath(subpath, path) == path
assert commonpath(Path(str(path) + "suffix"), path) == path.parent
assert commonpath(path, path.parent.parent) == path.parent.parent
def test_visit_ignores_errors(tmp_path: Path) -> None:
symlink_or_skip("recursive", tmp_path / "recursive")
tmp_path.joinpath("foo").write_bytes(b"")
tmp_path.joinpath("bar").write_bytes(b"")
assert [
entry.name for entry in visit(str(tmp_path), recurse=lambda entry: False)
] == ["bar", "foo"]
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only")
def test_samefile_false_negatives(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
"""
import_file() should not raise ImportPathMismatchError if the paths are exactly
equal on Windows. It seems directories mounted as UNC paths make os.path.samefile
return False, even when they are clearly equal.
"""
module_path = tmp_path.joinpath("my_module.py")
module_path.write_text("def foo(): return 42", encoding="utf-8")
monkeypatch.syspath_prepend(tmp_path)
with monkeypatch.context() as mp:
# Forcibly make os.path.samefile() return False here to ensure we are comparing
# the paths too. Using a context to narrow the patch as much as possible given
# this is an important system function.
mp.setattr(os.path, "samefile", lambda x, y: False)
module = import_path(
module_path, root=tmp_path, consider_namespace_packages=False
)
assert getattr(module, "foo")() == 42
class TestImportLibMode:
def test_importmode_importlib_with_dataclass(
self, tmp_path: Path, ns_param: bool
) -> None:
"""Ensure that importlib mode works with a module containing dataclasses (#7856)."""
fn = tmp_path.joinpath("_src/tests/test_dataclass.py")
fn.parent.mkdir(parents=True)
fn.write_text(
dedent(
"""
from dataclasses import dataclass
@dataclass
class Data:
value: str
"""
),
encoding="utf-8",
)
module = import_path(
fn, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param
)
Data: Any = getattr(module, "Data")
data = Data(value="foo")
assert data.value == "foo"
assert data.__module__ == "_src.tests.test_dataclass"
# Ensure we do not import the same module again (#11475).
module2 = import_path(
fn, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param
)
assert module is module2
def test_importmode_importlib_with_pickle(
self, tmp_path: Path, ns_param: bool
) -> None:
"""Ensure that importlib mode works with pickle (#7859)."""
fn = tmp_path.joinpath("_src/tests/test_pickle.py")
fn.parent.mkdir(parents=True)
fn.write_text(
dedent(
"""
import pickle
def _action():
return 42
def round_trip():
s = pickle.dumps(_action)
return pickle.loads(s)
"""
),
encoding="utf-8",
)
module = import_path(
fn, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param
)
round_trip = getattr(module, "round_trip")
action = round_trip()
assert action() == 42
# Ensure we do not import the same module again (#11475).
module2 = import_path(
fn, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param
)
assert module is module2
def test_importmode_importlib_with_pickle_separate_modules(
self, tmp_path: Path, ns_param: bool
) -> None:
"""
Ensure that importlib mode works can load pickles that look similar but are
defined in separate modules.
"""
fn1 = tmp_path.joinpath("_src/m1/tests/test.py")
fn1.parent.mkdir(parents=True)
fn1.write_text(
dedent(
"""
import dataclasses
import pickle
@dataclasses.dataclass
class Data:
x: int = 42
"""
),
encoding="utf-8",
)
fn2 = tmp_path.joinpath("_src/m2/tests/test.py")
fn2.parent.mkdir(parents=True)
fn2.write_text(
dedent(
"""
import dataclasses
import pickle
@dataclasses.dataclass
class Data:
x: str = ""
"""
),
encoding="utf-8",
)
import pickle
def round_trip(obj):
s = pickle.dumps(obj)
return pickle.loads(s)
module = import_path(
fn1, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param
)
Data1 = getattr(module, "Data")
module = import_path(
fn2, mode="importlib", root=tmp_path, consider_namespace_packages=ns_param
)
Data2 = getattr(module, "Data")
assert round_trip(Data1(20)) == Data1(20)
assert round_trip(Data2("hello")) == Data2("hello")
assert Data1.__module__ == "_src.m1.tests.test"
assert Data2.__module__ == "_src.m2.tests.test"
def test_module_name_from_path(self, tmp_path: Path) -> None:
result = module_name_from_path(tmp_path / "src/tests/test_foo.py", tmp_path)
assert result == "src.tests.test_foo"
# Path is not relative to root dir: use the full path to obtain the module name.
result = module_name_from_path(Path("/home/foo/test_foo.py"), Path("/bar"))
assert result == "home.foo.test_foo"
# Importing __init__.py files should return the package as module name.
result = module_name_from_path(tmp_path / "src/app/__init__.py", tmp_path)
assert result == "src.app"
# Unless __init__.py file is at the root, in which case we cannot have an empty module name.
result = module_name_from_path(tmp_path / "__init__.py", tmp_path)
assert result == "__init__"
# Modules which start with "." are considered relative and will not be imported
# unless part of a package, so we replace it with a "_" when generating the fake module name.
result = module_name_from_path(tmp_path / ".env/tests/test_foo.py", tmp_path)
assert result == "_env.tests.test_foo"
# We want to avoid generating extra intermediate modules if some directory just happens
# to contain a "." in the name.
result = module_name_from_path(
tmp_path / ".env.310/tests/test_foo.py", tmp_path
)
assert result == "_env_310.tests.test_foo"
def test_resolve_pkg_root_and_module_name(
self, tmp_path: Path, monkeypatch: MonkeyPatch, pytester: Pytester
) -> None:
# Create a directory structure first without __init__.py files.
(tmp_path / "src/app/core").mkdir(parents=True)
models_py = tmp_path / "src/app/core/models.py"
models_py.touch()
with pytest.raises(CouldNotResolvePathError):
_ = resolve_pkg_root_and_module_name(models_py)
# Create the __init__.py files, it should now resolve to a proper module name.
(tmp_path / "src/app/__init__.py").touch()
(tmp_path / "src/app/core/__init__.py").touch()
assert resolve_pkg_root_and_module_name(
models_py, consider_namespace_packages=True
) == (
tmp_path / "src",
"app.core.models",
)
# If we add tmp_path to sys.path, src becomes a namespace package.
monkeypatch.syspath_prepend(tmp_path)
validate_namespace_package(pytester, [tmp_path], ["src.app.core.models"])
assert resolve_pkg_root_and_module_name(
models_py, consider_namespace_packages=True
) == (
tmp_path,
"src.app.core.models",
)
assert resolve_pkg_root_and_module_name(
models_py, consider_namespace_packages=False
) == (
tmp_path / "src",
"app.core.models",
)
def test_insert_missing_modules(
self, monkeypatch: MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
# Use 'xxx' and 'xxy' as parent names as they are unlikely to exist and
# don't end up being imported.
modules = {"xxx.tests.foo": ModuleType("xxx.tests.foo")}
insert_missing_modules(modules, "xxx.tests.foo")
assert sorted(modules) == ["xxx", "xxx.tests", "xxx.tests.foo"]
mod = ModuleType("mod", doc="My Module")
modules = {"xxy": mod}
insert_missing_modules(modules, "xxy")
assert modules == {"xxy": mod}
modules = {}
insert_missing_modules(modules, "")
assert modules == {}
def test_parent_contains_child_module_attribute(
self, monkeypatch: MonkeyPatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
# Use 'xxx' and 'xxy' as parent names as they are unlikely to exist and
# don't end up being imported.
modules = {"xxx.tests.foo": ModuleType("xxx.tests.foo")}
insert_missing_modules(modules, "xxx.tests.foo")
assert sorted(modules) == ["xxx", "xxx.tests", "xxx.tests.foo"]
assert modules["xxx"].tests is modules["xxx.tests"]
assert modules["xxx.tests"].foo is modules["xxx.tests.foo"]
def test_importlib_package(
self, monkeypatch: MonkeyPatch, tmp_path: Path, ns_param: bool
):
"""
Importing a package using --importmode=importlib should not import the
package's __init__.py file more than once (#11306).
"""
monkeypatch.chdir(tmp_path)
monkeypatch.syspath_prepend(tmp_path)
package_name = "importlib_import_package"
tmp_path.joinpath(package_name).mkdir()
init = tmp_path.joinpath(f"{package_name}/__init__.py")
init.write_text(
dedent(
"""
from .singleton import Singleton
instance = Singleton()
"""
),
encoding="ascii",
)
singleton = tmp_path.joinpath(f"{package_name}/singleton.py")
singleton.write_text(
dedent(
"""
class Singleton:
INSTANCES = []
def __init__(self) -> None:
self.INSTANCES.append(self)
if len(self.INSTANCES) > 1:
raise RuntimeError("Already initialized")
"""
),
encoding="ascii",
)
mod = import_path(
init,
root=tmp_path,
mode=ImportMode.importlib,
consider_namespace_packages=ns_param,
)
assert len(mod.instance.INSTANCES) == 1
# Ensure we do not import the same module again (#11475).
mod2 = import_path(
init,
root=tmp_path,
mode=ImportMode.importlib,
consider_namespace_packages=ns_param,
)
assert mod is mod2
def test_importlib_root_is_package(self, pytester: Pytester) -> None:
"""
Regression for importing a `__init__`.py file that is at the root
(#11417).
"""
pytester.makepyfile(__init__="")
pytester.makepyfile(
"""
def test_my_test():
assert True
"""
)
result = pytester.runpytest("--import-mode=importlib")
result.stdout.fnmatch_lines("* 1 passed *")
def create_installed_doctests_and_tests_dir(
self, path: Path, monkeypatch: MonkeyPatch
) -> Tuple[Path, Path, Path]:
"""
Create a directory structure where the application code is installed in a virtual environment,
and the tests are in an outside ".tests" directory.
Return the paths to the core module (installed in the virtualenv), and the test modules.
"""
app = path / "src/app"
app.mkdir(parents=True)
(app / "__init__.py").touch()
core_py = app / "core.py"
core_py.write_text(
dedent(
"""
def foo():
'''
>>> 1 + 1
2
'''
"""
),
encoding="ascii",
)
# Install it into a site-packages directory, and add it to sys.path, mimicking what
# happens when installing into a virtualenv.
site_packages = path / ".env/lib/site-packages"
site_packages.mkdir(parents=True)
shutil.copytree(app, site_packages / "app")
assert (site_packages / "app/core.py").is_file()
monkeypatch.syspath_prepend(site_packages)
# Create the tests files, outside 'src' and the virtualenv.
# We use the same test name on purpose, but in different directories, to ensure
# this works as advertised.
conftest_path1 = path / ".tests/a/conftest.py"
conftest_path1.parent.mkdir(parents=True)
conftest_path1.write_text(
dedent(
"""
import pytest
@pytest.fixture
def a_fix(): return "a"
"""
),
encoding="ascii",
)
test_path1 = path / ".tests/a/test_core.py"
test_path1.write_text(
dedent(
"""
import app.core
def test(a_fix):
assert a_fix == "a"
""",
),
encoding="ascii",
)
conftest_path2 = path / ".tests/b/conftest.py"
conftest_path2.parent.mkdir(parents=True)
conftest_path2.write_text(
dedent(
"""
import pytest
@pytest.fixture
def b_fix(): return "b"
"""
),
encoding="ascii",
)
test_path2 = path / ".tests/b/test_core.py"
test_path2.write_text(
dedent(
"""
import app.core
def test(b_fix):
assert b_fix == "b"
""",
),
encoding="ascii",
)
return (site_packages / "app/core.py"), test_path1, test_path2
def test_import_using_normal_mechanism_first(
self, monkeypatch: MonkeyPatch, pytester: Pytester, ns_param: bool
) -> None:
"""
Test import_path imports from the canonical location when possible first, only
falling back to its normal flow when the module being imported is not reachable via sys.path (#11475).
"""
core_py, test_path1, test_path2 = self.create_installed_doctests_and_tests_dir(
pytester.path, monkeypatch
)
# core_py is reached from sys.path, so should be imported normally.
mod = import_path(
core_py,
mode="importlib",
root=pytester.path,
consider_namespace_packages=ns_param,
)
assert mod.__name__ == "app.core"
assert mod.__file__ and Path(mod.__file__) == core_py
# Ensure we do not import the same module again (#11475).
mod2 = import_path(
core_py,
mode="importlib",
root=pytester.path,
consider_namespace_packages=ns_param,
)
assert mod is mod2
# tests are not reachable from sys.path, so they are imported as a standalone modules.
# Instead of '.tests.a.test_core', we import as "_tests.a.test_core" because
# importlib considers module names starting with '.' to be local imports.
mod = import_path(
test_path1,
mode="importlib",
root=pytester.path,
consider_namespace_packages=ns_param,
)
assert mod.__name__ == "_tests.a.test_core"
# Ensure we do not import the same module again (#11475).
mod2 = import_path(
test_path1,
mode="importlib",
root=pytester.path,
consider_namespace_packages=ns_param,
)
assert mod is mod2
mod = import_path(
test_path2,
mode="importlib",
root=pytester.path,
consider_namespace_packages=ns_param,
)
assert mod.__name__ == "_tests.b.test_core"
# Ensure we do not import the same module again (#11475).
mod2 = import_path(
test_path2,
mode="importlib",
root=pytester.path,
consider_namespace_packages=ns_param,
)
assert mod is mod2
def test_import_using_normal_mechanism_first_integration(
self, monkeypatch: MonkeyPatch, pytester: Pytester, ns_param: bool
) -> None:
"""
Same test as above, but verify the behavior calling pytest.
We should not make this call in the same test as above, as the modules have already
been imported by separate import_path() calls.
"""
core_py, test_path1, test_path2 = self.create_installed_doctests_and_tests_dir(
pytester.path, monkeypatch
)
result = pytester.runpytest(
"--import-mode=importlib",
"-o",
f"consider_namespace_packages={ns_param}",
"--doctest-modules",
"--pyargs",
"app",
"./.tests",
)
result.stdout.fnmatch_lines(
[
f"{core_py.relative_to(pytester.path)} . *",
f"{test_path1.relative_to(pytester.path)} . *",
f"{test_path2.relative_to(pytester.path)} . *",
"* 3 passed*",
]
)
def test_import_path_imports_correct_file(
self, pytester: Pytester, ns_param: bool
) -> None:
"""
Import the module by the given path, even if other module with the same name
is reachable from sys.path.
"""
pytester.syspathinsert()
# Create a 'x.py' module reachable from sys.path that raises AssertionError
# if imported.
x_at_root = pytester.path / "x.py"
x_at_root.write_text("raise AssertionError('x at root')", encoding="ascii")
# Create another x.py module, but in some subdirectories to ensure it is not
# accessible from sys.path.
x_in_sub_folder = pytester.path / "a/b/x.py"
x_in_sub_folder.parent.mkdir(parents=True)
x_in_sub_folder.write_text("X = 'a/b/x'", encoding="ascii")
# Import our x.py module from the subdirectories.
# The 'x.py' module from sys.path was not imported for sure because
# otherwise we would get an AssertionError.
mod = import_path(
x_in_sub_folder,
mode=ImportMode.importlib,
root=pytester.path,
consider_namespace_packages=ns_param,
)
assert mod.__file__ and Path(mod.__file__) == x_in_sub_folder
assert mod.X == "a/b/x"
mod2 = import_path(
x_in_sub_folder,
mode=ImportMode.importlib,
root=pytester.path,
consider_namespace_packages=ns_param,
)
assert mod is mod2
# Attempt to import root 'x.py'.
with pytest.raises(AssertionError, match="x at root"):
_ = import_path(
x_at_root,
mode=ImportMode.importlib,
root=pytester.path,
consider_namespace_packages=ns_param,
)
def test_safe_exists(tmp_path: Path) -> None:
d = tmp_path.joinpath("some_dir")
d.mkdir()
assert safe_exists(d) is True
f = tmp_path.joinpath("some_file")
f.touch()
assert safe_exists(f) is True
# Use unittest.mock() as a context manager to have a very narrow
# patch lifetime.
p = tmp_path.joinpath("some long filename" * 100)
with unittest.mock.patch.object(
Path,
"exists",
autospec=True,
side_effect=OSError(errno.ENAMETOOLONG, "name too long"),
):
assert safe_exists(p) is False
with unittest.mock.patch.object(
Path,
"exists",
autospec=True,
side_effect=ValueError("name too long"),
):
assert safe_exists(p) is False
class TestNamespacePackages:
"""Test import_path support when importing from properly namespace packages."""
@pytest.fixture(autouse=True)
def setup_imports_tracking(self, monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr(sys, "pytest_namespace_packages_test", [], raising=False)
def setup_directories(
self, tmp_path: Path, monkeypatch: Optional[MonkeyPatch], pytester: Pytester
) -> Tuple[Path, Path]:
# Use a code to guard against modules being imported more than once.
# This is a safeguard in case future changes break this invariant.
code = dedent(
"""
import sys
imported = getattr(sys, "pytest_namespace_packages_test", [])
assert __name__ not in imported, f"{__name__} already imported"
imported.append(__name__)
sys.pytest_namespace_packages_test = imported
"""
)
# Set up a namespace package "com.company", containing
# two subpackages, "app" and "calc".
(tmp_path / "src/dist1/com/company/app/core").mkdir(parents=True)
(tmp_path / "src/dist1/com/company/app/__init__.py").write_text(
code, encoding="UTF-8"
)
(tmp_path / "src/dist1/com/company/app/core/__init__.py").write_text(
code, encoding="UTF-8"
)
models_py = tmp_path / "src/dist1/com/company/app/core/models.py"
models_py.touch()
(tmp_path / "src/dist2/com/company/calc/algo").mkdir(parents=True)
(tmp_path / "src/dist2/com/company/calc/__init__.py").write_text(
code, encoding="UTF-8"
)
(tmp_path / "src/dist2/com/company/calc/algo/__init__.py").write_text(
code, encoding="UTF-8"
)
algorithms_py = tmp_path / "src/dist2/com/company/calc/algo/algorithms.py"
algorithms_py.write_text(code, encoding="UTF-8")
r = validate_namespace_package(
pytester,
[tmp_path / "src/dist1", tmp_path / "src/dist2"],
["com.company.app.core.models", "com.company.calc.algo.algorithms"],
)
assert r.ret == 0
if monkeypatch is not None:
monkeypatch.syspath_prepend(tmp_path / "src/dist1")
monkeypatch.syspath_prepend(tmp_path / "src/dist2")
return models_py, algorithms_py
@pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"])
def test_resolve_pkg_root_and_module_name_ns_multiple_levels(
self,
tmp_path: Path,
monkeypatch: MonkeyPatch,
pytester: Pytester,
import_mode: str,
) -> None:
models_py, algorithms_py = self.setup_directories(
tmp_path, monkeypatch, pytester
)
pkg_root, module_name = resolve_pkg_root_and_module_name(
models_py, consider_namespace_packages=True
)
assert (pkg_root, module_name) == (
tmp_path / "src/dist1",
"com.company.app.core.models",
)
mod = import_path(
models_py, mode=import_mode, root=tmp_path, consider_namespace_packages=True
)
assert mod.__name__ == "com.company.app.core.models"
assert mod.__file__ == str(models_py)
# Ensure we do not import the same module again (#11475).
mod2 = import_path(
models_py, mode=import_mode, root=tmp_path, consider_namespace_packages=True
)
assert mod is mod2
pkg_root, module_name = resolve_pkg_root_and_module_name(
algorithms_py, consider_namespace_packages=True
)
assert (pkg_root, module_name) == (
tmp_path / "src/dist2",
"com.company.calc.algo.algorithms",
)
mod = import_path(
algorithms_py,
mode=import_mode,
root=tmp_path,
consider_namespace_packages=True,
)
assert mod.__name__ == "com.company.calc.algo.algorithms"
assert mod.__file__ == str(algorithms_py)
# Ensure we do not import the same module again (#11475).
mod2 = import_path(
algorithms_py,
mode=import_mode,
root=tmp_path,
consider_namespace_packages=True,
)
assert mod is mod2
@pytest.mark.parametrize("import_mode", ["prepend", "append", "importlib"])
def test_incorrect_namespace_package(
self,
tmp_path: Path,
monkeypatch: MonkeyPatch,
pytester: Pytester,
import_mode: str,
) -> None:
models_py, algorithms_py = self.setup_directories(
tmp_path, monkeypatch, pytester
)
# Namespace packages must not have an __init__.py at its top-level
# directory; if it does, it is no longer a namespace package, and we fall back
# to importing just the part of the package containing the __init__.py files.
(tmp_path / "src/dist1/com/__init__.py").touch()
# Because of the __init__ file, 'com' is no longer a namespace package:
# 'com.company.app' is importable as a normal module.
# 'com.company.calc' is no longer importable because 'com' is not a namespace package anymore.
r = validate_namespace_package(
pytester,
[tmp_path / "src/dist1", tmp_path / "src/dist2"],
["com.company.app.core.models", "com.company.calc.algo.algorithms"],
)
assert r.ret == 1
r.stderr.fnmatch_lines("*No module named 'com.company.calc*")
pkg_root, module_name = resolve_pkg_root_and_module_name(
models_py, consider_namespace_packages=True
)
assert (pkg_root, module_name) == (
tmp_path / "src/dist1",
"com.company.app.core.models",
)
# dist2/com/company will contain a normal Python package.
pkg_root, module_name = resolve_pkg_root_and_module_name(
algorithms_py, consider_namespace_packages=True
)
assert (pkg_root, module_name) == (
tmp_path / "src/dist2/com/company",
"calc.algo.algorithms",
)
def test_detect_meta_path(
self,
tmp_path: Path,
monkeypatch: MonkeyPatch,
pytester: Pytester,
) -> None:
"""
resolve_pkg_root_and_module_name() considers sys.meta_path when importing namespace packages.
Regression test for #12112.
"""
class CustomImporter(importlib.abc.MetaPathFinder):
"""
Imports the module name "com" as a namespace package.
This ensures our namespace detection considers sys.meta_path, which is important
to support all possible ways a module can be imported (for example editable installs).
"""
def find_spec(
self, name: str, path: Any = None, target: Any = None
) -> Optional[importlib.machinery.ModuleSpec]:
if name == "com":
spec = importlib.machinery.ModuleSpec("com", loader=None)
spec.submodule_search_locations = [str(com_root_2), str(com_root_1)]
return spec
return None
# Setup directories without configuring sys.path.
models_py, algorithms_py = self.setup_directories(
tmp_path, monkeypatch=None, pytester=pytester
)
com_root_1 = tmp_path / "src/dist1/com"
com_root_2 = tmp_path / "src/dist2/com"
# Because the namespace package is not setup correctly, we cannot resolve it as a namespace package.
pkg_root, module_name = resolve_pkg_root_and_module_name(
models_py, consider_namespace_packages=True
)
assert (pkg_root, module_name) == (
tmp_path / "src/dist1/com/company",
"app.core.models",
)
# Insert our custom importer, which will recognize the "com" directory as a namespace package.
new_meta_path = [CustomImporter(), *sys.meta_path]
monkeypatch.setattr(sys, "meta_path", new_meta_path)
# Now we should be able to resolve the path as namespace package.
pkg_root, module_name = resolve_pkg_root_and_module_name(
models_py, consider_namespace_packages=True
)
assert (pkg_root, module_name) == (
tmp_path / "src/dist1",
"com.company.app.core.models",
)
@pytest.mark.parametrize("insert", [True, False])
def test_full_ns_packages_without_init_files(
self, pytester: Pytester, tmp_path: Path, monkeypatch: MonkeyPatch, insert: bool
) -> None:
(tmp_path / "src/dist1/ns/b/app/bar/test").mkdir(parents=True)
(tmp_path / "src/dist1/ns/b/app/bar/m.py").touch()
if insert:
# The presence of this __init__.py is not a problem, ns.b.app is still part of the namespace package.
(tmp_path / "src/dist1/ns/b/app/__init__.py").touch()
(tmp_path / "src/dist2/ns/a/core/foo/test").mkdir(parents=True)
(tmp_path / "src/dist2/ns/a/core/foo/m.py").touch()
# Validate the namespace package by importing it in a Python subprocess.
r = validate_namespace_package(
pytester,
[tmp_path / "src/dist1", tmp_path / "src/dist2"],
["ns.b.app.bar.m", "ns.a.core.foo.m"],
)
assert r.ret == 0
monkeypatch.syspath_prepend(tmp_path / "src/dist1")
monkeypatch.syspath_prepend(tmp_path / "src/dist2")
assert resolve_pkg_root_and_module_name(
tmp_path / "src/dist1/ns/b/app/bar/m.py", consider_namespace_packages=True
) == (tmp_path / "src/dist1", "ns.b.app.bar.m")
assert resolve_pkg_root_and_module_name(
tmp_path / "src/dist2/ns/a/core/foo/m.py", consider_namespace_packages=True
) == (tmp_path / "src/dist2", "ns.a.core.foo.m")
def test_is_importable(pytester: Pytester) -> None:
pytester.syspathinsert()
path = pytester.path / "bar/foo.py"
path.parent.mkdir()
path.touch()
assert is_importable("bar.foo", path) is True
# Ensure that the module that can be imported points to the path we expect.
path = pytester.path / "some/other/path/bar/foo.py"
path.mkdir(parents=True, exist_ok=True)
assert is_importable("bar.foo", path) is False
# Paths containing "." cannot be imported.
path = pytester.path / "bar.x/__init__.py"
path.parent.mkdir()
path.touch()
assert is_importable("bar.x", path) is False
# Pass starting with "." denote relative imports and cannot be checked using is_importable.
path = pytester.path / ".bar.x/__init__.py"
path.parent.mkdir()
path.touch()
assert is_importable(".bar.x", path) is False
def test_compute_module_name(tmp_path: Path) -> None:
assert compute_module_name(tmp_path, tmp_path) is None
assert compute_module_name(Path(), Path()) is None
assert compute_module_name(tmp_path, tmp_path / "mod.py") == "mod"
assert compute_module_name(tmp_path, tmp_path / "src/app/bar") == "src.app.bar"
assert compute_module_name(tmp_path, tmp_path / "src/app/bar.py") == "src.app.bar"
assert (
compute_module_name(tmp_path, tmp_path / "src/app/bar/__init__.py")
== "src.app.bar"
)
def validate_namespace_package(
pytester: Pytester, paths: Sequence[Path], modules: Sequence[str]
) -> RunResult:
"""
Validate that a Python namespace package is set up correctly.
In a sub interpreter, add 'paths' to sys.path and attempt to import the given modules.
In this module many tests configure a set of files as a namespace package, this function
is used as sanity check that our files are configured correctly from the point of view of Python.
"""
lines = [
"import sys",
# Configure sys.path.
*[f"sys.path.append(r{str(x)!r})" for x in paths],
# Imports.
*[f"import {x}" for x in modules],
]
return pytester.runpython_c("\n".join(lines))