executable-stories-pytest 0.1.0__py3-none-any.whl → 0.2.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.
@@ -16,6 +16,7 @@ class _Collector:
16
16
  def __init__(self) -> None:
17
17
  self._lock = threading.Lock()
18
18
  self._cases: list[dict[str, Any]] = []
19
+ self._features: dict[str, dict[str, Any]] = {}
19
20
 
20
21
  def record(self, test_case: dict[str, Any]) -> None:
21
22
  """Append a completed RawTestCase dict."""
@@ -27,10 +28,24 @@ class _Collector:
27
28
  with self._lock:
28
29
  return list(self._cases)
29
30
 
31
+ def record_feature(self, feature: dict[str, Any]) -> None:
32
+ """Store a declaration, replacing an earlier one for the same file.
33
+
34
+ A re-declaration then reads the way it does in source order.
35
+ """
36
+ with self._lock:
37
+ self._features[feature.get("sourceFile", "")] = feature
38
+
39
+ def get_features(self) -> list[dict[str, Any]]:
40
+ """Return all declared features."""
41
+ with self._lock:
42
+ return list(self._features.values())
43
+
30
44
  def clear(self) -> None:
31
45
  """Reset the collector."""
32
46
  with self._lock:
33
47
  self._cases.clear()
48
+ self._features.clear()
34
49
 
35
50
 
36
51
  # Module-level singleton
@@ -4,18 +4,28 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import tempfile
7
8
  from typing import Any
8
9
 
9
10
 
10
11
  def write_raw_run(raw_run: dict[str, Any], output_path: str) -> None:
11
12
  """Write a RawRun dict to a JSON file.
12
13
 
13
- Creates parent directories if they don't exist.
14
+ Creates parent directories if they don't exist. The file appears whole:
15
+ it is written alongside the destination and then renamed over it, so a
16
+ reader never sees a half-written run and a crash mid-write leaves the
17
+ previous file intact.
14
18
  """
15
- parent = os.path.dirname(output_path)
16
- if parent:
17
- os.makedirs(parent, exist_ok=True)
19
+ parent = os.path.dirname(output_path) or "."
20
+ os.makedirs(parent, exist_ok=True)
18
21
 
19
- with open(output_path, "w", encoding="utf-8") as f:
20
- json.dump(raw_run, f, indent=2)
21
- f.write("\n")
22
+ fd, temp_path = tempfile.mkstemp(dir=parent, prefix=".raw-run-", suffix=".json")
23
+ try:
24
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
25
+ json.dump(raw_run, f, indent=2)
26
+ f.write("\n")
27
+ os.replace(temp_path, output_path)
28
+ except BaseException:
29
+ if os.path.exists(temp_path):
30
+ os.unlink(temp_path)
31
+ raise
@@ -6,8 +6,10 @@ Registered via the ``pytest11`` entry point in pyproject.toml.
6
6
  from __future__ import annotations
7
7
 
8
8
  import os
9
+ import subprocess
10
+ import sys
9
11
  import time
10
- import traceback
12
+ from importlib.metadata import PackageNotFoundError, version
11
13
  from typing import Any
12
14
 
13
15
  import pytest
@@ -16,6 +18,18 @@ from executable_stories._collector import _collector
16
18
  from executable_stories._json_writer import write_raw_run
17
19
  from executable_stories._story_api import story
18
20
 
21
+ #: Published raw-run schema, emitted as ``$schema`` so editors validate the
22
+ #: output file as the adapter writes it.
23
+ SCHEMA_URL = "https://executable-stories.dev/schemas/raw-run.schema.json"
24
+
25
+ #: How long to wait for `git rev-parse` before giving up on the commit sha.
26
+ GIT_TIMEOUT_SECONDS = 5
27
+
28
+ #: Exit statuses that mean pytest got through everything it selected. Anything
29
+ #: else — an interrupt, an internal error, a usage error — left files it had
30
+ #: already visited only partly run.
31
+ _COMPLETED_EXIT_STATUSES = (int(pytest.ExitCode.OK), int(pytest.ExitCode.TESTS_FAILED))
32
+
19
33
  # ── CI detection ──────────────────────────────────────────────────
20
34
 
21
35
 
@@ -72,17 +86,35 @@ def _detect_ci() -> dict[str, Any] | None:
72
86
  return None
73
87
 
74
88
 
75
- # ── Session-level timestamps ───────────────────────────────────────
89
+ # ── Session state ──────────────────────────────────────────────────
76
90
 
77
91
  _started_at_ms: float = 0.0
78
92
 
93
+ #: Every file this run executed a test in, so a file emptied of scenarios is
94
+ #: distinguishable from one that never ran.
95
+ _covered_files: set[str] = set()
96
+
97
+ #: Files this run cannot speak for, so a consumer keeps what they last
98
+ #: documented rather than treating the run as authoritative.
99
+ _incomplete_files: set[str] = set()
100
+
79
101
 
80
102
  def pytest_sessionstart(session: pytest.Session) -> None:
81
103
  global _started_at_ms
82
104
  _started_at_ms = time.time() * 1000
105
+ _covered_files.clear()
106
+ _incomplete_files.clear()
83
107
  _collector.clear()
84
108
 
85
109
 
110
+ def pytest_collectreport(report: pytest.CollectReport) -> None:
111
+ """A module that failed to import reports none of the stories inside it."""
112
+ if report.failed:
113
+ source_file = report.nodeid.split("::")[0]
114
+ if source_file:
115
+ _incomplete_files.add(source_file)
116
+
117
+
86
118
  # ── Per-test hooks ─────────────────────────────────────────────────
87
119
 
88
120
  # We store per-test start times keyed by node id.
@@ -104,22 +136,59 @@ _STATUS_MAP = {
104
136
  }
