From bf2c8310b81854afa642d18a19cfacd98568004b Mon Sep 17 00:00:00 2001 From: Anton Petrov Date: Wed, 12 Nov 2025 21:18:12 +0200 Subject: [PATCH 1/4] feature: add list all running containers Signed-off-by: Anton Petrov --- podman_compose.py | 98 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/podman_compose.py b/podman_compose.py index 46725ffd..8058fa82 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -62,9 +62,13 @@ def filteri(a: list[str]) -> list[str]: @overload -def try_int(i: int | str, fallback: int) -> int: ... +def try_int(i: int | str, fallback: int) -> int: + ... + + @overload -def try_int(i: int | str, fallback: None) -> int | None: ... +def try_int(i: int | str, fallback: None) -> int | None: + ... def try_int(i: int | str, fallback: int | None = None) -> int | None: @@ -271,11 +275,18 @@ def fix_mount_dict( @overload -def rec_subs(value: dict, subs_dict: dict[str, Any]) -> dict: ... +def rec_subs(value: dict, subs_dict: dict[str, Any]) -> dict: + ... + + @overload -def rec_subs(value: str, subs_dict: dict[str, Any]) -> str: ... +def rec_subs(value: str, subs_dict: dict[str, Any]) -> str: + ... + + @overload -def rec_subs(value: Iterable, subs_dict: dict[str, Any]) -> Iterable: ... +def rec_subs(value: Iterable, subs_dict: dict[str, Any]) -> Iterable: + ... def rec_subs(value: dict | str | Iterable, subs_dict: dict[str, Any]) -> dict | str | Iterable: @@ -2569,7 +2580,11 @@ def _parse_args(self, argv: list[str] | None = None) -> argparse.Namespace: subparsers = parser.add_subparsers(title="command", dest="command") _ = subparsers.add_parser("help", help="show help") for cmd_name, cmd in self.commands.items(): - subparser = subparsers.add_parser(cmd_name, help=cmd.help, description=cmd.desc) # pylint: disable=protected-access + subparser = subparsers.add_parser( + cmd_name, + help=cmd.help, + description=cmd.desc + ) # pylint: disable=protected-access for cmd_parser in cmd._parse_args: # pylint: disable=protected-access cmd_parser(subparser) self.global_args = parser.parse_args(argv) @@ -2733,6 +2748,66 @@ def wrapped(*args: Any, **kw: Any) -> Any: # actual commands ################### +@cmd_run(podman_compose, "ls", "List running compose projects") +async def list_running_projects(compose: PodmanCompose, args: argparse.Namespace) -> None: + img_containers = [cnt for cnt in compose.containers if "image" in cnt] + parsed_args = vars(args) + _format = parsed_args.get("format", "table") + data = [] + if _format == "table": + data.append(["NAME", "STATUS", "CONFIG_FILES"]) + + for img in img_containers: + try: + name = img["name"] + output = await compose.podman.output( + [], + "inspect", + [ + name, + "--format", + ''' + {{ .State.Status }} + {{ .State.Running }} + {{ index .Config.Labels "com.docker.compose.project.working_dir" }} + {{ index .Config.Labels "com.docker.compose.project.config_files" }} + ''' + ], + ) + output = output.decode().split() + running = bool(json.loads(output[1])) + status = "{}({})".format(output[0], 1 if running else 0) + path = "{}/{}".format(output[2], output[3]) + + if _format == "table": + if isinstance(output, list): + data.append([name, status, path]) + + elif _format == "json": + # Replicate how docker compose returns the list + json_obj = { + "Name": name, + "Status": status, + "ConfigFiles": path + } + data.append(json_obj) + except Exception: + break + + if _format == "table": + # Determine the maximum length of each column + column_widths = [max(map(len, column)) for column in zip(*data)] + + # Print each row + for row in data: + # Format each cell using the maximum column width + formatted_row = [cell.ljust(width) for cell, width in zip(row, column_widths)] + formatted_row[-2:] = ["\t".join(formatted_row[-2:]).strip()] + print("\t".join(formatted_row)) + + elif _format == "json": + print(data) + @cmd_run(podman_compose, "version", "show version") async def compose_version(compose: PodmanCompose, args: argparse.Namespace) -> None: @@ -4381,6 +4456,17 @@ def compose_format_parse(parser: argparse.ArgumentParser) -> None: ) +@cmd_parse(podman_compose, "ls") +def compose_ls_parse(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "-f", + "--format", + choices=["table", "json"], + default="table", + help="Format the output", + ) + + async def async_main() -> None: await podman_compose.run() From a5eca77d3e464e79e0fbf3c5487dba4a69afbc81 Mon Sep 17 00:00:00 2001 From: Anton Petrov Date: Sun, 30 Nov 2025 14:08:20 +0200 Subject: [PATCH 2/4] add integration test --- tests/integration/list/__init__.py | 0 tests/integration/list/docker-compose.yml | 13 +++ .../list/test_podman_compose_list.py | 99 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 tests/integration/list/__init__.py create mode 100644 tests/integration/list/docker-compose.yml create mode 100644 tests/integration/list/test_podman_compose_list.py diff --git a/tests/integration/list/__init__.py b/tests/integration/list/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/list/docker-compose.yml b/tests/integration/list/docker-compose.yml new file mode 100644 index 00000000..99384f3e --- /dev/null +++ b/tests/integration/list/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.6' + +services: + service_1: + image: nopush/podman-compose-test + command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", ".", "-p", "8003"] + service_2: + image: nopush/podman-compose-test + command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", ".", "-p", "8003"] + service_3: + image: nopush/podman-compose-test + command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", ".", "-p", "8003"] + diff --git a/tests/integration/list/test_podman_compose_list.py b/tests/integration/list/test_podman_compose_list.py new file mode 100644 index 00000000..59fcd84f --- /dev/null +++ b/tests/integration/list/test_podman_compose_list.py @@ -0,0 +1,99 @@ +import ast +import os +import unittest + +from tests.integration.test_utils import RunSubprocessMixin +from tests.integration.test_utils import podman_compose_path +from tests.integration.test_utils import test_path + + +class TestPodmanComposeInclude(unittest.TestCase, RunSubprocessMixin): + def test_podman_compose_list(self) -> None: + """ + Test podman compose list (ls) command + """ + command_up = [ + "coverage", + "run", + podman_compose_path(), + "-f", + os.path.join(test_path(), "list", "docker-compose.yml"), + "up", + "-d", + ] + + command_list = [ + "coverage", + "run", + podman_compose_path(), + "-f", + os.path.join(test_path(), "list", "docker-compose.yml"), + "ls", + ] + + command_check_container = [ + "podman", + "ps", + "-a", + "--filter", + "label=io.podman.compose.project=list", + "--format", + '"{{.Image}}"', + ] + + command_container_id = [ + "podman", + "ps", + "-a", + "--filter", + "label=io.podman.compose.project=list", + "--format", + '"{{.ID}}"', + ] + + command_down = ["podman", "rm", "--force"] + + running_containers = [] + self.run_subprocess_assert_returncode(command_up) + out, _ = self.run_subprocess_assert_returncode(command_list) + out = out.decode() + + # Test for table view + services = out.strip().split("\n") + headers = [h.strip() for h in services[0].split("\t")] + + for service in services[1:]: + values = [val.strip() for val in service.split("\t")] + zipped = dict(zip(headers, values)) + self.assertNotEqual(zipped.get("NAME"), None) + self.assertNotEqual(zipped.get("STATUS"), None) + self.assertNotEqual(zipped.get("CONFIG_FILES"), None) + running_containers.append(zipped) + self.assertEqual(len(running_containers), 3) + + # Test for json view + command_list.extend(["--format", "json"]) + out, _ = self.run_subprocess_assert_returncode(command_list) + out = out.decode() + services = ast.literal_eval(out) + + for service in services: + self.assertIsInstance(service, dict) + self.assertNotEqual(service.get("Name"), None) + self.assertNotEqual(service.get("Status"), None) + self.assertNotEqual(service.get("ConfigFiles"), None) + + self.assertEqual(len(services), 3) + + # Get container ID to remove it + out, _ = self.run_subprocess_assert_returncode(command_container_id) + self.assertNotEqual(out, b"") + container_ids = out.decode().strip().split("\n") + container_ids = [container_id.replace('"', "") for container_id in container_ids] + command_down.extend(container_ids) + out, _ = self.run_subprocess_assert_returncode(command_down) + # cleanup test image(tags) + self.assertNotEqual(out, b"") + # check container did not exists anymore + out, _ = self.run_subprocess_assert_returncode(command_check_container) + self.assertEqual(out, b"") From 6e48502e31b67ec3938ecf4118a894c13a855370 Mon Sep 17 00:00:00 2001 From: Anton Petrov Date: Sun, 30 Nov 2025 14:11:42 +0200 Subject: [PATCH 3/4] fix ruff formatting --- podman_compose.py | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/podman_compose.py b/podman_compose.py index 8058fa82..fe958efc 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -62,13 +62,11 @@ def filteri(a: list[str]) -> list[str]: @overload -def try_int(i: int | str, fallback: int) -> int: - ... +def try_int(i: int | str, fallback: int) -> int: ... @overload -def try_int(i: int | str, fallback: None) -> int | None: - ... +def try_int(i: int | str, fallback: None) -> int | None: ... def try_int(i: int | str, fallback: int | None = None) -> int | None: @@ -275,18 +273,15 @@ def fix_mount_dict( @overload -def rec_subs(value: dict, subs_dict: dict[str, Any]) -> dict: - ... +def rec_subs(value: dict, subs_dict: dict[str, Any]) -> dict: ... @overload -def rec_subs(value: str, subs_dict: dict[str, Any]) -> str: - ... +def rec_subs(value: str, subs_dict: dict[str, Any]) -> str: ... @overload -def rec_subs(value: Iterable, subs_dict: dict[str, Any]) -> Iterable: - ... +def rec_subs(value: Iterable, subs_dict: dict[str, Any]) -> Iterable: ... def rec_subs(value: dict | str | Iterable, subs_dict: dict[str, Any]) -> dict | str | Iterable: @@ -2580,11 +2575,7 @@ def _parse_args(self, argv: list[str] | None = None) -> argparse.Namespace: subparsers = parser.add_subparsers(title="command", dest="command") _ = subparsers.add_parser("help", help="show help") for cmd_name, cmd in self.commands.items(): - subparser = subparsers.add_parser( - cmd_name, - help=cmd.help, - description=cmd.desc - ) # pylint: disable=protected-access + subparser = subparsers.add_parser(cmd_name, help=cmd.help, description=cmd.desc) # pylint: disable=protected-access for cmd_parser in cmd._parse_args: # pylint: disable=protected-access cmd_parser(subparser) self.global_args = parser.parse_args(argv) @@ -2748,6 +2739,7 @@ def wrapped(*args: Any, **kw: Any) -> Any: # actual commands ################### + @cmd_run(podman_compose, "ls", "List running compose projects") async def list_running_projects(compose: PodmanCompose, args: argparse.Namespace) -> None: img_containers = [cnt for cnt in compose.containers if "image" in cnt] @@ -2771,7 +2763,7 @@ async def list_running_projects(compose: PodmanCompose, args: argparse.Namespace {{ .State.Running }} {{ index .Config.Labels "com.docker.compose.project.working_dir" }} {{ index .Config.Labels "com.docker.compose.project.config_files" }} - ''' + ''', ], ) output = output.decode().split() @@ -2785,11 +2777,7 @@ async def list_running_projects(compose: PodmanCompose, args: argparse.Namespace elif _format == "json": # Replicate how docker compose returns the list - json_obj = { - "Name": name, - "Status": status, - "ConfigFiles": path - } + json_obj = {"Name": name, "Status": status, "ConfigFiles": path} data.append(json_obj) except Exception: break From 8df1df9ede1a570db2c8e739705d0be39122c6dd Mon Sep 17 00:00:00 2001 From: Anton Petrov Date: Sun, 30 Nov 2025 14:53:41 +0200 Subject: [PATCH 4/4] fix mypy errors --- podman_compose.py | 12 ++++++------ .../integration/list/test_podman_compose_list.py | 15 +++++++++------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/podman_compose.py b/podman_compose.py index fe958efc..d878e193 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -2745,7 +2745,7 @@ async def list_running_projects(compose: PodmanCompose, args: argparse.Namespace img_containers = [cnt for cnt in compose.containers if "image" in cnt] parsed_args = vars(args) _format = parsed_args.get("format", "table") - data = [] + data: list[Any] = [] if _format == "table": data.append(["NAME", "STATUS", "CONFIG_FILES"]) @@ -2766,13 +2766,13 @@ async def list_running_projects(compose: PodmanCompose, args: argparse.Namespace ''', ], ) - output = output.decode().split() - running = bool(json.loads(output[1])) - status = "{}({})".format(output[0], 1 if running else 0) - path = "{}/{}".format(output[2], output[3]) + command_output = output.decode().split() + running = bool(json.loads(command_output[1])) + status = "{}({})".format(command_output[0], 1 if running else 0) + path = "{}/{}".format(command_output[2], command_output[3]) if _format == "table": - if isinstance(output, list): + if isinstance(command_output, list): data.append([name, status, path]) elif _format == "json": diff --git a/tests/integration/list/test_podman_compose_list.py b/tests/integration/list/test_podman_compose_list.py index 59fcd84f..be5af27e 100644 --- a/tests/integration/list/test_podman_compose_list.py +++ b/tests/integration/list/test_podman_compose_list.py @@ -1,6 +1,7 @@ import ast import os import unittest +from typing import Union from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path @@ -52,14 +53,15 @@ def test_podman_compose_list(self) -> None: ] command_down = ["podman", "rm", "--force"] + service: Union[dict[str, str], str] running_containers = [] self.run_subprocess_assert_returncode(command_up) out, _ = self.run_subprocess_assert_returncode(command_list) - out = out.decode() + str_out = out.decode() # Test for table view - services = out.strip().split("\n") + services = str_out.strip().split("\n") headers = [h.strip() for h in services[0].split("\t")] for service in services[1:]: @@ -74,16 +76,17 @@ def test_podman_compose_list(self) -> None: # Test for json view command_list.extend(["--format", "json"]) out, _ = self.run_subprocess_assert_returncode(command_list) - out = out.decode() - services = ast.literal_eval(out) + str_out = out.decode() + json_services: list[dict] = ast.literal_eval(str_out) + self.assertIsInstance(json_services, list) - for service in services: + for service in json_services: self.assertIsInstance(service, dict) self.assertNotEqual(service.get("Name"), None) self.assertNotEqual(service.get("Status"), None) self.assertNotEqual(service.get("ConfigFiles"), None) - self.assertEqual(len(services), 3) + self.assertEqual(len(json_services), 3) # Get container ID to remove it out, _ = self.run_subprocess_assert_returncode(command_container_id)