diff --git a/contree_cli/cli/images.py b/contree_cli/cli/images.py index 707f67d..64c00a2 100644 --- a/contree_cli/cli/images.py +++ b/contree_cli/cli/images.py @@ -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" @@ -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: @@ -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), ) @@ -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: @@ -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]: diff --git a/contree_cli/types.py b/contree_cli/types.py index 7036dfe..e0007b7 100644 --- a/contree_cli/types.py +++ b/contree_cli/types.py @@ -39,6 +39,8 @@ "since": ("--since",), "until": ("--until",), "timeout": ("-t", "--timeout"), + "page": ("--page",), + "size": ("-n", "--size"), "profile": ("-p", "--profile"), "offline": ("-O", "--offline"), "status": ("--status",), @@ -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\d{4})(?:[./\-\s]?(?P\d{1,2}))?(?:[./\-\s]?(?P\d{1,2}))?" diff --git a/tests/test_images.py b/tests/test_images.py index df838a1..66ab1a7 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -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, @@ -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}) @@ -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.""" diff --git a/tests/test_types.py b/tests/test_types.py index 4023529..ca9d227 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -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) @@ -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)."""