pytest-rhiza 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ """The rhiza repository checks, packaged as a pytest plugin.
2
+
3
+ Two halves, because pytest treats them differently:
4
+
5
+ * :mod:`pytest_rhiza.plugin` is the ``pytest11`` entry point. It contributes the
6
+ ``root``, ``logger`` and ``latest_tag`` fixtures to every session, which is what
7
+ removes ``.rhiza/tests/conftest.py`` from consumer repositories.
8
+ * :mod:`pytest_rhiza.checks` holds the check modules themselves. An entry point cannot
9
+ contribute *tests*, so these are collected explicitly with ``--pyargs``.
10
+
11
+ See ``README.md`` for the make wiring that replaces the synced folder.
12
+ """
13
+
14
+ __all__ = ["__version__"]
15
+
16
+ # Kept in step with [project].version by bump-my-version; no [[files]] entry is needed
17
+ # for pyproject.toml itself, only for this one.
18
+ __version__ = "0.1.0"
@@ -0,0 +1,96 @@
1
+ """Markdown fence parsing shared by the two README checks.
2
+
3
+ **Why this module exists.** Upstream, ``SKIP_FLAG`` and ``_should_skip`` are duplicated
4
+ between ``core``'s ``test_readme.py`` and the ``tests`` bundle's
5
+ ``test_readme_validation.py``, and both files say why::
6
+
7
+ Bundles are copied independently — a Rust project receives this file and not the
8
+ other — so a shared helper would need a third home that both bundles ship, which is
9
+ a worse trade for four lines.
10
+
11
+ One distribution *is* that third home, so the trade reverses. The duplication also had a
12
+ second cost that is easy to miss: the ``TestSkipFlag`` classes exercising these helpers
13
+ shipped into every consumer repository, where they tested template code against itself.
14
+ They now live in this package's own suite instead.
15
+
16
+ The regexes and the flag keep their upstream names so the ported checks read as a move.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import functools
22
+ import re
23
+ import subprocess # nosec B404
24
+
25
+ # Bash code blocks — captures optional flags (e.g. "+RHIZA_SKIP") and the code body.
26
+ BASH_BLOCK = re.compile(r"```bash([^\n]*)\n(.*?)```", re.DOTALL)
27
+
28
+ # Python code blocks, same shape.
29
+ CODE_BLOCK = re.compile(r"```python([^\n]*)\n(.*?)```", re.DOTALL)
30
+
31
+ # The ```result fences a python fence's stdout is diffed against.
32
+ RESULT = re.compile(r"```result\n(.*?)```", re.DOTALL)
33
+
34
+ # Bash executable used for syntax checking; `bash -n` parses without executing.
35
+ BASH = "bash"
36
+
37
+ # A snippet that is unambiguously valid bash: `:` is the no-op builtin. Used to probe
38
+ # the toolchain rather than the README.
39
+ _PROBE = ":\n"
40
+
41
+ # Flag marking a fence as intentionally excluded. Usage: add it after the language
42
+ # identifier on the opening fence line, e.g. ```bash +RHIZA_SKIP
43
+ SKIP_FLAG = "+RHIZA_SKIP"
44
+
45
+ # Box-drawing characters mean the fence is a directory tree, not runnable shell.
46
+ TREE_MARKERS = ("├──", "└──", "│")
47
+
48
+
49
+ def should_skip(flags: str) -> bool:
50
+ """Return True if the fence flags string contains the +RHIZA_SKIP marker.
51
+
52
+ Args:
53
+ flags: Text following the language identifier on the opening fence line.
54
+
55
+ Returns:
56
+ True when the block is intentionally excluded.
57
+
58
+ Examples:
59
+ >>> should_skip(" +RHIZA_SKIP")
60
+ True
61
+ >>> should_skip("other-flag")
62
+ False
63
+ """
64
+ return SKIP_FLAG in flags
65
+
66
+
67
+ @functools.cache
68
+ def bash_usable() -> bool:
69
+ r"""Return True when ``bash -n`` actually parses a trivially valid snippet.
70
+
71
+ **Why probe instead of just running the check.** ``bash -n`` reports a syntax error
72
+ by exiting non-zero, so anything else that exits non-zero is indistinguishable from
73
+ one. On Windows that is not hypothetical: ``bash`` can resolve to
74
+ ``C:\Windows\System32\bash.exe``, the WSL launcher, which exits non-zero having
75
+ written nothing to stderr when no distribution is installed. The README checks then
76
+ fail on ``make install`` and print an empty error, which is worse than not running:
77
+ it accuses the project of a defect the tool invented.
78
+
79
+ Probing with :data:`_PROBE` separates "this fence is broken" from "there is no usable
80
+ bash here", so the checks can skip honestly in the second case. The result is cached
81
+ because it cannot change within a session and every fence would otherwise re-pay it.
82
+
83
+ Returns:
84
+ True when a working bash was found, False otherwise.
85
+ """
86
+ try:
87
+ result = subprocess.run( # noqa: S603 - fixed argument list, no shell # nosec B603
88
+ [BASH, "-n"],
89
+ input=_PROBE,
90
+ capture_output=True,
91
+ text=True,
92
+ )
93
+ except OSError:
94
+ # bash is absent entirely, or not executable.
95
+ return False
96
+ return result.returncode == 0
@@ -0,0 +1,24 @@
1
+ """The check modules, collected with ``--pyargs`` rather than by entry point.
2
+
3
+ A ``pytest11`` plugin can contribute fixtures, hooks and options; it cannot contribute
4
+ tests. So the checks are named explicitly on the command line:
5
+
6
+ .. code-block:: bash
7
+
8
+ pytest --pyargs pytest_rhiza.checks.test_readme pytest_rhiza.checks.test_pyproject
9
+
10
+ **One module per file the template used to sync**, names unchanged. That is deliberate:
11
+ selection stays where it already is — with the make fragment the owning bundle ships, one
12
+ ``RHIZA_CHECKS +=`` line each — so a Python project never names the Cargo checks and
13
+ nothing has to sniff for manifests at runtime to decide what applies.
14
+
15
+ | module | bundle that names it | replaces |
16
+ | --- | --- | --- |
17
+ | ``test_readme`` | ``core`` | ``.rhiza/tests/test_readme.py`` |
18
+ | ``test_release_tags`` | ``core`` | ``.rhiza/tests/test_release_tags.py`` |
19
+ | ``test_pyproject`` | ``python-core`` | ``.rhiza/tests/test_pyproject.py`` |
20
+ | ``test_docstrings`` | ``python-core`` | ``.rhiza/tests/test_docstrings.py`` |
21
+ | ``test_readme_validation`` | ``tests`` | ``.rhiza/tests/test_readme_validation.py`` |
22
+ | ``test_cargo_toml`` | ``rust-core`` | ``.rhiza/tests/test_cargo_toml.py`` |
23
+ | ``test_go_module`` | ``go-core`` | ``.rhiza/tests/test_go_module.py`` |
24
+ """
@@ -0,0 +1,296 @@
1
+ """Tests for Cargo.toml structure and the release config that rewrites it.
2
+
3
+ Ported from ``jebel-quant/rhiza`` at 89f9298, where bundle ``rust-core`` synced it
4
+ to ``.rhiza/tests/test_cargo_toml.py``. It now arrives installed, and is collected by name:
5
+ ``pytest --pyargs pytest_rhiza.checks.test_cargo_toml``.
6
+
7
+ The Rust counterpart of ``test_pyproject.py``. Written in Python and run through uv by
8
+ ``make rhiza-test`` rather than as a ``#[test]``, for the same reason the rest of the
9
+ suite is: it validates *configuration*, and the release flow that reads it is Python
10
+ tooling. ``uv`` lives in ``core`` precisely so it is available whatever the project's
11
+ language.
12
+
13
+ Validates that Cargo.toml:
14
+ - is syntactically valid TOML
15
+ - contains the [package] fields cargo publish requires
16
+ - declares a semver version
17
+ - carries a license or license-file, which deny.toml's allow-list checks against
18
+ - version matches the latest git tag (vX.Y.Z → X.Y.Z)
19
+
20
+ …and that the shipped .bumpversion.toml can still write the version back:
21
+ - it is the config bump-my-version discovers, and nothing shadows it
22
+ - it points at Cargo.toml's [package] table, anchored
23
+ - it leaves the commit and the tag to /rhiza:release
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ import tomllib
30
+ from pathlib import Path
31
+
32
+ import pytest
33
+
34
+ _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+")
35
+
36
+ # cargo tolerates more than this, but a crate that cannot be published is a crate whose
37
+ # release flow breaks at the last step rather than the first.
38
+ _REQUIRED_PACKAGE_FIELDS = ("name", "version", "edition", "description")
39
+
40
+ # The only filenames bump-my-version auto-discovers (#1453). A Rust project owns none of
41
+ # them natively, which is why rust-core ships the first one.
42
+ _DISCOVERABLE_CONFIGS = (".bumpversion.toml", ".bumpversion.cfg", "setup.cfg", "pyproject.toml")
43
+
44
+
45
+ def _has_bumpversion_section(path: Path) -> bool:
46
+ """Report whether a config file carries a bumpversion section at all.
47
+
48
+ Args:
49
+ path: Candidate config file; a missing or malformed file counts as absent.
50
+
51
+ Returns:
52
+ True when the file declares ``[tool.bumpversion]`` (TOML) or ``[bumpversion]``
53
+ (INI).
54
+ """
55
+ if not path.is_file():
56
+ return False
57
+ if path.suffix == ".cfg":
58
+ return "[bumpversion]" in path.read_text(encoding="utf-8")
59
+ try:
60
+ with path.open("rb") as handle:
61
+ data = tomllib.load(handle)
62
+ except tomllib.TOMLDecodeError:
63
+ return False
64
+ return isinstance(data.get("tool", {}).get("bumpversion"), dict)
65
+
66
+
67
+ @pytest.fixture(scope="module")
68
+ def cargo_toml(root: Path) -> dict:
69
+ """Load and return Cargo.toml as a parsed dict."""
70
+ path = root / "Cargo.toml"
71
+ if not path.exists():
72
+ pytest.skip("Cargo.toml not found")
73
+ with path.open("rb") as handle:
74
+ return tomllib.load(handle)
75
+
76
+
77
+ @pytest.fixture(scope="module")
78
+ def package(cargo_toml: dict) -> dict:
79
+ """Return the [package] table, skipping on a virtual workspace manifest.
80
+
81
+ A workspace root legitimately has no ``[package]`` — it carries only
82
+ ``[workspace]`` — and its members hold the versions. Nothing here applies.
83
+ """
84
+ table = cargo_toml.get("package")
85
+ if not isinstance(table, dict):
86
+ if "workspace" in cargo_toml:
87
+ pytest.skip("virtual workspace manifest — the members carry the versions")
88
+ pytest.fail("Cargo.toml is missing a [package] table")
89
+ return table
90
+
91
+
92
+ @pytest.fixture(scope="module")
93
+ def bumpversion(root: Path) -> dict:
94
+ """Return the [tool.bumpversion] table from .bumpversion.toml."""
95
+ path = root / ".bumpversion.toml"
96
+ if not path.is_file():
97
+ pytest.skip(".bumpversion.toml not found — reported by TestBumpversionConfig")
98
+ with path.open("rb") as handle:
99
+ return tomllib.load(handle).get("tool", {}).get("bumpversion", {})
100
+
101
+
102
+ class TestCargoToml:
103
+ """Tests for basic Cargo.toml existence and validity."""
104
+
105
+ def test_cargo_toml_exists(self, root: Path) -> None:
106
+ """Cargo.toml must exist at the project root."""
107
+ assert (root / "Cargo.toml").is_file(), "Cargo.toml not found at project root"
108
+
109
+ def test_cargo_toml_is_valid_toml(self, root: Path) -> None:
110
+ """Cargo.toml must be syntactically valid TOML."""
111
+ with (root / "Cargo.toml").open("rb") as handle:
112
+ data = tomllib.load(handle)
113
+ assert isinstance(data, dict), "Parsed Cargo.toml must be a TOML table"
114
+
115
+
116
+ class TestPackageFields:
117
+ """Tests for required fields within the [package] table."""
118
+
119
+ @pytest.mark.parametrize("field", _REQUIRED_PACKAGE_FIELDS)
120
+ def test_required_field_present(self, package: dict, field: str) -> None:
121
+ """Each required [package] field must be present."""
122
+ assert field in package, f"[package] is missing required field '{field}'"
123
+
124
+ def test_name_is_non_empty_string(self, package: dict) -> None:
125
+ """[package].name must be a non-empty string."""
126
+ name = package.get("name", "")
127
+ assert isinstance(name, str), "[package].name must be a string"
128
+ assert name.strip(), "[package].name must be a non-empty string"
129
+
130
+ def test_version_follows_semver(self, package: dict) -> None:
131
+ """[package].version must follow semver (MAJOR.MINOR.PATCH).
132
+
133
+ Not merely a convention here: ``.bumpversion.toml``'s ``parse`` regex and its
134
+ ``tag_name = "v{new_version}"`` both assume this shape, and a version cargo
135
+ accepts but that regex does not would fail the release rather than the build.
136
+ """
137
+ version = package.get("version", "")
138
+ if isinstance(version, dict):
139
+ pytest.skip("[package].version is inherited from the workspace")
140
+ assert _SEMVER_RE.match(str(version)), (
141
+ f"[package].version {version!r} does not follow semver (expected MAJOR.MINOR.PATCH)"
142
+ )
143
+
144
+ def test_description_is_non_empty_string(self, package: dict) -> None:
145
+ """[package].description must be a non-empty string."""
146
+ desc = package.get("description", "")
147
+ if isinstance(desc, dict):
148
+ pytest.skip("[package].description is inherited from the workspace")
149
+ assert isinstance(desc, str), "[package].description must be a string"
150
+ assert desc.strip(), "[package].description must be a non-empty string"
151
+
152
+ def test_a_license_is_declared(self, package: dict) -> None:
153
+ """[package] must declare `license` or `license-file`.
154
+
155
+ ``make license`` is ``cargo deny check licenses`` and deny.toml is an
156
+ allow-list, so a crate with no licence of its own fails that gate on itself
157
+ rather than on a dependency.
158
+ """
159
+ declared = [key for key in ("license", "license-file") if package.get(key)]
160
+ assert declared, (
161
+ "[package] declares neither 'license' nor 'license-file'; `make license` runs "
162
+ "cargo-deny against an allow-list and fails a crate that does not state its own"
163
+ )
164
+
165
+
166
+ class TestBumpversionConfig:
167
+ """The release flow must find a version config, not silently invent one (#1453).
168
+
169
+ bump-my-version searches four filenames and stops. Finding none it does **not**
170
+ fail — it falls back to ``git describe`` and reports the last reachable tag as the
171
+ current version, so a release can be cut at a version that already exists. A Rust
172
+ project owns none of those four filenames natively, so ``rust-core`` ships a root
173
+ ``.bumpversion.toml``; these tests assert it is still the file that wins and still
174
+ points where the version actually lives.
175
+ """
176
+
177
+ def test_a_discoverable_config_exists(self, root: Path) -> None:
178
+ """A bumpversion section must live in a file bump-my-version actually reads."""
179
+ found = [name for name in _DISCOVERABLE_CONFIGS if _has_bumpversion_section(root / name)]
180
+ hint = ""
181
+ if (root / ".rhiza" / ".cfg.toml").is_file():
182
+ hint = (
183
+ " A leftover .rhiza/.cfg.toml is present: that path is never auto-discovered "
184
+ "(it predates the fix for issue #1453) and can be deleted."
185
+ )
186
+ assert found, (
187
+ f"No bumpversion config was found in any file bump-my-version searches "
188
+ f"({', '.join(_DISCOVERABLE_CONFIGS)}). It will silently fall back to "
189
+ f"`git describe`, so a release can be cut at a version that already exists. "
190
+ f"Restore the .bumpversion.toml the rust-core bundle ships.{hint}"
191
+ )
192
+
193
+ def test_no_pyproject_shadows_the_rust_config(self, root: Path) -> None:
194
+ """A stray ``[tool.bumpversion]`` in pyproject.toml must not take over.
195
+
196
+ ``.bumpversion.toml`` is searched first, so this is about the reverse mistake:
197
+ a Rust repo that also carries a Python manifest (a helper package, a docs
198
+ toolchain) and declares the table there too now has two configs, only one of
199
+ which knows about Cargo.toml.
200
+ """
201
+ if not _has_bumpversion_section(root / ".bumpversion.toml"):
202
+ pytest.skip("no .bumpversion.toml — reported by test_a_discoverable_config_exists")
203
+ duplicates = [
204
+ name
205
+ for name in _DISCOVERABLE_CONFIGS
206
+ if name != ".bumpversion.toml" and _has_bumpversion_section(root / name)
207
+ ]
208
+ assert not duplicates, (
209
+ f"{duplicates} also declares a bumpversion section. .bumpversion.toml is searched "
210
+ f"first and wins, so the other config is inert — and being inert, it drifts."
211
+ )
212
+
213
+ def test_the_config_does_not_pin_a_current_version(self, bumpversion: dict) -> None:
214
+ """``current_version`` must stay absent from a synced config.
215
+
216
+ The file is owned by rhiza, so a value only the consuming repo can maintain
217
+ would be reset by the next ``/rhiza:update``. Omitting it makes bump-my-version
218
+ derive the version from the newest tag matching ``tag_name``, which is what
219
+ Cargo.toml carries in any repo whose crate version and tags agree.
220
+ """
221
+ assert "current_version" not in bumpversion, (
222
+ "[tool.bumpversion].current_version is set in a file rhiza syncs; the next "
223
+ "/rhiza:update would overwrite it. Omit the key and let the newest tag supply it."
224
+ )
225
+
226
+ def test_the_config_targets_cargo_toml(self, bumpversion: dict) -> None:
227
+ """A ``[[files]]`` entry must point at Cargo.toml, or nothing gets rewritten.
228
+
229
+ With no entry for it the bump still "succeeds": it rewrites ``current_version``
230
+ in its own config, tags nothing, and leaves ``[package].version`` untouched.
231
+ """
232
+ targets = [entry.get("filename") for entry in bumpversion.get("files", [])]
233
+ assert "Cargo.toml" in targets, (
234
+ f"no [[tool.bumpversion.files]] entry targets Cargo.toml (found {targets}); "
235
+ f"a bump would leave [package].version untouched"
236
+ )
237
+
238
+ def test_the_cargo_toml_pattern_is_anchored_to_the_package_table(self, bumpversion: dict) -> None:
239
+ """The search must be anchored, or it rewrites a same-numbered dependency.
240
+
241
+ ``search``/``replace`` are applied to *every* occurrence in the file, so a bare
242
+ ``version = "{current_version}"`` also rewrites a ``[dependencies]`` entry that
243
+ happens to share the crate's number.
244
+ """
245
+ entries = [entry for entry in bumpversion.get("files", []) if entry.get("filename") == "Cargo.toml"]
246
+ if not entries:
247
+ pytest.skip("no Cargo.toml entry — reported by test_the_config_targets_cargo_toml")
248
+ for entry in entries:
249
+ search = str(entry.get("search", ""))
250
+ assert entry.get("regex") is True, (
251
+ f"the Cargo.toml entry's search {search!r} is not a regex, so it cannot be "
252
+ f"anchored to the [package] table and would also rewrite a [dependencies] pin "
253
+ f"sharing the crate's version"
254
+ )
255
+ # Backslashes stripped before matching: the anchored form is a regex, so the
256
+ # table header is escaped there as `\[package\]`.
257
+ assert "[package]" in search.replace("\\", ""), (
258
+ f"the Cargo.toml entry's search {search!r} does not mention the [package] table; "
259
+ f"an unanchored version pattern also rewrites a [dependencies] entry that happens "
260
+ f"to share the crate's number"
261
+ )
262
+
263
+ def test_the_release_flow_owns_the_commit_and_the_tag(self, bumpversion: dict) -> None:
264
+ """``/rhiza:release`` folds the changelog into the bump commit and tags it itself."""
265
+ for key in ("commit", "tag"):
266
+ assert bumpversion.get(key, False) is False, (
267
+ f"[tool.bumpversion].{key} must be false: the release flow commits and tags "
268
+ f"itself so the changelog lands in the bump commit, and a bare "
269
+ f"`bump-my-version bump` would otherwise add a second commit and a duplicate tag"
270
+ )
271
+
272
+
273
+ class TestGitTagVersion:
274
+ """Harmony between the latest git tag and the crate version.
275
+
276
+ Reachability of that tag is asserted by ``test_release_tags.py``, which ``core``
277
+ ships for every language layer.
278
+ """
279
+
280
+ def test_latest_tag_matches_cargo_version(self, latest_tag: str, package: dict) -> None:
281
+ """The latest git tag (vX.Y.Z) must match [package].version.
282
+
283
+ This is the invariant ``.bumpversion.toml`` relies on rather than a style
284
+ preference. With no ``current_version`` key, bump-my-version reads the current
285
+ version from the newest tag and then searches Cargo.toml for it; if the two
286
+ disagree the next release fails with "did not find current version" — loudly,
287
+ but only at release time, which is the worst moment to find out.
288
+ """
289
+ version = package.get("version")
290
+ if isinstance(version, dict):
291
+ pytest.skip("[package].version is inherited from the workspace")
292
+ assert latest_tag.lstrip("v") == str(version), (
293
+ f"Latest git tag {latest_tag!r} does not match [package].version {version!r}. "
294
+ f"bump-my-version derives the current version from the tag and then looks for it "
295
+ f"in Cargo.toml, so the next release would fail to find it."
296
+ )
@@ -0,0 +1,213 @@
1
+ """Tests for module docstrings using doctest.
2
+
3
+ Ported from ``jebel-quant/rhiza`` at 89f9298, where bundle ``python-core`` synced it
4
+ to ``.rhiza/tests/test_docstrings.py``. It now arrives installed, and is collected by name:
5
+ ``pytest --pyargs pytest_rhiza.checks.test_docstrings``.
6
+
7
+ Automatically discovers all packages and runs doctests for each.
8
+
9
+ **Scope.** The folders searched come from ``RHIZA_DOCTEST_FOLDERS``, which ``quality.mk``
10
+ sets from python-core's ``DOCSTRING_FOLDERS`` accumulator — the same list ``make
11
+ docs-coverage`` uses. With the variable unset (running pytest by hand, say) it falls back
12
+ to ``SOURCE_FOLDER`` from ``.rhiza/.env``, defaulting to ``src``.
13
+
14
+ That indirection exists because this gate used to resolve ``src`` and nothing else, so a
15
+ project keeping Python outside its source root had its docstring examples silently
16
+ skipped — the mother repo being the extreme case, with no ``src/`` at all and 23 unchecked
17
+ examples in ``utils/`` (#1517). It is the same hole #1505 closed for the Makefile gates
18
+ and #1516 for coverage, in the shipped suite rather than in make.
19
+
20
+ **Two layouts, deliberately.** A folder may hold packages (``src/mypkg/__init__.py``) or
21
+ loose scripts (``utils/link_dogfood.py``); both carry docstrings worth checking, so both
22
+ are discovered. A module that cannot be imported is reported as a warning and skipped, not
23
+ failed: a loose script may execute at import, and "we could not measure this" is a
24
+ different statement from "this example is wrong".
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import doctest
30
+ import importlib
31
+ import importlib.util
32
+ import os
33
+ import warnings
34
+ from pathlib import Path
35
+
36
+ import pytest
37
+ from dotenv import dotenv_values
38
+
39
+ # Read .rhiza/.env at collection time (no environment side-effects).
40
+ RHIZA_ENV_PATH = Path(".rhiza/.env")
41
+
42
+ # Set by `make rhiza-test` from DOCSTRING_FOLDERS; whitespace-separated.
43
+ DOCTEST_FOLDERS_ENV = "RHIZA_DOCTEST_FOLDERS"
44
+
45
+
46
+ def _iter_modules_from_path(logger, package_path: Path, src_path: Path):
47
+ """Recursively find all Python modules in a directory."""
48
+ for path in package_path.rglob("*.py"):
49
+ if path.name == "__init__.py":
50
+ module_path = path.parent.relative_to(src_path)
51
+ else:
52
+ module_path = path.relative_to(src_path).with_suffix("")
53
+
54
+ # Convert path to module name in an OS-independent way
55
+ module_name = ".".join(module_path.parts)
56
+
57
+ try:
58
+ yield importlib.import_module(module_name)
59
+ except ImportError as e:
60
+ warnings.warn(f"Could not import {module_name}: {e}", stacklevel=2)
61
+ logger.warning("Could not import module %s: %s", module_name, e)
62
+ continue
63
+
64
+
65
+ def _find_packages(src_path: Path):
66
+ """Find all packages in the source path, including those nested under namespace packages."""
67
+ for init_file in src_path.rglob("__init__.py"):
68
+ package_dir = init_file.parent
69
+ # Only yield top-level packages (those whose parent doesn't have __init__.py or is src_path)
70
+ parent = package_dir.parent
71
+ if parent == src_path or not (parent / "__init__.py").exists():
72
+ yield package_dir
73
+
74
+
75
+ def _doctest_folders(root: Path, values: dict) -> list[Path]:
76
+ """Return the existing folders whose docstrings should be doctested.
77
+
78
+ Args:
79
+ root: The repository root.
80
+ values: The parsed ``.rhiza/.env`` mapping.
81
+
82
+ Returns:
83
+ Each configured folder that exists, in order, without duplicates.
84
+ """
85
+ configured = os.environ.get(DOCTEST_FOLDERS_ENV, "").split()
86
+ if not configured:
87
+ configured = [values.get("SOURCE_FOLDER") or "src"]
88
+
89
+ folders: list[Path] = []
90
+ for name in configured:
91
+ path = root / name
92
+ if path.is_dir() and path not in folders:
93
+ folders.append(path)
94
+ return folders
95
+
96
+
97
+ def _iter_loose_modules(logger, folder: Path):
98
+ """Import the top-level ``*.py`` files in ``folder`` that are not part of a package.
99
+
100
+ A folder of standalone scripts (``utils/``, ``scripts/``, ``tools/``) has no
101
+ ``__init__.py``, so :func:`_find_packages` never reaches it even though its docstrings
102
+ may carry examples. Each file is loaded from its path rather than by module name, so
103
+ no ``sys.path`` manipulation is needed and two folders may hold same-named scripts.
104
+
105
+ Args:
106
+ logger: The test logger.
107
+ folder: The directory to scan (not recursed — a package below it is the other
108
+ discovery path's job).
109
+
110
+ Yields:
111
+ Each imported module. A module that raises on import is warned about and skipped:
112
+ a loose script may run code at import time, and being unable to measure an example
113
+ is not the same as the example being wrong.
114
+ """
115
+ if (folder / "__init__.py").exists():
116
+ return # a package; _find_packages handles it
117
+
118
+ for path in sorted(folder.glob("*.py")):
119
+ spec = importlib.util.spec_from_file_location(path.stem, path)
120
+ if spec is None or spec.loader is None: # pragma: no cover - defensive
121
+ continue
122
+ module = importlib.util.module_from_spec(spec)
123
+ try:
124
+ spec.loader.exec_module(module)
125
+ except BaseException as e: # noqa: BLE001 - a script may raise or SystemExit on import
126
+ warnings.warn(f"Could not import {path}: {e}", stacklevel=2)
127
+ logger.warning("Could not import loose module %s: %s", path, e)
128
+ continue
129
+ yield module
130
+
131
+
132
+ def test_doctests(logger, root, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
133
+ """Run doctests for every module in the configured folders."""
134
+ values = dotenv_values(root / RHIZA_ENV_PATH) if (root / RHIZA_ENV_PATH).exists() else {}
135
+ folders = _doctest_folders(root, values)
136
+
137
+ logger.info("Starting doctest discovery in: %s", [str(f) for f in folders] or "(nothing configured)")
138
+ if not folders:
139
+ configured = os.environ.get(DOCTEST_FOLDERS_ENV) or values.get("SOURCE_FOLDER") or "src"
140
+ logger.info("No doctest folder exists (looked for: %s) — skipping doctests", configured)
141
+ pytest.skip(f"No doctest folder found (looked for: {configured})")
142
+
143
+ total_tests = 0
144
+ total_failures = 0
145
+ failed_modules = []
146
+
147
+ def _run(module) -> None:
148
+ """Run one module's doctests and fold the result into the running totals."""
149
+ nonlocal total_tests, total_failures
150
+ logger.debug("Running doctests for module: %s", module.__name__)
151
+ # Disable pytest's stdout capture during doctest to avoid interference
152
+ with capsys.disabled():
153
+ results = doctest.testmod(
154
+ module,
155
+ verbose=False,
156
+ optionflags=(doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE),
157
+ )
158
+ total_tests += results.attempted
159
+
160
+ if results.failed:
161
+ logger.warning(
162
+ "Doctests failed for %s: %d/%d failed",
163
+ module.__name__,
164
+ results.failed,
165
+ results.attempted,
166
+ )
167
+ total_failures += results.failed
168
+ failed_modules.append((module.__name__, results.failed, results.attempted))
169
+ else:
170
+ logger.debug("Doctests passed for %s (%d test(s))", module.__name__, results.attempted)
171
+
172
+ for src_path in folders:
173
+ # Add the folder to sys.path with automatic cleanup
174
+ monkeypatch.syspath_prepend(str(src_path))
175
+ logger.debug("Prepended to sys.path: %s", src_path)
176
+
177
+ # Find all packages in the folder (supports namespace packages)
178
+ for package_dir in _find_packages(src_path):
179
+ if package_dir.is_dir() and (package_dir / "__init__.py").exists():
180
+ # Import the package
181
+ package_name = package_dir.name
182
+ logger.info("Discovered package: %s", package_name)
183
+ try:
184
+ modules = list(_iter_modules_from_path(logger, package_dir, src_path))
185
+ logger.debug("%d module(s) found in package %s", len(modules), package_name)
186
+
187
+ for module in modules:
188
+ _run(module)
189
+
190
+ except ImportError as e:
191
+ warnings.warn(f"Could not import package {package_name}: {e}", stacklevel=2)
192
+ logger.warning("Could not import package %s: %s", package_name, e)
193
+ continue
194
+
195
+ # And the loose scripts, which no package walk reaches
196
+ for module in _iter_loose_modules(logger, src_path):
197
+ _run(module)
198
+
199
+ if failed_modules:
200
+ formatted = "\n".join(f" {name}: {failed}/{attempted} failed" for name, failed, attempted in failed_modules)
201
+ msg = (
202
+ f"Doctest summary: {total_tests} tests across {len(failed_modules)} module(s)\n"
203
+ f"Failures: {total_failures}\n"
204
+ f"Failed modules:\n{formatted}"
205
+ )
206
+ logger.error("%s", msg)
207
+ assert total_failures == 0, msg
208
+ else:
209
+ logger.info("Doctest summary: %d tests, 0 failures", total_tests)
210
+
211
+ if total_tests == 0:
212
+ logger.info("No doctests were found in any module — skipping")
213
+ pytest.skip("No doctests were found in any module")