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.
- pytest_rhiza/__init__.py +18 -0
- pytest_rhiza/_fences.py +96 -0
- pytest_rhiza/checks/__init__.py +24 -0
- pytest_rhiza/checks/test_cargo_toml.py +296 -0
- pytest_rhiza/checks/test_docstrings.py +213 -0
- pytest_rhiza/checks/test_go_module.py +284 -0
- pytest_rhiza/checks/test_pyproject.py +341 -0
- pytest_rhiza/checks/test_readme.py +100 -0
- pytest_rhiza/checks/test_readme_validation.py +86 -0
- pytest_rhiza/checks/test_release_tags.py +64 -0
- pytest_rhiza/plugin.py +112 -0
- pytest_rhiza-0.2.1.dist-info/METADATA +156 -0
- pytest_rhiza-0.2.1.dist-info/RECORD +16 -0
- pytest_rhiza-0.2.1.dist-info/WHEEL +4 -0
- pytest_rhiza-0.2.1.dist-info/entry_points.txt +2 -0
- pytest_rhiza-0.2.1.dist-info/licenses/LICENSE +21 -0
pytest_rhiza/__init__.py
ADDED
|
@@ -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"
|
pytest_rhiza/_fences.py
ADDED
|
@@ -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")
|