feat(pytest): plugin writing the XML shape this extension reads - #151
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #151 +/- ##
==========================================
- Coverage 93.23% 92.13% -1.11%
==========================================
Files 43 45 +2
Lines 3712 4195 +483
Branches 341 384 +43
==========================================
+ Hits 3461 3865 +404
- Misses 158 233 +75
- Partials 93 97 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
51cc446 to
78c2d4e
Compare
d4055f3 to
04764df
Compare
04764df to
acdee64
Compare
Addresses the second review of #145. * Discovery: `find_project_config` stopped at any directory holding a `pyproject.toml`, the start directory included, so `docs/pyproject.toml` beside `conf.py` and every uv-workspace member layout (`packages/<dist>/pyproject.toml` with the docs below it, one `ubproject.toml` at the monorepo root) returned `None` without a word. A `pyproject.toml` marks a distribution, not the project, so it is no longer a marker: the search is bounded by the repository root (`.git`) and by nothing else. A fruitless search reports where it ended -- the bridge logs it at verbose level (`sphinx-build -v`), a converter passes its own logger -- and a relative start directory is made absolute first so it has parents to walk. Tests cover both layouts, the nested-repository boundary and the report, at the loader and in a real build. * `tr_rootdir` declares `types=(str, os.PathLike)`. Its default is Sphinx's `confdir`, a `_StrPath`, so a plain string -- the only thing TOML or a string literal in `conf.py` can supply -- failed `check_confval_types` and took a `-W` build down on the documented example. The example is now built by a test that reads it from `configuration.rst` verbatim. * Values inside the `file`/`suite`/`case` spellings are covered by tests: a non-string table value (`color = 999999`), a non-string list element and a short list each raise. Dropping either check left the suite green before. * The package `__init__` resolves `setup` lazily (PEP 562), so importing `projectconfig` -- which runs the package `__init__` first -- no longer imports Sphinx, and the module's premise holds for a consumer without the documentation toolchain. An `ImportError` from the lazy import is re-raised as Sphinx's `ExtensionError` when Sphinx is importable: Sphinx fetches `setup` with `getattr()`, which only tolerates `AttributeError`, so a missing sphinx-needs would otherwise escape as a raw traceback. Taken from #151, which carried the same change and drops it on its next rebase. `setup` gains a return annotation so the lazily returned callable carries no `Any` under strict mypy. * Text: the docs no longer claim that sub-tables of other tools are left alone -- only `[test_reports.convert]` is, any other sub-table is an unknown key like any other; both warning types (`test_reports.unknown_key`, `test_reports.missing_config`) are listed; the comment on `tr_config_from_toml` says an explicit file warns rather than "must exist"; and the symlink claim of `_anchor_paths` is gone -- Sphinx resolves its `confdir` before the bridge runs, so the loader merely keeps the form of the path it is handed.
76ff965 to
f917bd0
Compare
Addresses the second review of #145. * Discovery: `find_project_config` stopped at any directory holding a `pyproject.toml`, the start directory included, so `docs/pyproject.toml` beside `conf.py` and every uv-workspace member layout (`packages/<dist>/pyproject.toml` with the docs below it, one `ubproject.toml` at the monorepo root) returned `None` without a word. A `pyproject.toml` marks a distribution, not the project, so it is no longer a marker: the search is bounded by the repository root (`.git`) and by nothing else. A fruitless search reports where it ended -- the bridge logs it at verbose level (`sphinx-build -v`), a converter passes its own logger -- and a relative start directory is made absolute first so it has parents to walk. Tests cover both layouts, the nested-repository boundary and the report, at the loader and in a real build. * `tr_rootdir` declares `types=(str, os.PathLike)`. Its default is Sphinx's `confdir`, a `_StrPath`, so a plain string -- the only thing TOML or a string literal in `conf.py` can supply -- failed `check_confval_types` and took a `-W` build down on the documented example. The example is now built by a test that reads it from `configuration.rst` verbatim. * Values inside the `file`/`suite`/`case` spellings are covered by tests: a non-string table value (`color = 999999`), a non-string list element and a short list each raise. Dropping either check left the suite green before. * The package `__init__` resolves `setup` lazily (PEP 562), so importing `projectconfig` -- which runs the package `__init__` first -- no longer imports Sphinx, and the module's premise holds for a consumer without the documentation toolchain. An `ImportError` from the lazy import is re-raised as Sphinx's `ExtensionError` when Sphinx is importable: Sphinx fetches `setup` with `getattr()`, which only tolerates `AttributeError`, so a missing sphinx-needs would otherwise escape as a raw traceback. Taken from #151, which carried the same change and drops it on its next rebase. `setup` gains a return annotation so the lazily returned callable carries no `Any` under strict mypy. * Text: the docs no longer claim that sub-tables of other tools are left alone -- only `[test_reports.convert]` is, any other sub-table is an unknown key like any other; both warning types (`test_reports.unknown_key`, `test_reports.missing_config`) are listed; the comment on `tr_config_from_toml` says an explicit file warns rather than "must exist"; and the symlink claim of `_anchor_paths` is gone -- Sphinx resolves its `confdir` before the bridge runs, so the loader merely keeps the form of the path it is handed.
b60308b to
f916ae5
Compare
ae75128 to
aaca208
Compare
|
Nice shape overall — the lazy- Correctness ran through the review workflow engine at High1. [High] The decorator vocabulary is hard-coded to S-CORE, and the generic escape hatch corrupts lists (architecture) Only properties_mapping(Satisfies=["REQ_1", "REQ_2"]) # -> value="['REQ_1', 'REQ_2']"
# tr_property_link_types splits on "," -> ["['REQ_1'", "'REQ_2']"] = garbage link IDs, silentlysphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 86 to 93 in aaca208 Three further asymmetries push non-S-CORE projects onto a second-class path: the four blessed keywords are renamed snake_case→PascalCase while custom ones pass through verbatim ( To my mind this is the thing to settle before shipping: S-CORE's names are a fine default, but they shouldn't be the only ergonomic path. The smallest fix that makes it generic is to join any sequence value uniformly, whichever keyword it arrived under — then the four named parameters become pure sugar over a mechanism that works for every metamodel, and finding 2 below disappears with it. Worth also deciding whether the snake_case→PascalCase alias map should be extensible (a module-level dict) rather than four constants. 2. [High] A bare string requirement ID is exploded character by character (correctness)
properties_mapping(partially_verifies="REQ_1") # -> {'PartiallyVerifies': 'R, E, Q, _, 1'}sphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 87 to 88 in aaca208 The build side then splits that into five nonexistent need IDs, and REQ_1 reads as unverified in traceability. 3. [High] Class-level and stacked markers are silently discarded (correctness)
@add_test_properties(test_type="requirements-based")
class TestThing:
@add_test_properties(partially_verifies=["REQ_1"])
def test_one(self): assert True
# XML: <property name="PartiallyVerifies" value="REQ_1"/> -- TestType is gonesphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 267 to 270 in aaca208 Putting a classification on the class and links on each method is the natural way to use this. 4. [High] Two separate mechanisms, both reproduced on pytest 9.1:
sphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 226 to 230 in aaca208 A 5. [High] The marker is an unanchored substring plus sphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 212 to 215 in aaca208 Two different test files then write the same 6. [High] Under pytest-xdist the location attributes are silently dropped (correctness) pytest builds sphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 261 to 265 in aaca208
Medium7. [Medium] The guard covers an empty mapping but not a mapping whose values are all empty — which is exactly what a spec file with an empty metadata block produces: sphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 161 to 163 in aaca208
8. [Medium] The docs' sphinx-test-reports/docs/pytest.rst Lines 66 to 68 in aaca208 The mapped link fields must also be registered in sphinx-needs' 9. [Medium] The whole new module is excluded from mypy (architecture) sphinx-test-reports/pyproject.toml Lines 108 to 111 in aaca208 That list is otherwise a legacy backlog ( The [[tool.mypy.overrides]]
module = "test_reports.pytest_plugin"
disallow_any_expr = false
disallow_any_explicit = falseLow10. [Low] sphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 231 to 233 in aaca208
11. [Low] Wrong sphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 38 to 40 in aaca208 The key is CompatibilityNo concerns — purely additive. New module, new opt-in |
|
@MaximilianSoerenPollak — could you weigh in on two design calls before I apply the review fixes? The other nine findings are mechanical and I'll just do them; these two change the public API, so I'd rather not pick alone. Background: the plugin cannot know field typesWorth stating up front, because it constrains both questions. JUnit XML has exactly one property kind —
sphinx-test-reports/sphinxcontrib/test_reports/directives/test_common.py Lines 200 to 207 in aaca208 So the plugin has no say in link-vs-plain and structurally can't. The only thing it controls is arity — whether a value is single- or multi-valued, i.e. whether it gets comma-joined on the way into that string. Today arity is hard-coded to four names:
sphinx-test-reports/sphinxcontrib/test_reports/pytest_plugin.py Lines 86 to 93 in aaca208 Those two multi-valued names are S-CORE's link fields. A project with its own link field has no way to get list handling, so Question A — how should arity be decided?A1. Infer it. Any sequence value is joined with add_test_properties(Satisfies=["REQ_1", "REQ_2"]) # -> value="REQ_1, REQ_2"
add_test_properties(partially_verifies="REQ_1") # -> value="REQ_1" (also fixes finding 2)Smallest change, fixes findings 1 and 2 together, keeps the A2. Declare it. Same joining, but each property name carries an arity, and passing a list to a single-valued field is an error rather than a silent join. Costs a small table; catches the A3. A2 plus an extensible alias map. Additionally let a project register its own snake_case → XML-name aliases, so custom link fields get the same ergonomics as the S-CORE four ( PROPERTY_ALIASES["satisfies"] = "Satisfies"
add_test_properties(satisfies=["REQ_1"]) # -> <property name="Satisfies" value="REQ_1"/>A4. Drive the map from config (pytest ini / My preference is A2, with A3 if we expect non-S-CORE metamodels soon — A1 is tempting for its size but trades away the only arity check we have. Related hazard, independent of the choice: comma is the separator and there is no escaping. A free-text property containing commas ( Question B — pytest-xdist silently drops
|
|
@ubmarco So regarding your questions. For For |
| "equivalence-classes", | ||
| "fuzz-testing", | ||
| "error-guessing", | ||
| "explorative-testing", |
There was a problem hiding this comment.
I think this is not the entier list (at least from S-CORE process sight).
The one I had in my plugin I think were not complete.
Here you can find the complete list of the things:
I don't know why they have written it like this, it links from one place to another and then in the end links there.
Here is the original place:
https://eclipse-score.github.io/process_description/main/process_areas/verification/guidance/verification_specification.html#test-specification
BUt ye we probably want to have all allowed values in this list, not my incomplete one. Sorry for the bad pre-work there.
There was a problem hiding this comment.
Done. I took the full list from the "Verification Methods" section you linked.
TEST_TYPES now has all 11 test types from that page:
control-flow-analysis, data-flow-analysis, fault-injection, inspection, interface-test, requirements-based, resource-usage, static-code-analysis, structural-statement-coverage, structural-branch-coverage, walkthrough.
DERIVATION_TECHNIQUES was already complete. It has the same 7 values as the page.
The docs page now links to that section, so people can look up what each value means.
One thing stays as it was: the plugin does not reject values outside these lists. It only documents them. The reason: S-CORE's own metamodel also accepts any text for test_type and derivation_technique (the pattern is ^.*$ in metamodel.yaml). If the plugin were stricter than the metamodel, a value that the docs build accepts would fail in pytest. If you want the plugin to reject unknown values anyway, tell me. It is a small change.
Commit 933737f.
There was a problem hiding this comment.
Yes I don't think for this it makes sense to reject things outside as it's not S-CORE specific, but general. So that seems good for me.
One thing we could think of in the future is maybe a strict mode with an extensible list (that the user can extend or give) and then reject everything not in that list.
| [project.optional-dependencies] | ||
| # The pytest plugin (sphinxcontrib.test_reports.pytest_plugin); pytest is not | ||
| # a dependency of the extension or the converter. | ||
| pytest = ["pytest>=7.0"] |
There was a problem hiding this comment.
I think you need some higher pytest version but I don't quiet remember.
we caught some issue swith pytest v 9.2 I think cause it changed stuff but maybe 7 and up should work.
9.2 might have changed things that were not relevant to the plugin.
There was a problem hiding this comment.
But, if it works, it works, so don''t change it :D
There was a problem hiding this comment.
I tested it instead of guessing. The plugin's own tests (35 tests, run through pytest's pytester, one of them with pytest-xdist) pass without any change on:
- pytest 7.0.1 and 7.4.4 on Python 3.11
- pytest 8.4.2 on Python 3.12
- pytest 9.1.1 on Python 3.14
9.1.1 is the newest pytest on PyPI right now, so there is no 9.2 yet to test against.
Everything the plugin uses from pytest exists since version 7.0 or earlier: issue_config_time_warning, record_xml_attribute, iter_markers, Item.user_properties, and the public pytest.Config / pytest.Item / pytest.Mark names. So pytest>=7.0 stays.
aaca208 to
8998a3b
Compare
Only partially_verifies/fully_verifies were list-aware; everything else fell through **properties and was written with str(), so a project's own link field -- Satisfies=["REQ_1", "REQ_2"] -- ended up as the Python repr "['REQ_1', 'REQ_2']", which tr_property_link_types then split into bogus IDs, silently. And since str is a Sequence[str], partially_verifies="REQ_1" was joined character by character into "R, E, Q, _, 1". A PROPERTIES registry now decides how a keyword is written: its XML name and whether it is multi-valued. Multi-valued properties join a sequence with ", " and take a bare string as one value; single-valued ones reject a sequence with a TypeError instead of joining it; a keyword the registry does not know is written under its own name with a single value, and a list under it is a TypeError pointing at register_property(), which adds a project's own keywords (in conftest.py, before collection). The XML name doubles as keyword. Sets and other objects are a TypeError rather than a str() surprise; numbers are written as text. apply_test_metadata goes through the same normalisation: metadata whose values are all empty writes nothing and no longer raises into the test body, and an explicit file/line override is applied regardless of the metadata. Also corrects the ubproject.toml key in the module comment (property_link_types, not link_properties). Review findings 1, 2, 7 and 11 of #151.
get_closest_marker returns one marker, so a classification on the class and links on each method -- the natural way to use the decorator -- lost the class's properties silently, and of two stacked decorators on a function only the one nearest the def survived. The fixture now merges every marker on the node's chain (iter_markers), the one closest to the function winning per property. Review finding 3 of #151.
Two mechanisms took a project with warnings-as-errors down:
* the xunit2 notice was a PytestConfigWarning through
issue_config_time_warning, which `filterwarnings = error` raised straight
out of pytest_configure -- an INTERNALERROR traceback, no tests, no
report, instead of the advice;
* pytest's "record_xml_attribute is an experimental feature" notice was
silenced by an appended ini filterwarnings line only, which `-W error`
on the command line and a filterwarnings("error") mark both override, so
every test errored at setup on a warning the user never opted into.
The notices are now a TestReportsConfigWarning of the plugin's own. Where
the project's filters make it an error, it is re-raised as a clean
UsageError ("ERROR: ..." and exit code 4) -- the project asked for
warnings to fail the run, and this one carries the fix. An
`ignore::sphinxcontrib.test_reports.pytest_plugin.TestReportsConfigWarning`
line silences it. The experimental-feature notice is dropped in process,
in a warnings.catch_warnings() around requesting the fixture, out of
reach of any filter setting.
With that, the fixture requests record_xml_attribute only when a report is
written under xunit1 (`legacy` is pytest's alias and no longer warned
about); under xunit2 pytest would drop the attributes again and warn per
test, which the one start-up notice covers. The properties go straight to
Item.user_properties, which is what the record_property fixture does, so
neither fixture is needed -- both belong to the junitxml plugin and are
gone under `-p no:junitxml`, where config.option.xmlpath does not exist
either and used to crash the session before collection.
Under pytest-xdist the XML writer exists on the controller only, so the
workers' record_xml_attribute is a no-op and the cases silently carry
pytest's stock 0-based locations while the properties still arrive. The
plugin now warns at start-up when tests are distributed; the docs say so.
The plugin's tests run the inner pytest as a subprocess from here on: the
test module has the plugin imported already, and an in-process run then
warns that the -p module cannot be assert-rewritten -- noise in the
warning counts and an error under the strict policies these tests set.
Review findings 4, 6 (B1) and 10 of #151.
The marker was an unanchored substring, so any directory whose name ends in `_main` lost its prefix: services/app_main/tests/test_api.py became tests/test_api.py, and two different files then wrote the same `file` -- under tr_deterministic_case_ids, the same need ID. The cut now happens at a whole `_main` component, on either separator, and at the last one: under bzlmod the execroot's workspace directory is `_main` too, and an absolute runfiles path handed to apply_test_metadata goes through both. Review finding 5 of #151.
The exclude list is a legacy backlog ("reactivate below files"), and the
new module went into it under strict = true. Only two checks need to give
way: disallow_any_expr, for pytest's Any-typed marker and config API, and
disallow_any_explicit, for the Callable[..., object] a decorator of
arbitrary test functions has to take. A per-module override lifts those
two and keeps everything else strict; it names the module both ways
because `packages` yields sphinxcontrib.test_reports.pytest_plugin while a
file on the command line resolves to test_reports.pytest_plugin, the
namespace package dropping its prefix.
Review finding 9 of #151.
TEST_TYPES listed four of S-CORE's verification methods; the verification concept in its process description names eleven (control-flow-analysis, data-flow-analysis, inspection, static-code-analysis, the two structural coverages and walkthrough were missing). DERIVATION_TECHNIQUES was already complete. Still documented rather than enforced -- S-CORE's own metamodel accepts any string for both fields -- and the docs now point at the concept page and say so. The build-side snippet mapped the properties to link fields without registering those in needs_extra_links, which fails the build on the first test case carrying either property; the snippet and a sentence now cover it, and the needs_extra_options counterpart for plain fields. Reviewer's comment on the vocabulary, and review finding 8 of #151.
|
All review findings are fixed. The fixes are six commits, one per topic, so each one can be read on its own. The two design questions were done the way @MaximilianSoerenPollak suggested: each property declares its own shape (A2 + A3), and pytest-xdist gets a warning (B1). Here is what changed, finding by finding. "Before" is the bug, "Now" is the fix. 1. Lists under custom keywords were written as Python text (commit 105212a) 2. A single requirement ID was split into letters (commit 105212a) 3. Decorators on classes were ignored (commit 77b3ee2) 4. Strict warning settings crashed the run (commit 942a48b) 5. Folders ending in 6. pytest-xdist silently broke the source locations (commit 942a48b) 7. Empty metadata raised an error inside the test (commit 105212a) 8. The docs example did not build (commit 933737f) 9. The new module was excluded from mypy (commit 18d34f5) 10. 11. Wrong key in a comment (commit 105212a) Review comment on the vocabulary (commit 933737f) Two more small things:
Checked with: the full test suite, mypy, pre-commit, a Sphinx build of the docs, and the plugin tests on pytest 7.0.1, 7.4.4, 8.4.2 and 9.1.1. |
A stock pytest run writes neither the `file`/`line` attributes a `<testcase>` needs for a source location nor any `<properties>`, so the directives' `tr_source_file_option`/`tr_source_line_option`, `tr_deterministic_case_ids` and `tr_property_link_types` have nothing to work from. Enabled with `-p sphinxcontrib.test_reports.pytest_plugin` under `junit_family = xunit1`, the plugin records the source location of every test (Bazel's `_main/` runfiles prefix cut off) and turns the metadata of `add_test_properties` into `<properties>`: `PartiallyVerifies`, `FullyVerifies`, `TestType`, `DerivationTechnique`, plus any further keyword under its own name. Lists are joined with ", ", which `tr_property_link_types` splits again. `apply_test_metadata` does the same at run time for parameterised tests whose metadata lives in the files that drive them, and can point the case at that file. Ported from the `score_pytest` attribute plugin of S-CORE's docs-as-code; the XML is shaped identically and the two entry points keep their names, so tests written against that plugin keep working when they import from here. Two of its rules are deliberately not ported -- the `TestType` / `DerivationTechnique` vocabularies are documented, not enforced, and a decorated test needs no docstring -- as process rules of that project rather than properties of the data. Implemented as an autouse fixture rather than a `pytest_runtest_makereport` hookwrapper: same XML, no hook plumbing. The plugin warns at start-up when a report is requested under `xunit2` (no attributes would be written) and silences pytest's per-test "experimental" notice for `record_xml_attribute`, which it exists to use. It imports pytest only; a `pytest` extra pulls it in. Tested through `pytester`.
Only partially_verifies/fully_verifies were list-aware; everything else fell through **properties and was written with str(), so a project's own link field -- Satisfies=["REQ_1", "REQ_2"] -- ended up as the Python repr "['REQ_1', 'REQ_2']", which tr_property_link_types then split into bogus IDs, silently. And since str is a Sequence[str], partially_verifies="REQ_1" was joined character by character into "R, E, Q, _, 1". A PROPERTIES registry now decides how a keyword is written: its XML name and whether it is multi-valued. Multi-valued properties join a sequence with ", " and take a bare string as one value; single-valued ones reject a sequence with a TypeError instead of joining it; a keyword the registry does not know is written under its own name with a single value, and a list under it is a TypeError pointing at register_property(), which adds a project's own keywords (in conftest.py, before collection). The XML name doubles as keyword. Sets and other objects are a TypeError rather than a str() surprise; numbers are written as text. apply_test_metadata goes through the same normalisation: metadata whose values are all empty writes nothing and no longer raises into the test body, and an explicit file/line override is applied regardless of the metadata. Also corrects the ubproject.toml key in the module comment (property_link_types, not link_properties). Review findings 1, 2, 7 and 11 of #151.
get_closest_marker returns one marker, so a classification on the class and links on each method -- the natural way to use the decorator -- lost the class's properties silently, and of two stacked decorators on a function only the one nearest the def survived. The fixture now merges every marker on the node's chain (iter_markers), the one closest to the function winning per property. Review finding 3 of #151.
Two mechanisms took a project with warnings-as-errors down:
* the xunit2 notice was a PytestConfigWarning through
issue_config_time_warning, which `filterwarnings = error` raised straight
out of pytest_configure -- an INTERNALERROR traceback, no tests, no
report, instead of the advice;
* pytest's "record_xml_attribute is an experimental feature" notice was
silenced by an appended ini filterwarnings line only, which `-W error`
on the command line and a filterwarnings("error") mark both override, so
every test errored at setup on a warning the user never opted into.
The notices are now a TestReportsConfigWarning of the plugin's own. Where
the project's filters make it an error, it is re-raised as a clean
UsageError ("ERROR: ..." and exit code 4) -- the project asked for
warnings to fail the run, and this one carries the fix. An
`ignore::sphinxcontrib.test_reports.pytest_plugin.TestReportsConfigWarning`
line silences it. The experimental-feature notice is dropped in process,
in a warnings.catch_warnings() around requesting the fixture, out of
reach of any filter setting.
With that, the fixture requests record_xml_attribute only when a report is
written under xunit1 (`legacy` is pytest's alias and no longer warned
about); under xunit2 pytest would drop the attributes again and warn per
test, which the one start-up notice covers. The properties go straight to
Item.user_properties, which is what the record_property fixture does, so
neither fixture is needed -- both belong to the junitxml plugin and are
gone under `-p no:junitxml`, where config.option.xmlpath does not exist
either and used to crash the session before collection.
Under pytest-xdist the XML writer exists on the controller only, so the
workers' record_xml_attribute is a no-op and the cases silently carry
pytest's stock 0-based locations while the properties still arrive. The
plugin now warns at start-up when tests are distributed; the docs say so.
The plugin's tests run the inner pytest as a subprocess from here on: the
test module has the plugin imported already, and an in-process run then
warns that the -p module cannot be assert-rewritten -- noise in the
warning counts and an error under the strict policies these tests set.
Review findings 4, 6 (B1) and 10 of #151.
The marker was an unanchored substring, so any directory whose name ends in `_main` lost its prefix: services/app_main/tests/test_api.py became tests/test_api.py, and two different files then wrote the same `file` -- under tr_deterministic_case_ids, the same need ID. The cut now happens at a whole `_main` component, on either separator, and at the last one: under bzlmod the execroot's workspace directory is `_main` too, and an absolute runfiles path handed to apply_test_metadata goes through both. Review finding 5 of #151.
The exclude list is a legacy backlog ("reactivate below files"), and the
new module went into it under strict = true. Only two checks need to give
way: disallow_any_expr, for pytest's Any-typed marker and config API, and
disallow_any_explicit, for the Callable[..., object] a decorator of
arbitrary test functions has to take. A per-module override lifts those
two and keeps everything else strict; it names the module both ways
because `packages` yields sphinxcontrib.test_reports.pytest_plugin while a
file on the command line resolves to test_reports.pytest_plugin, the
namespace package dropping its prefix.
Review finding 9 of #151.
TEST_TYPES listed four of S-CORE's verification methods; the verification concept in its process description names eleven (control-flow-analysis, data-flow-analysis, inspection, static-code-analysis, the two structural coverages and walkthrough were missing). DERIVATION_TECHNIQUES was already complete. Still documented rather than enforced -- S-CORE's own metamodel accepts any string for both fields -- and the docs now point at the concept page and say so. The build-side snippet mapped the properties to link fields without registering those in needs_extra_links, which fails the build on the first test case carrying either property; the snippet and a sentence now cover it, and the needs_extra_options counterpart for plain fields. Reviewer's comment on the vocabulary, and review finding 8 of #151.
The CI mypy job runs `uv sync --group dev` and then mypy. pytest is an extra, not a dependency, so that environment had no pytest and mypy could not follow the plugin's imports: "Cannot find implementation or library stub for module named pytest", with every pytest type collapsing to Any. The module was excluded from mypy until 18d34f5, which is why this never showed. Adding pytest to the dev group mirrors the project venv, where the check passes.
da90a0e to
9f71cfd
Compare
The plugin carried S-CORE's metamodel in code: the four property names as
constants, the four keywords as named parameters of add_test_properties
and properties_mapping, a pre-filled registry, and the two vocabularies.
Other metamodels got a code-level side door, register_property() in a
conftest.py. sphinx-test-reports is a generic project, and the plugin
should be shaped like one.
The model now comes from a pytest ini option the plugin registers,
test_reports_properties, one line per property:
keyword [= XmlName] [, list]
keyword is what a test writes, XmlName the <property> name it is written
under (the keyword itself when omitted), and `list` marks a multi-valued
property. A keyword not declared is written under its own name with a
single value; a list under it is a TypeError that names the option. A
line outside the grammar, an unknown flag or a keyword declared twice stop
the run at start-up with a usage error quoting the line. Being pytest
configuration, the option lives in pytest.ini or pyproject.toml, reaches
xdist workers, and needs nothing outside pytest -- in particular not
ubproject.toml, which in S-CORE is an artifact of the Sphinx build that
runs after the tests.
The values are no longer serialised when the decorator runs but at test
setup, in the fixture, against the model pytest_configure installed: the
decorator stores the keywords as given and does not depend on
configuration having been read (a conftest may import test helpers before
pytest_configure). It still refuses a call with nothing in it at import
time, which needs no model. Merging stacked markers happens on the
serialised properties, so the XML name decides what "the same property"
is.
Gone from the code: the name constants, TEST_TYPES, DERIVATION_TECHNIQUES,
register_property and the named parameters. S-CORE's model is the worked
example of the docs page, in ini and pyproject form, with the two
vocabularies as a table; with it, tests written against score_pytest keep
working unchanged, since the parameters were keyword-only all along.
|
One more change after the review round, and it replaces something the review agreed on, so here is the why and the what. Why. The plugin still carried S-CORE's metamodel in code: the four property names as constants, the four keywords as named parameters of What. A new pytest ini option,
A keyword that is not declared is written under its own name with a single value. A list under an undeclared keyword is a S-CORE's model is now the example in the docs, in [pytest]
addopts = -p sphinxcontrib.test_reports.pytest_plugin
junit_family = xunit1
test_reports_properties =
partially_verifies = PartiallyVerifies, list
fully_verifies = FullyVerifies, list
test_type = TestType
derivation_technique = DerivationTechniqueand as a list of the same four strings in Removed: Also changed: the values are now written when a test is set up, not when the decorator runs, so the decorator never depends on configuration having been read yet. A decorator with nothing in it is still refused at import time. Commit 72ff2dd. Checked with the full test suite, mypy, pre-commit, a docs build, and the plugin tests on pytest 7.0.1, 7.4.4, 8.4.2 and 9.1.1. |
chrisjsewell
left a comment
There was a problem hiding this comment.
Reviewed at 72ff2dde by reproducing the claims rather than reading them: the same test file run under this plugin and under the score_pytest origin with the XMLs diffed; the full suite on the 9.1/8.5.0 and 7.4.7/6.0.1 cells (378 passed, no skips, on each); the plugin's own module with no Sphinx installed (47 passed) and on pytest 7.0.1; the three loading routes under --strict-config and --strict-markers; the page's conf.py example built under -W; and eight mutations of the new code against its tests (seven caught; the survivor is the same blind spot as the second item under "Worth fixing"). Verdict: the redesign is right and the code is close. I would hold the merge for the four items under "Blocking", all small. The rest is your call.
What holds, measured. Every one of the eleven review findings is fixed and stays fixed under mutation: the arity check, the marker merge, the legacy alias, the _main component cut, the catch_warnings, the flag guard. The ini option is known at validation time on all three loading routes (addopts -p, PYTEST_PLUGINS, pytest_plugins in a root conftest), so the documented recipe is --strict-config clean. The claim in your last comment that the decorator no longer depends on configuration having been read is true and load-bearing: a decorated function in a module that a conftest.py imports runs during _preparse, before pytest_configure, and the head handles it where serialising at decorator time dies before collection. No test covers that shape; the eight-line probe (a conftest importing a helper module with a decorated function) is worth adding to TestPropertyModel. The warning policy is robust in every shape I tried, including a warnings.warn in a test body after the fixture under -W error, which still fails the test, so the catch_warnings block restores pytest's recorder. xdist warns exactly once and the model reaches the workers. The mypy override is minimal: removing either module name or either flag produces errors (12, 12 and 3). The vocabulary table matches the linked S-CORE page (11 and 7). The wheel carries the module, pytest sits behind the extra only, and [pytest] installs from the built wheel.
Blocking
1. The premise is false as written, in four places, and one of them becomes the commit message. The body's "Why", the module docstring, the first paragraph of docs/pytest.rst and the changelog all say pytest writes file/line "only through the record_xml_attribute fixture" and that no stock testcase carries them. Measured on pytest 9.1.1, and it is right there in _NodeReporter.record_testreport: under junit_family = xunit1 pytest writes file and a 0-based line on every testcase with no fixture at all, and record_xml_attribute merely overrides them. Only the default xunit2 drops them. Since the page's next section tells the user to set xunit1, the claim is false about the configuration it prescribes. What the plugin adds to the location is the 1-based line, the _main runfiles cut and the apply_test_metadata override, which is the honest sentence and still a useful one. directives/test_common.py:74 already attributes the absence to the family, correctly.
2. The body describes the design before 72ff2dde. It still lists PartiallyVerifies, FullyVerifies, TestType, DerivationTechnique as what the decorator writes, cites TEST_TYPES and DERIVATION_TECHNIQUES (deleted in that commit), says lists are joined (only for a property declared , list; undeclared, a list is a TypeError, which the head's own test asserts), and never mentions test_reports_properties. The squash commit copies it. Your 07:41Z comment is the right starting point for the rewrite; and "35 tests" is 47 by --collect-only.
3. str(item) inside the join re-opens findings 1 and 2, one level down. _serialise hoists str out of the sequence branch and then calls str() on every item of any other Sequence unchecked. Measured with the S-CORE profile, real runs, real XML:
fully_verifies=b"REQ_1" -> <property name="FullyVerifies" value="82, 69, 81, 95, 49">
partially_verifies=[["REQ_1", "REQ_2"]] -> <property name="PartiallyVerifies" value="['REQ_1', 'REQ_2']">
partially_verifies=[[]], fully_verifies=["R"] -> <property name="PartiallyVerifies" value="[]">
The build then splits them into 82;69;81;95;49, ['REQ_1';'REQ_2'] and [] as link IDs, silently, against the page's sentence that "a project cannot lose requirement IDs to a Python repr silently". The third row also shows _empty and _serialise disagreeing about [[]]: the import-time gate fires only when every value is empty, so it passes alongside a real value. apply_test_metadata is the realistic source, since a list of lists is one indentation away in a spec file. Fix: reject bytes/bytearray outright and require each item of a sequence to be a str, with a TypeError naming the item; that makes the two functions agree by construction. One test each.
4. The conf.py recipe on the page is deprecated on the sphinx-needs this repository just pinned CI to. docs/pytest.rst prescribes needs_extra_links and says each plain field "needs its needs_extra_options entry". Built verbatim against sphinx-needs 8.5.0 under -W:
WARNING: Config option "needs_extra_options" is deprecated. Please use "needs_fields" instead. [needs.deprecated]
WARNING: Config option "needs_extra_links" is deprecated. Please use "needs_links" instead. [needs.deprecated]
Both replacements arrived in sphinx-needs 7.0.0, the exact version this repository's own docs/conf.py switches on. The modern spelling (needs_links = {...}, needs_fields = {"TestType": {"nullable": True}, ...}) builds clean with the same project and report. The floor is 6.0.1, so either mirror the conf.py switch or add one clause: on sphinx-needs < 7 these are needs_extra_links and needs_extra_options. This is the successor of finding 8, whose fix added the deprecated spelling.
Worth fixing
- Cases skipped at setup carry the other convention, and under Bazel that moves the deterministic ID. The location is recorded by a function-scoped autouse fixture, which never runs for
@pytest.mark.skip,skipif(True),xfail(run=False)or a case whose module-scoped fixture errors; those keep pytest's stock 0-based, uncutfile/lineand carry no<properties>. So one report mixes 1-based executed cases with 0-based skipped ones, and in a tree with a component named_mainthe skipped case'sfilekeeps the prefix:deterministic_case_id, which hashes the file, givestestcase__test_x__test_skipped_qybocwhen the test runs and…_ijsjzwhen it is skipped.score_pytesthas the same fixture, so this is inherited, not a regression. But this repository is the one shippingtr_deterministic_case_ids, and "Every test case now carriesfileandline" denies it. Apytest_runtest_makereporthookwrapper on thesetupreport reaches every case, becauserecord_testreportrestores already-set attributes over its own; measured as a 21-line conftest on both probe trees, every skipped case corrected, nothing else moved, theapply_test_metadataoverride still wins. It does reach into_pytest.junitxml.xml_key, which is private (present on 7.0.1), so that is your call. The docs sentence needs changing either way. - A nested in-process pytest session that loads the plugin empties the outer model.
PROPERTIESis a module global cleared bypytest_unconfigure, sopytester.runpytest("-p", <plugin>)(in-process isrunpytest's default) or apytest.main()inside a test leaves every decorated test after it erroring at setup with "'partially_verifies' is not a configured property", a message that tells the user to declare what they declared. Narrow, since the inner session must itself load the plugin, but that is what a project testing its own pytest setup does, and the head's suite dodges it only by running its inner sessions as subprocesses. Put the model onconfig.stash(the fixture and_marked_propertiesalready hold the config), or havepytest_configuresave andpytest_unconfigurerestore the previous value. My mutation that madepytest_unconfigurea no-op survived the whole suite; an in-process inner run between two decorated tests is the regression test for both. - "the XML is shaped identically" to
score_pytesthas four exceptions, and the two the texts name are the two that do not touch the XML. Same file under both plugins, diffed: a case that errors in a later fixture carries properties here and none there (they are written at setup now, and I think that is right); property order follows the keywords, not S-CORE's fixed order; the_maincut differs by design (finding 5); andapply_test_metadata(file=…)is cleaned here and passed through raw there. Name them in "Origin" and the docstring; three are improvements. - Two keywords may declare the same
XmlName.parse_properties(['a = B', 'c = B'])is accepted and_normalisethen keeps only the later value. The duplicate-keyword check applied toProperty.namecloses it. - "a wrong shape … fails that test": it errors at setup (the test asserts
errors=1), and in this tool the need then carriesresult="error", notfailure. test_reports_propertiesin the ini without the-pline isERROR: Unknown config optionunder--strict-config; one sentence next to the example saying the two blocks are a unit.test_the_plugin_does_not_import_sphinxcould also assertdocutilsis absent (it passes today).
Nits and for the record
A real keyword shadows another property's XmlName in _lookup (a = B and B = C: writing B=… gives <property name="C">); the precedence is right, the "doubles as keyword" sentence just does not say so. The comma-in-a-value hazard you raised in the design question is on cli.rst and configuration.rst but not on the page that produces the value. "The plugin tests pass on pytest 7.0.1" holds on Python 3.11 only: on 3.12 five fail on pytest's own ast.Str deprecation in the assertion rewriter, not on the plugin (7.0.1 predates 3.12; pytest>=7.4 would make the statement interpreter-independent). versionadded:: 1.5.0 is a release-time check if #159's breaking entry makes the release 2.0.
For #159, which lands second: the merge has two keep-both-sides conflicts (pyproject.toml extras, tests/conftest.py's pytest_plugins) and the changelog auto-merges. And tests/test_pytest_plugin.py is missing from its TOOLCHAIN_FREE_TESTS. That session is the only place "the plugin needs no Sphinx" is proven in CI's real shape; built as the nox session builds it (. plus pytest, no toolchain), the module passes 46 with the one xdist skip, and with the eight listed files 294 pass. One line in whichever PR lands second.
MaximilianSoerenPollak
left a comment
There was a problem hiding this comment.
From my side with the changes made etc. this now seems like a good candidate and starting point for further improvements.
💯
Review by chrisjsewell on 72ff2dd, all items. The location and the properties were recorded by a function-scoped autouse fixture, which never runs for a case skipped or erroring during setup: those kept pytest's stock 0-based, uncut file/line and carried no properties, so one report mixed the two conventions and, under Bazel, a deterministic ID moved with the outcome. Both are hooks now. A pytest_runtest_makereport wrapper attaches the markers' properties to the item before its setup report is made (a wrong shape turns that report into an error with the message), and a per-session pytest_runtest_logreport hook writes file/line from the setup report on the process that owns pytest's XML writer. That process is the xdist controller, so the locations now hold under -n as well; the apply_test_metadata override travels as two reserved user_properties entries that the hook takes out before pytest writes the properties, and record_xml_attribute is no longer used (still accepted by apply_test_metadata for score_pytest call sites). The xdist notice and the experimental-notice suppression go with the fixture. The XML writer sits behind _pytest.junitxml.xml_key; its absence is handled, not assumed. str(item) inside the join re-opened findings 1 and 2 one level down: b"REQ_1" became "82, 69, 81, 95, 49", [["REQ_1", "REQ_2"]] its repr, and [[]] the text "[]", all silently split into link IDs on the build side. Every item of a list must be a str now, bytes/bytearray are rejected, and the import-time emptiness check uses the writer's definition of empty so the two cannot disagree. Two keywords declaring one XML name are an error at start-up, where _normalise used to keep the later value. PROPERTIES was cleared by pytest_unconfigure, so an in-process inner session (pytest.main() in a test) left every later decorated test erroring on "not a configured property". pytest_configure saves the outer model and pytest_unconfigure restores it. The premise was wrong in four places: pytest does write file/line under xunit1, 0-based and with the runfiles prefix intact; only the default xunit2 drops them. Module docstring, docs and changelog now say what the plugin adds. The docs' conf.py recipe uses needs_links/needs_fields, which sphinx-needs 7.0.0 introduced while deprecating needs_extra_links and needs_extra_options, with a clause for sphinx-needs 6 -- built end to end under -W against a plugin-written report. Also on the page: the four deviations from score_pytest's XML, "errors at setup" instead of "fails", the --strict-config unit of ini option and -p line, the comma hazard, the keyword-over-XmlName precedence. The no-Sphinx test asserts docutils too. The pytest extra requires 7.4, the first pytest that runs on Python 3.12.
…roperties A conftest.py that imports a helper module runs during pre-parse, before the model is installed; the decorator storing the keywords as given is what makes that shape work, and nothing covered it. The probe from the review: a conftest importing a helper with a decorated function, run with the S-CORE profile, the properties written at setup.
|
@chrisjsewell on your review of 72ff2dd: Thanks for measuring rather than reading. All four blocking items and the seven under "worth fixing" are in, on top of the rebased branch, plus the nits and the probe you suggested. Commits 5d044e8 and aace50d; 57 tests. In plain words: Blocking
Worth fixing
The probe you suggested is a test now: a Nits: the keyword-over-XmlName precedence is stated on the page; the Left open on purpose, two items: |
chrisjsewell
left a comment
There was a problem hiding this comment.
Re-reviewed at aace50d4, the same way: every round-one finding re-measured on the new head, the new hook code put through its own battery, and the suites re-run. The four blocking items are closed, and fourteen of the sixteen round-one findings with them; the two left open (versionadded and the TOOLCHAIN_FREE_TESTS line) are the two we agreed to defer. Two small things remain before I approve, both below; everything else is your call.
The rewrite holds, measured. The eleven-shape file from round one (pass, fail, skip, skipif, xfail(run=False), a function-fixture error, a module-fixture error, a class-level decorator, a keyword-order variant, the parameterised pair) now gives every case the 1-based line, the cut path and its properties, skipped and setup-erroring ones included, and in the tree with a _main component the skipped case's deterministic ID now equals the executed one's. Under -n 2 the report is byte-equivalent to the serial run, override applied, with no reserved entry in any <properties>. The report.user_properties mechanics are the ones pytest actually has: each report holds its own copy, the setup report carries what the wrapper appended before yield, junitxml writes properties from the teardown report, and the tryfirst handler strips before it reads. Eight mutations of the hook code: seven red on the right tests (dropping tryfirst alone loses every attribute; dropping the worker guard makes the worker strip its own override, so the guard is load-bearing, not belt and braces) and the eighth, writing the location on every phase, byte-diffs identical, so nothing should pin it. Every repr input from round one is now a TypeError at setup with the case shown as <error>. The recipe on the page builds a real report end to end under -W on sphinx-needs 8.5.0 with zero warnings, links landing on both fields; the sphinx-needs 6 sentence is necessary and right. 388 passed on both cells with no skips, 57 in the module with no Sphinx installed, 55 on pytest 7.4.4, mypy and the -W docs build clean. The conftest-before-configure probe is a test now and it is the right one.
Before I approve
1. A score_pytest call site that still requests the record_xml_attribute fixture errors at setup under a strict warning policy, and it passed on 72ff2dde. The page and the docstring say such calls "may keep passing record_xml_attribute; it is accepted and not needed", but the problem is the fixture request in the test's signature, not the argument: pytest issues PytestExperimentalApiWarning for it, and under -W error or filterwarnings = error that is a setup error. On the previous head the plugin's own fixture requested it inside catch_warnings() first, so the test's request got the cached value silently; removing the fixture removed that cover. test_record_xml_attribute_is_still_accepted asserts warnings=1, so it encodes the notice rather than the strict case. Zero code: make the compatibility sentence say to drop the fixture from the signature and why, and add the strict-policy run to that test.
2. An empty list under a single-valued or undeclared keyword is a TypeError, and apply_test_metadata raises it into the test body. The arity check runs before the items are looked at, so test_type=[] or Owner=[] never reaches "nothing to write". With the documented file-driven pattern and a parser that returns [] for an absent field:
FAILED test_md.py::test_driven_by_a_file[a] - TypeError: 'test_type' takes a single value, not a sequence; …
FAILED test_md.py::test_driven_by_a_file[b] - TypeError: …
That is your review finding 7 returning through the list-valued door, against the page's "metadata without values … writes no properties and is not an error", and _empty still disagrees with _serialise on these shapes. Three lines: filter the None/"" items first, return None when nothing is left, then check arity. One test per direction.
At your discretion
- An inner in-process session whose
pytest_configurefails (a bad ini line) pops the outer model, because the push sits below the code that raises whilepytest_unconfigurepops regardless; measured with your nested-session shape plus a broken ini. Move_OUTER_MODELS.append(dict(PROPERTIES))to the first statement ofpytest_configure. - Under
-n 2withxunit2the start-up notice is issued three times (controller plus workers); pytest collapses the text, so the count reads3 warningsrather than a flood. The old xdist notice had theworkerinputguard and_notifyhas none now. - A bad property shape on a test whose fixture also raises at setup shows only the plugin's message; the fixture error is gone. Self-correcting, worth a line in the wrapper's docstring or an append rather than a replace.
- The two reserved
user_propertiesnames are documented nowhere; a user who writes one has the property consumed as a location override with no message. - The body says 56 tests (57 by
--collect-only), and "7.4, the first pytest that runs on Python 3.12" is not true, 7.3 runs and passes the module. The floor is fine; the reason, which is also a comment inpyproject.toml, is not.
One observation, not a finding: a skipped test now contributes its requirement links, with result="skipped" next to them. Strictly better than before, and honest, but a traceability query that does not look at result answers "verified" on a test that did not run. Worth a sentence on the page.
I will approve on the next push after checking the two items by hand; no further round is needed for the rest.
…n CI Second review by chrisjsewell on aace50d. An empty list under a single-valued or undeclared keyword was a TypeError, because the arity check ran before the items were looked at; a parser handing back [] for an absent field made apply_test_metadata raise into the test body, against the documented "empty metadata is not an error". Empty items are dropped first now, nothing left means nothing written, and only then does the list-or-single rule apply. The pytest extra is back at >=7.0, the widest range the plugin's use of pytest allows, with an honest comment: pytest before 7.3.2 does not run on Python 3.12 by itself, which is pytest's floor, not this one's. A plugin_floor nox session, wired into CI as two jobs, runs the plugin's tests on the oldest pytest of each Python -- 7.0.1 on 3.11, 7.3.2 on 3.12 -- so the claim is measured on every push. Smaller things from the same review: pytest_configure pushes the outer model as its first statement, so an inner session that fails to configure (a bad ini line) pops its own entry and not the outer one; the xunit2 notice is issued on the controller only, not once more per xdist worker; a bad property shape on a case whose fixture also failed at setup is appended to that error instead of replacing it; the two reserved user_properties names of the location override are documented, on the page and in the docstring. Two documented decisions. A score_pytest call site may keep passing record_xml_attribute, but should drop the fixture from the test's signature: requesting pytest's fixture is what draws its experimental-feature notice, a setup error under -W error, and the plugin neither requests it nor filters it any more, so that request and its notice are the test's own business -- a test pins the strict-policy behaviour. And a skipped test keeps its requirement links, next to result="skipped": the plugin records the link, the project gives it its meaning, and a traceability query that reads "verified" off a link has to look at result as well.
|
@chrisjsewell on your second review: round two is in as 417cd34, and CI grew two jobs you may want to look at first. The two before approval
Empty lists under single-valued or undeclared keywords. Fixed the way you described. Empty items go first, nothing left means nothing written, and only then the list-or-single rule. At your discretion, all taken
Your observation about skipped tests is on the page now. We keep the links: the plugin records that this test claims to verify REQ_1, with The two deferrals stand: |
chrisjsewell
left a comment
There was a problem hiding this comment.
Checked 417cd34 by hand, as promised. Approving.
The two items hold. A spec parser that hands back [] for every absent field now writes nothing and the one real value survives (2 passed, FullyVerifies="REQ_9" alone in the XML); the documented call shape without the fixture passes under -W error with the override applied (file="specs/a.rst" line="3"); the old shape that still requests record_xml_attribute is now the test's own notice, and the page says to drop it and why. The empty-items-first order in _serialise is the fix I described, and _empty agrees with it by construction.
The discretionary items I re-read in the diff: the outer model is pushed as the first statement of pytest_configure, the xunit2 notice carries the workerinput guard, a property error is appended to a fixture error rather than replacing it, the two reserved names are on the page and in the docstring, and the skipped-test sentence is the right one. On the floor: pytest>=7.0 with the reason stated as pytest's own 3.12 boundary is honest, and the plugin_floor session makes it measured rather than claimed. Both new CI cells ran the real thing: 63 passed on pytest 7.0.1 / Python 3.11 and on 7.3.2 / 3.12, and all 29 legs are green at this head. Locally: 394 passed on the full suite, 63 in the module with no Sphinx installed, mypy clean, the docs build clean under -W, and the body's count is right.
The two deferrals stand as agreed: versionadded when the release number is decided, and tests/test_pytest_plugin.py into TOOLCHAIN_FREE_TESTS in whichever of #151 and #159 lands second.
…xtra #151 made the pytest plugin the `pytest` extra, so with Sphinx and sphinx-needs moved to the `sphinx` extra, `pip install "sphinx-test-reports[pytest]"` gives a test runner the plugin, pytest and `lxml` -- nothing of the documentation toolchain. Nothing verified that: the `toolchain_free` session ran the converter's tests only, and `plugin_floor` installed `.[test]`, which now brings the toolchain back in. Both sessions install `.[pytest]` -- what a test runner installs -- and assert that the toolchain is absent before running. `toolchain_free` adds the plugin's test module (its three pytest-xdist tests skip there); `plugin_floor` adds pytest-xdist so those keep running at the floor, and forwards nox's posargs, which CI passes (`--full-trace`) and it ignored. The install page lists the three install lines side by side -- the `sphinx` extra for a documentation project, the `pytest` extra for a test runner, the bare package for a build action running the command -- and the plugin's page, which said how to enable it but not how to install it, names the extra. The changelog does too.
… and `[pytest]` install without the toolchain (#159) Closes #155. ## What changes `pip install sphinx-test-reports` now installs `lxml` alone. Sphinx and sphinx-needs move to a `sphinx` extra: ```toml dependencies = ["lxml"] [project.optional-dependencies] sphinx = ["sphinx>=7.4", "sphinx-needs>=6.0.1"] pytest = ["pytest>=7.0"] # from #151, unchanged ``` So the install line of a documentation project becomes `pip install "sphinx-test-reports[sphinx]"`, and a test runner or build action that only wants `test-reports build needs` no longer pulls the toolchain. The same goes for the pytest plugin #151 added as the `pytest` extra: `pip install "sphinx-test-reports[pytest]"` now yields the plugin, pytest and `lxml`, nothing of the toolchain. `[test]` gains the two floors it used to inherit from the core list; `[docs]` already had them; the nox matrix pins both explicitly and is unaffected. The install page lists the three install lines side by side (`sphinx` extra for a documentation project, `pytest` extra for a test runner, bare package for a build action running the command), and the plugin's page, which said how to enable the plugin but not how to install it, names the extra. ## The version-constraint gap An extra is opt-in, so a project that keeps installing the bare package next to an already-installed older toolchain never shows pip the floors. The lazy `setup` in `__init__.py` now enforces them at load time, before the extension is imported (an outdated sphinx-needs may well import and only fail later, inside a directive): ``` Extension error: Could not load extension sphinxcontrib.test_reports: sphinx-needs 5.1.0 is installed, but sphinx-needs>=6.0.1 is required. Install the Sphinx extension's dependencies with: pip install "sphinx-test-reports[sphinx]" ``` A toolchain that is missing altogether keeps the existing "Could not import extension" path, which now names the extra too: ``` Extension error: Could not import extension sphinxcontrib.test_reports; the Sphinx extension's dependencies are an extra, install them with: pip install "sphinx-test-reports[sphinx]" (exception: No module named 'sphinx_needs') ``` One deviation from the sketch in the issue: instead of `app.require_sphinx((7, 4))` plus a hard-coded sphinx-needs floor, the new `toolchain` module reads the `sphinx` extra's requirements from the package's own metadata (`importlib.metadata.requires`) and compares each installed version against its specifier. The floors then live in `pyproject.toml` alone -- #150 just moved them, and a copy in code would need a lockstep test -- and a test guards that the extra the check reads is the one `pyproject.toml` declares. `packaging` does the parsing; it is imported lazily and the check stands down without it, since it arrives with Sphinx (and pytest) wherever the extension can be loaded. A distribution that is *not installed* is deliberately left to the import: the import states it more precisely, and a toolchain importable from a source tree without metadata must not be refused. On Sphinx 9 the error appears inside Sphinx's crash report, as the existing "Known" changelog entry already notes for the configuration errors; the note now covers this case too. ## Guaranteeing the property in CI A `toolchain_free` nox session (Python 3.11 and 3.12, new CI job, wired into `all_good`) installs the package with the dependencies it declares -- `pip install ".[pytest]"`, no `--no-deps`, so the declared lists themselves are what is tested -- asserts that neither `sphinx`, `sphinx_needs` nor `docutils` is importable, and runs the converter's and the pytest plugin's test modules with `-m "not toolchain"`. The tests that need a build carry the new `toolchain` mark (the issue suggested `needs_sphinx`; `sphinx` itself is taken by Sphinx's own testing fixtures). `tests/conftest.py` loads `sphinx.testing.fixtures` only where Sphinx is importable, and one top-level import of the package's Sphinx-dependent `exceptions` module in `test_project_config.py` became local to the marked test that uses it. #151's `plugin_floor` session (the plugin's tests on the oldest pytest per Python) installed `.[test]`, which after this change brings the toolchain back in. It now installs `.[pytest]` plus pytest-xdist and runs the same toolchain-absent guard, so the floor run doubles as the isolation proof on old pytest. It also forwards nox's posargs, which CI passes (`--full-trace`) and it ignored. The mypy CI job installs with `uv sync --group dev --extra sphinx`; without the extra it would no longer see Sphinx and docutils. ## Open questions from the issue, as resolved here - **Extra name:** `sphinx`, as proposed. It matches the `[docs]`/`[test]` style and the `[pytest]` extra from #151. - **1.5.0 or 2.0:** not decided here. The changelog carries a "Breaking:" entry spelling out the install-line change; `version` is untouched, so the release can decide. - **Two-distribution split:** not done, per the suggested path. - **`[pytest]` extra:** on master since #151; this PR is what makes it toolchain-free, and the second commit proves and documents that. ## Verification - `pytest -n auto tests/`: 407 passed (13 new in `tests/test_toolchain.py`) - `nox -s toolchain_free-3.11 toolchain_free-3.12`: 308 passed, 3 skipped (the pytest-xdist tests; xdist is not installed there), 24 `toolchain`-marked deselected, in a venv holding the package, `lxml` and pytest only; the toolchain-absent guard passes - `nox -s "plugin_floor(python='3.11', pytest_version='7.0.1')" "plugin_floor(python='3.12', pytest_version='7.3.2')"`: 63 passed each, toolchain absent - `mypy`: clean (the new module is strict-checked, not excluded) - `pre-commit run --all-files`: clean - `sphinx-build -W` of the docs: clean ## Notes for the reviewer - Rebased onto #151 (`pyproject.toml`, `noxfile.py`, `ci.yaml`, `tests/conftest.py` conflicted; all resolved as a union of both sides). The first commit is the original change; the second is the plugin-isolation follow-up and can be reviewed, or dropped, on its own. - `docs/install.rst` also fixes two pre-existing typos on the page it rewrites ("must to be", "can be find").
…locks/sphinx-test-reports#151) Based on master. Independent of the needs.json converter (useblocks/sphinx-test-reports#148, merged); rebased on top of it. ## Why A stock pytest run writes a JUnit XML this extension can only partly use. Under pytest's default `junit_family = xunit2`, no `<testcase>` carries the `file`/`line` attributes that give a test case its source location (`tr_source_file_option`/`tr_source_line_option`, and the deterministic ID of `tr_deterministic_case_ids`). Under `xunit1` pytest writes them, but counts the line from 0, keeps Bazel's runfiles prefix, and has no way to point a case at the file that drove it. And nothing records the requirements a test verifies. ## What `-p sphinxcontrib.test_reports.pytest_plugin`, with `junit_family = xunit1` (warned about otherwise), does two things to every `<testcase>`, through hooks on the test reports rather than fixtures, so a case skipped or erroring during setup gets the same treatment and pytest-xdist works: - the source location becomes the one an editor shows: line counted from 1, path relative to the rootdir with Bazel's `_main/` runfiles prefix cut at a whole path component; - the properties of an `add_test_properties(...)` decorator (or, for file-driven parameterised tests, the `apply_test_metadata(...)` runtime helper, which can also point the case at the driving file) are written as `<properties>`. Which properties exist is configuration, not code. The `test_reports_properties` ini option declares them, one `keyword [= XmlName] [, list]` per line: the keyword a test writes, the `<property>` name it is written under, and whether it takes a list (joined with `", "`, which `tr_property_link_types` splits again). A keyword not declared is written under its own name with a single value; a list under it, a list item that is not a string, or bytes, is a `TypeError` naming the option, so nothing is lost to a Python `repr` silently. The plugin ships no metamodel of its own. S-CORE's model is the worked example in the docs: ```ini [pytest] addopts = -p sphinxcontrib.test_reports.pytest_plugin junit_family = xunit1 test_reports_properties = partially_verifies = PartiallyVerifies, list fully_verifies = FullyVerifies, list test_type = TestType derivation_technique = DerivationTechnique ``` Ported from S-CORE docs-as-code's `score_pytest` attribute plugin: same two entry-point names, and with that example the XML comes out the same, so tests written against that plugin keep working when they import from here. Four deliberate differences in the XML: a case skipped or erroring at setup carries its location and properties here and pytest's stock values there; the properties keep the order of the keywords; the Bazel prefix is cut at a whole `_main` component only; and a `file` handed to `apply_test_metadata` is cut the same way. Two of that plugin's rules are not ported, as process rules of that project rather than properties of the data: the `TestType`/`DerivationTechnique` vocabularies are documented, not enforced, and a decorated test needs no docstring. The package `__init__` resolves `setup` lazily (PEP 562, useblocks/sphinx-test-reports#145), so loading the plugin into a pytest process imports neither Sphinx nor docutils; a test asserts it. A per-module mypy override lifts only the two `Any` checks the pytest API needs. A `pytest` extra (`pytest>=7.0`, where everything the plugin uses exists) pulls pytest in; it is not a dependency of the extension. pytest before 7.3.2 does not run on Python 3.12 by itself, so a `plugin_floor` nox session, wired into CI, runs the plugin's tests on the oldest pytest of each Python: 7.0.1 on 3.11, 7.3.2 on 3.12. ## Tests `tests/test_pytest_plugin.py`, 63 tests through `pytester`, each inner pytest a fresh subprocess: locations (a decorated function is located at its first decorator, as pytest does; skipped and setup-erroring cases included), the properties written from ini and pyproject declarations, the option grammar and its errors, the value rules, marker merging across class and function, the runtime helper's location override, nested in-process sessions (one failing to configure), a decorator applied before `pytest_configure`, a bad shape next to a fixture error, `--strict-markers`, the `xunit2` start-up notice under strict warning policies, `-p no:junitxml`, `legacy`, an xdist run, the Bazel path cut, and that importing the plugin imports no Sphinx. Verified on pytest 7.0.1 and 7.4.4 (Python 3.11), 7.3.2, 7.4.4 and 8.4.2 (3.12) and 9.1.1 (3.14).
… and `[pytest]` install without the toolchain (useblocks/sphinx-test-reports#159) Closes useblocks/sphinx-test-reports#155. ## What changes `pip install sphinx-test-reports` now installs `lxml` alone. Sphinx and sphinx-needs move to a `sphinx` extra: ```toml dependencies = ["lxml"] [project.optional-dependencies] sphinx = ["sphinx>=7.4", "sphinx-needs>=6.0.1"] pytest = ["pytest>=7.0"] # from useblocks/sphinx-test-reports#151, unchanged ``` So the install line of a documentation project becomes `pip install "sphinx-test-reports[sphinx]"`, and a test runner or build action that only wants `test-reports build needs` no longer pulls the toolchain. The same goes for the pytest plugin useblocks/sphinx-test-reports#151 added as the `pytest` extra: `pip install "sphinx-test-reports[pytest]"` now yields the plugin, pytest and `lxml`, nothing of the toolchain. `[test]` gains the two floors it used to inherit from the core list; `[docs]` already had them; the nox matrix pins both explicitly and is unaffected. The install page lists the three install lines side by side (`sphinx` extra for a documentation project, `pytest` extra for a test runner, bare package for a build action running the command), and the plugin's page, which said how to enable the plugin but not how to install it, names the extra. ## The version-constraint gap An extra is opt-in, so a project that keeps installing the bare package next to an already-installed older toolchain never shows pip the floors. The lazy `setup` in `__init__.py` now enforces them at load time, before the extension is imported (an outdated sphinx-needs may well import and only fail later, inside a directive): ``` Extension error: Could not load extension sphinxcontrib.test_reports: sphinx-needs 5.1.0 is installed, but sphinx-needs>=6.0.1 is required. Install the Sphinx extension's dependencies with: pip install "sphinx-test-reports[sphinx]" ``` A toolchain that is missing altogether keeps the existing "Could not import extension" path, which now names the extra too: ``` Extension error: Could not import extension sphinxcontrib.test_reports; the Sphinx extension's dependencies are an extra, install them with: pip install "sphinx-test-reports[sphinx]" (exception: No module named 'sphinx_needs') ``` One deviation from the sketch in the issue: instead of `app.require_sphinx((7, 4))` plus a hard-coded sphinx-needs floor, the new `toolchain` module reads the `sphinx` extra's requirements from the package's own metadata (`importlib.metadata.requires`) and compares each installed version against its specifier. The floors then live in `pyproject.toml` alone -- useblocks/sphinx-test-reports#150 just moved them, and a copy in code would need a lockstep test -- and a test guards that the extra the check reads is the one `pyproject.toml` declares. `packaging` does the parsing; it is imported lazily and the check stands down without it, since it arrives with Sphinx (and pytest) wherever the extension can be loaded. A distribution that is *not installed* is deliberately left to the import: the import states it more precisely, and a toolchain importable from a source tree without metadata must not be refused. On Sphinx 9 the error appears inside Sphinx's crash report, as the existing "Known" changelog entry already notes for the configuration errors; the note now covers this case too. ## Guaranteeing the property in CI A `toolchain_free` nox session (Python 3.11 and 3.12, new CI job, wired into `all_good`) installs the package with the dependencies it declares -- `pip install ".[pytest]"`, no `--no-deps`, so the declared lists themselves are what is tested -- asserts that neither `sphinx`, `sphinx_needs` nor `docutils` is importable, and runs the converter's and the pytest plugin's test modules with `-m "not toolchain"`. The tests that need a build carry the new `toolchain` mark (the issue suggested `needs_sphinx`; `sphinx` itself is taken by Sphinx's own testing fixtures). `tests/conftest.py` loads `sphinx.testing.fixtures` only where Sphinx is importable, and one top-level import of the package's Sphinx-dependent `exceptions` module in `test_project_config.py` became local to the marked test that uses it. useblocks/sphinx-test-reports#151's `plugin_floor` session (the plugin's tests on the oldest pytest per Python) installed `.[test]`, which after this change brings the toolchain back in. It now installs `.[pytest]` plus pytest-xdist and runs the same toolchain-absent guard, so the floor run doubles as the isolation proof on old pytest. It also forwards nox's posargs, which CI passes (`--full-trace`) and it ignored. The mypy CI job installs with `uv sync --group dev --extra sphinx`; without the extra it would no longer see Sphinx and docutils. ## Open questions from the issue, as resolved here - **Extra name:** `sphinx`, as proposed. It matches the `[docs]`/`[test]` style and the `[pytest]` extra from useblocks/sphinx-test-reports#151. - **1.5.0 or 2.0:** not decided here. The changelog carries a "Breaking:" entry spelling out the install-line change; `version` is untouched, so the release can decide. - **Two-distribution split:** not done, per the suggested path. - **`[pytest]` extra:** on master since useblocks/sphinx-test-reports#151; this PR is what makes it toolchain-free, and the second commit proves and documents that. ## Verification - `pytest -n auto tests/`: 407 passed (13 new in `tests/test_toolchain.py`) - `nox -s toolchain_free-3.11 toolchain_free-3.12`: 308 passed, 3 skipped (the pytest-xdist tests; xdist is not installed there), 24 `toolchain`-marked deselected, in a venv holding the package, `lxml` and pytest only; the toolchain-absent guard passes - `nox -s "plugin_floor(python='3.11', pytest_version='7.0.1')" "plugin_floor(python='3.12', pytest_version='7.3.2')"`: 63 passed each, toolchain absent - `mypy`: clean (the new module is strict-checked, not excluded) - `pre-commit run --all-files`: clean - `sphinx-build -W` of the docs: clean ## Notes for the reviewer - Rebased onto useblocks/sphinx-test-reports#151 (`pyproject.toml`, `noxfile.py`, `ci.yaml`, `tests/conftest.py` conflicted; all resolved as a union of both sides). The first commit is the original change; the second is the plugin-isolation follow-up and can be reviewed, or dropped, on its own. - `docs/install.rst` also fixes two pre-existing typos on the page it rewrites ("must to be", "can be find").
Based on master. Independent of the needs.json converter (#148, merged); rebased on top of it.
Why
A stock pytest run writes a JUnit XML this extension can only partly use. Under pytest's default
junit_family = xunit2, no<testcase>carries thefile/lineattributes that give a test case its source location (tr_source_file_option/tr_source_line_option, and the deterministic ID oftr_deterministic_case_ids). Underxunit1pytest writes them, but counts the line from 0, keeps Bazel's runfiles prefix, and has no way to point a case at the file that drove it. And nothing records the requirements a test verifies.What
-p sphinxcontrib.test_reports.pytest_plugin, withjunit_family = xunit1(warned about otherwise), does two things to every<testcase>, through hooks on the test reports rather than fixtures, so a case skipped or erroring during setup gets the same treatment and pytest-xdist works:_main/runfiles prefix cut at a whole path component;add_test_properties(...)decorator (or, for file-driven parameterised tests, theapply_test_metadata(...)runtime helper, which can also point the case at the driving file) are written as<properties>.Which properties exist is configuration, not code. The
test_reports_propertiesini option declares them, onekeyword [= XmlName] [, list]per line: the keyword a test writes, the<property>name it is written under, and whether it takes a list (joined with", ", whichtr_property_link_typessplits again). A keyword not declared is written under its own name with a single value; a list under it, a list item that is not a string, or bytes, is aTypeErrornaming the option, so nothing is lost to a Pythonreprsilently. The plugin ships no metamodel of its own.S-CORE's model is the worked example in the docs:
Ported from S-CORE docs-as-code's
score_pytestattribute plugin: same two entry-point names, and with that example the XML comes out the same, so tests written against that plugin keep working when they import from here. Four deliberate differences in the XML: a case skipped or erroring at setup carries its location and properties here and pytest's stock values there; the properties keep the order of the keywords; the Bazel prefix is cut at a whole_maincomponent only; and afilehanded toapply_test_metadatais cut the same way. Two of that plugin's rules are not ported, as process rules of that project rather than properties of the data: theTestType/DerivationTechniquevocabularies are documented, not enforced, and a decorated test needs no docstring.The package
__init__resolvessetuplazily (PEP 562, #145), so loading the plugin into a pytest process imports neither Sphinx nor docutils; a test asserts it. A per-module mypy override lifts only the twoAnychecks the pytest API needs. Apytestextra (pytest>=7.0, where everything the plugin uses exists) pulls pytest in; it is not a dependency of the extension. pytest before 7.3.2 does not run on Python 3.12 by itself, so aplugin_floornox session, wired into CI, runs the plugin's tests on the oldest pytest of each Python: 7.0.1 on 3.11, 7.3.2 on 3.12.Tests
tests/test_pytest_plugin.py, 63 tests throughpytester, each inner pytest a fresh subprocess: locations (a decorated function is located at its first decorator, as pytest does; skipped and setup-erroring cases included), the properties written from ini and pyproject declarations, the option grammar and its errors, the value rules, marker merging across class and function, the runtime helper's location override, nested in-process sessions (one failing to configure), a decorator applied beforepytest_configure, a bad shape next to a fixture error,--strict-markers, thexunit2start-up notice under strict warning policies,-p no:junitxml,legacy, an xdist run, the Bazel path cut, and that importing the plugin imports no Sphinx. Verified on pytest 7.0.1 and 7.4.4 (Python 3.11), 7.3.2, 7.4.4 and 8.4.2 (3.12) and 9.1.1 (3.14).