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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotframework-testcontainers
3
- Version: 0.2.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: testcontainers[generic]>=4.13.2
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 created containers.
60
- It will take care of stopping all created containers at the end of the test,
61
- by making use of the `end_test`/`end_suite` listener methods.
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 created containers.
41
- It will take care of stopping all created containers at the end of the test,
42
- by making use of the `end_test`/`end_suite` listener methods.
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.2.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 = ["robotframework>=7.3.2", "testcontainers[generic]>=4.13.2"]
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>=1.18.2",
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]
@@ -0,0 +1,6 @@
1
+ # ruff: noqa: N999
2
+
3
+ from TestcontainersLibrary.library import TestcontainersLibrary
4
+ from TestcontainersLibrary.listener import FailedTestLogCollector
5
+
6
+ __all__ = ["FailedTestLogCollector", "TestcontainersLibrary"]
@@ -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 robot.api import logger
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
- from testcontainers.generic.server import ServerContainer
14
+
15
+ from TestcontainersLibrary.lifecycle import ResourceLifecycle
16
+ from TestcontainersLibrary.listener import LifecycleListener
11
17
 
12
18
 
13
- @library(listener="SELF")
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._containers: list[DockerContainer] = []
21
- self._networks: list[Network] = []
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
- try:
129
- container.start()
130
- self._containers.append(container)
131
- return container
132
- except Exception:
133
- container.stop()
134
- raise
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 instance will take care of stopping all containers it has
168
- started during it's lifecycle via listener methods.
218
+ The library stops containers automatically when the Robot test or suite
219
+ that started them ends.
169
220
  """
170
- self._containers.remove(container)
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 automatically at the end of the test/suite.
228
+ Created networks are cleaned up when the Robot test or suite that created
229
+ them ends.
179
230
  """
180
- network = Network()
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._networks.remove(network)
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