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,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)