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.
- executable_stories/_collector.py +15 -0
- executable_stories/_json_writer.py +17 -7
- executable_stories/_plugin.py +242 -27
- executable_stories/_story_api.py +235 -13
- executable_stories/_types.py +65 -2
- executable_stories_pytest-0.2.0.dist-info/METADATA +111 -0
- executable_stories_pytest-0.2.0.dist-info/RECORD +12 -0
- {executable_stories_pytest-0.1.0.dist-info → executable_stories_pytest-0.2.0.dist-info}/WHEEL +1 -1
- executable_stories_pytest-0.2.0.dist-info/licenses/LICENSE +202 -0
- executable_stories_pytest-0.1.0.dist-info/METADATA +0 -23
- executable_stories_pytest-0.1.0.dist-info/RECORD +0 -11
- {executable_stories_pytest-0.1.0.dist-info → executable_stories_pytest-0.2.0.dist-info}/entry_points.txt +0 -0
- {executable_stories_pytest-0.1.0.dist-info → executable_stories_pytest-0.2.0.dist-info}/top_level.txt +0 -0
executable_stories/_collector.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
executable_stories/_plugin.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
)
|
executable_stories/_story_api.py
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
90
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 ""
|
executable_stories/_types.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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,,
|
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|