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,208 @@
|
|
|
1
|
+
"""Caller-frame source-location capture for non-pytest-item objects.
|
|
2
|
+
|
|
3
|
+
`Story` and `GlossaryTerm` are constructed at user-code import time, not
|
|
4
|
+
inside a pytest hook, so their source-location must be reconstructed from
|
|
5
|
+
the call stack. The plugin sets rootdir during `pytest_load_initial_conftests`
|
|
6
|
+
(before root conftest is imported); capture sites then call
|
|
7
|
+
`capture_caller_source(skip=N)` from their user-facing wrappers. Returns
|
|
8
|
+
None if rootdir is unset or the caller's file lies outside rootdir — the
|
|
9
|
+
renderer treats that as "no link", same as a scenario whose `item.location`
|
|
10
|
+
is absent.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import types
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from ..model import SourceLocation
|
|
21
|
+
|
|
22
|
+
_WINDOWS_PATH_RE = re.compile(r'^([A-Za-z]):[\\/]')
|
|
23
|
+
_MNT_PATH_RE = re.compile(r'^[\\/]mnt[\\/]([A-Za-z])(?=[\\/]|$)')
|
|
24
|
+
|
|
25
|
+
# Detecting WSL and native Windows specifically (rather than "not the other")
|
|
26
|
+
# keeps each rewrite below from firing on plain Linux or macOS, where /mnt/<drive>
|
|
27
|
+
# is a real directory and a Windows-style co_filename should never occur.
|
|
28
|
+
_IS_WSL = (
|
|
29
|
+
sys.platform == 'linux'
|
|
30
|
+
and Path('/proc/version').exists()
|
|
31
|
+
and 'microsoft' in Path('/proc/version').read_text().lower()
|
|
32
|
+
)
|
|
33
|
+
_IS_WINDOWS = sys.platform == 'win32'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _co_filename_to_path(filename: str) -> Path:
|
|
37
|
+
"""Normalise a path string to the running platform's native convention.
|
|
38
|
+
|
|
39
|
+
The same file can reach us in either convention regardless of platform: a
|
|
40
|
+
frame's `co_filename` (or a stored rootdir) may be a Windows path
|
|
41
|
+
(``C:\\...``) or a WSL-mount POSIX path (``/mnt/<drive>/...``). A native
|
|
42
|
+
Windows interpreter that loads an assertion-rewritten ``.pyc`` previously
|
|
43
|
+
compiled under WSL, for instance, carries a ``/mnt/c/...`` co_filename for a
|
|
44
|
+
file the rest of the process knows as ``C:\\...``. Comparing the two
|
|
45
|
+
conventions with `relative_to` fails (mismatched drive anchors), so we fold
|
|
46
|
+
whichever foreign form we see into the native one:
|
|
47
|
+
|
|
48
|
+
- Under WSL (Linux kernel, Windows filesystem): ``C:\\...`` -> ``/mnt/c/...``
|
|
49
|
+
- Under native Windows: ``/mnt/<drive>/...`` -> ``<drive>:\\...``
|
|
50
|
+
|
|
51
|
+
On plain Linux/macOS nothing is rewritten — there ``/mnt/<drive>`` is a real
|
|
52
|
+
directory and must be left alone.
|
|
53
|
+
"""
|
|
54
|
+
if _IS_WSL:
|
|
55
|
+
match = _WINDOWS_PATH_RE.match(filename)
|
|
56
|
+
if match:
|
|
57
|
+
rest = filename[2:].replace('\\', '/')
|
|
58
|
+
return Path(f'/mnt/{match.group(1).lower()}{rest}')
|
|
59
|
+
elif _IS_WINDOWS:
|
|
60
|
+
match = _MNT_PATH_RE.match(filename)
|
|
61
|
+
if match:
|
|
62
|
+
rest = filename[match.end() :] or '/'
|
|
63
|
+
return Path(f'{match.group(1).upper()}:{rest}')
|
|
64
|
+
return Path(filename)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_rootdir: Path | None = None
|
|
68
|
+
|
|
69
|
+
# path string -> rootdir-relative result, valid only for the current _rootdir.
|
|
70
|
+
# `Path.resolve` lstats every path component, and with the lint enabled it runs
|
|
71
|
+
# once per captured step — memoizing per file keeps that O(distinct files)
|
|
72
|
+
# instead of O(steps), which matters on slow mounts (WSL 9p: ~2ms per resolve).
|
|
73
|
+
_relpath_cache: dict[str, str | None] = {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def set_rootdir(path: Path) -> None:
|
|
77
|
+
"""Called by the plugin in `pytest_load_initial_conftests`.
|
|
78
|
+
|
|
79
|
+
Folded into the native convention first (see `_co_filename_to_path`) so the
|
|
80
|
+
rootdir and the captured `co_filename`s always share a drive anchor — e.g. a
|
|
81
|
+
``/mnt/c/...`` rootdir on native Windows resolves to ``C:\\mnt\\c\\...``
|
|
82
|
+
rather than the real ``C:\\...`` and would never match.
|
|
83
|
+
"""
|
|
84
|
+
global _rootdir
|
|
85
|
+
_rootdir = _co_filename_to_path(str(path)).resolve()
|
|
86
|
+
_relpath_cache.clear()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def current_rootdir() -> Path | None:
|
|
90
|
+
"""The rootdir as last set (already resolved). The plugin saves this before
|
|
91
|
+
a nested in-process run re-points it via `set_rootdir`, and hands it back
|
|
92
|
+
to `restore_rootdir` when that run unconfigures."""
|
|
93
|
+
return _rootdir
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def restore_rootdir(previous: Path | None) -> None:
|
|
97
|
+
"""Reinstate a rootdir captured with `current_rootdir`. Unlike
|
|
98
|
+
`set_rootdir`, the value is used as-is — it was already normalised and
|
|
99
|
+
resolved when first set. Clears the relpath cache, whose entries are only
|
|
100
|
+
valid for the rootdir they were computed against."""
|
|
101
|
+
global _rootdir
|
|
102
|
+
_rootdir = previous
|
|
103
|
+
_relpath_cache.clear()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _reset_rootdir() -> None:
|
|
107
|
+
"""Test-only — reset module state between cases."""
|
|
108
|
+
global _rootdir
|
|
109
|
+
_rootdir = None
|
|
110
|
+
_relpath_cache.clear()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _relativize(abs_path: Path) -> str | None:
|
|
114
|
+
"""rootdir-relative POSIX string for an absolute path. Returns None if
|
|
115
|
+
rootdir is unset or the path lies outside it. Callers that start from a
|
|
116
|
+
`co_filename` must normalise via `_co_filename_to_path` first.
|
|
117
|
+
"""
|
|
118
|
+
if _rootdir is None:
|
|
119
|
+
return None
|
|
120
|
+
key = str(abs_path)
|
|
121
|
+
if key in _relpath_cache:
|
|
122
|
+
return _relpath_cache[key]
|
|
123
|
+
try:
|
|
124
|
+
rel: str | None = abs_path.resolve().relative_to(_rootdir).as_posix()
|
|
125
|
+
except ValueError:
|
|
126
|
+
rel = None
|
|
127
|
+
_relpath_cache[key] = rel
|
|
128
|
+
return rel
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _optional_source(abs_path: Path, line: int) -> SourceLocation | None:
|
|
132
|
+
"""SourceLocation for an absolute path, or None when it can't be made
|
|
133
|
+
rootdir-relative (rootdir unset or path outside it) — i.e. "no link". The
|
|
134
|
+
counterpart to `to_relpath`, which instead degrades to the path as given.
|
|
135
|
+
"""
|
|
136
|
+
rel = _relativize(abs_path)
|
|
137
|
+
return None if rel is None else SourceLocation(relpath=rel, line=line)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def capture_caller_source(skip: int = 1) -> SourceLocation | None:
|
|
141
|
+
"""Return a SourceLocation for the frame `skip` levels up the call stack.
|
|
142
|
+
|
|
143
|
+
`skip=1` (default) returns the immediate caller of `capture_caller_source`.
|
|
144
|
+
Use `skip=2` when called from a one-level-deep user-facing wrapper (e.g.
|
|
145
|
+
`g.actor("Guest")` -> wrapper -> this helper) so frame 2 is the user's
|
|
146
|
+
code.
|
|
147
|
+
|
|
148
|
+
Returns None if rootdir is unset, or if the caller's file resolves to a
|
|
149
|
+
path outside rootdir.
|
|
150
|
+
"""
|
|
151
|
+
frame = sys._getframe(skip)
|
|
152
|
+
abs_path = _co_filename_to_path(frame.f_code.co_filename)
|
|
153
|
+
return _optional_source(abs_path, frame.f_lineno)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def to_relpath(raw: str) -> str:
|
|
157
|
+
"""Normalise a path string and make it rootdir-relative when possible.
|
|
158
|
+
|
|
159
|
+
Folds the path into the native convention (see `_co_filename_to_path`) and,
|
|
160
|
+
if it is then an absolute path inside rootdir, returns it relative to rootdir;
|
|
161
|
+
otherwise returns it unchanged as posix. Used for both scenario
|
|
162
|
+
`item.location` paths and traceback frame paths so neither leaks an absolute,
|
|
163
|
+
machine-specific path — the same file can surface absolute (and in the wrong
|
|
164
|
+
convention) when a `.pyc` compiled by the other interpreter is reused across
|
|
165
|
+
a shared WSL+Windows checkout.
|
|
166
|
+
"""
|
|
167
|
+
path = _co_filename_to_path(raw)
|
|
168
|
+
if path.is_absolute():
|
|
169
|
+
rel = _relativize(path)
|
|
170
|
+
if rel is not None:
|
|
171
|
+
return rel
|
|
172
|
+
return path.as_posix()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def item_source(relpath_raw: str, line: int) -> SourceLocation:
|
|
176
|
+
"""Build a SourceLocation from a pytest `item.location` path + 1-based line.
|
|
177
|
+
|
|
178
|
+
`item.location[0]` is normally already rootdir-relative, but under WSL pytest
|
|
179
|
+
cannot relativize a Windows-style `co_filename` (e.g. ``C:\\Users\\...``)
|
|
180
|
+
against the POSIX ``/mnt/<drive>`` rootdir and falls back to the absolute
|
|
181
|
+
Windows path. `to_relpath` rewrites it to the native convention and
|
|
182
|
+
re-relativizes against rootdir. Unlike `capture_caller_source`, this always
|
|
183
|
+
returns a location: an absolute path outside rootdir (or unset rootdir)
|
|
184
|
+
degrades to the path as given rather than to "no link", matching the prior
|
|
185
|
+
behaviour where every scenario carried a source.
|
|
186
|
+
"""
|
|
187
|
+
return SourceLocation(relpath=to_relpath(relpath_raw), line=line)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def code_source(code: types.CodeType) -> SourceLocation | None:
|
|
191
|
+
"""SourceLocation for a code object's definition site (used to anchor
|
|
192
|
+
decorated step-helper functions for the narration lint).
|
|
193
|
+
|
|
194
|
+
`co_firstlineno` points at the function's first decorator line when it has
|
|
195
|
+
decorators; the lint's AST index accounts for that. Returns None if rootdir
|
|
196
|
+
is unset or the file lies outside it.
|
|
197
|
+
"""
|
|
198
|
+
abs_path = _co_filename_to_path(code.co_filename)
|
|
199
|
+
return _optional_source(abs_path, code.co_firstlineno)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def file_source(path: Path, line: int) -> SourceLocation | None:
|
|
203
|
+
"""SourceLocation for a known file path + line (e.g. a glossary table row).
|
|
204
|
+
|
|
205
|
+
Mirrors `capture_caller_source` but for a path we already hold rather than
|
|
206
|
+
a stack frame. Returns None if rootdir is unset or the file is outside it.
|
|
207
|
+
"""
|
|
208
|
+
return _optional_source(path, line)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Story / Activity / Path constructors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
|
|
8
|
+
from ..model import (
|
|
9
|
+
Activity,
|
|
10
|
+
ActivityId,
|
|
11
|
+
ActivityPart,
|
|
12
|
+
ActivityPath,
|
|
13
|
+
ActivityTermRef,
|
|
14
|
+
ActivityWord,
|
|
15
|
+
Glossary,
|
|
16
|
+
PytestGivenError,
|
|
17
|
+
Story,
|
|
18
|
+
StoryId,
|
|
19
|
+
id_derive,
|
|
20
|
+
)
|
|
21
|
+
from .glossary import (
|
|
22
|
+
Actor,
|
|
23
|
+
ActorInstance,
|
|
24
|
+
DeferredTermHandle,
|
|
25
|
+
DeferredTermInstance,
|
|
26
|
+
InflectedVerb,
|
|
27
|
+
Verb,
|
|
28
|
+
WorkObject,
|
|
29
|
+
WorkObjectInstance,
|
|
30
|
+
)
|
|
31
|
+
from .source import capture_caller_source
|
|
32
|
+
|
|
33
|
+
# Capture the built-in id() before it can be shadowed by local parameters.
|
|
34
|
+
_obj_id = id
|
|
35
|
+
|
|
36
|
+
type _PathArg = (
|
|
37
|
+
Actor
|
|
38
|
+
| WorkObject
|
|
39
|
+
| Verb
|
|
40
|
+
| ActorInstance
|
|
41
|
+
| WorkObjectInstance
|
|
42
|
+
| InflectedVerb
|
|
43
|
+
| DeferredTermHandle
|
|
44
|
+
| DeferredTermInstance
|
|
45
|
+
| str
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Even positions are graph nodes (entities); odd positions are edges (a verb
|
|
49
|
+
# arrow or a bare-string connective). A DeferredTermHandle/Instance has no eager
|
|
50
|
+
# kind, so it is structurally valid at either kind of position and resolved by
|
|
51
|
+
# kind inference + _slot_for later.
|
|
52
|
+
_ACTOR_TYPES = (Actor, ActorInstance, DeferredTermHandle, DeferredTermInstance)
|
|
53
|
+
_VERB_TYPES = (Verb, InflectedVerb, DeferredTermHandle, DeferredTermInstance)
|
|
54
|
+
_NODE_TYPES = (
|
|
55
|
+
Actor,
|
|
56
|
+
ActorInstance,
|
|
57
|
+
WorkObject,
|
|
58
|
+
WorkObjectInstance,
|
|
59
|
+
DeferredTermHandle,
|
|
60
|
+
DeferredTermInstance,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def path(*parts: _PathArg) -> ActivityPath:
|
|
65
|
+
"""Build an ActivityPath as a node/edge alternation, so it maps directly
|
|
66
|
+
onto a Domain Storytelling graph. Even positions (0, 2, ...) are entity
|
|
67
|
+
nodes (actor / work object); odd positions (1, 3, ...) are edges — a verb
|
|
68
|
+
handle or a bare-string connective. Position 0 is an actor, position 1 a
|
|
69
|
+
verb — but any position also accepts a bare string (an ActivityWord that
|
|
70
|
+
carries no role). The path has odd length >= 3 and ends on a node."""
|
|
71
|
+
if len(parts) < 3 or len(parts) % 2 == 0:
|
|
72
|
+
raise PytestGivenError(
|
|
73
|
+
f'activity path must alternate node / edge / node … with an odd '
|
|
74
|
+
f'length >= 3 (it must start and end on an entity node); got '
|
|
75
|
+
f'{len(parts)} part(s): {parts!r}. A trailing arrow with no target '
|
|
76
|
+
f'is not allowed — split multi-arrow activities into separate '
|
|
77
|
+
f'path(...) calls.'
|
|
78
|
+
)
|
|
79
|
+
for position, part in enumerate(parts):
|
|
80
|
+
if isinstance(part, str):
|
|
81
|
+
continue # a bare word carries no role; valid at any position
|
|
82
|
+
if position == 0:
|
|
83
|
+
_check_position(part, 0, 'actor', _ACTOR_TYPES, parts)
|
|
84
|
+
elif position % 2 == 1:
|
|
85
|
+
_check_position(part, position, 'verb', _VERB_TYPES, parts)
|
|
86
|
+
else:
|
|
87
|
+
_check_position(part, position, 'noun', _NODE_TYPES, parts)
|
|
88
|
+
schema_parts = tuple(_to_part(part) for part in parts)
|
|
89
|
+
# Stash the live Glossary objects the path references, keyed by object id —
|
|
90
|
+
# read by activity() and _check_single_glossary to enforce the v1 "one
|
|
91
|
+
# glossary per story" invariant at construction time, and by story() so the
|
|
92
|
+
# owning Glossary travels on the Story tree itself. plugin._resolve_glossary
|
|
93
|
+
# reads that stash to pick the report's glossary deterministically, without
|
|
94
|
+
# consulting any session-global. Keying by id() dedups by identity (Glossary
|
|
95
|
+
# is unhashable) while keeping the object. Not serialized.
|
|
96
|
+
glossaries: dict[int, Glossary] = {
|
|
97
|
+
_obj_id(owner): owner
|
|
98
|
+
for owner in (_glossary_of(part) for part in parts)
|
|
99
|
+
if owner is not None
|
|
100
|
+
}
|
|
101
|
+
path_obj = ActivityPath(parts=schema_parts)
|
|
102
|
+
object.__setattr__(path_obj, '_glossaries', glossaries)
|
|
103
|
+
return path_obj
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _glossary_of(value: object) -> Glossary | None:
|
|
107
|
+
match value:
|
|
108
|
+
case Actor() | WorkObject() | Verb():
|
|
109
|
+
return value.glossary
|
|
110
|
+
case ActorInstance(actor=h):
|
|
111
|
+
return h.glossary
|
|
112
|
+
case WorkObjectInstance(work_object=h):
|
|
113
|
+
return h.glossary
|
|
114
|
+
case InflectedVerb(verb=h):
|
|
115
|
+
return h.glossary
|
|
116
|
+
case DeferredTermHandle():
|
|
117
|
+
return value.glossary
|
|
118
|
+
case DeferredTermInstance(handle=handle):
|
|
119
|
+
return handle.glossary
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def activity(
|
|
124
|
+
*parts_or_paths: _PathArg | ActivityPath,
|
|
125
|
+
id: int | None = None,
|
|
126
|
+
) -> Activity:
|
|
127
|
+
"""Build an Activity from either positional parts (single path) or
|
|
128
|
+
positional ActivityPath instances (multi-path). Mixing raises.
|
|
129
|
+
|
|
130
|
+
`id=` overrides the default sequence number (0). `story(...)` reassigns
|
|
131
|
+
sequence numbers when activities are passed without explicit ids.
|
|
132
|
+
"""
|
|
133
|
+
has_paths = any(isinstance(p, ActivityPath) for p in parts_or_paths)
|
|
134
|
+
has_parts = any(not isinstance(p, ActivityPath) for p in parts_or_paths)
|
|
135
|
+
if has_paths and has_parts:
|
|
136
|
+
raise PytestGivenError(
|
|
137
|
+
'activity(...) cannot mix ActivityPath instances with bare parts; '
|
|
138
|
+
'either pass parts (for a single path) or paths (for multi-path), '
|
|
139
|
+
'not both.'
|
|
140
|
+
)
|
|
141
|
+
if has_paths:
|
|
142
|
+
paths = tuple(p for p in parts_or_paths if isinstance(p, ActivityPath))
|
|
143
|
+
else:
|
|
144
|
+
paths = (path(*parts_or_paths),) # type: ignore[arg-type]
|
|
145
|
+
glossaries = _merge_glossaries(getattr(p, '_glossaries', {}) for p in paths)
|
|
146
|
+
if id == 0:
|
|
147
|
+
raise PytestGivenError(
|
|
148
|
+
'activity(id=0) is reserved as the unset sentinel; '
|
|
149
|
+
'use id=1.. or omit to take the auto-assigned sequence number.'
|
|
150
|
+
)
|
|
151
|
+
a = Activity(id=ActivityId(id if id is not None else 0), paths=paths)
|
|
152
|
+
object.__setattr__(a, '_glossaries', glossaries)
|
|
153
|
+
return a
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _merge_glossaries(
|
|
157
|
+
stashes: Iterable[dict[int, Glossary]],
|
|
158
|
+
) -> dict[int, Glossary]:
|
|
159
|
+
"""Union the id→Glossary stashes of several paths/activities, deduping by
|
|
160
|
+
object identity."""
|
|
161
|
+
merged: dict[int, Glossary] = {}
|
|
162
|
+
for stash in stashes:
|
|
163
|
+
merged.update(stash)
|
|
164
|
+
return merged
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_STORY_REGISTRY: dict[StoryId, str] = {}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def clear_story_registry() -> None:
|
|
171
|
+
_STORY_REGISTRY.clear()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def snapshot_story_registry() -> dict[StoryId, str]:
|
|
175
|
+
"""A copy of the registry. The plugin takes one before clearing at
|
|
176
|
+
sessionstart, so a nested in-process run can hand the outer session's
|
|
177
|
+
registrations back via `restore_story_registry` when it unconfigures."""
|
|
178
|
+
return dict(_STORY_REGISTRY)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def restore_story_registry(snapshot: dict[StoryId, str]) -> None:
|
|
182
|
+
"""Reinstate a snapshot taken with `snapshot_story_registry`."""
|
|
183
|
+
_STORY_REGISTRY.clear()
|
|
184
|
+
_STORY_REGISTRY.update(snapshot)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _register_story(sid: StoryId, title: str) -> None:
|
|
188
|
+
frame = sys._getframe(2)
|
|
189
|
+
site = f'{frame.f_code.co_filename}:{frame.f_lineno}'
|
|
190
|
+
if sid in _STORY_REGISTRY:
|
|
191
|
+
raise PytestGivenError(
|
|
192
|
+
f'story {title!r} (id {sid!r}) already declared at '
|
|
193
|
+
f'{_STORY_REGISTRY[sid]}; declaring it again at {site}.'
|
|
194
|
+
)
|
|
195
|
+
_STORY_REGISTRY[sid] = site
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def story(title: str, activities: Sequence[Activity] = ()) -> Story:
|
|
199
|
+
"""Construct a Story. Reassigns auto-numbered ids, validates uniqueness,
|
|
200
|
+
and enforces v1's single-glossary invariant."""
|
|
201
|
+
sid = StoryId(id_derive(title))
|
|
202
|
+
_register_story(sid, title)
|
|
203
|
+
source = capture_caller_source(skip=2)
|
|
204
|
+
numbered = _assign_sequence_numbers(tuple(activities))
|
|
205
|
+
_check_unique_ids(numbered)
|
|
206
|
+
glossaries = _merge_glossaries(getattr(a, '_glossaries', {}) for a in numbered)
|
|
207
|
+
_check_single_glossary(title, glossaries)
|
|
208
|
+
result = Story(id=sid, title=title, activities=numbered, source=source)
|
|
209
|
+
# Carry the story's glossary on the tree so plugin._resolve_glossary can
|
|
210
|
+
# pick it deterministically from the collected stories themselves.
|
|
211
|
+
object.__setattr__(result, '_glossaries', glossaries)
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _assign_sequence_numbers(
|
|
216
|
+
activities: tuple[Activity, ...],
|
|
217
|
+
) -> tuple[Activity, ...]:
|
|
218
|
+
"""Activities passed with id=0 (the unset sentinel) get sequential ids
|
|
219
|
+
skipping any explicit ids already taken; activities with an explicit id
|
|
220
|
+
keep theirs."""
|
|
221
|
+
taken: set[ActivityId] = {a.id for a in activities if a.id != 0}
|
|
222
|
+
out: list[Activity] = []
|
|
223
|
+
next_seq = 1
|
|
224
|
+
for a in activities:
|
|
225
|
+
if a.id != 0:
|
|
226
|
+
out.append(a)
|
|
227
|
+
continue
|
|
228
|
+
while ActivityId(next_seq) in taken:
|
|
229
|
+
next_seq += 1
|
|
230
|
+
new = Activity(id=ActivityId(next_seq), paths=a.paths)
|
|
231
|
+
object.__setattr__(new, '_glossaries', getattr(a, '_glossaries', {}))
|
|
232
|
+
out.append(new)
|
|
233
|
+
next_seq += 1
|
|
234
|
+
return tuple(out)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _check_unique_ids(activities: tuple[Activity, ...]) -> None:
|
|
238
|
+
seen: set[ActivityId] = set()
|
|
239
|
+
for a in activities:
|
|
240
|
+
if a.id in seen:
|
|
241
|
+
raise PytestGivenError(
|
|
242
|
+
f'duplicate activity id {a.id} in story; activity ids must be unique.'
|
|
243
|
+
)
|
|
244
|
+
seen.add(a.id)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _check_single_glossary(title: str, glossaries: dict[int, Glossary]) -> None:
|
|
248
|
+
if len(glossaries) > 1:
|
|
249
|
+
raise PytestGivenError(
|
|
250
|
+
f'story {title!r} spans multiple glossaries ({len(glossaries)}); '
|
|
251
|
+
f'v1 supports at most one glossary per story.'
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _check_position(
|
|
256
|
+
value: object,
|
|
257
|
+
pos: int,
|
|
258
|
+
role: str,
|
|
259
|
+
accepted: tuple[type, ...],
|
|
260
|
+
full_parts: tuple[object, ...],
|
|
261
|
+
) -> None:
|
|
262
|
+
if isinstance(value, accepted):
|
|
263
|
+
return
|
|
264
|
+
raise PytestGivenError(
|
|
265
|
+
f'activity path position {pos} must be a glossary {role} handle or a '
|
|
266
|
+
f'bare string; got {type(value).__name__}: {value!r}. '
|
|
267
|
+
f'{_suggestion_for(role)} Full parts: {full_parts!r}'
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _suggestion_for(role: str) -> str:
|
|
272
|
+
if role == 'actor':
|
|
273
|
+
return (
|
|
274
|
+
'Position 0 is the actor node — pass an actor handle '
|
|
275
|
+
'(g.actor("…") / g("…")) or a bare string, not a work object or verb.'
|
|
276
|
+
)
|
|
277
|
+
if role == 'verb':
|
|
278
|
+
return (
|
|
279
|
+
'An edge takes a verb handle (g.verb("…") / g("…")) or a bare '
|
|
280
|
+
'connective string, not an actor or work object.'
|
|
281
|
+
)
|
|
282
|
+
return (
|
|
283
|
+
'A node takes an actor or work-object handle (g.work_object("…") / '
|
|
284
|
+
'g("…")) or a bare string, not a verb.'
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _to_part(value: _PathArg) -> ActivityPart:
|
|
289
|
+
match value:
|
|
290
|
+
case Actor() | WorkObject() | Verb():
|
|
291
|
+
return ActivityTermRef(term_id=value.id, display=value.canonical)
|
|
292
|
+
case ActorInstance(actor=actor, display=display):
|
|
293
|
+
return ActivityTermRef(term_id=actor.id, display=display)
|
|
294
|
+
case WorkObjectInstance(work_object=wo, display=display):
|
|
295
|
+
return ActivityTermRef(term_id=wo.id, display=display)
|
|
296
|
+
case InflectedVerb(verb=verb, display=display):
|
|
297
|
+
return ActivityTermRef(term_id=verb.id, display=display)
|
|
298
|
+
case DeferredTermHandle():
|
|
299
|
+
return ActivityTermRef(term_id=value.id, display=value.canonical)
|
|
300
|
+
case DeferredTermInstance(handle=handle, display=display):
|
|
301
|
+
return ActivityTermRef(term_id=handle.id, display=display)
|
|
302
|
+
case str():
|
|
303
|
+
return ActivityWord(text=value)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from string import Formatter, templatelib
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..model import (
|
|
6
|
+
Narration,
|
|
7
|
+
NarrationLiteral,
|
|
8
|
+
NarrationPart,
|
|
9
|
+
NarrationPlaceholder,
|
|
10
|
+
NarrationTermRef,
|
|
11
|
+
NarrationValue,
|
|
12
|
+
PytestGivenError,
|
|
13
|
+
)
|
|
14
|
+
from .glossary import (
|
|
15
|
+
Actor,
|
|
16
|
+
ActorInstance,
|
|
17
|
+
DeferredTermHandle,
|
|
18
|
+
DeferredTermInstance,
|
|
19
|
+
InflectedVerb,
|
|
20
|
+
Verb,
|
|
21
|
+
WorkObject,
|
|
22
|
+
WorkObjectInstance,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_FORMATTER = Formatter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def narration_from(
|
|
29
|
+
value: str | Template | templatelib.Template | Narration,
|
|
30
|
+
) -> Narration:
|
|
31
|
+
"""Build a Narration from a plain string, a Template, a t-string, or an
|
|
32
|
+
already-rendered Narration (which passes through unchanged — used for eager
|
|
33
|
+
glossary-t-string scenario names)."""
|
|
34
|
+
if isinstance(value, Narration):
|
|
35
|
+
return value
|
|
36
|
+
if isinstance(value, templatelib.Template):
|
|
37
|
+
text, parts = parse_tstring(value)
|
|
38
|
+
return Narration(text=text, parts=parts)
|
|
39
|
+
if isinstance(value, Template):
|
|
40
|
+
return Narration(text=value.template, parts=list(value.parts))
|
|
41
|
+
return Narration(text=value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Template:
|
|
45
|
+
"""Deferred brace-style template. Same `{...}` syntax as f/t-strings.
|
|
46
|
+
|
|
47
|
+
Supports bare identifiers only — `{name}`, `{name:spec}`, `{name!conv}`.
|
|
48
|
+
Attribute access, indexing, and arbitrary expressions raise PytestGivenError
|
|
49
|
+
at construction time.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, template: str) -> None:
|
|
53
|
+
self.template = template
|
|
54
|
+
parts: list[NarrationPart] = []
|
|
55
|
+
for literal, name, spec, conversion in _FORMATTER.parse(template):
|
|
56
|
+
if literal:
|
|
57
|
+
parts.append(NarrationLiteral(value=literal))
|
|
58
|
+
if name is not None:
|
|
59
|
+
if not name.isidentifier():
|
|
60
|
+
raise PytestGivenError(
|
|
61
|
+
f'pytest_given.Template only supports bare identifiers '
|
|
62
|
+
f'as placeholders; got {name!r}. For attribute access '
|
|
63
|
+
f'or expressions, use a t-string in the test body '
|
|
64
|
+
f'(where the value is in scope).'
|
|
65
|
+
)
|
|
66
|
+
parts.append(
|
|
67
|
+
NarrationPlaceholder(
|
|
68
|
+
name=name,
|
|
69
|
+
format_spec=spec or '',
|
|
70
|
+
conversion=conversion,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
self.parts: list[NarrationPart] = parts
|
|
74
|
+
|
|
75
|
+
def substitute(self, mapping: Mapping[str, Any]) -> str:
|
|
76
|
+
out: list[str] = []
|
|
77
|
+
for part in self.parts:
|
|
78
|
+
match part:
|
|
79
|
+
case NarrationLiteral(value=value):
|
|
80
|
+
out.append(value)
|
|
81
|
+
case NarrationPlaceholder(name=name, format_spec=spec, conversion=conv):
|
|
82
|
+
if name not in mapping:
|
|
83
|
+
raise KeyError(name)
|
|
84
|
+
resolved = _FORMATTER.convert_field(mapping[name], conv)
|
|
85
|
+
out.append(format(resolved, spec))
|
|
86
|
+
return ''.join(out)
|
|
87
|
+
|
|
88
|
+
def get_identifiers(self) -> list[str]:
|
|
89
|
+
return [p.name for p in self.parts if isinstance(p, NarrationPlaceholder)]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_tstring(
|
|
93
|
+
tstring: templatelib.Template,
|
|
94
|
+
) -> tuple[str, list[NarrationPart]]:
|
|
95
|
+
"""Convert a t-string Template into (rendered text, structured parts).
|
|
96
|
+
|
|
97
|
+
Iterates the t-string yielding str | Interpolation. Each interpolation
|
|
98
|
+
is inspected:
|
|
99
|
+
- Glossary handles (Actor, WorkObject, Verb, and their instances/inflections)
|
|
100
|
+
become NarrationTermRef carrying term_id, display, and the source expression.
|
|
101
|
+
- All other values become NarrationValue as before.
|
|
102
|
+
"""
|
|
103
|
+
parts: list[NarrationPart] = []
|
|
104
|
+
rendered_chunks: list[str] = []
|
|
105
|
+
for chunk in tstring:
|
|
106
|
+
match chunk:
|
|
107
|
+
case str() as literal:
|
|
108
|
+
if literal:
|
|
109
|
+
parts.append(NarrationLiteral(value=literal))
|
|
110
|
+
rendered_chunks.append(literal)
|
|
111
|
+
case templatelib.Interpolation(
|
|
112
|
+
value=value,
|
|
113
|
+
expression=expression,
|
|
114
|
+
conversion=conversion,
|
|
115
|
+
format_spec=format_spec,
|
|
116
|
+
):
|
|
117
|
+
term_ref = _try_term_ref(
|
|
118
|
+
value, expression, format_spec=format_spec, conversion=conversion
|
|
119
|
+
)
|
|
120
|
+
if term_ref is not None:
|
|
121
|
+
parts.append(term_ref)
|
|
122
|
+
rendered_chunks.append(term_ref.display)
|
|
123
|
+
continue
|
|
124
|
+
rendered = format(
|
|
125
|
+
_FORMATTER.convert_field(value, conversion), format_spec
|
|
126
|
+
)
|
|
127
|
+
parts.append(
|
|
128
|
+
NarrationValue(
|
|
129
|
+
rendered=rendered,
|
|
130
|
+
expression=expression,
|
|
131
|
+
format_spec=format_spec,
|
|
132
|
+
conversion=conversion,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
rendered_chunks.append(rendered)
|
|
136
|
+
return ''.join(rendered_chunks), parts
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _try_term_ref(
|
|
140
|
+
value: object,
|
|
141
|
+
expression: str = '',
|
|
142
|
+
*,
|
|
143
|
+
format_spec: str = '',
|
|
144
|
+
conversion: str | None = None,
|
|
145
|
+
) -> NarrationTermRef | None:
|
|
146
|
+
"""Return a NarrationTermRef if `value` is a glossary handle, instance, or
|
|
147
|
+
inflection — else None (fall back to NarrationValue).
|
|
148
|
+
|
|
149
|
+
Glossary handles render as kind-coloured pills carrying the term's
|
|
150
|
+
canonical or instance display; format_spec and conversion have no
|
|
151
|
+
meaningful target on a pill, so a non-empty value is rejected at
|
|
152
|
+
parse time rather than silently dropped.
|
|
153
|
+
"""
|
|
154
|
+
match value:
|
|
155
|
+
case Actor() | WorkObject() | Verb():
|
|
156
|
+
display = value.canonical
|
|
157
|
+
term_id = value.id
|
|
158
|
+
case ActorInstance(actor=h, display=display):
|
|
159
|
+
term_id = h.id
|
|
160
|
+
case WorkObjectInstance(work_object=h, display=display):
|
|
161
|
+
term_id = h.id
|
|
162
|
+
case InflectedVerb(verb=h, display=display):
|
|
163
|
+
term_id = h.id
|
|
164
|
+
case DeferredTermHandle():
|
|
165
|
+
display = value.canonical
|
|
166
|
+
term_id = value.id
|
|
167
|
+
case DeferredTermInstance(handle=handle, display=display):
|
|
168
|
+
term_id = handle.id
|
|
169
|
+
case _:
|
|
170
|
+
return None
|
|
171
|
+
if format_spec or conversion:
|
|
172
|
+
suffix = f':{format_spec}' if format_spec else ''
|
|
173
|
+
conv = f'!{conversion}' if conversion else ''
|
|
174
|
+
raise PytestGivenError(
|
|
175
|
+
f'glossary term interpolation {{{expression}{conv}{suffix}}} '
|
|
176
|
+
f'cannot carry a format spec or conversion — glossary handles '
|
|
177
|
+
f'render as kind pills with a fixed display ({display!r}). '
|
|
178
|
+
f'Drop the spec, or interpolate the underlying value separately.'
|
|
179
|
+
)
|
|
180
|
+
return NarrationTermRef(term_id=term_id, display=display, expression=expression)
|