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,502 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import types
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
from string import Formatter, templatelib
|
|
8
|
+
from typing import Any, Protocol, Self, cast, runtime_checkable
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from ..model import (
|
|
13
|
+
ActivityId,
|
|
14
|
+
Narration,
|
|
15
|
+
NarrationLiteral,
|
|
16
|
+
NarrationPart,
|
|
17
|
+
NarrationPlaceholder,
|
|
18
|
+
NarrationValue,
|
|
19
|
+
Phase,
|
|
20
|
+
PytestGivenError,
|
|
21
|
+
SourceLocation,
|
|
22
|
+
Story,
|
|
23
|
+
)
|
|
24
|
+
from .collector import get_active_collector
|
|
25
|
+
from .source import capture_caller_source, code_source
|
|
26
|
+
from .template import Template, narration_from
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class StepDecorated(Protocol):
|
|
31
|
+
"""A function carrying a pytest-given step descriptor.
|
|
32
|
+
|
|
33
|
+
`StepDescriptor.__call__` stashes `self` as ``_step_descriptor`` on the
|
|
34
|
+
wrapped function; `_ensure_teardown_wrapped` does the same for the
|
|
35
|
+
generator-fixture wrapper. Read sites (`pytest_fixture_setup`,
|
|
36
|
+
`_graft_fixture_recordings`) cast to this Protocol instead of probing an
|
|
37
|
+
untyped attribute.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_step_descriptor: StepDescriptor
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@runtime_checkable
|
|
44
|
+
class ScenarioMarked(Protocol):
|
|
45
|
+
"""A test function carrying a `ScenarioDecorator` marker.
|
|
46
|
+
|
|
47
|
+
`ScenarioDecorator.__call__` stashes `self` as ``_scenario`` on the
|
|
48
|
+
wrapper; `_get_scenario_marker` reads it via this Protocol.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_scenario: ScenarioDecorator
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_TEMPLATE_PARAM_KINDS = frozenset(
|
|
55
|
+
{
|
|
56
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
57
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
58
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StepDescriptor:
|
|
64
|
+
"""Dual context-manager / decorator for Given/When/Then steps.
|
|
65
|
+
|
|
66
|
+
As a context manager:
|
|
67
|
+
with given("a coffee machine"):
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
As a decorator:
|
|
71
|
+
@given("a coffee machine")
|
|
72
|
+
def coffee_machine():
|
|
73
|
+
...
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
phase: Phase,
|
|
79
|
+
text: str | templatelib.Template | Template,
|
|
80
|
+
*,
|
|
81
|
+
activity_ids: tuple[ActivityId, ...] = (),
|
|
82
|
+
) -> None:
|
|
83
|
+
self.phase = phase
|
|
84
|
+
self._source: str | templatelib.Template | Template = text
|
|
85
|
+
self.narration: Narration = narration_from(text)
|
|
86
|
+
self.activity_ids: tuple[ActivityId, ...] = activity_ids
|
|
87
|
+
# Lint anchor of the step's `with` statement. A descriptor normally
|
|
88
|
+
# captures its own caller frame on __enter__; when_then composes two
|
|
89
|
+
# descriptors behind an extra frame, so it captures once itself and
|
|
90
|
+
# pins the shared location here (_captures_own_source False).
|
|
91
|
+
self._pinned_source: SourceLocation | None = None
|
|
92
|
+
self._captures_own_source: bool = True
|
|
93
|
+
|
|
94
|
+
def __enter__(self) -> Self:
|
|
95
|
+
if isinstance(self._source, Template):
|
|
96
|
+
raise PytestGivenError(
|
|
97
|
+
f'{self.phase}(Template(...)) is not supported in a test body; '
|
|
98
|
+
f'use a t-string for dynamic values, or a plain string for '
|
|
99
|
+
f'static labels. Template is for @scenario(...) and helper-'
|
|
100
|
+
f'function decorators, where deferred substitution is the only '
|
|
101
|
+
f'sensible option.'
|
|
102
|
+
)
|
|
103
|
+
collector = get_active_collector()
|
|
104
|
+
if collector is None or collector.state == 'idle':
|
|
105
|
+
if collector is not None and collector.inside_unannotated_test:
|
|
106
|
+
warnings.warn(
|
|
107
|
+
f"'{self.phase}: {self.narration.text}' recorded in a test "
|
|
108
|
+
'without @scenario — step will not appear in the report.',
|
|
109
|
+
pytest.PytestWarning,
|
|
110
|
+
stacklevel=2,
|
|
111
|
+
)
|
|
112
|
+
return self
|
|
113
|
+
raise PytestGivenError(
|
|
114
|
+
f"Cannot enter '{self.phase}: {self.narration.text}' — "
|
|
115
|
+
'no active scenario or fixture.'
|
|
116
|
+
)
|
|
117
|
+
source: SourceLocation | None = None
|
|
118
|
+
if collector.capture_step_source:
|
|
119
|
+
source = (
|
|
120
|
+
capture_caller_source(skip=2)
|
|
121
|
+
if self._captures_own_source
|
|
122
|
+
else self._pinned_source
|
|
123
|
+
)
|
|
124
|
+
collector.push_step(
|
|
125
|
+
self.phase, self.narration, activity_ids=self.activity_ids, source=source
|
|
126
|
+
)
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(
|
|
130
|
+
self,
|
|
131
|
+
exc_type: type[BaseException] | None,
|
|
132
|
+
exc_val: BaseException | None,
|
|
133
|
+
exc_tb: types.TracebackType | None,
|
|
134
|
+
) -> None:
|
|
135
|
+
collector = get_active_collector()
|
|
136
|
+
if collector is None or collector.inside_unannotated_test:
|
|
137
|
+
return
|
|
138
|
+
collector.pop_step()
|
|
139
|
+
|
|
140
|
+
def __call__(self, func: Callable[..., object]) -> StepDecorated:
|
|
141
|
+
is_fixture = (
|
|
142
|
+
getattr(func, '_fixture_function_marker', None) is not None
|
|
143
|
+
or getattr(func, '_pytestfixturefunction', None) is not None
|
|
144
|
+
)
|
|
145
|
+
if isinstance(self._source, Template) and is_fixture:
|
|
146
|
+
raise PytestGivenError(
|
|
147
|
+
f'@{self.phase}(Template(...)) on a fixture is not yet '
|
|
148
|
+
'supported; use a plain string label, or move the step into a '
|
|
149
|
+
'helper function.'
|
|
150
|
+
)
|
|
151
|
+
if isinstance(self._source, templatelib.Template):
|
|
152
|
+
self._check_tstring_decorator_safety()
|
|
153
|
+
sig = (
|
|
154
|
+
self._validate_template_against_signature(func)
|
|
155
|
+
if isinstance(self._source, Template)
|
|
156
|
+
else None
|
|
157
|
+
)
|
|
158
|
+
if inspect.isgeneratorfunction(func):
|
|
159
|
+
|
|
160
|
+
@functools.wraps(func)
|
|
161
|
+
def gen_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
162
|
+
yield from func(*args, **kwargs)
|
|
163
|
+
|
|
164
|
+
gen_wrapper._step_descriptor = self # type: ignore[attr-defined]
|
|
165
|
+
return cast('StepDecorated', gen_wrapper)
|
|
166
|
+
|
|
167
|
+
@functools.wraps(func)
|
|
168
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
169
|
+
collector = get_active_collector()
|
|
170
|
+
if (
|
|
171
|
+
collector is None
|
|
172
|
+
or collector.state == 'idle'
|
|
173
|
+
or collector.active_fixture_descriptor is self
|
|
174
|
+
):
|
|
175
|
+
return func(*args, **kwargs)
|
|
176
|
+
narration = (
|
|
177
|
+
self._narration_for_call(sig, args, kwargs)
|
|
178
|
+
if sig is not None
|
|
179
|
+
else self.narration
|
|
180
|
+
)
|
|
181
|
+
# The helper's FunctionDef *is* the step body — anchor there, no
|
|
182
|
+
# frame walk needed.
|
|
183
|
+
source = (
|
|
184
|
+
code_source(func.__code__) if collector.capture_step_source else None
|
|
185
|
+
)
|
|
186
|
+
collector.push_step(
|
|
187
|
+
self.phase, narration, activity_ids=self.activity_ids, source=source
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
return func(*args, **kwargs)
|
|
191
|
+
finally:
|
|
192
|
+
collector.pop_step()
|
|
193
|
+
|
|
194
|
+
wrapper._step_descriptor = self # type: ignore[attr-defined]
|
|
195
|
+
return cast('StepDecorated', wrapper)
|
|
196
|
+
|
|
197
|
+
def _check_tstring_decorator_safety(self) -> None:
|
|
198
|
+
"""A t-string passed to a decorator is evaluated once at module load;
|
|
199
|
+
any non-glossary interpolation captures its value frozen there.
|
|
200
|
+
|
|
201
|
+
Glossary handles render as `NarrationTermRef` and are safe to bake in
|
|
202
|
+
(they identify a concept, not a per-call datum). Anything else surfaces
|
|
203
|
+
as `NarrationValue`, which means the author probably expected per-call
|
|
204
|
+
substitution and won't get it — point them at the right form.
|
|
205
|
+
"""
|
|
206
|
+
for part in self.narration.parts:
|
|
207
|
+
if not isinstance(part, NarrationValue):
|
|
208
|
+
continue
|
|
209
|
+
raise PytestGivenError(
|
|
210
|
+
f'@{self.phase}(t"...") interpolates non-glossary value '
|
|
211
|
+
f'{{{part.expression}}} (rendered as {part.rendered!r}); '
|
|
212
|
+
f't-strings on a decorator evaluate once at module load, '
|
|
213
|
+
f'so the value is baked into every recorded step. '
|
|
214
|
+
f'Use a glossary handle (g.actor/g.work_object/g.verb) for a '
|
|
215
|
+
f'term reference; pytest_given.Template('
|
|
216
|
+
f"'...{{{part.expression}}}...') for a helper arg bound "
|
|
217
|
+
f'per call; or move the step into the test body (with '
|
|
218
|
+
f'given/when/then(t"...")) where the value is in scope.'
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _validate_template_against_signature(
|
|
222
|
+
self, func: Callable[..., object]
|
|
223
|
+
) -> inspect.Signature:
|
|
224
|
+
assert isinstance(self._source, Template)
|
|
225
|
+
sig = inspect.signature(func)
|
|
226
|
+
for name in self._source.get_identifiers():
|
|
227
|
+
param = sig.parameters.get(name)
|
|
228
|
+
if param is None or param.kind not in _TEMPLATE_PARAM_KINDS:
|
|
229
|
+
available = sorted(
|
|
230
|
+
n
|
|
231
|
+
for n, p in sig.parameters.items()
|
|
232
|
+
if p.kind in _TEMPLATE_PARAM_KINDS
|
|
233
|
+
)
|
|
234
|
+
raise PytestGivenError(
|
|
235
|
+
f'@{self.phase}(Template({self._source.template!r})) '
|
|
236
|
+
f'references placeholder {{{name}}} which is not a '
|
|
237
|
+
f'positional-or-keyword parameter of {func.__name__}. '
|
|
238
|
+
f'Available parameters: {available}. Rename the '
|
|
239
|
+
f'placeholder, or add the parameter.'
|
|
240
|
+
)
|
|
241
|
+
return sig
|
|
242
|
+
|
|
243
|
+
def _narration_for_call(
|
|
244
|
+
self,
|
|
245
|
+
sig: inspect.Signature,
|
|
246
|
+
args: tuple[Any, ...],
|
|
247
|
+
kwargs: Mapping[str, Any],
|
|
248
|
+
) -> Narration:
|
|
249
|
+
assert isinstance(self._source, Template)
|
|
250
|
+
bound = sig.bind(*args, **kwargs)
|
|
251
|
+
bound.apply_defaults()
|
|
252
|
+
parts = _resolve_template_parts(self.narration.parts, bound.arguments)
|
|
253
|
+
return Narration(text=self._source.substitute(bound.arguments), parts=parts)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class ScenarioDecorator:
|
|
257
|
+
"""Decorator that marks a test for inclusion in the report."""
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
name: str | Template | Narration,
|
|
262
|
+
tags: list[str],
|
|
263
|
+
*,
|
|
264
|
+
story: Story | None = None,
|
|
265
|
+
activity_ids: tuple[ActivityId, ...] = (),
|
|
266
|
+
) -> None:
|
|
267
|
+
self.name: str | Template | Narration = name
|
|
268
|
+
self.tags = tags
|
|
269
|
+
self.story = story
|
|
270
|
+
self.activity_ids = activity_ids
|
|
271
|
+
|
|
272
|
+
def __call__(self, func: Callable[..., object]) -> ScenarioMarked:
|
|
273
|
+
@functools.wraps(func)
|
|
274
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
275
|
+
return func(*args, **kwargs)
|
|
276
|
+
|
|
277
|
+
wrapper._scenario = self # type: ignore[attr-defined]
|
|
278
|
+
return cast('ScenarioMarked', wrapper)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _normalize_activity(
|
|
282
|
+
activity: int | Sequence[int] | None,
|
|
283
|
+
) -> tuple[ActivityId, ...]:
|
|
284
|
+
"""Normalize the ``activity=`` kwarg to a tuple of ActivityId values."""
|
|
285
|
+
if activity is None:
|
|
286
|
+
return ()
|
|
287
|
+
if isinstance(activity, bool | str):
|
|
288
|
+
raise TypeError(
|
|
289
|
+
f'activity must be an int or a Sequence[int], got {type(activity)!r}'
|
|
290
|
+
)
|
|
291
|
+
if isinstance(activity, int):
|
|
292
|
+
return (ActivityId(activity),)
|
|
293
|
+
if isinstance(activity, Sequence):
|
|
294
|
+
result: list[ActivityId] = []
|
|
295
|
+
for item in activity:
|
|
296
|
+
if not isinstance(item, int):
|
|
297
|
+
raise TypeError(
|
|
298
|
+
f'activity sequence must contain int values, got {type(item)!r}'
|
|
299
|
+
)
|
|
300
|
+
result.append(ActivityId(item))
|
|
301
|
+
return tuple(result)
|
|
302
|
+
raise TypeError(
|
|
303
|
+
f'activity must be an int or a Sequence[int], got {type(activity)!r}'
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def given(
|
|
308
|
+
text: str | templatelib.Template | Template,
|
|
309
|
+
*,
|
|
310
|
+
activity: int | Sequence[int] | None = None,
|
|
311
|
+
) -> StepDescriptor:
|
|
312
|
+
"""Create a Given step (context manager or decorator)."""
|
|
313
|
+
return StepDescriptor('given', text, activity_ids=_normalize_activity(activity))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def when(
|
|
317
|
+
text: str | templatelib.Template | Template,
|
|
318
|
+
*,
|
|
319
|
+
activity: int | Sequence[int] | None = None,
|
|
320
|
+
) -> StepDescriptor:
|
|
321
|
+
"""Create a When step (context manager or decorator)."""
|
|
322
|
+
return StepDescriptor('when', text, activity_ids=_normalize_activity(activity))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def then(
|
|
326
|
+
text: str | templatelib.Template | Template,
|
|
327
|
+
*,
|
|
328
|
+
activity: int | Sequence[int] | None = None,
|
|
329
|
+
) -> StepDescriptor:
|
|
330
|
+
"""Create a Then step (context manager or decorator)."""
|
|
331
|
+
return StepDescriptor('then', text, activity_ids=_normalize_activity(activity))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class WhenThen:
|
|
335
|
+
"""Narrate an action and its outcome as two sibling steps in one ``with``.
|
|
336
|
+
|
|
337
|
+
Reach for it when a single expression is both the action under test and
|
|
338
|
+
the thing you assert about — most often an expected raise, where forcing
|
|
339
|
+
one ``then`` step to carry both reads awkwardly. The body runs inside the
|
|
340
|
+
``when``; the ``then`` sibling is emitted once the body exits cleanly, so
|
|
341
|
+
pair it with a vanilla ``pytest.raises`` *inside* the same ``with`` (the
|
|
342
|
+
inner context manager swallows the error before this one's ``__exit__``
|
|
343
|
+
runs)::
|
|
344
|
+
|
|
345
|
+
with when_then('the parser reads a table-less document',
|
|
346
|
+
'no pipe table is reported'), \\
|
|
347
|
+
pytest.raises(PytestGivenError, match=r'no .*table'):
|
|
348
|
+
parse_glossary_tables(text, term_column=0, ...)
|
|
349
|
+
|
|
350
|
+
If the body raises and nothing catches it, the outcome never held: the
|
|
351
|
+
``when`` is recorded, the ``then`` is skipped, and the exception
|
|
352
|
+
propagates. It composes two `StepDescriptor` instances, so every guard
|
|
353
|
+
(Template rejection, idle/unannotated handling, t-string narration) is
|
|
354
|
+
inherited by construction.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
def __init__(
|
|
358
|
+
self,
|
|
359
|
+
when_text: str | templatelib.Template | Template,
|
|
360
|
+
then_text: str | templatelib.Template | Template,
|
|
361
|
+
) -> None:
|
|
362
|
+
self._when = StepDescriptor('when', when_text)
|
|
363
|
+
self._then = StepDescriptor('then', then_text)
|
|
364
|
+
self._when._captures_own_source = False
|
|
365
|
+
self._then._captures_own_source = False
|
|
366
|
+
|
|
367
|
+
def __enter__(self) -> Self:
|
|
368
|
+
collector = get_active_collector()
|
|
369
|
+
if collector is not None and collector.capture_step_source:
|
|
370
|
+
# Both steps share the pair's `with` statement as their anchor —
|
|
371
|
+
# captured here because the composed descriptors' own caller frame
|
|
372
|
+
# would be this method, not user code.
|
|
373
|
+
source = capture_caller_source(skip=2)
|
|
374
|
+
self._when._pinned_source = source
|
|
375
|
+
self._then._pinned_source = source
|
|
376
|
+
self._when.__enter__()
|
|
377
|
+
return self
|
|
378
|
+
|
|
379
|
+
def __exit__(
|
|
380
|
+
self,
|
|
381
|
+
exc_type: type[BaseException] | None,
|
|
382
|
+
exc_val: BaseException | None,
|
|
383
|
+
exc_tb: types.TracebackType | None,
|
|
384
|
+
) -> None:
|
|
385
|
+
self._when.__exit__(exc_type, exc_val, exc_tb)
|
|
386
|
+
if exc_type is None:
|
|
387
|
+
self._then.__enter__()
|
|
388
|
+
self._then.__exit__(None, None, None)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def when_then(
|
|
392
|
+
when_text: str | templatelib.Template | Template,
|
|
393
|
+
then_text: str | templatelib.Template | Template,
|
|
394
|
+
) -> WhenThen:
|
|
395
|
+
"""Pair a When action with its Then outcome as two sibling steps."""
|
|
396
|
+
return WhenThen(when_text, then_text)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
_FORMATTER = Formatter()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _resolve_template_parts(
|
|
403
|
+
parts: list[NarrationPart],
|
|
404
|
+
mapping: Mapping[str, Any],
|
|
405
|
+
) -> list[NarrationPart]:
|
|
406
|
+
out: list[NarrationPart] = []
|
|
407
|
+
for part in parts:
|
|
408
|
+
assert not isinstance(part, NarrationValue), (
|
|
409
|
+
'pytest_given.Template never yields NarrationValue'
|
|
410
|
+
)
|
|
411
|
+
match part:
|
|
412
|
+
case NarrationLiteral():
|
|
413
|
+
out.append(part)
|
|
414
|
+
case NarrationPlaceholder(name=name, format_spec=spec, conversion=conv):
|
|
415
|
+
resolved = _FORMATTER.convert_field(mapping[name], conv)
|
|
416
|
+
out.append(
|
|
417
|
+
NarrationValue(
|
|
418
|
+
rendered=format(resolved, spec),
|
|
419
|
+
expression=name,
|
|
420
|
+
format_spec=spec,
|
|
421
|
+
conversion=conv,
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def attach(label: str | templatelib.Template, content: object) -> None:
|
|
428
|
+
"""Attach data to the current step.
|
|
429
|
+
|
|
430
|
+
If *content* is a ``str`` it is stored verbatim. Any other type is
|
|
431
|
+
serialised as indented JSON.
|
|
432
|
+
"""
|
|
433
|
+
if isinstance(label, Template):
|
|
434
|
+
raise PytestGivenError(
|
|
435
|
+
'attach(Template(...)) is not supported; use a t-string (eager) '
|
|
436
|
+
'or a plain string.'
|
|
437
|
+
)
|
|
438
|
+
if isinstance(label, templatelib.Template):
|
|
439
|
+
label = narration_from(label).text
|
|
440
|
+
collector = get_active_collector()
|
|
441
|
+
if collector is None or collector.state == 'idle':
|
|
442
|
+
if collector is not None and collector.inside_unannotated_test:
|
|
443
|
+
warnings.warn(
|
|
444
|
+
f"attach('{label}') called in a test without @scenario — "
|
|
445
|
+
'attachment will not appear in the report.',
|
|
446
|
+
pytest.PytestWarning,
|
|
447
|
+
stacklevel=2,
|
|
448
|
+
)
|
|
449
|
+
return
|
|
450
|
+
raise PytestGivenError(
|
|
451
|
+
f"Cannot attach '{label}' — no active scenario or fixture."
|
|
452
|
+
)
|
|
453
|
+
if isinstance(content, str):
|
|
454
|
+
collector.attach(label, content, content_type='text')
|
|
455
|
+
else:
|
|
456
|
+
collector.attach(
|
|
457
|
+
label,
|
|
458
|
+
json.dumps(content, indent=2, default=str),
|
|
459
|
+
content_type='json',
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def scenario(
|
|
464
|
+
name: str | templatelib.Template | Template,
|
|
465
|
+
tags: list[str] | None = None,
|
|
466
|
+
*,
|
|
467
|
+
story: Story | None = None,
|
|
468
|
+
activities: Sequence[int] | None = None,
|
|
469
|
+
) -> ScenarioDecorator:
|
|
470
|
+
"""Mark a test for inclusion in the report."""
|
|
471
|
+
resolved_name: str | Template | Narration
|
|
472
|
+
if isinstance(name, templatelib.Template):
|
|
473
|
+
# @scenario runs at module-import time. Glossary handles are in scope
|
|
474
|
+
# then and render eagerly to term pills; a parametrize value is not,
|
|
475
|
+
# so it would be baked into the name frozen. Accept the first, reject
|
|
476
|
+
# the second — mirrors the step-decorator rule.
|
|
477
|
+
narration = narration_from(name)
|
|
478
|
+
for part in narration.parts:
|
|
479
|
+
if isinstance(part, NarrationValue):
|
|
480
|
+
raise PytestGivenError(
|
|
481
|
+
f'@scenario(t"...") interpolates non-glossary value '
|
|
482
|
+
f'{{{part.expression}}} (rendered as {part.rendered!r}); '
|
|
483
|
+
f'@scenario runs at module-import time, so parametrize '
|
|
484
|
+
f'values are not in scope. Use pytest_given.Template(...) '
|
|
485
|
+
f'for a parametrized name, a glossary handle '
|
|
486
|
+
f'(g.actor/g.work_object/g.verb) for a term pill, or a '
|
|
487
|
+
f'plain string for a static name.'
|
|
488
|
+
)
|
|
489
|
+
resolved_name = narration
|
|
490
|
+
else:
|
|
491
|
+
resolved_name = name
|
|
492
|
+
if story is not None and not isinstance(story, Story):
|
|
493
|
+
raise PytestGivenError(
|
|
494
|
+
f'@scenario(story=...) must be a Story instance; '
|
|
495
|
+
f'got {type(story).__name__}: {story!r}'
|
|
496
|
+
)
|
|
497
|
+
activity_ids: tuple[ActivityId, ...] = (
|
|
498
|
+
tuple(ActivityId(i) for i in activities) if activities else ()
|
|
499
|
+
)
|
|
500
|
+
return ScenarioDecorator(
|
|
501
|
+
resolved_name, tags or [], story=story, activity_ids=activity_ids
|
|
502
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""File-backed glossary: parse a Markdown file into a Glossary, accessed by name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from ..model import (
|
|
9
|
+
Glossary,
|
|
10
|
+
GlossaryTerm,
|
|
11
|
+
PytestGivenError,
|
|
12
|
+
TermId,
|
|
13
|
+
id_derive,
|
|
14
|
+
)
|
|
15
|
+
from .glossary import (
|
|
16
|
+
DeferredTermHandle,
|
|
17
|
+
_normalize_definition,
|
|
18
|
+
deferred_handle_or_raise,
|
|
19
|
+
terms_match,
|
|
20
|
+
)
|
|
21
|
+
from .markdown_glossary import ColumnSpec, GlossaryRow, parse_glossary_tables
|
|
22
|
+
from .source import file_source
|
|
23
|
+
|
|
24
|
+
_KIND_ALIASES: dict[str, Literal['actor', 'object', 'verb']] = {
|
|
25
|
+
'actor': 'actor',
|
|
26
|
+
'object': 'object',
|
|
27
|
+
'work object': 'object',
|
|
28
|
+
'work_object': 'object',
|
|
29
|
+
'verb': 'verb',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FileGlossary:
|
|
34
|
+
"""Glossary loaded from a Markdown file. Access terms by name: g['Guest']."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
path: str | Path,
|
|
39
|
+
*,
|
|
40
|
+
term_column: ColumnSpec = 0,
|
|
41
|
+
description_column: ColumnSpec = 1,
|
|
42
|
+
kind_column: ColumnSpec | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._path = Path(path)
|
|
45
|
+
try:
|
|
46
|
+
text = self._path.read_text(encoding='utf-8')
|
|
47
|
+
except FileNotFoundError as exc:
|
|
48
|
+
raise PytestGivenError(f'glossary file not found: {self._path}.') from exc
|
|
49
|
+
rows = parse_glossary_tables(
|
|
50
|
+
text,
|
|
51
|
+
term_column=term_column,
|
|
52
|
+
description_column=description_column,
|
|
53
|
+
kind_column=kind_column,
|
|
54
|
+
)
|
|
55
|
+
self._glossary = Glossary()
|
|
56
|
+
self._handles: dict[TermId, DeferredTermHandle] = {}
|
|
57
|
+
for row in rows:
|
|
58
|
+
self._add_row(row)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def glossary(self) -> Glossary:
|
|
62
|
+
return self._glossary
|
|
63
|
+
|
|
64
|
+
def _add_row(self, row: GlossaryRow) -> None:
|
|
65
|
+
try:
|
|
66
|
+
term_id = id_derive(row.term)
|
|
67
|
+
except PytestGivenError as exc:
|
|
68
|
+
raise PytestGivenError(f'{self._path}:{row.line}: {exc}') from exc
|
|
69
|
+
kind = self._parse_kind(row.kind, row.line)
|
|
70
|
+
term = GlossaryTerm(
|
|
71
|
+
id=term_id,
|
|
72
|
+
kind=kind,
|
|
73
|
+
canonical=row.term,
|
|
74
|
+
definition=_normalize_definition(row.definition),
|
|
75
|
+
source=file_source(self._path, row.line),
|
|
76
|
+
)
|
|
77
|
+
existing = self._glossary.get(term_id)
|
|
78
|
+
if existing is not None:
|
|
79
|
+
if not terms_match(existing, term):
|
|
80
|
+
raise PytestGivenError(
|
|
81
|
+
f'{self._path}:{row.line}: term {row.term!r} (id {term_id!r}) '
|
|
82
|
+
f'conflicts with an earlier row.'
|
|
83
|
+
)
|
|
84
|
+
return
|
|
85
|
+
self._glossary._register(term)
|
|
86
|
+
|
|
87
|
+
def _parse_kind(
|
|
88
|
+
self, raw: str | None, line: int
|
|
89
|
+
) -> Literal['actor', 'object', 'verb'] | None:
|
|
90
|
+
if raw is None:
|
|
91
|
+
return None
|
|
92
|
+
mapped = _KIND_ALIASES.get(raw.lower())
|
|
93
|
+
if mapped is None:
|
|
94
|
+
raise PytestGivenError(
|
|
95
|
+
f'{self._path}:{line}: unrecognised kind {raw!r}; expected one of '
|
|
96
|
+
f"'actor', 'object'/'work object', 'verb'."
|
|
97
|
+
)
|
|
98
|
+
return mapped
|
|
99
|
+
|
|
100
|
+
def __getitem__(self, name: str) -> DeferredTermHandle:
|
|
101
|
+
return deferred_handle_or_raise(self._glossary, name, self._handles)
|
|
102
|
+
|
|
103
|
+
def __call__(self, name: str) -> DeferredTermHandle:
|
|
104
|
+
# A FileGlossary is a closed vocabulary: the call form looks up only,
|
|
105
|
+
# never creates. Unknown names raise (same as the subscript).
|
|
106
|
+
return self[name]
|