Skip to content

Commit

Permalink
Merge pull request #11997 from nicoddemus/11475-importlib
Browse files Browse the repository at this point in the history
Change importlib to first try to import modules using the standard mechanism
  • Loading branch information
nicoddemus authored Mar 3, 2024
2 parents 8248946 + d6134bc commit 89ee449
Show file tree
Hide file tree
Showing 16 changed files with 920 additions and 152 deletions.
3 changes: 3 additions & 0 deletions changelog/11475.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added the new :confval:`consider_namespace_packages` configuration option, defaulting to ``False``.

If set to ``True``, pytest will attempt to identify modules that are part of `namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages>`__ when importing modules.
3 changes: 3 additions & 0 deletions changelog/11475.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:ref:`--import-mode=importlib <import-mode-importlib>` now tries to import modules using the standard import mechanism (but still without changing :py:data:`sys.path`), falling back to importing modules directly only if that fails.

This means that installed packages will be imported under their canonical name if possible first, for example ``app.core.models``, instead of having the module name always be derived from their path (for example ``.env310.lib.site_packages.app.core.models``).
6 changes: 4 additions & 2 deletions doc/en/explanation/goodpractices.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ Within Python modules, ``pytest`` also discovers tests using the standard
:ref:`unittest.TestCase <unittest.TestCase>` subclassing technique.


Choosing a test layout / import rules
-------------------------------------
.. _`test layout`:

Choosing a test layout
----------------------

``pytest`` supports two common test layouts:

Expand Down
90 changes: 72 additions & 18 deletions doc/en/explanation/pythonpath.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,27 @@ Import modes

pytest as a testing framework needs to import test modules and ``conftest.py`` files for execution.

Importing files in Python (at least until recently) is a non-trivial processes, often requiring
changing :data:`sys.path`. Some aspects of the
Importing files in Python is a non-trivial processes, so aspects of the
import process can be controlled through the ``--import-mode`` command-line flag, which can assume
these values:

.. _`import-mode-prepend`:

* ``prepend`` (default): the directory path containing each module will be inserted into the *beginning*
of :py:data:`sys.path` if not already there, and then imported with the :func:`importlib.import_module <importlib.import_module>` function.
of :py:data:`sys.path` if not already there, and then imported with
the :func:`importlib.import_module <importlib.import_module>` function.

It is highly recommended to arrange your test modules as packages by adding ``__init__.py`` files to your directories
containing tests. This will make the tests part of a proper Python package, allowing pytest to resolve their full
name (for example ``tests.core.test_core`` for ``test_core.py`` inside the ``tests.core`` package).

This requires test module names to be unique when the test directory tree is not arranged in
packages, because the modules will put in :py:data:`sys.modules` after importing.
If the test directory tree is not arranged as packages, then each test file needs to have a unique name
compared to the other test files, otherwise pytest will raise an error if it finds two tests with the same name.

This is the classic mechanism, dating back from the time Python 2 was still supported.

.. _`import-mode-append`:

* ``append``: the directory containing each module is appended to the end of :py:data:`sys.path` if not already
there, and imported with :func:`importlib.import_module <importlib.import_module>`.

Expand All @@ -38,32 +46,78 @@ these values:
the tests will run against the installed version
of ``pkg_under_test`` when ``--import-mode=append`` is used whereas
with ``prepend`` they would pick up the local version. This kind of confusion is why
we advocate for using :ref:`src <src-layout>` layouts.
we advocate for using :ref:`src-layouts <src-layout>`.

Same as ``prepend``, requires test module names to be unique when the test directory tree is
not arranged in packages, because the modules will put in :py:data:`sys.modules` after importing.

* ``importlib``: new in pytest-6.0, this mode uses more fine control mechanisms provided by :mod:`importlib` to import test modules. This gives full control over the import process, and doesn't require changing :py:data:`sys.path`.
.. _`import-mode-importlib`:

* ``importlib``: this mode uses more fine control mechanisms provided by :mod:`importlib` to import test modules, without changing :py:data:`sys.path`.

Advantages of this mode:

* pytest will not change :py:data:`sys.path` at all.
* Test module names do not need to be unique -- pytest will generate a unique name automatically based on the ``rootdir``.

Disadvantages:

* Test modules can't import each other.
* Testing utility modules in the tests directories (for example a ``tests.helpers`` module containing test-related functions/classes)
are not importable. The recommendation in this case it to place testing utility modules together with the application/library
code, for example ``app.testing.helpers``.

Important: by "test utility modules" we mean functions/classes which are imported by
other tests directly; this does not include fixtures, which should be placed in ``conftest.py`` files, along
with the test modules, and are discovered automatically by pytest.

