robotframework-events 0.1.0__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.
Files changed (28) hide show
  1. robotframework_events-0.1.0/LICENSE +21 -0
  2. robotframework_events-0.1.0/PKG-INFO +112 -0
  3. robotframework_events-0.1.0/README.md +77 -0
  4. robotframework_events-0.1.0/pyproject.toml +80 -0
  5. robotframework_events-0.1.0/setup.cfg +4 -0
  6. robotframework_events-0.1.0/src/robotframework_events/__init__.py +30 -0
  7. robotframework_events-0.1.0/src/robotframework_events/__main__.py +5 -0
  8. robotframework_events-0.1.0/src/robotframework_events/cli.py +176 -0
  9. robotframework_events-0.1.0/src/robotframework_events/convert.py +172 -0
  10. robotframework_events-0.1.0/src/robotframework_events/events-v1.schema.json +175 -0
  11. robotframework_events-0.1.0/src/robotframework_events/events.py +248 -0
  12. robotframework_events-0.1.0/src/robotframework_events/listener.py +523 -0
  13. robotframework_events-0.1.0/src/robotframework_events/py.typed +0 -0
  14. robotframework_events-0.1.0/src/robotframework_events/reader.py +118 -0
  15. robotframework_events-0.1.0/src/robotframework_events/state.py +207 -0
  16. robotframework_events-0.1.0/src/robotframework_events.egg-info/PKG-INFO +112 -0
  17. robotframework_events-0.1.0/src/robotframework_events.egg-info/SOURCES.txt +26 -0
  18. robotframework_events-0.1.0/src/robotframework_events.egg-info/dependency_links.txt +1 -0
  19. robotframework_events-0.1.0/src/robotframework_events.egg-info/entry_points.txt +2 -0
  20. robotframework_events-0.1.0/src/robotframework_events.egg-info/requires.txt +7 -0
  21. robotframework_events-0.1.0/src/robotframework_events.egg-info/top_level.txt +1 -0
  22. robotframework_events-0.1.0/tests/test_benchmark.py +46 -0
  23. robotframework_events-0.1.0/tests/test_cli.py +45 -0
  24. robotframework_events-0.1.0/tests/test_conformance.py +150 -0
  25. robotframework_events-0.1.0/tests/test_events.py +236 -0
  26. robotframework_events-0.1.0/tests/test_model.py +79 -0
  27. robotframework_events-0.1.0/tests/test_reader.py +88 -0
  28. robotframework_events-0.1.0/tests/test_state.py +88 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tim de Groot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: robotframework-events
