executable-stories-pytest 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.
@@ -0,0 +1,5 @@
1
+ """pytest plugin for executable-stories BDD documentation."""
2
+
3
+ from executable_stories._story_api import story
4
+
5
+ __all__ = ["story"]
@@ -0,0 +1,37 @@
1
+ """Thread-safe test case collector.
2
+
3
+ Accumulates RawTestCase dicts as tests complete, then hands them off
4
+ to the JSON writer at session end.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from typing import Any
11
+
12
+
13
+ class _Collector:
14
+ """Thread-safe registry for completed test case results."""
15
+
16
+ def __init__(self) -> None:
17
+ self._lock = threading.Lock()
18
+ self._cases: list[dict[str, Any]] = []
19
+
20
+ def record(self, test_case: dict[str, Any]) -> None:
21
+ """Append a completed RawTestCase dict."""
22
+ with self._lock:
23
+ self._cases.append(test_case)
24
+
25
+ def get_all(self) -> list[dict[str, Any]]:
26
+ """Return all collected test cases."""
27
+ with self._lock:
28
+ return list(self._cases)
29
+
30
+ def clear(self) -> None:
31
+ """Reset the collector."""
32
+ with self._lock:
33
+ self._cases.clear()
34
+
35
+
36
+ # Module-level singleton
37
+ _collector = _Collector()
@@ -0,0 +1,21 @@
1
+ """Simple JSON serialization for RawRun output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+
10
+ def write_raw_run(raw_run: dict[str, Any], output_path: str) -> None:
11
+ """Write a RawRun dict to a JSON file.
12
+
13
+ Creates parent directories if they don't exist.
14
+ """
15
+ parent = os.path.dirname(output_path)
16
+ if parent:
17
+ os.makedirs(parent, exist_ok=True)
18
+
19
+ with open(output_path, "w", encoding="utf-8") as f:
20
+ json.dump(raw_run, f, indent=2)
21
+ f.write("\n")
@@ -0,0 +1,221 @@
1
+ """pytest plugin that hooks into test lifecycle to capture BDD story data.
2
+
3
+ Registered via the ``pytest11`` entry point in pyproject.toml.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import time
10
+ import traceback
11
+ from typing import Any
12
+
13
+ import pytest
14
+
15
+ from executable_stories._collector import _collector
16
+ from executable_stories._json_writer import write_raw_run
17
+ from executable_stories._story_api import story
18
+
19
+ # ── CI detection ──────────────────────────────────────────────────
20
+
21
+
22
+ def _detect_ci() -> dict[str, Any] | None:
23
+ if os.environ.get("GITHUB_ACTIONS") == "true":
24
+ ci: dict[str, Any] = {"name": "github"}
25
+ build_num = os.environ.get("GITHUB_RUN_NUMBER")
26
+ if build_num:
27
+ ci["buildNumber"] = build_num
28
+ server = os.environ.get("GITHUB_SERVER_URL")
29
+ repo = os.environ.get("GITHUB_REPOSITORY")
30
+ run_id = os.environ.get("GITHUB_RUN_ID")
31
+ if server and repo and run_id:
32
+ ci["url"] = f"{server}/{repo}/actions/runs/{run_id}"
33
+ return ci
34
+ if os.environ.get("CIRCLECI") == "true":
35
+ ci = {"name": "circleci"}
36
+ build_num = os.environ.get("CIRCLE_BUILD_NUM")
37
+ if build_num:
38
+ ci["buildNumber"] = build_num
39
+ url = os.environ.get("CIRCLE_BUILD_URL")
40
+ if url:
41
+ ci["url"] = url
42
+ return ci
43
+ if os.environ.get("JENKINS_URL"):
44
+ ci = {"name": "jenkins"}
45
+ build_num = os.environ.get("BUILD_NUMBER")
46
+ if build_num:
47
+ ci["buildNumber"] = build_num
48
+ url = os.environ.get("BUILD_URL")
49
+ if url:
50
+ ci["url"] = url
51
+ return ci
52
+ if os.environ.get("TRAVIS") == "true":
53
+ ci = {"name": "travis"}
54
+ build_num = os.environ.get("TRAVIS_BUILD_NUMBER")
55
+ if build_num:
56
+ ci["buildNumber"] = build_num
57
+ url = os.environ.get("TRAVIS_BUILD_WEB_URL")
58
+ if url:
59
+ ci["url"] = url
60
+ return ci
61
+ if os.environ.get("GITLAB_CI") == "true":
62
+ ci = {"name": "gitlab"}
63
+ build_num = os.environ.get("CI_PIPELINE_IID")
64
+ if build_num:
65
+ ci["buildNumber"] = build_num
66
+ url = os.environ.get("CI_PIPELINE_URL")
67
+ if url:
68
+ ci["url"] = url
69
+ return ci
70
+ if os.environ.get("CI") == "true":
71
+ return {"name": "ci"}
72
+ return None
73
+
74
+
75
+ # ── Session-level timestamps ───────────────────────────────────────
76
+
77
+ _started_at_ms: float = 0.0
78
+
79
+
80
+ def pytest_sessionstart(session: pytest.Session) -> None:
81
+ global _started_at_ms
82
+ _started_at_ms = time.time() * 1000
83
+ _collector.clear()
84
+
85
+
86
+ # ── Per-test hooks ─────────────────────────────────────────────────
87
+
88
+ # We store per-test start times keyed by node id.
89
+ _test_start_times: dict[str, float] = {}
90
+
91
+
92
+ @pytest.hookimpl(hookwrapper=True)
93
+ def pytest_runtest_call(item: pytest.Item) -> Any:
94
+ """Wrap test execution to set up / tear down the story context."""
95
+ _test_start_times[item.nodeid] = time.time() * 1000
96
+ yield
97
+ # Story context is cleared after report is built (in makereport)
98
+
99
+
100
+ _STATUS_MAP = {
101
+ "passed": "pass",
102
+ "failed": "fail",
103
+ "skipped": "skip",
104
+ }
105
+
106
+
107
+ @pytest.hookimpl(hookwrapper=True)
108
+ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) -> Any:
109
+ """Capture test result on the 'call' phase and record it."""
110
+ outcome = yield
111
+ report: pytest.TestReport = outcome.get_result()
112
+
113
+ if report.when != "call":
114
+ return
115
+
116
+ # Build the test case dict
117
+ start_ms = _test_start_times.pop(item.nodeid, 0.0)
118
+ now_ms = time.time() * 1000
119
+ duration_ms = now_ms - start_ms if start_ms else 0.0
120
+
121
+ # Status mapping
122
+ if hasattr(report, "wasxfail"):
123
+ status = "skip"
124
+ else:
125
+ status = _STATUS_MAP.get(report.outcome, "unknown")
126
+
127
+ # Retry info from pytest-rerunfailures (if available)
128
+ rerun = getattr(item, "execution_count", None)
129
+ retry = (rerun - 1) if rerun is not None and rerun > 0 else 0
130
+ reruns_count = getattr(item.session.config, "_rerun_count", None)
131
+ retries = reruns_count if reruns_count is not None else 0
132
+
133
+ test_case: dict[str, Any] = {
134
+ "status": status,
135
+ "externalId": item.nodeid,
136
+ "title": item.name,
137
+ "durationMs": round(duration_ms, 2),
138
+ "retry": retry,
139
+ "retries": retries,
140
+ }
141
+
142
+ # Source file info
143
+ try:
144
+ fspath = str(item.fspath)
145
+ if fspath:
146
+ test_case["sourceFile"] = fspath
147
+ if item.location and item.location[1] is not None:
148
+ test_case["sourceLine"] = item.location[1] + 1 # 0-based to 1-based
149
+ except Exception:
150
+ pass
151
+
152
+ # Error info
153
+ if report.failed and report.longrepr:
154
+ error: dict[str, str] = {}
155
+ if isinstance(report.longrepr, tuple):
156
+ error["message"] = str(report.longrepr[2])
157
+ error["stack"] = f"{report.longrepr[0]}:{report.longrepr[1]}"
158
+ else:
159
+ repr_str = str(report.longrepr)
160
+ error["message"] = repr_str
161
+ error["stack"] = repr_str
162
+ test_case["error"] = error
163
+
164
+ # Story metadata
165
+ story_meta = story._get_meta()
166
+ if story_meta is not None:
167
+ test_case["story"] = story_meta
168
+ # Build stepEvents from steps with durationMs
169
+ step_events: list[dict[str, Any]] = []
170
+ if "steps" in story_meta:
171
+ for i, step in enumerate(story_meta["steps"]):
172
+ if "durationMs" in step:
173
+ step_events.append(
174
+ {
175
+ "index": i,
176
+ "title": step["text"],
177
+ "durationMs": step["durationMs"],
178
+ }
179
+ )
180
+ if step_events:
181
+ test_case["stepEvents"] = step_events
182
+
183
+ # Attachments
184
+ attachments = story._get_attachments()
185
+ if attachments:
186
+ test_case["attachments"] = attachments
187
+
188
+ _collector.record(test_case)
189
+
190
+ # Clear story context for next test
191
+ story._clear()
192
+
193
+
194
+ # ── Session finish — write output ──────────────────────────────────
195
+
196
+
197
+ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
198
+ test_cases = _collector.get_all()
199
+ if not test_cases:
200
+ return
201
+
202
+ finished_at_ms = time.time() * 1000
203
+
204
+ raw_run: dict[str, Any] = {
205
+ "schemaVersion": 1,
206
+ "testCases": test_cases,
207
+ "projectRoot": str(session.config.rootdir), # type: ignore[attr-defined]
208
+ "startedAtMs": round(_started_at_ms, 2),
209
+ "finishedAtMs": round(finished_at_ms, 2),
210
+ }
211
+
212
+ ci = _detect_ci()
213
+ if ci is not None:
214
+ raw_run["ci"] = ci
215
+
216
+ output_path = os.environ.get(
217
+ "EXECUTABLE_STORIES_OUTPUT",
218
+ os.path.join(str(session.config.rootdir), ".executable-stories", "raw-run.json"), # type: ignore[attr-defined]
219
+ )
220
+
221
+ write_raw_run(raw_run, output_path)
@@ -0,0 +1,554 @@
1
+ """Thread-safe BDD story API.
2
+
3
+ Usage in tests::
4
+
5
+ from executable_stories import story
6
+
7
+ def test_login():
8
+ story.init("User logs in with valid credentials", tags=["auth", "smoke"])
9
+ story.given("a registered user")
10
+ story.when("they submit valid credentials")
11
+ story.then("they see the dashboard")
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import threading
19
+ import time
20
+ from typing import Any, Callable, TypeVar
21
+
22
+ _T = TypeVar("_T")
23
+
24
+
25
+ class _StoryContext:
26
+ """Per-test story context stored in thread-local storage."""
27
+
28
+ __slots__ = (
29
+ "scenario",
30
+ "steps",
31
+ "tags",
32
+ "tickets",
33
+ "meta",
34
+ "suite_path",
35
+ "docs",
36
+ "seen_primary_keywords",
37
+ "step_counter",
38
+ "attachments",
39
+ "_current_step",
40
+ "active_timers",
41
+ "timer_counter",
42
+ "otel_spans",
43
+ )
44
+
45
+ def __init__(
46
+ self,
47
+ scenario: str,
48
+ *,
49
+ tags: list[str] | None = None,
50
+ tickets: list[dict[str, Any]] | None = None,
51
+ meta: dict[str, Any] | None = None,
52
+ ) -> None:
53
+ self.scenario = scenario
54
+ self.steps: list[dict[str, Any]] = []
55
+ self.tags = tags or []
56
+ self.tickets: list[dict[str, Any]] = tickets or []
57
+ self.meta = meta or {}
58
+ self.suite_path: list[str] = []
59
+ self.docs: list[dict[str, Any]] = []
60
+ self.seen_primary_keywords: set[str] = set()
61
+ self.step_counter: int = 0
62
+ self.attachments: list[dict[str, Any]] = []
63
+ self._current_step: dict[str, Any] | None = None
64
+ self.active_timers: dict[int, dict[str, Any]] = {}
65
+ self.timer_counter: int = 0
66
+ self.otel_spans: list[dict[str, Any]] | None = None
67
+
68
+
69
+ class Story:
70
+ """Thread-safe BDD story builder.
71
+
72
+ Each test thread gets its own context via ``threading.local()``.
73
+ """
74
+
75
+ def __init__(self) -> None:
76
+ self._local = threading.local()
77
+
78
+ # ── context management ─────────────────────────────────────────
79
+
80
+ @property
81
+ def _ctx(self) -> _StoryContext | None:
82
+ return getattr(self._local, "ctx", None)
83
+
84
+ @staticmethod
85
+ def _normalize_tickets(ticket: Any) -> list[dict[str, Any]] | None:
86
+ """Normalize ticket input to list of ``{"id": ...}`` dicts."""
87
+ if ticket is None:
88
+ return None
89
+ if not isinstance(ticket, list):
90
+ ticket = [ticket]
91
+ return [{"id": t} if isinstance(t, str) else t for t in ticket]
92
+
93
+ def init(
94
+ self,
95
+ scenario: str,
96
+ *,
97
+ tags: list[str] | None = None,
98
+ ticket: str | list[str] | dict | list[dict | str] | None = None,
99
+ meta: dict[str, Any] | None = None,
100
+ trace_url_template: str | None = None,
101
+ ) -> None:
102
+ """Start a new story context for the current test."""
103
+ tickets = self._normalize_tickets(ticket)
104
+ self._local.ctx = _StoryContext(scenario, tags=tags, tickets=tickets, meta=meta)
105
+
106
+ # OTel bridge: detect active span, flow data bidirectionally
107
+ ctx = self._local.ctx
108
+ try:
109
+ from opentelemetry import trace as otel_trace # type: ignore[import-not-found]
110
+
111
+ span = otel_trace.get_current_span()
112
+ span_ctx = span.get_span_context()
113
+ if span_ctx and span_ctx.trace_id and span_ctx.trace_id != 0:
114
+ trace_id = format(span_ctx.trace_id, "032x")
115
+ span_id = format(span_ctx.span_id, "016x")
116
+
117
+ # OTel -> Story: capture traceId in structured meta
118
+ ctx.meta["otel"] = {"traceId": trace_id, "spanId": span_id}
119
+
120
+ # OTel -> Story: inject human-readable doc entries
121
+ ctx.docs.append({"kind": "kv", "label": "Trace ID", "value": trace_id, "phase": "runtime"})
122
+
123
+ template = trace_url_template or os.environ.get("OTEL_TRACE_URL_TEMPLATE")
124
+ if template:
125
+ url = template.replace("{traceId}", trace_id)
126
+ ctx.docs.append({"kind": "link", "label": "View Trace", "url": url, "phase": "runtime"})
127
+
128
+ # Story -> OTel: enrich active span with story attributes
129
+ span.set_attribute("story.scenario", scenario)
130
+ if tags:
131
+ span.set_attribute("story.tags", tags)
132
+ if ticket:
133
+ ticket_list = [ticket] if isinstance(ticket, str) else ticket
134
+ span.set_attribute("story.tickets", ticket_list)
135
+ except ImportError:
136
+ pass # opentelemetry not installed
137
+ except Exception:
138
+ pass # OTel not available or no active span
139
+
140
+ def _get_meta(self) -> dict[str, Any] | None:
141
+ """Return the StoryMeta dict for the current test, or None."""
142
+ ctx = self._ctx
143
+ if ctx is None:
144
+ return None
145
+
146
+ result: dict[str, Any] = {"scenario": ctx.scenario}
147
+ if ctx.steps:
148
+ result["steps"] = list(ctx.steps)
149
+ if ctx.tags:
150
+ result["tags"] = list(ctx.tags)
151
+ if ctx.tickets:
152
+ result["tickets"] = list(ctx.tickets)
153
+ if ctx.meta:
154
+ result["meta"] = dict(ctx.meta)
155
+ if ctx.suite_path:
156
+ result["suitePath"] = list(ctx.suite_path)
157
+ if ctx.docs:
158
+ result["docs"] = list(ctx.docs)
159
+ if ctx.otel_spans:
160
+ result["otelSpans"] = ctx.otel_spans
161
+ return result
162
+
163
+ def _get_attachments(self) -> list[dict[str, Any]]:
164
+ """Return the attachments list for the current test."""
165
+ ctx = self._ctx
166
+ if ctx is None:
167
+ return []
168
+ return list(ctx.attachments)
169
+
170
+ def _require_context(self) -> _StoryContext:
171
+ """Return the current context or raise."""
172
+ ctx = self._ctx
173
+ if ctx is None:
174
+ raise RuntimeError("story method called before story.init()")
175
+ return ctx
176
+
177
+ def _clear(self) -> None:
178
+ """Clear the current test's story context."""
179
+ self._local.ctx = None
180
+
181
+ # ── BDD steps ──────────────────────────────────────────────────
182
+
183
+ def _add_step(
184
+ self,
185
+ keyword: str,
186
+ text: str,
187
+ *,
188
+ mode: str | None = None,
189
+ docs: list[dict[str, Any]] | None = None,
190
+ ) -> None:
191
+ ctx = self._ctx
192
+ if ctx is None:
193
+ raise RuntimeError(f"story.{keyword.lower()}() called before story.init()")
194
+ # Auto-And: repeated primary keywords render as "And"
195
+ if keyword in ("Given", "When", "Then"):
196
+ if keyword in ctx.seen_primary_keywords:
197
+ keyword = "And"
198
+ else:
199
+ ctx.seen_primary_keywords.add(keyword)
200
+ step: dict[str, Any] = {"keyword": keyword, "text": text}
201
+ step["id"] = f"step-{ctx.step_counter}"
202
+ ctx.step_counter += 1
203
+ if mode is not None:
204
+ step["mode"] = mode
205
+ if docs:
206
+ step["docs"] = list(docs)
207
+ ctx.steps.append(step)
208
+ ctx._current_step = step
209
+
210
+ def given(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
211
+ self._add_step("Given", text, mode=mode, docs=docs)
212
+
213
+ def when(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
214
+ self._add_step("When", text, mode=mode, docs=docs)
215
+
216
+ def then(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
217
+ self._add_step("Then", text, mode=mode, docs=docs)
218
+
219
+ def and_(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
220
+ self._add_step("And", text, mode=mode, docs=docs)
221
+
222
+ def but(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
223
+ self._add_step("But", text, mode=mode, docs=docs)
224
+
225
+ # ── AAA pattern aliases ────────────────────────────────────────
226
+
227
+ def arrange(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
228
+ """Add an Arrange step (alias for Given)."""
229
+ self._add_step("Given", text, mode=mode, docs=docs)
230
+
231
+ def act(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
232
+ """Add an Act step (alias for When)."""
233
+ self._add_step("When", text, mode=mode, docs=docs)
234
+
235
+ def assert_(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
236
+ """Add an Assert step (alias for Then). Uses assert_ because assert is reserved."""
237
+ self._add_step("Then", text, mode=mode, docs=docs)
238
+
239
+ # ── Additional aliases ─────────────────────────────────────────
240
+
241
+ def setup(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
242
+ """Add a Setup step (alias for Given)."""
243
+ self._add_step("Given", text, mode=mode, docs=docs)
244
+
245
+ def context(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
246
+ """Add a Context step (alias for Given)."""
247
+ self._add_step("Given", text, mode=mode, docs=docs)
248
+
249
+ def execute(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
250
+ """Add an Execute step (alias for When)."""
251
+ self._add_step("When", text, mode=mode, docs=docs)
252
+
253
+ def action(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
254
+ """Add an Action step (alias for When)."""
255
+ self._add_step("When", text, mode=mode, docs=docs)
256
+
257
+ def verify(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
258
+ """Add a Verify step (alias for Then)."""
259
+ self._add_step("Then", text, mode=mode, docs=docs)
260
+
261
+ # ── Wrapped step execution ────────────────────────────────────
262
+
263
+ def fn(self, keyword: str, text: str, body: Callable[[], _T]) -> _T:
264
+ """Wrap a callable as a BDD step with automatic timing.
265
+
266
+ Creates a step marked as ``wrapped=True``, executes *body*,
267
+ records ``durationMs``, and re-raises any exception.
268
+ """
269
+ self._add_step(keyword, text)
270
+ ctx = self._require_context()
271
+ step = ctx._current_step
272
+ assert step is not None
273
+ step["wrapped"] = True
274
+
275
+ start = time.perf_counter()
276
+ try:
277
+ result = body()
278
+ return result
279
+ finally:
280
+ step["durationMs"] = (time.perf_counter() - start) * 1000.0
281
+
282
+ def expect(self, text: str, body: Callable[[], _T]) -> _T:
283
+ """Shorthand for ``fn("Then", text, body)``."""
284
+ return self.fn("Then", text, body)
285
+
286
+ # ── Step timing ────────────────────────────────────────────────
287
+
288
+ def start_timer(self) -> int:
289
+ """Start a high-resolution timer tied to the current step.
290
+ Returns a token to pass to end_timer().
291
+ """
292
+ ctx = self._require_context()
293
+ token = ctx.timer_counter
294
+ ctx.timer_counter += 1
295
+
296
+ entry: dict[str, Any] = {
297
+ "start": time.perf_counter(),
298
+ "consumed": False,
299
+ }
300
+ if ctx._current_step is not None:
301
+ entry["step_index"] = len(ctx.steps) - 1
302
+ entry["step_id"] = ctx._current_step.get("id")
303
+
304
+ ctx.active_timers[token] = entry
305
+ return token
306
+
307
+ def end_timer(self, token: int) -> None:
308
+ """Stop the timer and record durationMs on the step that was active
309
+ when start_timer() was called. Double-end is a no-op.
310
+ """
311
+ ctx = self._require_context()
312
+ entry = ctx.active_timers.get(token)
313
+ if entry is None or entry["consumed"]:
314
+ return
315
+ entry["consumed"] = True
316
+
317
+ duration_ms = (time.perf_counter() - entry["start"]) * 1000.0
318
+
319
+ step = None
320
+ step_id = entry.get("step_id")
321
+ if step_id is not None:
322
+ for s in ctx.steps:
323
+ if s.get("id") == step_id:
324
+ step = s
325
+ break
326
+ if step is None:
327
+ step_index = entry.get("step_index")
328
+ if step_index is not None and step_index < len(ctx.steps):
329
+ step = ctx.steps[step_index]
330
+
331
+ if step is not None:
332
+ step["durationMs"] = duration_ms
333
+
334
+ # ── attachments ───────────────────────────────────────────────
335
+
336
+ def attach(
337
+ self,
338
+ name: str,
339
+ media_type: str,
340
+ *,
341
+ path: str | None = None,
342
+ body: str | None = None,
343
+ encoding: str | None = None,
344
+ charset: str | None = None,
345
+ file_name: str | None = None,
346
+ ) -> "Story":
347
+ """Attach a file or inline content to the current test."""
348
+ ctx = self._require_context()
349
+ a: dict[str, Any] = {"name": name, "mediaType": media_type}
350
+ if path is not None:
351
+ a["path"] = path
352
+ if body is not None:
353
+ a["body"] = body
354
+ if encoding is not None:
355
+ a["encoding"] = encoding
356
+ if charset is not None:
357
+ a["charset"] = charset
358
+ if file_name is not None:
359
+ a["fileName"] = file_name
360
+ if ctx._current_step is not None:
361
+ steps = ctx.steps
362
+ idx = len(steps) - 1
363
+ a["stepIndex"] = idx
364
+ a["stepId"] = ctx._current_step.get("id")
365
+ ctx.attachments.append(a)
366
+ return self
367
+
368
+ def attach_spans(self, spans: list[dict[str, Any]]) -> "Story":
369
+ """Attach OTel spans for trace waterfall rendering in HTML reports."""
370
+ ctx = self._require_context()
371
+ ctx.otel_spans = spans
372
+ return self
373
+
374
+ # ── doc helpers ────────────────────────────────────────────────
375
+
376
+ def _attach_doc(self, entry: dict[str, Any]) -> None:
377
+ """Attach a doc entry to the current step or story-level docs."""
378
+ ctx = self._ctx
379
+ if ctx is None:
380
+ raise RuntimeError("Doc method called before story.init()")
381
+ if ctx.steps:
382
+ # Attach to last step
383
+ last = ctx.steps[-1]
384
+ if "docs" not in last:
385
+ last["docs"] = []
386
+ last["docs"].append(entry)
387
+ else:
388
+ # Attach to story-level docs
389
+ ctx.docs.append(entry)
390
+
391
+ def _current_step_docs(self) -> list[dict[str, Any]]:
392
+ """Return the docs list of the current (last) step."""
393
+ ctx = self._require_context()
394
+ last = ctx.steps[-1]
395
+ if "docs" not in last:
396
+ last["docs"] = []
397
+ return last["docs"]
398
+
399
+ def _story_docs(self) -> list[dict[str, Any]]:
400
+ """Return the story-level docs list."""
401
+ return self._require_context().docs
402
+
403
+ def _dedup_children(self, children: list[dict[str, Any]]) -> None:
404
+ """Remove children from all flat containers so they only appear nested."""
405
+ child_ids = {id(c) for c in children}
406
+ ctx = self._require_context()
407
+ ctx.docs[:] = [d for d in ctx.docs if id(d) not in child_ids]
408
+ for step in ctx.steps:
409
+ docs = step.get("docs")
410
+ if docs is not None:
411
+ step["docs"] = [d for d in docs if id(d) not in child_ids]
412
+
413
+ def _apply_children(self, entry: dict[str, Any], children: list[dict[str, Any]] | None) -> None:
414
+ """If children are provided, attach them and deduplicate."""
415
+ if children:
416
+ entry["children"] = children
417
+ self._dedup_children(children)
418
+
419
+ def note(self, text: str, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
420
+ """Add a free-text note."""
421
+ entry: dict[str, Any] = {"kind": "note", "text": text, "phase": "runtime"}
422
+ if children:
423
+ entry["children"] = children
424
+ self._dedup_children(children)
425
+ self._attach_doc(entry)
426
+ return entry
427
+
428
+ def tag(self, name_or_names: str | list[str], *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
429
+ """Add tag(s) as a doc entry."""
430
+ names = [name_or_names] if isinstance(name_or_names, str) else list(name_or_names)
431
+ entry: dict[str, Any] = {"kind": "tag", "names": names, "phase": "runtime"}
432
+ if children:
433
+ entry["children"] = children
434
+ self._dedup_children(children)
435
+ self._attach_doc(entry)
436
+ return entry
437
+
438
+ def kv(self, label: str, value: Any, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
439
+ """Add a key-value pair."""
440
+ entry: dict[str, Any] = {"kind": "kv", "label": label, "value": value, "phase": "runtime"}
441
+ if children:
442
+ entry["children"] = children
443
+ self._dedup_children(children)
444
+ self._attach_doc(entry)
445
+ return entry
446
+
447
+ def json(self, label: str, value: Any, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
448
+ """Add a JSON code block (serialized with indent=2)."""
449
+ entry: dict[str, Any] = {
450
+ "kind": "code",
451
+ "label": label,
452
+ "content": json.dumps(value, indent=2),
453
+ "lang": "json",
454
+ "phase": "runtime",
455
+ }
456
+ if children:
457
+ entry["children"] = children
458
+ self._dedup_children(children)
459
+ self._attach_doc(entry)
460
+ return entry
461
+
462
+ def code(self, label: str, content: str, *, lang: str | None = None, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
463
+ """Add a code block."""
464
+ entry: dict[str, Any] = {
465
+ "kind": "code",
466
+ "label": label,
467
+ "content": content,
468
+ "phase": "runtime",
469
+ }
470
+ if lang is not None:
471
+ entry["lang"] = lang
472
+ if children:
473
+ entry["children"] = children
474
+ self._dedup_children(children)
475
+ self._attach_doc(entry)
476
+ return entry
477
+
478
+ def table(self, label: str, columns: list[str], rows: list[list[str]], *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
479
+ """Add a table."""
480
+ entry: dict[str, Any] = {
481
+ "kind": "table",
482
+ "label": label,
483
+ "columns": columns,
484
+ "rows": rows,
485
+ "phase": "runtime",
486
+ }
487
+ if children:
488
+ entry["children"] = children
489
+ self._dedup_children(children)
490
+ self._attach_doc(entry)
491
+ return entry
492
+
493
+ def link(self, label: str, url: str, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
494
+ """Add a hyperlink."""
495
+ entry: dict[str, Any] = {"kind": "link", "label": label, "url": url, "phase": "runtime"}
496
+ if children:
497
+ entry["children"] = children
498
+ self._dedup_children(children)
499
+ self._attach_doc(entry)
500
+ return entry
501
+
502
+ def section(self, title: str, markdown: str, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
503
+ """Add a titled markdown section."""
504
+ entry: dict[str, Any] = {
505
+ "kind": "section",
506
+ "title": title,
507
+ "markdown": markdown,
508
+ "phase": "runtime",
509
+ }
510
+ if children:
511
+ entry["children"] = children
512
+ self._dedup_children(children)
513
+ self._attach_doc(entry)
514
+ return entry
515
+
516
+ def mermaid(self, code: str, *, title: str | None = None, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
517
+ """Add a Mermaid diagram."""
518
+ entry: dict[str, Any] = {"kind": "mermaid", "code": code, "phase": "runtime"}
519
+ if title is not None:
520
+ entry["title"] = title
521
+ if children:
522
+ entry["children"] = children
523
+ self._dedup_children(children)
524
+ self._attach_doc(entry)
525
+ return entry
526
+
527
+ def screenshot(self, path: str, *, alt: str | None = None, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
528
+ """Add a screenshot reference."""
529
+ entry: dict[str, Any] = {"kind": "screenshot", "path": path, "phase": "runtime"}
530
+ if alt is not None:
531
+ entry["alt"] = alt
532
+ if children:
533
+ entry["children"] = children
534
+ self._dedup_children(children)
535
+ self._attach_doc(entry)
536
+ return entry
537
+
538
+ def custom(self, type: str, data: Any, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
539
+ """Add a custom doc entry."""
540
+ entry: dict[str, Any] = {
541
+ "kind": "custom",
542
+ "type": type,
543
+ "data": data,
544
+ "phase": "runtime",
545
+ }
546
+ if children:
547
+ entry["children"] = children
548
+ self._dedup_children(children)
549
+ self._attach_doc(entry)
550
+ return entry
551
+
552
+
553
+ # Module-level singleton
554
+ story = Story()
@@ -0,0 +1,206 @@
1
+ """Type definitions matching the raw-run.schema.json schema.
2
+
3
+ These are plain TypedDicts used for documentation and type-checking.
4
+ At runtime everything is just dicts serialized to JSON.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, NotRequired, TypedDict
10
+
11
+ # ── DocEntry variants ──────────────────────────────────────────────
12
+
13
+
14
+ class NoteDoc(TypedDict):
15
+ kind: str # "note"
16
+ text: str
17
+ phase: str # "static" | "runtime"
18
+ children: NotRequired[list["DocEntry"]]
19
+
20
+
21
+ class TagDoc(TypedDict):
22
+ kind: str # "tag"
23
+ names: list[str]
24
+ phase: str
25
+ children: NotRequired[list["DocEntry"]]
26
+
27
+
28
+ class KvDoc(TypedDict):
29
+ kind: str # "kv"
30
+ label: str
31
+ value: Any
32
+ phase: str
33
+ children: NotRequired[list["DocEntry"]]
34
+
35
+
36
+ class CodeDoc(TypedDict):
37
+ kind: str # "code"
38
+ label: str
39
+ content: str
40
+ phase: str
41
+ lang: NotRequired[str]
42
+ children: NotRequired[list["DocEntry"]]
43
+
44
+
45
+ class TableDoc(TypedDict):
46
+ kind: str # "table"
47
+ label: str
48
+ columns: list[str]
49
+ rows: list[list[str]]
50
+ phase: str
51
+ children: NotRequired[list["DocEntry"]]
52
+
53
+
54
+ class LinkDoc(TypedDict):
55
+ kind: str # "link"
56
+ label: str
57
+ url: str
58
+ phase: str
59
+ children: NotRequired[list["DocEntry"]]
60
+
61
+
62
+ class SectionDoc(TypedDict):
63
+ kind: str # "section"
64
+ title: str
65
+ markdown: str
66
+ phase: str
67
+ children: NotRequired[list["DocEntry"]]
68
+
69
+
70
+ class MermaidDoc(TypedDict):
71
+ kind: str # "mermaid"
72
+ code: str
73
+ phase: str
74
+ title: NotRequired[str]
75
+ children: NotRequired[list["DocEntry"]]
76
+
77
+
78
+ class ScreenshotDoc(TypedDict):
79
+ kind: str # "screenshot"
80
+ path: str
81
+ phase: str
82
+ alt: NotRequired[str]
83
+ children: NotRequired[list["DocEntry"]]
84
+
85
+
86
+ class CustomDoc(TypedDict):
87
+ kind: str # "custom"
88
+ type: str
89
+ data: Any
90
+ phase: str
91
+ children: NotRequired[list["DocEntry"]]
92
+
93
+
94
+ # Union of all doc entry types
95
+ DocEntry = NoteDoc | TagDoc | KvDoc | CodeDoc | TableDoc | LinkDoc | SectionDoc | MermaidDoc | ScreenshotDoc | CustomDoc
96
+
97
+
98
+ # ── Ticket ────────────────────────────────────────────────────────
99
+
100
+
101
+ class TicketDoc(TypedDict):
102
+ id: str
103
+ url: NotRequired[str]
104
+
105
+
106
+ # ── Attachment ────────────────────────────────────────────────────
107
+
108
+
109
+ class Attachment(TypedDict, total=False):
110
+ name: str
111
+ mediaType: str
112
+ path: str
113
+ body: str
114
+ encoding: str # "BASE64" | "IDENTITY"
115
+ charset: str
116
+ fileName: str
117
+ byteLength: int
118
+ stepIndex: int
119
+ stepId: str
120
+
121
+
122
+ # ── RawStepEvent ──────────────────────────────────────────────────
123
+
124
+
125
+ class RawStepEvent(TypedDict, total=False):
126
+ index: int
127
+ title: str
128
+ status: str
129
+ durationMs: float
130
+
131
+
132
+ # ── StoryStep ──────────────────────────────────────────────────────
133
+
134
+
135
+ class StoryStep(TypedDict):
136
+ keyword: str # "Given" | "When" | "Then" | "And" | "But"
137
+ text: str
138
+ id: NotRequired[str]
139
+ mode: NotRequired[str]
140
+ wrapped: NotRequired[bool]
141
+ durationMs: NotRequired[float]
142
+ docs: NotRequired[list[DocEntry]]
143
+
144
+
145
+ # ── StoryMeta ──────────────────────────────────────────────────────
146
+
147
+
148
+ class StoryMeta(TypedDict):
149
+ scenario: str
150
+ steps: NotRequired[list[StoryStep]]
151
+ tags: NotRequired[list[str]]
152
+ tickets: NotRequired[list[TicketDoc]]
153
+ meta: NotRequired[dict[str, Any]]
154
+ suitePath: NotRequired[list[str]]
155
+ docs: NotRequired[list[DocEntry]]
156
+ sourceOrder: NotRequired[int]
157
+
158
+
159
+ # ── RawTestCase ────────────────────────────────────────────────────
160
+
161
+
162
+ class ErrorInfo(TypedDict, total=False):
163
+ message: str
164
+ stack: str
165
+
166
+
167
+ class RawTestCase(TypedDict):
168
+ status: str # "pass" | "fail" | "skip" | ...
169
+ externalId: NotRequired[str]
170
+ title: NotRequired[str]
171
+ titlePath: NotRequired[list[str]]
172
+ story: NotRequired[StoryMeta]
173
+ sourceFile: NotRequired[str]
174
+ sourceLine: NotRequired[int]
175
+ durationMs: NotRequired[float]
176
+ error: NotRequired[ErrorInfo]
177
+ meta: NotRequired[dict[str, Any]]
178
+ retry: NotRequired[int]
179
+ retries: NotRequired[int]
180
+ attachments: NotRequired[list[Attachment]]
181
+ stepEvents: NotRequired[list[RawStepEvent]]
182
+ projectName: NotRequired[str]
183
+
184
+
185
+ # ── RawCIInfo ──────────────────────────────────────────────────────
186
+
187
+
188
+ class RawCIInfo(TypedDict):
189
+ name: str
190
+ url: NotRequired[str]
191
+ buildNumber: NotRequired[str]
192
+
193
+
194
+ # ── RawRun ─────────────────────────────────────────────────────────
195
+
196
+
197
+ class RawRun(TypedDict):
198
+ schemaVersion: int # must be 1
199
+ testCases: list[RawTestCase]
200
+ projectRoot: str
201
+ startedAtMs: NotRequired[float]
202
+ finishedAtMs: NotRequired[float]
203
+ packageVersion: NotRequired[str]
204
+ gitSha: NotRequired[str]
205
+ ci: NotRequired[RawCIInfo]
206
+ meta: NotRequired[dict[str, Any]]
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: executable-stories-pytest
3
+ Version: 0.1.0
4
+ Summary: pytest plugin for executable-stories BDD documentation
5
+ Author: Jag Reehal
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jagreehal/executable-stories
8
+ Project-URL: Repository, https://github.com/jagreehal/executable-stories.git
9
+ Project-URL: Documentation, https://github.com/jagreehal/executable-stories#readme
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ Provides-Extra: otel
13
+ Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
14
+ Provides-Extra: dev
15
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
16
+ Requires-Dist: mypy>=1.5.0; extra == "dev"
17
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
18
+
19
+ # executable-stories-pytest
20
+
21
+ pytest plugin for executable-stories.
22
+
23
+ Adds BDD-style story helpers and structured story metadata so pytest tests can generate executable documentation.
@@ -0,0 +1,11 @@
1
+ executable_stories/__init__.py,sha256=X-m5x5nt_kkzd8IGJZ52e6lfr6aDArhbXci9K0fLWLM,132
2
+ executable_stories/_collector.py,sha256=JCl4crRHdAx1qqqW6ia7ABWi2yG4NN91IPZgB-VWVOs,932
3
+ executable_stories/_json_writer.py,sha256=l0wlGiijxTtEzdQ4N0oQJZI4uEHer-aIAUNlFQ-wgws,528
4
+ executable_stories/_plugin.py,sha256=f-QhkcyndouaymIiM38GOg63HRrvVN9U7lXnIiZZAyA,7190
5
+ executable_stories/_story_api.py,sha256=jOSf4-9MJGumVGhlKrX6hbEsD7tXY3V0D99Fxp3wGMw,21381
6
+ executable_stories/_types.py,sha256=iIoBdZ63eT6TIkimO7o_PXMzVSBBsope5L4gGL5ulAw,5648
7
+ executable_stories_pytest-0.1.0.dist-info/METADATA,sha256=nQLaU3J04FiaKUgN1wGAxJqYBk5Ho3wqimVZl_aP_oY,882
8
+ executable_stories_pytest-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ executable_stories_pytest-0.1.0.dist-info/entry_points.txt,sha256=NXzZqAD31EI6q3ovbbWQE7VOzsok09tPpmd-QPgSTiE,59
10
+ executable_stories_pytest-0.1.0.dist-info/top_level.txt,sha256=0CmAUpeJrtl-ykfogdr3L373JMtP92uU1P7-GIlbF7I,19
11
+ executable_stories_pytest-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ executable_stories = executable_stories._plugin
@@ -0,0 +1 @@
1
+ executable_stories