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
+ )
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.