Skip to content

Implement sparse keyword for containers.list() #540

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions podman/domain/containers_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,21 +27,26 @@ 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`.

Raises:
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())

Expand All @@ -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", {}),
Expand All @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions podman/domain/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
130 changes: 129 additions & 1 deletion podman/tests/unit/test_containersmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
79 changes: 79 additions & 0 deletions podman/tests/unit/test_domainmanager.py
Original file line number Diff line number Diff line change
@@ -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()