narrativetrace-pytest 0.1.0__tar.gz
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.
- narrativetrace_pytest-0.1.0/.gitignore +28 -0
- narrativetrace_pytest-0.1.0/LICENSE +99 -0
- narrativetrace_pytest-0.1.0/PKG-INFO +23 -0
- narrativetrace_pytest-0.1.0/README.md +4 -0
- narrativetrace_pytest-0.1.0/pyproject.toml +36 -0
- narrativetrace_pytest-0.1.0/src/narrativetrace_pytest/__init__.py +3 -0
- narrativetrace_pytest-0.1.0/src/narrativetrace_pytest/plugin.py +316 -0
- narrativetrace_pytest-0.1.0/src/narrativetrace_pytest/py.typed +0 -0
- narrativetrace_pytest-0.1.0/tests/test_conformance.py +250 -0
- narrativetrace_pytest-0.1.0/tests/test_plugin.py +678 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
.hypothesis/
|
|
8
|
+
.mutmut-cache
|
|
9
|
+
mutants/
|
|
10
|
+
# pytest-benchmark autosave (poe bench-gate): per-machine comparison history, never comparable
|
|
11
|
+
# across machines -- stays local to whichever machine runs the nightly, like .venv/.uv-cache.
|
|
12
|
+
.benchmarks/
|
|
13
|
+
htmlcov/
|
|
14
|
+
.coverage
|
|
15
|
+
.coverage.*
|
|
16
|
+
dist/
|
|
17
|
+
build/
|
|
18
|
+
*.egg-info/
|
|
19
|
+
|
|
20
|
+
# Cache for security-tool binaries auto-fetched by scripts/run_security_tool.py
|
|
21
|
+
.tools/
|
|
22
|
+
|
|
23
|
+
# Local tooling state (contains credentials — never commit)
|
|
24
|
+
.devcontainer/claude-home/
|
|
25
|
+
.agent-run*
|
|
26
|
+
|
|
27
|
+
# Local tooling state (holds host-absolute paths)
|
|
28
|
+
.claude/settings.local.json
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
License text copyright © 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
4
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
5
|
+
|
|
6
|
+
Parameters
|
|
7
|
+
|
|
8
|
+
Licensor: Empower Agile
|
|
9
|
+
|
|
10
|
+
Licensed Work: NarrativeTrace for Python (the narrativetrace PyPI distributions) version {{VERSION}}. The Licensed Work is (c) 2026 Empower Agile.
|
|
11
|
+
|
|
12
|
+
Additional Use Grant: You may make production use of the Licensed Work for any
|
|
13
|
+
purpose, including internal use and use in products and
|
|
14
|
+
services you provide to your own customers, provided that
|
|
15
|
+
such production use does not include offering the
|
|
16
|
+
Licensed Work, or a product or service whose value
|
|
17
|
+
derives substantially from the Licensed Work, to third
|
|
18
|
+
parties as a logging, tracing, or code-narrative product
|
|
19
|
+
or service.
|
|
20
|
+
|
|
21
|
+
Change Date: Four years from the date the Licensed Work is published. ({{CHANGE_DATE}} for this version)
|
|
22
|
+
|
|
23
|
+
Change License: Apache License, Version 2.0
|
|
24
|
+
|
|
25
|
+
For information about alternative licensing arrangements for the Licensed
|
|
26
|
+
Work, please contact Empower Agile at hello@narrativetrace.ai.
|
|
27
|
+
|
|
28
|
+
Notice
|
|
29
|
+
|
|
30
|
+
The Business Source License (this document, or the "License") is not an Open
|
|
31
|
+
Source license. However, the Licensed Work will eventually be made available
|
|
32
|
+
under an Open Source License, as stated in this License.
|
|
33
|
+
|
|
34
|
+
Terms
|
|
35
|
+
|
|
36
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
37
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
38
|
+
Licensor may make an Additional Use Grant, above, permitting limited
|
|
39
|
+
production use.
|
|
40
|
+
|
|
41
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
42
|
+
available distribution of a specific version of the Licensed Work under this
|
|
43
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
44
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
45
|
+
above terminate.
|
|
46
|
+
|
|
47
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
48
|
+
currently in effect as described in this License, you must purchase a
|
|
49
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
50
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
51
|
+
|
|
52
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
53
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
54
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
55
|
+
for each version of the Licensed Work released by Licensor.
|
|
56
|
+
|
|
57
|
+
You must conspicuously display this License on each original or modified copy
|
|
58
|
+
of the Licensed Work. If you receive the Licensed Work in original or
|
|
59
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
60
|
+
License apply to your use of that work.
|
|
61
|
+
|
|
62
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
63
|
+
terminate your rights under this License for the current and all other
|
|
64
|
+
versions of the Licensed Work.
|
|
65
|
+
|
|
66
|
+
This License does not grant you any right in any trademark or logo of
|
|
67
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
68
|
+
Licensor as expressly required by this License).
|
|
69
|
+
|
|
70
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
71
|
+
AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
72
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
73
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
74
|
+
TITLE.
|
|
75
|
+
|
|
76
|
+
MariaDB hereby grants you permission to use this License’s text to license
|
|
77
|
+
your works, and to refer to it using the trademark “Business Source License”,
|
|
78
|
+
as long as you comply with the Covenants of Licensor below.
|
|
79
|
+
|
|
80
|
+
Covenants of Licensor
|
|
81
|
+
|
|
82
|
+
In consideration of the right to use this License’s text and the “Business
|
|
83
|
+
Source License” name and trademark, Licensor covenants to MariaDB, and to all
|
|
84
|
+
other recipients of the licensed work to be provided by Licensor:
|
|
85
|
+
|
|
86
|
+
1. To specify as the Change License the GPL Version 2.0 or any later version,
|
|
87
|
+
or a license that is compatible with GPL Version 2.0 or a later version,
|
|
88
|
+
where “compatible” means that software provided under the Change License can
|
|
89
|
+
be included in a program with software provided under GPL Version 2.0 or a
|
|
90
|
+
later version. Licensor may specify additional Change Licenses without
|
|
91
|
+
limitation.
|
|
92
|
+
|
|
93
|
+
2. To either: (a) specify an additional grant of rights to use that does not
|
|
94
|
+
impose any additional restriction on the right granted in this License, as
|
|
95
|
+
the Additional Use Grant; or (b) insert the text “None”.
|
|
96
|
+
|
|
97
|
+
3. To specify a Change Date.
|
|
98
|
+
|
|
99
|
+
4. Not to modify this License in any other way.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: narrativetrace-pytest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pytest plugin for narrativetrace: per-test trace fixtures, artifacts, and reporting
|
|
5
|
+
Project-URL: Homepage, https://github.com/narrativetrace/narrativetrace-python
|
|
6
|
+
Project-URL: Source, https://github.com/narrativetrace/narrativetrace-python
|
|
7
|
+
Author: narrativetrace contributors
|
|
8
|
+
License-Expression: BUSL-1.1
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Requires-Dist: narrativetrace
|
|
14
|
+
Requires-Dist: narrativetrace-clarity
|
|
15
|
+
Requires-Dist: narrativetrace-diagrams
|
|
16
|
+
Requires-Dist: narrativetrace-glossary
|
|
17
|
+
Requires-Dist: pytest>=8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# narrativetrace-pytest
|
|
21
|
+
|
|
22
|
+
pytest plugin for [narrativetrace](../narrativetrace): a `narrative_trace` fixture, per-test
|
|
23
|
+
trace artifacts, failure execution-trace reports, and a suite summary reporter.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "narrativetrace-pytest"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "pytest plugin for narrativetrace: per-test trace fixtures, artifacts, and reporting"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "BUSL-1.1"
|
|
8
|
+
# See packages/narrativetrace/pyproject.toml: one byte-identical copy of the root LICENSE per
|
|
9
|
+
# distribution, because PEP 639 forbids reaching out of the project directory.
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
authors = [{ name = "narrativetrace contributors" }]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Framework :: Pytest",
|
|
14
|
+
"Typing :: Typed",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"narrativetrace",
|
|
18
|
+
"narrativetrace-diagrams",
|
|
19
|
+
"narrativetrace-clarity",
|
|
20
|
+
"narrativetrace-glossary",
|
|
21
|
+
"pytest>=8",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/narrativetrace/narrativetrace-python"
|
|
26
|
+
Source = "https://github.com/narrativetrace/narrativetrace-python"
|
|
27
|
+
|
|
28
|
+
[project.entry-points.pytest11]
|
|
29
|
+
narrativetrace = "narrativetrace_pytest.plugin"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/narrativetrace_pytest"]
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""The narrativetrace pytest plugin: fixture, per-test artifacts, and suite reporting.
|
|
2
|
+
|
|
3
|
+
The JUnit4/5 extension behaviours (testfx-logging-otel §TS-TESTFX-*). The ``narrative_trace``
|
|
4
|
+
fixture builds a fresh context at the env-configured level; on teardown it emits template warnings,
|
|
5
|
+
prints a framed execution trace for failed tests, and (when output is enabled) writes per-test
|
|
6
|
+
artifacts. A session reporter prints the suite header/footer.
|
|
7
|
+
|
|
8
|
+
Artifacts match the Java JUnit integration so conformance fixtures can compare them across
|
|
9
|
+
platforms: the ``.json`` companion is the full ``export_document`` envelope (``version`` +
|
|
10
|
+
``scenario``), and the scenario result travels as a
|
|
11
|
+
:class:`~narrativetrace.render.scenario_result.ScenarioResult` — ``success``/``error`` in the JSON
|
|
12
|
+
artifact, ``PASSED``/``FAILED`` in the Markdown caption.
|
|
13
|
+
|
|
14
|
+
Environment channels: ``NARRATIVETRACE_LEVEL`` (garbage → DETAIL), ``NARRATIVETRACE_OUTPUT``
|
|
15
|
+
(truthy → write files), ``NARRATIVETRACE_OUTPUT_DIR`` (default ``narrative-traces``),
|
|
16
|
+
``NARRATIVETRACE_FORMAT`` (default ``markdown``; ``text``/``mermaid``/``plantuml`` replace the
|
|
17
|
+
Markdown trace, and only ``markdown`` carries the ``.json`` + ``.mmd`` companions),
|
|
18
|
+
``NARRATIVETRACE_CANONICAL`` (truthy → also write the per-test ``.canonical.json`` entry array,
|
|
19
|
+
whatever the format; off by default because it is a machine artifact for conformance runners).
|
|
20
|
+
|
|
21
|
+
Clarity aggregation (PY12): each test's captured tree is scored, the suite footer prints a
|
|
22
|
+
high/moderate/low split, and (when output is enabled) a ``clarity-results.json`` plus Markdown
|
|
23
|
+
suite report are written with one entry per test (duplicate scenario names retained).
|
|
24
|
+
|
|
25
|
+
Loss reporting: each test's :meth:`~narrativetrace.context.NarrativeContext.trace_loss` reading is
|
|
26
|
+
summed (one context per test, so readings add rather than needing a difference), and the footer
|
|
27
|
+
names what the suite lost on one line — omitted entirely when it lost nothing.
|
|
28
|
+
|
|
29
|
+
Glossary harvest hook (Phase 5): opt-in by ``glossary.json`` presence — a suite harvests only when
|
|
30
|
+
the repository has already committed a glossary, unless ``NARRATIVETRACE_GLOSSARY`` overrides that
|
|
31
|
+
default (``off`` disables unconditionally; any other truthy value forces harvesting even with no
|
|
32
|
+
glossary.json yet, which then creates one). Distinct from ``NARRATIVETRACE_GLOSSARY_DIR``, which
|
|
33
|
+
names *where* the glossary lives for both this hook and the unconditional vocabulary read above;
|
|
34
|
+
harvesting itself always writes the merged glossary/usage report back once it runs, independent of
|
|
35
|
+
``NARRATIVETRACE_OUTPUT`` (that key gates trace-file artifacts, an unrelated concern).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from collections.abc import Iterator
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any, cast
|
|
44
|
+
|
|
45
|
+
import pytest
|
|
46
|
+
from narrativetrace_clarity import analyze as analyze_clarity
|
|
47
|
+
from narrativetrace_clarity import export as export_clarity
|
|
48
|
+
from narrativetrace_clarity import render_suite_report
|
|
49
|
+
from narrativetrace_clarity.models import ClarityResult
|
|
50
|
+
from narrativetrace_clarity.vocabulary import EMPTY, DomainVocabulary
|
|
51
|
+
from narrativetrace_diagrams.mermaid import MermaidSequenceDiagramRenderer
|
|
52
|
+
from narrativetrace_diagrams.plantuml import PlantUmlSequenceDiagramRenderer
|
|
53
|
+
from narrativetrace_glossary.class_package_index import class_package_index
|
|
54
|
+
from narrativetrace_glossary.harvester import harvest_traces
|
|
55
|
+
from narrativetrace_glossary.json_reader import read_glossary_json
|
|
56
|
+
from narrativetrace_glossary.models import Glossary
|
|
57
|
+
from narrativetrace_glossary.suite_harvest import GLOSSARY_JSON_FILE, run_suite_harvest
|
|
58
|
+
from narrativetrace_glossary.summary_formatter import format_violation_details
|
|
59
|
+
from narrativetrace_glossary.vocabulary import read_project_vocabulary
|
|
60
|
+
|
|
61
|
+
from narrativetrace.config import ConfigResolver
|
|
62
|
+
from narrativetrace.context import ContextVarNarrativeContext
|
|
63
|
+
from narrativetrace.export import export_document as export_document_json
|
|
64
|
+
from narrativetrace.levels import NarrativeTraceConfig
|
|
65
|
+
from narrativetrace.loss import TraceLoss
|
|
66
|
+
from narrativetrace.output.reporter import ConsoleSummaryReporter
|
|
67
|
+
from narrativetrace.output.warnings import collect, format_warnings
|
|
68
|
+
from narrativetrace.output.writer import TraceArtifact, write_trace
|
|
69
|
+
from narrativetrace.render.base import TraceMetadata
|
|
70
|
+
from narrativetrace.render.scenario import humanize
|
|
71
|
+
from narrativetrace.render.scenario_result import ScenarioResult
|
|
72
|
+
from narrativetrace.tree import TraceTree
|
|
73
|
+
|
|
74
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class _OutputSettings:
|
|
79
|
+
enabled: bool
|
|
80
|
+
base_dir: Path
|
|
81
|
+
fmt: str
|
|
82
|
+
canonical: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _truthy(resolver: ConfigResolver, key: str) -> bool:
|
|
86
|
+
return (resolver.resolve(key, "") or "").strip().lower() in _TRUTHY
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _output_settings(resolver: ConfigResolver) -> _OutputSettings:
|
|
90
|
+
"""Reads the output keys through the shared precedence chain (env → config file → default)."""
|
|
91
|
+
enabled = _truthy(resolver, "output")
|
|
92
|
+
base_dir = Path((resolver.resolve("output_dir", "") or "").strip() or "narrative-traces")
|
|
93
|
+
fmt = (resolver.resolve("format", "") or "").strip() or "markdown"
|
|
94
|
+
return _OutputSettings(enabled, base_dir, fmt, _truthy(resolver, "canonical"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolver(config: pytest.Config) -> ConfigResolver:
|
|
98
|
+
"""One resolver per session — file discovery walks the filesystem and must not run per test."""
|
|
99
|
+
existing = getattr(config, "_narrativetrace_resolver", None)
|
|
100
|
+
if existing is None:
|
|
101
|
+
existing = ConfigResolver()
|
|
102
|
+
config._narrativetrace_resolver = existing # type: ignore[attr-defined]
|
|
103
|
+
return existing
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(slots=True)
|
|
107
|
+
class _SuiteAccumulator:
|
|
108
|
+
scenarios: list[str] = field(default_factory=list)
|
|
109
|
+
clarity: list[tuple[str, ClarityResult]] = field(default_factory=list)
|
|
110
|
+
loss: TraceLoss = field(default_factory=TraceLoss.none)
|
|
111
|
+
harvest_trees: list[TraceTree] = field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _glossary_dir(config: pytest.Config) -> str:
|
|
115
|
+
return (_resolver(config).resolve("glossary_dir", "") or "").strip() or "."
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _harvest_enabled(config: pytest.Config) -> bool:
|
|
119
|
+
"""Opt-in by ``glossary.json`` presence, with an explicit ``NARRATIVETRACE_GLOSSARY`` override.
|
|
120
|
+
|
|
121
|
+
``off`` disables harvesting unconditionally, even with a glossary already committed — the
|
|
122
|
+
mid-suite equivalent of Java's suite extension defaulting to ``disabled()``. Any other truthy
|
|
123
|
+
value forces harvesting on, even with no ``glossary.json`` yet (a project's first opt-in run,
|
|
124
|
+
which then creates one). Unset defaults to "harvest only when a glossary.json already exists"
|
|
125
|
+
— the least-surprising default: a project that has never curated a glossary does not get one
|
|
126
|
+
silently written just by running its test suite.
|
|
127
|
+
"""
|
|
128
|
+
override = (_resolver(config).resolve("glossary", "") or "").strip().lower()
|
|
129
|
+
if override == "off":
|
|
130
|
+
return False
|
|
131
|
+
if override in _TRUTHY:
|
|
132
|
+
return True
|
|
133
|
+
return (Path(_glossary_dir(config)) / GLOSSARY_JSON_FILE).is_file()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _project_vocabulary(config: pytest.Config) -> DomainVocabulary:
|
|
137
|
+
"""The repository's committed glossary, read once per session as the scoring vocabulary.
|
|
138
|
+
|
|
139
|
+
Unlike harvesting, reading is unconditional: it changes nothing on disk, and a project that
|
|
140
|
+
curates its ubiquitous language should not have to opt in to being scored in it. Only the
|
|
141
|
+
committed file counts — nothing this run harvests feeds back into its own scores.
|
|
142
|
+
|
|
143
|
+
``narrativetrace.glossary_dir`` (env ``NARRATIVETRACE_GLOSSARY_DIR``) names the directory,
|
|
144
|
+
defaulting to the working directory. A glossary that cannot be read degrades to the built-in
|
|
145
|
+
dictionaries with a warning: a reporting artifact must never fail the suite that produced it.
|
|
146
|
+
"""
|
|
147
|
+
existing = getattr(config, "_narrativetrace_vocabulary", None)
|
|
148
|
+
if existing is not None:
|
|
149
|
+
return cast("DomainVocabulary", existing)
|
|
150
|
+
glossary_dir = _glossary_dir(config)
|
|
151
|
+
try:
|
|
152
|
+
resolved = read_project_vocabulary(glossary_dir)
|
|
153
|
+
except (OSError, ValueError) as error:
|
|
154
|
+
print(
|
|
155
|
+
f"narrative-trace: committed glossary at {glossary_dir} could not be read, "
|
|
156
|
+
f"scoring with the built-in dictionaries only ({error})"
|
|
157
|
+
)
|
|
158
|
+
resolved = EMPTY
|
|
159
|
+
config._narrativetrace_vocabulary = resolved # type: ignore[attr-defined]
|
|
160
|
+
return resolved
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _accumulator(config: pytest.Config) -> _SuiteAccumulator:
|
|
164
|
+
existing = getattr(config, "_narrativetrace_acc", None)
|
|
165
|
+
if existing is None:
|
|
166
|
+
existing = _SuiteAccumulator()
|
|
167
|
+
config._narrativetrace_acc = existing # type: ignore[attr-defined]
|
|
168
|
+
return existing
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _class_name(request: pytest.FixtureRequest) -> str:
|
|
172
|
+
node_cls = getattr(request.node, "cls", None)
|
|
173
|
+
if node_cls is not None:
|
|
174
|
+
return str(node_cls.__name__)
|
|
175
|
+
return str(request.module.__name__).rsplit(".", 1)[-1]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
179
|
+
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Iterator[None]:
|
|
180
|
+
outcome = yield
|
|
181
|
+
report = outcome.get_result() # type: ignore[attr-defined]
|
|
182
|
+
setattr(item, f"_nt_rep_{report.when}", report)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@pytest.fixture
|
|
186
|
+
def narrative_trace(request: pytest.FixtureRequest) -> Iterator[ContextVarNarrativeContext]:
|
|
187
|
+
"""A fresh capture context at the env-configured level; writes artifacts on teardown."""
|
|
188
|
+
context = ContextVarNarrativeContext(
|
|
189
|
+
NarrativeTraceConfig.resolve(resolver=_resolver(request.config))
|
|
190
|
+
)
|
|
191
|
+
yield context
|
|
192
|
+
_finish(context, request)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _finish(context: ContextVarNarrativeContext, request: pytest.FixtureRequest) -> None:
|
|
196
|
+
"""Teardown: aggregate for the suite, surface template warnings, write per-test artifacts."""
|
|
197
|
+
tree = context.capture_trace()
|
|
198
|
+
scenario = humanize(request.node.name)
|
|
199
|
+
_accumulate(request.config, scenario, tree, context.trace_loss())
|
|
200
|
+
|
|
201
|
+
warning_text = format_warnings(collect(tree))
|
|
202
|
+
if warning_text:
|
|
203
|
+
print(warning_text)
|
|
204
|
+
|
|
205
|
+
report = getattr(request.node, "_nt_rep_call", None)
|
|
206
|
+
_write_artifacts(tree, scenario, request, failed=report is not None and report.failed)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _accumulate(config: pytest.Config, scenario: str, tree: TraceTree, loss: TraceLoss) -> None:
|
|
210
|
+
"""Records the scenario for the footer, scoring clarity for non-empty traces.
|
|
211
|
+
|
|
212
|
+
Each test gets its own context, so per-test readings sum: nothing is double-counted the way
|
|
213
|
+
a difference against a process-wide counter would be.
|
|
214
|
+
"""
|
|
215
|
+
accumulator = _accumulator(config)
|
|
216
|
+
accumulator.scenarios.append(scenario)
|
|
217
|
+
accumulator.loss = accumulator.loss.plus(loss)
|
|
218
|
+
if not tree.is_empty: # clarity aggregates non-empty traces (Java's GlobalTraceAccumulator)
|
|
219
|
+
accumulator.clarity.append((scenario, analyze_clarity(tree, _project_vocabulary(config))))
|
|
220
|
+
if _harvest_enabled(config):
|
|
221
|
+
accumulator.harvest_trees.append(tree)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _write_artifacts(
|
|
225
|
+
tree: TraceTree, scenario: str, request: pytest.FixtureRequest, *, failed: bool
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Writes this test's trace artifacts when output is enabled and anything was captured."""
|
|
228
|
+
settings = _output_settings(_resolver(request.config))
|
|
229
|
+
if not settings.enabled or tree.is_empty:
|
|
230
|
+
return
|
|
231
|
+
metadata = TraceMetadata(scenario, ScenarioResult.of(failed))
|
|
232
|
+
write_result = write_trace(
|
|
233
|
+
tree,
|
|
234
|
+
metadata,
|
|
235
|
+
TraceArtifact(
|
|
236
|
+
settings.base_dir,
|
|
237
|
+
_class_name(request),
|
|
238
|
+
request.node.name,
|
|
239
|
+
settings.fmt,
|
|
240
|
+
canonical=settings.canonical,
|
|
241
|
+
),
|
|
242
|
+
json_exporter=lambda captured: export_document_json(captured, metadata),
|
|
243
|
+
diagram_renderer=MermaidSequenceDiagramRenderer().render,
|
|
244
|
+
plantuml_renderer=PlantUmlSequenceDiagramRenderer().render,
|
|
245
|
+
)
|
|
246
|
+
if write_result.files:
|
|
247
|
+
print(write_result.console_echo)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def pytest_terminal_summary(terminalreporter: Any) -> None:
|
|
251
|
+
"""Prints the suite footer with the clarity split; writes clarity artifacts if enabled."""
|
|
252
|
+
accumulator = getattr(terminalreporter.config, "_narrativetrace_acc", None)
|
|
253
|
+
if accumulator is None or not accumulator.scenarios:
|
|
254
|
+
return
|
|
255
|
+
reporter = ConsoleSummaryReporter()
|
|
256
|
+
settings = _output_settings(_resolver(terminalreporter.config))
|
|
257
|
+
scores = [result.overall_score for _, result in accumulator.clarity]
|
|
258
|
+
terminalreporter.write_line(
|
|
259
|
+
reporter.format_suite_footer(
|
|
260
|
+
len(accumulator.scenarios), str(settings.base_dir), scores, accumulator.loss
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
if settings.enabled and accumulator.clarity:
|
|
264
|
+
_write_clarity_reports(accumulator.clarity, settings.base_dir)
|
|
265
|
+
if accumulator.harvest_trees:
|
|
266
|
+
_run_harvest(
|
|
267
|
+
terminalreporter.config, accumulator.harvest_trees, settings.base_dir, terminalreporter
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _write_clarity_reports(clarity: list[tuple[str, ClarityResult]], base_dir: Path) -> None:
|
|
272
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
(base_dir / "clarity-results.json").write_text(export_clarity(clarity), encoding="utf-8")
|
|
274
|
+
(base_dir / "clarity-report.md").write_text(render_suite_report(clarity), encoding="utf-8")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _context_glossary(glossary_dir: str) -> Glossary:
|
|
278
|
+
"""Reads the committed glossary for bounded-context resolution only.
|
|
279
|
+
|
|
280
|
+
Separate from :func:`~narrativetrace_glossary.suite_harvest.run_suite_harvest`'s own read of
|
|
281
|
+
the same file (for the merge) — a malformed file must not break context resolution silently,
|
|
282
|
+
so this degrades to an empty glossary (every candidate files under ``_unassigned``) rather than
|
|
283
|
+
raising into a terminal-summary hook that must never crash the suite that already passed.
|
|
284
|
+
"""
|
|
285
|
+
path = Path(glossary_dir) / GLOSSARY_JSON_FILE
|
|
286
|
+
if not path.is_file():
|
|
287
|
+
return Glossary()
|
|
288
|
+
try:
|
|
289
|
+
return read_glossary_json(path.read_text(encoding="utf-8"))
|
|
290
|
+
except (OSError, ValueError):
|
|
291
|
+
return Glossary()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _run_harvest(
|
|
295
|
+
config: pytest.Config, trees: list[TraceTree], base_dir: Path, terminalreporter: Any
|
|
296
|
+
) -> None:
|
|
297
|
+
"""Harvests, merges, writes back, and reports — the opt-in half of glossary maintenance.
|
|
298
|
+
|
|
299
|
+
A failure here (a malformed committed glossary, an unwritable directory) is reported and
|
|
300
|
+
swallowed: harvesting is a maintenance side effect of running the suite, never a reason the
|
|
301
|
+
suite itself should be reported as broken.
|
|
302
|
+
"""
|
|
303
|
+
glossary_dir = _glossary_dir(config)
|
|
304
|
+
try:
|
|
305
|
+
module_of = class_package_index(trees)
|
|
306
|
+
candidates = harvest_traces(
|
|
307
|
+
trees, glossary=_context_glossary(glossary_dir), module_of=module_of
|
|
308
|
+
)
|
|
309
|
+
result = run_suite_harvest(candidates, glossary_dir=glossary_dir, output_dir=str(base_dir))
|
|
310
|
+
except (OSError, ValueError) as error:
|
|
311
|
+
terminalreporter.write_line(f"narrative-trace: glossary harvest failed, skipped ({error})")
|
|
312
|
+
return
|
|
313
|
+
terminalreporter.write_line(result.summary)
|
|
314
|
+
details = format_violation_details(result.violations)
|
|
315
|
+
if details:
|
|
316
|
+
terminalreporter.write_line(details)
|
|
File without changes
|