robotframework-testcontainers 0.2.0__tar.gz → 0.3.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_testcontainers-0.2.0 → robotframework_testcontainers-0.3.0}/PKG-INFO +37 -6
- {robotframework_testcontainers-0.2.0 → robotframework_testcontainers-0.3.0}/README.md +33 -3
- robotframework_testcontainers-0.3.0/pyproject.toml +65 -0
- robotframework_testcontainers-0.2.0/pyproject.toml → robotframework_testcontainers-0.3.0/pyproject.toml.orig +12 -3
- robotframework_testcontainers-0.3.0/src/TestcontainersLibrary/__init__.py +6 -0
- {robotframework_testcontainers-0.2.0 → robotframework_testcontainers-0.3.0}/src/TestcontainersLibrary/library.py +72 -51
- robotframework_testcontainers-0.3.0/src/TestcontainersLibrary/lifecycle.py +151 -0
- robotframework_testcontainers-0.3.0/src/TestcontainersLibrary/listener.py +112 -0
- robotframework_testcontainers-0.3.0/src/TestcontainersLibrary/log_capture.py +93 -0
- robotframework_testcontainers-0.2.0/src/TestcontainersLibrary/__init__.py +0 -1
- {robotframework_testcontainers-0.2.0 → robotframework_testcontainers-0.3.0}/src/TestcontainersLibrary/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-testcontainers
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Robot Framework keywords for Testcontainers.
|
|
5
5
|
Keywords: robotframework,testing,test,automation,container,testcontainers,docker
|
|
6
6
|
Author: Andreas Finkler
|
|
@@ -11,10 +11,11 @@ Classifier: Framework :: Robot Framework :: Library
|
|
|
11
11
|
Classifier: Topic :: Software Development :: Testing
|
|
12
12
|
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
13
13
|
Requires-Dist: robotframework>=7.3.2
|
|
14
|
-
Requires-Dist:
|
|
14
|
+
Requires-Dist: robotframework-assertion-engine>=5.0.1
|
|
15
|
+
Requires-Dist: testcontainers[generic]>=4.15.0
|
|
15
16
|
Requires-Python: >=3.10
|
|
16
|
-
Project-URL: Documentation, https://dudenr33.github.io/robotframework-testcontainers/
|
|
17
17
|
Project-URL: Repository, https://github.com/DudeNr33/robotframework-testcontainers
|
|
18
|
+
Project-URL: Documentation, https://dudenr33.github.io/robotframework-testcontainers/
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
|
|
20
21
|
# robotframework-testcontainers
|
|
@@ -56,9 +57,9 @@ Basic Usage Example
|
|
|
56
57
|
Create Docker Container image=hello-world
|
|
57
58
|
```
|
|
58
59
|
|
|
59
|
-
`TestcontainersLibrary` keeps track of all
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
`TestcontainersLibrary` keeps track of all started containers. It stops containers
|
|
61
|
+
started during a test when that test ends, and containers started during suite
|
|
62
|
+
initialization or suite setup when that suite ends.
|
|
62
63
|
|
|
63
64
|
If you require more control, you can also manually start and stop
|
|
64
65
|
the container. Additionally, you can use different wait strategies
|
|
@@ -101,6 +102,36 @@ Starting an CockroachDb Container
|
|
|
101
102
|
|
|
102
103
|
You can read the acceptance tests in `test/acceptance/` for more concrete usage examples.
|
|
103
104
|
|
|
105
|
+
## Failed-test log artifacts
|
|
106
|
+
|
|
107
|
+
Register the failed-test log collector on the Robot command line to retain stdout and stderr for every active container started through the library. The listener covers every test in the execution, including child suites that do not import `TestcontainersLibrary`:
|
|
108
|
+
|
|
109
|
+
```shell
|
|
110
|
+
robot \
|
|
111
|
+
--listener TestcontainersLibrary.FailedTestLogCollector \
|
|
112
|
+
tests/
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
By default, artifacts go to `${OUTPUT_DIR}/container-logs`. Pass a different root after the listener name when needed:
|
|
116
|
+
|
|
117
|
+
```shell
|
|
118
|
+
robot \
|
|
119
|
+
--listener TestcontainersLibrary.FailedTestLogCollector:/tmp/container-logs \
|
|
120
|
+
tests/
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The listener uses absolute custom paths as written. It resolves relative custom paths from the process working directory, not Robot's output directory.
|
|
124
|
+
|
|
125
|
+
The log window extends one second before and after the failed test to account for Docker log timing. Each Robot run gets one timestamped directory. Beneath it, the listener preserves the logical suite hierarchy:
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
<artifact-root>/<timestamp>/<suite>/<child-suite>/<test>/<container files>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Unsafe path characters and whitespace become underscores. Each container gets separate stdout and stderr files. When collection writes at least one log or error file, `log.html` includes one INFO message with the absolute path to that test's artifact directory. Collection errors do not change the Robot test result.
|
|
132
|
+
|
|
133
|
+
The files contain raw container logs and may include passwords, tokens, or other secrets. Choose an artifact directory with suitable access and retention controls.
|
|
134
|
+
|
|
104
135
|
## License
|
|
105
136
|
|
|
106
137
|
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -37,9 +37,9 @@ Basic Usage Example
|
|
|
37
37
|
Create Docker Container image=hello-world
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
`TestcontainersLibrary` keeps track of all
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
`TestcontainersLibrary` keeps track of all started containers. It stops containers
|
|
41
|
+
started during a test when that test ends, and containers started during suite
|
|
42
|
+
initialization or suite setup when that suite ends.
|
|
43
43
|
|
|
44
44
|
If you require more control, you can also manually start and stop
|
|
45
45
|
the container. Additionally, you can use different wait strategies
|
|
@@ -82,6 +82,36 @@ Starting an CockroachDb Container
|
|
|
82
82
|
|
|
83
83
|
You can read the acceptance tests in `test/acceptance/` for more concrete usage examples.
|
|
84
84
|
|
|
85
|
+
## Failed-test log artifacts
|
|
86
|
+
|
|
87
|
+
Register the failed-test log collector on the Robot command line to retain stdout and stderr for every active container started through the library. The listener covers every test in the execution, including child suites that do not import `TestcontainersLibrary`:
|
|
88
|
+
|
|
89
|
+
```shell
|
|
90
|
+
robot \
|
|
91
|
+
--listener TestcontainersLibrary.FailedTestLogCollector \
|
|
92
|
+
tests/
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
By default, artifacts go to `${OUTPUT_DIR}/container-logs`. Pass a different root after the listener name when needed:
|
|
96
|
+
|
|
97
|
+
```shell
|
|
98
|
+
robot \
|
|
99
|
+
--listener TestcontainersLibrary.FailedTestLogCollector:/tmp/container-logs \
|
|
100
|
+
tests/
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The listener uses absolute custom paths as written. It resolves relative custom paths from the process working directory, not Robot's output directory.
|
|
104
|
+
|
|
105
|
+
The log window extends one second before and after the failed test to account for Docker log timing. Each Robot run gets one timestamped directory. Beneath it, the listener preserves the logical suite hierarchy:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
<artifact-root>/<timestamp>/<suite>/<child-suite>/<test>/<container files>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Unsafe path characters and whitespace become underscores. Each container gets separate stdout and stderr files. When collection writes at least one log or error file, `log.html` includes one INFO message with the absolute path to that test's artifact directory. Collection errors do not change the Robot test result.
|
|
112
|
+
|
|
113
|
+
The files contain raw container logs and may include passwords, tokens, or other secrets. Choose an artifact directory with suitable access and retention controls.
|
|
114
|
+
|
|
85
115
|
## License
|
|
86
116
|
|
|
87
117
|
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "robotframework-testcontainers"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Robot Framework keywords for Testcontainers."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-file = "LICENSE"
|
|
8
|
+
keywords = [
|
|
9
|
+
"robotframework",
|
|
10
|
+
"testing",
|
|
11
|
+
"test",
|
|
12
|
+
"automation",
|
|
13
|
+
"container",
|
|
14
|
+
"testcontainers",
|
|
15
|
+
"docker",
|
|
16
|
+
]
|
|
17
|
+
requires-python = ">=3.10"
|
|
18
|
+
dependencies = [
|
|
19
|
+
"robotframework>=7.3.2",
|
|
20
|
+
"robotframework-assertion-engine>=5.0.1",
|
|
21
|
+
"testcontainers[generic]>=4.15.0",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Framework :: Robot Framework",
|
|
25
|
+
"Framework :: Robot Framework :: Library",
|
|
26
|
+
"Topic :: Software Development :: Testing",
|
|
27
|
+
"Topic :: Software Development :: Testing :: Mocking",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "Andreas Finkler"
|
|
32
|
+
email = "andi.finkler@gmail.com"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/DudeNr33/robotframework-testcontainers"
|
|
36
|
+
Documentation = "https://dudenr33.github.io/robotframework-testcontainers/"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.9.5,<0.10.0"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[tool.uv]
|
|
43
|
+
exclude-newer = "7 days"
|
|
44
|
+
|
|
45
|
+
[tool.uv.audit]
|
|
46
|
+
malware-check = true
|
|
47
|
+
|
|
48
|
+
[tool.uv.build-backend]
|
|
49
|
+
module-name = "TestcontainersLibrary"
|
|
50
|
+
module-root = "src"
|
|
51
|
+
|
|
52
|
+
[tool.mypy]
|
|
53
|
+
ignore_missing_imports = true
|
|
54
|
+
strict = true
|
|
55
|
+
|
|
56
|
+
[dependency-groups]
|
|
57
|
+
dev = [
|
|
58
|
+
"mypy>=2.3.0",
|
|
59
|
+
"pre-commit>=4.3.0",
|
|
60
|
+
"robotframework-requests>=0.9.7",
|
|
61
|
+
"ruff>=0.14.2",
|
|
62
|
+
"robotcode[all]>=2.0.1",
|
|
63
|
+
"testcontainers[cockroachdb,redis]>=4.13.2",
|
|
64
|
+
"pytest>=9.1.1",
|
|
65
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "robotframework-testcontainers"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Robot Framework keywords for Testcontainers."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Andreas Finkler", email = "andi.finkler@gmail.com" }]
|
|
@@ -16,7 +16,11 @@ keywords = [
|
|
|
16
16
|
"docker",
|
|
17
17
|
]
|
|
18
18
|
requires-python = ">=3.10"
|
|
19
|
-
dependencies = [
|
|
19
|
+
dependencies = [
|
|
20
|
+
"robotframework>=7.3.2",
|
|
21
|
+
"robotframework-assertion-engine>=5.0.1",
|
|
22
|
+
"testcontainers[generic]>=4.15.0",
|
|
23
|
+
]
|
|
20
24
|
classifiers = [
|
|
21
25
|
"Framework :: Robot Framework",
|
|
22
26
|
"Framework :: Robot Framework :: Library",
|
|
@@ -32,18 +36,23 @@ Documentation = "https://dudenr33.github.io/robotframework-testcontainers/"
|
|
|
32
36
|
requires = ["uv_build>=0.9.5,<0.10.0"]
|
|
33
37
|
build-backend = "uv_build"
|
|
34
38
|
|
|
39
|
+
[tool.uv]
|
|
40
|
+
exclude-newer = "7 days"
|
|
41
|
+
audit.malware-check = true
|
|
42
|
+
|
|
35
43
|
[tool.uv.build-backend]
|
|
36
44
|
module-name = "TestcontainersLibrary"
|
|
37
45
|
module-root = "src"
|
|
38
46
|
|
|
39
47
|
[dependency-groups]
|
|
40
48
|
dev = [
|
|
41
|
-
"mypy>=
|
|
49
|
+
"mypy>=2.3.0",
|
|
42
50
|
"pre-commit>=4.3.0",
|
|
43
51
|
"robotframework-requests>=0.9.7",
|
|
44
52
|
"ruff>=0.14.2",
|
|
45
53
|
"robotcode[all]>=2.0.1",
|
|
46
54
|
"testcontainers[cockroachdb,redis]>=4.13.2",
|
|
55
|
+
"pytest>=9.1.1",
|
|
47
56
|
]
|
|
48
57
|
|
|
49
58
|
[tool.mypy]
|
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
import importlib
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
from pathlib import Path
|
|
3
|
-
from typing import Any
|
|
4
|
+
from typing import Any, Literal, cast
|
|
4
5
|
|
|
5
|
-
from
|
|
6
|
+
from assertionengine import AssertionOperator, verify_assertion
|
|
6
7
|
from robot.api.deco import keyword, library
|
|
8
|
+
|
|
9
|
+
# see https://github.com/testcontainers/testcontainers-python/blob/main/src/testcontainers/generic.py#L9
|
|
10
|
+
from testcontainers.community.generic import ServerContainer # type: ignore[attr-defined] # ruff: isort: skip
|
|
7
11
|
from testcontainers.core.container import DockerContainer
|
|
8
12
|
from testcontainers.core.network import Network
|
|
9
13
|
from testcontainers.core.wait_strategies import HttpWaitStrategy, LogMessageWaitStrategy
|
|
10
|
-
|
|
14
|
+
|
|
15
|
+
from TestcontainersLibrary.lifecycle import ResourceLifecycle
|
|
16
|
+
from TestcontainersLibrary.listener import LifecycleListener
|
|
11
17
|
|
|
12
18
|
|
|
13
|
-
@library
|
|
19
|
+
@library
|
|
14
20
|
class TestcontainersLibrary:
|
|
15
21
|
"""
|
|
16
22
|
Keywords for [https://testcontainers.com/|Testcontainers].
|
|
17
23
|
"""
|
|
18
24
|
|
|
19
25
|
def __init__(self) -> None:
|
|
20
|
-
self.
|
|
21
|
-
self.
|
|
26
|
+
self._resources = ResourceLifecycle.for_execution()
|
|
27
|
+
self.ROBOT_LIBRARY_LISTENER = LifecycleListener(self._resources)
|
|
22
28
|
|
|
23
29
|
@keyword
|
|
24
30
|
def create_docker_container(
|
|
@@ -105,7 +111,7 @@ class TestcontainersLibrary:
|
|
|
105
111
|
"""
|
|
106
112
|
_module = importlib.import_module(module)
|
|
107
113
|
clazz = getattr(_module, container_class)
|
|
108
|
-
container = clazz(**kwargs)
|
|
114
|
+
container = cast(DockerContainer, clazz(**kwargs))
|
|
109
115
|
if start:
|
|
110
116
|
self.start_container(container)
|
|
111
117
|
return container
|
|
@@ -125,13 +131,58 @@ class TestcontainersLibrary:
|
|
|
125
131
|
"""
|
|
126
132
|
Start the given container.
|
|
127
133
|
"""
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
return self._resources.start_container(container)
|
|
135
|
+
|
|
136
|
+
@keyword
|
|
137
|
+
def get_container_logs(
|
|
138
|
+
self,
|
|
139
|
+
container: DockerContainer,
|
|
140
|
+
assertion_operator: AssertionOperator | None = None,
|
|
141
|
+
assertion_expected: Any = None,
|
|
142
|
+
message: str = "",
|
|
143
|
+
custom_message: str | None = None,
|
|
144
|
+
stream: Literal["stdout", "stderr"] = "stdout",
|
|
145
|
+
since: datetime | None = None,
|
|
146
|
+
until: datetime | None = None,
|
|
147
|
+
timestamps: bool = False,
|
|
148
|
+
) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Return a managed container's stdout or stderr.
|
|
151
|
+
|
|
152
|
+
Stdout is selected by default. Set ``stream=stderr`` to retrieve stderr.
|
|
153
|
+
Pass absolute datetimes with ``since`` and ``until`` to bound the logs.
|
|
154
|
+
Set ``timestamps=True`` to include Docker timestamps in each line. Pass
|
|
155
|
+
assertion-engine arguments after the container to assert against the selected
|
|
156
|
+
container log.
|
|
157
|
+
|
|
158
|
+
Examples:
|
|
159
|
+
| ${stderr}= | Get Container Logs | ${container} | stream=stderr | |
|
|
160
|
+
| | Get Container Logs | ${container} | contains | started |
|
|
161
|
+
| ${logs}= | Get Container Logs | ${container} | since=${start} | timestamps=${True} |
|
|
162
|
+
"""
|
|
163
|
+
wrapped_container = container.get_wrapped_container()
|
|
164
|
+
|
|
165
|
+
stdout = stream == "stdout"
|
|
166
|
+
stderr = stream == "stderr"
|
|
167
|
+
log_bytes = wrapped_container.logs(
|
|
168
|
+
stdout=stdout,
|
|
169
|
+
stderr=stderr,
|
|
170
|
+
since=since,
|
|
171
|
+
until=until,
|
|
172
|
+
timestamps=timestamps,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
container_log = log_bytes.decode("utf-8", errors="replace")
|
|
176
|
+
return cast(
|
|
177
|
+
str,
|
|
178
|
+
verify_assertion(
|
|
179
|
+
container_log,
|
|
180
|
+
assertion_operator,
|
|
181
|
+
assertion_expected,
|
|
182
|
+
message,
|
|
183
|
+
custom_message,
|
|
184
|
+
),
|
|
185
|
+
)
|
|
135
186
|
|
|
136
187
|
@keyword
|
|
137
188
|
def wait_for_log_message(
|
|
@@ -164,31 +215,27 @@ class TestcontainersLibrary:
|
|
|
164
215
|
Stop the given container.
|
|
165
216
|
|
|
166
217
|
It is not required to call this keyword manually just to clean up after tests.
|
|
167
|
-
The library
|
|
168
|
-
started
|
|
218
|
+
The library stops containers automatically when the Robot test or suite
|
|
219
|
+
that started them ends.
|
|
169
220
|
"""
|
|
170
|
-
self.
|
|
171
|
-
container.stop()
|
|
221
|
+
self._resources.stop_container(container)
|
|
172
222
|
|
|
173
223
|
@keyword
|
|
174
224
|
def create_network(self) -> Network:
|
|
175
225
|
"""
|
|
176
226
|
Create a network to connect different containers with each other.
|
|
177
227
|
|
|
178
|
-
Created networks are cleaned up
|
|
228
|
+
Created networks are cleaned up when the Robot test or suite that created
|
|
229
|
+
them ends.
|
|
179
230
|
"""
|
|
180
|
-
|
|
181
|
-
network.create()
|
|
182
|
-
self._networks.append(network)
|
|
183
|
-
return network
|
|
231
|
+
return self._resources.create_network()
|
|
184
232
|
|
|
185
233
|
@keyword
|
|
186
234
|
def remove_network(self, network: Network) -> None:
|
|
187
235
|
"""
|
|
188
236
|
Delete the given network.
|
|
189
237
|
"""
|
|
190
|
-
self.
|
|
191
|
-
network.remove()
|
|
238
|
+
self._resources.remove_network(network)
|
|
192
239
|
|
|
193
240
|
@keyword
|
|
194
241
|
def connect_container_to_network(
|
|
@@ -204,29 +251,3 @@ class TestcontainersLibrary:
|
|
|
204
251
|
if container_id is None:
|
|
205
252
|
raise ValueError("Failed to obtain ID of wrapped container")
|
|
206
253
|
network.connect(container_id=container_id, network_aliases=aliases)
|
|
207
|
-
|
|
208
|
-
def _end_test(self, name: Any, attrs: Any) -> None:
|
|
209
|
-
for container in self._containers.copy():
|
|
210
|
-
cname = container.get_wrapped_container().name
|
|
211
|
-
logger.console(
|
|
212
|
-
f"\n\tTestcontainersLibrary: stopping container {cname} in end_test hook."
|
|
213
|
-
)
|
|
214
|
-
self.stop_container(container)
|
|
215
|
-
for network in self._networks.copy():
|
|
216
|
-
logger.console(
|
|
217
|
-
f"\n\tTestcontainersLibrary: removing network {network.id} in end_test hook."
|
|
218
|
-
)
|
|
219
|
-
self.remove_network(network)
|
|
220
|
-
|
|
221
|
-
def _end_suite(self, name: Any, attrs: Any) -> None:
|
|
222
|
-
for container in self._containers.copy():
|
|
223
|
-
cname = container.get_wrapped_container().name
|
|
224
|
-
logger.console(
|
|
225
|
-
f"\n\tTestcontainersLibrary: stopping container {cname} in end_suite hook."
|
|
226
|
-
)
|
|
227
|
-
self.stop_container(container)
|
|
228
|
-
for network in self._networks.copy():
|
|
229
|
-
logger.console(
|
|
230
|
-
f"\n\tTestcontainersLibrary: removing network {network.id} in end_suite hook."
|
|
231
|
-
)
|
|
232
|
-
self.remove_network(network)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import ClassVar, Literal, cast
|
|
3
|
+
|
|
4
|
+
from robot.api import logger
|
|
5
|
+
from testcontainers.core.container import DockerContainer
|
|
6
|
+
from testcontainers.core.network import Network
|
|
7
|
+
|
|
8
|
+
CleanupHook = Literal["end_test", "end_suite"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ResourceOwner:
|
|
13
|
+
kind: Literal["suite", "test"]
|
|
14
|
+
longname: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ManagedResource:
|
|
19
|
+
kind: Literal["container", "network"]
|
|
20
|
+
resource: DockerContainer | Network
|
|
21
|
+
owner: ResourceOwner | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ResourceLifecycle:
|
|
25
|
+
"""Track resources for one Robot execution and clean them by owner."""
|
|
26
|
+
|
|
27
|
+
_execution: ClassVar["ResourceLifecycle | None"] = None
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._resources: list[ManagedResource] = []
|
|
31
|
+
self._active_owner: ResourceOwner | None = None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def for_execution(cls) -> "ResourceLifecycle":
|
|
35
|
+
if cls._execution is None:
|
|
36
|
+
cls._execution = cls()
|
|
37
|
+
return cls._execution
|
|
38
|
+
|
|
39
|
+
def enter_suite(self, longname: str) -> None:
|
|
40
|
+
self._active_owner = ResourceOwner("suite", longname)
|
|
41
|
+
|
|
42
|
+
def enter_test(self, longname: str) -> None:
|
|
43
|
+
self._active_owner = ResourceOwner("test", longname)
|
|
44
|
+
|
|
45
|
+
def clear_owner(self) -> None:
|
|
46
|
+
self._active_owner = None
|
|
47
|
+
|
|
48
|
+
def start_container(self, container: DockerContainer) -> DockerContainer:
|
|
49
|
+
owner = self._require_active_owner()
|
|
50
|
+
try:
|
|
51
|
+
container.start()
|
|
52
|
+
self._resources.append(ManagedResource("container", container, owner))
|
|
53
|
+
return container
|
|
54
|
+
except Exception:
|
|
55
|
+
container.stop()
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
def stop_container(self, container: DockerContainer) -> None:
|
|
59
|
+
container.stop()
|
|
60
|
+
self._remove_resource(container)
|
|
61
|
+
|
|
62
|
+
def active_containers(self) -> tuple[DockerContainer, ...]:
|
|
63
|
+
return tuple(
|
|
64
|
+
cast(DockerContainer, resource.resource)
|
|
65
|
+
for resource in self._resources
|
|
66
|
+
if resource.kind == "container"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def create_network(self) -> Network:
|
|
70
|
+
owner = self._require_active_owner()
|
|
71
|
+
network = Network()
|
|
72
|
+
network.create()
|
|
73
|
+
self._resources.append(ManagedResource("network", network, owner))
|
|
74
|
+
return network
|
|
75
|
+
|
|
76
|
+
def remove_network(self, network: Network) -> None:
|
|
77
|
+
network.remove()
|
|
78
|
+
self._remove_resource(network)
|
|
79
|
+
|
|
80
|
+
def cleanup_test(self, longname: str) -> None:
|
|
81
|
+
self._cleanup_owner(ResourceOwner("test", longname), "end_test")
|
|
82
|
+
|
|
83
|
+
def cleanup_suite(self, longname: str) -> None:
|
|
84
|
+
self._cleanup_owner(ResourceOwner("suite", longname), "end_suite")
|
|
85
|
+
|
|
86
|
+
def cleanup(self, hook: CleanupHook) -> None:
|
|
87
|
+
self._cleanup_resources(self._resources.copy(), hook)
|
|
88
|
+
|
|
89
|
+
def _cleanup_owner(self, owner: ResourceOwner, hook: CleanupHook) -> None:
|
|
90
|
+
self._cleanup_resources(
|
|
91
|
+
[
|
|
92
|
+
managed_resource
|
|
93
|
+
for managed_resource in self._resources
|
|
94
|
+
if managed_resource.owner == owner
|
|
95
|
+
],
|
|
96
|
+
hook,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _cleanup_resources(
|
|
100
|
+
self,
|
|
101
|
+
managed_resources: list[ManagedResource],
|
|
102
|
+
hook: CleanupHook,
|
|
103
|
+
) -> None:
|
|
104
|
+
first_error: Exception | None = None
|
|
105
|
+
for kind in ("container", "network"):
|
|
106
|
+
for managed_resource in managed_resources:
|
|
107
|
+
if managed_resource.kind != kind:
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
self._cleanup_resource(managed_resource, hook)
|
|
111
|
+
except Exception as error: # noqa: BLE001
|
|
112
|
+
logger.error(
|
|
113
|
+
f"TestcontainersLibrary: failed to clean up {kind}: {error}"
|
|
114
|
+
)
|
|
115
|
+
if first_error is None:
|
|
116
|
+
first_error = error
|
|
117
|
+
if first_error is not None:
|
|
118
|
+
raise first_error
|
|
119
|
+
|
|
120
|
+
def _cleanup_resource(
|
|
121
|
+
self, managed_resource: ManagedResource, hook: CleanupHook
|
|
122
|
+
) -> None:
|
|
123
|
+
resource = managed_resource.resource
|
|
124
|
+
if managed_resource.kind == "container":
|
|
125
|
+
container = cast(DockerContainer, resource)
|
|
126
|
+
name = container.get_wrapped_container().name
|
|
127
|
+
logger.console(
|
|
128
|
+
f"\n\tTestcontainersLibrary: stopping container {name} in {hook} hook."
|
|
129
|
+
)
|
|
130
|
+
self.stop_container(container)
|
|
131
|
+
else:
|
|
132
|
+
network = cast(Network, resource)
|
|
133
|
+
logger.console(
|
|
134
|
+
f"\n\tTestcontainersLibrary: removing network {network.id} in {hook} hook."
|
|
135
|
+
)
|
|
136
|
+
self.remove_network(network)
|
|
137
|
+
|
|
138
|
+
def _require_active_owner(self) -> ResourceOwner:
|
|
139
|
+
if self._active_owner is None:
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
"Containers and networks can only be started during Robot suite "
|
|
142
|
+
"setup or test execution."
|
|
143
|
+
)
|
|
144
|
+
return self._active_owner
|
|
145
|
+
|
|
146
|
+
def _remove_resource(self, resource: DockerContainer | Network) -> None:
|
|
147
|
+
for managed_resource in self._resources:
|
|
148
|
+
if managed_resource.resource is resource:
|
|
149
|
+
self._resources.remove(managed_resource)
|
|
150
|
+
return
|
|
151
|
+
raise ValueError("Resource is not managed by this library execution.")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from robot.api import logger
|
|
6
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
7
|
+
|
|
8
|
+
from TestcontainersLibrary.lifecycle import ResourceLifecycle
|
|
9
|
+
from TestcontainersLibrary.log_capture import FailedTestLogCapture, safe_path_component
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FailedTestLogCollector:
|
|
13
|
+
"""Collect active container logs when any test in the execution fails."""
|
|
14
|
+
|
|
15
|
+
ROBOT_LISTENER_API_VERSION = 3
|
|
16
|
+
|
|
17
|
+
def __init__(self, artifact_root: str | None = None) -> None:
|
|
18
|
+
if artifact_root == "":
|
|
19
|
+
raise ValueError("artifact root must not be empty")
|
|
20
|
+
|
|
21
|
+
root = Path(artifact_root) if artifact_root is not None else None
|
|
22
|
+
if root is not None and not root.is_absolute():
|
|
23
|
+
root = Path.cwd() / root
|
|
24
|
+
if root is not None and root.exists() and not root.is_dir():
|
|
25
|
+
raise ValueError(f"artifact root is not a directory: {root}")
|
|
26
|
+
|
|
27
|
+
self._resources = ResourceLifecycle.for_execution()
|
|
28
|
+
self._artifact_root = root
|
|
29
|
+
self._run_directory: Path | None = None
|
|
30
|
+
self._capture = FailedTestLogCapture()
|
|
31
|
+
|
|
32
|
+
def end_test(self, data: Any, result: Any) -> None:
|
|
33
|
+
if result.status != "FAIL":
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
artifact_directory = self._artifact_directory(data, result)
|
|
37
|
+
wrote_artifact = self._capture.write(
|
|
38
|
+
artifact_directory,
|
|
39
|
+
result.start_time,
|
|
40
|
+
result.end_time,
|
|
41
|
+
self._resources.active_containers(),
|
|
42
|
+
)
|
|
43
|
+
if wrote_artifact:
|
|
44
|
+
logger.info(
|
|
45
|
+
"TestcontainersLibrary: failed-test container logs written to "
|
|
46
|
+
f"{artifact_directory}"
|
|
47
|
+
)
|
|
48
|
+
except Exception: # noqa: BLE001
|
|
49
|
+
# Log collection must never change Robot's test result.
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
def _artifact_directory(self, data: Any, result: Any) -> Path:
|
|
53
|
+
if self._run_directory is None:
|
|
54
|
+
self._run_directory = self._artifact_root_for_execution() / self._timestamp(
|
|
55
|
+
result.start_time
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
directory = self._run_directory
|
|
59
|
+
for suite_name in self._suite_names(result):
|
|
60
|
+
directory /= safe_path_component(suite_name)
|
|
61
|
+
return directory / safe_path_component(data.name)
|
|
62
|
+
|
|
63
|
+
def _artifact_root_for_execution(self) -> Path:
|
|
64
|
+
if self._artifact_root is None:
|
|
65
|
+
output_directory = cast(str, BuiltIn().get_variable_value("${OUTPUT_DIR}"))
|
|
66
|
+
self._artifact_root = Path(output_directory) / "container-logs"
|
|
67
|
+
return self._artifact_root
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _suite_names(result: Any) -> list[str]:
|
|
71
|
+
# Suite names can contain dots, so splitting longname would lose the
|
|
72
|
+
# boundaries in Robot's logical suite hierarchy.
|
|
73
|
+
names: list[str] = []
|
|
74
|
+
suite = result.parent
|
|
75
|
+
while suite is not None:
|
|
76
|
+
names.append(suite.name)
|
|
77
|
+
suite = suite.parent
|
|
78
|
+
names.reverse()
|
|
79
|
+
return names
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _timestamp(start_time: datetime) -> str:
|
|
83
|
+
if start_time.tzinfo is None:
|
|
84
|
+
start_time = start_time.astimezone()
|
|
85
|
+
return start_time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LifecycleListener:
|
|
89
|
+
"""Forward Robot Framework lifecycle events to resource cleanup."""
|
|
90
|
+
|
|
91
|
+
ROBOT_LISTENER_API_VERSION = 3
|
|
92
|
+
|
|
93
|
+
def __init__(self, resources: ResourceLifecycle) -> None:
|
|
94
|
+
self._resources = resources
|
|
95
|
+
|
|
96
|
+
def start_suite(self, data: Any, result: Any) -> None:
|
|
97
|
+
self._resources.enter_suite(result.longname)
|
|
98
|
+
|
|
99
|
+
def start_test(self, data: Any, result: Any) -> None:
|
|
100
|
+
self._resources.enter_test(result.longname)
|
|
101
|
+
|
|
102
|
+
def end_test(self, data: Any, result: Any) -> None:
|
|
103
|
+
try:
|
|
104
|
+
self._resources.cleanup_test(result.longname)
|
|
105
|
+
finally:
|
|
106
|
+
self._resources.clear_owner()
|
|
107
|
+
|
|
108
|
+
def end_suite(self, data: Any, result: Any) -> None:
|
|
109
|
+
try:
|
|
110
|
+
self._resources.cleanup_suite(result.longname)
|
|
111
|
+
finally:
|
|
112
|
+
self._resources.clear_owner()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from testcontainers.core.container import DockerContainer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def safe_path_component(value: str) -> str:
|
|
10
|
+
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-")
|
|
11
|
+
return sanitized or "unnamed"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FailedTestLogCapture:
|
|
15
|
+
"""Write active containers' logs when a Robot test fails."""
|
|
16
|
+
|
|
17
|
+
_time_tolerance = timedelta(seconds=1)
|
|
18
|
+
|
|
19
|
+
def write(
|
|
20
|
+
self,
|
|
21
|
+
artifact_directory: Path,
|
|
22
|
+
start_time: datetime,
|
|
23
|
+
end_time: datetime,
|
|
24
|
+
containers: Iterable[DockerContainer],
|
|
25
|
+
) -> bool:
|
|
26
|
+
containers = tuple(containers)
|
|
27
|
+
if not containers:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
artifact_directory.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
except OSError:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
wrote_artifact = False
|
|
36
|
+
for container in containers:
|
|
37
|
+
wrote_artifact = (
|
|
38
|
+
self._write_container_logs(
|
|
39
|
+
container, artifact_directory, start_time, end_time
|
|
40
|
+
)
|
|
41
|
+
or wrote_artifact
|
|
42
|
+
)
|
|
43
|
+
return wrote_artifact
|
|
44
|
+
|
|
45
|
+
def _write_container_logs(
|
|
46
|
+
self,
|
|
47
|
+
container: DockerContainer,
|
|
48
|
+
artifact_directory: Path,
|
|
49
|
+
start_time: datetime,
|
|
50
|
+
end_time: datetime,
|
|
51
|
+
) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
wrapped_container = container.get_wrapped_container()
|
|
54
|
+
name = safe_path_component(wrapped_container.name or "container")
|
|
55
|
+
if wrapped_container.id is None:
|
|
56
|
+
raise KeyError(f"No ID found on container object {wrapped_container}.")
|
|
57
|
+
container_id = wrapped_container.id[:12]
|
|
58
|
+
filename = f"{name}-{container_id}"
|
|
59
|
+
except Exception as error: # noqa: BLE001
|
|
60
|
+
return self._write_collection_error(
|
|
61
|
+
artifact_directory / "container.error.txt", error
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
wrote_artifact = False
|
|
65
|
+
for stream in ("stdout", "stderr"):
|
|
66
|
+
try:
|
|
67
|
+
log_bytes = wrapped_container.logs(
|
|
68
|
+
stdout=stream == "stdout",
|
|
69
|
+
stderr=stream == "stderr",
|
|
70
|
+
since=start_time - self._time_tolerance,
|
|
71
|
+
until=end_time + self._time_tolerance,
|
|
72
|
+
timestamps=False,
|
|
73
|
+
)
|
|
74
|
+
(artifact_directory / f"{filename}.{stream}.log").write_text(
|
|
75
|
+
log_bytes.decode("utf-8", errors="replace")
|
|
76
|
+
)
|
|
77
|
+
wrote_artifact = True
|
|
78
|
+
except Exception as error: # noqa: BLE001
|
|
79
|
+
wrote_artifact = (
|
|
80
|
+
self._write_collection_error(
|
|
81
|
+
artifact_directory / f"{filename}.error.txt", error
|
|
82
|
+
)
|
|
83
|
+
or wrote_artifact
|
|
84
|
+
)
|
|
85
|
+
return wrote_artifact
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _write_collection_error(path: Path, error: Exception) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
path.write_text(f"Could not collect container logs: {error}\n")
|
|
91
|
+
except OSError:
|
|
92
|
+
return False
|
|
93
|
+
return True
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
from TestcontainersLibrary.library import TestcontainersLibrary # noqa
|
|
File without changes
|