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,258 @@
|
|
|
1
|
+
"""User-facing Glossary API: id derivation, value classes, registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from ..model import (
|
|
10
|
+
Glossary,
|
|
11
|
+
GlossaryTerm,
|
|
12
|
+
PytestGivenError,
|
|
13
|
+
SourceLocation,
|
|
14
|
+
TermId,
|
|
15
|
+
id_derive,
|
|
16
|
+
)
|
|
17
|
+
from .source import capture_caller_source
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_definition(definition: str | None) -> str | None:
|
|
21
|
+
"""Collapse an empty or whitespace-only definition to None so 'undefined'
|
|
22
|
+
has exactly one representation."""
|
|
23
|
+
if definition is None:
|
|
24
|
+
return None
|
|
25
|
+
stripped = definition.strip()
|
|
26
|
+
return stripped or None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class TermHandle:
|
|
31
|
+
"""Common base: a GlossaryTerm + back-ref to its owning Glossary."""
|
|
32
|
+
|
|
33
|
+
_term: GlossaryTerm
|
|
34
|
+
_glossary: Glossary
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def term(self) -> GlossaryTerm:
|
|
38
|
+
return self._term
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def glossary(self) -> Glossary:
|
|
42
|
+
return self._glossary
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def id(self) -> TermId:
|
|
46
|
+
return self._term.id
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def canonical(self) -> str:
|
|
50
|
+
return self._term.canonical
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Actor(TermHandle):
|
|
55
|
+
def __call__(self, display: str) -> ActorInstance:
|
|
56
|
+
return ActorInstance(actor=self, display=display)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def low(self) -> ActorInstance:
|
|
60
|
+
"""Instance whose surface is the canonical term lowercased -- the common
|
|
61
|
+
mid-sentence form (``guest.low`` instead of ``guest('guest')``)."""
|
|
62
|
+
return self(self.canonical.lower())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class WorkObject(TermHandle):
|
|
67
|
+
def __call__(self, display: str) -> WorkObjectInstance:
|
|
68
|
+
return WorkObjectInstance(work_object=self, display=display)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def low(self) -> WorkObjectInstance:
|
|
72
|
+
"""Instance whose surface is the canonical term lowercased -- the common
|
|
73
|
+
mid-sentence form (``room.low`` instead of ``room('room')``)."""
|
|
74
|
+
return self(self.canonical.lower())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Verb(TermHandle):
|
|
79
|
+
def __call__(self, display: str) -> InflectedVerb:
|
|
80
|
+
return InflectedVerb(verb=self, display=display)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def low(self) -> InflectedVerb:
|
|
84
|
+
"""Inflection whose surface is the canonical term lowercased -- the common
|
|
85
|
+
mid-sentence form (``book.low`` instead of ``book('book')``)."""
|
|
86
|
+
return self(self.canonical.lower())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class DeferredTermInstance:
|
|
91
|
+
handle: DeferredTermHandle
|
|
92
|
+
display: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class DeferredTermHandle(TermHandle):
|
|
97
|
+
"""Deferred-kind handle for a term whose kind is resolved post-collection
|
|
98
|
+
(file-glossary terms and code-glossary g(...) / g[...] terms). One type for
|
|
99
|
+
all kinds, unlike the eager Actor/WorkObject/Verb handles. Callable to
|
|
100
|
+
override display."""
|
|
101
|
+
|
|
102
|
+
def __call__(self, display: str) -> DeferredTermInstance:
|
|
103
|
+
return DeferredTermInstance(handle=self, display=display)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def low(self) -> DeferredTermInstance:
|
|
107
|
+
"""Instance whose surface is the canonical term lowercased -- the common
|
|
108
|
+
mid-sentence form (``pg['Attachment'].low`` instead of
|
|
109
|
+
``pg['Attachment']('attachment')``)."""
|
|
110
|
+
return self(self.canonical.lower())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def terms_match(existing: GlossaryTerm, candidate: GlossaryTerm) -> bool:
|
|
114
|
+
"""Whether two terms are the same registration: kind, canonical, and
|
|
115
|
+
definition agree (`source` is intentionally excluded). Shared by the
|
|
116
|
+
code-defined (`_register_kind`) and file-backed (`FileGlossary._add_row`)
|
|
117
|
+
idempotency-and-conflict checks so the identity rule lives in one place."""
|
|
118
|
+
return (
|
|
119
|
+
existing.kind == candidate.kind
|
|
120
|
+
and existing.canonical == candidate.canonical
|
|
121
|
+
and existing.definition == candidate.definition
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class ActorInstance:
|
|
127
|
+
actor: Actor
|
|
128
|
+
display: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class WorkObjectInstance:
|
|
133
|
+
work_object: WorkObject
|
|
134
|
+
display: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class InflectedVerb:
|
|
139
|
+
verb: Verb
|
|
140
|
+
display: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _register_kind(
|
|
144
|
+
self: Glossary,
|
|
145
|
+
kind: Literal['actor', 'object', 'verb'] | None,
|
|
146
|
+
name: str,
|
|
147
|
+
definition: str | None,
|
|
148
|
+
source: SourceLocation | None = None,
|
|
149
|
+
) -> GlossaryTerm:
|
|
150
|
+
"""Idempotent registration. Returns the canonical term (existing or new).
|
|
151
|
+
|
|
152
|
+
`source` is the call site of the user-facing wrapper (g.actor / g.work_object /
|
|
153
|
+
g.verb) — captured by those wrappers and threaded through. First-registration
|
|
154
|
+
wins: re-registration with matching (kind, canonical, definition) returns the
|
|
155
|
+
existing term unchanged, preserving its original `source`.
|
|
156
|
+
"""
|
|
157
|
+
new = GlossaryTerm(
|
|
158
|
+
id=id_derive(name),
|
|
159
|
+
kind=kind,
|
|
160
|
+
canonical=name,
|
|
161
|
+
definition=_normalize_definition(definition),
|
|
162
|
+
source=source,
|
|
163
|
+
)
|
|
164
|
+
existing = self.get(new.id)
|
|
165
|
+
if existing is not None:
|
|
166
|
+
if terms_match(existing, new):
|
|
167
|
+
return existing
|
|
168
|
+
raise PytestGivenError(
|
|
169
|
+
f'term {name!r} (id {new.id!r}) conflicts with prior registration '
|
|
170
|
+
f'(existing: kind={existing.kind!r}, canonical={existing.canonical!r}, '
|
|
171
|
+
f'definition={existing.definition!r}; new: kind={new.kind!r}, '
|
|
172
|
+
f'canonical={new.canonical!r}, definition={new.definition!r}).'
|
|
173
|
+
)
|
|
174
|
+
self._register(new)
|
|
175
|
+
return new
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_HANDLE_BY_KIND: dict[Literal['actor', 'object', 'verb'], type[TermHandle]] = {
|
|
179
|
+
'actor': Actor,
|
|
180
|
+
'object': WorkObject,
|
|
181
|
+
'verb': Verb,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _mint_handle(
|
|
186
|
+
glossary: Glossary,
|
|
187
|
+
kind: Literal['actor', 'object', 'verb'],
|
|
188
|
+
name: str,
|
|
189
|
+
definition: str | None,
|
|
190
|
+
) -> TermHandle:
|
|
191
|
+
# skip=3: this function → kind wrapper (actor/work_object/verb) → user call site
|
|
192
|
+
source = capture_caller_source(skip=3)
|
|
193
|
+
term = _register_kind(glossary, kind, name, definition, source)
|
|
194
|
+
return _HANDLE_BY_KIND[kind](_term=term, _glossary=glossary)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _glossary_actor(self: Glossary, name: str, definition: str | None = None) -> Actor:
|
|
198
|
+
handle = _mint_handle(self, 'actor', name, definition)
|
|
199
|
+
assert isinstance(handle, Actor)
|
|
200
|
+
return handle
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _glossary_work_object(
|
|
204
|
+
self: Glossary, name: str, definition: str | None = None
|
|
205
|
+
) -> WorkObject:
|
|
206
|
+
handle = _mint_handle(self, 'object', name, definition)
|
|
207
|
+
assert isinstance(handle, WorkObject)
|
|
208
|
+
return handle
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _glossary_verb(self: Glossary, name: str, definition: str | None = None) -> Verb:
|
|
212
|
+
handle = _mint_handle(self, 'verb', name, definition)
|
|
213
|
+
assert isinstance(handle, Verb)
|
|
214
|
+
return handle
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def deferred_handle_or_raise(
|
|
218
|
+
glossary: Glossary,
|
|
219
|
+
name: str,
|
|
220
|
+
handle_cache: dict[TermId, DeferredTermHandle] | None = None,
|
|
221
|
+
) -> DeferredTermHandle:
|
|
222
|
+
"""Name-based, case-insensitive get-only lookup returning a deferred handle.
|
|
223
|
+
Raises PytestGivenError with a did-you-mean hint on an unknown name. Shared
|
|
224
|
+
by the code glossary's g[...] / g(...) lookups and FileGlossary."""
|
|
225
|
+
term_id = id_derive(name)
|
|
226
|
+
if handle_cache is not None and term_id in handle_cache:
|
|
227
|
+
return handle_cache[term_id]
|
|
228
|
+
term = glossary.get(term_id)
|
|
229
|
+
if term is None:
|
|
230
|
+
close = difflib.get_close_matches(
|
|
231
|
+
name, [candidate.canonical for candidate in glossary.terms], n=3
|
|
232
|
+
)
|
|
233
|
+
hint = f' Did you mean: {", ".join(close)}?' if close else ''
|
|
234
|
+
raise PytestGivenError(f'no glossary term named {name!r}.{hint}')
|
|
235
|
+
handle = DeferredTermHandle(_term=term, _glossary=glossary)
|
|
236
|
+
if handle_cache is not None:
|
|
237
|
+
handle_cache[term_id] = handle
|
|
238
|
+
return handle
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _glossary_call(
|
|
242
|
+
self: Glossary, name: str, definition: str | None = None
|
|
243
|
+
) -> DeferredTermHandle:
|
|
244
|
+
# skip=2: this function → user call site
|
|
245
|
+
source = capture_caller_source(skip=2)
|
|
246
|
+
term = _register_kind(self, None, name, definition, source)
|
|
247
|
+
return DeferredTermHandle(_term=term, _glossary=self)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _glossary_getitem(self: Glossary, name: str) -> DeferredTermHandle:
|
|
251
|
+
return deferred_handle_or_raise(self, name)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
Glossary.actor = _glossary_actor # type: ignore[method-assign]
|
|
255
|
+
Glossary.work_object = _glossary_work_object # type: ignore[method-assign]
|
|
256
|
+
Glossary.verb = _glossary_verb # type: ignore[method-assign]
|
|
257
|
+
Glossary.__call__ = _glossary_call # type: ignore[method-assign]
|
|
258
|
+
Glossary.__getitem__ = _glossary_getitem # type: ignore[method-assign]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Post-collection inference of file-glossary term kinds from story usage.
|
|
2
|
+
|
|
3
|
+
A term referenced by an activity path gets a kind from the slot positions it
|
|
4
|
+
appears in across all stories: 0 → actor, odd → verb, even ≥ 2 → noun
|
|
5
|
+
(actor or work object). Undeclared kinds are inferred; declared kinds are
|
|
6
|
+
verified against observed positions. Conflicts raise."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from ..model import (
|
|
15
|
+
ActivityTermRef,
|
|
16
|
+
Glossary,
|
|
17
|
+
GlossaryTerm,
|
|
18
|
+
PytestGivenError,
|
|
19
|
+
Story,
|
|
20
|
+
TermId,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
type _Kind = Literal['actor', 'object', 'verb']
|
|
24
|
+
type _Slot = Literal['actor', 'verb', 'noun']
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def infer_glossary_kinds(glossary: Glossary, stories: list[Story]) -> Glossary:
|
|
28
|
+
"""Return a new Glossary with each term's kind inferred/verified."""
|
|
29
|
+
stories_by_slot: dict[TermId, dict[_Slot, set[str]]] = defaultdict(
|
|
30
|
+
lambda: defaultdict(set)
|
|
31
|
+
)
|
|
32
|
+
for story in stories:
|
|
33
|
+
for activity in story.activities:
|
|
34
|
+
for activity_path in activity.paths:
|
|
35
|
+
for position, part in enumerate(activity_path.parts):
|
|
36
|
+
if isinstance(part, ActivityTermRef):
|
|
37
|
+
slot = _slot_for(position)
|
|
38
|
+
stories_by_slot[part.term_id][slot].add(story.title)
|
|
39
|
+
inferred_terms = [
|
|
40
|
+
replace(term, kind=_infer_one(term, stories_by_slot.get(term.id, {})))
|
|
41
|
+
for term in glossary.terms
|
|
42
|
+
]
|
|
43
|
+
return Glossary(terms=inferred_terms)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _slot_for(position: int) -> _Slot:
|
|
47
|
+
if position == 0:
|
|
48
|
+
return 'actor'
|
|
49
|
+
if position % 2 == 1:
|
|
50
|
+
return 'verb'
|
|
51
|
+
return 'noun'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _infer_one(
|
|
55
|
+
term: GlossaryTerm,
|
|
56
|
+
stories_by_slot: dict[_Slot, set[str]],
|
|
57
|
+
) -> _Kind | None:
|
|
58
|
+
slots = set(stories_by_slot)
|
|
59
|
+
if term.kind is not None:
|
|
60
|
+
_verify_declared(term, stories_by_slot)
|
|
61
|
+
return term.kind
|
|
62
|
+
if 'verb' in slots and ('actor' in slots or 'noun' in slots):
|
|
63
|
+
other: _Slot = 'actor' if 'actor' in slots else 'noun'
|
|
64
|
+
where = _where(stories_by_slot, 'verb', other)
|
|
65
|
+
raise PytestGivenError(
|
|
66
|
+
f'term {term.canonical!r} is used in incompatible positions{where}: '
|
|
67
|
+
f'a verb slot and an actor/noun slot. Add a kind column to disambiguate.'
|
|
68
|
+
)
|
|
69
|
+
if 'verb' in slots:
|
|
70
|
+
return 'verb'
|
|
71
|
+
if 'actor' in slots:
|
|
72
|
+
return 'actor'
|
|
73
|
+
if 'noun' in slots:
|
|
74
|
+
return 'object'
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _verify_declared(
|
|
79
|
+
term: GlossaryTerm, stories_by_slot: dict[_Slot, set[str]]
|
|
80
|
+
) -> None:
|
|
81
|
+
declared = term.kind
|
|
82
|
+
slots = set(stories_by_slot)
|
|
83
|
+
if 'verb' in slots and declared != 'verb':
|
|
84
|
+
_raise_declared(term, 'verb slot', _where(stories_by_slot, 'verb'))
|
|
85
|
+
if 'actor' in slots and declared != 'actor':
|
|
86
|
+
_raise_declared(term, 'actor slot', _where(stories_by_slot, 'actor'))
|
|
87
|
+
if 'noun' in slots and declared == 'verb':
|
|
88
|
+
_raise_declared(term, 'noun slot', _where(stories_by_slot, 'noun'))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _raise_declared(term: GlossaryTerm, slot: str, where: str) -> None:
|
|
92
|
+
raise PytestGivenError(
|
|
93
|
+
f'term {term.canonical!r} is declared kind {term.kind!r} but appears in '
|
|
94
|
+
f'a {slot}{where}, which is incompatible.'
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _where(stories_by_slot: dict[_Slot, set[str]], *slots: _Slot) -> str:
|
|
99
|
+
"""Story titles for just the named slots, so a conflict names only the
|
|
100
|
+
stories that actually contributed the offending positions."""
|
|
101
|
+
titles = sorted({title for slot in slots for title in stories_by_slot[slot]})
|
|
102
|
+
return f' (in {", ".join(titles)})' if titles else ''
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Minimal GFM pipe-table parser for file-backed glossaries.
|
|
2
|
+
|
|
3
|
+
Parses every pipe table in a Markdown document into GlossaryRow records.
|
|
4
|
+
Not a full CommonMark parser — it recognises pipe tables, honours `\\|`
|
|
5
|
+
escapes, and skips fenced code blocks. Column selection accepts a header
|
|
6
|
+
name (case-insensitive) or a 0-based index.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from ..model import PytestGivenError
|
|
15
|
+
|
|
16
|
+
type ColumnSpec = int | str
|
|
17
|
+
|
|
18
|
+
_SEPARATOR_CELL = re.compile(r'^:?-+:?$')
|
|
19
|
+
_FENCE = re.compile(r'^\s*(```|~~~)')
|
|
20
|
+
_SPLIT_ON_UNESCAPED_PIPE = re.compile(r'(?<!\\)\|')
|
|
21
|
+
|
|
22
|
+
# Unwrap inline emphasis so a term cell written as `**Scenario**` yields the
|
|
23
|
+
# plain canonical `Scenario`. Only paired markers are unwrapped; a lone
|
|
24
|
+
# underscore inside an identifier (e.g. work_object) is left untouched.
|
|
25
|
+
_EMPHASIS = re.compile(r'\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|`(.+?)`')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class GlossaryRow:
|
|
30
|
+
term: str
|
|
31
|
+
definition: str
|
|
32
|
+
kind: str | None
|
|
33
|
+
line: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_glossary_tables(
|
|
37
|
+
text: str,
|
|
38
|
+
*,
|
|
39
|
+
term_column: ColumnSpec,
|
|
40
|
+
description_column: ColumnSpec,
|
|
41
|
+
kind_column: ColumnSpec | None,
|
|
42
|
+
) -> list[GlossaryRow]:
|
|
43
|
+
rows: list[GlossaryRow] = []
|
|
44
|
+
lines = text.splitlines()
|
|
45
|
+
in_code = False
|
|
46
|
+
index = 0
|
|
47
|
+
while index < len(lines):
|
|
48
|
+
line = lines[index]
|
|
49
|
+
if _FENCE.match(line):
|
|
50
|
+
in_code = not in_code
|
|
51
|
+
index += 1
|
|
52
|
+
continue
|
|
53
|
+
if in_code or not _is_table_row(line):
|
|
54
|
+
index += 1
|
|
55
|
+
continue
|
|
56
|
+
if index + 1 >= len(lines) or not _is_separator_row(lines[index + 1]):
|
|
57
|
+
index += 1
|
|
58
|
+
continue
|
|
59
|
+
header = _split_row(line)
|
|
60
|
+
term_idx = _resolve_column(term_column, header)
|
|
61
|
+
desc_idx = _resolve_column(description_column, header)
|
|
62
|
+
kind_idx = (
|
|
63
|
+
_resolve_column(kind_column, header) if kind_column is not None else None
|
|
64
|
+
)
|
|
65
|
+
index += 2 # consume header + separator
|
|
66
|
+
required_width = (
|
|
67
|
+
max(term_idx, desc_idx, kind_idx if kind_idx is not None else -1) + 1
|
|
68
|
+
)
|
|
69
|
+
while (
|
|
70
|
+
index < len(lines)
|
|
71
|
+
and _is_table_row(lines[index])
|
|
72
|
+
and not _FENCE.match(lines[index])
|
|
73
|
+
):
|
|
74
|
+
cells = _split_row(lines[index])
|
|
75
|
+
if len(cells) < required_width:
|
|
76
|
+
raise PytestGivenError(
|
|
77
|
+
f'data row at line {index + 1} has {len(cells)} column(s), '
|
|
78
|
+
f'but {required_width} column(s) required.'
|
|
79
|
+
)
|
|
80
|
+
kind_value = (
|
|
81
|
+
_strip_emphasis(cells[kind_idx].strip()) if kind_idx is not None else ''
|
|
82
|
+
)
|
|
83
|
+
rows.append(
|
|
84
|
+
GlossaryRow(
|
|
85
|
+
term=_strip_emphasis(cells[term_idx]),
|
|
86
|
+
definition=cells[desc_idx],
|
|
87
|
+
kind=kind_value or None,
|
|
88
|
+
line=index + 1,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
index += 1
|
|
92
|
+
if not rows:
|
|
93
|
+
raise PytestGivenError(
|
|
94
|
+
'found no Markdown pipe table in the glossary file (a table needs a '
|
|
95
|
+
'header row followed by a |---|---| separator row).'
|
|
96
|
+
)
|
|
97
|
+
return rows
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _strip_emphasis(cell: str) -> str:
|
|
101
|
+
"""Unwrap inline Markdown emphasis (**bold**, *italic*, `code`, __bold__)
|
|
102
|
+
from a cell, leaving its text content. Applied to term and kind cells so a
|
|
103
|
+
glossary written with emphasised term names renders clean pills."""
|
|
104
|
+
prev = None
|
|
105
|
+
text = cell
|
|
106
|
+
while prev != text:
|
|
107
|
+
prev = text
|
|
108
|
+
text = _EMPHASIS.sub(
|
|
109
|
+
lambda m: next(g for g in m.groups() if g is not None), text
|
|
110
|
+
)
|
|
111
|
+
return text.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_table_row(line: str) -> bool:
|
|
115
|
+
return '|' in line
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_separator_row(line: str) -> bool:
|
|
119
|
+
cells = _split_row(line)
|
|
120
|
+
return len(cells) > 0 and all(_SEPARATOR_CELL.match(cell) for cell in cells)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _split_row(line: str) -> list[str]:
|
|
124
|
+
stripped = line.strip()
|
|
125
|
+
if stripped.startswith('|'):
|
|
126
|
+
stripped = stripped[1:]
|
|
127
|
+
if stripped.endswith('|') and not stripped.endswith('\\|'):
|
|
128
|
+
stripped = stripped[:-1]
|
|
129
|
+
cells = _SPLIT_ON_UNESCAPED_PIPE.split(stripped)
|
|
130
|
+
return [cell.replace('\\|', '|').strip() for cell in cells]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _resolve_column(spec: ColumnSpec, header: list[str]) -> int:
|
|
134
|
+
if isinstance(spec, int):
|
|
135
|
+
if 0 <= spec < len(header):
|
|
136
|
+
return spec
|
|
137
|
+
raise PytestGivenError(
|
|
138
|
+
f'column index {spec} is out of range for a table with '
|
|
139
|
+
f'{len(header)} column(s): {header!r}.'
|
|
140
|
+
)
|
|
141
|
+
lowered = [cell.lower() for cell in header]
|
|
142
|
+
try:
|
|
143
|
+
return lowered.index(spec.lower())
|
|
144
|
+
except ValueError:
|
|
145
|
+
raise PytestGivenError(
|
|
146
|
+
f'column {spec!r} not found in table header {header!r}.'
|
|
147
|
+
) from None
|