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