tracera-core 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,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: tracera-core
3
+ Version: 0.1.0rc1
4
+ Summary: Tracera Autotest adapter core (HTTP + steps + result upload)
5
+ Project-URL: Homepage, https://tracera.dev/docs/adapters
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: adapter,autotest,reporting,test-reporting,testops,tracera
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: httpx>=0.27
13
+ Description-Content-Type: text/markdown
14
+
15
+ # tracera-core
16
+
17
+ Shared Autotest HTTP client, step context, and result upload for Tracera adapters.
18
+
19
+ **Requires Python ≥ 3.11.**
20
+
21
+ ```bash
22
+ pip install tracera-core
23
+ ```
24
+
25
+ Import package name is **`tracera`**.
26
+
27
+ Foreign thread: capture `get_context()` on the test thread, then wrap the worker in `with run_with_context(ctx):`. Without a pin, steps and comments on that thread are dropped.
28
+
29
+ **Several test processes** (e.g. Pabot, parallel Behave): they share one run automatically — the last process to finish closes it. You do not create the run by hand.
30
+
31
+ ```python
32
+ from tracera import step
33
+
34
+ step("title", lambda: None)
35
+
36
+
37
+ @step("open {path}")
38
+ def open_login(path: str): ...
39
+
40
+
41
+ with step("title"):
42
+ ...
43
+
44
+ async with step("title"):
45
+ ...
46
+ ```
47
+
48
+ Used by the official Python adapters. Customer how-to: [Autotest adapters](https://tracera.dev/docs/adapters). Prefer a framework package unless you write a custom runner integration.
49
+
50
+ Plugin / runner helpers (`begin_reporting`, `create_test_context`, …) live in submodules (`tracera.session`, `tracera.context`, …) — not on the `tracera` package root.
@@ -0,0 +1,36 @@
1
+ # tracera-core
2
+
3
+ Shared Autotest HTTP client, step context, and result upload for Tracera adapters.
4
+
5
+ **Requires Python ≥ 3.11.**
6
+
7
+ ```bash
8
+ pip install tracera-core
9
+ ```
10
+
11
+ Import package name is **`tracera`**.
12
+
13
+ Foreign thread: capture `get_context()` on the test thread, then wrap the worker in `with run_with_context(ctx):`. Without a pin, steps and comments on that thread are dropped.
14
+
15
+ **Several test processes** (e.g. Pabot, parallel Behave): they share one run automatically — the last process to finish closes it. You do not create the run by hand.
16
+
17
+ ```python
18
+ from tracera import step
19
+
20
+ step("title", lambda: None)
21
+
22
+
23
+ @step("open {path}")
24
+ def open_login(path: str): ...
25
+
26
+
27
+ with step("title"):
28
+ ...
29
+
30
+ async with step("title"):
31
+ ...
32
+ ```
33
+
34
+ Used by the official Python adapters. Customer how-to: [Autotest adapters](https://tracera.dev/docs/adapters). Prefer a framework package unless you write a custom runner integration.
35
+
36
+ Plugin / runner helpers (`begin_reporting`, `create_test_context`, …) live in submodules (`tracera.session`, `tracera.context`, …) — not on the `tracera` package root.
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "tracera-core"
3
+ version = "0.1.0rc1"
4
+ description = "Tracera Autotest adapter core (HTTP + steps + result upload)"
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", "test-reporting", "testops", "adapter", "reporting"]
10
+ dependencies = [
11
+ "httpx>=0.27",
12
+ ]
13
+
14
+ [project.urls]
15
+ Homepage = "https://tracera.dev/docs/adapters"
16
+ Bug-Tracker = "https://github.com/tracera-dev/tracera-releases/issues"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ # Distribution: tracera-core. Import package: tracera (mirrors @tracera/core → import from package).
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/tracera"]
25
+
26
+ [tool.hatch.build.targets.wheel.sources]
27
+ "src/tracera" = "tracera"
28
+
29
+ # Publish surface: runtime package + docs only (no tests / harness / gitignore).
30
+ [tool.hatch.build.targets.sdist]
31
+ only-include = [
32
+ "src/tracera",
33
+ "LICENSE.md",
34
+ "README.md",
35
+ ]
@@ -0,0 +1,35 @@
1
+ """Tracera Autotest adapter core — customer DX.
2
+
3
+ Public surface: ``step`` / ``comment`` / ``error`` / ``attachment`` /
4
+ ``get_context`` / ``run_with_context`` (+ types). How-to:
5
+ https://tracera.dev/docs/adapters
6
+
7
+ Plugin / runner helpers stay in submodules (``tracera.session``, ``tracera.context``,
8
+ ``tracera.registry``, ``tracera.runtime``, …) — not on this package root / ``__all__``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from tracera.context import (
14
+ AttachmentMeta,
15
+ TestContext,
16
+ attachment,
17
+ comment,
18
+ error,
19
+ get_context,
20
+ reserve_attachment,
21
+ run_with_context,
22
+ step,
23
+ )
24
+
25
+ __all__ = [
26
+ "AttachmentMeta",
27
+ "TestContext",
28
+ "attachment",
29
+ "comment",
30
+ "error",
31
+ "get_context",
32
+ "reserve_attachment",
33
+ "run_with_context",
34
+ "step",
35
+ ]
@@ -0,0 +1,18 @@
1
+ """Declarative bind helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class TraceraBind:
10
+ test_case_id: int
11
+ autotest_name: str | None = None
12
+
13
+
14
+ def make_bind(test_case_id: int, autotest_name: str | None = None) -> TraceraBind:
15
+ return TraceraBind(
16
+ test_case_id=test_case_id,
17
+ autotest_name=autotest_name[:128] if autotest_name else None,
18
+ )
@@ -0,0 +1,245 @@
1
+ """Result buffer, session, soft-disable after consecutive batch failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Final, Literal
8
+
9
+ from tracera.client import BatchItem, TraceraClient
10
+ from tracera.config import TraceraConfig
11
+ from tracera.context import TestContext, snapshot_payload
12
+ from tracera.env_snapshot import snapshot_env_vars
13
+ from tracera.log import log
14
+
15
+ CONSECUTIVE_BATCH_FAIL_LIMIT: Final[int] = 2
16
+
17
+ Status = Literal["Passed", "Failed", "Skipped"]
18
+
19
+
20
+ @dataclass
21
+ class PendingResult:
22
+ ctx: TestContext
23
+ status: Status
24
+ duration_minutes: float | None
25
+ payload: dict[str, Any] | None = None
26
+ env_vars_extra: dict[str, str] | None = None
27
+
28
+
29
+ @dataclass
30
+ class ReportSession:
31
+ client: TraceraClient
32
+ config: TraceraConfig
33
+ test_run_id: int
34
+ owned_by_adapter: bool
35
+ reporting_enabled: bool = True
36
+ failed_report: int = 0
37
+ unsent_results: int = 0
38
+ last_error: str | None = None
39
+ """When True, finish via peer handoff refcount (Pabot / Behave / …)."""
40
+ peer_handoff: bool = False
41
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
42
+
43
+ def note_failed_report(self, n: int = 1) -> None:
44
+ with self._lock:
45
+ self.failed_report += n
46
+
47
+ def note_unsent_results(self, n: int = 1) -> None:
48
+ with self._lock:
49
+ self.unsent_results += n
50
+
51
+ def clear_one_failed_report(self) -> None:
52
+ with self._lock:
53
+ self.failed_report = max(0, self.failed_report - 1)
54
+
55
+ def disable_reporting(self) -> None:
56
+ with self._lock:
57
+ self.reporting_enabled = False
58
+
59
+ def set_last_error(self, message: str | None) -> None:
60
+ with self._lock:
61
+ self.last_error = message
62
+
63
+
64
+ def _create_session(
65
+ client: TraceraClient,
66
+ config: TraceraConfig,
67
+ test_run_id: int,
68
+ owned_by_adapter: bool,
69
+ *,
70
+ peer_handoff: bool = False,
71
+ ) -> ReportSession:
72
+ return ReportSession(
73
+ client=client,
74
+ config=config,
75
+ test_run_id=test_run_id,
76
+ owned_by_adapter=owned_by_adapter,
77
+ peer_handoff=peer_handoff,
78
+ )
79
+
80
+
81
+ class ResultBuffer:
82
+ def __init__(self, session: ReportSession) -> None:
83
+ self._session = session
84
+ self._pending: list[PendingResult] = []
85
+ self._lock = threading.Lock()
86
+ self._timer: threading.Timer | None = None
87
+ self._flushing = threading.Lock()
88
+ self._transport_failures: dict[int, int] = {}
89
+ self._consecutive_batch_failures = 0
90
+
91
+ def enqueue(self, item: PendingResult) -> None:
92
+ if not self._session.reporting_enabled:
93
+ return
94
+ if item.ctx.test_case_id is None or not isinstance(item.ctx.test_case_id, int):
95
+ return
96
+ with self._lock:
97
+ self._pending.append(item)
98
+ if len(self._pending) >= self._session.config.flush_max_items:
99
+ self._schedule_flush(immediate=True)
100
+ return
101
+ if self._timer is None:
102
+ self._schedule_flush(immediate=False)
103
+
104
+ @property
105
+ def pending_count(self) -> int:
106
+ with self._lock:
107
+ return len(self._pending)
108
+
109
+ def _schedule_flush(self, *, immediate: bool) -> None:
110
+ if self._timer is not None:
111
+ self._timer.cancel()
112
+ self._timer = None
113
+ if immediate:
114
+ threading.Thread(target=self.flush, daemon=True).start()
115
+ return
116
+ delay = self._session.config.flush_max_ms / 1000
117
+ self._timer = threading.Timer(delay, self.flush)
118
+ self._timer.daemon = True
119
+ self._timer.start()
120
+
121
+ def flush(self) -> None:
122
+ with self._flushing:
123
+ self._flush_once()
124
+ if self._session.reporting_enabled and self.pending_count > 0:
125
+ self._flush_once()
126
+
127
+ def _flush_once(self) -> None:
128
+ with self._lock:
129
+ if self._timer is not None:
130
+ self._timer.cancel()
131
+ self._timer = None
132
+ if not self._session.reporting_enabled or not self._pending:
133
+ return
134
+ pending_snapshot = list(self._pending)
135
+ self._pending.clear()
136
+
137
+ retry_later: list[PendingResult] = []
138
+ idx = 0
139
+ while self._session.reporting_enabled and idx < len(pending_snapshot):
140
+ batch = pending_snapshot[idx : idx + self._session.config.flush_max_items]
141
+ idx += len(batch)
142
+ log.debug(f"Tracera: sent {len(batch)} result(s)")
143
+ base_env = snapshot_env_vars()
144
+ items: list[BatchItem] = []
145
+ for p in batch:
146
+ if isinstance(p.payload, dict):
147
+ snap = p.payload
148
+ else:
149
+ snap = snapshot_payload(p.ctx)
150
+ error_message = snap.get("errorMessage")
151
+ if error_message is None:
152
+ error_message = p.ctx.result_error
153
+ comment = snap.get("comment")
154
+ if comment is None:
155
+ comment = p.ctx.result_comment
156
+ env_vars = {**base_env, **p.env_vars_extra} if p.env_vars_extra else base_env
157
+ items.append(
158
+ BatchItem(
159
+ is_automated=True,
160
+ test_case_id=p.ctx.test_case_id or 0,
161
+ test_run_id=self._session.test_run_id,
162
+ result=p.status,
163
+ autotest_name=p.ctx.autotest_name or None,
164
+ duration_minutes=p.duration_minutes,
165
+ setup=snap.get("setup") or [],
166
+ test=snap.get("test") or [],
167
+ teardown=snap.get("teardown") or [],
168
+ attachments=snap.get("attachments") or [],
169
+ error_message=error_message,
170
+ comment=comment,
171
+ env_vars=env_vars,
172
+ )
173
+ )
174
+ try:
175
+ res = self._session.client.post_results_batch(self._session.test_run_id, items)
176
+ self._consecutive_batch_failures = 0
177
+ for p in batch:
178
+ self._note_transport_success(p)
179
+ for r in res.results:
180
+ if r.get("ok"):
181
+ continue
182
+ self._session.note_failed_report()
183
+ self._session.set_last_error(r.get("error") or "item_failed")
184
+ ridx = int(r.get("index", -1))
185
+ test_case_id = items[ridx].test_case_id if 0 <= ridx < len(items) else None
186
+ case_part = (
187
+ f"testCaseId={test_case_id}"
188
+ if test_case_id is not None
189
+ else f"batch index {ridx}"
190
+ )
191
+ log.error(
192
+ f"Tracera: server rejected result ({case_part}): {r.get('error') or 'unknown'}"
193
+ )
194
+ except Exception as e:
195
+ self._session.set_last_error(str(e))
196
+ log.error(f"Tracera: {self._session.last_error} — requeued")
197
+ for p in batch:
198
+ self._note_transport_failure(p)
199
+ retry_later.extend(batch)
200
+ self._consecutive_batch_failures += 1
201
+ if self._consecutive_batch_failures >= CONSECUTIVE_BATCH_FAIL_LIMIT:
202
+ self._session.disable_reporting()
203
+ log.error(
204
+ f"Tracera: stopped reporting after {CONSECUTIVE_BATCH_FAIL_LIMIT} "
205
+ "failed batch uploads"
206
+ )
207
+ break
208
+
209
+ # Soft-disable mid-flush: never-attempted snapshot batches must stay
210
+ # buffered (count as not uploaded) instead of vanishing.
211
+ if idx < len(pending_snapshot):
212
+ retry_later.extend(pending_snapshot[idx:])
213
+ if retry_later:
214
+ with self._lock:
215
+ self._pending = retry_later + self._pending
216
+
217
+ def _note_transport_success(self, item: PendingResult) -> None:
218
+ key = id(item)
219
+ if self._transport_failures.get(key, 0) == 0:
220
+ return
221
+ self._session.clear_one_failed_report()
222
+ self._transport_failures.pop(key, None)
223
+
224
+ def _note_transport_failure(self, item: PendingResult) -> None:
225
+ key = id(item)
226
+ n = self._transport_failures.get(key, 0) + 1
227
+ self._transport_failures[key] = n
228
+ if n == 1:
229
+ self._session.note_failed_report()
230
+
231
+
232
+ def _print_summary(session: ReportSession) -> None:
233
+ log.link(session.client.run_url(session.test_run_id))
234
+
235
+
236
+ def finish_owned_run(session: ReportSession) -> None:
237
+ if not session.owned_by_adapter:
238
+ return
239
+ try:
240
+ session.client.finish_run(session.test_run_id)
241
+ except Exception as e:
242
+ session.set_last_error(str(e))
243
+ session.note_failed_report()
244
+ msg = session.last_error or str(e)
245
+ log.error(msg if msg.startswith("Tracera:") else f"Tracera: {msg}")