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.
Files changed (52) hide show
  1. pytest_given/__init__.py +32 -0
  2. pytest_given/capture/__init__.py +48 -0
  3. pytest_given/capture/collector.py +369 -0
  4. pytest_given/capture/decorators.py +502 -0
  5. pytest_given/capture/file_glossary.py +106 -0
  6. pytest_given/capture/glossary.py +258 -0
  7. pytest_given/capture/kind_inference.py +102 -0
  8. pytest_given/capture/markdown_glossary.py +147 -0
  9. pytest_given/capture/source.py +208 -0
  10. pytest_given/capture/story.py +303 -0
  11. pytest_given/capture/template.py +180 -0
  12. pytest_given/capture/traceback.py +157 -0
  13. pytest_given/cli.py +126 -0
  14. pytest_given/lint/__init__.py +47 -0
  15. pytest_given/lint/ast_rules.py +395 -0
  16. pytest_given/lint/base.py +77 -0
  17. pytest_given/lint/config.py +137 -0
  18. pytest_given/lint/runtime_rules.py +212 -0
  19. pytest_given/model/__init__.py +84 -0
  20. pytest_given/model/errors.py +2 -0
  21. pytest_given/model/ids.py +32 -0
  22. pytest_given/model/schema.py +326 -0
  23. pytest_given/model/serde.py +263 -0
  24. pytest_given/plugin.py +958 -0
  25. pytest_given/py.typed +0 -0
  26. pytest_given/report/__init__.py +10 -0
  27. pytest_given/report/aggregations.py +382 -0
  28. pytest_given/report/cli.py +68 -0
  29. pytest_given/report/coverage.py +175 -0
  30. pytest_given/report/html_renderer.py +370 -0
  31. pytest_given/report/inline_markdown.py +35 -0
  32. pytest_given/report/md_renderer.py +164 -0
  33. pytest_given/report/source_link.py +212 -0
  34. pytest_given/report/templates/alpine.min.js +6 -0
  35. pytest_given/report/templates/app.js +438 -0
  36. pytest_given/report/templates/report.html.j2 +606 -0
  37. pytest_given/report/templates/styles.css +1013 -0
  38. pytest_given/skills_data/pytest-given-authoring/SKILL.md +43 -0
  39. pytest_given/skills_data/pytest-given-authoring/references/api.md +62 -0
  40. pytest_given/skills_data/pytest-given-authoring/references/domain-storytelling.md +36 -0
  41. pytest_given/skills_data/pytest-given-authoring/references/glossaries.md +68 -0
  42. pytest_given/skills_data/pytest-given-authoring/references/scenarios.md +29 -0
  43. pytest_given/skills_data/pytest-given-authoring/references/stories.md +53 -0
  44. pytest_given/skills_data/pytest-given-navigating/SKILL.md +40 -0
  45. pytest_given/skills_data/pytest-given-navigating/references/report-json.md +69 -0
  46. pytest_given/skills_data/pytest-given-reviewing/SKILL.md +47 -0
  47. pytest_given-0.1.0.dist-info/METADATA +510 -0
  48. pytest_given-0.1.0.dist-info/RECORD +52 -0
  49. pytest_given-0.1.0.dist-info/WHEEL +4 -0
  50. pytest_given-0.1.0.dist-info/entry_points.txt +5 -0
  51. pytest_given-0.1.0.dist-info/licenses/LICENSE.md +21 -0
  52. 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,2 @@
1
+ class PytestGivenError(RuntimeError):
2
+ """Raised when given/when/then/attach is called in an invalid lifecycle context."""
@@ -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)