Skip to content

Commit 0252faf

Browse files
committed
Implement sparse keyword for containers.list()
Defaults to True for Libpod calls and False for Docker Compat calls This ensures: 1. Docker API compatibility 2. No breaking changes with Libpod It also provides: 1. Possibility to inspect containers on demand for list calls 2. Safer behavior if container hangs 3. Fewer expensive calls to the API by default Note: Requests need to pass compat explicitely to reload containers. A unit test has been added. Fixes: containers#459 Fixes: containers#446 Signed-off-by: Nicola Sella <[email protected]>
1 parent 7054b46 commit 0252faf

4 files changed

Lines changed: 251 additions & 10 deletions

File tree

podman/domain/containers_manager.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
import logging
44
import urllib
5-
from typing import Any, Union
65
from collections.abc import Mapping
6+
from typing import Any, Union
77

88
from podman import api
99
from podman.domain.containers import Container
@@ -27,21 +27,26 @@ def exists(self, key: str) -> bool:
2727
response = self.client.get(f"/containers/{key}/exists")
2828
return response.ok
2929

30-
def get(self, key: str) -> Container:
30+
def get(self, key: str, **kwargs) -> Container:
3131
"""Get container by name or id.
3232
3333
Args:
3434
key: Container name or id.
3535
36+
Keyword Args:
37+
compatible (bool): Use Docker compatibility endpoint
38+
3639
Returns:
3740
A `Container` object corresponding to `key`.
3841
3942
Raises:
4043
NotFound: when Container does not exist
4144
APIError: when an error return by service
4245
"""
46+
compatible = kwargs.get("compatible", False)
47+
4348
container_id = urllib.parse.quote_plus(key)
44-
response = self.client.get(f"/containers/{container_id}/json")
49+
response = self.client.get(f"/containers/{container_id}/json", compatible=compatible)
4550
response.raise_for_status()
4651
return self.prepare_model(attrs=response.json())
4752

@@ -67,12 +72,26 @@ def list(self, **kwargs) -> list[Container]:
6772
Give the container name or id.
6873
- since (str): Only containers created after a particular container.
6974
Give container name or id.
70-
sparse: Ignored
75+
sparse: If False, return basic container information without additional
76+
inspection requests. This improves performance when listing many containers
77+
but might provide less detail. You can call Container.reload() on individual
78+
containers later to retrieve complete attributes. Default: True.
79+
When Docker compatibility is enabled with `compatible=True`: Default: False.
7180
ignore_removed: If True, ignore failures due to missing containers.
7281
7382
Raises:
7483
APIError: when service returns an error
7584
"""
85+
compatible = kwargs.get("compatible", False)
86+
87+
# Set sparse default based on mode:
88+
# Libpod behavior: default is sparse=True (faster, requires reload for full details)
89+
# Docker behavior: default is sparse=False (full details immediately, compatible)
90+
if "sparse" in kwargs:
91+
sparse = kwargs["sparse"]
92+
else:
93+
sparse = not compatible # True for libpod, False for compat
94+
7695
params = {
7796
"all": kwargs.get("all"),
7897
"filters": kwargs.get("filters", {}),
@@ -86,10 +105,21 @@ def list(self, **kwargs) -> list[Container]:
86105
# filters formatted last because some kwargs may need to be mapped into filters
87106
params["filters"] = api.prepare_filters(params["filters"])
88107

89-
response = self.client.get("/containers/json", params=params)
108+
response = self.client.get("/containers/json", params=params, compatible=compatible)
90109
response.raise_for_status()
91110

92-
return [self.prepare_model(attrs=i) for i in response.json()]
111+
containers: list[Container] = [self.prepare_model(attrs=i) for i in response.json()]
112+
113+
# If sparse is False, reload each container to get full details
114+
if not sparse:
115+
for container in containers:
116+
try:
117+
container.reload(compatible=compatible)
118+
except APIError:
119+
# Skip containers that might have been removed
120+
pass
121+
122+
return containers
93123

94124
def prune(self, filters: Mapping[str, str] = None) -> dict[str, Any]:
95125
"""Delete stopped containers.

podman/domain/manager.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,13 @@ def short_id(self):
6767
return self.id[:17]
6868
return self.id[:10]
6969