105
137
 
106
138
 
139
+ def _record_file_inventory(report: pytest.TestReport, source_file: str, has_story: bool) -> None:
140
+ """Note what this run can and cannot say about ``source_file``."""
141
+ if report.skipped:
142
+ # Scenario identity comes from the title the body passes to story.init,
143
+ # and a body that never ran has none to give.
144
+ _incomplete_files.add(source_file)
145
+ return
146
+ if report.when != "call":
147
+ if report.failed:
148
+ _incomplete_files.add(source_file)
149
+ return
150
+ _covered_files.add(source_file)
151
+ if report.failed and not has_story:
152
+ # It broke before it declared anything, so its scenario is missing
153
+ # because the run failed rather than because someone deleted it.
154
+ _incomplete_files.add(source_file)
155
+
156
+
107
157
  @pytest.hookimpl(hookwrapper=True)
108
158
  def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) -> Any:
109
159
  """Capture test result on the 'call' phase and record it."""
110
160
  outcome = yield
111
161
  report: pytest.TestReport = outcome.get_result()
112
162
 
163
+ story_meta = story._get_meta() if report.when == "call" else None
164
+
165
+ # The file the item was collected from, which is pytest's own
166
+ # `report.fspath`. Not `item.location[0]`: that is where the test function
167
+ # was defined, so an inherited method would be filed under its base class.
168
+ source_file = item.nodeid.split("::")[0]
169
+ if source_file:
170
+ _record_file_inventory(report, source_file, story_meta is not None)
171
+
113
172
  if report.when != "call":
114
173
  return
115
174
 
116
- # Build the test case dict
175
+ # A red test shows how far its story got without opening the report. A
176
+ # section, not longrepr, so the recorded error message stays as it was.
177
+ if report.failed:
178
+ trail = story._step_trail()
179
+ if trail:
180
+ report.sections.append(("Story steps", trail))
181
+
117
182
  start_ms = _test_start_times.pop(item.nodeid, 0.0)
118
183
  now_ms = time.time() * 1000
119
184
  duration_ms = now_ms - start_ms if start_ms else 0.0
120
185
 
121
- # Status mapping
122
- if hasattr(report, "wasxfail"):
186
+ # Status mapping. A planned declaration only counts when the test itself
187
+ # came out clean: code after story.planned() can still fail, and reporting
188
+ # that failure as "planned" would hide it.
189
+ if story.is_planned() and report.outcome == "passed":
190
+ status = "todo"
191
+ elif hasattr(report, "wasxfail"):
123
192
  status = "skip"
124
193
  else:
125
194
  status = _STATUS_MAP.get(report.outcome, "unknown")
@@ -139,17 +208,13 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) ->
139
208
  "retries": retries,
140
209
  }
141
210
 
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
211
+ if source_file:
212
+ test_case["sourceFile"] = source_file
213
+ # Only when the body lives in the file it was collected from: an inherited
214
+ # test's line points into another module.
215
+ if item.location and item.location[1] is not None and item.location[0] == source_file:
216
+ test_case["sourceLine"] = item.location[1] + 1 # 0-based to 1-based
217
+
153
218
  if report.failed and report.longrepr:
154
219
  error: dict[str, str] = {}
155
220
  if isinstance(report.longrepr, tuple):
@@ -161,11 +226,8 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) ->
161
226
  error["stack"] = repr_str
162
227
  test_case["error"] = error
163
228
 
164
- # Story metadata
165
- story_meta = story._get_meta()
166
229
  if story_meta is not None:
167
230
  test_case["story"] = story_meta
168
- # Build stepEvents from steps with durationMs
169
231
  step_events: list[dict[str, Any]] = []
170
232
  if "steps" in story_meta:
171
233
  for i, step in enumerate(story_meta["steps"]):
@@ -180,42 +242,195 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) ->
180
242
  if step_events:
181
243
  test_case["stepEvents"] = step_events
182
244
 
183
- # Attachments
184
245
  attachments = story._get_attachments()
185
246
  if attachments:
186
247
  test_case["attachments"] = attachments
187
248
 
188
249
  _collector.record(test_case)
189
250
 
190
- # Clear story context for next test
191
251
  story._clear()
192
252
 
193
253
 
194
254
  # ── Session finish — write output ──────────────────────────────────
195
255
 
196
256
 
