executable-stories-pytest 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: executable-stories-pytest
3
+ Version: 0.1.0
4
+ Summary: pytest plugin for executable-stories BDD documentation
5
+ Author: Jag Reehal
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jagreehal/executable-stories
8
+ Project-URL: Repository, https://github.com/jagreehal/executable-stories.git
9
+ Project-URL: Documentation, https://github.com/jagreehal/executable-stories#readme
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ Provides-Extra: otel
13
+ Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
14
+ Provides-Extra: dev
15
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
16
+ Requires-Dist: mypy>=1.5.0; extra == "dev"
17
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
18
+
19
+ # executable-stories-pytest
20
+
21
+ pytest plugin for executable-stories.
22
+
23
+ Adds BDD-style story helpers and structured story metadata so pytest tests can generate executable documentation.
@@ -0,0 +1,5 @@
1
+ # executable-stories-pytest
2
+
3
+ pytest plugin for executable-stories.
4
+
5
+ Adds BDD-style story helpers and structured story metadata so pytest tests can generate executable documentation.
@@ -0,0 +1,75 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "executable-stories-pytest"
7
+ version = "0.1.0"
8
+ description = "pytest plugin for executable-stories BDD documentation"
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ readme = "README.md"
12
+ authors = [{ name = "Jag Reehal" }]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/jagreehal/executable-stories"
16
+ Repository = "https://github.com/jagreehal/executable-stories.git"
17
+ Documentation = "https://github.com/jagreehal/executable-stories#readme"
18
+
19
+ [project.optional-dependencies]
20
+ otel = ["opentelemetry-api>=1.20"]
21
+ dev = [
22
+ "ruff>=0.6.0",
23
+ "mypy>=1.5.0",
24
+ "pytest>=7.4.0",
25
+ ]
26
+
27
+ [project.entry-points.pytest11]
28
+ executable_stories = "executable_stories._plugin"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.ruff]
34
+ line-length = 120
35
+ target-version = "py312"
36
+
37
+ [tool.ruff.format]
38
+ quote-style = "double"
39
+ indent-style = "space"
40
+ skip-magic-trailing-comma = false
41
+ line-ending = "auto"
42
+
43
+ [tool.ruff.lint]
44
+ select = [
45
+ "E",
46
+ "W",
47
+ "F",
48
+ "I",
49
+ "C",
50
+ "B",
51
+ "N",
52
+ ]
53
+ ignore = [
54
+ "E501",
55
+ "B008",
56
+ "C901",
57
+ "F401",
58
+ ]
59
+
60
+ [tool.ruff.lint.isort]
61
+ known-first-party = ["executable_stories"]
62
+
63
+ [tool.pytest.ini_options]
64
+ minversion = "6.0"
65
+ testpaths = ["tests"]
66
+ addopts = "-q"
67
+
68
+ [tool.mypy]
69
+ python_version = "3.12"
70
+ warn_return_any = true
71
+ warn_unused_configs = true
72
+ strict_optional = true
73
+ check_untyped_defs = true
74
+ no_implicit_optional = true
75
+ show_error_codes = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """pytest plugin for executable-stories BDD documentation."""
2
+
3
+ from executable_stories._story_api import story
4
+
5
+ __all__ = ["story"]
@@ -0,0 +1,37 @@
1
+ """Thread-safe test case collector.
2
+
3
+ Accumulates RawTestCase dicts as tests complete, then hands them off
4
+ to the JSON writer at session end.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from typing import Any
11
+
12
+
13
+ class _Collector:
14
+ """Thread-safe registry for completed test case results."""
15
+
16
+ def __init__(self) -> None:
17
+ self._lock = threading.Lock()
18
+ self._cases: list[dict[str, Any]] = []
19
+
20
+ def record(self, test_case: dict[str, Any]) -> None:
21
+ """Append a completed RawTestCase dict."""
22
+ with self._lock:
23
+ self._cases.append(test_case)
24
+
25
+ def get_all(self) -> list[dict[str, Any]]:
26
+ """Return all collected test cases."""
27
+ with self._lock:
28
+ return list(self._cases)
29
+
30
+ def clear(self) -> None:
31
+ """Reset the collector."""
32
+ with self._lock:
33
+ self._cases.clear()
34
+
35
+
36
+ # Module-level singleton
37
+ _collector = _Collector()
@@ -0,0 +1,21 @@
1
+ """Simple JSON serialization for RawRun output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+
10
+ def write_raw_run(raw_run: dict[str, Any], output_path: str) -> None:
11
+ """Write a RawRun dict to a JSON file.
12
+
13
+ Creates parent directories if they don't exist.
14
+ """
15
+ parent = os.path.dirname(output_path)
16
+ if parent:
17
+ os.makedirs(parent, exist_ok=True)
18
+
19
+ with open(output_path, "w", encoding="utf-8") as f:
20
+ json.dump(raw_run, f, indent=2)
21
+ f.write("\n")
@@ -0,0 +1,221 @@
1
+ """pytest plugin that hooks into test lifecycle to capture BDD story data.
2
+
3
+ Registered via the ``pytest11`` entry point in pyproject.toml.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import time
10
+ import traceback
11
+ from typing import Any
12
+
13
+ import pytest
14
+
15
+ from executable_stories._collector import _collector
16
+ from executable_stories._json_writer import write_raw_run
17
+ from executable_stories._story_api import story
18
+
19
+ # ── CI detection ──────────────────────────────────────────────────
20
+
21
+
22
+ def _detect_ci() -> dict[str, Any] | None:
23
+ if os.environ.get("GITHUB_ACTIONS") == "true":
24
+ ci: dict[str, Any] = {"name": "github"}
25
+ build_num = os.environ.get("GITHUB_RUN_NUMBER")
26
+ if build_num:
27
+ ci["buildNumber"] = build_num
28
+ server = os.environ.get("GITHUB_SERVER_URL")
29
+ repo = os.environ.get("GITHUB_REPOSITORY")
30
+ run_id = os.environ.get("GITHUB_RUN_ID")
31
+ if server and repo and run_id:
32
+ ci["url"] = f"{server}/{repo}/actions/runs/{run_id}"
33
+ return ci
34
+ if os.environ.get("CIRCLECI") == "true":
35
+ ci = {"name": "circleci"}
36
+ build_num = os.environ.get("CIRCLE_BUILD_NUM")
37
+ if build_num:
38
+ ci["buildNumber"] = build_num
39
+ url = os.environ.get("CIRCLE_BUILD_URL")
40
+ if url:
41
+ ci["url"] = url
42
+ return ci
43
+ if os.environ.get("JENKINS_URL"):
44
+ ci = {"name": "jenkins"}
45
+ build_num = os.environ.get("BUILD_NUMBER")
46
+ if build_num:
47
+ ci["buildNumber"] = build_num
48
+ url = os.environ.get("BUILD_URL")
49
+ if url:
50
+ ci["url"] = url
51
+ return ci
52
+ if os.environ.get("TRAVIS") == "true":
53
+ ci = {"name": "travis"}
54
+ build_num = os.environ.get("TRAVIS_BUILD_NUMBER")
55
+ if build_num:
56
+ ci["buildNumber"] = build_num
57
+ url = os.environ.get("TRAVIS_BUILD_WEB_URL")
58
+ if url:
59
+ ci["url"] = url
60
+ return ci
61
+ if os.environ.get("GITLAB_CI") == "true":
62
+ ci = {"name": "gitlab"}
63
+ build_num = os.environ.get("CI_PIPELINE_IID")
64
+ if build_num:
65
+ ci["buildNumber"] = build_num
66
+ url = os.environ.get("CI_PIPELINE_URL")
67
+ if url:
68
+ ci["url"] = url
69
+ return ci
70
+ if os.environ.get("CI") == "true":
71
+ return {"name": "ci"}
72
+ return None
73
+
74
+
75
+ # ── Session-level timestamps ───────────────────────────────────────
76
+
77
+ _started_at_ms: float = 0.0
78
+
79
+
80
+ def pytest_sessionstart(session: pytest.Session) -> None:
81
+ global _started_at_ms
82
+ _started_at_ms = time.time() * 1000
83
+ _collector.clear()
84
+
85
+
86
+ # ── Per-test hooks ─────────────────────────────────────────────────
87
+
88
+ # We store per-test start times keyed by node id.
89
+ _test_start_times: dict[str, float] = {}
90
+
91
+
92
+ @pytest.hookimpl(hookwrapper=True)
93
+ def pytest_runtest_call(item: pytest.Item) -> Any:
94
+ """Wrap test execution to set up / tear down the story context."""
95
+ _test_start_times[item.nodeid] = time.time() * 1000
96
+ yield
97
+ # Story context is cleared after report is built (in makereport)
98
+
99
+
100
+ _STATUS_MAP = {
101
+ "passed": "pass",
102
+ "failed": "fail",
103
+ "skipped": "skip",
104
+ }
105
+
106
+
107
+ @pytest.hookimpl(hookwrapper=True)
108
+ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) -> Any:
109
+ """Capture test result on the 'call' phase and record it."""
110
+ outcome = yield
111
+ report: pytest.TestReport = outcome.get_result()
112
+
113
+ if report.when != "call":
114
+ return
115
+
116
+ # Build the test case dict
117
+ start_ms = _test_start_times.pop(item.nodeid, 0.0)
118
+ now_ms = time.time() * 1000
119
+ duration_ms = now_ms - start_ms if start_ms else 0.0
120
+
121
+ # Status mapping
122
+ if hasattr(report, "wasxfail"):
123
+ status = "skip"
124
+ else:
125
+ status = _STATUS_MAP.get(report.outcome, "unknown")
126
+
127
+ # Retry info from pytest-rerunfailures (if available)
128
+ rerun = getattr(item, "execution_count", None)
129
+ retry = (rerun - 1) if rerun is not None and rerun > 0 else 0
130
+ reruns_count = getattr(item.session.config, "_rerun_count", None)
131
+ retries = reruns_count if reruns_count is not None else 0
132
+
133
+ test_case: dict[str, Any] = {
134
+ "status": status,
135
+ "externalId": item.nodeid,
136
+ "title": item.name,
137
+ "durationMs": round(duration_ms, 2),
138
+ "retry": retry,
139
+ "retries": retries,
140
+ }
141
+
142
+ # Source file info
143
+ try:
144
+ fspath = str(item.fspath)
145
+ if fspath:
146
+ test_case["sourceFile"] = fspath
147
+ if item.location and item.location[1] is not None:
148
+ test_case["sourceLine"] = item.location[1] + 1 # 0-based to 1-based
149
+ except Exception:
150
+ pass
151
+
152
+ # Error info
153
+ if report.failed and report.longrepr:
154
+ error: dict[str, str] = {}
155
+ if isinstance(report.longrepr, tuple):
156
+ error["message"] = str(report.longrepr[2])
157
+ error["stack"] = f"{report.longrepr[0]}:{report.longrepr[1]}"
158
+ else:
159
+ repr_str = str(report.longrepr)
160
+ error["message"] = repr_str
161
+ error["stack"] = repr_str
162
+ test_case["error"] = error
163
+
164
+ # Story metadata
165
+ story_meta = story._get_meta()
166
+ if story_meta is not None:
167
+ test_case["story"] = story_meta
168
+ # Build stepEvents from steps with durationMs
169
+ step_events: list[dict[str, Any]] = []
170
+ if "steps" in story_meta:
171
+ for i, step in enumerate(story_meta["steps"]):
172
+ if "durationMs" in step:
173
+ step_events.append(
174
+ {
175
+ "index": i,
176
+ "title": step["text"],
177
+ "durationMs": step["durationMs"],
178
+ }
179
+ )
180
+ if step_events:
181
+ test_case["stepEvents"] = step_events
182
+
183
+ # Attachments
184
+ attachments = story._get_attachments()
185
+ if attachments:
186
+ test_case["attachments"] = attachments
187
+
188
+ _collector.record(test_case)
189
+
190
+ # Clear story context for next test
191
+ story._clear()
192
+
193
+
194
+ # ── Session finish — write output ──────────────────────────────────
195
+
196
+
197
+ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
198
+ test_cases = _collector.get_all()
199
+ if not test_cases:
200
+ return
201
+
202
+ finished_at_ms = time.time() * 1000
203
+
204
+ raw_run: dict[str, Any] = {
205
+ "schemaVersion": 1,
206
+ "testCases": test_cases,
207
+ "projectRoot": str(session.config.rootdir), # type: ignore[attr-defined]
208
+ "startedAtMs": round(_started_at_ms, 2),
209
+ "finishedAtMs": round(finished_at_ms, 2),
210
+ }
211
+
212
+ ci = _detect_ci()
213
+ if ci is not None:
214
+ raw_run["ci"] = ci
215
+
216
+ output_path = os.environ.get(
217
+ "EXECUTABLE_STORIES_OUTPUT",
218
+ os.path.join(str(session.config.rootdir), ".executable-stories", "raw-run.json"), # type: ignore[attr-defined]
219
+ )
220
+
221
+ write_raw_run(raw_run, output_path)