tracera-robot 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,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: tracera-robot
3
+ Version: 0.1.0rc1
4
+ Summary: Tracera Autotest adapter for Robot Framework
5
+ Project-URL: Homepage, https://tracera.dev/docs/robot
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,robotframework,test-reporting,testops,tracera
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: tracera-core==0.1.0rc1
13
+ Provides-Extra: dev
14
+ Requires-Dist: robotframework>=7; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # tracera-robot
18
+
19
+ Tracera Autotest adapter for Robot Framework (listener).
20
+
21
+ **Requires Python ≥ 3.11 and Robot Framework ≥ 7.**
22
+
23
+ ```bash
24
+ pip install tracera-robot
25
+ ```
26
+
27
+ How-to: [Robot Framework](https://tracera.dev/docs/robot). Shared env / run mode: [Autotest adapters](https://tracera.dev/docs/adapters).
28
+
29
+ ## Setup
30
+
31
+ ```bash
32
+ robot --listener tracera_robot.listener.TraceraListener suite.robot
33
+ ```
34
+
35
+ Optional companion library for comment / error / attachment / explicit step:
36
+
37
+ ```robot
38
+ *** Settings ***
39
+ Library tracera_robot.keywords.TraceraKeywords
40
+ ```
41
+
42
+ ## Bind
43
+
44
+ Tags `tracera.id:…` / optional literal `tracera.name:…`. If `tracera.name` is omitted → Robot test name. Tags do not support `{param}` templates. Imperative `Tracera Test Case` via the companion library is also available.
45
+
46
+ ## Skip
47
+
48
+ Stock Robot `Skip`. The reason becomes a result `comment`.
49
+
50
+ ## Steps
51
+
52
+ User and library keywords → **Test** steps (nesting follows keyword calls). FOR / IF / WHILE / TRY are reported. **Test** Setup / Teardown → **Setup** / **Teardown**; Suite Setup / Teardown are not reported.
53
+
54
+ By default the step title is the keyword name. For a different title, use the companion library keyword `Tracera Step` (custom title, then the keyword to run). Python helpers can also use `tracera.step` or `@tracera.step("…")` from core.
55
+
56
+ ## Comments, errors, and attachments
57
+
58
+ Use `TraceraKeywords` (`Tracera Comment`, `Tracera Error`, `Tracera Attachment`) — see [Autotest adapters](https://tracera.dev/docs/adapters).
59
+
60
+ ## Parameterized
61
+
62
+ Stock `[Template]` — same `tracera.id` for every argument line under the template. Different id per row → one Robot test per row.
63
+
64
+ **Pabot:** workers share one run automatically — nothing extra to set.
@@ -0,0 +1,48 @@
1
+ # tracera-robot
2
+
3
+ Tracera Autotest adapter for Robot Framework (listener).
4
+
5
+ **Requires Python ≥ 3.11 and Robot Framework ≥ 7.**
6
+
7
+ ```bash
8
+ pip install tracera-robot
9
+ ```
10
+
11
+ How-to: [Robot Framework](https://tracera.dev/docs/robot). Shared env / run mode: [Autotest adapters](https://tracera.dev/docs/adapters).
12
+
13
+ ## Setup
14
+
15
+ ```bash
16
+ robot --listener tracera_robot.listener.TraceraListener suite.robot
17
+ ```
18
+
19
+ Optional companion library for comment / error / attachment / explicit step:
20
+
21
+ ```robot
22
+ *** Settings ***
23
+ Library tracera_robot.keywords.TraceraKeywords
24
+ ```
25
+
26
+ ## Bind
27
+
28
+ Tags `tracera.id:…` / optional literal `tracera.name:…`. If `tracera.name` is omitted → Robot test name. Tags do not support `{param}` templates. Imperative `Tracera Test Case` via the companion library is also available.
29
+
30
+ ## Skip
31
+
32
+ Stock Robot `Skip`. The reason becomes a result `comment`.
33
+
34
+ ## Steps
35
+
36
+ User and library keywords → **Test** steps (nesting follows keyword calls). FOR / IF / WHILE / TRY are reported. **Test** Setup / Teardown → **Setup** / **Teardown**; Suite Setup / Teardown are not reported.
37
+
38
+ By default the step title is the keyword name. For a different title, use the companion library keyword `Tracera Step` (custom title, then the keyword to run). Python helpers can also use `tracera.step` or `@tracera.step("…")` from core.
39
+
40
+ ## Comments, errors, and attachments
41
+
42
+ Use `TraceraKeywords` (`Tracera Comment`, `Tracera Error`, `Tracera Attachment`) — see [Autotest adapters](https://tracera.dev/docs/adapters).
43
+
44
+ ## Parameterized
45
+
46
+ Stock `[Template]` — same `tracera.id` for every argument line under the template. Different id per row → one Robot test per row.
47
+
48
+ **Pabot:** workers share one run automatically — nothing extra to set.
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "tracera-robot"
3
+ version = "0.1.0rc1"
4
+ description = "Tracera Autotest adapter for Robot Framework"
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", "robotframework", "test-reporting", "testops"]
10
+ dependencies = [
11
+ "tracera-core==0.1.0rc1",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["robotframework>=7"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://tracera.dev/docs/robot"
19
+ Bug-Tracker = "https://github.com/tracera-dev/tracera-releases/issues"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/tracera_robot"]
27
+
28
+ [tool.hatch.build.targets.wheel.sources]
29
+ "src/tracera_robot" = "tracera_robot"
30
+
31
+ # Publish surface: runtime package + docs only (no tests / harness / gitignore).
32
+ [tool.hatch.build.targets.sdist]
33
+ only-include = [
34
+ "src/tracera_robot",
35
+ "LICENSE.md",
36
+ "README.md",
37
+ ]
@@ -0,0 +1,7 @@
1
+ """Tracera Autotest adapter for Robot Framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tracera_robot.runtime import tracera
6
+
7
+ __all__ = ["tracera"]
@@ -0,0 +1,60 @@
1
+ """Robot Framework library for imperative Tracera APIs.
2
+
3
+ Companion keywords are not auto-mapped as steps (PRODUCT) — they would duplicate
4
+ explicit ``tracera.step`` / side-effect APIs. Prefer tags for bind; user /
5
+ library/builtin keywords and FOR/IF/WHILE/TRY (listener API 3 control hooks)
6
+ build the step tree; use this library for comment / error / attachment and
7
+ optional ``tracera.test_case`` / ``tracera.step``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from tracera_robot.runtime import tracera
16
+
17
+
18
+ class TraceraKeywords:
19
+ """Companion keywords — comment / attach / step; not auto-mapped into the tree."""
20
+
21
+ ROBOT_LIBRARY_SCOPE = "GLOBAL"
22
+
23
+ def tracera_test_case(self, test_case_id: int | str, name: str | None = None) -> None:
24
+ tracera.test_case(int(test_case_id), name)
25
+
26
+ def tracera_comment(self, text: str) -> None:
27
+ tracera.comment(str(text))
28
+
29
+ def tracera_error(self, text: str) -> None:
30
+ tracera.error(str(text))
31
+
32
+ def tracera_attach_file(
33
+ self,
34
+ path: str,
35
+ mime_type: str = "application/octet-stream",
36
+ file_name: str | None = None,
37
+ ) -> None:
38
+ p = Path(path)
39
+ tracera.attachment(
40
+ file_name=file_name or p.name,
41
+ mime_type=mime_type,
42
+ path=str(p),
43
+ )
44
+
45
+ def tracera_attach_text(self, file_name: str, content: str) -> None:
46
+ tracera.attachment(
47
+ file_name=file_name,
48
+ mime_type="text/plain",
49
+ data=content.encode("utf-8"),
50
+ )
51
+
52
+ def tracera_step(self, title: str, keyword: str, *args: object) -> object:
53
+ """Run a Robot keyword inside an explicit ``tracera.step`` (custom title)."""
54
+ from robot.libraries.BuiltIn import BuiltIn
55
+
56
+ def body() -> object:
57
+ # RF stubs type run_keyword narrowly; keyword name + args are dynamic at runtime.
58
+ return BuiltIn().run_keyword(cast(Any, keyword), *cast(tuple[Any, ...], args))
59
+
60
+ return tracera.step(str(title), body)
@@ -0,0 +1,374 @@
1
+ """Robot Framework listener — bind via tags, auto keyword / control-structure steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from contextlib import AbstractContextManager
8
+ from typing import Any, ClassVar, Literal
9
+ from urllib.parse import unquote
10
+
11
+ from tracera import (
12
+ run_with_context,
13
+ )
14
+ from tracera.buffer import PendingResult
15
+ from tracera.context import (
16
+ StepNode,
17
+ TestContext,
18
+ create_test_context,
19
+ empty_pm_doc,
20
+ get_context,
21
+ record_failure,
22
+ record_skip,
23
+ set_phase,
24
+ snapshot_payload,
25
+ )
26
+ from tracera.env_extras import build_env_vars_extra
27
+ from tracera.parallel import sync_pabot_env_from_robot
28
+ from tracera.pending import (
29
+ discard_pending_blobs,
30
+ pending_blobs_for_payload,
31
+ resolve_pending_attachments,
32
+ )
33
+ from tracera.session import (
34
+ ReporterState,
35
+ begin_reporting,
36
+ create_reporter_state,
37
+ duration_minutes_from_ms,
38
+ end_reporting,
39
+ )
40
+ from tracera_robot import runtime as rt
41
+
42
+ _ID_TAG = re.compile(r"^tracera\.id:(\d+)$", re.I)
43
+ _NAME_TAG = re.compile(r"^tracera\.name:(.+)$", re.I)
44
+
45
+ Status = Literal["Passed", "Failed", "Skipped"]
46
+
47
+
48
+ class TraceraListener:
49
+ """Register with ``--listener tracera_robot.listener.TraceraListener``."""
50
+
51
+ ROBOT_LISTENER_API_VERSION: ClassVar[int] = 3
52
+
53
+ def __init__(self) -> None:
54
+ self._reporter: ReporterState = create_reporter_state()
55
+ self._ctx: TestContext | None = None
56
+ self._token: AbstractContextManager[TestContext] | None = None
57
+ self._started: float = 0.0
58
+ self._status: Status = "Passed"
59
+ self._open_steps: int = 0
60
+
61
+ def start_suite(self, data: Any, result: Any) -> None: # noqa: ARG002
62
+ if getattr(data, "parent", None) is None:
63
+ rt.clear_pending()
64
+ # Pabot injects pool/queue as Robot variables — promote before handoff detect.
65
+ sync_pabot_env_from_robot()
66
+ begin_reporting(self._reporter)
67
+
68
+ def end_suite(self, data: Any, result: Any) -> None: # noqa: ARG002
69
+ if getattr(data, "parent", None) is None:
70
+ end_reporting(self._reporter)
71
+
72
+ def start_test(self, data: Any, result: Any) -> None:
73
+ # RF listener API 3: `data.tags` keep unresolved `${VAR}`; resolved tags are on `result`.
74
+ raw_tags = getattr(result, "tags", None) or getattr(data, "tags", None) or []
75
+ case_id, name = _parse_tags(list(raw_tags))
76
+ self._ctx = create_test_context(
77
+ test_case_id=case_id,
78
+ autotest_name=(name or getattr(data, "name", "") or "")[:128],
79
+ )
80
+ rt.set_listener_context(self._ctx)
81
+ self._token = run_with_context(self._ctx)
82
+ self._token.__enter__()
83
+ self._started = time.time()
84
+ self._status = "Passed"
85
+ self._open_steps = 0
86
+ set_phase("test")
87
+
88
+ def end_test(self, data: Any, result: Any) -> None: # noqa: ARG002
89
+ if self._ctx is None:
90
+ return
91
+ status = str(getattr(result, "status", "") or "").upper()
92
+ if status == "SKIP":
93
+ self._status = "Skipped"
94
+ record_skip(self._ctx, getattr(result, "message", None) or None)
95
+ elif status != "PASS":
96
+ self._status = "Failed"
97
+ msg = getattr(result, "message", None)
98
+ if msg:
99
+ payload = snapshot_payload(self._ctx)
100
+ has_step_err = any(
101
+ isinstance(s, dict)
102
+ and isinstance(s.get("errorMessage"), str)
103
+ and s["errorMessage"].strip()
104
+ for s in payload["setup"] + payload["test"] + payload["teardown"]
105
+ )
106
+ if not has_step_err:
107
+ record_failure(self._ctx, msg)
108
+ self._enqueue_current()
109
+ if self._token is not None:
110
+ try:
111
+ self._token.__exit__(None, None, None)
112
+ except Exception:
113
+ pass
114
+ rt.set_listener_context(None)
115
+ self._ctx = None
116
+ self._token = None
117
+
118
+ def start_keyword(self, data: Any, result: Any) -> None:
119
+ if self._ctx is None:
120
+ return
121
+ kw_type = str(getattr(data, "type", "") or "").upper()
122
+ if kw_type in ("SETUP", "SUITE SETUP", "TEST SETUP"):
123
+ set_phase("setup")
124
+ return
125
+ if kw_type in ("TEARDOWN", "SUITE TEARDOWN", "TEST TEARDOWN"):
126
+ set_phase("teardown")
127
+ return
128
+ if not _should_report_keyword(data, result):
129
+ return
130
+ self._begin_reported_step(getattr(data, "name", None) or "keyword")
131
+
132
+ def end_keyword(self, data: Any, result: Any) -> None:
133
+ if self._ctx is None:
134
+ return
135
+ kw_type = str(getattr(data, "type", "") or "").upper()
136
+ if kw_type in ("SETUP", "SUITE SETUP", "TEST SETUP"):
137
+ set_phase("test")
138
+ return
139
+ if kw_type in ("TEARDOWN", "SUITE TEARDOWN", "TEST TEARDOWN"):
140
+ set_phase("test")
141
+ return
142
+ if not _should_report_keyword(data, result):
143
+ return
144
+ self._end_reported_step(result)
145
+
146
+ # --- RF listener API 3: control structures are not start_keyword -------------
147
+
148
+ def start_for(self, data: Any, result: Any) -> None: # noqa: ARG002
149
+ self._begin_reported_step(_control_step_title(data))
150
+
151
+ def end_for(self, data: Any, result: Any) -> None: # noqa: ARG002
152
+ self._end_reported_step(result)
153
+
154
+ def start_while(self, data: Any, result: Any) -> None: # noqa: ARG002
155
+ self._begin_reported_step(_control_step_title(data))
156
+
157
+ def end_while(self, data: Any, result: Any) -> None: # noqa: ARG002
158
+ self._end_reported_step(result)
159
+
160
+ def start_if_branch(self, data: Any, result: Any) -> None: # noqa: ARG002
161
+ self._begin_reported_step(_control_step_title(data))
162
+
163
+ def end_if_branch(self, data: Any, result: Any) -> None: # noqa: ARG002
164
+ self._end_reported_step(result)
165
+
166
+ def start_try_branch(self, data: Any, result: Any) -> None: # noqa: ARG002
167
+ self._begin_reported_step(_control_step_title(data))
168
+
169
+ def end_try_branch(self, data: Any, result: Any) -> None: # noqa: ARG002
170
+ self._end_reported_step(result)
171
+
172
+ # start_for_iteration / start_while_iteration / start_if / start_try omitted on purpose
173
+ # (ITERATION + IF/TRY roots — body nests under FOR/WHILE / IF|TRY branches).
174
+
175
+ def _begin_reported_step(self, title: str) -> None:
176
+ if self._ctx is None:
177
+ return
178
+ _push_step(title)
179
+ self._open_steps += 1
180
+
181
+ def _end_reported_step(self, result: Any) -> None:
182
+ if self._ctx is None or self._open_steps <= 0:
183
+ return
184
+ failed = str(getattr(result, "status", "") or "").upper() == "FAIL"
185
+ msg = getattr(result, "message", None) if failed else None
186
+ _pop_step(failed=failed, message=msg)
187
+ self._open_steps -= 1
188
+
189
+ def _enqueue_current(self) -> None:
190
+ if self._ctx is None:
191
+ return
192
+ if (
193
+ self._reporter.soft_disabled
194
+ or not self._reporter.session
195
+ or not self._reporter.buffer
196
+ or not self._reporter.upload_pool
197
+ ):
198
+ payload = snapshot_payload(self._ctx)
199
+ discard_pending_blobs(payload, take_blob=rt.take_pending)
200
+ rt.clear_pending()
201
+ return
202
+ payload = snapshot_payload(self._ctx)
203
+ env_vars_extra = build_env_vars_extra(
204
+ package_ids=["robotframework"],
205
+ version_key="ROBOT_VERSION",
206
+ )
207
+ duration_minutes = duration_minutes_from_ms((time.time() - self._started) * 1000)
208
+ session_ref = self._reporter.session
209
+ buffer = self._reporter.buffer
210
+ pending = pending_blobs_for_payload(rt.all_pending(), payload)
211
+ ctx = self._ctx
212
+ status = self._status
213
+
214
+ def job() -> None:
215
+ if not session_ref.reporting_enabled:
216
+ discard_pending_blobs(
217
+ payload,
218
+ take_blob=lambda aid: pending.pop(aid, None) or rt.take_pending(aid),
219
+ )
220
+ return
221
+ errs = resolve_pending_attachments(
222
+ session_ref.client,
223
+ payload,
224
+ resolve_blob=lambda aid: pending.get(aid) or rt.take_pending(aid),
225
+ )
226
+ if errs:
227
+ session_ref.set_last_error(errs[0])
228
+ if session_ref.config.require_report:
229
+ session_ref.note_failed_report()
230
+ session_ref.note_unsent_results()
231
+ return
232
+ buffer.enqueue(
233
+ PendingResult(
234
+ ctx=ctx,
235
+ status=status, # type: ignore[arg-type]
236
+ duration_minutes=duration_minutes,
237
+ payload=payload,
238
+ env_vars_extra=env_vars_extra,
239
+ )
240
+ )
241
+
242
+ self._reporter.upload_pool.enqueue(job)
243
+
244
+
245
+ def _push_step(title: str) -> None:
246
+ ctx = get_context()
247
+ if ctx is None:
248
+ return
249
+ node = StepNode(
250
+ id=ctx._next_step_id,
251
+ parent_id=ctx._step_stack[-1].id if ctx._step_stack else None,
252
+ result="NotSet",
253
+ action=empty_pm_doc(title),
254
+ expectation={"type": "doc", "content": [{"type": "paragraph"}]},
255
+ attachments=[],
256
+ error_message=None,
257
+ comment=None,
258
+ )
259
+ ctx._next_step_id += 1
260
+ parent = ctx._step_stack[-1] if ctx._step_stack else None
261
+ if parent:
262
+ parent.children.append(node)
263
+ else:
264
+ ctx._roots[ctx.section].append(node)
265
+ ctx._step_stack.append(node)
266
+
267
+
268
+ def _pop_step(*, failed: bool, message: str | None) -> None:
269
+ from tracera.context import append_text, format_failure_text
270
+
271
+ ctx = get_context()
272
+ if ctx is None or not ctx._step_stack:
273
+ return
274
+ node = ctx._step_stack.pop()
275
+ if failed:
276
+ node.result = "Failed"
277
+ if message:
278
+ node.error_message = append_text(node.error_message, format_failure_text(message))
279
+ elif node.result == "NotSet":
280
+ node.result = "Passed"
281
+
282
+
283
+ # Control structures use dedicated API 3 hooks (not start_keyword). Still skip if
284
+ # a type ever leaks through the keyword path.
285
+ _SKIP_KEYWORD_TYPES = frozenset(
286
+ {
287
+ "FOR",
288
+ "WHILE",
289
+ "IF",
290
+ "ELSE IF",
291
+ "ELSE",
292
+ "TRY",
293
+ "EXCEPT",
294
+ "FINALLY",
295
+ "ITERATION",
296
+ "IF/ELSE ROOT",
297
+ "TRY/EXCEPT ROOT",
298
+ "GROUP",
299
+ "VAR",
300
+ "BREAK",
301
+ "CONTINUE",
302
+ "RETURN",
303
+ "ERROR",
304
+ }
305
+ )
306
+
307
+
308
+ def _control_step_title(data: Any) -> str:
309
+ """Stable step title for FOR / WHILE / IF|TRY branches (RF log-like)."""
310
+ kw_type = str(getattr(data, "type", "") or "").upper()
311
+ if kw_type == "FOR":
312
+ assign = tuple(str(a) for a in (getattr(data, "assign", None) or ()))
313
+ flavor = str(getattr(data, "flavor", None) or "IN")
314
+ values = tuple(str(v) for v in (getattr(data, "values", None) or ()))
315
+ return " ".join(("FOR", *assign, flavor, *values))
316
+ if kw_type == "WHILE":
317
+ cond = getattr(data, "condition", None)
318
+ return f"WHILE {cond}" if cond not in (None, "") else "WHILE"
319
+ if kw_type in ("IF", "ELSE IF"):
320
+ cond = getattr(data, "condition", None)
321
+ return f"{kw_type} {cond}" if cond not in (None, "") else kw_type
322
+ if kw_type == "EXCEPT":
323
+ patterns = tuple(str(p) for p in (getattr(data, "patterns", None) or ()))
324
+ return "EXCEPT " + " ".join(patterns) if patterns else "EXCEPT"
325
+ if kw_type in ("ELSE", "TRY", "FINALLY"):
326
+ return kw_type
327
+ return str(getattr(data, "name", None) or kw_type or "keyword")
328
+
329
+
330
+ def _is_tracera_companion_lib(lib: str) -> bool:
331
+ """Companion `TraceraKeywords` — comment/attach/step APIs, not tree content."""
332
+ low = lib.lower().replace("\\", "/")
333
+ return "tracerakeywords" in low.replace("_", "") or "tracera_robot.keywords" in low
334
+
335
+
336
+ def _should_report_keyword(data: Any, result: Any | None = None) -> bool:
337
+ kw_type = str(getattr(data, "type", "") or "").upper()
338
+ if kw_type in _SKIP_KEYWORD_TYPES:
339
+ return False
340
+ # RF listener API 3: library ownership lives on `result`, not `data`.
341
+ lib = ""
342
+ if result is not None:
343
+ lib = getattr(result, "owner", None) or getattr(result, "libname", None) or ""
344
+ if not lib:
345
+ lib = getattr(data, "owner", None) or getattr(data, "libname", None) or ""
346
+ if isinstance(lib, str) and lib and _is_tracera_companion_lib(lib):
347
+ # Skip companion APIs (would double-nest under explicit `Tracera Step`).
348
+ return False
349
+ name = getattr(data, "name", "") or ""
350
+ if name.startswith("${") or name.startswith("@{"):
351
+ return False
352
+ # BuiltIn Skip / Fail — status/error land on the parent keyword or result; not tree leaves
353
+ # (Skipped UI = empty Steps; Failed UI = user keyword only with the fail text).
354
+ lib_l = str(lib).lower() if isinstance(lib, str) else ""
355
+ if str(name).strip().lower() in ("skip", "fail") and (not lib_l or lib_l == "builtin"):
356
+ return False
357
+ return True
358
+
359
+
360
+ def _parse_tags(tags: list[Any]) -> tuple[int | None, str | None]:
361
+ case_id: int | None = None
362
+ name: str | None = None
363
+ for raw in tags:
364
+ t = str(raw).strip()
365
+ m = _ID_TAG.match(t)
366
+ if m:
367
+ n = int(m.group(1))
368
+ if n > 0:
369
+ case_id = n
370
+ continue
371
+ m = _NAME_TAG.match(t)
372
+ if m:
373
+ name = unquote(m.group(1))[:128]
374
+ return case_id, name
@@ -0,0 +1,143 @@
1
+ """Companion API for Robot keywords (imperative bind + steps)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any, Callable, TypeVar, overload
8
+
9
+ from tracera import get_context, run_with_context
10
+ from tracera.context import AttachmentMeta, StepContext, TestContext
11
+ from tracera.runtime import create_tracera_runtime
12
+
13
+ T = TypeVar("T")
14
+ _base = create_tracera_runtime()
15
+ _pending: dict[str, dict[str, Any]] = {}
16
+ # Listener holds the active test. Under Pabot, ContextVar can be empty when
17
+ # companion library keywords run — fall back to this pin.
18
+ _listener_ctx: TestContext | None = None
19
+
20
+
21
+ def set_listener_context(ctx: TestContext | None) -> None:
22
+ global _listener_ctx
23
+ _listener_ctx = ctx
24
+
25
+
26
+ def _pinned_context() -> TestContext | None:
27
+ return get_context() or _listener_ctx
28
+
29
+
30
+ def take_pending(ref: str) -> dict[str, Any] | None:
31
+ return _pending.pop(ref, None)
32
+
33
+
34
+ def all_pending() -> dict[str, dict[str, Any]]:
35
+ return dict(_pending)
36
+
37
+
38
+ def clear_pending() -> None:
39
+ _pending.clear()
40
+
41
+
42
+ def test_case(test_case_id: int, autotest_name: str | None = None) -> None:
43
+ """Imperative bind to a Tracera test case (optional result name). Prefer ``tracera.id:`` tags."""
44
+ ctx = _pinned_context()
45
+ if ctx is None:
46
+ _base.test_case(test_case_id, autotest_name)
47
+ return
48
+ with run_with_context(ctx):
49
+ _base.test_case(test_case_id, autotest_name)
50
+
51
+
52
+ @overload
53
+ def step(title: str, body: Callable[[], T]) -> T: ...
54
+
55
+
56
+ @overload
57
+ def step(title: str, body: None = None) -> StepContext: ...
58
+
59
+
60
+ @overload
61
+ def step(title: Callable[..., T]) -> Callable[..., T]: ...
62
+
63
+
64
+ def step(
65
+ title: str | Callable[..., T], body: Callable[[], T] | None = None
66
+ ) -> T | StepContext | Callable[..., T]:
67
+ """Record a step — call / decorator / ``with`` / ``async with`` (same forms as ``tracera.step``)."""
68
+ ctx = _pinned_context()
69
+ if ctx is None:
70
+ return _base.step(title, body) # type: ignore[arg-type,return-value]
71
+ with run_with_context(ctx):
72
+ return _base.step(title, body) # type: ignore[arg-type,return-value]
73
+
74
+
75
+ def comment(text: str) -> None:
76
+ """Comment on the open step, or on the result when no step is open."""
77
+ ctx = _pinned_context()
78
+ if ctx is None:
79
+ return
80
+ with run_with_context(ctx):
81
+ _base.comment(text)
82
+
83
+
84
+ def error(text: str) -> None:
85
+ """Soft error text on the open step (or result). Does not by itself fail the test."""
86
+ ctx = _pinned_context()
87
+ if ctx is None:
88
+ return
89
+ with run_with_context(ctx):
90
+ _base.error(text)
91
+
92
+
93
+ def attachment(
94
+ meta: AttachmentMeta | dict[str, Any] | None = None,
95
+ *,
96
+ file_name: str | None = None,
97
+ mime_type: str | None = None,
98
+ data: bytes | str | None = None,
99
+ path: str | None = None,
100
+ ) -> None:
101
+ """Attach a file (pending upload) or an already-uploaded AttachmentMeta."""
102
+ ctx = _pinned_context()
103
+
104
+ def _do() -> None:
105
+ if isinstance(meta, AttachmentMeta) or (
106
+ isinstance(meta, dict) and "attachmentId" in meta and data is None and path is None
107
+ ):
108
+ _base.attachment(meta) # type: ignore[arg-type]
109
+ return
110
+ fname = file_name or (Path(path).name if path else "attachment.bin")
111
+ mime = mime_type or "application/octet-stream"
112
+ blob_data = data.encode("utf-8") if isinstance(data, str) else data
113
+ pending_id = f"pending:{uuid.uuid4()}"
114
+ stored: dict[str, Any] = {
115
+ "fileName": fname,
116
+ "mimeType": mime,
117
+ }
118
+ if path:
119
+ stored["path"] = path
120
+ else:
121
+ stored["data"] = blob_data
122
+ _pending[pending_id] = stored
123
+ _base.attachment({"attachmentId": pending_id, "fileName": fname, "mimeType": mime})
124
+
125
+ if ctx is not None:
126
+ with run_with_context(ctx):
127
+ _do()
128
+ else:
129
+ _do()
130
+
131
+
132
+ class _TraceraApi:
133
+ test_case = staticmethod(test_case)
134
+ step = staticmethod(step)
135
+ comment = staticmethod(comment)
136
+ error = staticmethod(error)
137
+ attachment = staticmethod(attachment)
138
+ reserve_attachment = staticmethod(_base.reserve_attachment)
139
+ get_context = staticmethod(get_context)
140
+ run_with_context = staticmethod(run_with_context)
141
+
142
+
143
+ tracera = _TraceraApi()