Skip to content

Commit 5d4ff56

Browse files
Merge pull request #540 from inknos/list-sparse
Implement sparse keyword for containers.list()
2 parents 1368c96 + 010949a commit 5d4ff56

File tree

4 files changed

+250
-9
lines changed

4 files changed

+250
-9
lines changed

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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,134 @@ def test_list_no_filters(self, mock):
162162
"6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03",
163163
)
164164

165+
@requests_mock.Mocker()
166+
def test_list_sparse_libpod_default(self, mock):
167+
mock.get(
168+
tests.LIBPOD_URL + "/containers/json",
169+
json=[FIRST_CONTAINER, SECOND_CONTAINER],
170+
)
171+
actual = self.client.containers.list()
172+
self.assertIsInstance(actual, list)
173+
174+
self.assertEqual(
175+
actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd"
176+
)
177+
self.assertEqual(
178+
actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03"
179+
)
180+
181+
# Verify that no individual reload() calls were made for sparse=True (default)
182+
# Should be only 1 request for the list endpoint
183+
self.assertEqual(len(mock.request_history), 1)
184+
# lower() needs to be enforced since the mocked url is transformed as lowercase and
185+
# this avoids %2f != %2F errors. Same applies for other instances of assertEqual
186+
self.assertEqual(mock.request_history[0].url, tests.LIBPOD_URL.lower() + "/containers/json")
187+
188+
@requests_mock.Mocker()
189+
def test_list_sparse_libpod_false(self, mock):
190+
mock.get(
191+
tests.LIBPOD_URL + "/containers/json",
192+
json=[FIRST_CONTAINER, SECOND_CONTAINER],
193+
)
194+
# Mock individual container detail endpoints for reload() calls
195+
# that are done for sparse=False
196+
mock.get(
197+
tests.LIBPOD_URL + f"/containers/{FIRST_CONTAINER['Id']}/json",
198+
json=FIRST_CONTAINER,
199+
)
200+
mock.get(
201+
tests.LIBPOD_URL + f"/containers/{SECOND_CONTAINER['Id']}/json",
202+
json=SECOND_CONTAINER,
203+
)
204+
actual = self.client.containers.list(sparse=False)
205+
self.assertIsInstance(actual, list)
206+
207+
self.assertEqual(
208+
actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd"
209+
)
210+
self.assertEqual(
211+
actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03"
212+
)
213+
214+
# Verify that individual reload() calls were made for sparse=False
215+
# Should be 3 requests total: 1 for list + 2 for individual container details
216+
self.assertEqual(len(mock.request_history), 3)
217+
218+
# Verify the list endpoint was called first
219+
self.assertEqual(mock.request_history[0].url, tests.LIBPOD_URL.lower() + "/containers/json")
220+
221+
# Verify the individual container detail endpoints were called
222+
individual_urls = {req.url for req in mock.request_history[1:]}
223+
expected_urls = {
224+
tests.LIBPOD_URL.lower() + f"/containers/{FIRST_CONTAINER['Id']}/json",
225+
tests.LIBPOD_URL.lower() + f"/containers/{SECOND_CONTAINER['Id']}/json",
226+
}
227+
self.assertEqual(individual_urls, expected_urls)
228+
229+
@requests_mock.Mocker()
230+
def test_list_sparse_compat_default(self, mock):
231+
mock.get(
232+
tests.COMPATIBLE_URL + "/containers/json",
233+
json=[FIRST_CONTAINER, SECOND_CONTAINER],
234+
)
235+
# Mock individual container detail endpoints for reload() calls
236+
# that are done for sparse=False
237+
mock.get(
238+
tests.COMPATIBLE_URL + f"/containers/{FIRST_CONTAINER['Id']}/json",
239+
json=FIRST_CONTAINER,
240+
)
241+
mock.get(
242+
tests.COMPATIBLE_URL + f"/containers/{SECOND_CONTAINER['Id']}/json",
243+
json=SECOND_CONTAINER,
244+
)
245+
actual = self.client.containers.list(compatible=True)
246+
self.assertIsInstance(actual, list)
247+
248+
self.assertEqual(
249+
actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd"
250+
)
251+
self.assertEqual(
252+
actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03"
253+
)
254+
255+
# Verify that individual reload() calls were made for compat default (sparse=True)
256+
# Should be 3 requests total: 1 for list + 2 for individual container details
257+
self.assertEqual(len(mock.request_history), 3)
258+
self.assertEqual(
259+
mock.request_history[0].url, tests.COMPATIBLE_URL.lower() + "/containers/json"
260+
)
261+
262+
# Verify the individual container detail endpoints were called
263+
individual_urls = {req.url for req in mock.request_history[1:]}
264+
expected_urls = {
265+
tests.COMPATIBLE_URL.lower() + f"/containers/{FIRST_CONTAINER['Id']}/json",
266+
tests.COMPATIBLE_URL.lower() + f"/containers/{SECOND_CONTAINER['Id']}/json",
267+
}
268+
self.assertEqual(individual_urls, expected_urls)
269+
270+
@requests_mock.Mocker()
271+
def test_list_sparse_compat_true(self, mock):
272+
mock.get(
273+
tests.COMPATIBLE_URL + "/containers/json",
274+
json=[FIRST_CONTAINER, SECOND_CONTAINER],
275+
)
276+
actual = self.client.containers.list(sparse=True, compatible=True)
277+
self.assertIsInstance(actual, list)
278+
279+
self.assertEqual(
280+
actual[0].id, "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd"
281+
)
282+
self.assertEqual(
283+
actual[1].id, "6dc84cc0a46747da94e4c1571efcc01a756b4017261440b4b8985d37203c3c03"
284+
)
285+
286+
# Verify that no individual reload() calls were made for sparse=True
287+
# Should be only 1 request for the list endpoint
288+
self.assertEqual(len(mock.request_history), 1)
289+
self.assertEqual(
290+
mock.request_history[0].url, tests.COMPATIBLE_URL.lower() + "/containers/json"
291+
)
292+
165293
@requests_mock.Mocker()
166294
def test_prune(self, mock):
167295
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)