257
+ def _run_scope(config: Any) -> str | None:
258
+ """How much of each source file this run covered.
259
+
260
+ ``"filtered"`` when pytest was pointed at part of a file rather than all of
261
+ it — ``-k``, ``-m``, ``--deselect``, ``--last-failed``, or a node id naming
262
+ a single test — and ``"full"`` when none of those applied. ``None`` when
263
+ there is no option object to inspect: some entry points build a config
264
+ without one. ``None`` means unknown, and a consumer keeps what an
265
+ unknown-scope run did not report rather than retiring it on a guess.
266
+ """
267
+ option = getattr(config, "option", None)
268
+ if option is None:
269
+ return None
270
+ if bool(getattr(option, "keyword", "")) or bool(getattr(option, "markexpr", "")):
271
+ return "filtered"
272
+ if getattr(option, "deselect", None) or getattr(option, "lf", False):
273
+ return "filtered"
274
+ # `pytest tests/test_cart.py::test_totals` runs one test out of a file, so
275
+ # the file's other scenarios simply were not part of this run.
276
+ if any("::" in str(arg) for arg in getattr(option, "file_or_dir", None) or []):
277
+ return "filtered"
278
+ return "full"
279
+
280
+
281
+ def _relative_source(source: str, project_root: str) -> str:
282
+ """``source`` relative to the project root, in POSIX form."""
283
+ try:
284
+ relative = os.path.relpath(source, project_root)
285
+ except ValueError: # a different drive on Windows
286
+ return source
287
+ if relative.startswith(".."):
288
+ return source
289
+ return relative.replace(os.sep, "/")
290
+
291
+
292
+ def _git_sha(project_root: str) -> str | None:
293
+ """The commit this run describes, so a report says which code it documents.
294
+
295
+ CI exports it, and asking Git is the fallback. The call is bounded: a Git
296
+ that hangs would otherwise hold up the end of the test run.
297
+ """
298
+ for key in ("GITHUB_SHA", "GIT_COMMIT", "CI_COMMIT_SHA"):
299
+ value = os.environ.get(key)
300
+ if value:
301
+ return value
302
+ try:
303
+ result = subprocess.run(
304
+ ["git", "rev-parse", "HEAD"],
305
+ cwd=project_root,
306
+ stdout=subprocess.PIPE,
307
+ stderr=subprocess.DEVNULL,
308
+ text=True,
309
+ timeout=GIT_TIMEOUT_SECONDS,
310
+ check=False,
311
+ )
312
+ except (OSError, subprocess.SubprocessError):
313
+ return None
314
+ if result.returncode != 0:
315
+ return None
316
+ return result.stdout.strip() or None
317
+
318
+
319
+ def _package_version() -> str | None:
320
+ """Version of this adapter, so a report says what produced it."""
321
+ try:
322
+ return version("executable-stories-pytest")
323
+ except PackageNotFoundError:
324
+ return None
325
+
326
+
327
+ def _resolve_output_path(project_root: str) -> str:
328
+ """Where the run file lands.
329
+
330
+ A relative ``EXECUTABLE_STORIES_OUTPUT`` is resolved against the project
331
+ root rather than the working directory, so the file lands where the docs
332
+ say it does however pytest was invoked. An absolute one passes through.
333
+ """
334
+ declared = os.environ.get("EXECUTABLE_STORIES_OUTPUT")
335
+ if not declared:
336
+ return os.path.join(project_root, ".executable-stories", "raw-run.json")
337
+ return os.path.join(project_root, declared)
338
+
339
+
340
+ def _stopped_early(session: pytest.Session, exitstatus: int) -> bool:
341
+ """Whether the run ended before it got through everything it selected.
342
+
343
+ ``-x`` and ``--maxfail`` set a flag on the session. Ctrl-C, an internal
344
+ error and a usage error do not, and only the exit status shows them. Either
345
+ way a file this run visited was left partly run, so the run cannot be
346
+ treated as the complete contents of that file.
347
+ """
348
+ if bool(getattr(session, "shouldstop", False)) or bool(getattr(session, "shouldfail", False)):
349
+ return True
350
+ try:
351
+ return int(exitstatus) not in _COMPLETED_EXIT_STATUSES
352
+ except (TypeError, ValueError):
353
+ return True
354
+
355
+
197
356
  def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
198
357
  test_cases = _collector.get_all()
199
- if not test_cases:
358
+ covered = sorted(_covered_files)
359
+ incomplete = sorted(_incomplete_files)
360
+ # A run that reached files but produced no scenario still has something to
361
+ # say: those files are now empty, and only a written run retires them. So
362
+ # does one that broke before reaching anything, which keeps them instead.
363
+ if not test_cases and not covered and not incomplete:
200
364
  return
201
365
 
202
366
  finished_at_ms = time.time() * 1000
367
+ project_root = str(session.config.rootpath)
203
368
 
204
369
  raw_run: dict[str, Any] = {
370
+ # $schema first so editors pick it up and validate the file as it is
371
+ # written; `executable-stories doctor` also reports its presence.
372
+ "$schema": SCHEMA_URL,
205
373
  "schemaVersion": 1,
206
374
  "testCases": test_cases,
207
- "projectRoot": str(session.config.rootdir), # type: ignore[attr-defined]
375
+ "projectRoot": project_root,
208
376
  "startedAtMs": round(_started_at_ms, 2),
209
377
  "finishedAtMs": round(finished_at_ms, 2),
210
378
  }
211
379
 
380
+ package_version = _package_version()
381
+ if package_version is not None:
382
+ raw_run["packageVersion"] = package_version
383
+
384
+ git_sha = _git_sha(project_root)
385
+ if git_sha is not None:
386
+ raw_run["gitSha"] = git_sha
387
+
388
+ features = _collector.get_features()
389
+ if features:
390
+ # Declarations run at import time, where the stack is the only source
391
+ # of the module path, so they arrive absolute.
392
+ for feature in features:
393
+ declared_in = feature.get("sourceFile")
394
+ if declared_in:
395
+ feature["sourceFile"] = _relative_source(declared_in, project_root)
396
+ raw_run["features"] = features
397
+
212
398
  ci = _detect_ci()
213
399
  if ci is not None:
214
400
  raw_run["ci"] = ci
215
401
 
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
- )
402
+ # Absent means pytest gave us nothing to inspect; consumers then keep what
403
+ # this run did not report rather than retiring it on a guess.
404
+ scope = _run_scope(session.config)
405
+ if scope == "full" and _stopped_early(session, exitstatus):
406
+ scope = "filtered"
407
+ if scope is not None:
408
+ raw_run["runScope"] = scope
409
+
410
+ if covered:
411
+ raw_run["coveredSourceFiles"] = covered
412
+
413
+ if incomplete:
414
+ raw_run["incompleteSourceFiles"] = incomplete
415
+
416
+ output_path = _resolve_output_path(project_root)
220
417
 
221
418
  write_raw_run(raw_run, output_path)
