Skip to content
Closed
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
63 changes: 37 additions & 26 deletions contree_cli/cli/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@
isoformat_datetime,
parse_datetime,
parse_interval,
positive_int,
)

logger = logging.getLogger(__name__)

PAGE_SIZE = 100
DEFAULT_PAGE_SIZE = 100
TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
DOCKER_HUB = "docker.io"

Expand Down Expand Up @@ -77,6 +78,8 @@ class ImagesArgs(ArgumentsProtocol):
all_images: bool = False
since: datetime | None = None
until: datetime | None = None
page: int = 1
size: int = DEFAULT_PAGE_SIZE

@classmethod
def from_args(cls, ns: argparse.Namespace) -> ImagesArgs:
Expand All @@ -86,6 +89,8 @@ def from_args(cls, ns: argparse.Namespace) -> ImagesArgs:
all_images=getattr(ns, "all_images", False),
since=getattr(ns, "since", None),
until=getattr(ns, "until", None),
page=getattr(ns, "page", 1),
size=getattr(ns, "size", DEFAULT_PAGE_SIZE),
)


Expand Down Expand Up @@ -189,6 +194,18 @@ def _add_list_args(p: argparse.ArgumentParser) -> None:
type=parse_interval,
help="Show images before. " + str(parse_interval.__doc__),
)
p.add_argument(
*FLAGS["page"],
type=positive_int,
default=1,
help="Page number, 1-based (default: %(default)s)",
)
p.add_argument(
*FLAGS["size"],
type=positive_int,
default=DEFAULT_PAGE_SIZE,
help="Page size (default: %(default)s)",
)


def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
Expand Down Expand Up @@ -249,36 +266,30 @@ def cmd_images(args: ImagesArgs) -> None:
client = CLIENT.get()
formatter = FORMATTER.get()

base_params: dict[str, str] = {"limit": str(PAGE_SIZE)}
params: dict[str, str] = {
"limit": str(args.size),
"offset": str((args.page - 1) * args.size),
}
if args.prefix is not None:
base_params["tag"] = args.prefix
params["tag"] = args.prefix
if args.uuid is not None:
base_params["uuid"] = args.uuid
params["uuid"] = args.uuid
if not args.all_images:
base_params["tagged"] = "1"
params["tagged"] = "1"
if args.since is not None:
base_params["since"] = isoformat_datetime(args.since)
params["since"] = isoformat_datetime(args.since)
if args.until is not None:
base_params["until"] = isoformat_datetime(args.until)

offset = 0
while True:
params = {**base_params, "offset": str(offset)}
resp = client.get("/v1/images", params=params)
data = json.loads(resp.read())
images = data["images"]
if not images:
break
for image in images:
created_at = parse_datetime(image["created_at"])
formatter(
uuid=image["uuid"],
created_at=created_at,
tag=image.get("tag") or "",
)
if len(images) < PAGE_SIZE:
break
offset += len(images)
params["until"] = isoformat_datetime(args.until)

resp = client.get("/v1/images", params=params)
data = json.loads(resp.read())
for image in data["images"]:
created_at = parse_datetime(image["created_at"])
formatter(
uuid=image["uuid"],
created_at=created_at,
tag=image.get("tag") or "",
)


def _parse_explicit_tag(ref: str) -> tuple[str, str | None]:
Expand Down
10 changes: 10 additions & 0 deletions contree_cli/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
"since": ("--since",),
"until": ("--until",),
"timeout": ("-t", "--timeout"),
"page": ("--page",),
"size": ("-n", "--size"),
"profile": ("-p", "--profile"),
"offline": ("-O", "--offline"),
"status": ("--status",),
Expand Down Expand Up @@ -157,6 +159,14 @@ def isoformat_datetime(dt: datetime) -> str:
return dt.astimezone(tz=timezone.utc).isoformat().replace("+00:00", "Z")


def positive_int(value: str) -> int:
"""argparse ``type=`` validator: accept ints >= 1."""
n = int(value)
if n < 1:
raise argparse.ArgumentTypeError(f"must be >= 1, got {n}")
return n


