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.
- tracera_pytest-0.1.0rc1/.gitignore +1 -0
- tracera_pytest-0.1.0rc1/LICENSE.md +23 -0
- tracera_pytest-0.1.0rc1/PKG-INFO +62 -0
- tracera_pytest-0.1.0rc1/README.md +46 -0
- tracera_pytest-0.1.0rc1/pyproject.toml +40 -0
- tracera_pytest-0.1.0rc1/src/tracera_pytest/__init__.py +8 -0
- tracera_pytest-0.1.0rc1/src/tracera_pytest/annotations.py +29 -0
- tracera_pytest-0.1.0rc1/src/tracera_pytest/plugin.py +353 -0
- tracera_pytest-0.1.0rc1/src/tracera_pytest/runtime.py +250 -0
- tracera_pytest-0.1.0rc1/src/tracera_pytest/state.py +66 -0
|
@@ -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,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)
|