kensho-pytest 0.1.1__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,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: kensho-pytest
3
+ Version: 0.1.1
4
+ Summary: Kensho reporter for pytest — writes kensho-results/ so the Kensho CLI can generate a rich HTML report.
5
+ Author: KaizenReport
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/kaizenreport/kensho
8
+ Project-URL: Source, https://github.com/kaizenreport/kensho
9
+ Keywords: pytest,kensho,kaizenreport,test-report,reporter
10
+ Classifier: Framework :: Pytest
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: pytest>=7.0
23
+
24
+ # kensho-pytest
25
+
26
+ A pytest plugin that emits the canonical [Kensho v1](../schema) JSON format.
27
+ Run your tests, then point the `kensho` CLI at `kensho-results/` to get a
28
+ self-contained static HTML report.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install kensho-pytest
34
+ # or, in this monorepo:
35
+ pip install -e packages/pytest
36
+ ```
37
+
38
+ The plugin auto-registers via the `pytest11` entry point — no `conftest.py`
39
+ edits required.
40
+
41
+ ## Run
42
+
43
+ ```bash
44
+ pytest
45
+ # => kensho-results/run.json + kensho-results/cases/*.json
46
+
47
+ # Generate the HTML report (uses the JS CLI from the same monorepo):
48
+ npx kensho generate
49
+ npx kensho open
50
+ ```
51
+
52
+ ## CLI flags
53
+
54
+ ```
55
+ --kensho-output PATH Output dir (default: ./kensho-results)
56
+ --kensho-project-name STR Project name in run.json
57
+ --kensho-project-slug STR Project slug (lowercase, alnum + dash/underscore)
58
+ --kensho-run-id STR Override the auto-generated run id
59
+ --kensho-no-severity-from-marks Don't promote @pytest.mark.<severity> to case.severity
60
+ ```
61
+
62
+ `pytest.ini` / `pyproject.toml` `[tool.pytest.ini_options]` keys are also
63
+ respected: `kensho_output`, `kensho_project_name`, `kensho_project_slug`.
64
+
65
+ ## What it produces
66
+
67
+ - `kensho-results/run.json` — run manifest (project, env, totals, timing).
68
+ - `kensho-results/cases/<stableId>.json` — one file per test case.
69
+ - `kensho-results/attachments/<caseId>/...` — files registered via
70
+ `kensho_pytest.attach`.
71
+
72
+ Each case gets a **stable id** (`tc_<16 hex>`) hashed from its `nodeid` +
73
+ file path, so test history correlates across runs and across adapters
74
+ (the JS adapters use the same FNV-1a-based hash).
75
+
76
+ ## Markers we read
77
+
78
+ | Marker | Effect |
79
+ | ----------------------------------------------- | --------------------------------------------------------- |
80
+ | `@pytest.mark.severity('critical')` | Sets `case.severity`. Allowed values: `blocker`, `critical`, `normal`, `minor`, `trivial`. |
81
+ | `@pytest.mark.blocker` / `critical` / `normal` / `minor` / `trivial` | Shorthand alias for the above. |
82
+ | `@pytest.mark.feature('Cart')` | Sets `case.behavior.feature`. |
83
+ | `@pytest.mark.epic('Checkout')` | Sets `case.behavior.epic`. |
84
+ | `@pytest.mark.story('Empty cart shows CTA')` | Sets `case.behavior.scenario`. |
85
+ | `@pytest.mark.description('Long-form...')` | Sets `case.description`. |
86
+ | `@pytest.mark.owner('alice')` | Sets `case.owner`. |
87
+ | `@pytest.mark.kensho_label(team='cart')` | Adds free-form `key=value` to `case.labels` (string only). |
88
+ | `@pytest.mark.kensho_link(kind='jira', url='https://…', label='PROJ-123')` | Adds an entry to `case.links`. |
89
+ | `@pytest.mark.parametrize(...)` | Each parametrized case gets `case.parameters[]`. |
90
+
91
+ Inline `@tag` syntax in test names (`def test_foo_at_smoke()` or
92
+ `pytest.param(..., id="x@smoke")`) is also picked up as a tag, mirroring
93
+ the JS adapters.
94
+
95
+ ## Helper API
96
+
97
+ Import the helpers from `kensho_pytest`. All four are no-ops outside a
98
+ running test, so it's safe to call them from shared utility code.
99
+
100
+ ```python
101
+ import kensho_pytest as kensho
102
+
103
+ def test_login(page):
104
+ with kensho.step("open the login page"):
105
+ page.goto("/login")
106
+
107
+ with kensho.step("submit credentials"):
108
+ page.fill("#user", "demo")
109
+ page.click("text=Sign in")
110
+ # nesting works — child steps roll up under the parent
111
+ with kensho.step("verify redirect"):
112
+ assert page.url.endswith("/home")
113
+
114
+ kensho.label("team", "growth")
115
+ kensho.link("https://jira.example.com/browse/PROJ-123",
116
+ kind="jira", label_text="PROJ-123")
117
+
118
+ page.screenshot(path="/tmp/login.png")
119
+ kensho.attach("/tmp/login.png", kind="screenshot")
120
+ ```
121
+
122
+ | Helper | What it does |
123
+ | ----------------------------------------- | ------------------------------------------------------- |
124
+ | `with kensho.step(title, action=None):` | Opens a Kensho step. Nests automatically. On exception the step is marked `fail` and the exception re-raises. |
125
+ | `kensho.attach(path, kind=None, name=None, mime_type=None)` | Copies the file into `kensho-results/attachments/<caseId>/` and registers it on the current case (or current step, if one is open). |
126
+ | `kensho.label(key, value)` | Adds a string label to the case. |
127
+ | `kensho.link(url, kind=None, label_text=None)` | Adds a hyperlink to the case. |
128
+ | `kensho.current_case_id()` | Returns the stable case id of the running test, or `None`. |
129
+
130
+ ## Captured output → logs
131
+
132
+ Pytest's captured stdout / stderr / `caplog` for each phase (setup, call,
133
+ teardown) is converted to entries in `case.logs[]`. The `t` field is the
134
+ millisecond offset from the test start.
135
+
136
+ ## Errors
137
+
138
+ `call` failures map to `status: 'fail'`; `setup` / `teardown` failures map
139
+ to `status: 'broken'` (these are infrastructure, not real assertion
140
+ failures). Skips at any phase map to `status: 'skip'`. The full
141
+ `longrepr` is preserved in `case.errors[].stack`; the first line becomes
142
+ `case.errors[].message`.
143
+
144
+ ## Environment auto-detected
145
+
146
+ GitHub Actions, CircleCI, GitLab CI, Jenkins, Buildkite, Azure DevOps —
147
+ CI provider, branch, commit, run URL, OS, architecture, Python version.
148
+
149
+ Pass `KR_AUTHOR`, `KR_COMMIT_MSG`, `KR_STAGE`, `KR_BASE_URL`,
150
+ `KR_APP_VERSION`, `KR_BUILD_NUMBER`, `KR_RELEASE`, `KR_REGION`,
151
+ `KR_LOCALE`, `KR_TRIGGER`, or `KR_FEATURE` as env vars to populate the
152
+ matching fields on `run.env`.
153
+
154
+ ## Design notes
155
+
156
+ * Zero runtime dependencies beyond `pytest` itself. The schema lives in
157
+ the JS workspace; we vendor the minimum (id-hashing, status mapping,
158
+ env capture) inline so the adapter installs in seconds.
159
+ * The reporter never raises out of a hook — a broken adapter must not
160
+ break a test run. All errors come out as `warnings.warn`.
161
+ * IDs are stable across adapters: the FNV-1a hash matches the JS
162
+ `stableCaseId` byte-for-byte, so `pytest` and `playwright` runs of
163
+ the same suite roll up to the same history on the platform.
164
+
165
+ ## License
166
+
167
+ Apache-2.0.
@@ -0,0 +1,144 @@
1
+ # kensho-pytest
2
+
3
+ A pytest plugin that emits the canonical [Kensho v1](../schema) JSON format.
4
+ Run your tests, then point the `kensho` CLI at `kensho-results/` to get a
5
+ self-contained static HTML report.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install kensho-pytest
11
+ # or, in this monorepo:
12
+ pip install -e packages/pytest
13
+ ```
14
+
15
+ The plugin auto-registers via the `pytest11` entry point — no `conftest.py`
16
+ edits required.
17
+
18
+ ## Run
19
+
20
+ ```bash
21
+ pytest
22
+ # => kensho-results/run.json + kensho-results/cases/*.json
23
+
24
+ # Generate the HTML report (uses the JS CLI from the same monorepo):
25
+ npx kensho generate
26
+ npx kensho open
27
+ ```
28
+
29
+ ## CLI flags
30
+
31
+ ```
32
+ --kensho-output PATH Output dir (default: ./kensho-results)
33
+ --kensho-project-name STR Project name in run.json
34
+ --kensho-project-slug STR Project slug (lowercase, alnum + dash/underscore)
35
+ --kensho-run-id STR Override the auto-generated run id
36
+ --kensho-no-severity-from-marks Don't promote @pytest.mark.<severity> to case.severity
37
+ ```
38
+
39
+ `pytest.ini` / `pyproject.toml` `[tool.pytest.ini_options]` keys are also
40
+ respected: `kensho_output`, `kensho_project_name`, `kensho_project_slug`.
41
+
42
+ ## What it produces
43
+
44
+ - `kensho-results/run.json` — run manifest (project, env, totals, timing).
45
+ - `kensho-results/cases/<stableId>.json` — one file per test case.
46
+ - `kensho-results/attachments/<caseId>/...` — files registered via
47
+ `kensho_pytest.attach`.
48
+
49
+ Each case gets a **stable id** (`tc_<16 hex>`) hashed from its `nodeid` +
50
+ file path, so test history correlates across runs and across adapters
51
+ (the JS adapters use the same FNV-1a-based hash).
52
+
53
+ ## Markers we read
54
+
55
+ | Marker | Effect |
56
+ | ----------------------------------------------- | --------------------------------------------------------- |
57
+ | `@pytest.mark.severity('critical')` | Sets `case.severity`. Allowed values: `blocker`, `critical`, `normal`, `minor`, `trivial`. |
58
+ | `@pytest.mark.blocker` / `critical` / `normal` / `minor` / `trivial` | Shorthand alias for the above. |
59
+ | `@pytest.mark.feature('Cart')` | Sets `case.behavior.feature`. |
60
+ | `@pytest.mark.epic('Checkout')` | Sets `case.behavior.epic`. |
61
+ | `@pytest.mark.story('Empty cart shows CTA')` | Sets `case.behavior.scenario`. |
62
+ | `@pytest.mark.description('Long-form...')` | Sets `case.description`. |
63
+ | `@pytest.mark.owner('alice')` | Sets `case.owner`. |
64
+ | `@pytest.mark.kensho_label(team='cart')` | Adds free-form `key=value` to `case.labels` (string only). |
65
+ | `@pytest.mark.kensho_link(kind='jira', url='https://…', label='PROJ-123')` | Adds an entry to `case.links`. |
66
+ | `@pytest.mark.parametrize(...)` | Each parametrized case gets `case.parameters[]`. |
67
+
68
+ Inline `@tag` syntax in test names (`def test_foo_at_smoke()` or
69
+ `pytest.param(..., id="x@smoke")`) is also picked up as a tag, mirroring
70
+ the JS adapters.
71
+
72
+ ## Helper API
73
+
74
+ Import the helpers from `kensho_pytest`. All four are no-ops outside a
75
+ running test, so it's safe to call them from shared utility code.
76
+
77
+ ```python
78
+ import kensho_pytest as kensho
79
+
80
+ def test_login(page):
81
+ with kensho.step("open the login page"):
82
+ page.goto("/login")
83
+
84
+ with kensho.step("submit credentials"):
85
+ page.fill("#user", "demo")
86
+ page.click("text=Sign in")
87
+ # nesting works — child steps roll up under the parent
88
+ with kensho.step("verify redirect"):
89
+ assert page.url.endswith("/home")
90
+
91
+ kensho.label("team", "growth")
92
+ kensho.link("https://jira.example.com/browse/PROJ-123",
93
+ kind="jira", label_text="PROJ-123")
94
+
95
+ page.screenshot(path="/tmp/login.png")
96
+ kensho.attach("/tmp/login.png", kind="screenshot")
97
+ ```
98
+
99
+ | Helper | What it does |
100
+ | ----------------------------------------- | ------------------------------------------------------- |
101
+ | `with kensho.step(title, action=None):` | Opens a Kensho step. Nests automatically. On exception the step is marked `fail` and the exception re-raises. |
102
+ | `kensho.attach(path, kind=None, name=None, mime_type=None)` | Copies the file into `kensho-results/attachments/<caseId>/` and registers it on the current case (or current step, if one is open). |
103
+ | `kensho.label(key, value)` | Adds a string label to the case. |
104
+ | `kensho.link(url, kind=None, label_text=None)` | Adds a hyperlink to the case. |
105
+ | `kensho.current_case_id()` | Returns the stable case id of the running test, or `None`. |
106
+
107
+ ## Captured output → logs
108
+
109
+ Pytest's captured stdout / stderr / `caplog` for each phase (setup, call,
110
+ teardown) is converted to entries in `case.logs[]`. The `t` field is the
111
+ millisecond offset from the test start.
112
+
113
+ ## Errors
114
+
115
+ `call` failures map to `status: 'fail'`; `setup` / `teardown` failures map
116
+ to `status: 'broken'` (these are infrastructure, not real assertion
117
+ failures). Skips at any phase map to `status: 'skip'`. The full
118
+ `longrepr` is preserved in `case.errors[].stack`; the first line becomes
119
+ `case.errors[].message`.
120
+
121
+ ## Environment auto-detected
122
+
123
+ GitHub Actions, CircleCI, GitLab CI, Jenkins, Buildkite, Azure DevOps —
124
+ CI provider, branch, commit, run URL, OS, architecture, Python version.
125
+
126
+ Pass `KR_AUTHOR`, `KR_COMMIT_MSG`, `KR_STAGE`, `KR_BASE_URL`,
127
+ `KR_APP_VERSION`, `KR_BUILD_NUMBER`, `KR_RELEASE`, `KR_REGION`,
128
+ `KR_LOCALE`, `KR_TRIGGER`, or `KR_FEATURE` as env vars to populate the
129
+ matching fields on `run.env`.
130
+
131
+ ## Design notes
132
+
133
+ * Zero runtime dependencies beyond `pytest` itself. The schema lives in
134
+ the JS workspace; we vendor the minimum (id-hashing, status mapping,
135
+ env capture) inline so the adapter installs in seconds.
136
+ * The reporter never raises out of a hook — a broken adapter must not
137
+ break a test run. All errors come out as `warnings.warn`.
138
+ * IDs are stable across adapters: the FNV-1a hash matches the JS
139
+ `stableCaseId` byte-for-byte, so `pytest` and `playwright` runs of
140
+ the same suite roll up to the same history on the platform.
141
+
142
+ ## License
143
+
144
+ Apache-2.0.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kensho-pytest"
7
+ version = "0.1.1"
8
+ description = "Kensho reporter for pytest — writes kensho-results/ so the Kensho CLI can generate a rich HTML report."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "KaizenReport" }]
13
+ keywords = ["pytest", "kensho", "kaizenreport", "test-report", "reporter"]
14
+ classifiers = [
15
+ "Framework :: Pytest",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Testing",
25
+ ]
26
+ dependencies = [
27
+ "pytest>=7.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/kaizenreport/kensho"
32
+ Source = "https://github.com/kaizenreport/kensho"
33
+
34
+ # Pytest plugin entry point — pytest auto-discovers this on install.
35
+ [project.entry-points.pytest11]
36
+ kensho = "kensho_pytest.plugin"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.setuptools.package-data]
42
+ kensho_pytest = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,223 @@
1
+ """Public API for ``kensho-pytest``.
2
+
3
+ Most of the time you don't import anything — installing the package is
4
+ enough; pytest discovers the plugin via the ``pytest11`` entry point and
5
+ the reporter writes ``kensho-results/`` automatically.
6
+
7
+ The helpers exported here are for tests that want to add structured
8
+ metadata at runtime:
9
+
10
+ * :func:`step` — context manager, opens a Kensho step.
11
+ * :func:`attach` — register a file (screenshot, log, JSON dump, …).
12
+ * :func:`label` — attach an arbitrary string ``key=value`` to the case.
13
+ * :func:`link` — attach a hyperlink (Jira ticket, runbook, PR, …).
14
+
15
+ All four helpers are no-ops when called outside a running test (e.g.
16
+ during collection or in a non-pytest script), so it's safe to call them
17
+ from helper modules used from both prod and test code.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ import uuid
24
+ from contextlib import contextmanager
25
+ from pathlib import Path
26
+ from typing import Any, Dict, Iterator, Optional, Union
27
+
28
+ from . import _state
29
+
30
+ __all__ = ["step", "attach", "label", "link", "current_case_id"]
31
+
32
+ __version__ = "0.1.0"
33
+
34
+
35
+ @contextmanager
36
+ def step(title: str, *, action: Optional[str] = None) -> Iterator[Dict[str, Any]]:
37
+ """Context manager that opens a Kensho step.
38
+
39
+ Steps can nest — the innermost ``with kensho.step(...)`` becomes the
40
+ parent of any further steps opened inside it. If the ``with`` block
41
+ raises, the step is marked ``fail`` and re-raised.
42
+
43
+ Example::
44
+
45
+ from kensho_pytest import step
46
+
47
+ def test_login(page):
48
+ with step("open the login page"):
49
+ page.goto("/login")
50
+ with step("submit credentials"):
51
+ page.fill("#user", "demo")
52
+ page.fill("#pwd", "demo")
53
+ page.click("text=Sign in")
54
+
55
+ Outside a pytest run the call is a no-op — the test code remains
56
+ safe to run as a plain script.
57
+ """
58
+ scratch = _state.get_current()
59
+ if scratch is None:
60
+ # No active test — yield a dummy dict so callers can still write
61
+ # to it without surprising errors.
62
+ yield {}
63
+ return
64
+
65
+ started_perf = time.time()
66
+ started_iso = _iso_now()
67
+ step_obj: Dict[str, Any] = {
68
+ "id": "step_" + uuid.uuid4().hex[:10],
69
+ "title": str(title),
70
+ "status": "pass",
71
+ "startedAt": started_iso,
72
+ "duration": 0,
73
+ "_startedPerf": started_perf,
74
+ }
75
+ if action:
76
+ step_obj["action"] = str(action)
77
+
78
+ parent = scratch.step_stack[-1] if scratch.step_stack else None
79
+ if parent is not None:
80
+ parent.setdefault("children", []).append(step_obj)
81
+ else:
82
+ scratch.steps.append(step_obj)
83
+ scratch.step_stack.append(step_obj)
84
+
85
+ try:
86
+ yield step_obj
87
+ except Exception:
88
+ step_obj["status"] = "fail"
89
+ _close_step(step_obj)
90
+ # pop and re-raise — the test framework decides what to do.
91
+ if scratch.step_stack and scratch.step_stack[-1] is step_obj:
92
+ scratch.step_stack.pop()
93
+ raise
94
+ else:
95
+ _close_step(step_obj)
96
+ if scratch.step_stack and scratch.step_stack[-1] is step_obj:
97
+ scratch.step_stack.pop()
98
+
99
+
100
+ def attach(
101
+ path: Union[str, Path],
102
+ *,
103
+ kind: Optional[str] = None,
104
+ name: Optional[str] = None,
105
+ mime_type: Optional[str] = None,
106
+ ) -> Optional[Dict[str, Any]]:
107
+ """Copy a file into ``kensho-results/attachments/<caseId>/`` and register it.
108
+
109
+ Parameters
110
+ ----------
111
+ path:
112
+ Local path to the file to attach. Must exist; missing files are
113
+ skipped with a warning so a flaky screenshot can't break the run.
114
+ kind:
115
+ Optional override for ``attachment.kind``. One of ``screenshot``,
116
+ ``video``, ``trace``, ``har``, ``text``, ``json``, ``html``,
117
+ ``dom-snapshot``, ``log``. Inferred from the file extension when
118
+ omitted.
119
+ name:
120
+ Optional rename for the destination file. Defaults to the source
121
+ basename, prefixed with a short id to dodge collisions.
122
+ mime_type:
123
+ Optional MIME type override. Inferred from the file extension
124
+ when omitted.
125
+
126
+ Returns the registered attachment dict, or ``None`` if no test is
127
+ currently active (e.g. when called outside a pytest run).
128
+ """
129
+ scratch = _state.get_current()
130
+ if scratch is None:
131
+ return None
132
+ plugin = _resolve_plugin()
133
+ if plugin is None:
134
+ return None
135
+ record = plugin.register_attachment(
136
+ scratch,
137
+ Path(str(path)),
138
+ kind=kind,
139
+ name=name,
140
+ mime_type=mime_type,
141
+ )
142
+ if record is None:
143
+ return None
144
+ # Anchor it on the innermost step if one is open, otherwise on the case.
145
+ if scratch.step_stack:
146
+ scratch.step_stack[-1].setdefault("attachments", []).append(record)
147
+ else:
148
+ scratch.attachments.append(record)
149
+ return record
150
+
151
+
152
+ def label(key: str, value: str) -> None:
153
+ """Set ``case.labels[key] = value`` for the currently-running test.
154
+
155
+ Useful for free-form metadata (``service``, ``team``, ``env``) that
156
+ doesn't fit the typed fields. No-op outside a test."""
157
+ scratch = _state.get_current()
158
+ if scratch is None or not key:
159
+ return
160
+ scratch.labels[str(key)] = str(value)
161
+
162
+
163
+ def link(
164
+ url: str,
165
+ *,
166
+ kind: Optional[str] = None,
167
+ label_text: Optional[str] = None,
168
+ ) -> None:
169
+ """Attach a link to the currently-running case.
170
+
171
+ ``kind`` is free-form by convention (``jira``, ``github``, ``runbook``…).
172
+ ``label_text`` becomes ``link.label`` — the human-readable text. The
173
+ parameter is named ``label_text`` rather than ``label`` to avoid
174
+ shadowing the :func:`label` helper.
175
+ """
176
+ scratch = _state.get_current()
177
+ if scratch is None or not url:
178
+ return
179
+ entry: Dict[str, str] = {"url": str(url)}
180
+ if kind:
181
+ entry["kind"] = str(kind)
182
+ if label_text:
183
+ entry["label"] = str(label_text)
184
+ scratch.links.append(entry)
185
+
186
+
187
+ def current_case_id() -> Optional[str]:
188
+ """Return the stable id of the test currently being run, if any."""
189
+ scratch = _state.get_current()
190
+ return scratch.case_id if scratch is not None else None
191
+
192
+
193
+ # --------------------------------------------------------------------------- #
194
+ # Internals
195
+ # --------------------------------------------------------------------------- #
196
+
197
+
198
+ def _close_step(step_obj: Dict[str, Any]) -> None:
199
+ started_perf = step_obj.pop("_startedPerf", None)
200
+ if started_perf is None:
201
+ step_obj.setdefault("duration", 0)
202
+ return
203
+ step_obj["duration"] = max(0, int(round((time.time() - started_perf) * 1000)))
204
+
205
+
206
+ def _iso_now() -> str:
207
+ import datetime as _dt
208
+
209
+ return (
210
+ _dt.datetime.now(tz=_dt.timezone.utc)
211
+ .isoformat(timespec="milliseconds")
212
+ .replace("+00:00", "Z")
213
+ )
214
+
215
+
216
+ def _resolve_plugin() -> Any:
217
+ """Return the active ``KenshoPlugin`` instance, or ``None`` if there is none.
218
+
219
+ The plugin publishes itself into ``_state.PLUGIN`` during
220
+ ``pytest_configure`` so helper APIs can find it without importing
221
+ the heavy plugin module at startup time.
222
+ """
223
+ return getattr(_state, "PLUGIN", None)