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.
- executable_stories_pytest-0.1.0/PKG-INFO +23 -0
- executable_stories_pytest-0.1.0/README.md +5 -0
- executable_stories_pytest-0.1.0/pyproject.toml +75 -0
- executable_stories_pytest-0.1.0/setup.cfg +4 -0
- executable_stories_pytest-0.1.0/src/executable_stories/__init__.py +5 -0
- executable_stories_pytest-0.1.0/src/executable_stories/_collector.py +37 -0
- executable_stories_pytest-0.1.0/src/executable_stories/_json_writer.py +21 -0
- executable_stories_pytest-0.1.0/src/executable_stories/_plugin.py +221 -0
- executable_stories_pytest-0.1.0/src/executable_stories/_story_api.py +554 -0
- executable_stories_pytest-0.1.0/src/executable_stories/_types.py +206 -0
- executable_stories_pytest-0.1.0/src/executable_stories_pytest.egg-info/PKG-INFO +23 -0
- executable_stories_pytest-0.1.0/src/executable_stories_pytest.egg-info/SOURCES.txt +17 -0
- executable_stories_pytest-0.1.0/src/executable_stories_pytest.egg-info/dependency_links.txt +1 -0
- executable_stories_pytest-0.1.0/src/executable_stories_pytest.egg-info/entry_points.txt +2 -0
- executable_stories_pytest-0.1.0/src/executable_stories_pytest.egg-info/requires.txt +8 -0
- executable_stories_pytest-0.1.0/src/executable_stories_pytest.egg-info/top_level.txt +1 -0
- executable_stories_pytest-0.1.0/tests/test_json_output.py +124 -0
- executable_stories_pytest-0.1.0/tests/test_plugin.py +165 -0
- executable_stories_pytest-0.1.0/tests/test_story_api.py +593 -0
|
@@ -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,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,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)
|