3
+ Version: 0.1.0
4
+ Summary: Robot Framework listener that writes execution events as JSON Lines
5
+ Author: Tim de Groot
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/timdegroot1996/robotframework-events
8
+ Project-URL: Issues, https://github.com/timdegroot1996/robotframework-events/issues
9
+ Project-URL: Changelog, https://github.com/timdegroot1996/robotframework-events/blob/main/CHANGELOG.md
10
+ Keywords: robotframework,listener,events,jsonl,testing
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: Robot Framework
13
+ Classifier: Framework :: Robot Framework :: Tool
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Software Development :: Testing
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: robotframework>=7.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8; extra == "dev"
31
+ Requires-Dist: jsonschema>=4.18; extra == "dev"
32
+ Requires-Dist: ruff>=0.15; extra == "dev"
33
+ Requires-Dist: mypy>=1.15; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # robotframework-events
37
+
38
+ A Robot Framework listener that writes every suite, test, keyword, control structure and log
39
+ message of a run as JSON Lines, while the run happens. Item ids are Robot Framework's own
40
+ (`s1-t2-k3`), so events join with `output.xml` and link into `log.html#<id>`.
41
+
42
+ Requires Robot Framework 7.0 or newer and Python 3.10 or newer. Status: alpha.
43
+
44
+ ```
45
+ pip install robotframework-events
46
+ robot --listener robotframework_events.Listener tests/ # writes ${OUTPUT DIR}/events.jsonl
47
+ robot --listener "robotframework_events.Listener;file=ev.jsonl;level=DEBUG" tests/
48
+ ```
49
+
50
+ Listener arguments: `file` (`NONE` for no file), `level` (minimum message level, default `INFO`),
51
+ `messages`, `args`, `structures` (each `true`/`false`, default `true`) and `max_text` (default 4096).
52
+
53
+ ## Event format
54
+
55
+ One JSON object per line. Every event has `v` (format version), `seq` (1, 2, 3, ...), `t` (Unix
56
+ time) and `type`: `run.started`, `suite.started`, `test.started`, `step.started`,
57
+ `message.logged`, `step.ended`, `test.ended`, `suite.ended`, `status.changed`,
58
+ `resultfile.created`, `run.ended`.
59
+
60
+ ```jsonl
61
+ {"v":1,"seq":6,"t":1791581563.542,"type":"test.started","id":"s1-t1","parent":"s1","name":"Passing","longname":"Sample.Passing","lineno":6}
62
+ {"v":1,"seq":61,"t":1791581563.601,"type":"test.ended","id":"s1-t2","status":"FAIL","message":"1 != 2","elapsed":0.004}
63
+ ```
64
+
65
+ The JSON Schema [`events-v1.schema.json`](src/robotframework_events/events-v1.schema.json) is the
66
+ reference.
67
+
68
+ ## Command line
69
+
70
+ ```
71
+ rfevents tail -f results/events.jsonl # follow a run in the terminal, keyword by keyword
72
+ rfevents tail --steps 2 --level INFO events.jsonl
73
+ rfevents convert output.xml -o events.jsonl # events for a run recorded without the listener
74
+ ```
75
+
76
+ `tail` prints suites, tests, steps down to `--steps` levels (default 1), log messages from
77
+ `--level` (default `WARN`) and a summary. `convert` reads `output.xml` or `output.json`; steps
78
+ then have no source or line number, which result files do not store.
79
+
80
+ ## Python API
81
+
82
+ ```python
83
+ from robotframework_events import RunState, add_callback, convert, read_events
84
+
85
+ state = RunState.from_events(read_events("events.jsonl")) # also .jsonl.gz
86
+ print(state.status, state.counts())
87
+ for event in read_events("events.jsonl", follow=True): # while the run writes it
88
+ ...
89
+
90
+ events = list(convert("output.xml")) # dicts, as the listener writes them
91
+ add_callback(fn) # in-process consumers, e.g. robotcode's debugger
92
+ ```
93
+
94
+ `read_events` gives typed events (`robotframework_events.events`). `RunState` holds the tree of
95
+ suites, tests and steps with status, messages and times, and the running test and step.
96
+
97
+ ## Development
98
+
99
+ ```
100
+ pip install -e .[dev]
101
+ ruff check . && ruff format --check . && mypy
102
+ pytest # format, options, schema, reader, CLI and conformance tests
103
+ pytest -m bench # listener overhead
104
+ pytest -m e2e # through robotcode's debugger (needs ../robotcode)
105
+ ```
106
+
107
+ The conformance tests also run on Robot Framework's acceptance test data, found through
108
+ `RF_ATEST_DIR` or at `../robotframework/atest/testdata`, and are skipped when it is missing.
109
+
110
+ ## Licence
111
+
112
+ MIT
@@ -0,0 +1,77 @@
1
+ # robotframework-events
2
+
3
+ A Robot Framework listener that writes every suite, test, keyword, control structure and log
4
+ message of a run as JSON Lines, while the run happens. Item ids are Robot Framework's own
5
+ (`s1-t2-k3`), so events join with `output.xml` and link into `log.html#<id>`.
6
+
7
+ Requires Robot Framework 7.0 or newer and Python 3.10 or newer. Status: alpha.
8
+
9
+ ```
10
+ pip install robotframework-events
11
+ robot --listener robotframework_events.Listener tests/ # writes ${OUTPUT DIR}/events.jsonl
12
+ robot --listener "robotframework_events.Listener;file=ev.jsonl;level=DEBUG" tests/
13
+ ```
14
+
15
+ Listener arguments: `file` (`NONE` for no file), `level` (minimum message level, default `INFO`),
16
+ `messages`, `args`, `structures` (each `true`/`false`, default `true`) and `max_text` (default 4096).
17
+
18
+ ## Event format
19
+
20
+ One JSON object per line. Every event has `v` (format version), `seq` (1, 2, 3, ...), `t` (Unix
21
+ time) and `type`: `run.started`, `suite.started`, `test.started`, `step.started`,
22
+ `message.logged`, `step.ended`, `test.ended`, `suite.ended`, `status.changed`,
23
+ `resultfile.created`, `run.ended`.
24
+
25
+ ```jsonl
26
+ {"v":1,"seq":6,"t":1791581563.542,"type":"test.started","id":"s1-t1","parent":"s1","name":"Passing","longname":"Sample.Passing","lineno":6}
27
+ {"v":1,"seq":61,"t":1791581563.601,"type":"test.ended","id":"s1-t2","status":"FAIL","message":"1 != 2","elapsed":0.004}
28
+ ```
29
+
30
+ The JSON Schema [`events-v1.schema.json`](src/robotframework_events/events-v1.schema.json) is the
31
+ reference.
32
+
33
+ ## Command line
34
+
35
+ ```
36
+ rfevents tail -f results/events.jsonl # follow a run in the terminal, keyword by keyword
37
+ rfevents tail --steps 2 --level INFO events.jsonl
38
+ rfevents convert output.xml -o events.jsonl # events for a run recorded without the listener
39
+ ```
40
+
41
+ `tail` prints suites, tests, steps down to `--steps` levels (default 1), log messages from
42
+ `--level` (default `WARN`) and a summary. `convert` reads `output.xml` or `output.json`; steps
43
+ then have no source or line number, which result files do not store.
44
+
45
+ ## Python API
46
+
47
+ ```python
48
+ from robotframework_events import RunState, add_callback, convert, read_events
49
+
50
+ state = RunState.from_events(read_events("events.jsonl")) # also .jsonl.gz
51
+ print(state.status, state.counts())
52
+ for event in read_events("events.jsonl", follow=True): # while the run writes it
53
+ ...
54
+
55
+ events = list(convert("output.xml")) # dicts, as the listener writes them
56
+ add_callback(fn) # in-process consumers, e.g. robotcode's debugger
57
+ ```
58
+
59
+ `read_events` gives typed events (`robotframework_events.events`). `RunState` holds the tree of
60
+ suites, tests and steps with status, messages and times, and the running test and step.
61
+
62
+ ## Development
63
+
64
+ ```
65
+ pip install -e .[dev]
66
+ ruff check . && ruff format --check . && mypy
67
+ pytest # format, options, schema, reader, CLI and conformance tests
68
+ pytest -m bench # listener overhead
69
+ pytest -m e2e # through robotcode's debugger (needs ../robotcode)
70
+ ```
71
+
72
+ The conformance tests also run on Robot Framework's acceptance test data, found through
73
+ `RF_ATEST_DIR` or at `../robotframework/atest/testdata`, and are skipped when it is missing.
74
+
75
+ ## Licence
76
+
77
+ MIT
@@ -0,0 +1,80 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "robotframework-events"
7
+ dynamic = ["version"]
8
+ description = "Robot Framework listener that writes execution events as JSON Lines"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Tim de Groot" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = ["robotframework>=7.0"]
15
+ keywords = ["robotframework", "listener", "events", "jsonl", "testing"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Framework :: Robot Framework",
19
+ "Framework :: Robot Framework :: Tool",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Topic :: Software Development :: Testing",
30
+ "Typing :: Typed",
31
+ ]
32
+
33
+ [project.scripts]
34
+ rfevents = "robotframework_events.cli:main"
35
+
36
+ [project.optional-dependencies]
37
+ dev = ["pytest>=8", "jsonschema>=4.18", "ruff>=0.15", "mypy>=1.15"]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/timdegroot1996/robotframework-events"
41
+ Issues = "https://github.com/timdegroot1996/robotframework-events/issues"
42
+ Changelog = "https://github.com/timdegroot1996/robotframework-events/blob/main/CHANGELOG.md"
43
+
44
+ [tool.setuptools.dynamic]
45
+ version = { attr = "robotframework_events.__version__" }
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.setuptools.package-data]
51
+ robotframework_events = ["py.typed", "*.schema.json"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ markers = [
56
+ "bench: overhead benchmark (run with -m bench)",
57
+ "e2e: end-to-end run through robotcode's debugger (run with -m e2e)",
58
+ ]
59
+ addopts = "-m 'not bench and not e2e'"
60
+ filterwarnings = ["ignore::UserWarning:robot.result.modeldeprecation"]
61
+
62
+ [tool.ruff]
63
+ line-length = 120
64
+ target-version = "py310"
65
+ extend-exclude = ["spike", "build", "demo"]
66
+
67
+ [tool.ruff.lint]
68
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
69
+
70
+ [tool.ruff.lint.isort]
71
+ known-first-party = ["robotframework_events", "conftest"]
72
+
73
+ [tool.mypy]
74
+ python_version = "3.10"
75
+ files = ["src"]
76
+ strict = true
77
+
78
+ [[tool.mypy.overrides]]
79
+ module = ["robot", "robot.*"]
80
+ follow_untyped_imports = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ """Robot Framework execution events as JSON Lines.
2
+
3
+ Usage::
4
+
5
+ robot --listener robotframework_events.Listener tests/
6
+ robot --listener robotframework_events.Listener:file=results/events.jsonl tests/
7
+
8
+ Programs that run Robot Framework in the same process (for example robotcode's
9
+ debugger) can receive every event with :func:`add_callback`. Programs that read
10
+ events files use :func:`read_events` and :class:`RunState`.
11
+ """
12
+
13
+ from .convert import convert
14
+ from .events import Event, from_dict, schema
15
+ from .listener import Listener, add_callback, remove_callback
16
+ from .reader import read_events
17
+ from .state import RunState
18
+
19
+ __all__ = [
20
+ "Event",
21
+ "Listener",
22
+ "RunState",
23
+ "add_callback",
24
+ "convert",
25
+ "from_dict",
26
+ "read_events",
27
+ "remove_callback",
28
+ "schema",
29
+ ]
30
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,176 @@
1
+ """The ``rfevents`` command: ``tail`` shows a run in the terminal, ``convert`` builds events from a result file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import io
7
+ import json
8
+ import os
9
+ import sys
10
+ from collections.abc import Iterable
11
+ from typing import TextIO
12
+
13
+ from . import __version__
14
+ from .convert import convert
15
+ from .events import (
16
+ Event,
17
+ MessageLogged,
18
+ RunEnded,
19
+ StepStarted,
20
+ SuiteEnded,
21
+ SuiteStarted,
22
+ TestEnded,
23
+ TestStarted,
24
+ )
25
+ from .listener import LEVELS
26
+ from .reader import read_events
27
+ from .state import RunState, step_label
28
+
29
+ COLORS = {"PASS": "32", "FAIL": "31", "SKIP": "33", "NOT RUN": "90", "WARN": "33", "ERROR": "31"}
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ parser = argparse.ArgumentParser(prog="rfevents", description="Robot Framework execution events.")
34
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
35
+ commands = parser.add_subparsers(dest="command", required=True)
36
+
37
+ tail = commands.add_parser("tail", help="show a run from its events file, readable")
38
+ tail.add_argument("file", help="events file (.jsonl or .jsonl.gz)")
39
+ tail.add_argument("-f", "--follow", action="store_true", help="wait for new events until the run ends")
40
+ tail.add_argument(
41
+ "--steps",
42
+ type=int,
43
+ default=1,
44
+ metavar="DEPTH",
45
+ help="show steps this many levels below each test, 0 for none (default 1)",
46
+ )
47
+ tail.add_argument(
48
+ "--level",
49
+ default="WARN",
50
+ type=str.upper,
51
+ choices=list(LEVELS),
52
+ help="show log messages from this level (default WARN)",
53
+ )
54
+ tail.add_argument("--color", choices=["auto", "always", "never"], default="auto")
55
+
56
+ conv = commands.add_parser("convert", help="build events from output.xml or output.json")
57
+ conv.add_argument("output", help="output.xml or output.json")
58
+ conv.add_argument("-o", "--outfile", help="events file to write (default: standard output)")
59
+
60
+ args = parser.parse_args(argv)
61
+ if isinstance(sys.stdout, io.TextIOWrapper):
62
+ sys.stdout.reconfigure(errors="replace")
63
+ try:
64
+ if args.command == "tail":
65
+ color = args.color == "always" or (
66
+ args.color == "auto" and sys.stdout.isatty() and "NO_COLOR" not in os.environ
67
+ )
68
+ Tail(sys.stdout, args.steps, args.level, color).show(read_events(args.file, follow=args.follow))
69
+ else:
70
+ write_converted(args.output, args.outfile)
71
+ except KeyboardInterrupt:
72
+ return 130
73
+ except OutputClosed:
74
+ # keep Python from failing again when it flushes stdout at exit
75
+ sys.stdout = open(os.devnull, "w") # noqa: SIM115
76
+ return 0
77
+ except (OSError, ValueError) as e:
78
+ print(f"rfevents: {e}", file=sys.stderr)
79
+ return 2
80
+ return 0
81
+
82
+
83
+ def write_converted(output: str, outfile: str | None) -> None:
84
+ encode = json.JSONEncoder(ensure_ascii=False, separators=(",", ":")).encode
85
+ lines = (encode(event) + "\n" for event in convert(output))
86
+ if outfile is None:
87
+ sys.stdout.writelines(lines)
88
+ return
89
+ with open(outfile, "w", encoding="utf-8") as file:
90
+ file.writelines(lines)
91
+
92
+
93
+ class OutputClosed(Exception):
94
+ pass
95
+
96
+
97
+ class Tail:
98
+ """Prints suites, tests, top-level steps, failures and warnings as they happen."""
99
+
100
+ def __init__(self, out: TextIO, steps: int = 1, level: str = "WARN", color: bool = False) -> None:
101
+ self.out = out
102
+ self.steps = steps
103
+ self.level = LEVELS[level]
104
+ self.color = color
105
+ self.state = RunState()
106
+
107
+ def show(self, events: Iterable[Event]) -> None:
108
+ for event in events:
109
+ self.state.apply(event)
110
+ self.print_event(event)
111
+
112
+ def print_event(self, event: Event) -> None:
113
+ if isinstance(event, SuiteStarted):
114
+ self.line(self.indent(event.id), event.longname if event.parent is None else event.name)
115
+ elif isinstance(event, TestStarted):
116
+ self.line(self.indent(event.id), event.name)
117
+ elif isinstance(event, StepStarted):
118
+ depth = self.step_depth(event.id)
119
+ if depth <= self.steps:
120
+ node = self.state.nodes[event.id]
121
+ kind = "" if event.kind == "KEYWORD" else ("ITER" if event.kind == "ITERATION" else event.kind)
122
+ parts = [kind, step_label(node), *(event.args or [])]
123
+ self.line(self.indent(event.id), " ".join(p for p in parts if p))
124
+ elif isinstance(event, TestEnded):
125
+ node = self.state.nodes[event.id]
126
+ self.line(
127
+ self.indent(event.id), self.paint(node.status, node.status), node.name, f"({event.elapsed:.2f} s)"
128
+ )
129
+ if node.message:
130
+ self.line(self.indent(event.id) + " ", node.message)
131
+ elif isinstance(event, SuiteEnded):
132
+ node = self.state.nodes[event.id]
133
+ if node.parent is not None:
134
+ return
135
+ s = event.stats
136
+ self.line(
137
+ "",
138
+ self.paint(node.status, node.status),
139
+ getattr(node.started, "longname", node.name),
140
+ f"{s['total']} tests, {s['passed']} passed, {s['failed']} failed, {s['skipped']} skipped",
141
+ )
142
+ elif isinstance(event, MessageLogged):
143
+ if LEVELS.get(event.level, 2) >= self.level and event.parent in self.state.nodes:
144
+ self.line(
145
+ self.indent(self.shown(event.parent)) + " ",
146
+ self.paint(event.level, f"[{event.level}]"),
147
+ event.text or "",
148
+ )
149
+ elif isinstance(event, RunEnded):
150
+ self.out.flush()
151
+
152
+ def indent(self, node_id: str | None) -> str:
153
+ return " " * (len(self.state.path(node_id or "")) - 1)
154
+
155
+ def shown(self, node_id: str | None) -> str | None:
156
+ """The node, or its nearest ancestor that is printed."""
157
+ for node in reversed(self.state.path(node_id or "")):
158
+ if node.kind in ("SUITE", "TEST") or self.step_depth(node.id) <= self.steps:
159
+ return node.id
160
+ return node_id
161
+
162
+ def step_depth(self, node_id: str) -> int:
163
+ """1 for steps directly in a test or suite, 2 for their children, ..."""
164
+ return sum(1 for node in self.state.path(node_id) if node.kind not in ("SUITE", "TEST"))
165
+
166
+ def paint(self, status: str, text: str) -> str:
167
+ code = COLORS.get(status)
168
+ return f"\033[{code}m{text}\033[0m" if self.color and code else text
169
+
170
+ def line(self, indent: str, *parts: str) -> None:
171
+ self.out.write(indent + " ".join(p for p in parts if p) + "\n")
172
+ self.out.flush()
173
+
174
+
175
+ if __name__ == "__main__":
176
+ sys.exit(main())
@@ -0,0 +1,172 @@
1
+ """Events built from an existing ``output.xml`` or ``output.json``.
2
+
3
+ Gives events for runs that were recorded without the listener, so they can be replayed.
4
+ What the result file does not store is left out: steps have no ``source`` and ``lineno``,
5
+ ``run.started`` has no ``pid`` and ``cwd``, and children of ``robot:flatten`` keywords are
6
+ missing, as in ``log.html``. Statuses are final, so there are no ``status.changed`` events.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import uuid
14
+ from collections.abc import Iterator
15
+ from datetime import datetime
16
+ from typing import Any
17
+
18
+ from robot.api import ExecutionResult
19
+ from robot.result import TestCase, TestSuite
20
+
21
+ from .events import FORMAT_VERSION
22
+ from .listener import ROOT_TYPES, _stats, step_fields
23
+
24
+ GENERATOR = re.compile(r"Robot (\S+) \(\w+ (\S+)")
25
+
26
+
27
+ def convert(path: str | os.PathLike[str], *, run: str | None = None, max_text: int = 4096) -> Iterator[dict[str, Any]]:
28
+ """Yields the events of a result file as dicts, in the order a live run emits them."""
29
+ result = ExecutionResult(str(path))
30
+ return _Converter(max_text).events(result, str(path), run or str(uuid.uuid4()))
31
+
32
+
33
+ def _time(value: datetime | None) -> float | None:
34
+ return round(value.timestamp(), 3) if value else None
35
+
36
+
37
+ class _Counter:
38
+ __slots__ = ("children", "id")
39
+
40
+ def __init__(self, id: str) -> None:
41
+ self.id = id
42
+ self.children = 0
43
+
44
+
45
+ class _Converter:
46
+ def __init__(self, max_text: int) -> None:
47
+ self.max_text = max_text
48
+ self.seq = 0
49
+ self.t = 0.0
50
+
51
+ def emit(self, type_: str, t: float | None, **fields: Any) -> dict[str, Any]:
52
+ self.seq += 1
53
+ # NOT RUN items have no times; keep time moving forward
54
+ self.t = max(self.t, t) if t is not None else self.t
55
+ event: dict[str, Any] = {"v": FORMAT_VERSION, "seq": self.seq, "t": self.t, "type": type_}
56
+ event.update((k, v) for k, v in fields.items() if v is not None and v != "")
57
+ return event
58
+
59
+ def events(self, result: Any, path: str, run: str) -> Iterator[dict[str, Any]]:
60
+ suite = result.suite
61
+ match = GENERATOR.match(getattr(result, "generator", "") or "")
62
+ self.t = _time(suite.start_time) or 0.0
63
+ yield self.emit(
64
+ "run.started",
65
+ self.t,
66
+ run=run,
67
+ rf=match.group(1) if match else "unknown",
68
+ python=match.group(2) if match else None,
69
+ )
70
+ yield from self.suite(suite, "s1", None)
71
+ yield self.emit("resultfile.created", None, kind="OUTPUT", path=path)
72
+ yield self.emit("run.ended", None, status=suite.status, stats=_stats(suite))
73
+
74
+ def suite(self, suite: TestSuite, id: str, parent: str | None) -> Iterator[dict[str, Any]]:
75
+ yield self.emit(
76
+ "suite.started",
77
+ _time(suite.start_time),
78
+ id=id,
79
+ parent=parent,
80
+ name=suite.name,
81
+ longname=suite.full_name,
82
+ source=str(suite.source) if suite.source else None,
83
+ tests=suite.test_count,
84
+ )
85
+ counter = _Counter(id)
86
+ if suite.has_setup:
87
+ yield from self.step(suite.setup, counter, id)
88
+ for index, child in enumerate(suite.suites, 1):
89
+ yield from self.suite(child, f"{id}-s{index}", id)
90
+ for index, test in enumerate(suite.tests, 1):
91
+ yield from self.test(test, f"{id}-t{index}", id)
92
+ if suite.has_teardown:
93
+ yield from self.step(suite.teardown, counter, id)
94
+ yield self.emit(
95
+ "suite.ended",
96
+ _time(suite.end_time),
97
+ id=id,
98
+ status=suite.status,
99
+ message=suite.message,
100
+ elapsed=round(suite.elapsed_time.total_seconds(), 3),
101
+ stats=_stats(suite),
102
+ )
103
+
104
+ def test(self, test: TestCase, id: str, parent: str) -> Iterator[dict[str, Any]]:
105
+ yield self.emit(
106
+ "test.started",
107
+ _time(test.start_time),
108
+ id=id,
109
+ parent=parent,
110
+ name=test.name,
111
+ longname=test.full_name,
112
+ source=str(test.source) if test.source else None,
113
+ lineno=test.lineno,
114
+ tags=list(test.tags) or None,
115
+ )
116
+ counter = _Counter(id)
117
+ if test.has_setup:
118
+ yield from self.step(test.setup, counter, id)
119
+ yield from self.body(test.body, counter, id)
120
+ if test.has_teardown:
121
+ yield from self.step(test.teardown, counter, id)
122
+ yield self.emit(
123
+ "test.ended",
124
+ _time(test.end_time),
125
+ id=id,
126
+ status=test.status,
127
+ message=test.message,
128
+ elapsed=round(test.elapsed_time.total_seconds(), 3),
129
+ )
130
+
131
+ def body(self, items: Any, counter: _Counter, parent: str) -> Iterator[dict[str, Any]]:
132
+ for item in items:
133
+ if item.type == "MESSAGE":
134
+ text = item.message or ""
135
+ truncated = len(text) > self.max_text
136
+ yield self.emit(
137
+ "message.logged",
138
+ _time(item.timestamp),
139
+ parent=parent,
140
+ level=item.level,
141
+ text=text[: self.max_text] if truncated else text,
142
+ truncated=True if truncated else None,
143
+ html=True if item.html else None,
144
+ )
145
+ elif item.type in ROOT_TYPES:
146
+ # IF/TRY branches are numbered as children of the root's parent
147
+ yield from self.body(item.body, counter, parent)
148
+ else:
149
+ yield from self.step(item, counter, parent)
150
+
151
+ def step(self, item: Any, counter: _Counter, parent: str) -> Iterator[dict[str, Any]]:
152
+ counter.children += 1
153
+ id = f"{counter.id}-k{counter.children}"
154
+ kind = item.type
155
+ yield self.emit(
156
+ "step.started", _time(item.start_time), id=id, parent=parent, kind=kind, **step_fields(kind, item)
157
+ )
158
+ own = _Counter(id)
159
+ if getattr(item, "has_setup", False):
160
+ yield from self.step(item.setup, own, id)
161
+ if hasattr(item, "body"):
162
+ yield from self.body(item.body, own, id)
163
+ if getattr(item, "has_teardown", False):
164
+ yield from self.step(item.teardown, own, id)
165
+ yield self.emit(
166
+ "step.ended",
167
+ _time(item.end_time),
168
+ id=id,
169
+ status=item.status,
170
+ message=item.message,
171
+ elapsed=round(item.elapsed_time.total_seconds(), 4),
172
+ )