It works like this:

1. Given a certain module path, for example ``tests/core/test_models.py``, derives a canonical name
like ``tests.core.test_models`` and tries to import it.

For this reason this doesn't require test module names to be unique.
For non-test modules this will work if they are accessible via :py:data:`sys.path`, so
for example ``.env/lib/site-packages/app/core.py`` will be importable as ``app.core``.
This is happens when plugins import non-test modules (for example doctesting).

One drawback however is that test modules are non-importable by each other. Also, utility
modules in the tests directories are not automatically importable because the tests directory is no longer
added to :py:data:`sys.path`.
If this step succeeds, the module is returned.

Initially we intended to make ``importlib`` the default in future releases, however it is clear now that
it has its own set of drawbacks so the default will remain ``prepend`` for the foreseeable future.
For test modules, unless they are reachable from :py:data:`sys.path`, this step will fail.

2. If the previous step fails, we import the module directly using ``importlib`` facilities, which lets us import it without
changing :py:data:`sys.path`.

Because Python requires the module to also be available in :py:data:`sys.modules`, pytest derives a unique name for it based
on its relative location from the ``rootdir``, and adds the module to :py:data:`sys.modules`.

For example, ``tests/core/test_models.py`` will end up being imported as the module ``tests.core.test_models``.

.. versionadded:: 6.0

.. note::

Initially we intended to make ``importlib`` the default in future releases, however it is clear now that
it has its own set of drawbacks so the default will remain ``prepend`` for the foreseeable future.

.. note::

By default, pytest will not attempt to resolve namespace packages automatically, but that can
be changed via the :confval:`consider_namespace_packages` configuration variable.

.. seealso::

The :confval:`pythonpath` configuration variable.

The :confval:`consider_namespace_packages` configuration variable.

:ref:`test layout`.


``prepend`` and ``append`` import modes scenarios
-------------------------------------------------

Here's a list of scenarios when using ``prepend`` or ``append`` import modes where pytest needs to
change ``sys.path`` in order to import test modules or ``conftest.py`` files, and the issues users
change :py:data:`sys.path` in order to import test modules or ``conftest.py`` files, and the issues users
might encounter because of that.

Test modules / ``conftest.py`` files inside packages
Expand Down Expand Up @@ -92,7 +146,7 @@ pytest will find ``foo/bar/tests/test_foo.py`` and realize it is part of a packa
there's an ``__init__.py`` file in the same folder. It will then search upwards until it can find the
last folder which still contains an ``__init__.py`` file in order to find the package *root* (in
this case ``foo/``). To load the module, it will insert ``root/`` to the front of
``sys.path`` (if not there already) in order to load
:py:data:`sys.path` (if not there already) in order to load
``test_foo.py`` as the *module* ``foo.bar.tests.test_foo``.

The same logic applies to the ``conftest.py`` file: it will be imported as ``foo.conftest`` module.
Expand Down Expand Up @@ -122,8 +176,8 @@ When executing:
pytest will find ``foo/bar/tests/test_foo.py`` and realize it is NOT part of a package given that
there's no ``__init__.py`` file in the same folder. It will then add ``root/foo/bar/tests`` to
``sys.path`` in order to import ``test_foo.py`` as the *module* ``test_foo``. The same is done
with the ``conftest.py`` file by adding ``root/foo`` to ``sys.path`` to import it as ``conftest``.
:py:data:`sys.path` in order to import ``test_foo.py`` as the *module* ``test_foo``. The same is done
with the ``conftest.py`` file by adding ``root/foo`` to :py:data:`sys.path` to import it as ``conftest``.

For this reason this layout cannot have test modules with the same name, as they all will be
imported in the global import namespace.
Expand All @@ -136,7 +190,7 @@ Invoking ``pytest`` versus ``python -m pytest``
-----------------------------------------------

Running pytest with ``pytest [...]`` instead of ``python -m pytest [...]`` yields nearly
equivalent behaviour, except that the latter will add the current directory to ``sys.path``, which
equivalent behaviour, except that the latter will add the current directory to :py:data:`sys.path`, which
is standard ``python`` behavior.

See also :ref:`invoke-python`.
13 changes: 13 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,19 @@ passed multiple times. The expected format is ``name=value``. For example::
variables, that will be expanded. For more information about cache plugin
please refer to :ref:`cache_provider`.

.. confval:: consider_namespace_packages

Controls if pytest should attempt to identify `namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages>`__
when collecting Python modules. Default is ``False``.