70-
def reload(self) -> None:
71-
"""Refresh this object's data from the service."""
72-
latest = self.manager.get(self.id)
70+
def reload(self, **kwargs) -> None:
71+
"""Refresh this object's data from the service.
72+
73+
Keyword Args:
74+
compatible (bool): Use Docker compatibility endpoint
75+
"""
76+
latest = self.manager.get(self.id, **kwargs)
7377
self.attrs = latest.attrs
7478

7579

podman/tests/unit/test_containersmanager.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# Python < 3.10
99
from collections.abc import Iterator
1010

11-
from unittest.mock import DEFAULT, patch, MagicMock
11+
from unittest.mock import DEFAULT, MagicMock, patch
1212

1313
import requests_mock
1414

@@ -154,6 +154,134 @@ def test_list_no_filters(self, mock):
154154
actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03"
155155
)
156156

157+
@requests_mock.Mocker()
158+
def test_list_sparse_libpod_default(self, mock):
159+
mock.get(
160+
tests.LIBPOD_URL + "/containers/json",
161+
json=[FIRST_CONTAINER, SECOND_CONTAINER],
162+
)
163+
actual = self.client.containers.list()
164+
self.assertIsInstance(actual, list)
165+
166+
self.assertEqual(
167+
actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd"
168+
)
169+
self.assertEqual(
170+
actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03"
171+
)
172+
173+
# Verify that no individual reload() calls were made for sparse=True (default)
174+
# Should be only 1 request for the list endpoint
175+
self.assertEqual(len(mock.request_history), 1)
176+
# lower() needs to be enforced since the mocked url is transformed as lowercase and
177+
# this avoids %2f != %2F errors. Same applies for other instances of assertEqual
178+
self.assertEqual(mock.request_history[0].url, tests.LIBPOD_URL.lower() + "/containers/json")
179+
180+
@requests_mock.Mocker()
181+
def test_list_sparse_libpod_false(self, mock):
182+
mock.get(
183+
tests.LIBPOD_URL + "/containers/json",
184+
json=[FIRST_CONTAINER, SECOND_CONTAINER],
185+
)
186+
# Mock individual container detail endpoints for reload() calls
187+
# that are done for sparse=False
188+
mock.get(
189+
tests.LIBPOD_URL + f"/containers/{FIRST_CONTAINER['Id']}/json",
190+
json=FIRST_CONTAINER,
191+
)
192+
mock.get(
193+
tests.LIBPOD_URL + f"/containers/{SECOND_CONTAINER['Id']}/json",
194+
json=SECOND_CONTAINER,
195+
)
196+
actual = self.client.containers.list(sparse=False)
197+
self.assertIsInstance(actual, list)
198+
199+
self.assertEqual(
200+
actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd"
201+
)
202+
self.assertEqual(
203+
actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03"
204+
)
205+
206+
# Verify that individual reload() calls were made for sparse=False
207+
# Should be 3 requests total: 1 for list + 2 for individual container details
208+
self.assertEqual(len(mock.request_history), 3)
209+
210+
# Verify the list endpoint was called first
211+
self.assertEqual(mock.request_history[0].url, tests.LIBPOD_URL.lower() + "/containers/json")
212+
213+
# Verify the individual container detail endpoints were called
214+
individual_urls = {req.url for req in mock.request_history[1:]}
215+
expected_urls = {
216+
tests.LIBPOD_URL.lower() + f"/containers/{FIRST_CONTAINER['Id']}/json",
217+
tests.LIBPOD_URL.lower() + f"/containers/{SECOND_CONTAINER['Id']}/json",
218+
}
219+
self.assertEqual(individual_urls, expected_urls)
220+
221+
@requests_mock.Mocker()
222+
def test_list_sparse_compat_default(self, mock):
223+
mock.get(
224+
tests.COMPATIBLE_URL + "/containers/json",
225+
json=[FIRST_CONTAINER, SECOND_CONTAINER],
226+
)
227+
# Mock individual container detail endpoints for reload() calls
228+
# that are done for sparse=False
229+
mock.get(
230+
tests.COMPATIBLE_URL + f"/containers/{FIRST_CONTAINER['Id']}/json",
231+
json=FIRST_CONTAINER,
232+
)
233+
mock.get(
234+
tests.COMPATIBLE_URL + f"/containers/{SECOND_CONTAINER['Id']}/json",
235+
json=SECOND_CONTAINER,
236+
)
237+
actual = self.client.containers.list(compatible=True)
238+
self.assertIsInstance(actual, list)
239+
240+
self.assertEqual(
241+
actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd"
242+
)
243+
self.assertEqual(
244+
actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03"
245+
)
246+
247+
# Verify that individual reload() calls were made for compat default (sparse=True)
248+
# Should be 3 requests total: 1 for list + 2 for individual container details
249+
self.assertEqual(len(mock.request_history), 3)
250+
self.assertEqual(
251+
mock.request_history[0].url, tests.COMPATIBLE_URL.lower() + "/containers/json"
252+
)
253+
254+
# Verify the individual container detail endpoints were called
255+
individual_urls = {req.url for req in mock.request_history[1:]}
256+
expected_urls = {
257+
tests.COMPATIBLE_URL.lower() + f"/containers/{FIRST_CONTAINER['Id']}/json",
258+
tests.COMPATIBLE_URL.lower() + f"/containers/{SECOND_CONTAINER['Id']}/json",
259+
}
260+
self.assertEqual(individual_urls, expected_urls)
261+
262+
@requests_mock.Mocker()
263+
def test_list_sparse_compat_true(self, mock):
264+
mock.get(
265+
tests.COMPATIBLE_URL + "/containers/json",
266+
json=[FIRST_CONTAINER, SECOND_CONTAINER],
267+
)
268+
actual = self.client.containers.list(sparse=True, compatible=True)
269+
self.assertIsInstance(actual, list)
270+
271+
self.assertEqual(
272+
actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd"
273+
)
274+
self.assertEqual(
275+
actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03"
276+
)
277+
278+
# Verify that no individual reload() calls were made for sparse=True
279+
# Should be only 1 request for the list endpoint
280+
self.assertEqual(len(mock.request_history), 1)
281+
self.assertEqual(
282+
mock.request_history[0].url, tests.COMPATIBLE_URL.lower() + "/containers/json"
283+
)
284+
157285
@requests_mock.Mocker()
158286
def test_prune(self, mock):
159287
mock.post(
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import unittest
2+
3+
import requests_mock
4+
5+
from podman import PodmanClient, tests
6+
7+
8+
CONTAINER = {
9+
"Id": "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd",
10+
"Name": "quay.io/fedora:latest",
11+
"Image": "eloquent_pare",
12+
"State": {"Status": "running"},
13+
}
14+
15+
16+
class PodmanResourceTestCase(unittest.TestCase):
17+
"""Test PodmanResource area of concern."""
18+
19+
def setUp(self) -> None:
20+
super().setUp()
21+
22+
self.client = PodmanClient(base_url=tests.BASE_SOCK)
23+
24+
def tearDown(self) -> None:
25+
super().tearDown()
26+
27+
self.client.close()
28+
29+
@requests_mock.Mocker()
30+
def test_reload_with_compatible_options(self, mock):
31+
"""Test that reload uses the correct endpoint."""
32+
33+
# Mock the get() call
34+
mock.get(
35+
f"{tests.LIBPOD_URL}/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json",
36+
json=CONTAINER,
37+
)
38+
39+
# Mock the reload() call
40+
mock.get(
41+
f"{tests.LIBPOD_URL}/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json",
42+
json=CONTAINER,
43+
)
44+
45+
# Mock the reload(compatible=False) call
46+
mock.get(
47+
f"{tests.LIBPOD_URL}/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json",
48+
json=CONTAINER,
49+
)
50+
51+
# Mock the reload(compatible=True) call
52+
mock.get(
53+
f"{tests.COMPATIBLE_URL}/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json",
54+
json=CONTAINER,
55+
)
56+
57+
container = self.client.containers.get(
58+
"87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd"
59+
)
60+
container.reload()
61+
container.reload(compatible=False)
62+
container.reload(compatible=True)
63+
64+
self.assertEqual(len(mock.request_history), 4)
65+
for i in range(3):
66+
self.assertEqual(
67+
mock.request_history[i].url,
68+
tests.LIBPOD_URL.lower()
69+
+ "/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json",
70+
)
71+
self.assertEqual(
72+
mock.request_history[3].url,
73+
tests.COMPATIBLE_URL.lower()
74+
+ "/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/json",
75+
)
76+
77+
78+
if __name__ == '__main__':
79+
unittest.main()

0 commit comments

Comments
 (0)