_INTERVAL_RE = re.compile(r"([+-]?\d+)([smhdMy]?)")
_DATE_RE = re.compile(
r"^(?P<year>\d{4})(?:[./\-\s]?(?P<month>\d{1,2}))?(?:[./\-\s]?(?P<day>\d{1,2}))?"
Expand Down
60 changes: 27 additions & 33 deletions tests/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from contree_cli import CLIENT, FORMATTER
from contree_cli.cli.images import (
PAGE_SIZE,
DEFAULT_PAGE_SIZE,
ImagesArgs,
ImportArgs,
_derive_tag,
Expand All @@ -29,7 +29,7 @@ def _run_cmd(tc: ContreeTestClient, images, *, formatter=None, **kwargs):


def _run_cmd_pages(tc: ContreeTestClient, pages, *, formatter=None, **kwargs):
"""Run cmd_images with multiple pages of responses."""
"""Run cmd_images with one response per call."""
for page in pages:
tc.respond_json({"images": page})

Expand Down Expand Up @@ -130,44 +130,38 @@ def _make_image(i: int) -> dict:


class TestImagesPagination:
def test_single_page_partial(self, contree_client, capsys):
"""A page smaller than PAGE_SIZE means no further requests."""
images = [_make_image(i) for i in range(5)]
_run_cmd(contree_client, images)
def test_single_request(self, contree_client):
_run_cmd(contree_client, [_make_image(0), _make_image(1)])
assert contree_client.request_count == 1

def test_two_full_pages(self, contree_client, capsys):
"""Two full pages + an empty third page."""
page1 = [_make_image(i) for i in range(PAGE_SIZE)]
page2 = [_make_image(i) for i in range(PAGE_SIZE, PAGE_SIZE + 3)]
_run_cmd_pages(contree_client, [page1, page2])
assert contree_client.request_count == 2
out = capsys.readouterr().out
assert f"uuid-{PAGE_SIZE + 2}" in out
def test_default_page_size_in_query(self, contree_client):
_run_cmd(contree_client, [])
assert f"limit={DEFAULT_PAGE_SIZE}" in contree_client.request_paths[0]

def test_offset_increments(self, contree_client):
"""Each page request increments offset by PAGE_SIZE."""
page1 = [_make_image(i) for i in range(PAGE_SIZE)]
page2 = []
_run_cmd_pages(contree_client, [page1, page2])
paths = contree_client.request_paths
assert "offset=0" in paths[0]
assert f"offset={PAGE_SIZE}" in paths[1]
def test_default_offset_is_zero(self, contree_client):
_run_cmd(contree_client, [])
assert "offset=0" in contree_client.request_paths[0]

def test_explicit_size(self, contree_client):
_run_cmd(contree_client, [], size=25)
assert "limit=25" in contree_client.request_paths[0]

def test_empty_first_page(self, contree_client, capsys):
"""No output and only one request when first page is empty."""
def test_page_two_offsets_by_size(self, contree_client):
_run_cmd(contree_client, [], page=2, size=25)
assert "offset=25" in contree_client.request_paths[0]

def test_page_three_default_size(self, contree_client):
_run_cmd(contree_client, [], page=3)
assert f"offset={DEFAULT_PAGE_SIZE * 2}" in contree_client.request_paths[0]

def test_page_one_is_zero_offset(self, contree_client):
_run_cmd(contree_client, [], page=1, size=50)
assert "offset=0" in contree_client.request_paths[0]

def test_empty_response(self, contree_client, capsys):
_run_cmd(contree_client, [])
assert contree_client.request_count == 1
assert capsys.readouterr().out == ""

def test_all_images_emitted(self, contree_client, capsys):
"""All images across pages appear in output."""
page1 = [_make_image(i) for i in range(PAGE_SIZE)]
page2 = [_make_image(i) for i in range(PAGE_SIZE, PAGE_SIZE + 5)]
_run_cmd_pages(contree_client, [page1, page2])
out = capsys.readouterr().out
assert out.count("uuid-") == PAGE_SIZE + 5


class TestImagesCreatedAtFormats:
"""Verify created_at parsing with various ISO 8601 formats from the API."""
Expand Down
19 changes: 18 additions & 1 deletion tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import pytest

from contree_cli.types import FLAGS, parse_datetime, parse_interval
from contree_cli.types import FLAGS, parse_datetime, parse_interval, positive_int

REF = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc)

Expand Down Expand Up @@ -235,6 +235,23 @@ def test_naive_datetime(self) -> None:
assert dt.tzinfo is not None


class TestPositiveInt:
@pytest.mark.parametrize("value", ["0", "-1", "-100"])
def test_rejects_non_positive(self, value: str) -> None:
with pytest.raises(argparse.ArgumentTypeError):
positive_int(value)

@pytest.mark.parametrize(
"value, expected", [("1", 1), ("100", 100), ("9999", 9999)]
)
def test_accepts_positive(self, value: str, expected: int) -> None:
assert positive_int(value) == expected

def test_rejects_non_integer(self) -> None:
with pytest.raises(ValueError):
positive_int("abc")


class TestFlags:
def test_no_duplicate_flag_names(self) -> None:
"""Each FLAGS key must be unique (enforced by dict, but explicit)."""
Expand Down