Skip to content

Commit 4d88ea8

Browse files
committed
Introduce PodmanCommand
Introduce the PodmanCommand class, which is responsible for providing a Pythonic interface over the `podman` binary. The only prerequisite is that the `podman` binary must be installed in the user's system. More specifically, this class brings the following improvements to the codebase: 1. Allows users to run arbitrary Podman commands, in a more friendly interface than subprocess.run(). 2. Provides a Pythonic interface for `podman machine` commands, where arguments are type-checked and results are native Python objects. 3. Allows Linux users to easily start a Podman REST API using a new context manager (`with PodmanCommand.service()`) As an example, Linux users can now do the following: import podman import os XDG_RUNTIME_DIR = os.environ.get("XDG_RUNTIME_DIR", "/tmp") SOCK_URI = f"unix:///{XDG_RUNTIME_DIR}/podman/podman.sock" # Run an arbitrary Podman command. command = podman.PodmanCommand() version = command.run(["--version"]) print(f"Podman version is: {version}") # Start an API service and connect the PodmanClient to it. with command.service(uri=SOCK_URI) as p: with podman.PodmanClient(base_url=SOCK_URI) as client: containers = client.containers.list() print(f"Containers: {containers}") Also, macOS users can work more easily with Podman machines: import podman # Run `podman machine` commands through a Pythonic interface. command = podman.PodmanCommand() command.options.log_level = "debug" command.machine.init("dz", now=True, capture_output=False) machines = command.machine.list() print(f"Machines: {machines}") # Or run arbitrary Podman commands for which podman-py has no support yet. connection = command.run(["system", "connection", "list"]).split("\n")[1] uri = connection.split()[1] identity = connection.split()[2] print(f"Using socket URI {uri}") print(f"Using identity {identity}") # Start an API service and connect the PodmanClient to it. with podman.PodmanClient(base_url=uri, identity=identity) as client: containers = client.containers.list() print(f"Containers: {containers}") Refs #545 Signed-off-by: Alex Pyrgiotis <[email protected]>
1 parent bfc70e6 commit 4d88ea8

File tree

7 files changed

+668
-1
lines changed

7 files changed

+668
-1
lines changed