419
+ _print_next_step(output_path)
420
+
421
+
422
+ def _print_next_step(output_path: str) -> None:
423
+ """Tell the user how to turn the run JSON into a report.
424
+
425
+ The JS adapters render reports in-process, so their users never need to know
426
+ the CLI exists. pytest hands off to the CLI instead, so without this the run
427
+ ends with a file and no indication of what to do with it. stderr keeps piped
428
+ output clean; EXECUTABLE_STORIES_QUIET silences it in CI.
429
+ """
430
+ if os.environ.get("EXECUTABLE_STORIES_QUIET"):
431
+ return
432
+ print(f"\nexecutable-stories: wrote {output_path}", file=sys.stderr)
433
+ print(
434
+ f" next: executable-stories format {output_path} --format html",
435
+ file=sys.stderr,
436
+ )
@@ -13,14 +13,21 @@ Usage in tests::
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import inspect
16
17
  import json
17
18
  import os
18
19
  import threading
19
20
  import time
20
- from typing import Any, Callable, TypeVar
21
+ from collections.abc import Sequence
22
+ from typing import Any, Callable, TypeVar, Union
23
+
24
+ from executable_stories._collector import _collector
21
25
 
22
26
  _T = TypeVar("_T")
23
27
 
28
+ #: A ticket id, a ticket object, or a list mixing the two.
29
+ TicketArg = Union[str, dict[str, Any], Sequence[Union[str, dict[str, Any]]], None]
30
+
24
31
 
25
32
  class _StoryContext:
26
33
  """Per-test story context stored in thread-local storage."""
@@ -30,6 +37,7 @@ class _StoryContext:
30
37
  "steps",
31
38
  "tags",
32
39
  "tickets",
40
+ "covers",
33
41
  "meta",
34
42
  "suite_path",
35
43
  "docs",
@@ -40,6 +48,9 @@ class _StoryContext:
40
48
  "active_timers",
41
49
  "timer_counter",
42
50
  "otel_spans",
51
+ "planned",
52
+ "started_at_ms",
53
+ "failed_step",
43
54
  )
44
55
 
45
56
  def __init__(
@@ -48,12 +59,14 @@ class _StoryContext:
48
59
  *,
49
60
  tags: list[str] | None = None,
50
61
  tickets: list[dict[str, Any]] | None = None,
62
+ covers: list[str] | None = None,
51
63
  meta: dict[str, Any] | None = None,
52
64
  ) -> None:
53
65
  self.scenario = scenario
54
66
  self.steps: list[dict[str, Any]] = []
55
67
  self.tags = tags or []
56
68
  self.tickets: list[dict[str, Any]] = tickets or []
69
+ self.covers = covers or []
57
70
  self.meta = meta or {}
58
71
  self.suite_path: list[str] = []
59
72
  self.docs: list[dict[str, Any]] = []
@@ -64,6 +77,10 @@ class _StoryContext:
64
77
  self.active_timers: dict[int, dict[str, Any]] = {}
65
78
  self.timer_counter: int = 0
66
79
  self.otel_spans: list[dict[str, Any]] | None = None
80
+ self.planned: bool = False
81
+ self.started_at_ms: float = time.time() * 1000
82
+ # The wrapped step whose body raised, if one did.
83
+ self.failed_step: dict[str, Any] | None = None
67
84
 
68
85
 
69
86
  class Story:
@@ -82,26 +99,26 @@ class Story:
82
99
  return getattr(self._local, "ctx", None)
83
100
 
84
101
  @staticmethod
85
- def _normalize_tickets(ticket: Any) -> list[dict[str, Any]] | None:
102
+ def _normalize_tickets(ticket: TicketArg) -> list[dict[str, Any]] | None:
86
103
  """Normalize ticket input to list of ``{"id": ...}`` dicts."""
87
104
  if ticket is None:
88
105
  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]
106
+ tickets = [ticket] if isinstance(ticket, (str, dict)) else ticket
107
+ return [{"id": t} if isinstance(t, str) else dict(t) for t in tickets]
92
108
 
93
109
  def init(
94
110
  self,
95
111
  scenario: str,
96
112
  *,
97
113
  tags: list[str] | None = None,
98
- ticket: str | list[str] | dict | list[dict | str] | None = None,
114
+ ticket: TicketArg = None,
115
+ covers: list[str] | None = None,
99
116
  meta: dict[str, Any] | None = None,
100
117
  trace_url_template: str | None = None,
101
118
  ) -> None:
102
119
  """Start a new story context for the current test."""
103
120
  tickets = self._normalize_tickets(ticket)
104
- self._local.ctx = _StoryContext(scenario, tags=tags, tickets=tickets, meta=meta)
121
+ self._local.ctx = _StoryContext(scenario, tags=tags, tickets=tickets, covers=covers, meta=meta)
105
122
 
106
123
  # OTel bridge: detect active span, flow data bidirectionally
107
124
  ctx = self._local.ctx
@@ -137,6 +154,80 @@ class Story:
137
154
  except Exception:
138
155
  pass # OTel not available or no active span
139
156
 
