Introduce --import-mode=importlib (#7246)
Fix #5821 Co-authored-by: Ran Benita <ran@unusedvar.com>
This commit is contained in:
@@ -1,24 +1,31 @@
|
||||
import atexit
|
||||
import contextlib
|
||||
import fnmatch
|
||||
import importlib.util
|
||||
import itertools
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import uuid
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
from os.path import expanduser
|
||||
from os.path import expandvars
|
||||
from os.path import isabs
|
||||
from os.path import sep
|
||||
from posixpath import sep as posix_sep
|
||||
from types import ModuleType
|
||||
from typing import Iterable
|
||||
from typing import Iterator
|
||||
from typing import Optional
|
||||
from typing import Set
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
import py
|
||||
|
||||
from _pytest.compat import assert_never
|
||||
from _pytest.outcomes import skip
|
||||
from _pytest.warning_types import PytestWarning
|
||||
|
||||
@@ -413,3 +420,134 @@ def symlink_or_skip(src, dst, **kwargs):
|
||||
os.symlink(str(src), str(dst), **kwargs)
|
||||
except OSError as e:
|
||||
skip("symlinks not supported: {}".format(e))
|
||||
|
||||
|
||||
class ImportMode(Enum):
|
||||
"""Possible values for `mode` parameter of `import_path`"""
|
||||
|
||||
prepend = "prepend"
|
||||
append = "append"
|
||||
importlib = "importlib"
|
||||
|
||||
|
||||
class ImportPathMismatchError(ImportError):
|
||||
"""Raised on import_path() if there is a mismatch of __file__'s.
|
||||
|
||||
This can happen when `import_path` is called multiple times with different filenames that has
|
||||
the same basename but reside in packages
|
||||
(for example "/tests1/test_foo.py" and "/tests2/test_foo.py").
|
||||
"""
|
||||
|
||||
|
||||
def import_path(
|
||||
p: Union[str, py.path.local, Path],
|
||||
*,
|
||||
mode: Union[str, ImportMode] = ImportMode.prepend
|
||||
) -> ModuleType:
|
||||
"""
|
||||
Imports and returns a module from the given path, which can be a file (a module) or
|
||||
a directory (a package).
|
||||
|
||||
The import mechanism used is controlled by the `mode` parameter:
|
||||
|
||||
* `mode == ImportMode.prepend`: the directory containing the module (or package, taking
|
||||
`__init__.py` files into account) will be put at the *start* of `sys.path` before
|
||||
being imported with `__import__.
|
||||
|
||||
* `mode == ImportMode.append`: same as `prepend`, but the directory will be appended
|
||||
to the end of `sys.path`, if not already in `sys.path`.
|
||||
|
||||
* `mode == ImportMode.importlib`: uses more fine control mechanisms provided by `importlib`
|
||||
to import the module, which avoids having to use `__import__` and muck with `sys.path`
|
||||
at all. It effectively allows having same-named test modules in different places.
|
||||
|
||||
:raise ImportPathMismatchError: if after importing the given `path` and the module `__file__`
|
||||
are different. Only raised in `prepend` and `append` modes.
|
||||
"""
|
||||
mode = ImportMode(mode)
|
||||
|
||||
path = Path(p)
|
||||
|
||||
if not path.exists():
|
||||
raise ImportError(path)
|
||||
|
||||
if mode is ImportMode.importlib:
|
||||
module_name = path.stem
|
||||
|
||||
for meta_importer in sys.meta_path:
|
||||
spec = meta_importer.find_spec(module_name, [str(path.parent)])
|
||||
if spec is not None:
|
||||
break
|
||||
else:
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(path))
|
||||
|
||||
if spec is None:
|
||||
raise ImportError(
|
||||
"Can't find module {} at location {}".format(module_name, str(path))
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
return mod
|
||||
|
||||
pkg_path = resolve_package_path(path)
|
||||
if pkg_path is not None:
|
||||
pkg_root = pkg_path.parent
|
||||
names = list(path.with_suffix("").relative_to(pkg_root).parts)
|
||||
if names[-1] == "__init__":
|
||||
names.pop()
|
||||
module_name = ".".join(names)
|
||||
else:
|
||||
pkg_root = path.parent
|
||||
module_name = path.stem
|
||||
|
||||
# change sys.path permanently: restoring it at the end of this function would cause surprising
|
||||
# problems because of delayed imports: for example, a conftest.py file imported by this function
|
||||
# might have local imports, which would fail at runtime if we restored sys.path.
|
||||
if mode is ImportMode.append:
|
||||
if str(pkg_root) not in sys.path:
|
||||
sys.path.append(str(pkg_root))
|
||||
elif mode is ImportMode.prepend:
|
||||
if str(pkg_root) != sys.path[0]:
|
||||
sys.path.insert(0, str(pkg_root))
|
||||
else:
|
||||
assert_never(mode)
|
||||
|
||||
importlib.import_module(module_name)
|
||||
|
||||
mod = sys.modules[module_name]
|
||||
if path.name == "__init__.py":
|
||||
return mod
|
||||
|
||||
ignore = os.environ.get("PY_IGNORE_IMPORTMISMATCH", "")
|
||||
if ignore != "1":
|
||||
module_file = mod.__file__
|
||||
if module_file.endswith((".pyc", ".pyo")):
|
||||
module_file = module_file[:-1]
|
||||
if module_file.endswith(os.path.sep + "__init__.py"):
|
||||
module_file = module_file[: -(len(os.path.sep + "__init__.py"))]
|
||||
|
||||
try:
|
||||
is_same = os.path.samefile(str(path), module_file)
|
||||
except FileNotFoundError:
|
||||
is_same = False
|
||||
|
||||
if not is_same:
|
||||
raise ImportPathMismatchError(module_name, module_file, path)
|
||||
|
||||
return mod
|
||||
|
||||
|
||||
def resolve_package_path(path: Path) -> Optional[Path]:
|
||||
"""Return the Python package path by looking for the last
|
||||
directory upwards which still contains an __init__.py.
|
||||
Return None if it can not be determined.
|
||||
"""
|
||||
result = None
|
||||
for parent in itertools.chain((path,), path.parents):
|
||||
if parent.is_dir():
|
||||
if not parent.joinpath("__init__.py").is_file():
|
||||
break
|
||||
if not parent.name.isidentifier():
|
||||
break
|
||||
result = parent
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user