pytest-given 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_given/__init__.py +32 -0
- pytest_given/capture/__init__.py +48 -0
- pytest_given/capture/collector.py +369 -0
- pytest_given/capture/decorators.py +502 -0
- pytest_given/capture/file_glossary.py +106 -0
- pytest_given/capture/glossary.py +258 -0
- pytest_given/capture/kind_inference.py +102 -0
- pytest_given/capture/markdown_glossary.py +147 -0
- pytest_given/capture/source.py +208 -0
- pytest_given/capture/story.py +303 -0
- pytest_given/capture/template.py +180 -0
- pytest_given/capture/traceback.py +157 -0
- pytest_given/cli.py +126 -0
- pytest_given/lint/__init__.py +47 -0
- pytest_given/lint/ast_rules.py +395 -0
- pytest_given/lint/base.py +77 -0
- pytest_given/lint/config.py +137 -0
- pytest_given/lint/runtime_rules.py +212 -0
- pytest_given/model/__init__.py +84 -0
- pytest_given/model/errors.py +2 -0
- pytest_given/model/ids.py +32 -0
- pytest_given/model/schema.py +326 -0
- pytest_given/model/serde.py +263 -0
- pytest_given/plugin.py +958 -0
- pytest_given/py.typed +0 -0
- pytest_given/report/__init__.py +10 -0
- pytest_given/report/aggregations.py +382 -0
- pytest_given/report/cli.py +68 -0
- pytest_given/report/coverage.py +175 -0
- pytest_given/report/html_renderer.py +370 -0
- pytest_given/report/inline_markdown.py +35 -0
- pytest_given/report/md_renderer.py +164 -0
- pytest_given/report/source_link.py +212 -0
- pytest_given/report/templates/alpine.min.js +6 -0
- pytest_given/report/templates/app.js +438 -0
- pytest_given/report/templates/report.html.j2 +606 -0
- pytest_given/report/templates/styles.css +1013 -0
- pytest_given/skills_data/pytest-given-authoring/SKILL.md +43 -0
- pytest_given/skills_data/pytest-given-authoring/references/api.md +62 -0
- pytest_given/skills_data/pytest-given-authoring/references/domain-storytelling.md +36 -0
- pytest_given/skills_data/pytest-given-authoring/references/glossaries.md +68 -0
- pytest_given/skills_data/pytest-given-authoring/references/scenarios.md +29 -0
- pytest_given/skills_data/pytest-given-authoring/references/stories.md +53 -0
- pytest_given/skills_data/pytest-given-navigating/SKILL.md +40 -0
- pytest_given/skills_data/pytest-given-navigating/references/report-json.md +69 -0
- pytest_given/skills_data/pytest-given-reviewing/SKILL.md +47 -0
- pytest_given-0.1.0.dist-info/METADATA +510 -0
- pytest_given-0.1.0.dist-info/RECORD +52 -0
- pytest_given-0.1.0.dist-info/WHEEL +4 -0
- pytest_given-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_given-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- pytest_given-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES +26 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Runtime-surface rules: pure inspection of the recorded report model, no
|
|
2
|
+
source access needed."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from ..model import (
|
|
9
|
+
ActivityTermRef,
|
|
10
|
+
Glossary,
|
|
11
|
+
Narration,
|
|
12
|
+
NarrationTermRef,
|
|
13
|
+
NodeId,
|
|
14
|
+
Phase,
|
|
15
|
+
Scenario,
|
|
16
|
+
Step,
|
|
17
|
+
Story,
|
|
18
|
+
TermId,
|
|
19
|
+
case_suffix,
|
|
20
|
+
id_derive,
|
|
21
|
+
node_base,
|
|
22
|
+
)
|
|
23
|
+
from .base import RULES_BY_ID, Finding, RuleId, iter_steps, location_suffix
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_runtime_rules(
|
|
27
|
+
grouped: list[Scenario],
|
|
28
|
+
per_case: list[Scenario],
|
|
29
|
+
glossary: Glossary | None,
|
|
30
|
+
stories: list[Story],
|
|
31
|
+
) -> list[Finding]:
|
|
32
|
+
"""Run the runtime-surface rules over the recorded report model.
|
|
33
|
+
|
|
34
|
+
`missing-phase` and the glossary rules evaluate the grouped scenario list
|
|
35
|
+
(one evaluation per logical scenario); `divergent-case-structure` needs
|
|
36
|
+
the pre-grouping per-case list — the only place per-case step structure
|
|
37
|
+
exists, since grouping collapses every case onto case 1's tree.
|
|
38
|
+
"""
|
|
39
|
+
findings: list[Finding] = []
|
|
40
|
+
findings.extend(_missing_phase_findings(grouped))
|
|
41
|
+
findings.extend(_divergent_case_findings(per_case))
|
|
42
|
+
if glossary is not None:
|
|
43
|
+
findings.extend(_tag_shadows_term_findings(grouped, glossary))
|
|
44
|
+
findings.extend(_dead_term_findings(grouped, glossary, stories))
|
|
45
|
+
return findings
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Canonical Given/When/Then order, used both to test completeness and to
|
|
49
|
+
# report missing phases in reading order rather than alphabetically.
|
|
50
|
+
_PHASE_ORDER: tuple[Phase, ...] = ('given', 'when', 'then')
|
|
51
|
+
|
|
52
|
+
# A step tree reduced to nested phase tuples — narration text and values
|
|
53
|
+
# ignored. Two cases with equal signatures render truthfully in the merged
|
|
54
|
+
# parameter-table view.
|
|
55
|
+
type _StepSignature = tuple[tuple[Phase, _StepSignature], ...]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _missing_phase_findings(grouped: list[Scenario]) -> list[Finding]:
|
|
59
|
+
"""Rule `missing-phase`: a passed scenario lacks a Given, When, or Then.
|
|
60
|
+
|
|
61
|
+
Non-passed scenarios are skipped — a skipped one records no steps and a
|
|
62
|
+
failed one may be missing a phase only because it aborted mid-body.
|
|
63
|
+
"""
|
|
64
|
+
findings: list[Finding] = []
|
|
65
|
+
for scenario in grouped:
|
|
66
|
+
if scenario.status != 'passed':
|
|
67
|
+
continue
|
|
68
|
+
present = {step.phase for step in iter_steps(scenario.steps)}
|
|
69
|
+
missing = [phase for phase in _PHASE_ORDER if phase not in present]
|
|
70
|
+
if missing:
|
|
71
|
+
findings.append(
|
|
72
|
+
_scenario_finding(
|
|
73
|
+
RuleId('missing-phase'),
|
|
74
|
+
scenario,
|
|
75
|
+
f'missing: {", ".join(missing)}',
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return findings
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _divergent_case_findings(per_case: list[Scenario]) -> list[Finding]:
|
|
82
|
+
"""Rule `divergent-case-structure`: a parametrize case records a
|
|
83
|
+
different step structure than case 1.
|
|
84
|
+
|
|
85
|
+
Groups the pre-grouping cases by node-id base. Non-passed cases are
|
|
86
|
+
exempt (skipped cases record no steps; failed cases abort mid-tree), so
|
|
87
|
+
the baseline is the first passed case. One finding per scenario, naming
|
|
88
|
+
the diverging case ids.
|
|
89
|
+
"""
|
|
90
|
+
groups: dict[str, list[Scenario]] = {}
|
|
91
|
+
for scenario in per_case:
|
|
92
|
+
if '[' in scenario.id:
|
|
93
|
+
groups.setdefault(node_base(scenario.id), []).append(scenario)
|
|
94
|
+
findings: list[Finding] = []
|
|
95
|
+
for base, cases in groups.items():
|
|
96
|
+
passed = [case for case in cases if case.status == 'passed']
|
|
97
|
+
if len(passed) < 2:
|
|
98
|
+
continue
|
|
99
|
+
baseline = _structure_signature(passed[0].steps)
|
|
100
|
+
diverging = [
|
|
101
|
+
case_suffix(case.id)
|
|
102
|
+
for case in passed[1:]
|
|
103
|
+
if _structure_signature(case.steps) != baseline
|
|
104
|
+
]
|
|
105
|
+
if diverging:
|
|
106
|
+
findings.append(
|
|
107
|
+
Finding(
|
|
108
|
+
rule=RuleId('divergent-case-structure'),
|
|
109
|
+
severity=RULES_BY_ID[RuleId('divergent-case-structure')].default,
|
|
110
|
+
subject=base,
|
|
111
|
+
node_id=NodeId(base),
|
|
112
|
+
location=passed[0].source,
|
|
113
|
+
message=(
|
|
114
|
+
f'cases {", ".join(diverging)} record a different step '
|
|
115
|
+
f'structure than case {case_suffix(passed[0].id)}'
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return findings
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _structure_signature(steps: list[Step]) -> _StepSignature:
|
|
123
|
+
return tuple((step.phase, _structure_signature(step.children)) for step in steps)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _tag_shadows_term_findings(
|
|
127
|
+
grouped: list[Scenario], glossary: Glossary
|
|
128
|
+
) -> list[Finding]:
|
|
129
|
+
"""Rule `tag-shadows-term`: a scenario tag duplicates a glossary term.
|
|
130
|
+
|
|
131
|
+
One finding per unique tag (subject = tag slug) — the fix is renaming the
|
|
132
|
+
tag once, and per-scenario findings would be pure repetition.
|
|
133
|
+
"""
|
|
134
|
+
counts: dict[TermId, tuple[str, NodeId, int]] = {}
|
|
135
|
+
for scenario in grouped:
|
|
136
|
+
for tag in scenario.tags:
|
|
137
|
+
slug = id_derive(tag)
|
|
138
|
+
if glossary.get(slug) is None:
|
|
139
|
+
continue
|
|
140
|
+
if slug in counts:
|
|
141
|
+
first_tag, first_id, count = counts[slug]
|
|
142
|
+
counts[slug] = (first_tag, first_id, count + 1)
|
|
143
|
+
else:
|
|
144
|
+
counts[slug] = (tag, scenario.id, 1)
|
|
145
|
+
findings: list[Finding] = []
|
|
146
|
+
for slug, (tag, node_id, count) in counts.items():
|
|
147
|
+
term = glossary.get(slug)
|
|
148
|
+
assert term is not None
|
|
149
|
+
noun = 'scenario' if count == 1 else 'scenarios'
|
|
150
|
+
findings.append(
|
|
151
|
+
Finding(
|
|
152
|
+
rule=RuleId('tag-shadows-term'),
|
|
153
|
+
severity=RULES_BY_ID[RuleId('tag-shadows-term')].default,
|
|
154
|
+
subject=slug,
|
|
155
|
+
node_id=node_id,
|
|
156
|
+
location=None,
|
|
157
|
+
message=(
|
|
158
|
+
f'tag {tag!r} duplicates glossary term {term.canonical!r} '
|
|
159
|
+
f'({count} {noun}, e.g. {node_id})'
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
return findings
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _dead_term_findings(
|
|
167
|
+
grouped: list[Scenario], glossary: Glossary, stories: list[Story]
|
|
168
|
+
) -> list[Finding]:
|
|
169
|
+
"""Rule `dead-term`: a glossary term is referenced by no step and no
|
|
170
|
+
story. Default `off`: for a file-backed glossary, unreferenced terms are
|
|
171
|
+
often intentionally present (documented behaviour)."""
|
|
172
|
+
referenced: set[TermId] = set()
|
|
173
|
+
for scenario in grouped:
|
|
174
|
+
for narration in _iter_narrations(scenario):
|
|
175
|
+
for part in narration.parts:
|
|
176
|
+
if isinstance(part, NarrationTermRef):
|
|
177
|
+
referenced.add(part.term_id)
|
|
178
|
+
for story in stories:
|
|
179
|
+
for activity in story.activities:
|
|
180
|
+
for path in activity.paths:
|
|
181
|
+
for ref in path.parts:
|
|
182
|
+
if isinstance(ref, ActivityTermRef):
|
|
183
|
+
referenced.add(ref.term_id)
|
|
184
|
+
return [
|
|
185
|
+
Finding(
|
|
186
|
+
rule=RuleId('dead-term'),
|
|
187
|
+
severity=RULES_BY_ID[RuleId('dead-term')].default,
|
|
188
|
+
subject=term.id,
|
|
189
|
+
node_id=None,
|
|
190
|
+
location=term.source,
|
|
191
|
+
message=f'term {term.canonical!r} is referenced by no step and no story',
|
|
192
|
+
)
|
|
193
|
+
for term in glossary.terms
|
|
194
|
+
if term.id not in referenced
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _iter_narrations(scenario: Scenario) -> Iterator[Narration]:
|
|
199
|
+
yield scenario.narration
|
|
200
|
+
for step in iter_steps(scenario.steps):
|
|
201
|
+
yield step.narration
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _scenario_finding(rule: RuleId, scenario: Scenario, text: str) -> Finding:
|
|
205
|
+
return Finding(
|
|
206
|
+
rule=rule,
|
|
207
|
+
severity=RULES_BY_ID[rule].default,
|
|
208
|
+
subject=scenario.id,
|
|
209
|
+
node_id=scenario.id,
|
|
210
|
+
location=scenario.source,
|
|
211
|
+
message=f'{text}{location_suffix(scenario.source)}',
|
|
212
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from .errors import PytestGivenError
|
|
2
|
+
from .ids import case_suffix, id_derive, node_base
|
|
3
|
+
from .schema import (
|
|
4
|
+
Activity,
|
|
5
|
+
ActivityId,
|
|
6
|
+
ActivityPart,
|
|
7
|
+
ActivityPath,
|
|
8
|
+
ActivityTermRef,
|
|
9
|
+
ActivityWord,
|
|
10
|
+
Attachment,
|
|
11
|
+
ContentType,
|
|
12
|
+
ErrorInfo,
|
|
13
|
+
FixtureRecording,
|
|
14
|
+
Glossary,
|
|
15
|
+
GlossaryTerm,
|
|
16
|
+
Metadata,
|
|
17
|
+
Narration,
|
|
18
|
+
NarrationLiteral,
|
|
19
|
+
NarrationPart,
|
|
20
|
+
NarrationPlaceholder,
|
|
21
|
+
NarrationTermRef,
|
|
22
|
+
NarrationValue,
|
|
23
|
+
NodeId,
|
|
24
|
+
ParameterCase,
|
|
25
|
+
ParameterTable,
|
|
26
|
+
ParamInfo,
|
|
27
|
+
ParamSpec,
|
|
28
|
+
ParamValue,
|
|
29
|
+
Phase,
|
|
30
|
+
RecordingState,
|
|
31
|
+
ReportData,
|
|
32
|
+
Scenario,
|
|
33
|
+
SourceLocation,
|
|
34
|
+
Step,
|
|
35
|
+
Story,
|
|
36
|
+
StoryId,
|
|
37
|
+
TermId,
|
|
38
|
+
TracebackFrame,
|
|
39
|
+
)
|
|
40
|
+
from .serde import report_from_dict, report_to_dict
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
'Activity',
|
|
44
|
+
'ActivityId',
|
|
45
|
+
'ActivityPart',
|
|
46
|
+
'ActivityPath',
|
|
47
|
+
'ActivityTermRef',
|
|
48
|
+
'ActivityWord',
|
|
49
|
+
'Attachment',
|
|
50
|
+
'ContentType',
|
|
51
|
+
'ErrorInfo',
|
|
52
|
+
'FixtureRecording',
|
|
53
|
+
'Glossary',
|
|
54
|
+
'GlossaryTerm',
|
|
55
|
+
'Metadata',
|
|
56
|
+
'Narration',
|
|
57
|
+
'NarrationLiteral',
|
|
58
|
+
'NarrationPart',
|
|
59
|
+
'NarrationPlaceholder',
|
|
60
|
+
'NarrationTermRef',
|
|
61
|
+
'NarrationValue',
|
|
62
|
+
'NodeId',
|
|
63
|
+
'ParamInfo',
|
|
64
|
+
'ParamSpec',
|
|
65
|
+
'ParamValue',
|
|
66
|
+
'ParameterCase',
|
|
67
|
+
'ParameterTable',
|
|
68
|
+
'Phase',
|
|
69
|
+
'PytestGivenError',
|
|
70
|
+
'RecordingState',
|
|
71
|
+
'ReportData',
|
|
72
|
+
'Scenario',
|
|
73
|
+
'SourceLocation',
|
|
74
|
+
'Step',
|
|
75
|
+
'Story',
|
|
76
|
+
'StoryId',
|
|
77
|
+
'TermId',
|
|
78
|
+
'TracebackFrame',
|
|
79
|
+
'case_suffix',
|
|
80
|
+
'id_derive',
|
|
81
|
+
'node_base',
|
|
82
|
+
'report_from_dict',
|
|
83
|
+
'report_to_dict',
|
|
84
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Pure-function id derivation, shared by capture and report layers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .errors import PytestGivenError
|
|
6
|
+
from .schema import TermId
|
|
7
|
+
|
|
8
|
+
_NON_ALNUM = re.compile(r'[^a-z0-9]+')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def id_derive(name: str) -> TermId:
|
|
12
|
+
slug = _NON_ALNUM.sub('-', name.lower()).strip('-')
|
|
13
|
+
if not slug:
|
|
14
|
+
raise PytestGivenError(
|
|
15
|
+
f'derived id is empty for {name!r}; provide a name with at least '
|
|
16
|
+
f'one ASCII alphanumeric character.'
|
|
17
|
+
)
|
|
18
|
+
return TermId(slug)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def node_base(node_id: str) -> str:
|
|
22
|
+
"""A node id (or a bare function segment) without its parametrize tail:
|
|
23
|
+
``'f.py::t[1-2]' -> 'f.py::t'``, ``'t[1-2]' -> 't'``. Two cases of one
|
|
24
|
+
parametrized function share this; two different functions never do."""
|
|
25
|
+
return node_id.split('[', 1)[0]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def case_suffix(node_id: str) -> str:
|
|
29
|
+
"""The parametrize tail of a node id, brackets included, or ``''`` when it
|
|
30
|
+
has none: ``'f.py::t[1-2]' -> '[1-2]'``, ``'f.py::t' -> ''``."""
|
|
31
|
+
_, bracket, rest = node_id.partition('[')
|
|
32
|
+
return f'{bracket}{rest}'
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, NewType
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ..capture.glossary import Actor, DeferredTermHandle, Verb, WorkObject
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, kw_only=True)
|
|
11
|
+
class NarrationLiteral:
|
|
12
|
+
value: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, kw_only=True)
|
|
16
|
+
class NarrationValue:
|
|
17
|
+
"""A t-string interpolation — value already known at construction time."""
|
|
18
|
+
|
|
19
|
+
rendered: str
|
|
20
|
+
expression: str
|
|
21
|
+
format_spec: str = ''
|
|
22
|
+
conversion: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, kw_only=True)
|
|
26
|
+
class NarrationPlaceholder:
|
|
27
|
+
"""A deferred placeholder — resolved at render time from a per-case mapping."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
format_spec: str = ''
|
|
31
|
+
conversion: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Pytest node ID, e.g. "tests/test_billing.py::test_buy_coffee[1-2-3]"
|
|
35
|
+
NodeId = NewType('NodeId', str)
|
|
36
|
+
|
|
37
|
+
TermId = NewType('TermId', str)
|
|
38
|
+
ActivityId = NewType('ActivityId', int)
|
|
39
|
+
StoryId = NewType('StoryId', str)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, kw_only=True)
|
|
43
|
+
class NarrationTermRef:
|
|
44
|
+
"""Reference to a glossary term — kind resolved via glossary[term_id].kind."""
|
|
45
|
+
|
|
46
|
+
term_id: TermId
|
|
47
|
+
display: str
|
|
48
|
+
expression: str = ''
|
|
49
|
+
param_column: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
type NarrationPart = (
|
|
53
|
+
NarrationLiteral | NarrationValue | NarrationPlaceholder | NarrationTermRef
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Narration:
|
|
59
|
+
"""The text of a step or scenario, plus optional structured parts.
|
|
60
|
+
|
|
61
|
+
`text` is the rendered string for display. `parts` is empty for plain-string
|
|
62
|
+
inputs; non-empty when the source was a t-string or a `Template`, in which
|
|
63
|
+
case the parts reconstruct `text` and carry per-part highlighting metadata.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
text: str
|
|
67
|
+
parts: list[NarrationPart] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, kw_only=True)
|
|
71
|
+
class GlossaryTerm:
|
|
72
|
+
id: TermId
|
|
73
|
+
kind: Literal['actor', 'object', 'verb'] | None
|
|
74
|
+
canonical: str
|
|
75
|
+
definition: str | None = None
|
|
76
|
+
source: SourceLocation | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, kw_only=True)
|
|
80
|
+
class ActivityTermRef:
|
|
81
|
+
"""Reference to a glossary term in an activity path. Kind resolved via
|
|
82
|
+
glossary[term_id].kind — mirrors NarrationTermRef. Used for both
|
|
83
|
+
code-defined handles and file-glossary handles (whose kind may be
|
|
84
|
+
inferred post-collection)."""
|
|
85
|
+
|
|
86
|
+
term_id: TermId
|
|
87
|
+
display: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, kw_only=True)
|
|
91
|
+
class ActivityWord:
|
|
92
|
+
"""Bare-string connective (preposition, article, etc.). Carries no kind."""
|
|
93
|
+
|
|
94
|
+
text: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
type ActivityPart = ActivityTermRef | ActivityWord
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, kw_only=True)
|
|
101
|
+
class ActivityPath:
|
|
102
|
+
parts: tuple[ActivityPart, ...]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, kw_only=True)
|
|
106
|
+
class Activity:
|
|
107
|
+
id: ActivityId
|
|
108
|
+
paths: tuple[ActivityPath, ...]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, kw_only=True)
|
|
112
|
+
class Story:
|
|
113
|
+
id: StoryId
|
|
114
|
+
title: str
|
|
115
|
+
activities: tuple[Activity, ...]
|
|
116
|
+
source: SourceLocation | None = None
|
|
117
|
+
_by_id: dict[ActivityId, Activity] = field(
|
|
118
|
+
init=False, repr=False, compare=False, default_factory=dict
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def __post_init__(self) -> None:
|
|
122
|
+
index: dict[ActivityId, Activity] = {}
|
|
123
|
+
for activity in self.activities:
|
|
124
|
+
index[activity.id] = activity
|
|
125
|
+
object.__setattr__(self, '_by_id', index)
|
|
126
|
+
|
|
127
|
+
def __getitem__(self, key: ActivityId) -> Activity:
|
|
128
|
+
return self._by_id[key]
|
|
129
|
+
|
|
130
|
+
def get(self, key: ActivityId) -> Activity | None:
|
|
131
|
+
return self._by_id.get(key)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class Glossary:
|
|
136
|
+
"""Mutable container of glossary terms with an id-keyed index.
|
|
137
|
+
|
|
138
|
+
The user-facing registration methods (`actor` / `work_object` / `verb`)
|
|
139
|
+
live in `pytest_given.capture.glossary`; this class only owns storage
|
|
140
|
+
and atomic write-through. `_register` is the low-level primitive both
|
|
141
|
+
those methods and the deserializer call.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
terms: list[GlossaryTerm] = field(default_factory=list)
|
|
145
|
+
_by_id: dict[TermId, GlossaryTerm] = field(
|
|
146
|
+
init=False, repr=False, compare=False, default_factory=dict
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
for term in self.terms:
|
|
151
|
+
self._by_id[term.id] = term
|
|
152
|
+
|
|
153
|
+
def get(self, key: TermId) -> GlossaryTerm | None:
|
|
154
|
+
return self._by_id.get(key)
|
|
155
|
+
|
|
156
|
+
def _register(self, term: GlossaryTerm) -> None:
|
|
157
|
+
if term.id in self._by_id:
|
|
158
|
+
raise ValueError(f'term id {term.id!r} already registered')
|
|
159
|
+
self.terms.append(term)
|
|
160
|
+
self._by_id[term.id] = term
|
|
161
|
+
|
|
162
|
+
if TYPE_CHECKING:
|
|
163
|
+
|
|
164
|
+
def __call__(
|
|
165
|
+
self, name: str, definition: str | None = None
|
|
166
|
+
) -> DeferredTermHandle: ...
|
|
167
|
+
def __getitem__(self, name: str) -> DeferredTermHandle: ...
|
|
168
|
+
def actor(self, name: str, definition: str | None = None) -> Actor: ...
|
|
169
|
+
def work_object(
|
|
170
|
+
self, name: str, definition: str | None = None
|
|
171
|
+
) -> WorkObject: ...
|
|
172
|
+
def verb(self, name: str, definition: str | None = None) -> Verb: ...
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Step phase
|
|
176
|
+
type Phase = Literal['given', 'when', 'then']
|
|
177
|
+
|
|
178
|
+
# Lifecycle state of the collector — determines where push_step/attach route.
|
|
179
|
+
type RecordingState = Literal['idle', 'test', 'fixture_setup', 'fixture_teardown']
|
|
180
|
+
|
|
181
|
+
# A @pytest.mark.parametrize value as captured for the report: JSON primitives
|
|
182
|
+
# pass through; anything else (dates, objects) is captured as its str(), since
|
|
183
|
+
# parametrize values only feed display and the JSON sink must serialize them.
|
|
184
|
+
type ParamValue = str | int | float | bool | None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ParamSpec(NamedTuple):
|
|
188
|
+
"""Parameter names and values for a single parametrized test run."""
|
|
189
|
+
|
|
190
|
+
names: list[str]
|
|
191
|
+
values: list[ParamValue]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Maps node IDs to their parameter specification
|
|
195
|
+
type ParamInfo = dict[NodeId, ParamSpec]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
type ContentType = Literal['text', 'json']
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class Attachment:
|
|
203
|
+
label: str
|
|
204
|
+
content: str
|
|
205
|
+
content_type: ContentType = 'text'
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class SourceLocation:
|
|
210
|
+
"""A file/line pointer to a scenario's test function.
|
|
211
|
+
|
|
212
|
+
`relpath` is POSIX-normalized and relative to pytest's rootdir; `line` is
|
|
213
|
+
1-indexed. Stored on Scenario; rootdir is never serialized to avoid
|
|
214
|
+
leaking local paths.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
relpath: str
|
|
218
|
+
line: int
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True)
|
|
222
|
+
class TracebackFrame:
|
|
223
|
+
"""One frame from a parsed pytest short-style traceback.
|
|
224
|
+
|
|
225
|
+
`path` is POSIX-normalized (backslashes converted) so renderer logic
|
|
226
|
+
doesn't need per-OS branches. `code` may span multiple lines (caret
|
|
227
|
+
rows, multi-line statements); newlines are preserved verbatim.
|
|
228
|
+
`is_internal` flags pluggy/_pytest/decorator-wrapper frames that the
|
|
229
|
+
UI hides by default.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
path: str
|
|
233
|
+
lineno: int
|
|
234
|
+
func: str
|
|
235
|
+
code: str
|
|
236
|
+
is_internal: bool
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class ErrorInfo:
|
|
241
|
+
message: str
|
|
242
|
+
frames: list[TracebackFrame] = field(default_factory=list)
|
|
243
|
+
error_tail: str | None = None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class Step:
|
|
248
|
+
phase: Phase
|
|
249
|
+
narration: Narration
|
|
250
|
+
status: str = 'passed'
|
|
251
|
+
children: list[Step] = field(default_factory=list)
|
|
252
|
+
attachments: list[Attachment] = field(default_factory=list)
|
|
253
|
+
error: ErrorInfo | None = None
|
|
254
|
+
activity_ids: tuple[ActivityId, ...] = ()
|
|
255
|
+
fixture_name: str | None = None
|
|
256
|
+
# Anchor of the step's body for the narration lint; captured only when
|
|
257
|
+
# lint is enabled, and never serialized so report artifacts stay
|
|
258
|
+
# byte-identical either way.
|
|
259
|
+
source: SourceLocation | None = field(
|
|
260
|
+
default=None, metadata={'serde_exclude': True}
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass
|
|
265
|
+
class FixtureRecording:
|
|
266
|
+
"""A captured subtree of steps/attachments for one fixture instance.
|
|
267
|
+
|
|
268
|
+
`root` is the labeled step from @given/@when/@then on the fixture; its
|
|
269
|
+
`children` accumulate as the fixture body runs. `stack` mirrors the
|
|
270
|
+
collector's step stack while the recording is active, so nested
|
|
271
|
+
`with given(...)` inside the body works.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
root: Step
|
|
275
|
+
stack: list[Step] = field(default_factory=list)
|
|
276
|
+
|
|
277
|
+
def __post_init__(self) -> None:
|
|
278
|
+
if not self.stack:
|
|
279
|
+
self.stack.append(self.root)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass
|
|
283
|
+
class ParameterCase:
|
|
284
|
+
values: list[Any]
|
|
285
|
+
status: str = 'passed'
|
|
286
|
+
error: ErrorInfo | None = None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass
|
|
290
|
+
class ParameterTable:
|
|
291
|
+
names: list[str]
|
|
292
|
+
cases: list[ParameterCase] = field(default_factory=list)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass
|
|
296
|
+
class Scenario:
|
|
297
|
+
id: NodeId
|
|
298
|
+
narration: Narration
|
|
299
|
+
module: str
|
|
300
|
+
tags: list[str] = field(default_factory=list)
|
|
301
|
+
status: str = 'passed'
|
|
302
|
+
duration_ms: int = 0
|
|
303
|
+
steps: list[Step] = field(default_factory=list)
|
|
304
|
+
parameters: ParameterTable | None = None
|
|
305
|
+
error: ErrorInfo | None = None
|
|
306
|
+
skip_reason: str | None = None
|
|
307
|
+
source: SourceLocation | None = None
|
|
308
|
+
story_id: StoryId | None = None
|
|
309
|
+
activity_ids: tuple[ActivityId, ...] = ()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class Metadata:
|
|
314
|
+
project: str
|
|
315
|
+
timestamp: str
|
|
316
|
+
pytest_version: str
|
|
317
|
+
plugin_version: str
|
|
318
|
+
commit_sha: str | None = None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass
|
|
322
|
+
class ReportData:
|
|
323
|
+
metadata: Metadata
|
|
324
|
+
scenarios: list[Scenario] = field(default_factory=list)
|
|
325
|
+
glossary: Glossary | None = None
|
|
326
|
+
stories: list[Story] = field(default_factory=list)
|