podman/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Podman client module."""
22

33
from podman.client import PodmanClient, from_env
4+
from podman.command import PodmanCommand
45
from podman.version import __version__
56

67
# isort: unique-list
7-
__all__ = ['PodmanClient', '__version__', 'from_env']
8+
__all__ = ['PodmanClient', 'PodmanCommand', '__version__', 'from_env']

podman/command/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .command import PodmanCommand
2+
from .cli_runner import GlobalOptions
3+
4+
__all__ = ["PodmanCommand", "GlobalOptions"]

podman/command/cli_runner.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import dataclasses
2+
import logging
3+
import os
4+
import platform
5+
import shlex
6+
import shutil
7+
import subprocess
8+
from pathlib import Path
9+
from typing import Optional, Union
10+
11+
from .. import errors
12+
13+
logger = logging.getLogger("podman.command.cli_runner")
14+
15+
16+
@dataclasses.dataclass
17+
class GlobalOptions:
18+
"""Global options for Podman commands.
19+
20+
Attributes:
21+
cdi_spec_dir (Union[str, Path, list[str], list[Path], None]): The CDI spec directory path (can be a list of paths).
22+
cgroup_manager: CGroup manager to use.
23+
config: Location of config file, mainly for Docker compatibility.
24+
conmon: Path to the conmon binary.
25+
connection: Connection to use for remote Podman.
26+
events_backend: Backend to use for storing events.
27+
hooks_dir: Directory for hooks (can be a list of directories).
28+
identity: Path to SSH identity file.
29+
imagestore: Path to the image store.
30+
log_level: Logging level.
31+
module: Load a containers.conf module.
32+
network_cmd_path: Path to slirp4netns command.
33+
network_config_dir: Path to network config directory.
34+
remote: When true, access to the Podman service is remote.
35+
root: Storage root dir in which data, including images, is stored
36+
runroot: Storage state directory where all state information is stored
37+
runtime: Name or path of the OCI runtime.
38+
runtime_flag: Global flags for the container runtime
39+
ssh: Change SSH mode.
40+
storage_driver: Storage driver to use.
41+
storage_opt: Storage options.
42+
syslog: Output logging information to syslog as well as the console.
43+
tmpdir: Path to the tmp directory, for libpod runtime content.
44+
transient_store: Whether to use a transient store.
45+
url: URL for Podman service.
46+
volumepath: Volume directory where builtin volume information is stored
47+
"""
48+
49+
cdi_spec_dir: Union[str, Path, list[str], list[Path], None] = None
50+
cgroup_manager: Union[str, None] = None
51+
config: Union[str, Path, None] = None
52+
conmon: Union[str, Path, None] = None
53+
connection: Union[str, None] = None
54+
events_backend: Union[str, None] = None
55+
hooks_dir: Union[str, Path, list[str], list[Path], None] = None
56+
identity: Union[str, Path, None] = None
57+
imagestore: Union[str, None] = None
58+
log_level: Union[str, None] = None
59+
module: Union[str, None] = None
60+
network_cmd_path: Union[str, Path, None] = None
61+
network_config_dir: Union[str, Path, None] = None
62+
remote: Union[bool, None] = None
63+
root: Union[str, Path, None] = None
64+
runroot: Union[str, Path, None] = None
65+
runtime: Union[str, Path, None] = None
66+
runtime_flag: Union[str, list[str], None] = None
67+
ssh: Union[str, None] = None
68+
storage_driver: Union[str, None] = None
69+
storage_opt: Union[str, list[str], None] = None
70+
syslog: Union[bool, None] = None
71+
tmpdir: Union[str, Path, None] = None
72+
transient_store: Union[bool, None] = False
73+
url: Union[str, None] = None
74+
volumepath: Union[str, Path, None] = None
75+
76+
77+
def get_subprocess_startupinfo():
78+
if platform.system() == "Windows":
79+
startupinfo = subprocess.STARTUPINFO()
80+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
81+
return startupinfo
82+
else:
83+
return None
84+
85+
86+
class Runner:
87+
"""Runner class to execute Podman commands.
88+
89+
Attributes:
90+
podman_path (Path): Path to the Podman executable.
91+
privileged (bool): Whether to run commands with elevated privileges.
92+
options (GlobalOptions): Global options for Podman commands.
93+
env (dict): Environment variables for the subprocess.
94+
"""
95+
96+
def __init__(
97+
self,
98+
path: Path = None,
99+
privileged: bool = False,
100+
options: GlobalOptions = None,
101+
env: dict = None,
102+
):
103+
"""Initialize the Runner.
104+
105+
Args:
106+
path (Path, optional): Path to the Podman executable. Defaults to the system path.
107+
privileged (bool, optional): Whether to run commands with elevated privileges. Defaults to False.
108+
options (GlobalOptions, optional): Global options for Podman commands. Defaults to None.
109+
env (dict, optional): Environment variables for the subprocess. Defaults to None.
110+
111+
Raises:
112+
errors.PodmanNotInstalled: If Podman is not installed.
113+
"""
114+
if path is None:
115+
path = shutil.which("podman")
116+
if path is None:
117+
raise errors.PodmanNotInstalled()
118+
Path(path)
119+
120+
self.podman_path = path
121+
if privileged and platform.system() == "Windows":
122+
raise errors.PodmanError("Cannot run privileged Podman command on Windows")
123+
self.privileged = privileged
124+
self.options = options
125+
self.env = env
126+
127+
def display(self, cmd):
128+
"""Display a list of command-line options as a single command invocation."""
129+
parts = [str(part) for part in cmd]
130+
return shlex.join(parts)
131+
132+
def format_cli_opts(self, *args, **kwargs) -> list[str]:
133+
"""Format Pythonic arguments into command-line options for the Podman command.
134+
135+
Args:
136+
*args: Positional arguments to format.
137+
**kwargs: Keyword arguments to format.
138+
139+
Returns:
140+
list[str]: A list of formatted command-line options.
141+
"""
142+
cmd = []
143+
# Positional arguments (*args) are added as is, provided that they are
144+
# defined.
145+
for arg in args:
146+
if arg is not None:
147+
cmd.append(arg)
148+
149+
for arg, value in kwargs.items():
150+
option_name = "--" + arg.replace("_", "-")
151+
if value is True:
152+
# Options like cli_flag=True get converted to ["--cli-flag"].
153+
cmd.append(option_name)
154+
elif isinstance(value, list):
155+
# Options like cli_flag=["foo", "bar"] get converted to
156+
# ["--cli-flag", "foo", "--cli-flag", "bar"].
157+
for v in value:
158+
cmd += [option_name, str(v)]
159+
elif value is not None and value is not False:
160+
# Options like cli_flag="foo" get converted to
161+
# ["--cli-flag", "foo"].
162+
cmd += [option_name, str(value)]
163+
return cmd
164+
165+
def construct(self, *args, **kwargs) -> list[str]:
166+
"""Construct the full command to run.
167+
168+
Construct the base Podman command, along with the global CLI options.
169+
Then, format the Pythonic arguments for the Podman command
170+
(*args/**kwargs) and append them to the final command.
171+
172+
Args:
173+
*args: Positional arguments for the command.
174+
**kwargs: Keyword arguments for the command.
175+
176+
Returns:
177+
list[str]: The constructed command as a list of strings.
178+
"""
179+
cmd = []
180+
if self.privileged:
181+
cmd.append("sudo")
182+
183+
cmd.append(str(self.podman_path))
184+
185+
if self.options:
186+
cmd += self.format_cli_opts(**dataclasses.asdict(self.options))
187+
188+
cmd += self.format_cli_opts(*args, **kwargs)
189+
return cmd
190+
191+
def run(
192+
self,
193+
cmd: list[str],
194+
*,
195+
check: bool = True,
196+
capture_output=True,
197+
wait=True,
198+
**skwargs,
199+
) -> Union[str, subprocess.Popen]:
200+
"""Run the specified Podman command.
201+
202+
Args:
203+
cmd (list[str]): The command to run, as a list of strings.
204+
check (bool, optional): Whether to check for errors. Defaults to True.
205+
capture_output (bool, optional): Whether to capture output. Defaults to True.
206+
wait (bool, optional): Whether to wait for the command to complete. Defaults to True.
207+
**skwargs: Additional keyword arguments for subprocess.
208+
209+
Returns:
210+
Optional[str]: The output of the command if captured, otherwise the
211+
subprocess.Popen instance.
212+
213+
Raises:
214+
errors.CommandError: If the command fails.
215+
"""
216+
cmd = self.construct() + cmd
217+
return self.run_raw(cmd, check=check, capture_output=capture_output, wait=wait, **skwargs)
218+
219+
def run_raw(
220+
self,
221+
cmd: list[str],
222+
*,
223+
check: bool = True,
224+
capture_output=True,
225+
stdin=subprocess.DEVNULL,
226+
wait=True,
227+
**skwargs,
228+
) -> Union[str, subprocess.Popen]:
229+
"""Run the command without additional construction. Mostly for internal use.
230+
231+
Args:
232+
cmd (list[str]): The full command to run.
233+
check (bool, optional): Whether to check for errors. Defaults to True.
234+
capture_output (bool, optional): Whether to capture output. Defaults to True.
235+
stdin: Control the process' stdin. Disabled by default, to avoid hanging commands.
236+
wait (bool, optional): Whether to wait for the command to complete. Defaults to True.
237+
**skwargs: Additional keyword arguments for subprocess.
238+
239+
Returns:
240+
Optional[str]: The output of the command if captured, otherwise the
241+
subprocess.Popen instance.
242+
243+
Raises:
244+
errors.CommandError: If the command fails.
245+
"""
246+
logger.debug(f"Running: {self.display(cmd)}")
247+
if not wait:
248+
return subprocess.Popen(
249+
cmd,
250+
env=self.env,
251+
startupinfo=get_subprocess_startupinfo(),
252+
**skwargs,
253+
)
254+
255+
try:
256+
ret = subprocess.run(
257+
cmd,
258+
env=self.env,
259+
check=check,
260+
capture_output=capture_output,
261+
stdin=stdin,
262+
startupinfo=get_subprocess_startupinfo(),
263+
**skwargs,
264+
)
265+
except subprocess.CalledProcessError as e:
266+
raise errors.CommandError(e) from e
267+
if capture_output:
268+
return ret.stdout.decode().rstrip()

0 commit comments

Comments
 (0)