diff --git a/podman/domain/containers_manager.py b/podman/domain/containers_manager.py index f41585a7..ac9b80e6 100644 --- a/podman/domain/containers_manager.py +++ b/podman/domain/containers_manager.py @@ -2,8 +2,8 @@ import logging import urllib -from typing import Any, Union from collections.abc import Mapping +from typing import Any, Union from podman import api from podman.domain.containers import Container @@ -27,12 +27,15 @@ def exists(self, key: str) -> bool: response = self.client.get(f"/containers/{key}/exists") return response.ok - def get(self, key: str) -> Container: + def get(self, key: str, **kwargs) -> Container: """Get container by name or id. Args: key: Container name or id. + Keyword Args: + compatible (bool): Use Docker compatibility endpoint + Returns: A `Container` object corresponding to `key`. @@ -40,8 +43,10 @@ def get(self, key: str) -> Container: NotFound: when Container does not exist APIError: when an error return by service """ + compatible = kwargs.get("compatible", False) + container_id = urllib.parse.quote_plus(key) - response = self.client.get(f"/containers/{container_id}/json") + response = self.client.get(f"/containers/{container_id}/json", compatible=compatible) response.raise_for_status() return self.prepare_model(attrs=response.json()) @@ -67,12 +72,26 @@ def list(self, **kwargs) -> list[Container]: Give the container name or id. - since (str): Only containers created after a particular container. Give container name or id. - sparse: Ignored + sparse: If False, return basic container information without additional + inspection requests. This improves performance when listing many containers + but might provide less detail. You can call Container.reload() on individual + containers later to retrieve complete attributes. Default: True. + When Docker compatibility is enabled with `compatible=True`: Default: False. ignore_removed: If True, ignore failures due to missing containers. Raises: APIError: when service returns an error """ + compatible = kwargs.get("compatible", False) + + # Set sparse default based on mode: + # Libpod behavior: default is sparse=True (faster, requires reload for full details) + # Docker behavior: default is sparse=False (full details immediately, compatible) + if "sparse" in kwargs: + sparse = kwargs["sparse"] + else: + sparse = not compatible # True for libpod, False for compat + params = { "all": kwargs.get("all"), "filters": kwargs.get("filters", {}), @@ -86,10 +105,21 @@ def list(self, **kwargs) -> list[Container]: # filters formatted last because some kwargs may need to be mapped into filters params["filters"] = api.prepare_filters(params["filters"]) - response = self.client.get("/containers/json", params=params) + response = self.client.get("/containers/json", params=params, compatible=compatible) response.raise_for_status() - return [self.prepare_model(attrs=i) for i in response.json()] + containers: list[Container] = [self.prepare_model(attrs=i) for i in response.json()] + + # If sparse is False, reload each container to get full details + if not sparse: + for container in containers: + try: + container.reload(compatible=compatible) + except APIError: + # Skip containers that might have been removed + pass + + return containers def prune(self, filters: Mapping[str, str] = None) -> dict[str, Any]: """Delete stopped containers. diff --git a/podman/domain/manager.py b/podman/domain/manager.py index ffbad3c4..40ca9d40 100644 --- a/podman/domain/manager.py +++ b/podman/domain/manager.py @@ -67,9 +67,13 @@ def short_id(self): return self.id[:17] return self.id[:10] - def reload(self) -> None: - """Refresh this object's data from the service.""" - latest = self.manager.get(self.id) + def reload(self, **kwargs) -> None: + """Refresh this object's data from the service. + + Keyword Args: + compatible (bool): Use Docker compatibility endpoint + """ + latest = self.manager.get(self.id, **kwargs) self.attrs = latest.attrs diff --git a/podman/tests/unit/test_containersmanager.py b/podman/tests/unit/test_containersmanager.py index 47b30482..45df6cea 100644 --- a/podman/tests/unit/test_containersmanager.py +++ b/podman/tests/unit/test_containersmanager.py @@ -8,7 +8,7 @@ # Python < 3.10 from collections.abc import Iterator -from unittest.mock import DEFAULT, patch, MagicMock +from unittest.mock import DEFAULT, MagicMock, patch import requests_mock @@ -154,6 +154,134 @@ def test_list_no_filters(self, mock): actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03" ) + @requests_mock.Mocker() + def test_list_sparse_libpod_default(self, mock): + mock.get( + tests.LIBPOD_URL + "/containers/json", + json=[FIRST_CONTAINER, SECOND_CONTAINER], + ) + actual = self.client.containers.list() + self.assertIsInstance(actual, list) + + self.assertEqual( + actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd" + ) + self.assertEqual( + actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03" + ) + + # Verify that no individual reload() calls were made for sparse=True (default) + # Should be only 1 request for the list endpoint + self.assertEqual(len(mock.request_history), 1) + # lower() needs to be enforced since the mocked url is transformed as lowercase and + # this avoids %2f != %2F errors. Same applies for other instances of assertEqual + self.assertEqual(mock.request_history[0].url, tests.LIBPOD_URL.lower() + "/containers/json") + + @requests_mock.Mocker() + def test_list_sparse_libpod_false(self, mock): + mock.get( + tests.LIBPOD_URL + "/containers/json", + json=[FIRST_CONTAINER, SECOND_CONTAINER], + ) + # Mock individual container detail endpoints for reload() calls + # that are done for sparse=False + mock.get( + tests.LIBPOD_URL + f"/containers/{FIRST_CONTAINER['Id']}/json", + json=FIRST_CONTAINER, + ) + mock.get( + tests.LIBPOD_URL + f"/containers/{SECOND_CONTAINER['Id']}/json", + json=SECOND_CONTAINER, + ) + actual = self.client.containers.list(sparse=False) + self.assertIsInstance(actual, list) + + self.assertEqual( + actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd" + ) + self.assertEqual( + actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03" + ) + + # Verify that individual reload() calls were made for sparse=False + # Should be 3 requests total: 1 for list + 2 for individual container details + self.assertEqual(len(mock.request_history), 3) + + # Verify the list endpoint was called first + self.assertEqual(mock.request_history[0].url, tests.LIBPOD_URL.lower() + "/containers/json") + + # Verify the individual container detail endpoints were called + individual_urls = {req.url for req in mock.request_history[1:]} + expected_urls = { + tests.LIBPOD_URL.lower() + f"/containers/{FIRST_CONTAINER['Id']}/json", + tests.LIBPOD_URL.lower() + f"/containers/{SECOND_CONTAINER['Id']}/json", + } + self.assertEqual(individual_urls, expected_urls) + + @requests_mock.Mocker() + def test_list_sparse_compat_default(self, mock): + mock.get( + tests.COMPATIBLE_URL + "/containers/json", + json=[FIRST_CONTAINER, SECOND_CONTAINER], + ) + # Mock individual container detail endpoints for reload() calls + # that are done for sparse=False + mock.get( + tests.COMPATIBLE_URL + f"/containers/{FIRST_CONTAINER['Id']}/json", + json=FIRST_CONTAINER, + ) + mock.get( + tests.COMPATIBLE_URL + f"/containers/{SECOND_CONTAINER['Id']}/json", + json=SECOND_CONTAINER, + ) + actual = self.client.containers.list(compatible=True) + self.assertIsInstance(actual, list) + + self.assertEqual( + actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd" + ) + self.assertEqual( + actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03" + ) + + # Verify that individual reload() calls were made for compat default (sparse=True) + # Should be 3 requests total: 1 for list + 2 for individual container details + self.assertEqual(len(mock.request_history), 3) + self.assertEqual( + mock.request_history[0].url, tests.COMPATIBLE_URL.lower() + "/containers/json" + ) + + # Verify the individual container detail endpoints were called + individual_urls = {req.url for req in mock.request_history[1:]} + expected_urls = { + tests.COMPATIBLE_URL.lower() + f"/containers/{FIRST_CONTAINER['Id']}/json", + tests.COMPATIBLE_URL.lower() + f"/containers/{SECOND_CONTAINER['Id']}/json", + } + self.assertEqual(individual_urls, expected_urls) + + @requests_mock.Mocker() + def test_list_sparse_compat_true(self, mock): + mock.get( + tests.COMPATIBLE_URL + "/containers/json", + json=[FIRST_CONTAINER, SECOND_CONTAINER], + ) + actual = self.client.containers.list(sparse=True, compatible=True) + self.assertIsInstance(actual, list) + + self.assertEqual( + actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd" + ) + self.assertEqual( + actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03" + ) + + # Verify that no individual reload() calls were made for sparse=True + # Should be only 1 request for the list endpoint + self.assertEqual(len(mock.request_history), 1) + self.assertEqual( + mock.request_history[0].url, tests.COMPATIBLE_URL.lower() + "/containers/json" + ) + @requests_mock.Mocker() def test_prune(self, mock): mock.post( diff --git a/podman/tests/unit/test_domainmanager.py b/podman/tests/unit/test_domainmanager.py new file mode 100644 index 00000000..edfb5a38 --- /dev/null +++ b/podman/tests/unit/test_domainmanager.py @@ -0,0 +1,79 @@ +import unittest + +import requests_mock + +from podman import PodmanClient, tests + + +CONTAINER = { + "Id": "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd", + "Name": "quay.io/fedora:latest", + "Image": "eloquent_pare", + "State": {"Status": "running"}, +} + + +class PodmanResourceTestCase(unittest.TestCase): + """Test PodmanResource area of concern.""" + + def setUp(self) -> None: + super().setUp() + + self.client = PodmanClient(base_url=tests.BASE_SOCK) + + def tearDown(self) -> None: + super().tearDown() + + self.client.close() + + @requests_mock.Mocker() + def test_reload_with_compatible_options(self, mock): + """Test that reload uses the correct endpoint.""" + + # Mock the get() call + mock.get( + f"{tests.LIBPOD_URL}/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json", + json=CONTAINER, + ) + + # Mock the reload() call + mock.get( + f"{tests.LIBPOD_URL}/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json", + json=CONTAINER, + ) + + # Mock the reload(compatible=False) call + mock.get( + f"{tests.LIBPOD_URL}/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json", + json=CONTAINER, + ) + + # Mock the reload(compatible=True) call + mock.get( + f"{tests.COMPATIBLE_URL}/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json", + json=CONTAINER, + ) + + container = self.client.containers.get( + "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd" + ) + container.reload() + container.reload(compatible=False) + container.reload(compatible=True) + + self.assertEqual(len(mock.request_history), 4) + for i in range(3): + self.assertEqual( + mock.request_history[i].url, + tests.LIBPOD_URL.lower() + + "/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json", + ) + self.assertEqual( + mock.request_history[3].url, + tests.COMPATIBLE_URL.lower() + + "/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json", + ) + + +if __name__ == '__main__': + unittest.main()