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.
- robotframework_events-0.1.0/LICENSE +21 -0
- robotframework_events-0.1.0/PKG-INFO +112 -0
- robotframework_events-0.1.0/README.md +77 -0
- robotframework_events-0.1.0/pyproject.toml +80 -0
- robotframework_events-0.1.0/setup.cfg +4 -0
- robotframework_events-0.1.0/src/robotframework_events/__init__.py +30 -0
- robotframework_events-0.1.0/src/robotframework_events/__main__.py +5 -0
- robotframework_events-0.1.0/src/robotframework_events/cli.py +176 -0
- robotframework_events-0.1.0/src/robotframework_events/convert.py +172 -0
- robotframework_events-0.1.0/src/robotframework_events/events-v1.schema.json +175 -0
- robotframework_events-0.1.0/src/robotframework_events/events.py +248 -0
- robotframework_events-0.1.0/src/robotframework_events/listener.py +523 -0
- robotframework_events-0.1.0/src/robotframework_events/py.typed +0 -0
- robotframework_events-0.1.0/src/robotframework_events/reader.py +118 -0
- robotframework_events-0.1.0/src/robotframework_events/state.py +207 -0
- robotframework_events-0.1.0/src/robotframework_events.egg-info/PKG-INFO +112 -0
- robotframework_events-0.1.0/src/robotframework_events.egg-info/SOURCES.txt +26 -0
- robotframework_events-0.1.0/src/robotframework_events.egg-info/dependency_links.txt +1 -0
- robotframework_events-0.1.0/src/robotframework_events.egg-info/entry_points.txt +2 -0
- robotframework_events-0.1.0/src/robotframework_events.egg-info/requires.txt +7 -0
- robotframework_events-0.1.0/src/robotframework_events.egg-info/top_level.txt +1 -0
- robotframework_events-0.1.0/tests/test_benchmark.py +46 -0
- robotframework_events-0.1.0/tests/test_cli.py +45 -0
- robotframework_events-0.1.0/tests/test_conformance.py +150 -0
- robotframework_events-0.1.0/tests/test_events.py +236 -0
- robotframework_events-0.1.0/tests/test_model.py +79 -0
- robotframework_events-0.1.0/tests/test_reader.py +88 -0
- 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,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,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
|
+
)
|