From 8e7ed05d39ce5c07c5a5f29ad8d0126fd9228c6c Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 13:53:06 +0300 Subject: [PATCH 01/11] feat: add reusable AWS pagination helper function Add paginate_aws_list() utility function to handle AWS API pagination across multiple pages. This generic helper will be used to fix issues with listing large numbers of clusters and services. - Supports all ECS list operations via boto3 paginators - Handles empty results and missing keys gracefully - Includes comprehensive test coverage for single/multiple pages --- src/lazy_ecs/core/utils.py | 30 ++++++++++++++++++ tests/test_core_utils.py | 63 +++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/lazy_ecs/core/utils.py b/src/lazy_ecs/core/utils.py index eb9b172..9194600 100644 --- a/src/lazy_ecs/core/utils.py +++ b/src/lazy_ecs/core/utils.py @@ -4,10 +4,14 @@ from collections.abc import Iterator from contextlib import contextmanager +from typing import TYPE_CHECKING, Literal from rich.console import Console from rich.spinner import Spinner +if TYPE_CHECKING: + from mypy_boto3_ecs.client import ECSClient + console = Console() @@ -49,3 +53,29 @@ def show_spinner() -> Iterator[None]: spinner = Spinner("dots", style="cyan") with console.status(spinner): yield + + +def paginate_aws_list( + client: ECSClient, + operation_name: Literal[ + "list_account_settings", + "list_attributes", + "list_clusters", + "list_container_instances", + "list_services_by_namespace", + "list_services", + "list_task_definition_families", + "list_task_definitions", + "list_tasks", + ], + result_key: str, + **kwargs: str, +) -> list[str]: + paginator = client.get_paginator(operation_name) # type: ignore[no-matching-overload] + page_iterator = paginator.paginate(**kwargs) + + results: list[str] = [] + for page in page_iterator: + results.extend(page.get(result_key, [])) + + return results diff --git a/tests/test_core_utils.py b/tests/test_core_utils.py index 6783797..1f3d218 100644 --- a/tests/test_core_utils.py +++ b/tests/test_core_utils.py @@ -1,8 +1,9 @@ """Tests for core utility functions.""" import time +from unittest.mock import Mock -from lazy_ecs.core.utils import determine_service_status, extract_name_from_arn, show_spinner +from lazy_ecs.core.utils import determine_service_status, extract_name_from_arn, paginate_aws_list, show_spinner def test_extract_name_from_arn(): @@ -63,3 +64,63 @@ def test_show_spinner(): """Test spinner context manager works without errors.""" with show_spinner(): time.sleep(0.01) # Brief pause to simulate work + + +def test_paginate_aws_list_single_page(): + mock_client = Mock() + mock_paginator = Mock() + mock_client.get_paginator.return_value = mock_paginator + + mock_page_iterator = [{"clusterArns": ["arn:aws:ecs:us-east-1:123:cluster/prod"]}] + mock_paginator.paginate.return_value = mock_page_iterator + + result = paginate_aws_list(mock_client, "list_clusters", "clusterArns") + + assert result == ["arn:aws:ecs:us-east-1:123:cluster/prod"] + mock_client.get_paginator.assert_called_once_with("list_clusters") + mock_paginator.paginate.assert_called_once_with() + + +def test_paginate_aws_list_multiple_pages(): + mock_client = Mock() + mock_paginator = Mock() + mock_client.get_paginator.return_value = mock_paginator + + mock_page_iterator = [ + {"serviceArns": ["arn:1", "arn:2"]}, + {"serviceArns": ["arn:3", "arn:4"]}, + {"serviceArns": ["arn:5"]}, + ] + mock_paginator.paginate.return_value = mock_page_iterator + + result = paginate_aws_list(mock_client, "list_services", "serviceArns", cluster="production") + + assert result == ["arn:1", "arn:2", "arn:3", "arn:4", "arn:5"] + mock_client.get_paginator.assert_called_once_with("list_services") + mock_paginator.paginate.assert_called_once_with(cluster="production") + + +def test_paginate_aws_list_empty_results(): + mock_client = Mock() + mock_paginator = Mock() + mock_client.get_paginator.return_value = mock_paginator + + mock_page_iterator = [{"clusterArns": []}] + mock_paginator.paginate.return_value = mock_page_iterator + + result = paginate_aws_list(mock_client, "list_clusters", "clusterArns") + + assert result == [] + + +def test_paginate_aws_list_missing_key(): + mock_client = Mock() + mock_paginator = Mock() + mock_client.get_paginator.return_value = mock_paginator + + mock_page_iterator = [{}] + mock_paginator.paginate.return_value = mock_page_iterator + + result = paginate_aws_list(mock_client, "list_clusters", "clusterArns") + + assert result == [] From 14958784342119291f12a012b0dd0d34343254c6 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 13:55:23 +0300 Subject: [PATCH 02/11] feat: add pagination support to cluster listing Update ClusterService to use paginate_aws_list() helper for fetching all clusters across multiple pages. Fixes issue where only first 100 clusters were shown. - ClusterService.get_cluster_names() now handles 100+ clusters - Added test with 150 clusters to verify pagination works --- src/lazy_ecs/features/cluster/cluster.py | 5 ++--- tests/test_aws_service.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lazy_ecs/features/cluster/cluster.py b/src/lazy_ecs/features/cluster/cluster.py index 8cdea86..054fe14 100644 --- a/src/lazy_ecs/features/cluster/cluster.py +++ b/src/lazy_ecs/features/cluster/cluster.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from ...core.base import BaseAWSService -from ...core.utils import extract_name_from_arn +from ...core.utils import extract_name_from_arn, paginate_aws_list if TYPE_CHECKING: from mypy_boto3_ecs.client import ECSClient @@ -18,6 +18,5 @@ def __init__(self, ecs_client: ECSClient) -> None: super().__init__(ecs_client) def get_cluster_names(self) -> list[str]: - response = self.ecs_client.list_clusters() - cluster_arns = response.get("clusterArns", []) + cluster_arns = paginate_aws_list(self.ecs_client, "list_clusters", "clusterArns") return [extract_name_from_arn(arn) for arn in cluster_arns] diff --git a/tests/test_aws_service.py b/tests/test_aws_service.py index 15f4048..eee73c6 100644 --- a/tests/test_aws_service.py +++ b/tests/test_aws_service.py @@ -209,6 +209,21 @@ def test_get_cluster_names_empty(): assert clusters == [] +def test_get_cluster_names_pagination(): + with mock_aws(): + client = boto3.client("ecs", region_name="us-east-1") + + for i in range(150): + client.create_cluster(clusterName=f"cluster-{i:03d}") + + service = ECSService(client) + clusters = service.get_cluster_names() + + assert len(clusters) == 150 + assert "cluster-000" in clusters + assert "cluster-149" in clusters + + def test_get_services(ecs_client_with_services) -> None: service = ECSService(ecs_client_with_services) services = service.get_services("production") From 6428cb20ff418783128562b15445aa218b031498 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 13:57:35 +0300 Subject: [PATCH 03/11] feat: add pagination support to service listing Update ServiceService to use paginate_aws_list() helper for fetching all services across multiple pages. Fixes issue where only first 100 services were shown, which caused the questionary 36-item limit crash. - ServiceService.get_services() now handles 100+ services - Added test with 200 services to verify pagination works --- src/lazy_ecs/features/service/service.py | 5 ++--- tests/test_aws_service.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/lazy_ecs/features/service/service.py b/src/lazy_ecs/features/service/service.py index 8965adc..807b637 100644 --- a/src/lazy_ecs/features/service/service.py +++ b/src/lazy_ecs/features/service/service.py @@ -7,7 +7,7 @@ from ...core.base import BaseAWSService from ...core.types import ServiceEvent, ServiceInfo -from ...core.utils import determine_service_status, extract_name_from_arn +from ...core.utils import determine_service_status, extract_name_from_arn, paginate_aws_list if TYPE_CHECKING: from typing import Any @@ -23,8 +23,7 @@ def __init__(self, ecs_client: ECSClient) -> None: super().__init__(ecs_client) def get_services(self, cluster_name: str) -> list[str]: - response = self.ecs_client.list_services(cluster=cluster_name) - service_arns = response.get("serviceArns", []) + service_arns = paginate_aws_list(self.ecs_client, "list_services", "serviceArns", cluster=cluster_name) return [extract_name_from_arn(arn) for arn in service_arns] def get_service_info(self, cluster_name: str) -> list[ServiceInfo]: diff --git a/tests/test_aws_service.py b/tests/test_aws_service.py index eee73c6..fbb8be4 100644 --- a/tests/test_aws_service.py +++ b/tests/test_aws_service.py @@ -232,6 +232,29 @@ def test_get_services(ecs_client_with_services) -> None: assert sorted(services) == sorted(expected) +def test_get_services_pagination(): + with mock_aws(): + client = boto3.client("ecs", region_name="us-east-1") + client.create_cluster(clusterName="production") + + client.register_task_definition( + family="app-task", + containerDefinitions=[{"name": "app", "image": "nginx", "memory": 256}], + ) + + for i in range(200): + client.create_service( + cluster="production", serviceName=f"service-{i:03d}", taskDefinition="app-task", desiredCount=1 + ) + + service = ECSService(client) + services = service.get_services("production") + + assert len(services) == 200 + assert "service-000" in services + assert "service-199" in services + + def test_get_service_info(ecs_client_with_services) -> None: service = ECSService(ecs_client_with_services) service_info = service.get_service_info("production") From 85a974965250c00ad00a249733403b4f88839857 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 14:00:22 +0300 Subject: [PATCH 04/11] feat: add paginated selection UI component Add select_with_pagination() function to handle large lists without hitting questionary's 36-item keyboard shortcut limit. Displays items in pages with next/previous navigation. - Supports configurable page size (default: 25 items) - Navigation: next page, previous page, back, exit - No keyboard shortcuts (avoids 36-item limit) - Shows page indicator (Page X of Y) --- src/lazy_ecs/core/navigation.py | 48 +++++++++++++++++++++++++++ tests/test_core_navigation.py | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/src/lazy_ecs/core/navigation.py b/src/lazy_ecs/core/navigation.py index bfb5474..30ffd73 100644 --- a/src/lazy_ecs/core/navigation.py +++ b/src/lazy_ecs/core/navigation.py @@ -120,3 +120,51 @@ def _(event: KeyPressEvent) -> None: question.application.key_bindings = merged_bindings return question.ask() + + +def select_with_pagination( + prompt: str, choices: list[dict[str, str]], back_text: str | None, page_size: int = 25 +) -> str | None: + """Selection with pagination for large lists.""" + total_items = len(choices) + total_pages = (total_items + page_size - 1) // page_size + current_page = 0 + + while True: + start_idx = current_page * page_size + end_idx = min(start_idx + page_size, total_items) + page_choices = choices[start_idx:end_idx] + + page_prompt = f"{prompt} (Page {current_page + 1} of {total_pages})" + + paginated_choices = [] + for choice in page_choices: + paginated_choices.append(questionary.Choice(choice["name"], choice["value"])) + + if current_page < total_pages - 1: + paginated_choices.append( + questionary.Choice( + f"→ Next Page ({end_idx + 1}-{min(end_idx + page_size, total_items)})", "pagination:next" + ) + ) + + if current_page > 0: + paginated_choices.append( + questionary.Choice(f"← Previous Page ({start_idx - page_size + 1}-{start_idx})", "pagination:previous") + ) + + if back_text: + paginated_choices.append(questionary.Choice(f"⬅️ {back_text}", "navigation:back")) + + paginated_choices.append(questionary.Choice("❌ Exit", "navigation:exit")) + + selected = questionary.select( + page_prompt, choices=paginated_choices, style=get_questionary_style(), use_shortcuts=False + ).ask() + + if selected == "pagination:next": + current_page += 1 + elif selected == "pagination:previous": + current_page -= 1 + else: + return selected diff --git a/tests/test_core_navigation.py b/tests/test_core_navigation.py index 22213a9..46cec01 100644 --- a/tests/test_core_navigation.py +++ b/tests/test_core_navigation.py @@ -9,6 +9,7 @@ handle_navigation, parse_selection, select_with_navigation, + select_with_pagination, ) @@ -138,3 +139,59 @@ def test_select_with_navigation_choices_expanded(mock_select): assert passed_choices[0].value == "opt1" assert passed_choices[1].value == "navigation:back" assert passed_choices[2].value == "navigation:exit" + + +@patch("lazy_ecs.core.navigation.questionary.select") +def test_select_with_pagination_single_page(mock_select): + mock_select.return_value.ask.return_value = "item-5" + + choices = [{"name": f"Item {i}", "value": f"item-{i}"} for i in range(20)] + result = select_with_pagination("Select item:", choices, "Back", page_size=25) + + assert result == "item-5" + mock_select.assert_called_once() + call_kwargs = mock_select.call_args[1] + assert call_kwargs["use_shortcuts"] is False + + +@patch("lazy_ecs.core.navigation.questionary.select") +def test_select_with_pagination_navigation_between_pages(mock_select): + mock_select.return_value.ask.side_effect = ["pagination:next", "item-35"] + + choices = [{"name": f"Item {i}", "value": f"item-{i}"} for i in range(50)] + result = select_with_pagination("Select item:", choices, "Back", page_size=25) + + assert result == "item-35" + assert mock_select.call_count == 2 + + +@patch("lazy_ecs.core.navigation.questionary.select") +def test_select_with_pagination_back_from_second_page(mock_select): + mock_select.return_value.ask.side_effect = ["pagination:next", "navigation:back"] + + choices = [{"name": f"Item {i}", "value": f"item-{i}"} for i in range(50)] + result = select_with_pagination("Select item:", choices, "Back", page_size=25) + + assert result == "navigation:back" + assert mock_select.call_count == 2 + + +@patch("lazy_ecs.core.navigation.questionary.select") +def test_select_with_pagination_previous_page(mock_select): + mock_select.return_value.ask.side_effect = ["pagination:next", "pagination:previous", "item-5"] + + choices = [{"name": f"Item {i}", "value": f"item-{i}"} for i in range(50)] + result = select_with_pagination("Select item:", choices, "Back", page_size=25) + + assert result == "item-5" + assert mock_select.call_count == 3 + + +@patch("lazy_ecs.core.navigation.questionary.select") +def test_select_with_pagination_exit(mock_select): + mock_select.return_value.ask.return_value = "navigation:exit" + + choices = [{"name": f"Item {i}", "value": f"item-{i}"} for i in range(50)] + result = select_with_pagination("Select item:", choices, "Back", page_size=25) + + assert result == "navigation:exit" From d5b359d40c97d37ce7892c0c70eb7be5a7b096d7 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 14:03:43 +0300 Subject: [PATCH 05/11] feat: add pagination to cluster selection UI Update ClusterUI to use select_with_pagination() for lists with more than 30 clusters. Small lists continue using the existing shortcuts- enabled navigation. - Pagination threshold: 30 items - Automatically switches to pagination for large lists --- src/lazy_ecs/features/cluster/ui.py | 15 +++---- tests/test_cluster_ui.py | 63 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 tests/test_cluster_ui.py diff --git a/src/lazy_ecs/features/cluster/ui.py b/src/lazy_ecs/features/cluster/ui.py index 31f08da..4381e7c 100644 --- a/src/lazy_ecs/features/cluster/ui.py +++ b/src/lazy_ecs/features/cluster/ui.py @@ -5,12 +5,14 @@ from rich.console import Console from ...core.base import BaseUIComponent -from ...core.navigation import handle_navigation, select_with_navigation +from ...core.navigation import handle_navigation, select_with_navigation, select_with_pagination from ...core.utils import show_spinner from .cluster import ClusterService console = Console() +PAGINATION_THRESHOLD = 30 + class ClusterUI(BaseUIComponent): """UI component for cluster selection and display.""" @@ -27,18 +29,13 @@ def select_cluster(self) -> str: console.print("❌ No ECS clusters found", style="red") return "" - # Convert cluster names to choice format choices = [{"name": name, "value": name} for name in cluster_names] - selected = select_with_navigation( - "Select an ECS cluster:", - choices, - None, # No back option for top-level menu - ) + select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation + selected = select_fn("Select an ECS cluster:", choices, None) - # Handle navigation responses should_continue, _should_exit = handle_navigation(selected) if not should_continue: - return "" # Exit was chosen + return "" return selected or "" diff --git a/tests/test_cluster_ui.py b/tests/test_cluster_ui.py new file mode 100644 index 0000000..f78d629 --- /dev/null +++ b/tests/test_cluster_ui.py @@ -0,0 +1,63 @@ +"""Tests for cluster UI components.""" + +from unittest.mock import patch + +import boto3 +import pytest +from moto import mock_aws + +from lazy_ecs.features.cluster.cluster import ClusterService +from lazy_ecs.features.cluster.ui import ClusterUI + + +@pytest.fixture +def cluster_service_with_many_clusters(): + with mock_aws(): + client = boto3.client("ecs", region_name="us-east-1") + + for i in range(100): + client.create_cluster(clusterName=f"cluster-{i:03d}") + + yield ClusterService(client) + + +@patch("lazy_ecs.features.cluster.ui.select_with_pagination") +def test_select_cluster_with_pagination(mock_select_pagination, cluster_service_with_many_clusters): + mock_select_pagination.return_value = "cluster-050" + + cluster_ui = ClusterUI(cluster_service_with_many_clusters) + result = cluster_ui.select_cluster() + + assert result == "cluster-050" + mock_select_pagination.assert_called_once() + + call_args = mock_select_pagination.call_args + choices = call_args[0][1] + assert len(choices) == 100 + + +@patch("lazy_ecs.features.cluster.ui.select_with_navigation") +def test_select_cluster_without_pagination_small_list(mock_select_navigation): + with mock_aws(): + client = boto3.client("ecs", region_name="us-east-1") + for i in range(5): + client.create_cluster(clusterName=f"cluster-{i}") + + cluster_service = ClusterService(client) + mock_select_navigation.return_value = "cluster-2" + + cluster_ui = ClusterUI(cluster_service) + result = cluster_ui.select_cluster() + + assert result == "cluster-2" + mock_select_navigation.assert_called_once() + + +@patch("lazy_ecs.features.cluster.ui.select_with_pagination") +def test_select_cluster_navigation_exit(mock_select_pagination, cluster_service_with_many_clusters): + mock_select_pagination.return_value = "navigation:exit" + + cluster_ui = ClusterUI(cluster_service_with_many_clusters) + result = cluster_ui.select_cluster() + + assert result == "" From 352dc996a033a7a5ea786b52464fa027db49ce4b Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 14:06:14 +0300 Subject: [PATCH 06/11] support pagination for service UI --- src/lazy_ecs/features/service/ui.py | 6 ++- tests/test_service_pagination.py | 80 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/test_service_pagination.py diff --git a/src/lazy_ecs/features/service/ui.py b/src/lazy_ecs/features/service/ui.py index 0b9d9b9..2855656 100644 --- a/src/lazy_ecs/features/service/ui.py +++ b/src/lazy_ecs/features/service/ui.py @@ -7,6 +7,7 @@ from rich.table import Table from ...core.base import BaseUIComponent +from ...core.navigation import select_with_navigation, select_with_pagination from ...core.types import TaskInfo from ...core.utils import show_spinner from .actions import ServiceActions @@ -14,6 +15,8 @@ console = Console() +PAGINATION_THRESHOLD = 30 + class ServiceUI(BaseUIComponent): """UI component for service selection and display.""" @@ -34,7 +37,8 @@ def select_service(self, cluster_name: str) -> str | None: choices = [{"name": info["name"], "value": f"service:{info['name'].split(' ')[1]}"} for info in service_info] - return self.select_with_nav("Select a service:", choices, "Back to cluster selection") + select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation + return select_fn("Select a service:", choices, "Back to cluster selection") def select_service_action(self, service_name: str, task_info: list[TaskInfo]) -> str | None: choices = [] diff --git a/tests/test_service_pagination.py b/tests/test_service_pagination.py new file mode 100644 index 0000000..9ac8926 --- /dev/null +++ b/tests/test_service_pagination.py @@ -0,0 +1,80 @@ +"""Tests for service UI pagination - fixing the 104 service crash bug.""" + +from unittest.mock import patch + +import boto3 +import pytest +from moto import mock_aws + +from lazy_ecs.features.service.actions import ServiceActions +from lazy_ecs.features.service.service import ServiceService +from lazy_ecs.features.service.ui import ServiceUI + + +@pytest.fixture +def service_service_with_104_services(): + with mock_aws(): + client = boto3.client("ecs", region_name="us-east-1") + client.create_cluster(clusterName="production") + + client.register_task_definition( + family="app-task", + containerDefinitions=[{"name": "app", "image": "nginx", "memory": 256}], + ) + + for i in range(104): + client.create_service( + cluster="production", + serviceName=f"service-{i:03d}", + taskDefinition="app-task", + desiredCount=1, + ) + + yield ServiceService(client) + + +@patch("lazy_ecs.features.service.ui.select_with_pagination") +def test_select_service_with_104_services_uses_pagination(mock_select_pagination, service_service_with_104_services): + mock_select_pagination.return_value = "service:service-050" + + service_actions = ServiceActions(service_service_with_104_services.ecs_client) + service_ui = ServiceUI(service_service_with_104_services, service_actions) + + result = service_ui.select_service("production") + + assert result == "service:service-050" + mock_select_pagination.assert_called_once() + + call_args = mock_select_pagination.call_args + choices = call_args[0][1] + assert len(choices) == 104 + + +@patch("lazy_ecs.features.service.ui.select_with_navigation") +def test_select_service_small_list_uses_shortcuts(mock_select_navigation): + with mock_aws(): + client = boto3.client("ecs", region_name="us-east-1") + client.create_cluster(clusterName="production") + + client.register_task_definition( + family="app-task", + containerDefinitions=[{"name": "app", "image": "nginx", "memory": 256}], + ) + + for i in range(5): + client.create_service( + cluster="production", + serviceName=f"service-{i}", + taskDefinition="app-task", + desiredCount=1, + ) + + service_service = ServiceService(client) + service_actions = ServiceActions(client) + mock_select_navigation.return_value = "service:service-2" + + service_ui = ServiceUI(service_service, service_actions) + result = service_ui.select_service("production") + + assert result == "service:service-2" + mock_select_navigation.assert_called_once() From f90739aed07fec25b85e643118ab9ab78f14a390 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 14:12:46 +0300 Subject: [PATCH 07/11] fixup for service action pagination --- src/lazy_ecs/features/service/ui.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lazy_ecs/features/service/ui.py b/src/lazy_ecs/features/service/ui.py index 2855656..9008694 100644 --- a/src/lazy_ecs/features/service/ui.py +++ b/src/lazy_ecs/features/service/ui.py @@ -49,9 +49,8 @@ def select_service_action(self, service_name: str, task_info: list[TaskInfo]) -> choices.append({"name": "📋 Show service events", "value": "action:show_events"}) choices.append({"name": "🚀 Force new deployment", "value": "action:force_deployment"}) - return self.select_with_nav( - f"Select action for service '{service_name}':", choices, "Back to cluster selection" - ) + select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation + return select_fn(f"Select action for service '{service_name}':", choices, "Back to cluster selection") def handle_force_deployment(self, cluster_name: str, service_name: str) -> None: """Handle force deployment confirmation and execution.""" From 8586bad489c2f6453017a37a78b6ee91eff6a9c7 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 14:12:53 +0300 Subject: [PATCH 08/11] more tests --- tests/test_service_pagination.py | 80 -------------------------------- tests/test_service_ui.py | 51 +++++++++++++++++--- 2 files changed, 45 insertions(+), 86 deletions(-) delete mode 100644 tests/test_service_pagination.py diff --git a/tests/test_service_pagination.py b/tests/test_service_pagination.py deleted file mode 100644 index 9ac8926..0000000 --- a/tests/test_service_pagination.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Tests for service UI pagination - fixing the 104 service crash bug.""" - -from unittest.mock import patch - -import boto3 -import pytest -from moto import mock_aws - -from lazy_ecs.features.service.actions import ServiceActions -from lazy_ecs.features.service.service import ServiceService -from lazy_ecs.features.service.ui import ServiceUI - - -@pytest.fixture -def service_service_with_104_services(): - with mock_aws(): - client = boto3.client("ecs", region_name="us-east-1") - client.create_cluster(clusterName="production") - - client.register_task_definition( - family="app-task", - containerDefinitions=[{"name": "app", "image": "nginx", "memory": 256}], - ) - - for i in range(104): - client.create_service( - cluster="production", - serviceName=f"service-{i:03d}", - taskDefinition="app-task", - desiredCount=1, - ) - - yield ServiceService(client) - - -@patch("lazy_ecs.features.service.ui.select_with_pagination") -def test_select_service_with_104_services_uses_pagination(mock_select_pagination, service_service_with_104_services): - mock_select_pagination.return_value = "service:service-050" - - service_actions = ServiceActions(service_service_with_104_services.ecs_client) - service_ui = ServiceUI(service_service_with_104_services, service_actions) - - result = service_ui.select_service("production") - - assert result == "service:service-050" - mock_select_pagination.assert_called_once() - - call_args = mock_select_pagination.call_args - choices = call_args[0][1] - assert len(choices) == 104 - - -@patch("lazy_ecs.features.service.ui.select_with_navigation") -def test_select_service_small_list_uses_shortcuts(mock_select_navigation): - with mock_aws(): - client = boto3.client("ecs", region_name="us-east-1") - client.create_cluster(clusterName="production") - - client.register_task_definition( - family="app-task", - containerDefinitions=[{"name": "app", "image": "nginx", "memory": 256}], - ) - - for i in range(5): - client.create_service( - cluster="production", - serviceName=f"service-{i}", - taskDefinition="app-task", - desiredCount=1, - ) - - service_service = ServiceService(client) - service_actions = ServiceActions(client) - mock_select_navigation.return_value = "service:service-2" - - service_ui = ServiceUI(service_service, service_actions) - result = service_ui.select_service("production") - - assert result == "service:service-2" - mock_select_navigation.assert_called_once() diff --git a/tests/test_service_ui.py b/tests/test_service_ui.py index f7f6385..127fdad 100644 --- a/tests/test_service_ui.py +++ b/tests/test_service_ui.py @@ -24,9 +24,8 @@ def service_ui(mock_ecs_client): return ServiceUI(service_service, service_actions) -@patch("lazy_ecs.features.service.ui.questionary.select") +@patch("lazy_ecs.features.service.ui.select_with_navigation") def test_select_service_with_services(mock_select, service_ui): - """Test service selection with available services.""" service_ui.service_service.get_service_info = Mock( return_value=[ { @@ -38,7 +37,7 @@ def test_select_service_with_services(mock_select, service_ui): } ] ) - mock_select.return_value.ask.return_value = "service:web-api" + mock_select.return_value = "service:web-api" selected = service_ui.select_service("production") @@ -46,6 +45,32 @@ def test_select_service_with_services(mock_select, service_ui): mock_select.assert_called_once() +@patch("lazy_ecs.features.service.ui.select_with_pagination") +def test_select_service_with_many_services(mock_select_pagination, service_ui): + service_info = [] + for i in range(100): + service_info.append( + { + "name": f"✅ service-{i} (1/1)", + "status": "HEALTHY", + "running_count": 1, + "desired_count": 1, + "pending_count": 0, + } + ) + service_ui.service_service.get_service_info = Mock(return_value=service_info) + mock_select_pagination.return_value = "service:service-50" + + selected = service_ui.select_service("production") + + assert selected == "service:service-50" + mock_select_pagination.assert_called_once() + + call_args = mock_select_pagination.call_args + choices = call_args[0][1] + assert len(choices) == 100 + + def test_select_service_no_services(service_ui): """Test service selection with no services available.""" service_ui.service_service.get_service_info = Mock(return_value=[]) @@ -77,9 +102,8 @@ def test_select_service_navigation_back(mock_select, service_ui): mock_select.assert_called_once() -@patch("lazy_ecs.core.base.select_with_navigation") +@patch("lazy_ecs.features.service.ui.select_with_navigation") def test_select_service_action_with_tasks(mock_select, service_ui): - """Test service action selection with tasks available.""" task_info = [{"name": "task-1", "value": "task-arn-1"}] mock_select.return_value = "task:show_details:task-arn-1" @@ -89,7 +113,22 @@ def test_select_service_action_with_tasks(mock_select, service_ui): mock_select.assert_called_once() -@patch("lazy_ecs.core.base.select_with_navigation") +@patch("lazy_ecs.features.service.ui.select_with_pagination") +def test_select_service_action_with_many_tasks(mock_select_pagination, service_ui): + task_info = [{"name": f"task-{i}", "value": f"task-arn-{i}"} for i in range(100)] + mock_select_pagination.return_value = "task:show_details:task-arn-50" + + selected = service_ui.select_service_action("web-api", task_info) + + assert selected == "task:show_details:task-arn-50" + mock_select_pagination.assert_called_once() + + call_args = mock_select_pagination.call_args + choices = call_args[0][1] + assert len(choices) == 102 + + +@patch("lazy_ecs.features.service.ui.select_with_navigation") def test_select_service_action_show_events(mock_select, service_ui): """Test service action selection for show events.""" task_info = [{"name": "task-1", "value": "task-arn-1"}] From 694fe3ec995aafeef75a679fadc0f1a1973b8d2c Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 14:19:41 +0300 Subject: [PATCH 09/11] feat: add task pagination for AWS API and UI Extends pagination support to task operations: - Add pagination to TaskService.get_tasks() and get_task_history() - Add pagination to TaskUI.select_task() and select_task_feature() --- src/lazy_ecs/features/task/task.py | 36 ++++++++++++++++++++-------- src/lazy_ecs/features/task/ui.py | 11 +++++---- tests/test_aws_service.py | 24 +++++++++++++++++++ tests/test_task_history.py | 23 ++++++++++++------ tests/test_task_ui.py | 38 +++++++++++++++++++++++++++--- tests/test_ui.py | 2 +- 6 files changed, 108 insertions(+), 26 deletions(-) diff --git a/src/lazy_ecs/features/task/task.py b/src/lazy_ecs/features/task/task.py index 493fef9..dcca493 100644 --- a/src/lazy_ecs/features/task/task.py +++ b/src/lazy_ecs/features/task/task.py @@ -6,6 +6,7 @@ from ...core.base import BaseAWSService from ...core.types import TaskDetails, TaskHistoryDetails, TaskInfo +from ...core.utils import paginate_aws_list if TYPE_CHECKING: from mypy_boto3_ecs.client import ECSClient @@ -19,8 +20,9 @@ def __init__(self, ecs_client: ECSClient) -> None: super().__init__(ecs_client) def get_tasks(self, cluster_name: str, service_name: str) -> list[str]: - response = self.ecs_client.list_tasks(cluster=cluster_name, serviceName=service_name) - return response.get("taskArns", []) + return paginate_aws_list( + self.ecs_client, "list_tasks", "taskArns", cluster=cluster_name, serviceName=service_name + ) def get_task_info(self, cluster_name: str, service_name: str, desired_task_def_arn: str | None) -> list[TaskInfo]: task_arns = self.get_tasks(cluster_name, service_name) @@ -63,21 +65,35 @@ def get_task_history(self, cluster_name: str, service_name: str | None = None) - # Get running tasks if service_name: - running_response = self.ecs_client.list_tasks( - cluster=cluster_name, serviceName=service_name, desiredStatus="RUNNING" + running_arns = paginate_aws_list( + self.ecs_client, + "list_tasks", + "taskArns", + cluster=cluster_name, + serviceName=service_name, + desiredStatus="RUNNING", ) else: - running_response = self.ecs_client.list_tasks(cluster=cluster_name, desiredStatus="RUNNING") - task_arns.extend(running_response.get("taskArns", [])) + running_arns = paginate_aws_list( + self.ecs_client, "list_tasks", "taskArns", cluster=cluster_name, desiredStatus="RUNNING" + ) + task_arns.extend(running_arns) # Get stopped tasks if service_name: - stopped_response = self.ecs_client.list_tasks( - cluster=cluster_name, serviceName=service_name, desiredStatus="STOPPED" + stopped_arns = paginate_aws_list( + self.ecs_client, + "list_tasks", + "taskArns", + cluster=cluster_name, + serviceName=service_name, + desiredStatus="STOPPED", ) else: - stopped_response = self.ecs_client.list_tasks(cluster=cluster_name, desiredStatus="STOPPED") - task_arns.extend(stopped_response.get("taskArns", [])) + stopped_arns = paginate_aws_list( + self.ecs_client, "list_tasks", "taskArns", cluster=cluster_name, desiredStatus="STOPPED" + ) + task_arns.extend(stopped_arns) if not task_arns: return [] diff --git a/src/lazy_ecs/features/task/ui.py b/src/lazy_ecs/features/task/ui.py index 30b3c1d..8f6f2c4 100644 --- a/src/lazy_ecs/features/task/ui.py +++ b/src/lazy_ecs/features/task/ui.py @@ -9,7 +9,7 @@ from rich.table import Table from ...core.base import BaseUIComponent -from ...core.navigation import add_navigation_choices +from ...core.navigation import add_navigation_choices, select_with_navigation, select_with_pagination from ...core.types import TaskDetails, TaskHistoryDetails from ...core.utils import print_warning, show_spinner from .task import TaskService @@ -20,6 +20,7 @@ MAX_RECENT_TASKS = 10 MAX_STATUS_DETAILS_LENGTH = 50 SEPARATOR_WIDTH = 80 +PAGINATION_THRESHOLD = 30 class TaskUI(BaseUIComponent): @@ -43,7 +44,8 @@ def select_task(self, cluster_name: str, service_name: str, desired_task_def_arn choices = [{"name": task["name"], "value": task["value"]} for task in available_tasks] - selected = self.select_with_nav("Select a task:", choices, "Back to service selection") + select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation + selected = select_fn("Select a task:", choices, "Back to service selection") if selected: console.print("Task selected successfully!", style="blue") @@ -112,10 +114,8 @@ def select_task_feature(self, task_details: TaskDetails | None) -> str | None: if not containers: return None - # Build choices but don't add navigation - select_with_nav will handle it choices = [] - # Add task-level features first - show task details as first option choices.extend( [ {"name": "Show task details", "value": "task_action:show_details"}, @@ -154,7 +154,8 @@ def select_task_feature(self, task_details: TaskDetails | None) -> str | None: ] ) - return self.select_with_nav("Select a feature for this task:", choices, "Back to service selection") + select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation + return select_fn("Select a feature for this task:", choices, "Back to service selection") def display_task_history(self, cluster_name: str, service_name: str) -> None: """Display task history with failure analysis.""" diff --git a/tests/test_aws_service.py b/tests/test_aws_service.py index fbb8be4..9945805 100644 --- a/tests/test_aws_service.py +++ b/tests/test_aws_service.py @@ -278,6 +278,30 @@ def test_get_tasks(ecs_client_with_tasks) -> None: assert task_arn.startswith("arn:aws:ecs:") +def test_get_tasks_pagination() -> None: + from unittest.mock import Mock + + mock_client = Mock() + + task_arns = [f"arn:aws:ecs:us-east-1:123456789012:task/production/task-{i}" for i in range(200)] + + mock_paginator = Mock() + mock_paginator.paginate.return_value = [{"taskArns": task_arns[i : i + 100]} for i in range(0, 200, 100)] + + mock_client.get_paginator.return_value = mock_paginator + + service = ECSService(mock_client) + tasks = service.get_tasks("production", "web-api") + + assert len(tasks) == 200 + for task_arn in tasks: + assert isinstance(task_arn, str) + assert task_arn.startswith("arn:aws:ecs:") + + mock_client.get_paginator.assert_called_once_with("list_tasks") + mock_paginator.paginate.assert_called_once_with(cluster="production", serviceName="web-api") + + def test_get_task_info(ecs_client_with_tasks) -> None: service = ECSService(ecs_client_with_tasks) task_info = service.get_task_info("production", "web-api") diff --git a/tests/test_task_history.py b/tests/test_task_history.py index 59bed6c..42a84b2 100644 --- a/tests/test_task_history.py +++ b/tests/test_task_history.py @@ -132,12 +132,18 @@ class TestTaskHistoryService: def mock_ecs_client(self): """Mock ECS client for testing.""" client = Mock() - client.list_tasks.return_value = { - "taskArns": [ - "arn:aws:ecs:us-east-1:123456789012:task/cluster/running-task", - "arn:aws:ecs:us-east-1:123456789012:task/cluster/stopped-task", - ] - } + + mock_paginator = Mock() + mock_paginator.paginate.return_value = [ + { + "taskArns": [ + "arn:aws:ecs:us-east-1:123456789012:task/cluster/running-task", + "arn:aws:ecs:us-east-1:123456789012:task/cluster/stopped-task", + ] + } + ] + client.get_paginator.return_value = mock_paginator + client.describe_tasks.return_value = { "tasks": [ { @@ -162,7 +168,10 @@ def test_get_task_history_includes_stopped_tasks(self, mock_ecs_client): def test_get_task_history_handles_no_stopped_tasks(self, mock_ecs_client): """Test getting task history when no stopped tasks exist.""" - mock_ecs_client.list_tasks.return_value = {"taskArns": []} + mock_paginator = Mock() + mock_paginator.paginate.return_value = [{"taskArns": []}] + mock_ecs_client.get_paginator.return_value = mock_paginator + _service = TaskService(mock_ecs_client) result = _service.get_task_history("test-cluster", "web-service") diff --git a/tests/test_task_ui.py b/tests/test_task_ui.py index e8e23ab..033e950 100644 --- a/tests/test_task_ui.py +++ b/tests/test_task_ui.py @@ -21,7 +21,7 @@ def task_ui(mock_ecs_client): return TaskUI(task_service) -@patch("lazy_ecs.core.base.select_with_navigation") +@patch("lazy_ecs.features.task.ui.select_with_navigation") def test_select_task_multiple_tasks(mock_select, task_ui): """Test task selection with multiple tasks available.""" task_info = [{"name": "task-1", "value": "task-arn-1"}, {"name": "task-2", "value": "task-arn-2"}] @@ -80,7 +80,7 @@ def test_display_task_details_none(task_ui): # If we get here without exception, the test passes -@patch("lazy_ecs.core.base.select_with_navigation") +@patch("lazy_ecs.features.task.ui.select_with_navigation") def test_select_task_feature_includes_show_task_details(mock_select, task_ui): """Test that task feature selection includes show task details as first option.""" mock_select.return_value = "task_action:show_details" @@ -101,7 +101,7 @@ def test_select_task_feature_includes_show_task_details(mock_select, task_ui): assert result == "task_action:show_details" -@patch("lazy_ecs.core.base.select_with_navigation") +@patch("lazy_ecs.features.task.ui.select_with_navigation") def test_select_task_feature_show_history_is_second(mock_select, task_ui): """Test that show history is second option after show task details.""" mock_select.return_value = "task_action:show_history" @@ -117,3 +117,35 @@ def test_select_task_feature_show_history_is_second(mock_select, task_ui): # Check that "Show task history" is the second option assert choices[1]["name"] == "Show task history and failures" assert choices[1]["value"] == "task_action:show_history" + + +@patch("lazy_ecs.features.task.ui.select_with_pagination") +def test_select_task_with_many_tasks(mock_select_pagination, task_ui): + task_info = [{"name": f"task-{i}", "value": f"task-arn-{i}"} for i in range(100)] + task_ui.task_service.get_task_info = Mock(return_value=task_info) + mock_select_pagination.return_value = "task-arn-50" + + selected = task_ui.select_task("test-cluster", "web-api", "desired-task-def-arn") + + assert selected == "task-arn-50" + mock_select_pagination.assert_called_once() + + call_args = mock_select_pagination.call_args + choices = call_args[0][1] + assert len(choices) == 100 + + +@patch("lazy_ecs.features.task.ui.select_with_pagination") +def test_select_task_feature_with_many_containers(mock_select_pagination, task_ui): + containers = [{"name": f"container-{i}"} for i in range(10)] + task_details = {"containers": containers} + mock_select_pagination.return_value = "container_action:show_logs:container-5" + + result = task_ui.select_task_feature(task_details) + + assert result == "container_action:show_logs:container-5" + mock_select_pagination.assert_called_once() + + call_args = mock_select_pagination.call_args + choices = call_args[0][1] + assert len(choices) == 62 diff --git a/tests/test_ui.py b/tests/test_ui.py index d5eb4ce..e475977 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -109,7 +109,7 @@ def test_display_task_details_delegates_to_task_ui(mock_ecs_service) -> None: navigator._task_ui.display_task_details.assert_called_once_with(task_details) -@patch("lazy_ecs.core.base.select_with_navigation") +@patch("lazy_ecs.features.task.ui.select_with_navigation") def test_select_task_feature_with_containers(mock_select, mock_ecs_service) -> None: """Test task feature selection with containers.""" from lazy_ecs.core.types import TaskDetails From 26f24e685e344b4469feb683b794bf4f7017b612 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 14:20:50 +0300 Subject: [PATCH 10/11] bump version to v0.2.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 27ae704..dcb2e0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lazy-ecs" -version = "0.1.19" +version = "0.2.0" description = "A CLI tool for working with AWS services" readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index ac1e964..f0dcc07 100644 --- a/uv.lock +++ b/uv.lock @@ -381,7 +381,7 @@ wheels = [ [[package]] name = "lazy-ecs" -version = "0.1.19" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "boto3" }, From a5118955d7d8d8fe5c8169f51d60bbc05dd071c4 Mon Sep 17 00:00:00 2001 From: Janne Sinivirta Date: Tue, 30 Sep 2025 14:35:04 +0300 Subject: [PATCH 11/11] unify pagination support --- src/lazy_ecs/core/navigation.py | 13 +++++++++ src/lazy_ecs/features/cluster/ui.py | 7 ++--- src/lazy_ecs/features/service/ui.py | 12 ++++---- src/lazy_ecs/features/task/task.py | 37 ++++++------------------- src/lazy_ecs/features/task/ui.py | 9 ++---- tests/conftest.py | 27 ++++++++++++++++++ tests/test_aws_service.py | 14 ++-------- tests/test_cluster_ui.py | 24 ++++++++-------- tests/test_core_utils.py | 43 ++++++++--------------------- tests/test_service_ui.py | 26 ++++++++--------- tests/test_task_history.py | 19 +++++-------- tests/test_task_ui.py | 26 ++++++++--------- tests/test_ui.py | 2 +- 13 files changed, 120 insertions(+), 139 deletions(-) create mode 100644 tests/conftest.py diff --git a/src/lazy_ecs/core/navigation.py b/src/lazy_ecs/core/navigation.py index 30ffd73..21a4aab 100644 --- a/src/lazy_ecs/core/navigation.py +++ b/src/lazy_ecs/core/navigation.py @@ -8,6 +8,8 @@ from prompt_toolkit.keys import Keys from rich.console import Console +PAGINATION_THRESHOLD = 30 + def parse_selection(selected: str | None) -> tuple[str, str, str]: """Parse selection into (type, value, extra). Returns ('unknown', selected, '') if no colon.""" @@ -168,3 +170,14 @@ def select_with_pagination( current_page -= 1 else: return selected + + +def select_with_auto_pagination( + prompt: str, choices: list[dict[str, str]], back_text: str | None, threshold: int = PAGINATION_THRESHOLD +) -> str | None: + """Select with automatic pagination based on choice count. + + Uses keyboard shortcuts for small lists (≤threshold), pagination for large lists (>threshold). + """ + select_fn = select_with_pagination if len(choices) > threshold else select_with_navigation + return select_fn(prompt, choices, back_text) diff --git a/src/lazy_ecs/features/cluster/ui.py b/src/lazy_ecs/features/cluster/ui.py index 4381e7c..0253817 100644 --- a/src/lazy_ecs/features/cluster/ui.py +++ b/src/lazy_ecs/features/cluster/ui.py @@ -5,14 +5,12 @@ from rich.console import Console from ...core.base import BaseUIComponent -from ...core.navigation import handle_navigation, select_with_navigation, select_with_pagination +from ...core.navigation import handle_navigation, select_with_auto_pagination from ...core.utils import show_spinner from .cluster import ClusterService console = Console() -PAGINATION_THRESHOLD = 30 - class ClusterUI(BaseUIComponent): """UI component for cluster selection and display.""" @@ -31,8 +29,7 @@ def select_cluster(self) -> str: choices = [{"name": name, "value": name} for name in cluster_names] - select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation - selected = select_fn("Select an ECS cluster:", choices, None) + selected = select_with_auto_pagination("Select an ECS cluster:", choices, None) should_continue, _should_exit = handle_navigation(selected) if not should_continue: diff --git a/src/lazy_ecs/features/service/ui.py b/src/lazy_ecs/features/service/ui.py index 9008694..81565f0 100644 --- a/src/lazy_ecs/features/service/ui.py +++ b/src/lazy_ecs/features/service/ui.py @@ -7,7 +7,7 @@ from rich.table import Table from ...core.base import BaseUIComponent -from ...core.navigation import select_with_navigation, select_with_pagination +from ...core.navigation import select_with_auto_pagination from ...core.types import TaskInfo from ...core.utils import show_spinner from .actions import ServiceActions @@ -15,8 +15,6 @@ console = Console() -PAGINATION_THRESHOLD = 30 - class ServiceUI(BaseUIComponent): """UI component for service selection and display.""" @@ -37,8 +35,7 @@ def select_service(self, cluster_name: str) -> str | None: choices = [{"name": info["name"], "value": f"service:{info['name'].split(' ')[1]}"} for info in service_info] - select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation - return select_fn("Select a service:", choices, "Back to cluster selection") + return select_with_auto_pagination("Select a service:", choices, "Back to cluster selection") def select_service_action(self, service_name: str, task_info: list[TaskInfo]) -> str | None: choices = [] @@ -49,8 +46,9 @@ def select_service_action(self, service_name: str, task_info: list[TaskInfo]) -> choices.append({"name": "📋 Show service events", "value": "action:show_events"}) choices.append({"name": "🚀 Force new deployment", "value": "action:force_deployment"}) - select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation - return select_fn(f"Select action for service '{service_name}':", choices, "Back to cluster selection") + return select_with_auto_pagination( + f"Select action for service '{service_name}':", choices, "Back to cluster selection" + ) def handle_force_deployment(self, cluster_name: str, service_name: str) -> None: """Handle force deployment confirmation and execution.""" diff --git a/src/lazy_ecs/features/task/task.py b/src/lazy_ecs/features/task/task.py index dcca493..3b68b25 100644 --- a/src/lazy_ecs/features/task/task.py +++ b/src/lazy_ecs/features/task/task.py @@ -59,40 +59,21 @@ def get_task_and_definition( return task, task_definition + def _list_tasks_paginated(self, cluster_name: str, service_name: str | None, desired_status: str) -> list[str]: + """List tasks with optional service name filtering.""" + kwargs = {"cluster": cluster_name, "desiredStatus": desired_status} + if service_name: + kwargs["serviceName"] = service_name + return paginate_aws_list(self.ecs_client, "list_tasks", "taskArns", **kwargs) + def get_task_history(self, cluster_name: str, service_name: str | None = None) -> list[TaskHistoryDetails]: """Get task history including stopped tasks with failure information.""" task_arns = [] - # Get running tasks - if service_name: - running_arns = paginate_aws_list( - self.ecs_client, - "list_tasks", - "taskArns", - cluster=cluster_name, - serviceName=service_name, - desiredStatus="RUNNING", - ) - else: - running_arns = paginate_aws_list( - self.ecs_client, "list_tasks", "taskArns", cluster=cluster_name, desiredStatus="RUNNING" - ) + running_arns = self._list_tasks_paginated(cluster_name, service_name, "RUNNING") task_arns.extend(running_arns) - # Get stopped tasks - if service_name: - stopped_arns = paginate_aws_list( - self.ecs_client, - "list_tasks", - "taskArns", - cluster=cluster_name, - serviceName=service_name, - desiredStatus="STOPPED", - ) - else: - stopped_arns = paginate_aws_list( - self.ecs_client, "list_tasks", "taskArns", cluster=cluster_name, desiredStatus="STOPPED" - ) + stopped_arns = self._list_tasks_paginated(cluster_name, service_name, "STOPPED") task_arns.extend(stopped_arns) if not task_arns: diff --git a/src/lazy_ecs/features/task/ui.py b/src/lazy_ecs/features/task/ui.py index 8f6f2c4..aa6e812 100644 --- a/src/lazy_ecs/features/task/ui.py +++ b/src/lazy_ecs/features/task/ui.py @@ -9,7 +9,7 @@ from rich.table import Table from ...core.base import BaseUIComponent -from ...core.navigation import add_navigation_choices, select_with_navigation, select_with_pagination +from ...core.navigation import add_navigation_choices, select_with_auto_pagination from ...core.types import TaskDetails, TaskHistoryDetails from ...core.utils import print_warning, show_spinner from .task import TaskService @@ -20,7 +20,6 @@ MAX_RECENT_TASKS = 10 MAX_STATUS_DETAILS_LENGTH = 50 SEPARATOR_WIDTH = 80 -PAGINATION_THRESHOLD = 30 class TaskUI(BaseUIComponent): @@ -44,8 +43,7 @@ def select_task(self, cluster_name: str, service_name: str, desired_task_def_arn choices = [{"name": task["name"], "value": task["value"]} for task in available_tasks] - select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation - selected = select_fn("Select a task:", choices, "Back to service selection") + selected = select_with_auto_pagination("Select a task:", choices, "Back to service selection") if selected: console.print("Task selected successfully!", style="blue") @@ -154,8 +152,7 @@ def select_task_feature(self, task_details: TaskDetails | None) -> str | None: ] ) - select_fn = select_with_pagination if len(choices) > PAGINATION_THRESHOLD else select_with_navigation - return select_fn("Select a feature for this task:", choices, "Back to service selection") + return select_with_auto_pagination("Select a feature for this task:", choices, "Back to service selection") def display_task_history(self, cluster_name: str, service_name: str) -> None: """Display task history with failure analysis.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..de3764f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +"""Shared pytest fixtures for tests.""" + +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def mock_paginated_client(): + """Create a mock AWS client with paginator support. + + Returns a factory function that creates clients with specified pagination pages. + + Example: + def test_something(mock_paginated_client): + pages = [{"clusterArns": ["arn1", "arn2"]}] + client = mock_paginated_client(pages) + """ + + def _create_client(pages: list[dict]) -> Mock: + client = Mock() + paginator = Mock() + paginator.paginate.return_value = pages + client.get_paginator.return_value = paginator + return client + + return _create_client diff --git a/tests/test_aws_service.py b/tests/test_aws_service.py index 9945805..226832e 100644 --- a/tests/test_aws_service.py +++ b/tests/test_aws_service.py @@ -278,17 +278,10 @@ def test_get_tasks(ecs_client_with_tasks) -> None: assert task_arn.startswith("arn:aws:ecs:") -def test_get_tasks_pagination() -> None: - from unittest.mock import Mock - - mock_client = Mock() - +def test_get_tasks_pagination(mock_paginated_client) -> None: task_arns = [f"arn:aws:ecs:us-east-1:123456789012:task/production/task-{i}" for i in range(200)] - - mock_paginator = Mock() - mock_paginator.paginate.return_value = [{"taskArns": task_arns[i : i + 100]} for i in range(0, 200, 100)] - - mock_client.get_paginator.return_value = mock_paginator + pages = [{"taskArns": task_arns[i : i + 100]} for i in range(0, 200, 100)] + mock_client = mock_paginated_client(pages) service = ECSService(mock_client) tasks = service.get_tasks("production", "web-api") @@ -299,7 +292,6 @@ def test_get_tasks_pagination() -> None: assert task_arn.startswith("arn:aws:ecs:") mock_client.get_paginator.assert_called_once_with("list_tasks") - mock_paginator.paginate.assert_called_once_with(cluster="production", serviceName="web-api") def test_get_task_info(ecs_client_with_tasks) -> None: diff --git a/tests/test_cluster_ui.py b/tests/test_cluster_ui.py index f78d629..11e4c31 100644 --- a/tests/test_cluster_ui.py +++ b/tests/test_cluster_ui.py @@ -21,41 +21,41 @@ def cluster_service_with_many_clusters(): yield ClusterService(client) -@patch("lazy_ecs.features.cluster.ui.select_with_pagination") -def test_select_cluster_with_pagination(mock_select_pagination, cluster_service_with_many_clusters): - mock_select_pagination.return_value = "cluster-050" +@patch("lazy_ecs.features.cluster.ui.select_with_auto_pagination") +def test_select_cluster_with_pagination(mock_select, cluster_service_with_many_clusters): + mock_select.return_value = "cluster-050" cluster_ui = ClusterUI(cluster_service_with_many_clusters) result = cluster_ui.select_cluster() assert result == "cluster-050" - mock_select_pagination.assert_called_once() + mock_select.assert_called_once() - call_args = mock_select_pagination.call_args + call_args = mock_select.call_args choices = call_args[0][1] assert len(choices) == 100 -@patch("lazy_ecs.features.cluster.ui.select_with_navigation") -def test_select_cluster_without_pagination_small_list(mock_select_navigation): +@patch("lazy_ecs.features.cluster.ui.select_with_auto_pagination") +def test_select_cluster_without_pagination_small_list(mock_select): with mock_aws(): client = boto3.client("ecs", region_name="us-east-1") for i in range(5): client.create_cluster(clusterName=f"cluster-{i}") cluster_service = ClusterService(client) - mock_select_navigation.return_value = "cluster-2" + mock_select.return_value = "cluster-2" cluster_ui = ClusterUI(cluster_service) result = cluster_ui.select_cluster() assert result == "cluster-2" - mock_select_navigation.assert_called_once() + mock_select.assert_called_once() -@patch("lazy_ecs.features.cluster.ui.select_with_pagination") -def test_select_cluster_navigation_exit(mock_select_pagination, cluster_service_with_many_clusters): - mock_select_pagination.return_value = "navigation:exit" +@patch("lazy_ecs.features.cluster.ui.select_with_auto_pagination") +def test_select_cluster_navigation_exit(mock_select, cluster_service_with_many_clusters): + mock_select.return_value = "navigation:exit" cluster_ui = ClusterUI(cluster_service_with_many_clusters) result = cluster_ui.select_cluster() diff --git a/tests/test_core_utils.py b/tests/test_core_utils.py index 1f3d218..aa0b351 100644 --- a/tests/test_core_utils.py +++ b/tests/test_core_utils.py @@ -1,7 +1,6 @@ """Tests for core utility functions.""" import time -from unittest.mock import Mock from lazy_ecs.core.utils import determine_service_status, extract_name_from_arn, paginate_aws_list, show_spinner @@ -66,60 +65,42 @@ def test_show_spinner(): time.sleep(0.01) # Brief pause to simulate work -def test_paginate_aws_list_single_page(): - mock_client = Mock() - mock_paginator = Mock() - mock_client.get_paginator.return_value = mock_paginator - - mock_page_iterator = [{"clusterArns": ["arn:aws:ecs:us-east-1:123:cluster/prod"]}] - mock_paginator.paginate.return_value = mock_page_iterator +def test_paginate_aws_list_single_page(mock_paginated_client): + pages = [{"clusterArns": ["arn:aws:ecs:us-east-1:123:cluster/prod"]}] + mock_client = mock_paginated_client(pages) result = paginate_aws_list(mock_client, "list_clusters", "clusterArns") assert result == ["arn:aws:ecs:us-east-1:123:cluster/prod"] mock_client.get_paginator.assert_called_once_with("list_clusters") - mock_paginator.paginate.assert_called_once_with() - -def test_paginate_aws_list_multiple_pages(): - mock_client = Mock() - mock_paginator = Mock() - mock_client.get_paginator.return_value = mock_paginator - mock_page_iterator = [ +def test_paginate_aws_list_multiple_pages(mock_paginated_client): + pages = [ {"serviceArns": ["arn:1", "arn:2"]}, {"serviceArns": ["arn:3", "arn:4"]}, {"serviceArns": ["arn:5"]}, ] - mock_paginator.paginate.return_value = mock_page_iterator + mock_client = mock_paginated_client(pages) result = paginate_aws_list(mock_client, "list_services", "serviceArns", cluster="production") assert result == ["arn:1", "arn:2", "arn:3", "arn:4", "arn:5"] mock_client.get_paginator.assert_called_once_with("list_services") - mock_paginator.paginate.assert_called_once_with(cluster="production") -def test_paginate_aws_list_empty_results(): - mock_client = Mock() - mock_paginator = Mock() - mock_client.get_paginator.return_value = mock_paginator - - mock_page_iterator = [{"clusterArns": []}] - mock_paginator.paginate.return_value = mock_page_iterator +def test_paginate_aws_list_empty_results(mock_paginated_client): + pages = [{"clusterArns": []}] + mock_client = mock_paginated_client(pages) result = paginate_aws_list(mock_client, "list_clusters", "clusterArns") assert result == [] -def test_paginate_aws_list_missing_key(): - mock_client = Mock() - mock_paginator = Mock() - mock_client.get_paginator.return_value = mock_paginator - - mock_page_iterator = [{}] - mock_paginator.paginate.return_value = mock_page_iterator +def test_paginate_aws_list_missing_key(mock_paginated_client): + pages = [{}] + mock_client = mock_paginated_client(pages) result = paginate_aws_list(mock_client, "list_clusters", "clusterArns") diff --git a/tests/test_service_ui.py b/tests/test_service_ui.py index 127fdad..508eb36 100644 --- a/tests/test_service_ui.py +++ b/tests/test_service_ui.py @@ -24,7 +24,7 @@ def service_ui(mock_ecs_client): return ServiceUI(service_service, service_actions) -@patch("lazy_ecs.features.service.ui.select_with_navigation") +@patch("lazy_ecs.features.service.ui.select_with_auto_pagination") def test_select_service_with_services(mock_select, service_ui): service_ui.service_service.get_service_info = Mock( return_value=[ @@ -45,8 +45,8 @@ def test_select_service_with_services(mock_select, service_ui): mock_select.assert_called_once() -@patch("lazy_ecs.features.service.ui.select_with_pagination") -def test_select_service_with_many_services(mock_select_pagination, service_ui): +@patch("lazy_ecs.features.service.ui.select_with_auto_pagination") +def test_select_service_with_many_services(mock_select, service_ui): service_info = [] for i in range(100): service_info.append( @@ -59,14 +59,14 @@ def test_select_service_with_many_services(mock_select_pagination, service_ui): } ) service_ui.service_service.get_service_info = Mock(return_value=service_info) - mock_select_pagination.return_value = "service:service-50" + mock_select.return_value = "service:service-50" selected = service_ui.select_service("production") assert selected == "service:service-50" - mock_select_pagination.assert_called_once() + mock_select.assert_called_once() - call_args = mock_select_pagination.call_args + call_args = mock_select.call_args choices = call_args[0][1] assert len(choices) == 100 @@ -102,7 +102,7 @@ def test_select_service_navigation_back(mock_select, service_ui): mock_select.assert_called_once() -@patch("lazy_ecs.features.service.ui.select_with_navigation") +@patch("lazy_ecs.features.service.ui.select_with_auto_pagination") def test_select_service_action_with_tasks(mock_select, service_ui): task_info = [{"name": "task-1", "value": "task-arn-1"}] mock_select.return_value = "task:show_details:task-arn-1" @@ -113,22 +113,22 @@ def test_select_service_action_with_tasks(mock_select, service_ui): mock_select.assert_called_once() -@patch("lazy_ecs.features.service.ui.select_with_pagination") -def test_select_service_action_with_many_tasks(mock_select_pagination, service_ui): +@patch("lazy_ecs.features.service.ui.select_with_auto_pagination") +def test_select_service_action_with_many_tasks(mock_select, service_ui): task_info = [{"name": f"task-{i}", "value": f"task-arn-{i}"} for i in range(100)] - mock_select_pagination.return_value = "task:show_details:task-arn-50" + mock_select.return_value = "task:show_details:task-arn-50" selected = service_ui.select_service_action("web-api", task_info) assert selected == "task:show_details:task-arn-50" - mock_select_pagination.assert_called_once() + mock_select.assert_called_once() - call_args = mock_select_pagination.call_args + call_args = mock_select.call_args choices = call_args[0][1] assert len(choices) == 102 -@patch("lazy_ecs.features.service.ui.select_with_navigation") +@patch("lazy_ecs.features.service.ui.select_with_auto_pagination") def test_select_service_action_show_events(mock_select, service_ui): """Test service action selection for show events.""" task_info = [{"name": "task-1", "value": "task-arn-1"}] diff --git a/tests/test_task_history.py b/tests/test_task_history.py index 42a84b2..19d376b 100644 --- a/tests/test_task_history.py +++ b/tests/test_task_history.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import Any -from unittest.mock import Mock import pytest @@ -129,12 +128,9 @@ class TestTaskHistoryService: """Test task history service methods.""" @pytest.fixture - def mock_ecs_client(self): + def mock_ecs_client(self, mock_paginated_client): """Mock ECS client for testing.""" - client = Mock() - - mock_paginator = Mock() - mock_paginator.paginate.return_value = [ + pages = [ { "taskArns": [ "arn:aws:ecs:us-east-1:123456789012:task/cluster/running-task", @@ -142,7 +138,7 @@ def mock_ecs_client(self): ] } ] - client.get_paginator.return_value = mock_paginator + client = mock_paginated_client(pages) client.describe_tasks.return_value = { "tasks": [ @@ -166,13 +162,12 @@ def test_get_task_history_includes_stopped_tasks(self, mock_ecs_client): assert len(result) > 0 assert any(task["last_status"] == "STOPPED" for task in result) - def test_get_task_history_handles_no_stopped_tasks(self, mock_ecs_client): + def test_get_task_history_handles_no_stopped_tasks(self, mock_paginated_client): """Test getting task history when no stopped tasks exist.""" - mock_paginator = Mock() - mock_paginator.paginate.return_value = [{"taskArns": []}] - mock_ecs_client.get_paginator.return_value = mock_paginator + pages = [{"taskArns": []}] + client = mock_paginated_client(pages) - _service = TaskService(mock_ecs_client) + _service = TaskService(client) result = _service.get_task_history("test-cluster", "web-service") assert result == [] diff --git a/tests/test_task_ui.py b/tests/test_task_ui.py index 033e950..93db783 100644 --- a/tests/test_task_ui.py +++ b/tests/test_task_ui.py @@ -21,7 +21,7 @@ def task_ui(mock_ecs_client): return TaskUI(task_service) -@patch("lazy_ecs.features.task.ui.select_with_navigation") +@patch("lazy_ecs.features.task.ui.select_with_auto_pagination") def test_select_task_multiple_tasks(mock_select, task_ui): """Test task selection with multiple tasks available.""" task_info = [{"name": "task-1", "value": "task-arn-1"}, {"name": "task-2", "value": "task-arn-2"}] @@ -80,7 +80,7 @@ def test_display_task_details_none(task_ui): # If we get here without exception, the test passes -@patch("lazy_ecs.features.task.ui.select_with_navigation") +@patch("lazy_ecs.features.task.ui.select_with_auto_pagination") def test_select_task_feature_includes_show_task_details(mock_select, task_ui): """Test that task feature selection includes show task details as first option.""" mock_select.return_value = "task_action:show_details" @@ -101,7 +101,7 @@ def test_select_task_feature_includes_show_task_details(mock_select, task_ui): assert result == "task_action:show_details" -@patch("lazy_ecs.features.task.ui.select_with_navigation") +@patch("lazy_ecs.features.task.ui.select_with_auto_pagination") def test_select_task_feature_show_history_is_second(mock_select, task_ui): """Test that show history is second option after show task details.""" mock_select.return_value = "task_action:show_history" @@ -119,33 +119,33 @@ def test_select_task_feature_show_history_is_second(mock_select, task_ui): assert choices[1]["value"] == "task_action:show_history" -@patch("lazy_ecs.features.task.ui.select_with_pagination") -def test_select_task_with_many_tasks(mock_select_pagination, task_ui): +@patch("lazy_ecs.features.task.ui.select_with_auto_pagination") +def test_select_task_with_many_tasks(mock_select, task_ui): task_info = [{"name": f"task-{i}", "value": f"task-arn-{i}"} for i in range(100)] task_ui.task_service.get_task_info = Mock(return_value=task_info) - mock_select_pagination.return_value = "task-arn-50" + mock_select.return_value = "task-arn-50" selected = task_ui.select_task("test-cluster", "web-api", "desired-task-def-arn") assert selected == "task-arn-50" - mock_select_pagination.assert_called_once() + mock_select.assert_called_once() - call_args = mock_select_pagination.call_args + call_args = mock_select.call_args choices = call_args[0][1] assert len(choices) == 100 -@patch("lazy_ecs.features.task.ui.select_with_pagination") -def test_select_task_feature_with_many_containers(mock_select_pagination, task_ui): +@patch("lazy_ecs.features.task.ui.select_with_auto_pagination") +def test_select_task_feature_with_many_containers(mock_select, task_ui): containers = [{"name": f"container-{i}"} for i in range(10)] task_details = {"containers": containers} - mock_select_pagination.return_value = "container_action:show_logs:container-5" + mock_select.return_value = "container_action:show_logs:container-5" result = task_ui.select_task_feature(task_details) assert result == "container_action:show_logs:container-5" - mock_select_pagination.assert_called_once() + mock_select.assert_called_once() - call_args = mock_select_pagination.call_args + call_args = mock_select.call_args choices = call_args[0][1] assert len(choices) == 62 diff --git a/tests/test_ui.py b/tests/test_ui.py index e475977..f848ca5 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -109,7 +109,7 @@ def test_display_task_details_delegates_to_task_ui(mock_ecs_service) -> None: navigator._task_ui.display_task_details.assert_called_once_with(task_details) -@patch("lazy_ecs.features.task.ui.select_with_navigation") +@patch("lazy_ecs.features.task.ui.select_with_auto_pagination") def test_select_task_feature_with_containers(mock_select, mock_ecs_service) -> None: """Test task feature selection with containers.""" from lazy_ecs.core.types import TaskDetails