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,32 @@
1
+ """pytest-given: Generate interactive HTML reports from GWT tests."""
2
+
3
+ from .capture import (
4
+ FileGlossary,
5
+ Template,
6
+ activity,
7
+ attach,
8
+ given,
9
+ path,
10
+ scenario,
11
+ story,
12
+ then,
13
+ when,
14
+ when_then,
15
+ )
16
+ from .model import Glossary, PytestGivenError
17
+
18
+ __all__ = [
19
+ 'FileGlossary',
20
+ 'Glossary',
21
+ 'PytestGivenError',
22
+ 'Template',
23
+ 'activity',
24
+ 'attach',
25
+ 'given',
26
+ 'path',
27
+ 'scenario',
28
+ 'story',
29
+ 'then',
30
+ 'when',
31
+ 'when_then',
32
+ ]
@@ -0,0 +1,48 @@
1
+ from .collector import (
2
+ Collector,
3
+ FixtureInstanceKey,
4
+ get_active_collector,
5
+ set_active_collector,
6
+ )
7
+ from .decorators import (
8
+ ScenarioMarked,
9
+ StepDecorated,
10
+ attach,
11
+ given,
12
+ scenario,
13
+ then,
14
+ when,
15
+ when_then,
16
+ )
17
+ from .file_glossary import FileGlossary
18
+ from .glossary import DeferredTermHandle, DeferredTermInstance
19
+ from .story import activity, path, story
20
+ from .template import Template, narration_from
21
+ from .traceback import filter_internal_frames, parse_short_repr
22
+
23
+ __all__ = [
24
+ 'Collector',
25
+ 'DeferredTermHandle',
26
+ 'DeferredTermInstance',
27
+ 'FileGlossary',
28
+ 'FixtureInstanceKey',
29
+ 'ScenarioMarked',
30
+ 'StepDecorated',
31
+ 'Template',
32
+ 'activity',
33
+ 'attach',
34
+ 'filter_internal_frames',
35
+ 'get_active_collector',
36
+ 'given',
37
+ 'narration_from',
38
+ 'parse_short_repr',
39
+ 'path',
40
+ 'scenario',
41
+ 'set_active_collector',
42
+ 'story',
43
+ 'then',
44
+ 'when',
45
+ 'when_then',
46
+ ]
47
+
48
+ from . import glossary as _glossary_module # noqa: F401 — registers methods on Glossary
@@ -0,0 +1,369 @@
1
+ import copy
2
+ from collections.abc import Iterator
3
+ from contextvars import ContextVar
4
+ from dataclasses import dataclass
5
+
6
+ from ..model import (
7
+ ActivityId,
8
+ Attachment,
9
+ ContentType,
10
+ ErrorInfo,
11
+ FixtureRecording,
12
+ Narration,
13
+ NodeId,
14
+ ParamInfo,
15
+ Phase,
16
+ PytestGivenError,
17
+ RecordingState,
18
+ Scenario,
19
+ SourceLocation,
20
+ Step,
21
+ Story,
22
+ StoryId,
23
+ TracebackFrame,
24
+ )
25
+ from .template import Template, narration_from
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class StateToken:
30
+ """Opaque token returned by enter_* methods; pass to exit_* to restore."""
31
+
32
+ previous_state: RecordingState
33
+ previous_recording: FixtureRecording | None
34
+ previous_fixture_descriptor: object | None
35
+
36
+
37
+ type FixtureInstanceKey = tuple[object, object]
38
+
39
+ _collector_var: ContextVar[Collector | None] = ContextVar('collector', default=None)
40
+
41
+
42
+ def set_active_collector(collector: Collector | None) -> None:
43
+ """Set the active collector for the current thread."""
44
+ _collector_var.set(collector)
45
+
46
+
47
+ def get_active_collector() -> Collector | None:
48
+ """Get the active collector for the current thread, or None."""
49
+ return _collector_var.get()
50
+
51
+
52
+ class Collector:
53
+ """Collects step data during test execution.
54
+
55
+ Maintains a stack of active steps. Context managers push/pop steps.
56
+ Nested context managers create child steps.
57
+ """
58
+
59
+ def __init__(self) -> None:
60
+ self._scenarios: list[Scenario] = []
61
+ self._current_scenario: Scenario | None = None
62
+ self._step_stack: list[Step] = []
63
+ self.start_times: dict[NodeId, float] = {}
64
+ self.param_info: ParamInfo = {}
65
+ self._state: RecordingState = 'idle'
66
+ self._active_recording: FixtureRecording | None = None
67
+ self._active_fixture_descriptor: object | None = None
68
+ self._recordings: dict[FixtureInstanceKey, FixtureRecording] = {}
69
+ self.inside_unannotated_test: bool = False
70
+ # Whether steps record their body's source anchor (narration lint
71
+ # only); off is the zero-cost default — no frame walking happens.
72
+ self.capture_step_source: bool = False
73
+ self.active_scenario_story: Story | None = None
74
+ self.active_scenario_activity_ids: tuple[ActivityId, ...] = ()
75
+ self._discovered_stories: dict[StoryId, Story] = {}
76
+
77
+ @property
78
+ def state(self) -> RecordingState:
79
+ return self._state
80
+
81
+ @property
82
+ def active_scenario_id(self) -> NodeId | None:
83
+ if self._current_scenario is None:
84
+ return None
85
+ return self._current_scenario.id
86
+
87
+ @property
88
+ def scenarios(self) -> list[Scenario]:
89
+ return self._scenarios
90
+
91
+ @property
92
+ def current_phase(self) -> Phase | None:
93
+ """The phase of the innermost active step, or None."""
94
+ stack = self._target_stack()
95
+ if stack:
96
+ return stack[-1].phase
97
+ return None
98
+
99
+ @property
100
+ def active_fixture_descriptor(self) -> object | None:
101
+ """The descriptor pytest_fixture_setup pinned for the current fixture call.
102
+
103
+ Used by StepDescriptor's helper-decorator wrapper to recognise the case
104
+ where pytest is invoking it as a fixture body (in which case
105
+ pytest_fixture_setup has already created the recording's root step from
106
+ the descriptor's narration, and the wrapper must not push a duplicate).
107
+ """
108
+ return self._active_fixture_descriptor
109
+
110
+ def start_scenario(
111
+ self,
112
+ scenario_id: NodeId,
113
+ name: str | Template | Narration,
114
+ module: str,
115
+ tags: list[str],
116
+ source: SourceLocation | None = None,
117
+ *,
118
+ story: Story | None = None,
119
+ activity_ids: tuple[ActivityId, ...] = (),
120
+ ) -> None:
121
+ self._current_scenario = Scenario(
122
+ id=scenario_id,
123
+ narration=narration_from(name),
124
+ module=module,
125
+ tags=tags,
126
+ source=source,
127
+ story_id=story.id if story is not None else None,
128
+ activity_ids=activity_ids,
129
+ )
130
+ self._step_stack = []
131
+ self._state = 'test'
132
+ self.inside_unannotated_test = False
133
+ self.active_scenario_story = story
134
+ self.active_scenario_activity_ids = activity_ids
135
+ if story is not None:
136
+ self._discovered_stories[story.id] = story
137
+
138
+ def finish_scenario(
139
+ self,
140
+ status: str,
141
+ duration_ms: int,
142
+ skip_reason: str | None = None,
143
+ ) -> Scenario:
144
+ assert self._current_scenario is not None
145
+ self._current_scenario.status = status
146
+ self._current_scenario.duration_ms = duration_ms
147
+ self._current_scenario.skip_reason = skip_reason
148
+ scenario = self._current_scenario
149
+ self._scenarios.append(scenario)
150
+ self._current_scenario = None
151
+ self._step_stack = []
152
+ self._state = 'idle'
153
+ self.active_scenario_story = None
154
+ self.active_scenario_activity_ids = ()
155
+ return scenario
156
+
157
+ def enter_fixture_setup(
158
+ self,
159
+ recording: FixtureRecording,
160
+ descriptor: object | None = None,
161
+ ) -> StateToken:
162
+ token = StateToken(
163
+ previous_state=self._state,
164
+ previous_recording=self._active_recording,
165
+ previous_fixture_descriptor=self._active_fixture_descriptor,
166
+ )
167
+ self._state = 'fixture_setup'
168
+ self._active_recording = recording
169
+ self._active_fixture_descriptor = descriptor
170
+ return token
171
+
172
+ def exit_fixture_setup(self, token: StateToken) -> None:
173
+ self._state = token.previous_state
174
+ self._active_recording = token.previous_recording
175
+ self._active_fixture_descriptor = token.previous_fixture_descriptor
176
+
177
+ def enter_fixture_teardown(self) -> StateToken:
178
+ token = StateToken(
179
+ previous_state=self._state,
180
+ previous_recording=self._active_recording,
181
+ previous_fixture_descriptor=self._active_fixture_descriptor,
182
+ )
183
+ self._state = 'fixture_teardown'
184
+ return token
185
+
186
+ def exit_fixture_teardown(self, token: StateToken) -> None:
187
+ self._state = token.previous_state
188
+ self._active_recording = token.previous_recording
189
+ self._active_fixture_descriptor = token.previous_fixture_descriptor
190
+
191
+ def store_recording(
192
+ self, key: FixtureInstanceKey, recording: FixtureRecording
193
+ ) -> None:
194
+ self._recordings[key] = recording
195
+
196
+ def get_recording(self, key: FixtureInstanceKey) -> FixtureRecording | None:
197
+ return self._recordings.get(key)
198
+
199
+ def recordings(self) -> Iterator[tuple[FixtureInstanceKey, FixtureRecording]]:
200
+ """(key, recording) pairs in storage (setup) order."""
201
+ return iter(self._recordings.items())
202
+
203
+ def drop_recording(self, key: FixtureInstanceKey) -> None:
204
+ self._recordings.pop(key, None)
205
+
206
+ def graft_recording(
207
+ self,
208
+ recording: FixtureRecording,
209
+ *,
210
+ override_narration: Narration | None = None,
211
+ ) -> None:
212
+ """Deep-copy the recording's root into the active scenario's steps.
213
+
214
+ When *override_narration* is given (an Annotated label on the fixture
215
+ parameter), it replaces the grafted root's narration; the recorded
216
+ children and attachments are preserved.
217
+ """
218
+ if self._current_scenario is None:
219
+ return
220
+ root = copy.deepcopy(recording.root)
221
+ if override_narration is not None:
222
+ root.narration = override_narration
223
+ self._current_scenario.steps.append(root)
224
+
225
+ def graft_leaf_given(self, narration: Narration) -> None:
226
+ """Append a childless `given` step to the active scenario.
227
+
228
+ Used for Annotated labels on parametrize values and undecorated /
229
+ built-in fixtures — arrangements with no recorded body.
230
+ """
231
+ if self._current_scenario is None:
232
+ return
233
+ self._current_scenario.steps.append(Step(phase='given', narration=narration))
234
+
235
+ def push_step(
236
+ self,
237
+ phase: Phase,
238
+ narration: Narration,
239
+ *,
240
+ activity_ids: tuple[ActivityId, ...] = (),
241
+ source: SourceLocation | None = None,
242
+ ) -> Step:
243
+ if self._state == 'idle':
244
+ raise PytestGivenError(
245
+ f"Cannot record '{phase}: {narration.text}' — "
246
+ 'no active scenario or fixture.'
247
+ )
248
+ if self._state == 'fixture_teardown':
249
+ raise PytestGivenError(
250
+ f"Cannot record '{phase}: {narration.text}' from fixture "
251
+ 'teardown — teardown is technical, not narrative.'
252
+ )
253
+ stack = self._target_stack()
254
+ if stack and stack[-1].phase != phase:
255
+ raise PytestGivenError(
256
+ f"Cannot nest '{phase}' inside '{stack[-1].phase}'"
257
+ ' — restructure your test or use a phase-neutral helper'
258
+ )
259
+ if activity_ids:
260
+ self._check_step_activity_scope(phase, activity_ids)
261
+ step = Step(
262
+ phase=phase, narration=narration, activity_ids=activity_ids, source=source
263
+ )
264
+ if stack:
265
+ stack[-1].children.append(step)
266
+ elif self._state == 'test' and self._current_scenario is not None:
267
+ self._current_scenario.steps.append(step)
268
+ stack.append(step)
269
+ return step
270
+
271
+ def _check_step_activity_scope(
272
+ self,
273
+ phase: Phase,
274
+ activity_ids: tuple[ActivityId, ...],
275
+ ) -> None:
276
+ """Validate step activity_ids against the active scenario's story scope.
277
+
278
+ Lives on Collector (not on StepDescriptor) so every push_step entry
279
+ point — context manager, helper wrapper, future fixture grafting —
280
+ gets the check by construction.
281
+ """
282
+ story = self.active_scenario_story
283
+ if story is None:
284
+ raise PytestGivenError(
285
+ f'step activity= requires a story on the scenario '
286
+ f'(phase={phase!r}, ids={list(activity_ids)}).'
287
+ )
288
+ scope = self.active_scenario_activity_ids
289
+ valid = scope if scope else tuple(a.id for a in story.activities)
290
+ valid_set = set(valid)
291
+ for aid in activity_ids:
292
+ if aid in valid_set:
293
+ continue
294
+ if scope:
295
+ raise PytestGivenError(
296
+ f'step activity={aid} outside scenario scope '
297
+ f'(scenario activities={sorted(scope)}).'
298
+ )
299
+ raise PytestGivenError(
300
+ f'step activity={aid} not in story {story.title!r} '
301
+ f'(valid: {sorted(valid_set)}).'
302
+ )
303
+
304
+ def pop_step(self) -> Step | None:
305
+ stack = self._target_stack()
306
+ if not stack:
307
+ return None
308
+ # When recording into a fixture, don't pop the root: it's the labeled
309
+ # parent that the test will graft children under.
310
+ if self._state == 'fixture_setup' and len(stack) == 1:
311
+ return None
312
+ return stack.pop()
313
+
314
+ def attach(
315
+ self,
316
+ label: str,
317
+ content: str,
318
+ *,
319
+ content_type: ContentType = 'text',
320
+ ) -> None:
321
+ if self._state == 'idle':
322
+ raise PytestGivenError(
323
+ f"Cannot attach '{label}' — no active scenario or fixture."
324
+ )
325
+ if self._state == 'fixture_teardown':
326
+ raise PytestGivenError(
327
+ f"Cannot attach '{label}' from fixture teardown — "
328
+ 'teardown is technical, not narrative.'
329
+ )
330
+ stack = self._target_stack()
331
+ if stack:
332
+ stack[-1].attachments.append(
333
+ Attachment(label=label, content=content, content_type=content_type)
334
+ )
335
+
336
+ def _target_stack(self) -> list[Step]:
337
+ """Return the step stack that push/pop/attach should mutate, per state."""
338
+ if self._state == 'fixture_setup' and self._active_recording is not None:
339
+ return self._active_recording.stack
340
+ return self._step_stack
341
+
342
+ def fail_scenario(
343
+ self,
344
+ message: str,
345
+ frames: list[TracebackFrame] | None = None,
346
+ error_tail: str | None = None,
347
+ ) -> None:
348
+ if self._current_scenario is not None:
349
+ self._current_scenario.status = 'failed'
350
+ self._current_scenario.error = ErrorInfo(
351
+ message=message,
352
+ frames=frames or [],
353
+ error_tail=error_tail,
354
+ )
355
+
356
+ def fail_current_step(
357
+ self,
358
+ message: str,
359
+ frames: list[TracebackFrame] | None = None,
360
+ error_tail: str | None = None,
361
+ ) -> None:
362
+ if self._step_stack:
363
+ step = self._step_stack[-1]
364
+ step.status = 'failed'
365
+ step.error = ErrorInfo(
366
+ message=message,
367
+ frames=frames or [],
368
+ error_tail=error_tail,
369
+ )