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.
@@ -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,4 @@
1
+ # narrativetrace-pytest
2
+
3
+ pytest plugin for [narrativetrace](../narrativetrace): a `narrative_trace` fixture, per-test
4
+ 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,3 @@
1
+ """pytest plugin for narrativetrace."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)