diff --git a/README.md b/README.md index f4a0d53..d3bdff9 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,8 @@ lazy-ecs will automatically use the standard AWS credentials chain: - ✅ Follow logs in real-time (tail -f style) with responsive keyboard shortcuts - ⬜ Download logs to file - ⬜ **Monitoring integration**: - - ⬜ Show CloudWatch metrics (CPU/Memory utilization) for services and tasks - - ⬜ Display resource usage trends to identify spikes, leaks, and throttling + - ✅ Show CloudWatch metrics (CPU/Memory utilization) - Display current values, averages, and peaks + - ⬜ Add sparkline visualization - Inline Unicode trend indicators for quick visual assessment - ⬜ **Port forwarding to container** - Direct local connection to container ports for debugging - ⬜ **Multi-region support** - Work with ECS across different AWS regions diff --git a/pyproject.toml b/pyproject.toml index 54995f6..7d7d3e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lazy-ecs" -version = "0.3.2" +version = "0.4.0" description = "A CLI tool for working with AWS services" readme = "README.md" authors = [ @@ -47,7 +47,7 @@ dev-dependencies = [ "ruff==0.13.0", "pre-commit==4.3.0", "pyrefly==0.32.0", - "boto3-stubs[ecs,logs,sts]==1.40.30", + "boto3-stubs[ecs,logs,sts,cloudwatch]==1.40.30", ] [tool.ruff] diff --git a/src/lazy_ecs/__init__.py b/src/lazy_ecs/__init__.py index ecf68c9..89e1374 100644 --- a/src/lazy_ecs/__init__.py +++ b/src/lazy_ecs/__init__.py @@ -6,6 +6,7 @@ from rich.console import Console if TYPE_CHECKING: + from mypy_boto3_cloudwatch.client import CloudWatchClient from mypy_boto3_ecs import ECSClient from mypy_boto3_logs.client import CloudWatchLogsClient from mypy_boto3_sts.client import STSClient @@ -32,7 +33,8 @@ def main() -> None: ecs_client = _create_aws_client(args.profile) logs_client = _create_logs_client(args.profile) sts_client = _create_sts_client(args.profile) - ecs_service = ECSService(ecs_client, sts_client, logs_client) + cloudwatch_client = _create_cloudwatch_client(args.profile) + ecs_service = ECSService(ecs_client, sts_client, logs_client, cloudwatch_client) navigator = ECSNavigator(ecs_service) _navigate_clusters(navigator, ecs_service) @@ -85,6 +87,19 @@ def _create_sts_client(profile_name: str | None) -> "STSClient": return boto3.client("sts", config=config) +def _create_cloudwatch_client(profile_name: str | None) -> "CloudWatchClient": + """Create optimized CloudWatch client with connection pooling.""" + config = Config( + max_pool_connections=5, # Same config as ECS client + retries={"max_attempts": 2, "mode": "adaptive"}, + ) + + if profile_name: + session = boto3.Session(profile_name=profile_name) + return session.client("cloudwatch", config=config) + return boto3.client("cloudwatch", config=config) + + def _navigate_clusters(navigator: ECSNavigator, ecs_service: ECSService) -> None: """Handle cluster-level navigation with back support.""" while True: @@ -144,6 +159,10 @@ def _navigate_services(navigator: ECSNavigator, ecs_service: ECSService, cluster navigator.show_service_events(cluster_name, selected_service) # Continue the loop to show the menu again + elif selection_type == "action" and action_name == "show_metrics": + navigator.show_service_metrics(cluster_name, selected_service) + # Continue the loop to show the menu again + def _handle_task_features( navigator: ECSNavigator, cluster_name: str, task_arn: str, task_details: TaskDetails | None, service_name: str diff --git a/src/lazy_ecs/aws_service.py b/src/lazy_ecs/aws_service.py index 4993656..beb7d6f 100644 --- a/src/lazy_ecs/aws_service.py +++ b/src/lazy_ecs/aws_service.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Generator from typing import TYPE_CHECKING, Any -from .core.types import LogConfig, ServiceEvent, ServiceInfo, TaskDetails, TaskInfo +from .core.types import LogConfig, ServiceEvent, ServiceInfo, ServiceMetrics, TaskDetails, TaskInfo from .features.cluster.cluster import ClusterService from .features.container.container import ContainerService from .features.service.actions import ServiceActions @@ -13,6 +13,7 @@ from .features.task.task import TaskService if TYPE_CHECKING: + from mypy_boto3_cloudwatch.client import CloudWatchClient from mypy_boto3_ecs.client import ECSClient from mypy_boto3_logs.client import CloudWatchLogsClient from mypy_boto3_logs.type_defs import ( @@ -31,9 +32,11 @@ def __init__( ecs_client: ECSClient, sts_client: STSClient | None = None, logs_client: CloudWatchLogsClient | None = None, + cloudwatch_client: CloudWatchClient | None = None, ) -> None: self.ecs_client = ecs_client self.sts_client = sts_client + self.cloudwatch_client = cloudwatch_client # Initialize feature services self._cluster = ClusterService(ecs_client) self._service = ServiceService(ecs_client) @@ -130,3 +133,11 @@ def get_service_events(self, cluster_name: str, service_name: str) -> list[Servi def force_new_deployment(self, cluster_name: str, service_name: str) -> bool: """Force a new deployment for a service.""" return self._service_actions.force_new_deployment(cluster_name, service_name) + + def get_service_metrics(self, cluster_name: str, service_name: str, hours: int = 1) -> ServiceMetrics | None: + """Get CloudWatch metrics (CPU/Memory utilization) for a service.""" + if not self.cloudwatch_client: + return None + from .features.service.metrics import get_service_metrics + + return get_service_metrics(self.cloudwatch_client, cluster_name, service_name, hours) diff --git a/src/lazy_ecs/core/types.py b/src/lazy_ecs/core/types.py index 6071125..5ceab80 100644 --- a/src/lazy_ecs/core/types.py +++ b/src/lazy_ecs/core/types.py @@ -70,3 +70,15 @@ class TaskHistoryDetails(TypedDict): started_at: datetime | None stopped_at: datetime | None containers: list[ContainerHistoryInfo] + + +class MetricStatistics(TypedDict): + current: float + average: float + maximum: float + minimum: float + + +class ServiceMetrics(TypedDict): + cpu: MetricStatistics + memory: MetricStatistics diff --git a/src/lazy_ecs/features/service/metrics.py b/src/lazy_ecs/features/service/metrics.py new file mode 100644 index 0000000..edb999a --- /dev/null +++ b/src/lazy_ecs/features/service/metrics.py @@ -0,0 +1,118 @@ +"""CloudWatch metrics operations for ECS services.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from ...core.types import MetricStatistics, ServiceMetrics + +if TYPE_CHECKING: + from mypy_boto3_cloudwatch.client import CloudWatchClient + from mypy_boto3_cloudwatch.type_defs import DatapointTypeDef + + +def get_service_metrics( + cloudwatch_client: CloudWatchClient, + cluster_name: str, + service_name: str, + hours: int = 1, +) -> ServiceMetrics | None: + """Fetch CPU and Memory utilization metrics for an ECS service from CloudWatch.""" + utc_now = datetime.now(tz=UTC) + start_time = utc_now - timedelta(hours=hours) + end_time = utc_now + + cpu_stats = _get_metric_statistics( + cloudwatch_client=cloudwatch_client, + cluster_name=cluster_name, + service_name=service_name, + metric_name="CPUUtilization", + start_time=start_time, + end_time=end_time, + ) + + memory_stats = _get_metric_statistics( + cloudwatch_client=cloudwatch_client, + cluster_name=cluster_name, + service_name=service_name, + metric_name="MemoryUtilization", + start_time=start_time, + end_time=end_time, + ) + + if cpu_stats is None or memory_stats is None: + return None + + return {"cpu": cpu_stats, "memory": memory_stats} + + +def format_metrics_display(metrics: ServiceMetrics) -> list[str]: + """Format service metrics into human-readable display lines.""" + lines = [] + + cpu = metrics["cpu"] + memory = metrics["memory"] + + cpu_line = ( + f"CPU: Current: {cpu['current']:5.1f}% | Avg: {cpu['average']:5.1f}% | " + f"Peak: {cpu['maximum']:5.1f}% | Low: {cpu['minimum']:5.1f}%" + ) + memory_line = ( + f"Memory: Current: {memory['current']:5.1f}% | Avg: {memory['average']:5.1f}% | " + f"Peak: {memory['maximum']:5.1f}% | Low: {memory['minimum']:5.1f}%" + ) + + lines.append(cpu_line) + lines.append(memory_line) + + return lines + + +def _get_metric_statistics( + cloudwatch_client: CloudWatchClient, + cluster_name: str, + service_name: str, + metric_name: str, + start_time: datetime, + end_time: datetime, +) -> MetricStatistics | None: + """Fetch statistics for a single metric.""" + response = cloudwatch_client.get_metric_statistics( + Namespace="AWS/ECS", + MetricName=metric_name, + Dimensions=[ + {"Name": "ClusterName", "Value": cluster_name}, + {"Name": "ServiceName", "Value": service_name}, + ], + StartTime=start_time, + EndTime=end_time, + Period=300, + Statistics=["Average", "Maximum", "Minimum"], + ) + + datapoints: list[DatapointTypeDef] = response.get("Datapoints", []) + + if not datapoints: + return None + + sorted_datapoints = sorted(datapoints, key=lambda x: x.get("Timestamp", datetime.min), reverse=True) + + current_datapoint = sorted_datapoints[0] + current_value = current_datapoint.get("Average", 0.0) + + all_averages = [dp.get("Average", 0.0) for dp in datapoints if "Average" in dp] + average_value = sum(all_averages) / len(all_averages) if all_averages else 0.0 + + all_maximums = [dp.get("Maximum", 0.0) for dp in datapoints if "Maximum" in dp] + maximum_value = max(all_maximums) if all_maximums else 0.0 + + all_minimums = [dp.get("Minimum", 0.0) for dp in datapoints if "Minimum" in dp] + minimum_value = min(all_minimums) if all_minimums else 0.0 + + return { + "current": current_value, + "average": average_value, + "maximum": maximum_value, + "minimum": minimum_value, + } diff --git a/src/lazy_ecs/features/service/ui.py b/src/lazy_ecs/features/service/ui.py index 81565f0..2a45e8d 100644 --- a/src/lazy_ecs/features/service/ui.py +++ b/src/lazy_ecs/features/service/ui.py @@ -8,9 +8,10 @@ from ...core.base import BaseUIComponent from ...core.navigation import select_with_auto_pagination -from ...core.types import TaskInfo +from ...core.types import ServiceMetrics, TaskInfo from ...core.utils import show_spinner from .actions import ServiceActions +from .metrics import format_metrics_display from .service import ServiceService console = Console() @@ -44,6 +45,7 @@ def select_service_action(self, service_name: str, task_info: list[TaskInfo]) -> choices.append({"name": task["name"], "value": f"task:show_details:{task['value']}"}) choices.append({"name": "📋 Show service events", "value": "action:show_events"}) + choices.append({"name": "📊 Show metrics", "value": "action:show_metrics"}) choices.append({"name": "🚀 Force new deployment", "value": "action:force_deployment"}) return select_with_auto_pagination( @@ -109,6 +111,13 @@ def display_service_events(self, cluster_name: str, service_name: str) -> None: console.print(table) + def display_service_metrics(self, service_name: str, metrics: ServiceMetrics) -> None: + """Display service metrics.""" + lines = format_metrics_display(metrics) + console.print(f"\n[bold cyan]Metrics for service '{service_name}' (last hour):[/bold cyan]") + for line in lines: + console.print(line) + def _get_event_type_style(event_type: str) -> str: event_styles = { diff --git a/src/lazy_ecs/ui.py b/src/lazy_ecs/ui.py index 196f268..3b4e7ff 100644 --- a/src/lazy_ecs/ui.py +++ b/src/lazy_ecs/ui.py @@ -96,6 +96,20 @@ def handle_force_deployment(self, cluster_name: str, service_name: str) -> None: def show_service_events(self, cluster_name: str, service_name: str) -> None: return self._service_ui.display_service_events(cluster_name, service_name) + def show_service_metrics(self, cluster_name: str, service_name: str) -> None: + """Fetch and display service metrics.""" + with show_spinner(): + metrics = self.ecs_service.get_service_metrics(cluster_name, service_name, hours=1) + + if metrics: + self._service_ui.display_service_metrics(service_name, metrics) + else: + console.print(f"\n⚠️ No metrics available for service '{service_name}'", style="yellow") + console.print("This could mean:", style="dim") + console.print(" - The service has no running tasks", style="dim") + console.print(" - CloudWatch metrics are not yet available", style="dim") + console.print(" - The service was recently created", style="dim") + def show_task_history(self, cluster_name: str, service_name: str) -> None: self._task_ui.display_task_history(cluster_name, service_name) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2333ab6..2e78a44 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,11 +4,20 @@ from lazy_ecs import _create_aws_client, main +@patch("lazy_ecs._create_cloudwatch_client") +@patch("lazy_ecs._create_sts_client") @patch("lazy_ecs._create_logs_client") @patch("lazy_ecs._create_aws_client") @patch("lazy_ecs.ECSNavigator") @patch("lazy_ecs.console") -def test_main_successful_flow(mock_console, mock_navigator_class, mock_create_client, mock_create_logs_client) -> None: +def test_main_successful_flow( + mock_console, + mock_navigator_class, + mock_create_client, + mock_create_logs_client, + mock_create_sts_client, + mock_create_cloudwatch_client, +) -> None: """Test main function with successful cluster selection.""" mock_navigator = Mock() mock_navigator.select_cluster.return_value = "production" @@ -19,17 +28,26 @@ def test_main_successful_flow(mock_console, mock_navigator_class, mock_create_cl mock_create_client.assert_called_once_with(None) mock_create_logs_client.assert_called_once_with(None) + mock_create_sts_client.assert_called_once_with(None) + mock_create_cloudwatch_client.assert_called_once_with(None) mock_navigator.select_cluster.assert_called_once() mock_console.print.assert_any_call("🚀 Welcome to lazy-ecs!", style="bold cyan") mock_console.print.assert_any_call("\n✅ Selected cluster: production", style="green") +@patch("lazy_ecs._create_cloudwatch_client") +@patch("lazy_ecs._create_sts_client") @patch("lazy_ecs._create_logs_client") @patch("lazy_ecs._create_aws_client") @patch("lazy_ecs.ECSNavigator") @patch("lazy_ecs.console") def test_main_no_cluster_selected( - mock_console, mock_navigator_class, _mock_create_client, _mock_create_logs_client + mock_console, + mock_navigator_class, + _mock_create_client, + _mock_create_logs_client, + _mock_create_sts_client, + _mock_create_cloudwatch_client, ) -> None: """Test main function when no cluster is selected.""" mock_navigator = Mock() @@ -42,10 +60,18 @@ def test_main_no_cluster_selected( mock_console.print.assert_any_call("\n❌ No cluster selected. Goodbye!", style="yellow") +@patch("lazy_ecs._create_cloudwatch_client") +@patch("lazy_ecs._create_sts_client") @patch("lazy_ecs._create_logs_client") @patch("lazy_ecs._create_aws_client") @patch("lazy_ecs.console") -def test_main_aws_error(mock_console, mock_create_client, _mock_create_logs_client) -> None: +def test_main_aws_error( + mock_console, + mock_create_client, + _mock_create_logs_client, + _mock_create_sts_client, + _mock_create_cloudwatch_client, +) -> None: """Test main function with AWS connection error.""" mock_create_client.side_effect = Exception("No credentials found") @@ -56,12 +82,19 @@ def test_main_aws_error(mock_console, mock_create_client, _mock_create_logs_clie mock_console.print.assert_any_call("Make sure your AWS credentials are configured.", style="dim") +@patch("lazy_ecs._create_cloudwatch_client") +@patch("lazy_ecs._create_sts_client") @patch("lazy_ecs._create_logs_client") @patch("lazy_ecs._create_aws_client") @patch("lazy_ecs.ECSNavigator") @patch("lazy_ecs.console") def test_main_with_profile_argument( - _mock_console, mock_navigator_class, mock_create_client, mock_create_logs_client + _mock_console, + mock_navigator_class, + mock_create_client, + mock_create_logs_client, + mock_create_sts_client, + mock_create_cloudwatch_client, ) -> None: """Test main function with --profile argument.""" mock_navigator = Mock() @@ -73,6 +106,8 @@ def test_main_with_profile_argument( mock_create_client.assert_called_once_with("my-profile") mock_create_logs_client.assert_called_once_with("my-profile") + mock_create_sts_client.assert_called_once_with("my-profile") + mock_create_cloudwatch_client.assert_called_once_with("my-profile") def test_create_aws_client_without_profile(): diff --git a/tests/test_service_metrics.py b/tests/test_service_metrics.py new file mode 100644 index 0000000..d3fe4a4 --- /dev/null +++ b/tests/test_service_metrics.py @@ -0,0 +1,168 @@ +"""Tests for service metrics fetching from CloudWatch.""" + +from datetime import UTC, datetime, timedelta + +import boto3 +import pytest +from moto import mock_aws + +from lazy_ecs.core.types import ServiceMetrics +from lazy_ecs.features.service.metrics import format_metrics_display, get_service_metrics + + +@pytest.fixture +def cloudwatch_client_with_metrics(): + """Create a mocked CloudWatch client with test metrics.""" + with mock_aws(): + client = boto3.client("cloudwatch", region_name="us-east-1") + utc_now = datetime.now(tz=UTC) + + namespace = "AWS/ECS" + cluster_name = "production" + service_name = "web-api" + + for minutes_ago in range(60, 0, -5): + timestamp = utc_now - timedelta(minutes=minutes_ago) + + cpu_value = 45.0 + (minutes_ago % 10) + client.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "CPUUtilization", + "Value": cpu_value, + "Timestamp": timestamp, + "Dimensions": [ + {"Name": "ClusterName", "Value": cluster_name}, + {"Name": "ServiceName", "Value": service_name}, + ], + } + ], + ) + + memory_value = 75.0 + (minutes_ago % 15) + client.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "MemoryUtilization", + "Value": memory_value, + "Timestamp": timestamp, + "Dimensions": [ + {"Name": "ClusterName", "Value": cluster_name}, + {"Name": "ServiceName", "Value": service_name}, + ], + } + ], + ) + + yield client + + +def test_get_service_metrics_returns_cpu_and_memory_data(cloudwatch_client_with_metrics): + metrics = get_service_metrics( + cloudwatch_client=cloudwatch_client_with_metrics, + cluster_name="production", + service_name="web-api", + hours=1, + ) + + assert metrics is not None + assert "cpu" in metrics + assert "memory" in metrics + + +def test_get_service_metrics_cpu_contains_statistics(cloudwatch_client_with_metrics): + metrics = get_service_metrics( + cloudwatch_client=cloudwatch_client_with_metrics, + cluster_name="production", + service_name="web-api", + hours=1, + ) + + assert metrics is not None + cpu = metrics["cpu"] + assert "current" in cpu + assert "average" in cpu + assert "maximum" in cpu + assert "minimum" in cpu + + assert isinstance(cpu["current"], float) + assert isinstance(cpu["average"], float) + assert isinstance(cpu["maximum"], float) + assert isinstance(cpu["minimum"], float) + + +def test_get_service_metrics_memory_contains_statistics(cloudwatch_client_with_metrics): + metrics = get_service_metrics( + cloudwatch_client=cloudwatch_client_with_metrics, + cluster_name="production", + service_name="web-api", + hours=1, + ) + + assert metrics is not None + memory = metrics["memory"] + assert "current" in memory + assert "average" in memory + assert "maximum" in memory + assert "minimum" in memory + + assert isinstance(memory["current"], float) + assert isinstance(memory["average"], float) + assert isinstance(memory["maximum"], float) + assert isinstance(memory["minimum"], float) + + +def test_get_service_metrics_returns_none_when_no_data(): + with mock_aws(): + client = boto3.client("cloudwatch", region_name="us-east-1") + + metrics = get_service_metrics( + cloudwatch_client=client, + cluster_name="nonexistent", + service_name="nonexistent", + hours=1, + ) + + assert metrics is None + + +def test_format_metrics_display_returns_formatted_strings(): + metrics: ServiceMetrics = { + "cpu": {"current": 45.2, "average": 42.1, "maximum": 78.5, "minimum": 12.3}, + "memory": {"current": 82.7, "average": 75.0, "maximum": 95.8, "minimum": 60.2}, + } + + lines = format_metrics_display(metrics) + + assert len(lines) > 0 + assert any("CPU" in line for line in lines) + assert any("Memory" in line for line in lines) + + +def test_format_metrics_display_includes_all_statistics(): + metrics: ServiceMetrics = { + "cpu": {"current": 45.2, "average": 42.1, "maximum": 78.5, "minimum": 12.3}, + "memory": {"current": 82.7, "average": 75.0, "maximum": 95.8, "minimum": 60.2}, + } + + lines = format_metrics_display(metrics) + full_output = "\n".join(lines) + + assert "45.2" in full_output + assert "42.1" in full_output + assert "78.5" in full_output + assert "12.3" in full_output + + +def test_format_metrics_display_formats_percentages(): + metrics: ServiceMetrics = { + "cpu": {"current": 45.234567, "average": 42.1, "maximum": 78.5, "minimum": 12.3}, + "memory": {"current": 82.7, "average": 75.0, "maximum": 95.8, "minimum": 60.2}, + } + + lines = format_metrics_display(metrics) + full_output = "\n".join(lines) + + assert "45.2%" in full_output or "45.23%" in full_output diff --git a/tests/test_service_ui.py b/tests/test_service_ui.py index 508eb36..93b200b 100644 --- a/tests/test_service_ui.py +++ b/tests/test_service_ui.py @@ -125,7 +125,7 @@ def test_select_service_action_with_many_tasks(mock_select, service_ui): call_args = mock_select.call_args choices = call_args[0][1] - assert len(choices) == 102 + assert len(choices) == 103 # 100 tasks + 3 actions (events, metrics, deployment) @patch("lazy_ecs.features.service.ui.select_with_auto_pagination") diff --git a/uv.lock b/uv.lock index b98680f..2315c8b 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,9 @@ wheels = [ ] [package.optional-dependencies] +cloudwatch = [ + { name = "mypy-boto3-cloudwatch" }, +] ecs = [ { name = "mypy-boto3-ecs" }, ] @@ -381,7 +384,7 @@ wheels = [ [[package]] name = "lazy-ecs" -version = "0.3.2" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "boto3" }, @@ -391,7 +394,7 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "boto3-stubs", extra = ["ecs", "logs", "sts"] }, + { name = "boto3-stubs", extra = ["cloudwatch", "ecs", "logs", "sts"] }, { name = "moto" }, { name = "pre-commit" }, { name = "pyrefly" }, @@ -410,7 +413,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "boto3-stubs", extras = ["ecs", "logs", "sts"], specifier = "==1.40.30" }, + { name = "boto3-stubs", extras = ["ecs", "logs", "sts", "cloudwatch"], specifier = "==1.40.30" }, { name = "moto", extras = ["ecs"], specifier = "==5.1.12" }, { name = "pre-commit", specifier = "==4.3.0" }, { name = "pyrefly", specifier = "==0.32.0" }, @@ -509,6 +512,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/92/cfb9a8be3d6070bef53afb92e03a5a7eb6da0127b29a8438fe00b12f5ed2/moto-5.1.12-py3-none-any.whl", hash = "sha256:c9f1119ab57819ce4b88f793f51c6ca0361b6932a90c59865fd71022acfc5582", size = 5313196, upload-time = "2025-09-07T19:38:34.78Z" }, ] +[[package]] +name = "mypy-boto3-cloudwatch" +version = "1.40.38" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/5b/c642bc0563258db65ba7ca48bc610245e580a3bc32514edde60331115564/mypy_boto3_cloudwatch-1.40.38.tar.gz", hash = "sha256:2422937f2784bd8b30f1bd8438625587e63a960a7330579c8f6cb7e68dcaf4ca", size = 33062, upload-time = "2025-09-24T19:26:33.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/65/4507d7714ad5c102996d09d73d72985a2d5beb7a211094ebb94facdedd55/mypy_boto3_cloudwatch-1.40.38-py3-none-any.whl", hash = "sha256:6a4da37709c16bb19931c296ec745a6a2304018ee940bcf5ae8c0f0a3d6bf24c", size = 44639, upload-time = "2025-09-24T19:26:29.659Z" }, +] + [[package]] name = "mypy-boto3-ecs" version = "1.40.15"