tracera-pytest 0.1.0rc1__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 @@
1
+ # Package-local stub so hatchling sdist does not embed the monorepo root .gitignore.
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2026 Tracera. All rights reserved.
2
+
3
+ This software and associated documentation files (the "Software") are the
4
+ proprietary and confidential property of Tracera.
5
+
6
+ No license to use, copy, modify, merge, publish, distribute, sublicense,
7
+ and/or sell copies of the Software is granted by default. Any use of the
8
+ Software requires a separate written agreement with Tracera.
9
+
10
+ Unauthorized use, reproduction, or distribution of the Software, in whole
11
+ or in part, is strictly prohibited and may result in civil and criminal
12
+ penalties.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
17
+ TRACERA BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
18
+ ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+
21
+ This is a draft notice pending a full commercial license / EULA.
22
+ Third-party open-source components included with or used by the Software
23
+ remain subject to their respective licenses.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: tracera-pytest
3
+ Version: 0.1.0rc1
4
+ Summary: Tracera Autotest adapter for pytest
5
+ Project-URL: Homepage, https://tracera.dev/docs/pytest
6
+ Project-URL: Bug-Tracker, https://github.com/tracera-dev/tracera-releases/issues
7
+ Author-email: Tracera <contact@tracera.dev>
8
+ License: SEE LICENSE IN LICENSE.md
9
+ License-File: LICENSE.md
10
+ Keywords: autotest,pytest,test-reporting,testops,tracera
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: tracera-core==0.1.0rc1
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # tracera-pytest
18
+
19
+ Tracera Autotest adapter for pytest.
20
+
21
+ **Requires Python ≥ 3.11 and pytest ≥ 8.**
22
+
23
+ ```bash
24
+ pip install tracera-pytest
25
+ ```
26
+
27
+ How-to: [pytest](https://tracera.dev/docs/pytest). Shared env / run mode: [Autotest adapters](https://tracera.dev/docs/adapters).
28
+
29
+ ## Setup
30
+
31
+ The package registers as a pytest plugin automatically (no `-p` flag).
32
+
33
+ ```python
34
+ import pytest
35
+ from tracera_pytest import id as tracera_id
36
+ from tracera_pytest import tracera
37
+
38
+
39
+ @tracera_id(42, "e2e/login")
40
+ def test_login():
41
+ tracera.step("open login", lambda: None)
42
+ ```
43
+
44
+ ## Bind
45
+
46
+ `@tracera.id(…)` (preferred) or `tracera.test_case`. Optional name may use `{param}` / `{param_id}` from pytest parametrize. If `name` is omitted → `item.name`.
47
+
48
+ ## Skip
49
+
50
+ Stock `pytest.skip`. The reason becomes a result `comment`.
51
+
52
+ ## Steps
53
+
54
+ `tracera.step(title, body)`, `@tracera.step("…")`, `with tracera.step("…"):`, or `async with tracera.step("…"):` (async `def` / awaitable bodies OK; decorator `{name}` / `{0}` from args). **Function-scoped** fixtures → **Setup** / **Teardown**; suite-level fixtures are not reported.
55
+
56
+ ## Comments, errors, and attachments
57
+
58
+ `tracera.comment` / `tracera.error` / `tracera.attachment` — see [Autotest adapters](https://tracera.dev/docs/adapters).
59
+
60
+ ## Parameterized
61
+
62
+ `@pytest.mark.parametrize` + `@tracera.id(id, "login as {login}")`. Retries: final attempt only; `PYTEST_RETRY` on Env when retry > 0.
@@ -0,0 +1,46 @@
1
+ # tracera-pytest
2
+
3
+ Tracera Autotest adapter for pytest.
4
+
5
+ **Requires Python ≥ 3.11 and pytest ≥ 8.**
6
+
7
+ ```bash
8
+ pip install tracera-pytest
9
+ ```
10
+
11
+ How-to: [pytest](https://tracera.dev/docs/pytest). Shared env / run mode: [Autotest adapters](https://tracera.dev/docs/adapters).
12
+
13
+ ## Setup
14
+
15
+ The package registers as a pytest plugin automatically (no `-p` flag).
16
+
17
+ ```python
18
+ import pytest
19
+ from tracera_pytest import id as tracera_id
20
+ from tracera_pytest import tracera
21
+
22
+
23
+ @tracera_id(42, "e2e/login")
24
+ def test_login():
25
+ tracera.step("open login", lambda: None)
26
+ ```
27
+
28
+ ## Bind
29
+
30
+ `@tracera.id(…)` (preferred) or `tracera.test_case`. Optional name may use `{param}` / `{param_id}` from pytest parametrize. If `name` is omitted → `item.name`.
31
+
32
+ ## Skip
33
+
34
+ Stock `pytest.skip`. The reason becomes a result `comment`.
35
+
36
+ ## Steps
37
+
38
+ `tracera.step(title, body)`, `@tracera.step("…")`, `with tracera.step("…"):`, or `async with tracera.step("…"):` (async `def` / awaitable bodies OK; decorator `{name}` / `{0}` from args). **Function-scoped** fixtures → **Setup** / **Teardown**; suite-level fixtures are not reported.
39
+
40
+ ## Comments, errors, and attachments
41
+
42
+ `tracera.comment` / `tracera.error` / `tracera.attachment` — see [Autotest adapters](https://tracera.dev/docs/adapters).
43
+
44
+ ## Parameterized
45
+
46
+ `@pytest.mark.parametrize` + `@tracera.id(id, "login as {login}")`. Retries: final attempt only; `PYTEST_RETRY` on Env when retry > 0.
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "tracera-pytest"
3
+ version = "0.1.0rc1"
4
+ description = "Tracera Autotest adapter for pytest"
5
+ readme = "README.md"
6
+ license = { text = "SEE LICENSE IN LICENSE.md" }
7
+ authors = [{ name = "Tracera", email = "contact@tracera.dev" }]
8
+ requires-python = ">=3.11"
9
+ keywords = ["tracera", "autotest", "pytest", "test-reporting", "testops"]
10
+ dependencies = [
11
+ "tracera-core==0.1.0rc1",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8"]
16
+
17
+ [project.entry-points.pytest11]
18
+ tracera = "tracera_pytest.plugin"
19
+
20
+ [project.urls]
21
+ Homepage = "https://tracera.dev/docs/pytest"
22
+ Bug-Tracker = "https://github.com/tracera-dev/tracera-releases/issues"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/tracera_pytest"]
30
+
31
+ [tool.hatch.build.targets.wheel.sources]
32
+ "src/tracera_pytest" = "tracera_pytest"
33
+
34
+ # Publish surface: runtime package + docs only (no tests / harness / gitignore).
35
+ [tool.hatch.build.targets.sdist]
36
+ only-include = [
37
+ "src/tracera_pytest",
38
+ "LICENSE.md",
39
+ "README.md",
40
+ ]
@@ -0,0 +1,8 @@
1
+ """Tracera Autotest adapter for pytest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tracera_pytest.annotations import id
6
+ from tracera_pytest.runtime import tracera
7
+
8
+ __all__ = ["tracera", "id"]
@@ -0,0 +1,29 @@
1
+ """Declarative bind: ``@tracera.id(42)`` / ``@tracera.id(42, "name")``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, TypeVar
6
+
7
+ import pytest
8
+
9
+ from tracera.bind import make_bind
10
+
11
+ F = TypeVar("F", bound=Callable[..., Any])
12
+
13
+
14
+ def id(test_case_id: int, autotest_name: str | None = None) -> Callable[[F], F]:
15
+ """
16
+ Bind a pytest test to a Tracera case (preferred declarative form).
17
+
18
+ Applies ``pytest.mark.tracera(id=…, name=…)`` so early skip still reports.
19
+
20
+ Optional ``autotest_name`` may include ``{param}`` / ``{param_id}`` placeholders;
21
+ the plugin fills them from pytest parametrize values when the test starts.
22
+ """
23
+ bind = make_bind(test_case_id, autotest_name)
24
+ mark = pytest.mark.tracera(id=bind.test_case_id, name=bind.autotest_name)
25
+
26
+ def decorator(fn: F) -> F:
27
+ return mark(fn)
28
+
29
+ return decorator
@@ -0,0 +1,353 @@
1
+ """pytest plugin: session lifecycle, bind, Setup/Test/Teardown phases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Generator
7
+
8
+ import pytest
9
+
10
+ from tracera import (
11
+ run_with_context,
12
+ )
13
+ from tracera.buffer import PendingResult
14
+ from tracera.context import (
15
+ create_test_context,
16
+ format_title,
17
+ record_failure,
18
+ record_skip,
19
+ set_phase,
20
+ snapshot_payload,
21
+ suspend_reporting_context,
22
+ )
23
+ from tracera.env_extras import build_env_vars_extra
24
+ from tracera.materialize import payload_has_step_error
25
+ from tracera.pending import (
26
+ discard_pending_blobs,
27
+ pending_blobs_for_payload,
28
+ resolve_pending_attachments,
29
+ )
30
+ from tracera.registry import registry_set
31
+ from tracera.session import (
32
+ begin_reporting,
33
+ create_reporter_state,
34
+ duration_minutes_from_ms,
35
+ end_reporting,
36
+ )
37
+ from tracera_pytest import state as st
38
+
39
+ _reporter = create_reporter_state()
40
+ _final_reports: dict[str, dict[str, Any]] = {}
41
+
42
+
43
+ def _fixture_scope_name(fixturedef: Any) -> str:
44
+ scope = getattr(fixturedef, "scope", "function")
45
+ value = getattr(scope, "value", scope)
46
+ return str(value)
47
+
48
+
49
+ def pytest_configure(config: pytest.Config) -> None:
50
+ config.addinivalue_line(
51
+ "markers",
52
+ "tracera(id, name=None): bind test to Tracera testCaseId (and optional autotestName)",
53
+ )
54
+
55
+
56
+ @pytest.hookimpl(optionalhook=True)
57
+ def pytest_configure_node(node: Any) -> None:
58
+ """xdist master: create owned run once and hand the id to each worker."""
59
+ if _reporter.session is None and not _reporter.soft_disabled:
60
+ begin_reporting(_reporter)
61
+ session = _reporter.session
62
+ if session is not None:
63
+ node.workerinput["tracera_test_run_id"] = session.test_run_id
64
+
65
+
66
+ def pytest_sessionstart(session: pytest.Session) -> None:
67
+ st.clear_state()
68
+ _final_reports.clear()
69
+ workerinput = getattr(session.config, "workerinput", None)
70
+ if isinstance(workerinput, dict):
71
+ run_id = workerinput.get("tracera_test_run_id")
72
+ if run_id is not None:
73
+ import os
74
+
75
+ os.environ["TRACERA_TEST_RUN_ID"] = str(run_id)
76
+ begin_reporting(_reporter)
77
+ return
78
+ if _reporter.session is not None or _reporter.soft_disabled:
79
+ # Already started in pytest_configure_node (xdist master).
80
+ return
81
+ begin_reporting(_reporter)
82
+
83
+
84
+ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
85
+ if (
86
+ _reporter.soft_disabled
87
+ or not _reporter.session
88
+ or not _reporter.buffer
89
+ or not _reporter.upload_pool
90
+ ):
91
+ for row in _final_reports.values():
92
+ discard_pending_blobs(
93
+ row.get("payload") or {},
94
+ take_blob=lambda aid, blobs=dict(row.get("pending_blobs") or {}): (
95
+ blobs.pop(aid, None) or st.take_pending_blob(aid)
96
+ ),
97
+ )
98
+ st.clear_pending_blobs()
99
+ end_reporting(_reporter)
100
+ return
101
+
102
+ for row in _final_reports.values():
103
+ ctx = row["ctx"]
104
+ payload = row["payload"]
105
+ status = row["status"]
106
+ duration_ms = row["duration_ms"]
107
+ retry = row.get("retry", 0)
108
+ env_vars_extra = build_env_vars_extra(
109
+ retry=retry,
110
+ retry_key="PYTEST_RETRY",
111
+ package_ids=["pytest"],
112
+ version_key="PYTEST_VERSION",
113
+ )
114
+ duration_minutes = duration_minutes_from_ms(duration_ms)
115
+ session_ref = _reporter.session
116
+ buffer = _reporter.buffer
117
+ upload_pool = _reporter.upload_pool
118
+
119
+ def job(
120
+ ctx=ctx,
121
+ payload=payload,
122
+ status=status,
123
+ duration_minutes=duration_minutes,
124
+ env_vars_extra=env_vars_extra,
125
+ pending_blobs=dict(row.get("pending_blobs") or {}),
126
+ ) -> None:
127
+ if not session_ref.reporting_enabled:
128
+ discard_pending_blobs(
129
+ payload,
130
+ take_blob=lambda aid: pending_blobs.pop(aid, None) or st.take_pending_blob(aid),
131
+ )
132
+ return
133
+ upload_errors = resolve_pending_attachments(
134
+ session_ref.client,
135
+ payload,
136
+ resolve_blob=lambda aid: pending_blobs.get(aid) or st.take_pending_blob(aid),
137
+ )
138
+ if upload_errors:
139
+ session_ref.set_last_error(upload_errors[0])
140
+ if session_ref.config.require_report:
141
+ session_ref.note_failed_report()
142
+ session_ref.note_unsent_results()
143
+ return
144
+ buffer.enqueue(
145
+ PendingResult(
146
+ ctx=ctx,
147
+ status=status,
148
+ duration_minutes=duration_minutes,
149
+ payload=payload,
150
+ env_vars_extra=env_vars_extra,
151
+ )
152
+ )
153
+
154
+ upload_pool.enqueue(job)
155
+
156
+ end_reporting(_reporter)
157
+
158
+
159
+ def _callspec_title_values(item: pytest.Item) -> dict[str, Any]:
160
+ callspec = getattr(item, "callspec", None)
161
+ if callspec is None:
162
+ return {}
163
+ values: dict[str, Any] = dict(getattr(callspec, "params", {}) or {})
164
+ cid = getattr(callspec, "id", None)
165
+ if isinstance(cid, str) and cid:
166
+ values.setdefault("param_id", cid)
167
+ return values
168
+
169
+
170
+ def _resolve_autotest_name(bind_name: str | None, item: pytest.Item) -> str:
171
+ if bind_name:
172
+ return format_title(bind_name, _callspec_title_values(item))[:128]
173
+ return item.name[:128]
174
+
175
+
176
+ def _read_bind(item: pytest.Item) -> tuple[int | None, str | None]:
177
+ marker = item.get_closest_marker("tracera")
178
+ if marker is None:
179
+ return None, None
180
+ case_id = marker.kwargs.get("id")
181
+ if case_id is None and marker.args:
182
+ case_id = marker.args[0]
183
+ try:
184
+ n = int(case_id) if case_id is not None else 0
185
+ except (TypeError, ValueError):
186
+ n = 0
187
+ name = marker.kwargs.get("name")
188
+ return (n if n > 0 else None), (name if isinstance(name, str) else None)
189
+
190
+
191
+ def _skip_reason(report: pytest.TestReport) -> str | None:
192
+ longrepr = report.longrepr
193
+ if isinstance(longrepr, tuple) and len(longrepr) >= 3:
194
+ text = str(longrepr[2])
195
+ return text if text.strip() else None
196
+ if longrepr is not None:
197
+ text = str(longrepr)
198
+ return text if text.strip() else None
199
+ return None
200
+
201
+
202
+ def _failure_source(call: pytest.CallInfo[Any], report: pytest.TestReport) -> Any:
203
+ """Prefer full traceback text; fall back to ExceptionInfo / longrepr."""
204
+ # longreprtext is pytest's formatted report (includes Traceback) — richest + stable.
205
+ text = getattr(report, "longreprtext", None)
206
+ if isinstance(text, str) and text.strip():
207
+ return text
208
+ if call.excinfo is not None:
209
+ return call.excinfo
210
+ return report.longrepr
211
+
212
+
213
+ @pytest.hookimpl(hookwrapper=True)
214
+ def pytest_fixture_setup(fixturedef: Any, request: Any) -> Generator[None, None, None]:
215
+ """Session/module/class fixtures must not record steps onto the active test."""
216
+ if _fixture_scope_name(fixturedef) == "function":
217
+ yield
218
+ return
219
+ st.set_reporting_suspended(True)
220
+ try:
221
+ with suspend_reporting_context():
222
+ yield
223
+ finally:
224
+ st.set_reporting_suspended(False)
225
+
226
+ def _suspend_before_teardown() -> None:
227
+ st.set_reporting_suspended(True)
228
+ suspend_reporting_context().__enter__()
229
+
230
+ fixturedef.addfinalizer(_suspend_before_teardown)
231
+
232
+
233
+ @pytest.hookimpl(tryfirst=True)
234
+ def pytest_runtest_setup(item: pytest.Item) -> None:
235
+ st.set_reporting_suspended(False)
236
+ key = st.registry_key(item.nodeid)
237
+ attempt = st.note_attempt(key)
238
+ st.set_current_key(key)
239
+ case_id, bind_name = _read_bind(item)
240
+ ctx = create_test_context(
241
+ test_case_id=case_id,
242
+ autotest_name=_resolve_autotest_name(bind_name, item),
243
+ )
244
+ registry_set(key, ctx)
245
+ item._tracera_ctx = ctx # type: ignore[attr-defined]
246
+ item._tracera_started = time.time() # type: ignore[attr-defined]
247
+ item._tracera_attempt = attempt # type: ignore[attr-defined]
248
+ item._tracera_token = run_with_context(ctx) # type: ignore[attr-defined]
249
+ item._tracera_token.__enter__() # type: ignore[attr-defined]
250
+ set_phase("setup")
251
+
252
+
253
+ @pytest.hookimpl(tryfirst=True)
254
+ def pytest_runtest_call(item: pytest.Item) -> None:
255
+ ctx = getattr(item, "_tracera_ctx", None)
256
+ if ctx is not None:
257
+ set_phase("test")
258
+
259
+
260
+ @pytest.hookimpl(hookwrapper=True, tryfirst=True)
261
+ def pytest_runtest_teardown(
262
+ item: pytest.Item, nextitem: pytest.Item | None
263
+ ) -> Generator[None, None, None]:
264
+ # tryfirst so phase is Teardown before fixture teardown runs (default hook).
265
+ ctx = getattr(item, "_tracera_ctx", None)
266
+ if ctx is not None:
267
+ set_phase("teardown")
268
+ yield
269
+ _ = nextitem
270
+ # Drop registry fallback so module/session teardown cannot append onto the last test.
271
+ st.set_current_key(None)
272
+ st.set_reporting_suspended(True)
273
+
274
+
275
+ @pytest.hookimpl(hookwrapper=True)
276
+ def pytest_runtest_makereport(
277
+ item: pytest.Item, call: pytest.CallInfo[Any]
278
+ ) -> Generator[None, None, None]:
279
+ outcome = yield
280
+ assert outcome is not None
281
+ report: pytest.TestReport = outcome.get_result()
282
+ ctx = getattr(item, "_tracera_ctx", None)
283
+ if ctx is None:
284
+ return
285
+
286
+ if call.when == "setup":
287
+ if report.skipped:
288
+ record_skip(ctx, _skip_reason(report))
289
+ _store_final(item, ctx, "Skipped")
290
+ elif report.failed:
291
+ if not payload_has_step_error(snapshot_payload(ctx)):
292
+ record_failure(ctx, _failure_source(call, report))
293
+ _store_final(item, ctx, "Failed")
294
+ return
295
+
296
+ if call.when == "call":
297
+ if report.skipped:
298
+ record_skip(ctx, _skip_reason(report))
299
+ _store_final(item, ctx, "Skipped")
300
+ elif report.failed:
301
+ if not payload_has_step_error(snapshot_payload(ctx)):
302
+ record_failure(ctx, _failure_source(call, report))
303
+ _store_final(item, ctx, "Failed")
304
+ elif report.passed:
305
+ _store_final(item, ctx, "Passed")
306
+ return
307
+
308
+ if call.when == "teardown":
309
+ key = st.registry_key(item.nodeid)
310
+ row = _final_reports.get(key)
311
+ if row is None:
312
+ _store_final(item, ctx, "Passed")
313
+ row = _final_reports.get(key)
314
+ if row is not None:
315
+ payload = snapshot_payload(ctx)
316
+ status = row["status"]
317
+ if status == "Passed":
318
+ failed_step = any(
319
+ isinstance(s, dict) and s.get("result") == "Failed"
320
+ for s in (payload["setup"] + payload["test"] + payload["teardown"])
321
+ )
322
+ if failed_step or ctx.result_error:
323
+ status = "Failed"
324
+ row["payload"] = payload
325
+ row["status"] = status
326
+ row["duration_ms"] = (
327
+ time.time() - getattr(item, "_tracera_started", time.time())
328
+ ) * 1000
329
+ # Snapshot pending blobs used by this test (payload-referenced only).
330
+ row["pending_blobs"] = pending_blobs_for_payload(st._pending_blobs, payload) # noqa: SLF001
331
+ # Close context
332
+ token_cm = getattr(item, "_tracera_token", None)
333
+ if token_cm is not None:
334
+ try:
335
+ token_cm.__exit__(None, None, None)
336
+ except Exception:
337
+ pass
338
+ item._tracera_token = None # type: ignore[attr-defined]
339
+
340
+
341
+ def _store_final(item: pytest.Item, ctx: Any, status: str) -> None:
342
+ key = st.registry_key(item.nodeid)
343
+ attempt = getattr(item, "_tracera_attempt", 0)
344
+ started = getattr(item, "_tracera_started", time.time())
345
+ payload = snapshot_payload(ctx)
346
+ _final_reports[key] = {
347
+ "ctx": ctx,
348
+ "payload": payload,
349
+ "status": status,
350
+ "duration_ms": (time.time() - started) * 1000,
351
+ "retry": attempt,
352
+ "pending_blobs": {},
353
+ }
@@ -0,0 +1,250 @@
1
+ """Runtime API for pytest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import uuid
7
+ from contextlib import nullcontext
8
+ from functools import wraps
9
+ from pathlib import Path
10
+ from typing import Any, Callable, TypeVar, overload
11
+
12
+ from tracera import get_context, run_with_context
13
+ from tracera.context import (
14
+ AttachmentMeta,
15
+ StepContext,
16
+ TestContext,
17
+ format_step_title,
18
+ )
19
+ from tracera.registry import registry_get
20
+ from tracera.runtime import create_tracera_runtime
21
+ from tracera_pytest import state as st
22
+
23
+ T = TypeVar("T")
24
+ F = TypeVar("F", bound=Callable[..., Any])
25
+
26
+ _base = create_tracera_runtime()
27
+
28
+
29
+ def _ensure_ctx() -> TestContext | None:
30
+ if st.reporting_suspended():
31
+ return None
32
+ ctx = get_context()
33
+ if ctx is not None:
34
+ return ctx
35
+ # Fallback: current node key from state
36
+ key = st.current_key()
37
+ if key:
38
+ existing = registry_get(key)
39
+ if existing:
40
+ return existing
41
+ return None
42
+
43
+
44
+ def _run_step_body(title: str, body: Callable[[], T]) -> T:
45
+ ctx = _ensure_ctx()
46
+ if ctx is not None:
47
+ with run_with_context(ctx):
48
+ return _base.step(title, body) # type: ignore[return-value]
49
+ if st.reporting_suspended():
50
+ return body()
51
+ return _base.step(title, body) # type: ignore[return-value]
52
+
53
+
54
+ def test_case(test_case_id: int, autotest_name: str | None = None) -> None:
55
+ """Imperative bind to a Tracera test case (optional result name). Prefer ``@tracera.id``."""
56
+ ctx = _ensure_ctx()
57
+ if ctx is not None:
58
+ ctx.test_case_id = test_case_id
59
+ if autotest_name:
60
+ ctx.autotest_name = autotest_name[:128]
61
+ with run_with_context(ctx):
62
+ _base.test_case(test_case_id, autotest_name)
63
+ return
64
+ _base.test_case(test_case_id, autotest_name)
65
+
66
+
67
+ class _EnsuredStep:
68
+ """Re-bind pytest registry context for decorator / ``with`` / ``async with`` forms."""
69
+
70
+ def __init__(self, inner: StepContext) -> None:
71
+ self._inner = inner
72
+ self._token: Any = nullcontext()
73
+
74
+ def __enter__(self) -> StepContext:
75
+ if st.reporting_suspended():
76
+ self._dropped = True
77
+ self._token = nullcontext()
78
+ return self._inner
79
+ self._dropped = False
80
+ ctx = _ensure_ctx()
81
+ self._token = run_with_context(ctx) if ctx is not None else nullcontext()
82
+ self._token.__enter__()
83
+ return self._inner.__enter__()
84
+
85
+ def __exit__(
86
+ self,
87
+ exc_type: type[BaseException] | None,
88
+ exc: BaseException | None,
89
+ tb: Any,
90
+ ) -> bool:
91
+ if getattr(self, "_dropped", False):
92
+ return False
93
+ try:
94
+ return self._inner.__exit__(exc_type, exc, tb)
95
+ finally:
96
+ self._token.__exit__(exc_type, exc, tb)
97
+
98
+ async def __aenter__(self) -> StepContext:
99
+ if st.reporting_suspended():
100
+ self._dropped = True
101
+ self._token = nullcontext()
102
+ return self._inner
103
+ self._dropped = False
104
+ ctx = _ensure_ctx()
105
+ self._token = run_with_context(ctx) if ctx is not None else nullcontext()
106
+ self._token.__enter__()
107
+ return await self._inner.__aenter__()
108
+
109
+ async def __aexit__(
110
+ self,
111
+ exc_type: type[BaseException] | None,
112
+ exc: BaseException | None,
113
+ tb: Any,
114
+ ) -> bool:
115
+ if getattr(self, "_dropped", False):
116
+ return False
117
+ try:
118
+ return await self._inner.__aexit__(exc_type, exc, tb)
119
+ finally:
120
+ self._token.__exit__(exc_type, exc, tb)
121
+
122
+ def __call__(self, fn: F) -> F:
123
+ if inspect.iscoroutinefunction(fn):
124
+
125
+ @wraps(fn)
126
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
127
+ title = format_step_title(self._inner.title, fn, args, kwargs)
128
+ async with _EnsuredStep(StepContext(title)):
129
+ return await fn(*args, **kwargs)
130
+
131
+ return async_wrapper # type: ignore[return-value]
132
+
133
+ @wraps(fn)
134
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
135
+ title = format_step_title(self._inner.title, fn, args, kwargs)
136
+ with _EnsuredStep(StepContext(title)):
137
+ return fn(*args, **kwargs)
138
+
139
+ return wrapper # type: ignore[return-value]
140
+
141
+
142
+ @overload
143
+ def step(title: str, body: Callable[[], T]) -> T: ...
144
+
145
+
146
+ @overload
147
+ def step(title: str, body: None = None) -> _EnsuredStep: ...
148
+
149
+
150
+ @overload
151
+ def step(title: F) -> F: ...
152
+
153
+
154
+ def step(title: str | F, body: Callable[[], T] | None = None) -> T | _EnsuredStep | F:
155
+ """Record a step — call / decorator / ``with`` / ``async with`` (same forms as ``tracera.step``)."""
156
+ if callable(title) and body is None:
157
+ return _EnsuredStep(StepContext(title.__name__))(title)
158
+ if body is not None:
159
+ return _run_step_body(str(title), body)
160
+ return _EnsuredStep(_base.step(str(title))) # type: ignore[arg-type]
161
+
162
+
163
+ def comment(text: str) -> None:
164
+ """Comment on the open step, or on the result when no step is open."""
165
+ if st.reporting_suspended():
166
+ return
167
+ ctx = _ensure_ctx()
168
+ if ctx is not None:
169
+ with run_with_context(ctx):
170
+ _base.comment(text)
171
+ return
172
+ _base.comment(text)
173
+
174
+
175
+ def error(text: str) -> None:
176
+ """Soft error text on the open step (or result). Does not by itself fail the test."""
177
+ if st.reporting_suspended():
178
+ return
179
+ ctx = _ensure_ctx()
180
+ if ctx is not None:
181
+ with run_with_context(ctx):
182
+ _base.error(text)
183
+ return
184
+ _base.error(text)
185
+
186
+
187
+ def attachment(
188
+ meta: AttachmentMeta | dict[str, Any] | None = None,
189
+ *,
190
+ file_name: str | None = None,
191
+ mime_type: str | None = None,
192
+ data: bytes | str | None = None,
193
+ path: str | None = None,
194
+ ) -> None:
195
+ """Attach a file (pending upload) or an already-uploaded AttachmentMeta."""
196
+ if st.reporting_suspended():
197
+ return
198
+ ctx = _ensure_ctx()
199
+
200
+ def _do() -> None:
201
+ if isinstance(meta, AttachmentMeta) or (
202
+ isinstance(meta, dict) and "attachmentId" in meta and data is None and path is None
203
+ ):
204
+ _base.attachment(meta) # type: ignore[arg-type]
205
+ return
206
+ fname = file_name
207
+ mime = mime_type or "application/octet-stream"
208
+ blob_data = data
209
+ blob_path = path
210
+ if isinstance(meta, dict) and data is None and path is None and "attachmentId" not in meta:
211
+ fname = meta.get("fileName") or meta.get("file_name") or fname
212
+ mime = meta.get("mimeType") or meta.get("mime_type") or mime
213
+ blob_data = meta.get("data")
214
+ blob_path = meta.get("path")
215
+ if not fname:
216
+ fname = Path(blob_path).name if blob_path else "attachment.bin"
217
+ if isinstance(blob_data, str):
218
+ blob_data = blob_data.encode("utf-8")
219
+ pending_id = f"pending:{uuid.uuid4()}"
220
+ st.register_pending_blob(
221
+ pending_id,
222
+ {"fileName": fname, "mimeType": mime, "data": blob_data, "path": blob_path},
223
+ )
224
+ _base.attachment({"attachmentId": pending_id, "fileName": fname, "mimeType": mime})
225
+
226
+ if ctx is not None:
227
+ with run_with_context(ctx):
228
+ _do()
229
+ else:
230
+ _do()
231
+
232
+
233
+ class _TraceraApi:
234
+ test_case = staticmethod(test_case)
235
+ step = staticmethod(step)
236
+ comment = staticmethod(comment)
237
+ error = staticmethod(error)
238
+ attachment = staticmethod(attachment)
239
+ reserve_attachment = staticmethod(_base.reserve_attachment)
240
+ get_context = staticmethod(get_context)
241
+ run_with_context = staticmethod(run_with_context)
242
+ id: object | None = None # patched below
243
+
244
+
245
+ tracera = _TraceraApi()
246
+
247
+ # Prefer ``from tracera_pytest import tracera, id`` — also expose ``tracera.id``.
248
+ from tracera_pytest.annotations import id as _id # noqa: E402
249
+
250
+ tracera.id = _id
@@ -0,0 +1,66 @@
1
+ """Per-test state for the pytest plugin (pending blobs, attempt counters)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar
6
+ from typing import Any
7
+
8
+ _pending_blobs: dict[str, dict[str, Any]] = {}
9
+ _attempts: dict[str, int] = {}
10
+ _current_key: ContextVar[str | None] = ContextVar("tracera_pytest_current_key", default=None)
11
+ _reporting_suspended: bool = False
12
+
13
+
14
+ def set_current_key(key: str | None) -> None:
15
+ _current_key.set(key)
16
+
17
+
18
+ def current_key() -> str | None:
19
+ return _current_key.get()
20
+
21
+
22
+ def set_reporting_suspended(suspended: bool) -> None:
23
+ global _reporting_suspended
24
+ _reporting_suspended = suspended
25
+
26
+
27
+ def reporting_suspended() -> bool:
28
+ return _reporting_suspended
29
+
30
+
31
+ def registry_key(nodeid: str) -> str:
32
+ return f"pytest:{nodeid}"
33
+
34
+
35
+ def note_attempt(key: str) -> int:
36
+ n = _attempts.get(key, -1) + 1
37
+ _attempts[key] = n
38
+ return n
39
+
40
+
41
+ def register_pending_blob(pending_id: str, blob: dict[str, Any]) -> None:
42
+ # Path wins: drop in-memory data when a path is present (memory hygiene).
43
+ path = blob.get("path")
44
+ if isinstance(path, str) and path.strip():
45
+ _pending_blobs[pending_id] = {
46
+ "fileName": blob.get("fileName") or "attachment.bin",
47
+ "mimeType": blob.get("mimeType") or "application/octet-stream",
48
+ "path": path,
49
+ }
50
+ return
51
+ _pending_blobs[pending_id] = blob
52
+
53
+
54
+ def take_pending_blob(pending_id: str) -> dict[str, Any] | None:
55
+ return _pending_blobs.pop(pending_id, None)
56
+
57
+
58
+ def clear_pending_blobs() -> None:
59
+ _pending_blobs.clear()
60
+
61
+
62
+ def clear_state() -> None:
63
+ _pending_blobs.clear()
64
+ _attempts.clear()
65
+ set_current_key(None)
66
+ set_reporting_suspended(False)