pytest-podman 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from .plugin import (
|
|
4
|
+
podman_cleanup,
|
|
5
|
+
podman_compose_command,
|
|
6
|
+
podman_compose_file,
|
|
7
|
+
podman_compose_project_name,
|
|
8
|
+
podman_ip,
|
|
9
|
+
podman_services,
|
|
10
|
+
podman_setup,
|
|
11
|
+
Services,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"podman_compose_command",
|
|
16
|
+
"podman_compose_file",
|
|
17
|
+
"podman_compose_project_name",
|
|
18
|
+
"podman_ip",
|
|
19
|
+
"podman_setup",
|
|
20
|
+
"podman_cleanup",
|
|
21
|
+
"podman_services",
|
|
22
|
+
"Services",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
27
|
+
group = parser.getgroup("podman")
|
|
28
|
+
group.addoption(
|
|
29
|
+
"--container-scope",
|
|
30
|
+
type=str,
|
|
31
|
+
action="store",
|
|
32
|
+
default="session",
|
|
33
|
+
help="The pytest fixture scope for reusing containers between tests."
|
|
34
|
+
" For available scopes and descriptions, "
|
|
35
|
+
" see https://docs.pytest.org/en/6.2.x/fixture.html#fixture-scopes",
|
|
36
|
+
)
|
pytest_podman/plugin.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
import timeit
|
|
8
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
|
9
|
+
|
|
10
|
+
import attr
|
|
11
|
+
import pytest
|
|
12
|
+
from _pytest.config import Config
|
|
13
|
+
from _pytest.fixtures import FixtureRequest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def container_scope_fixture(request: FixtureRequest) -> Any:
|
|
18
|
+
return request.config.getoption("--container-scope")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def containers_scope(fixture_name: str, config: Config) -> Any:
|
|
22
|
+
return config.getoption("--container-scope", "session")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def execute(
|
|
26
|
+
command: str, success_codes: Iterable[int] = (0,), ignore_stderr: bool = False
|
|
27
|
+
) -> Union[bytes, Any]:
|
|
28
|
+
"""Run a shell command."""
|
|
29
|
+
try:
|
|
30
|
+
stderr_pipe = subprocess.DEVNULL if ignore_stderr else subprocess.STDOUT
|
|
31
|
+
output = subprocess.check_output(command, stderr=stderr_pipe, shell=True)
|
|
32
|
+
status = 0
|
|
33
|
+
except subprocess.CalledProcessError as error:
|
|
34
|
+
output = error.output or b""
|
|
35
|
+
status = error.returncode
|
|
36
|
+
command = error.cmd
|
|
37
|
+
|
|
38
|
+
if status not in success_codes:
|
|
39
|
+
raise Exception(
|
|
40
|
+
'Command {} returned {}: """{}""".'.format(
|
|
41
|
+
command, status, output.decode("utf-8")
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return output
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_podman_ip() -> Union[str, Any]:
|
|
48
|
+
# When talking to the podman daemon via a UNIX socket, route all TCP
|
|
49
|
+
# traffic to podman containers via the TCP loopback interface.
|
|
50
|
+
podman_host = os.environ.get("podman_HOST", "").strip()
|
|
51
|
+
if not podman_host or podman_host.startswith("unix://"):
|
|
52
|
+
return "127.0.0.1"
|
|
53
|
+
|
|
54
|
+
# Return just plain address without prefix and port
|
|
55
|
+
return re.sub(r"^[^:]+://(.+):\d+$", r"\1", podman_host)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture(scope=containers_scope)
|
|
59
|
+
def podman_ip() -> Union[str, Any]:
|
|
60
|
+
"""Determine the IP address for TCP connections to podman containers."""
|
|
61
|
+
|
|
62
|
+
return get_podman_ip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@attr.s(frozen=True)
|
|
66
|
+
class Services:
|
|
67
|
+
_podman_compose: Any = attr.ib()
|
|
68
|
+
_services: Dict[Any, Dict[Any, Any]] = attr.ib(
|
|
69
|
+
init=False, default=attr.Factory(dict)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def port_for(self, service: str, container_port: int) -> int:
|
|
73
|
+
"""Return the "host" port for `service` and `container_port`.
|
|
74
|
+
|
|
75
|
+
E.g. If the service is defined like this:
|
|
76
|
+
|
|
77
|
+
version: '2'
|
|
78
|
+
services:
|
|
79
|
+
httpbin:
|
|
80
|
+
build: .
|
|
81
|
+
ports:
|
|
82
|
+
- "8000:80"
|
|
83
|
+
|
|
84
|
+
this method will return 8000 for container_port=80.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
# Lookup in the cache.
|
|
88
|
+
cache: Optional[int] = self._services.get(service, {}).get(container_port, None)
|
|
89
|
+
if cache is not None:
|
|
90
|
+
return cache
|
|
91
|
+
|
|
92
|
+
output = self._podman_compose.execute("port %s %d" % (service, container_port))
|
|
93
|
+
endpoint = output.strip().decode("utf-8")
|
|
94
|
+
if not endpoint:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
'Could not detect port for "%s:%d".' % (service, container_port)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# This handles messy output that might contain warnings or other text
|
|
100
|
+
if len(endpoint.split("\n")) > 1:
|
|
101
|
+
endpoint = endpoint.split("\n")[-1]
|
|
102
|
+
|
|
103
|
+
# Usually, the IP address here is 0.0.0.0, so we don't use it.
|
|
104
|
+
match = int(endpoint.split(":", 1)[-1])
|
|
105
|
+
|
|
106
|
+
# Store it in cache in case we request it multiple times.
|
|
107
|
+
self._services.setdefault(service, {})[container_port] = match
|
|
108
|
+
|
|
109
|
+
return match
|
|
110
|
+
|
|
111
|
+
def wait_until_responsive(
|
|
112
|
+
self,
|
|
113
|
+
check: Any,
|
|
114
|
+
timeout: float,
|
|
115
|
+
pause: float,
|
|
116
|
+
clock: Any = timeit.default_timer,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Wait until a service is responsive."""
|
|
119
|
+
|
|
120
|
+
ref = clock()
|
|
121
|
+
now = ref
|
|
122
|
+
while (now - ref) < timeout:
|
|
123
|
+
if check():
|
|
124
|
+
return
|
|
125
|
+
time.sleep(pause)
|
|
126
|
+
now = clock()
|
|
127
|
+
|
|
128
|
+
raise Exception("Timeout reached while waiting on service!")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def str_to_list(
|
|
132
|
+
arg: Union[str, Path, List[Any], Tuple[Any]],
|
|
133
|
+
) -> Union[List[Any], Tuple[Any]]:
|
|
134
|
+
if isinstance(arg, (list, tuple)):
|
|
135
|
+
return arg
|
|
136
|
+
return [arg]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@attr.s(frozen=True)
|
|
140
|
+
class podmanComposeExecutor:
|
|
141
|
+
_compose_command: str = attr.ib()
|
|
142
|
+
_compose_files: Any = attr.ib(converter=str_to_list)
|
|
143
|
+
_compose_project_name: str = attr.ib()
|
|
144
|
+
|
|
145
|
+
def execute(self, subcommand: str, **kwargs: Any) -> Union[bytes, Any]:
|
|
146
|
+
command = self._compose_command
|
|
147
|
+
for compose_file in self._compose_files:
|
|
148
|
+
command += ' -f "{}"'.format(compose_file)
|
|
149
|
+
command += ' --project-name "{}" {}'.format(
|
|
150
|
+
self._compose_project_name, subcommand
|
|
151
|
+
)
|
|
152
|
+
return execute(command, **kwargs)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.fixture(scope=containers_scope)
|
|
156
|
+
def podman_compose_command() -> str:
|
|
157
|
+
return "podman compose"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.fixture(scope=containers_scope)
|
|
161
|
+
def podman_compose_file(pytestconfig: Any) -> Union[List[str], str]:
|
|
162
|
+
"""Get an absolute path to the `podman-compose.yml` file. Override this
|
|
163
|
+
fixture in your tests if you need a custom location."""
|
|
164
|
+
|
|
165
|
+
return os.path.join(str(pytestconfig.rootdir), "tests", "podman-compose.yml")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.fixture(scope=containers_scope)
|
|
169
|
+
def podman_compose_project_name() -> str:
|
|
170
|
+
"""Generate a project name using the current process PID. Override this
|
|
171
|
+
fixture in your tests if you need a particular project name."""
|
|
172
|
+
|
|
173
|
+
return "pytest{}".format(os.getpid())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_cleanup_command() -> Union[List[str], str]:
|
|
177
|
+
return ["down -v"]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pytest.fixture(scope=containers_scope)
|
|
181
|
+
def podman_cleanup() -> Union[List[str], str]:
|
|
182
|
+
"""Get the podman_compose command to be executed for test clean-up actions.
|
|
183
|
+
Override this fixture in your tests if you need to change clean-up actions.
|
|
184
|
+
Returning anything that would evaluate to False will skip this command."""
|
|
185
|
+
|
|
186
|
+
return get_cleanup_command()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_setup_command() -> Union[List[str], str]:
|
|
190
|
+
return ["up --build -d"]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.fixture(scope=containers_scope)
|
|
194
|
+
def podman_setup() -> Union[List[str], str]:
|
|
195
|
+
"""Get the podman_compose command to be executed for test setup actions.
|
|
196
|
+
Override this fixture in your tests if you need to change setup actions.
|
|
197
|
+
Returning anything that would evaluate to False will skip this command."""
|
|
198
|
+
|
|
199
|
+
return get_setup_command()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@contextlib.contextmanager
|
|
203
|
+
def get_podman_services(
|
|
204
|
+
podman_compose_command: str,
|
|
205
|
+
podman_compose_file: Union[List[str], str],
|
|
206
|
+
podman_compose_project_name: str,
|
|
207
|
+
podman_setup: Union[List[str], str],
|
|
208
|
+
podman_cleanup: Union[List[str], str],
|
|
209
|
+
) -> Iterator[Services]:
|
|
210
|
+
podman_compose = podmanComposeExecutor(
|
|
211
|
+
podman_compose_command, podman_compose_file, podman_compose_project_name
|
|
212
|
+
)
|
|
213
|
+
print('step 1: setup podman services...')
|
|
214
|
+
|
|
215
|
+
# setup containers.
|
|
216
|
+
if podman_setup:
|
|
217
|
+
print('step 2: executing podman setup commands...')
|
|
218
|
+
# Maintain backwards compatibility with the string format.
|
|
219
|
+
if isinstance(podman_setup, str):
|
|
220
|
+
print('step 3: podman setup is string, converting to list...')
|
|
221
|
+
podman_setup = [podman_setup]
|
|
222
|
+
for command in podman_setup:
|
|
223
|
+
print('step 4: executing command:', command)
|
|
224
|
+
podman_compose.execute(command)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
# Let test(s) run.
|
|
228
|
+
yield Services(podman_compose)
|
|
229
|
+
finally:
|
|
230
|
+
# Clean up.
|
|
231
|
+
if podman_cleanup:
|
|
232
|
+
# Maintain backwards compatibility with the string format.
|
|
233
|
+
if isinstance(podman_cleanup, str):
|
|
234
|
+
podman_cleanup = [podman_cleanup]
|
|
235
|
+
for command in podman_cleanup:
|
|
236
|
+
podman_compose.execute(command)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.fixture(scope=containers_scope)
|
|
240
|
+
def podman_services(
|
|
241
|
+
podman_compose_command: str,
|
|
242
|
+
podman_compose_file: Union[List[str], str],
|
|
243
|
+
podman_compose_project_name: str,
|
|
244
|
+
podman_setup: str,
|
|
245
|
+
podman_cleanup: str,
|
|
246
|
+
) -> Iterator[Services]:
|
|
247
|
+
"""Start all services from a podman compose file (`podman-compose up`).
|
|
248
|
+
After test are finished, shutdown all services (`podman-compose down`)."""
|
|
249
|
+
|
|
250
|
+
with get_podman_services(
|
|
251
|
+
podman_compose_command,
|
|
252
|
+
podman_compose_file,
|
|
253
|
+
podman_compose_project_name,
|
|
254
|
+
podman_setup,
|
|
255
|
+
podman_cleanup,
|
|
256
|
+
) as podman_service:
|
|
257
|
+
yield podman_service
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest_podman
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Pytest plugin for Podman integration
|
|
5
|
+
Project-URL: Homepage, https://github.com/azeddinebouabdallah/pytest_podman
|
|
6
|
+
Project-URL: Issues, https://github.com/azeddinebouabdallah/pytest_podman/issues
|
|
7
|
+
Author-email: Azeddine Bouabdallah <bouabdallahazeddine@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Pytest Podman Plugin
|
|
16
|
+
|
|
17
|
+
A Pytest plugin for managing Podman containers during tests, similar to `pytest-docker` but tailored for Podman. It simplifies integration testing by automatically starting and stopping services defined in a `podman-compose.yml` file.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Automatic Lifecycle Management**: Starts services (`podman compose up`) before tests and stops them (`podman compose down`) afterwards.
|
|
22
|
+
- **Service Discovery**: Helper methods to find the host port mapped to a container port.
|
|
23
|
+
- **Readiness Waiting**: Built-in mechanism to wait for services to become responsive.
|
|
24
|
+
- **Scoped Fixtures**: Configurable scope (default: `session`) to share containers across tests or restart them per test.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
You can install the plugin via pip:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install podman_pytest
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### 1. Create a `podman-compose.yml`
|
|
37
|
+
|
|
38
|
+
Create a `podman-compose.yml` file in your `tests/` directory (or configure the path).
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
version: '3'
|
|
42
|
+
services:
|
|
43
|
+
httpbin:
|
|
44
|
+
image: kennethreitz/httpbin
|
|
45
|
+
ports:
|
|
46
|
+
- "80"
|
|
47
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pytest_podman/__init__.py,sha256=G8EfJeBnwe5pxMWcfgwgRokN4xp2MD1E4M_tjTtp7IE,880
|
|
2
|
+
pytest_podman/plugin.py,sha256=g9AHSTqxdlnc7NO5xgYneObDQYChboy6F5otUkXXcHw,8418
|
|
3
|
+
pytest_podman-0.0.1.dist-info/METADATA,sha256=CEsYLL3YRKNeCC3u3mGMBiYx41AnhNernhkhMj_KuF4,1579
|
|
4
|
+
pytest_podman-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
5
|
+
pytest_podman-0.0.1.dist-info/licenses/LICENSE,sha256=3pMWR9GBhZDKM7ipjmBEUa2HfkbiPKwvYR_TYYycG5c,1096
|
|
6
|
+
pytest_podman-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Azeddine Bouabdallah
|
|
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.
|