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