157
+ def feature(
158
+ self,
159
+ title: str,
160
+ *,
161
+ kind: str | None = None,
162
+ narrative: str | None = None,
163
+ tags: list[str] | None = None,
164
+ glossary: list[dict[str, str]] | None = None,
165
+ source_file: str | None = None,
166
+ ) -> None:
167
+ """Declare what a module's scenarios are for, ahead of the examples.
168
+
169
+ Scenarios say what the system does. A declaration says why the feature
170
+ exists and who it serves, so a reader meets the intent before the
171
+ examples. Call it once per test module, at module scope::
172
+
173
+ story.feature(
174
+ "Anyone can do arithmetic without a calculator app",
175
+ kind="ability",
176
+ narrative="Switching apps for a quick sum loses your place.",
177
+ )
178
+
179
+ ``kind`` is ``"feature"`` by default. ``"ability"`` frames it as
180
+ something a person can now do; ``"business-need"`` covers cross-cutting
181
+ concerns like security or performance that nobody asks for by name.
182
+
183
+ The source file is taken from the caller, so the declaration lands on
184
+ the module that made it.
185
+ """
186
+ declared: dict[str, Any] = {
187
+ "title": title,
188
+ "sourceFile": source_file or _caller_file(),
189
+ }
190
+ if kind is not None:
191
+ declared["kind"] = kind
192
+ if narrative is not None:
193
+ declared["narrative"] = narrative
194
+ if tags:
195
+ declared["tags"] = tags
196
+ if glossary:
197
+ declared["glossary"] = [{"term": t["term"], "definition": t["definition"]} for t in glossary]
198
+
199
+ _collector.record_feature(declared)
200
+
201
+ def planned(
202
+ self,
203
+ scenario: str,
204
+ *,
205
+ tags: list[str] | None = None,
206
+ ticket: TicketArg = None,
207
+ covers: list[str] | None = None,
208
+ meta: dict[str, Any] | None = None,
209
+ ) -> None:
210
+ """Declare a scenario that is specified but not built yet.
211
+
212
+ It appears in the report marked "planned" and stops being planned once
213
+ someone writes it as a real story with ``story.init``::
214
+
215
+ def test_checkout_blocks_suspended_account():
216
+ story.planned("checkout is blocked for a suspended account")
217
+
218
+ ``pytest.mark.skip`` means "do not run this now", which is a different
219
+ claim from "we have not built this yet", so this does not skip for you.
220
+ """
221
+ self.init(scenario, tags=tags, ticket=ticket, covers=covers, meta=meta)
222
+ ctx = self._ctx
223
+ if ctx is not None:
224
+ ctx.planned = True
225
+
226
+ def is_planned(self) -> bool:
227
+ """True when the current test declared a planned scenario."""
228
+ ctx = self._ctx
229
+ return bool(ctx and ctx.planned)
230
+
140
231
  def _get_meta(self) -> dict[str, Any] | None:
141
232
  """Return the StoryMeta dict for the current test, or None."""
142
233
  ctx = self._ctx
@@ -150,6 +241,8 @@ class Story:
150
241
  result["tags"] = list(ctx.tags)
151
242
  if ctx.tickets:
152
243
  result["tickets"] = list(ctx.tickets)
244
+ if ctx.covers:
245
+ result["covers"] = list(ctx.covers)
153
246
  if ctx.meta:
154
247
  result["meta"] = dict(ctx.meta)
155
248
  if ctx.suite_path:
@@ -158,8 +251,29 @@ class Story:
158
251
  result["docs"] = list(ctx.docs)
159
252
  if ctx.otel_spans:
160
253
  result["otelSpans"] = ctx.otel_spans
254
+ # A planned scenario never ran, so it has no start time.
255
+ if not ctx.planned:
256
+ result["startedAtMs"] = ctx.started_at_ms
161
257
  return result
162
258
 
259
+ def _step_trail(self) -> str | None:
260
+ """The story's steps as a checklist for a failed test's report.
261
+
262
+ The failing step is the wrapped step whose body raised, else the last
263
+ step: a marker states a claim and the assertion that broke follows it.
264
+ When the last step is a wrapped body that finished, the failure came
265
+ after it.
266
+ """
267
+ ctx = self._ctx
268
+ if ctx is None or not ctx.steps:
269
+ return None
270
+ last = ctx.steps[-1]
271
+ failed = ctx.failed_step or (None if last.get("wrapped") else last)
272
+ lines = [f" {'✗' if s is failed else '✓'} {s['keyword']} {s['text']}" for s in ctx.steps]
273
+ if failed is None:
274
+ lines.append(" ✗ (failed after the last step)")
275
+ return "\n".join(lines)
276
+
163
277
  def _get_attachments(self) -> list[dict[str, Any]]:
164
278
  """Return the attachments list for the current test."""
165
279
  ctx = self._ctx
@@ -191,13 +305,15 @@ class Story:
191
305
  ctx = self._ctx
192
306
  if ctx is None:
193
307
  raise RuntimeError(f"story.{keyword.lower()}() called before story.init()")
308
+ # A new step means the test caught an earlier wrapped step's exception.
309
+ ctx.failed_step = None
194
310
  # Auto-And: repeated primary keywords render as "And"
195
311
  if keyword in ("Given", "When", "Then"):
196
312
  if keyword in ctx.seen_primary_keywords:
197
313
  keyword = "And"
198
314
  else:
199
315
  ctx.seen_primary_keywords.add(keyword)
200
- step: dict[str, Any] = {"keyword": keyword, "text": text}
316
+ step: dict[str, Any] = {"keyword": keyword, "text": text, "startedAtMs": time.time() * 1000}
201
317
  step["id"] = f"step-{ctx.step_counter}"
202
318
  ctx.step_counter += 1
203
319
  if mode is not None:
@@ -271,11 +387,22 @@ class Story:
271
387
  step = ctx._current_step
272
388
  assert step is not None
273
389
  step["wrapped"] = True
390
+ # Wrapping a claim is the only signal pytest can give that the step
391
+ # checked something: the body ran to completion. Setup steps arrange, so
392
+ # only a claim counts — tested against the keyword as written, since
393
+ # auto-And rewrites a repeated Then before the step is stored.
394
+ if keyword == "Then":
395
+ step["assertions"] = 1
274
396
 
275
397
  start = time.perf_counter()
276
398
  try:
277
399
  result = body()
278
400
  return result
401
+ except BaseException:
402
+ # A nested step that raised first keeps the mark.
403
+ if ctx.failed_step is None:
404
+ ctx.failed_step = step
405
+ raise
279
406
  finally:
280
407
  step["durationMs"] = (time.perf_counter() - start) * 1000.0
281
408
 
