From 1f64f95a7a2a47e68d846dcf2dfade2e03a237f7 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 19 Mar 2023 11:35:46 +0100 Subject: [PATCH 1/5] WIP: throw together a initial halfassed proposal on rootspec import mode --- doc/en/proposals/importmode_root_spec.rst | 71 +++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 doc/en/proposals/importmode_root_spec.rst diff --git a/doc/en/proposals/importmode_root_spec.rst b/doc/en/proposals/importmode_root_spec.rst new file mode 100644 index 00000000000..ed8b3ac2881 --- /dev/null +++ b/doc/en/proposals/importmode_root_spec.rst @@ -0,0 +1,71 @@ +:orphan: + +=================================== +PROPOSAL: Parametrize with fixtures +=================================== + +.. warning:: + + This document outlines a proposal around creating a new import mode that supports pep420 and + supports the dissonance between installed and editable installed + + +Problem +======= + +test module discovery in pytest currently is is pre-pep40 and create pains for users +by adding anything things to sys.path and creating easy conflicts in sys.modules + +Additionally this will not solve the dissonance between doctest tests vs installed modules + +The importlib mode in contrast breaks all kinds of expectations by importing test modules in a magical way +that breaks relative imports and leaves them out of ``sys.modules`` + + +Proposed Solution +================= + +A new import mode take a definition of import roots. + +a import root is either a folder in the worktree that will no be installed, +or a folder that will be installed either normally or editable. + +In any case pytest will collect the file tree in the working directory. + +If normal install us used and the content of the imported file differs from the working directory, +pytest will fail collection with a "ContentMissmatch" error. + +When import roots are specified, nothing will be added to sys.path +instead installable content will rely in installs, and local content will create namespace packages that limit the path of subitems + +everything else will be discovered using normal imports + + + + +.. code-block:: + + # contents of pytest.ini + [pytest] + import-roots = + testing/ local + src/mypkg installed as mypkg + # alterntively + src/ installed + +## todo testcases + +* handle install vs editable install + + * when installed - imported name will differe from name in soruce tree + fail collection when content differs + (optional strictly check all files/missing files) + * when installed editable - import using the right name + + * when not installed: + +* testing folders + * if no __init__.py -> create fake namespace, use it for importing + * if __init__.py -> create normal package, dont change sys.path + +* ensure each folder gets a collection package From 3d54959454b8f3634cbac0fd0e57d2c1c40b2342 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 11:28:08 +0200 Subject: [PATCH 2/5] docs: rewrite import roots proposal against current pytest reality Rename the proposal from importmode_root_spec to import_roots and reposition it as a declarative alternative that replaces --import-mode instead of adding a fourth mode: * update the problem statement for the pytest 8 importlib rework (real module names, sys.modules insertion) and ground it in the modern import system (PEP 420/451/610/660, importlib.metadata) * specify implied roots for simple layouts with a hard failure on ambiguity instead of guessing * specify minimal editable vs real vs stale install classification via PEP 610 direct_url.json, with StaleInstallError on divergence * fix the copy-pasted title and turn the todo notes into a proper test case matrix Co-Authored-By: Claude Fable 5 --- doc/en/proposals/import_roots.rst | 182 ++++++++++++++++++++++ doc/en/proposals/importmode_root_spec.rst | 71 --------- 2 files changed, 182 insertions(+), 71 deletions(-) create mode 100644 doc/en/proposals/import_roots.rst delete mode 100644 doc/en/proposals/importmode_root_spec.rst diff --git a/doc/en/proposals/import_roots.rst b/doc/en/proposals/import_roots.rst new file mode 100644 index 00000000000..b3d7f025324 --- /dev/null +++ b/doc/en/proposals/import_roots.rst @@ -0,0 +1,182 @@ +:orphan: + +====================== +PROPOSAL: Import roots +====================== + +.. warning:: + + This document outlines a proposal for *import roots*: a declarative + replacement for the ``--import-mode`` option that aligns test importing + with the modern Python import system and detects mismatches between the + worktree and installed distributions. + +Problem +------- + +pytest currently offers three import modes, none of which is suitable as a +long-term default: + +* ``prepend`` and ``append`` permanently mutate ``sys.path``. They make the + repository layout dictate importability, allow same-named test modules to + clash, and silently shadow (or get shadowed by) installed distributions — + so it is easy to test a different copy of the code than intended. +* ``importlib`` no longer mutates ``sys.path`` and, since pytest 8, imports + modules under their real name when one can be resolved. However, when + resolution fails it falls back to synthetic module names derived from the + rootdir, and its resolution is *inferred* rather than declared, which keeps + it surprising in non-trivial layouts. + +All three modes share a deeper issue: pytest guesses package roots by walking +up from each file while ``__init__.py`` files are present, anchored on the +rootdir. This inference is the root cause of a long-standing family of +issues: ``ImportPathMismatchError`` between same-named test trees, conftest +modules being imported twice under different names, and the special-casing +needed to keep unrelated ``conftest`` modules from clobbering each other in +``sys.modules``. + +Separately, no mode addresses the dissonance between the worktree and an +installed distribution. When a project is tested against a non-editable +install (for example in a tox environment), a stale install means tests and +doctests may run against code that differs from the files being collected — +with no diagnostic whatsoever. + +Finally, the import machinery that these modes were designed around predates +much of the modern import system. Python has since standardized: + +* namespace packages (:pep:`420`) — packages no longer require + ``__init__.py``, +* spec-based imports (:pep:`451`) — finders, loaders, and + ``ModuleSpec`` as the single source of truth for a module's origin, +* recorded install provenance (:pep:`610`) — ``direct_url.json`` marks + whether a distribution is an editable install, +* standardized editable installs (:pep:`660`), +* ``importlib.metadata`` and ``importlib.resources`` in the standard library + for interrogating installed distributions and their files. + +A modern solution should be expressed in these terms instead of in +``sys.path`` manipulation. + +Proposed solution +----------------- + +Introduce *import roots* as an alternative to import modes — configuring +import roots replaces ``--import-mode`` entirely rather than adding a fourth +mode. + +An import root is a directory in the worktree together with a declaration of +how its content maps onto the import system: + +``local`` + Content that is not distributed (typically test folders). Modules are + imported under names anchored at the root, without any ``sys.path`` + mutation. A folder without ``__init__.py`` becomes a namespace package + whose search path is limited to the root; a folder with ``__init__.py`` + is imported as a regular package. + +``installed`` + The worktree source of a distribution that is installed into the current + environment. Modules are imported under their real, installed name. + pytest classifies the installation (see below) and verifies that what it + collects is what will be imported. + +In all cases pytest collects the file tree of the worktree, never touches +``sys.path``, and resolves imports through the standard spec-based machinery. +Conftest files are imported under proper dotted names derived from their +root, removing the need for ``sys.modules`` special-casing. + +Implied and explicit roots +-------------------------- + +pytest should imply import roots automatically in simple cases, so most +projects need no configuration: + +* an installed (or editable-installed) distribution whose recorded files map + back into the worktree implies an ``installed`` root, +* conventional layouts (``src/`` layout, a ``tests`` folder that is not part + of any distribution) imply their obvious classification. + +When the layout is ambiguous — multiple distributions, overlapping trees, +test folders inside installed packages, or content matching no known +distribution — pytest must not guess. Collection fails with an error that +explains which paths could not be classified and asks for explicit roots: + +.. code-block:: ini + + # contents of pytest.ini + [pytest] + import_roots = + tests local + src/mypkg installed as mypkg + +The configuration syntax shown here is a sketch; the concrete spelling +(including a TOML-native form in ``pyproject.toml``) is an open question. + +Editable versus real versus stale installs +------------------------------------------ + +For ``installed`` roots, pytest performs a minimal classification using +``importlib.metadata`` and :pep:`610` ``direct_url.json``: + +editable install + ``direct_url.json`` marks the distribution as editable. The worktree + itself is the import origin, so collection and import trivially agree. + No content verification is needed. + +real (non-editable) install + The import origin is the installed copy, not the worktree. pytest + verifies that the content of each imported file matches the + corresponding worktree file, and fails collection with a + ``StaleInstallError`` (naming both paths and suggesting a reinstall) + when they differ. An optional strict variant may verify the complete + file set of the distribution, including files missing on either side. + +not installed + A root declared (or implied) as ``installed`` whose distribution cannot + be found fails collection with a clear message, instead of silently + falling back to path-based importing. + +The detection is deliberately minimal: distributions installed through +mechanisms that record no usable provenance are treated best-effort, with an +explicit root declaration as the escape hatch. + +Migration +--------- + +* Configuring import roots and ``--import-mode`` together is an error. +* The long-term goal is for implied import roots to become pytest's default + importing behavior, with the legacy import modes deprecated afterwards — + something none of the existing modes could achieve. +* Collection integrates naturally with the directory collection nodes: + each collected directory belongs to exactly one root, which determines the + module names beneath it. + +Open questions +-------------- + +* the concrete configuration syntax (ini line format versus structured TOML), +* interaction with the ``pythonpath`` ini option, ``--pyargs``, and rootdir, +* which file (worktree or installed copy) appears in tracebacks and reports + for real installs, and how assertion rewriting applies to the installed + origin, +* how much layout inference is acceptable before requiring explicit roots. + +Test cases +---------- + +* real install: imported name differs from the path in the source tree; + collection succeeds when content matches and fails with + ``StaleInstallError`` when it differs; strict mode additionally detects + missing/extra files, +* editable install: modules import under the real name with the worktree as + origin, +* declared ``installed`` root without a matching distribution: collection + fails with a clear message, +* ``local`` root without ``__init__.py``: a namespace package anchored at the + root, with its search path limited to the root, +* ``local`` root with ``__init__.py``: a regular package, still without any + ``sys.path`` mutation, +* ambiguous layouts fail collection with a message naming the unclassified + paths, +* conftest files receive proper dotted module names in every case, +* every collected folder maps to exactly one root. diff --git a/doc/en/proposals/importmode_root_spec.rst b/doc/en/proposals/importmode_root_spec.rst deleted file mode 100644 index ed8b3ac2881..00000000000 --- a/doc/en/proposals/importmode_root_spec.rst +++ /dev/null @@ -1,71 +0,0 @@ -:orphan: - -=================================== -PROPOSAL: Parametrize with fixtures -=================================== - -.. warning:: - - This document outlines a proposal around creating a new import mode that supports pep420 and - supports the dissonance between installed and editable installed - - -Problem -======= - -test module discovery in pytest currently is is pre-pep40 and create pains for users -by adding anything things to sys.path and creating easy conflicts in sys.modules - -Additionally this will not solve the dissonance between doctest tests vs installed modules - -The importlib mode in contrast breaks all kinds of expectations by importing test modules in a magical way -that breaks relative imports and leaves them out of ``sys.modules`` - - -Proposed Solution -================= - -A new import mode take a definition of import roots. - -a import root is either a folder in the worktree that will no be installed, -or a folder that will be installed either normally or editable. - -In any case pytest will collect the file tree in the working directory. - -If normal install us used and the content of the imported file differs from the working directory, -pytest will fail collection with a "ContentMissmatch" error. - -When import roots are specified, nothing will be added to sys.path -instead installable content will rely in installs, and local content will create namespace packages that limit the path of subitems - -everything else will be discovered using normal imports - - - - -.. code-block:: - - # contents of pytest.ini - [pytest] - import-roots = - testing/ local - src/mypkg installed as mypkg - # alterntively - src/ installed - -## todo testcases - -* handle install vs editable install - - * when installed - imported name will differe from name in soruce tree - fail collection when content differs - (optional strictly check all files/missing files) - * when installed editable - import using the right name - - * when not installed: - -* testing folders - * if no __init__.py -> create fake namespace, use it for importing - * if __init__.py -> create normal package, dont change sys.path - -* ensure each folder gets a collection package From c68fbd2997935cf5bdbe0b6c3fa936db4580cbd1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 11:38:10 +0200 Subject: [PATCH 3/5] docs: specify interaction between import roots and testpaths Collection targets must map to exactly one root, testpaths entries act as an inference signal for local roots, and conftest files above every root are recorded as an open naming question. Co-Authored-By: Claude Fable 5 --- doc/en/proposals/import_roots.rst | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/doc/en/proposals/import_roots.rst b/doc/en/proposals/import_roots.rst index b3d7f025324..d111fcb2771 100644 --- a/doc/en/proposals/import_roots.rst +++ b/doc/en/proposals/import_roots.rst @@ -112,6 +112,35 @@ explains which paths could not be classified and asks for explicit roots: The configuration syntax shown here is a sketch; the concrete spelling (including a TOML-native form in ``pyproject.toml``) is an open question. +Interaction with ``testpaths`` +------------------------------ + +``testpaths`` and import roots answer different questions and stay separate: +``testpaths`` selects *what* is collected when no arguments are given, import +roots declare *how* collected files are imported. They interact in defined +ways: + +* every collection target — whether it comes from ``testpaths``, command line + arguments, or full-tree collection — must fall under exactly one import + root. A target that maps to no root is reported with the same + unclassified-path error as any other ambiguity, instead of falling back to + ``sys.path`` guessing. +* ``testpaths`` entries serve as an additional signal for implied roots: an + entry that is not part of any installed distribution implies a ``local`` + root. The common ``testpaths = tests`` layout therefore needs no explicit + root configuration. +* ``testpaths`` may point into an ``installed`` root — for example + ``testpaths = src`` together with ``--doctest-modules``. Collection still + walks the worktree while imports resolve to the installed name, so the + staleness verification applies exactly as for test-driven imports. +* command line arguments outside ``testpaths`` but inside a declared or + implied root behave normally; arguments outside every root fail with the + unclassified-path error. +* conftest files between the rootdir and the ``testpaths`` entries are + loaded today without belonging to any test package; under import roots + these files still participate and need a defined module name (see the + open questions). + Editable versus real versus stale installs ------------------------------------------ @@ -156,6 +185,8 @@ Open questions * the concrete configuration syntax (ini line format versus structured TOML), * interaction with the ``pythonpath`` ini option, ``--pyargs``, and rootdir, +* the module name for conftest files that live above every import root + (for example a ``conftest.py`` next to ``pyproject.toml``), * which file (worktree or installed copy) appears in tracebacks and reports for real installs, and how assertion rewriting applies to the installed origin, From cffbe9a8497ba005b242531ff6af08def57d705e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 17:10:31 +0000 Subject: [PATCH 4/5] docs: add packaged suites proposal alongside import roots Suites shipped inside installed packages declare their parameters in Python (SuiteSpec) and are bound by the consuming project's initial conftests via a hook (pytest_bind_suites). Names are provisional and chosen to avoid the Test* collection trap, unittest.TestSuite and the junit-xml testsuite meaning. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ae6NTCLoJgE5chCkbn19HX --- doc/en/proposals/packaged_suites.rst | 215 +++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 doc/en/proposals/packaged_suites.rst diff --git a/doc/en/proposals/packaged_suites.rst b/doc/en/proposals/packaged_suites.rst new file mode 100644 index 00000000000..1a45e78d26b --- /dev/null +++ b/doc/en/proposals/packaged_suites.rst @@ -0,0 +1,215 @@ +:orphan: + +========================== +PROPOSAL: Packaged suites +========================== + +.. warning:: + + This document outlines a proposal for *packaged suites*: test suites that + ship inside an installed package, declare the parameters they need, and + are bound and run by a consuming project. Names used here are + provisional (see `Naming`_). + +Problem +------- + +Libraries that define an interface often ship a conformance test suite that +every implementation must pass. Today each of them invents its own way to let +an implementation run those tests: + +* pandas extension arrays: subclass ``pandas.tests.extension.base.*Tests`` + and provide the fixtures listed in a conftest file, +* fsspec: subclass ``AbstractFixtures`` and the ``Abstract*Tests`` classes + and override ``fs`` / ``fs_path``, +* SQLAlchemy dialects: star-import ``sqlalchemy.testing.suite`` into a test + module, star-import its pytest plugin into ``conftest.py``, and configure + ``[sqla_testing]`` / ``[db]`` sections in ``setup.cfg``, +* array-api-tests: run the suite's own checkout with an environment variable + naming the library under test. + +All of them copy or import the tests into the consumer's tree, because pytest +has no way to collect a suite from an installed package and hand it +configuration. The consequences: + +* the contract (what the consumer must provide) is documented only in prose + or in a conftest file, and a missing input surfaces per test as + ``fixture 'x' not found``, +* subclass-based opt-in means new tests added to the suite are silently not + run until each consumer adds the new base class, +* a suite cannot ship a placeholder fixture with a helpful error, because a + fixture in the suite's own conftest is more specific than the consumer's + and wins, +* running the same suite twice with different configuration (for example + two backends) needs ad-hoc parametrization tricks, +* node IDs of tests collected from site-packages drop the package path + (``test_basic.py::test_x``), so two installed suites are indistinguishable. + +Proposed solution +----------------- + +A suite is declared in Python, in the package that ships it, and bound by the +consuming project in Python, through a hook implemented in an initial conftest +or plugin. + +Declaring a suite +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # storagelib/tests/conformance/__init__.py + import pytest + + suite = pytest.SuiteSpec( + package=__name__, + doc="Conformance tests for storagelib backends.", + params={ + "backend": pytest.SuiteParam( + "A fresh MutableMapping implementation under test (per test)." + ), + "url": pytest.SuiteParam( + "Connection URL handed to the backend.", default="memory://" + ), + }, + ) + +The tests in the package are ordinary pytest tests. They receive parameters +by requesting fixtures of the same name: + +.. code-block:: python + + # storagelib/tests/conformance/test_basic.py + def test_roundtrip(backend): + backend["k"] = 1 + assert backend["k"] == 1 + +Binding a suite +~~~~~~~~~~~~~~~ + +.. code-block:: python + + # conftest.py of the consuming project (rootdir) + import pytest + from storagelib.tests.conformance import suite as storage_suite + + + @pytest.fixture(params=[DictBackend, DiskBackend], ids=["dict", "disk"]) + def backend_impl(request, tmp_path): + yield request.param(tmp_path) + + + def pytest_bind_suites(config): + return [ + storage_suite.bind(id="mem", backend=backend_impl), + storage_suite.bind(id="sqlite", backend=backend_impl, url="sqlite://"), + ] + +A bound value is either: + +* a plain object, which becomes a constant fixture, or +* a fixture function, which keeps its scope, ``params``, teardown and its own + fixture requests. + +Semantics +--------- + +* ``bind()`` validates the values against the declared parameters + immediately. Unknown or missing parameters raise ``UsageError`` before + collection starts, listing every declared parameter with its doc and + default. +* The hook is called once, before collection, and only implementations in + initial conftests and plugins are honoured. The set of suites therefore + never depends on collection order. +* Each bound suite becomes a collector directly below the session, named + ``[]``. Its test modules get node IDs below it, for example + ``storagelib.tests.conformance[sqlite]/test_basic.py::test_roundtrip[dict]``. +* Bound values are registered as fixtures visible only below that collector. + They are more specific than any conftest fixture of the same name, so + bindings never leak into other tests and a consumer's global fixture never + leaks into the suite. +* ``conftest.py`` files inside the suite package are loaded, scoped to the + suite collector, so suites can have internal helper fixtures. +* Selection works as usual: ``-k``, ``-m``, and node IDs. Command-line + argument resolution learns the ``[]/::`` form. +* Modules are imported once and shared between bindings. Suites must keep + per-binding state in fixtures, not in module globals. +* The suite's package is imported through the normal import system. This + proposal is independent of :doc:`import_roots` (proposed alongside it): + it is meant to work with or without configured roots and with every + ``--import-mode``; the prototype only exercised the default ``prepend``. + With roots configured, a suite's package belongs to an ``installed`` root. + +Prototype results +----------------- + +A prototype plugin (about 100 lines, against pytest main) confirmed: + +* two bindings of one suite plus the project's own tests collect and run + with the node IDs above, and each binding sees its own values, +* a parametrized fixture as a bound value multiplies the suite as expected, +* ``-k "sqlite and url"`` selects within a binding, +* a misspelt parameter fails at startup with the full parameter listing, +* a global fixture with the same name as a parameter does not override the + binding. + +It also showed what core must add: loading suite-internal conftests, +suite-aware node-ID arguments, and public replacements for the private +fixture-registration APIs the prototype used. + +Relation to ensembles +--------------------- + +The experimental ``_pytest.ensemble`` work (#14809) assembles a small pytest +from parts handed to it programmatically. A bound suite is a natural +*source* for an ensemble: a suite author could test their own declaration +hermetically with ``run_tests(suite.bind(id="t", backend=dict))``. +The two features should share vocabulary where they meet and stay distinct +where they don't: ensembles are about how pytest runs, packaged suites are +about what gets collected. + +Naming +------ + +Constraints found while prototyping: + +* any class name matching ``python_classes`` (default ``Test*``) is picked + up by collection when imported into a test module. A ``TestSuite`` + declaration class triggers ``PytestCollectionWarning: cannot collect test + class 'TestSuite' because it has a __init__ constructor``, so ``Test*`` + names are ruled out, +* ``unittest.TestSuite`` already exists and is supported by pytest, +* "testsuite" already means the junit-xml ```` element + (``junit_suite_name``, ``record_testsuite_property``), +* the ensemble work uses ``*Spec`` for frozen declarative data + (``ConfigSpec``) and "source" for things handed in to be collected. + +Provisional choice: ``pytest.SuiteSpec``, ``pytest.SuiteParam``, +``SuiteSpec.bind()`` returning a ``BoundSuite``, and the hook +``pytest_bind_suites(config)``. + +Open questions +-------------- + +* final names (see `Naming`_), +* whether suites may declare required plugins or ini options, and how those + are scoped to the suite collector, +* whether suites should be discoverable (for example + ``pytest --suites`` listing installed declarations through an entry + point), or only ever bound explicitly, +* whether parameters need types or validators beyond ``doc`` and + ``default``. + +Test cases +---------- + +* a declared suite bound once and twice collects with distinct node IDs, +* missing and unknown parameters fail before collection with the parameter + listing, +* plain values and fixture functions both bind, including parametrized and + yield fixtures, +* a consumer fixture with a parameter's name does not reach the suite, and + bound values do not reach the consumer's own tests, +* suite-internal conftest fixtures are visible inside the suite only, +* hook implementations in non-initial conftests are rejected with a clear + error, +* node-ID arguments in the ``[]/...`` form select tests. From dddf9e7b974f20aed2d9cdf5de56420e77b907e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:57:02 +0000 Subject: [PATCH 5/5] docs: update import roots proposal to the agreed design Roots are opt-in (no behaviour change without them), TOML-only, with three kinds (local, mirrored, installed) and a required name. Local roots use declared names with a collision check and no sys.path, aliases or subprocess support. --pyargs resolves through roots; pythonpath is superseded. Implied roots are dropped. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Ae6NTCLoJgE5chCkbn19HX --- doc/en/proposals/import_roots.rst | 220 +++++++++++++++++------------- 1 file changed, 126 insertions(+), 94 deletions(-) diff --git a/doc/en/proposals/import_roots.rst b/doc/en/proposals/import_roots.rst index d111fcb2771..952711d962d 100644 --- a/doc/en/proposals/import_roots.rst +++ b/doc/en/proposals/import_roots.rst @@ -60,91 +60,111 @@ A modern solution should be expressed in these terms instead of in Proposed solution ----------------- -Introduce *import roots* as an alternative to import modes — configuring -import roots replaces ``--import-mode`` entirely rather than adding a fourth -mode. +Introduce *import roots* as an opt-in alternative to import modes. Without +import roots configured, pytest behaves exactly as it does today. With import +roots configured, they replace ``--import-mode`` and the ``pythonpath`` option +entirely; combining either with import roots is a usage error. -An import root is a directory in the worktree together with a declaration of -how its content maps onto the import system: +An import root declares how one part of the world maps onto the import +system. There are three kinds: ``local`` - Content that is not distributed (typically test folders). Modules are - imported under names anchored at the root, without any ``sys.path`` - mutation. A folder without ``__init__.py`` becomes a namespace package - whose search path is limited to the root; a folder with ``__init__.py`` - is imported as a regular package. + Content that is not distributed (typically the project's own test + folder). Modules are imported under the root's declared name, without + any ``sys.path`` mutation, through an importer pytest installs for the + duration of the session. A folder without ``__init__.py`` becomes a + namespace package whose search path is limited to the root; a folder + with ``__init__.py`` is imported as a regular package. + +``mirrored`` + The worktree source of a distribution that is installed into the current + environment. Collection walks the worktree; modules are imported under + their real, installed name. pytest classifies the installation (see + below) and verifies that what it collects is what will be imported. ``installed`` - The worktree source of a distribution that is installed into the current - environment. Modules are imported under their real, installed name. - pytest classifies the installation (see below) and verifies that what it - collects is what will be imported. + An installed distribution with no worktree involved. Collection walks + the installed files and imports them under their real name, for example + to run a test suite shipped inside a package. -In all cases pytest collects the file tree of the worktree, never touches -``sys.path``, and resolves imports through the standard spec-based machinery. Conftest files are imported under proper dotted names derived from their -root, removing the need for ``sys.modules`` special-casing. +root (``.conftest``, ``.sub.conftest``), removing the need for +``sys.modules`` special-casing. -Implied and explicit roots --------------------------- +Configuration +------------- -pytest should imply import roots automatically in simple cases, so most -projects need no configuration: +Import roots are configured in TOML only, as native ``[tool.pytest]`` (or +``pytest.toml``) configuration. There is no ini spelling. Every root +requires a ``name``; there is no default and no inference. -* an installed (or editable-installed) distribution whose recorded files map - back into the worktree implies an ``installed`` root, -* conventional layouts (``src/`` layout, a ``tests`` folder that is not part - of any distribution) imply their obvious classification. +.. code-block:: toml -When the layout is ambiguous — multiple distributions, overlapping trees, -test folders inside installed packages, or content matching no known -distribution — pytest must not guess. Collection fails with an error that -explains which paths could not be classified and asks for explicit roots: + # contents of pyproject.toml + [tool.pytest] + import_roots = [ + { kind = "local", path = "testing", name = "mypkg_testing" }, + { kind = "mirrored", path = "src/mypkg", name = "mypkg" }, + { kind = "installed", name = "otherpkg" }, + ] -.. code-block:: ini +``local`` and ``mirrored`` roots take a ``path`` and a ``name``; ``installed`` +roots take a ``name`` only. - # contents of pytest.ini - [pytest] - import_roots = - tests local - src/mypkg installed as mypkg - -The configuration syntax shown here is a sketch; the concrete spelling -(including a TOML-native form in ``pyproject.toml``) is an open question. +Conventions and collisions +-------------------------- -Interaction with ``testpaths`` ------------------------------- +Import roots rely on conventions and a collision check instead of import +tricks: + +* tests that are not shipped live in a top-level ``testing/`` folder, + declared as a ``local`` root with a project-specific name (for example + ``mypkg_testing``), +* tests that are shipped live in a ``tests`` subpackage of the package + (``mypkg/tests/``) and belong to the package's ``mirrored`` or + ``installed`` root, importing as ``mypkg.tests``, +* a top-level ``tests/`` folder is discouraged under import roots: its name + does not say which of the two it is, and it is the most common colliding + top-level name. + +When roots are set up, every declared top-level name of a ``local`` root is +checked against ``sys.modules``, the import system (excluding pytest's own +importer) and ``importlib.metadata.packages_distributions()``. A clash is a +usage error naming the colliding distribution or module. pytest never +shadows an installed package and is never shadowed by one. + +There are no aliases, no path shims and no implicit subprocess support. +Modules of a ``local`` root are importable only inside the pytest process; +code that a subprocess or an unpickler must import belongs in a ``mirrored`` +or ``installed`` root. + +Interaction with ``testpaths``, ``--pyargs`` and ``pythonpath`` +------------------------------------------------------------------- ``testpaths`` and import roots answer different questions and stay separate: ``testpaths`` selects *what* is collected when no arguments are given, import -roots declare *how* collected files are imported. They interact in defined -ways: - -* every collection target — whether it comes from ``testpaths``, command line - arguments, or full-tree collection — must fall under exactly one import - root. A target that maps to no root is reported with the same - unclassified-path error as any other ambiguity, instead of falling back to - ``sys.path`` guessing. -* ``testpaths`` entries serve as an additional signal for implied roots: an - entry that is not part of any installed distribution implies a ``local`` - root. The common ``testpaths = tests`` layout therefore needs no explicit - root configuration. -* ``testpaths`` may point into an ``installed`` root — for example - ``testpaths = src`` together with ``--doctest-modules``. Collection still +roots declare *how* collected files are imported. + +* every collection target, whether it comes from ``testpaths``, command line + arguments, or full-tree collection, must fall under exactly one import + root. A target outside every root is a usage error. +* ``testpaths`` may point into a ``mirrored`` root (for example + ``testpaths = ["src"]`` together with ``--doctest-modules``). Collection walks the worktree while imports resolve to the installed name, so the staleness verification applies exactly as for test-driven imports. -* command line arguments outside ``testpaths`` but inside a declared or - implied root behave normally; arguments outside every root fail with the - unclassified-path error. -* conftest files between the rootdir and the ``testpaths`` entries are - loaded today without belonging to any test package; under import roots - these files still participate and need a defined module name (see the - open questions). +* ``--pyargs`` arguments are resolved through the configured roots by + longest name prefix, without importing anything. The resolved name is + the name the module is imported under; it is never re-derived from the + path. A name matching no root is a usage error. +* ``pythonpath`` is superseded by ``local`` roots. +* conftest files above every root (for example next to ``pyproject.toml``) + still participate and need a defined module name (see the open + questions). Editable versus real versus stale installs ------------------------------------------ -For ``installed`` roots, pytest performs a minimal classification using +For ``mirrored`` roots, pytest performs a minimal classification using ``importlib.metadata`` and :pep:`610` ``direct_url.json``: editable install @@ -155,59 +175,71 @@ editable install real (non-editable) install The import origin is the installed copy, not the worktree. pytest verifies that the content of each imported file matches the - corresponding worktree file, and fails collection with a - ``StaleInstallError`` (naming both paths and suggesting a reinstall) - when they differ. An optional strict variant may verify the complete - file set of the distribution, including files missing on either side. + corresponding worktree file (for example against the hashes recorded in + ``RECORD``), and fails collection with a ``StaleInstallError`` (naming + both paths and suggesting a reinstall) when they differ. An optional + strict variant may verify the complete file set of the distribution, + including files missing on either side. not installed - A root declared (or implied) as ``installed`` whose distribution cannot - be found fails collection with a clear message, instead of silently - falling back to path-based importing. + A ``mirrored`` or ``installed`` root whose distribution cannot be found + fails collection with a clear message, instead of silently falling back + to path-based importing. -The detection is deliberately minimal: distributions installed through -mechanisms that record no usable provenance are treated best-effort, with an -explicit root declaration as the escape hatch. +Distributions installed through mechanisms that record no usable provenance +are treated best-effort. Migration --------- -* Configuring import roots and ``--import-mode`` together is an error. -* The long-term goal is for implied import roots to become pytest's default - importing behavior, with the legacy import modes deprecated afterwards — - something none of the existing modes could achieve. -* Collection integrates naturally with the directory collection nodes: - each collected directory belongs to exactly one root, which determines the - module names beneath it. +* Without import roots configured, nothing changes: ``--import-mode``, + ``--pyargs``, ``pythonpath`` and ``consider_namespace_packages`` behave as + today. Import roots are not a new default. +* Configuring import roots together with ``--import-mode`` or ``pythonpath`` + is an error. +* Collection integrates with the directory collection nodes: each collected + directory belongs to exactly one root, which determines the module names + beneath it. + +Related proposals +----------------- + +:doc:`packaged_suites` describes how a test suite shipped inside a package +is declared and bound by a consuming project. It is independent of import +roots; with roots configured, such a suite's package is an ``installed`` (or +``mirrored``) root. Open questions -------------- -* the concrete configuration syntax (ini line format versus structured TOML), -* interaction with the ``pythonpath`` ini option, ``--pyargs``, and rootdir, -* the module name for conftest files that live above every import root - (for example a ``conftest.py`` next to ``pyproject.toml``), +* the module name for conftest files that live above every import root, * which file (worktree or installed copy) appears in tracebacks and reports for real installs, and how assertion rewriting applies to the installed origin, -* how much layout inference is acceptable before requiring explicit roots. +* the exact TOML schema (field names, validation messages) and the new + structured option type it needs, since ``addini`` types are currently + scalar or lists of strings. Test cases ---------- -* real install: imported name differs from the path in the source tree; - collection succeeds when content matches and fails with - ``StaleInstallError`` when it differs; strict mode additionally detects - missing/extra files, -* editable install: modules import under the real name with the worktree as - origin, -* declared ``installed`` root without a matching distribution: collection - fails with a clear message, +* no import roots configured: behaviour is identical to today, +* import roots together with ``--import-mode`` or ``pythonpath``: usage + error, +* a root without ``name``: usage error, +* ``mirrored`` real install: collection succeeds when content matches and + fails with ``StaleInstallError`` when it differs; strict mode additionally + detects missing/extra files, +* ``mirrored`` editable install: modules import under the real name with the + worktree as origin, +* ``mirrored`` or ``installed`` root without a matching distribution: + collection fails with a clear message, * ``local`` root without ``__init__.py``: a namespace package anchored at the root, with its search path limited to the root, * ``local`` root with ``__init__.py``: a regular package, still without any ``sys.path`` mutation, -* ambiguous layouts fail collection with a message naming the unclassified - paths, +* a ``local`` root whose name collides with an installed distribution: + usage error naming the distribution, +* ``--pyargs`` resolves through roots without importing parents, * conftest files receive proper dotted module names in every case, * every collected folder maps to exactly one root.