Set to ``True`` if you are testing namespace packages installed into a virtual environment and it is important for
your packages to be imported using their full namespace package name.

Only `native namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#native-namespace-packages>`__
are supported, with no plans to support `legacy namespace packages <https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#legacy-namespace-packages>`__.

.. versionadded:: 8.1

.. confval:: console_output_style

Sets the console output style while running tests:
Expand Down
68 changes: 59 additions & 9 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,8 @@ def _set_initial_conftests(
confcutdir: Optional[Path],
invocation_dir: Path,
importmode: Union[ImportMode, str],
*,
consider_namespace_packages: bool,
) -> None:
"""Load initial conftest files given a preparsed "namespace".
Expand All @@ -572,10 +574,20 @@ def _set_initial_conftests(
# Ensure we do not break if what appears to be an anchor
# is in fact a very long option (#10169, #11394).
if safe_exists(anchor):
self._try_load_conftest(anchor, importmode, rootpath)
self._try_load_conftest(
anchor,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)
foundanchor = True
if not foundanchor:
self._try_load_conftest(invocation_dir, importmode, rootpath)
self._try_load_conftest(
invocation_dir,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)

def _is_in_confcutdir(self, path: Path) -> bool:
"""Whether to consider the given path to load conftests from."""
Expand All @@ -593,17 +605,37 @@ def _is_in_confcutdir(self, path: Path) -> bool:
return path not in self._confcutdir.parents

def _try_load_conftest(
self, anchor: Path, importmode: Union[str, ImportMode], rootpath: Path
self,
anchor: Path,
importmode: Union[str, ImportMode],
rootpath: Path,
*,
consider_namespace_packages: bool,
) -> None:
self._loadconftestmodules(anchor, importmode, rootpath)
self._loadconftestmodules(
anchor,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)
# let's also consider test* subdirs
if anchor.is_dir():
for x in anchor.glob("test*"):
if x.is_dir():
self._loadconftestmodules(x, importmode, rootpath)
self._loadconftestmodules(
x,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)

def _loadconftestmodules(
self, path: Path, importmode: Union[str, ImportMode], rootpath: Path
self,
path: Path,
importmode: Union[str, ImportMode],
rootpath: Path,
*,
consider_namespace_packages: bool,
) -> None:
if self._noconftest:
return
Expand All @@ -620,7 +652,12 @@ def _loadconftestmodules(
if self._is_in_confcutdir(parent):
conftestpath = parent / "conftest.py"
if conftestpath.is_file():
mod = self._importconftest(conftestpath, importmode, rootpath)
mod = self._importconftest(
conftestpath,
importmode,
rootpath,
consider_namespace_packages=consider_namespace_packages,
)
clist.append(mod)
self._dirpath2confmods[directory] = clist

Expand All @@ -642,7 +679,12 @@ def _rget_with_confmod(
raise KeyError(name)

def _importconftest(
self, conftestpath: Path, importmode: Union[str, ImportMode], rootpath: Path
self,
conftestpath: Path,
importmode: Union[str, ImportMode],
rootpath: Path,
*,
consider_namespace_packages: bool,
) -> types.ModuleType:
conftestpath_plugin_name = str(conftestpath)
existing = self.get_plugin(conftestpath_plugin_name)
Expand All @@ -661,7 +703,12 @@ def _importconftest(
pass

try:
mod = import_path(conftestpath, mode=importmode, root=rootpath)
mod = import_path(
conftestpath,
mode=importmode,
root=rootpath,
consider_namespace_packages=consider_namespace_packages,
)
except Exception as e:
assert e.__traceback__ is not None
raise ConftestImportFailure(conftestpath, cause=e) from e
Expand Down Expand Up @@ -1177,6 +1224,9 @@ def pytest_load_initial_conftests(self, early_config: "Config") -> None:
confcutdir=early_config.known_args_namespace.confcutdir,
invocation_dir=early_config.invocation_params.dir,
importmode=early_config.known_args_namespace.importmode,
consider_namespace_packages=early_config.getini(
"consider_namespace_packages"
),
)

def _initini(self, args: Sequence[str]) -> None:
Expand Down
6 changes: 6 additions & 0 deletions src/_pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,12 @@ def pytest_addoption(parser: Parser) -> None:
help="Prepend/append to sys.path when importing test modules and conftest "
"files. Default: prepend.",
)
parser.addini(
"consider_namespace_packages",
type="bool",
default=False,
help="Consider namespace packages when resolving module names during import",
)

group = parser.getgroup("debugconfig", "test session debugging and configuration")
group.addoption(
Expand Down
Loading

0 comments on commit 89ee449

Please sign in to comment.