@@ -394,7 +521,8 @@ class Story:
394
521
  last = ctx.steps[-1]
395
522
  if "docs" not in last:
396
523
  last["docs"] = []
397
- return last["docs"]
524
+ docs: list[dict[str, Any]] = last["docs"]
525
+ return docs
398
526
 
399
527
  def _story_docs(self) -> list[dict[str, Any]]:
400
528
  """Return the story-level docs list."""
@@ -459,7 +587,9 @@ class Story:
459
587
  self._attach_doc(entry)
460
588
  return entry
461
589
 
462
- def code(self, label: str, content: str, *, lang: str | None = None, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
590
+ def code(
591
+ self, label: str, content: str, *, lang: str | None = None, children: list[dict[str, Any]] | None = None
592
+ ) -> dict[str, Any]:
463
593
  """Add a code block."""
464
594
  entry: dict[str, Any] = {
465
595
  "kind": "code",
@@ -475,7 +605,9 @@ class Story:
475
605
  self._attach_doc(entry)
476
606
  return entry
477
607
 
478
- def table(self, label: str, columns: list[str], rows: list[list[str]], *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
608
+ def table(
609
+ self, label: str, columns: list[str], rows: list[list[str]], *, children: list[dict[str, Any]] | None = None
610
+ ) -> dict[str, Any]:
479
611
  """Add a table."""
480
612
  entry: dict[str, Any] = {
481
613
  "kind": "table",
@@ -513,7 +645,9 @@ class Story:
513
645
  self._attach_doc(entry)
514
646
  return entry
515
647
 
516
- def mermaid(self, code: str, *, title: str | None = None, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
648
+ def mermaid(
649
+ self, code: str, *, title: str | None = None, children: list[dict[str, Any]] | None = None
650
+ ) -> dict[str, Any]:
517
651
  """Add a Mermaid diagram."""
518
652
  entry: dict[str, Any] = {"kind": "mermaid", "code": code, "phase": "runtime"}
519
653
  if title is not None:
@@ -524,7 +658,9 @@ class Story:
524
658
  self._attach_doc(entry)
525
659
  return entry
526
660
 
527
- def screenshot(self, path: str, *, alt: str | None = None, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
661
+ def screenshot(
662
+ self, path: str, *, alt: str | None = None, children: list[dict[str, Any]] | None = None
663
+ ) -> dict[str, Any]:
528
664
  """Add a screenshot reference."""
529
665
  entry: dict[str, Any] = {"kind": "screenshot", "path": path, "phase": "runtime"}
530
666
  if alt is not None:
@@ -535,6 +671,73 @@ class Story:
535
671
  self._attach_doc(entry)
536
672
  return entry
537
673
 
674
+ def video(
675
+ self,
676
+ path: str,
677
+ *,
678
+ caption: str | None = None,
679
+ poster: str | None = None,
680
+ children: list[dict[str, Any]] | None = None,
681
+ ) -> dict[str, Any]:
682
+ """Add a video reference, played inline in HTML reports."""
683
+ entry: dict[str, Any] = {"kind": "video", "path": path, "phase": "runtime"}
684
+ if caption is not None:
685
+ entry["caption"] = caption
686
+ if poster is not None:
687
+ entry["poster"] = poster
688
+ if children:
689
+ entry["children"] = children
690
+ self._dedup_children(children)
691
+ self._attach_doc(entry)
692
+ return entry
693
+
694
+ def state(
695
+ self, value: Any, *, label: str | None = None, children: list[dict[str, Any]] | None = None
696
+ ) -> dict[str, Any]:
697
+ """Add a state snapshot (storyboard frame). Value must be JSON-serializable."""
698
+ entry: dict[str, Any] = {"kind": "state", "value": value, "phase": "runtime"}
699
+ if label is not None:
700
+ entry["label"] = label
701
+ if children:
702
+ entry["children"] = children
703
+ self._dedup_children(children)
704
+ self._attach_doc(entry)
705
+ return entry
706
+
707
+ def html(
708
+ self,
709
+ *,
710
+ path: str | None = None,
711
+ url: str | None = None,
712
+ content: str | None = None,
713
+ title: str | None = None,
714
+ height: int | str | None = None,
715
+ children: list[dict[str, Any]] | None = None,
716
+ ) -> dict[str, Any]:
717
+ """Embed HTML in an always-sandboxed iframe.
718
+
719
+ Exactly one of ``path``, ``url``, or ``content`` must be provided.
720
+ """
721
+ sources = [s for s in (path, url, content) if s is not None]
722
+ if len(sources) != 1:
723
+ raise ValueError("story.html() requires exactly one of path, url, or content")
724
+ entry: dict[str, Any] = {"kind": "html", "phase": "runtime"}
725
+ if path is not None:
726
+ entry["path"] = path
727
+ if url is not None:
728
+ entry["url"] = url
729
+ if content is not None:
730
+ entry["content"] = content
731
+ if title is not None:
732
+ entry["title"] = title
733
+ if height is not None:
734
+ entry["height"] = height
735
+ if children:
736
+ entry["children"] = children
737
+ self._dedup_children(children)
738
+ self._attach_doc(entry)
739
+ return entry
740
+
538
741
  def custom(self, type: str, data: Any, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
539
742
  """Add a custom doc entry."""
540
743
  entry: dict[str, Any] = {
@@ -552,3 +755,22 @@ class Story:
552
755
 
553
756
  # Module-level singleton
554
757
  story = Story()
758
+
759
+
760
+ def _caller_file() -> str:
761
+ """Source file of whoever called into this module.
762
+
763
+ ``story.feature(...)`` runs at import time, before pytest has a current
764
+ item, so the stack is the only place the module path is available.
765
+ """
766
+ frame = inspect.currentframe()
767
+ try:
768
+ while frame is not None:
769
+ filename = frame.f_code.co_filename
770
+ if not filename.endswith(os.sep + "_story_api.py"):
771
+ return filename
772
+ frame = frame.f_back
773
+ finally:
774
+ del frame
775
+
776
+ return ""
@@ -6,7 +6,11 @@ At runtime everything is just dicts serialized to JSON.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from typing import Any, NotRequired, TypedDict
9
+ from typing import TYPE_CHECKING, Any, TypedDict
10
+
11
+ if TYPE_CHECKING:
12
+ # typing.NotRequired is 3.11+; annotations are strings here, so only type checkers see it.
13
+ from typing_extensions import NotRequired
10
14
 
11
15
  # ── DocEntry variants ──────────────────────────────────────────────
12
16
 
@@ -83,6 +87,34 @@ class ScreenshotDoc(TypedDict):
83
87
  children: NotRequired[list["DocEntry"]]
84
88
 
85
89
 
90
+ class VideoDoc(TypedDict):
91
+ kind: str # "video"
92
+ path: str
93
+ phase: str
94
+ caption: NotRequired[str]
95
+ poster: NotRequired[str]
96
+ children: NotRequired[list["DocEntry"]]
97
+
98
+
99
+ class StateDoc(TypedDict):
100
+ kind: str # "state"
101
+ value: Any
102
+ phase: str
103
+ label: NotRequired[str]
104
+ children: NotRequired[list["DocEntry"]]
105
+
106
+
107
+ class HtmlDoc(TypedDict):
108
+ kind: str # "html"
109
+ phase: str
110
+ path: NotRequired[str]
111
+ url: NotRequired[str]
112
+ content: NotRequired[str]
113
+ title: NotRequired[str]
114
+ height: NotRequired[int | str]
115
+ children: NotRequired[list["DocEntry"]]
116
+
117
+
86
118
  class CustomDoc(TypedDict):
87
119
  kind: str # "custom"
88
120
  type: str
@@ -92,7 +124,21 @@ class CustomDoc(TypedDict):
92
124
 
93
125
 
94
126
  # Union of all doc entry types
95
- DocEntry = NoteDoc | TagDoc | KvDoc | CodeDoc | TableDoc | LinkDoc | SectionDoc | MermaidDoc | ScreenshotDoc | CustomDoc
127
+ DocEntry = (
128
+ NoteDoc
129
+ | TagDoc
130
+ | KvDoc
131
+ | CodeDoc
132
+ | TableDoc
133
+ | LinkDoc
134
+ | SectionDoc
135
+ | MermaidDoc
136
+ | ScreenshotDoc
137
+ | VideoDoc
138
+ | StateDoc
139
+ | HtmlDoc
140
+ | CustomDoc
141
+ )
96
142
 
97
143
 
98
144
  # ── Ticket ────────────────────────────────────────────────────────
@@ -138,7 +184,12 @@ class StoryStep(TypedDict):
138
184
  id: NotRequired[str]
139
185
  mode: NotRequired[str]
140
186
  wrapped: NotRequired[bool]
187
+ # Assertions attributable to this step. pytest has no assertion counter, so
188
+ # this is set only when the author wraps a claim in expect/fn("Then", ...).
189
+ # An absent key means unobserved, which is not the same as zero.
190
+ assertions: NotRequired[int]
141
191
  durationMs: NotRequired[float]
192
+ startedAtMs: NotRequired[float]
142
193
  docs: NotRequired[list[DocEntry]]
143
194
 
144
195
 
@@ -150,10 +201,12 @@ class StoryMeta(TypedDict):
150
201
  steps: NotRequired[list[StoryStep]]
151
202
  tags: NotRequired[list[str]]
152
203
  tickets: NotRequired[list[TicketDoc]]
204
+ covers: NotRequired[list[str]]
153
205
  meta: NotRequired[dict[str, Any]]
154
206
  suitePath: NotRequired[list[str]]
155
207
  docs: NotRequired[list[DocEntry]]
156
208
  sourceOrder: NotRequired[int]
209
+ startedAtMs: NotRequired[float]
157
210
 
158
211
 
159
212
  # ── RawTestCase ────────────────────────────────────────────────────
@@ -204,3 +257,13 @@ class RawRun(TypedDict):
204
257
  gitSha: NotRequired[str]
205
258
  ci: NotRequired[RawCIInfo]
206
259
  meta: NotRequired[dict[str, Any]]
260
+ # How much of each source file this run covered: "full" (no name filter was
261
+ # applied), "filtered" (one was), or absent when it cannot be determined.
262
+ runScope: NotRequired[str]
263
+ # Every file this run executed a test in, whether or not it produced a
264
+ # story, so a file emptied of scenarios is distinguishable from one that
265
+ # never ran.
266
+ coveredSourceFiles: NotRequired[list[str]]
267
+ # Files this run cannot speak for, so a consumer keeps what they last
268
+ # documented rather than treating the run as authoritative.
269
+ incompleteSourceFiles: NotRequired[list[str]]
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: executable-stories-pytest
3
+ Version: 0.2.0
4
+ Summary: pytest plugin for executable-stories BDD documentation
5
+ Author: Jag Reehal
6
+ License-Expression: Apache-2.0
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.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Provides-Extra: otel
14
+ Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
15
+ Provides-Extra: dev
16
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
17
+ Requires-Dist: mypy>=1.5.0; extra == "dev"
18
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # executable-stories-pytest
22
+
23
+ pytest plugin for executable-stories.
24
+
25
+ Adds BDD-style story helpers and emits raw story run JSON so pytest tests can be rendered into executable documentation.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install executable-stories-pytest
31
+ ```
32
+
33
+ The PyPI distribution name is `executable-stories-pytest`, but the import name is `executable_stories`:
34
+
35
+ ```python
36
+ from executable_stories import story
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```python
42
+ from executable_stories import story
43
+
44
+
45
+ def test_login_success():
46
+ story.init("User logs in", tags=["auth", "smoke"])
47
+ story.given("a registered user")
48
+ story.when("they submit valid credentials")
49
+ story.then("they see the dashboard")
50
+ ```
51
+
52
+ ## Features
53
+
54
+ - BDD steps: `given`, `when`, `then`, `and_`, `but`
55
+ - Aliases: `arrange`, `act`, `assert_`, `setup`, `context`, `execute`, `action`, `verify`
56
+ - Rich docs: `note`, `tag`, `kv`, `json`, `code`, `table`, `link`, `section`, `mermaid`, `screenshot`, `video`, `html`, `state`, `custom`
57
+ - `story.state(value, label=None)` — JSON-serializable snapshot of "what the world looks like" at this step (a storyboard frame); consecutive same-label states are diffed at render time
58
+ - Feature declarations: `story.feature(...)` at module level heads every scenario in the file
59
+ - Planned scenarios: `story.planned("…")` records behaviour that is specified but not built
60
+ - Source coverage links: `covers=[...]` in `story.init(...)`
61
+ - Step timing: `start_timer` / `end_timer`
62
+ - Attachments: `attach(...)`
63
+ - OTel spans + trace links: `attach_spans(...)`, `trace_url_template` in `story.init(...)`, `OTEL_TRACE_URL_TEMPLATE`
64
+
65
+ ## Output
66
+
67
+ The plugin writes `.executable-stories/raw-run.json` under pytest's root directory when the
68
+ session finishes. Set `EXECUTABLE_STORIES_OUTPUT` to move it: a relative path is resolved
69
+ against the project root, an absolute one is used as given. The file is renamed into place,
70
+ so a reader never sees a half-written run.
71
+
72
+ Formatting the raw run updates one canonical report per source file under `reports/by-file/`.
73
+ Each run says what it reached so those reports stay honest as tests come and go:
74
+
75
+ - `coveredSourceFiles` — every file the run executed a test in, whether or not it produced a
76
+ story, so deleting a file's last scenario retires it
77
+ - `incompleteSourceFiles` — files the run cannot speak for: a skipped test, a broken fixture
78
+ or teardown, a module that failed to import, or a test that failed before `story.init`
79
+ - `runScope` — `"filtered"` for `-k`, `-m`, `--deselect`, `--last-failed`, a `file.py::test`
80
+ node id, or a run that ended early (`-x`, `--maxfail`, Ctrl-C, an internal or usage
81
+ error); otherwise `"full"`
82
+ - `gitSha` and `packageVersion` — which commit the run describes, and what produced it
83
+
84
+ Source paths are relative to the project root, so a report keeps its identity across machines
85
+ and CI.
86
+
87
+ pytest exposes no assertion counter. Use `story.expect("claim", callable)` to declare
88
+ assertion evidence. A plain `story.then()` followed by Python `assert` remains unobserved,
89
+ not zero.
90
+
91
+ Pass that file to the `executable-stories` CLI (from the `executable-stories-formatters` package) to render reports:
92
+
93
+ ```bash
94
+ npx --package executable-stories-formatters executable-stories format .executable-stories/raw-run.json --format html
95
+
96
+ # The path is optional — `format` defaults to .executable-stories/raw-run.json
97
+ npx --package executable-stories-formatters executable-stories format --format html
98
+
99
+ # Diagnose the run JSON if a report won't generate (schema drift, empty run)
100
+ npx --package executable-stories-formatters executable-stories doctor
101
+ ```
102
+
103
+ Supported formats include `html`, `markdown`, `junit`, and the Cucumber outputs. Use `--output-dir` to choose where reports are written.
104
+
105
+ ## Verify
106
+
107
+ From repo root:
108
+
109
+ ```bash
110
+ pnpm run verify:pytest
111
+ ```
@@ -0,0 +1,12 @@
1
+ executable_stories/__init__.py,sha256=X-m5x5nt_kkzd8IGJZ52e6lfr6aDArhbXci9K0fLWLM,132
2
+ executable_stories/_collector.py,sha256=gYQKlNTYpcCaNiTDbJXEV4IDWC4T7mF9zxqBz0zx_6k,1508
3
+ executable_stories/_json_writer.py,sha256=FrRuskUN93R-0vVermZTfskv6GZ9Aj6E-khwvmEjnH8,987
4
+ executable_stories/_plugin.py,sha256=IwhqMkrxjsJ9PFSyOFRRmeXTuUNSZ0VqRpcR-vpnguM,16199
5
+ executable_stories/_story_api.py,sha256=u3I__cTQr_5h_2Z6vD_3ZZzO3xfPj1-DlnftkCYCwgY,29517
6
+ executable_stories/_types.py,sha256=un-CvxwyX1KfO3Mf5RQxWkEqqkN9yuHwbA1zDCEEV6I,7459
7
+ executable_stories_pytest-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
8
+ executable_stories_pytest-0.2.0.dist-info/METADATA,sha256=E7d16fEgeTeoFioCBrcuE0uH4_0HnWbTlzZJp26NAgI,4546
9
+ executable_stories_pytest-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ executable_stories_pytest-0.2.0.dist-info/entry_points.txt,sha256=NXzZqAD31EI6q3ovbbWQE7VOzsok09tPpmd-QPgSTiE,59
11
+ executable_stories_pytest-0.2.0.dist-info/top_level.txt,sha256=0CmAUpeJrtl-ykfogdr3L373JMtP92uU1P7-GIlbF7I,19
12
+ executable_stories_pytest-0.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -1,23 +0,0 @@
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.
@@ -1,11 +0,0 @@
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,,