aws-inventory-manager 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aws-inventory-manager might be problematic. Click here for more details.

src/models/report.py ADDED
@@ -0,0 +1,276 @@
1
+ """
2
+ Data models for snapshot resource reporting.
3
+
4
+ This module defines the data structures used for generating and displaying
5
+ snapshot resource reports, including metadata, summaries, filtered views,
6
+ and detailed resource information.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Dict, List, Literal, Optional, Tuple
14
+
15
+
16
+ @dataclass
17
+ class SnapshotMetadata:
18
+ """
19
+ Snapshot identification information for report header.
20
+
21
+ Attributes:
22
+ name: Snapshot name (e.g., "baseline-2025-01-29")
23
+ created_at: Snapshot creation timestamp
24
+ account_id: AWS account ID
25
+ regions: List of AWS regions included in snapshot
26
+ inventory_name: Parent inventory name
27
+ total_resource_count: Total number of resources in snapshot
28
+ """
29
+
30
+ name: str
31
+ created_at: datetime
32
+ account_id: str
33
+ regions: List[str]
34
+ inventory_name: str
35
+ total_resource_count: int
36
+
37
+ @property
38
+ def region_summary(self) -> str:
39
+ """Human-readable region list (e.g., 'us-east-1, us-west-2 (2 regions)')."""
40
+ if len(self.regions) <= 3:
41
+ return ", ".join(self.regions)
42
+ else:
43
+ return f"{', '.join(self.regions[:3])} ... ({len(self.regions)} regions)"
44
+
45
+
46
+ @dataclass
47
+ class ResourceSummary:
48
+ """
49
+ Aggregated resource counts for summary view.
50
+
51
+ Attributes:
52
+ total_count: Total number of resources
53
+ by_service: Count per AWS service (e.g., {"EC2": 100, "S3": 50})
54
+ by_region: Count per AWS region (e.g., {"us-east-1": 80, "us-west-2": 70})
55
+ by_type: Count per resource type (e.g., {"AWS::EC2::Instance": 50})
56
+ """
57
+
58
+ total_count: int = 0
59
+ by_service: Dict[str, int] = field(default_factory=dict)
60
+ by_region: Dict[str, int] = field(default_factory=dict)
61
+ by_type: Dict[str, int] = field(default_factory=dict)
62
+
63
+ @property
64
+ def service_count(self) -> int:
65
+ """Number of distinct AWS services."""
66
+ return len(self.by_service)
67
+
68
+ @property
69
+ def region_count(self) -> int:
70
+ """Number of distinct AWS regions."""
71
+ return len(self.by_region)
72
+
73
+ @property
74
+ def type_count(self) -> int:
75
+ """Number of distinct resource types."""
76
+ return len(self.by_type)
77
+
78
+ def top_services(self, limit: int = 5) -> List[Tuple[str, int]]:
79
+ """Return top N services by resource count."""
80
+ return sorted(self.by_service.items(), key=lambda x: x[1], reverse=True)[:limit]
81
+
82
+ def top_regions(self, limit: int = 5) -> List[Tuple[str, int]]:
83
+ """Return top N regions by resource count."""
84
+ return sorted(self.by_region.items(), key=lambda x: x[1], reverse=True)[:limit]
85
+
86
+
87
+ @dataclass
88
+ class FilteredResource:
89
+ """
90
+ Minimal resource info for filtered view (non-detailed).
91
+
92
+ Attributes:
93
+ arn: AWS Resource Name (unique identifier)
94
+ resource_type: CloudFormation resource type (e.g., "AWS::EC2::Instance")
95
+ name: Resource name or identifier
96
+ region: AWS region (e.g., "us-east-1")
97
+ """
98
+
99
+ arn: str
100
+ resource_type: str
101
+ name: str
102
+ region: str
103
+
104
+ @property
105
+ def service(self) -> str:
106
+ """Extract service name from resource type (e.g., "EC2" from "AWS::EC2::Instance")."""
107
+ parts = self.resource_type.split("::")
108
+ return parts[1] if len(parts) >= 2 else "Unknown"
109
+
110
+ @property
111
+ def short_type(self) -> str:
112
+ """Short resource type (e.g., "Instance" from "AWS::EC2::Instance")."""
113
+ parts = self.resource_type.split("::")
114
+ return parts[-1] if parts else self.resource_type
115
+
116
+
117
+ @dataclass
118
+ class DetailedResource:
119
+ """
120
+ Full resource details for detailed view.
121
+
122
+ Attributes:
123
+ arn: AWS Resource Name
124
+ resource_type: CloudFormation resource type
125
+ name: Resource name or identifier
126
+ region: AWS region
127
+ tags: Key-value tag pairs
128
+ created_at: Resource creation timestamp (if available)
129
+ config_hash: Configuration hash for change detection
130
+ """
131
+
132
+ arn: str
133
+ resource_type: str
134
+ name: str
135
+ region: str
136
+ tags: Dict[str, str]
137
+ created_at: Optional[datetime]
138
+ config_hash: str
139
+
140
+ @property
141
+ def service(self) -> str:
142
+ """Extract service name from resource type."""
143
+ parts = self.resource_type.split("::")
144
+ return parts[1] if len(parts) >= 2 else "Unknown"
145
+
146
+ @property
147
+ def age_days(self) -> Optional[int]:
148
+ """Calculate resource age in days (if creation date available)."""
149
+ if self.created_at:
150
+ from datetime import timezone
151
+
152
+ # Ensure we're comparing timezone-aware datetimes
153
+ now_utc = datetime.now(timezone.utc)
154
+ created = self.created_at if self.created_at.tzinfo else self.created_at.replace(tzinfo=timezone.utc)
155
+ return (now_utc - created).days
156
+ return None
157
+
158
+ @property
159
+ def tag_count(self) -> int:
160
+ """Number of tags applied to resource."""
161
+ return len(self.tags)
162
+
163
+ def has_tag(self, key: str, value: Optional[str] = None) -> bool:
164
+ """Check if resource has specific tag (optionally with value)."""
165
+ if key not in self.tags:
166
+ return False
167
+ if value is not None:
168
+ return self.tags[key] == value
169
+ return True
170
+
171
+
172
+ @dataclass
173
+ class FilterCriteria:
174
+ """
175
+ Filter specification for narrowing report results.
176
+
177
+ Attributes:
178
+ resource_types: List of resource types to include (flexible matching)
179
+ regions: List of AWS regions to include (exact matching)
180
+ match_mode: Matching strategy ("flexible" or "exact")
181
+ """
182
+
183
+ resource_types: Optional[List[str]] = None
184
+ regions: Optional[List[str]] = None
185
+ match_mode: Literal["flexible", "exact"] = "flexible"
186
+
187
+ def __post_init__(self) -> None:
188
+ """Normalize filter values (lowercase for case-insensitive matching)."""
189
+ if self.resource_types:
190
+ self.resource_types = [rt.lower() for rt in self.resource_types]
191
+ if self.regions:
192
+ self.regions = [r.lower() for r in self.regions]
193
+
194
+ @property
195
+ def has_filters(self) -> bool:
196
+ """Check if any filters are applied."""
197
+ return bool(self.resource_types or self.regions)
198
+
199
+ @property
200
+ def filter_count(self) -> int:
201
+ """Total number of filter criteria."""
202
+ count = 0
203
+ if self.resource_types:
204
+ count += len(self.resource_types)
205
+ if self.regions:
206
+ count += len(self.regions)
207
+ return count
208
+
209
+ def matches_resource(self, resource: FilteredResource) -> bool:
210
+ """
211
+ Check if resource matches filter criteria.
212
+
213
+ Uses flexible matching for resource types (see research.md Task 2).
214
+ Uses exact matching for regions (case-insensitive).
215
+ """
216
+ # Region filter (exact match, case-insensitive)
217
+ if self.regions and resource.region.lower() not in self.regions:
218
+ return False
219
+
220
+ # Resource type filter (flexible matching)
221
+ if self.resource_types:
222
+ type_match = any(
223
+ self._match_resource_type(resource.resource_type, filter_type) for filter_type in self.resource_types
224
+ )
225
+ if not type_match:
226
+ return False
227
+
228
+ return True
229
+
230
+ def _match_resource_type(self, resource_type: str, filter_value: str) -> bool:
231
+ """Three-tier matching: exact → prefix → contains (see research.md)."""
232
+ resource_lower = resource_type.lower()
233
+ filter_lower = filter_value.lower()
234
+
235
+ # Tier 1: Exact match
236
+ if resource_lower == filter_lower:
237
+ return True
238
+
239
+ # Tier 2: Service prefix match
240
+ service_prefix = f"aws::{filter_lower}::"
241
+ if resource_lower.startswith(service_prefix):
242
+ return True
243
+
244
+ # Tier 3: Contains match
245
+ if filter_lower in resource_lower:
246
+ return True
247
+
248
+ return False
249
+
250
+
251
+ @dataclass
252
+ class ResourceReport:
253
+ """
254
+ Complete report data structure.
255
+
256
+ Attributes:
257
+ snapshot_metadata: Information about the snapshot being reported
258
+ summary: Aggregated resource counts by service/region/type
259
+ filtered_resources: Minimal resource list (if filtering applied)
260
+ detailed_resources: Full resource details (if detailed view requested)
261
+ """
262
+
263
+ snapshot_metadata: SnapshotMetadata
264
+ summary: ResourceSummary
265
+ filtered_resources: Optional[List[FilteredResource]] = None
266
+ detailed_resources: Optional[List[DetailedResource]] = None
267
+
268
+ @property
269
+ def has_filters(self) -> bool:
270
+ """Check if report includes filtered results."""
271
+ return self.filtered_resources is not None
272
+
273
+ @property
274
+ def has_details(self) -> bool:
275
+ """Check if report includes detailed resource info."""
276
+ return self.detailed_resources is not None
@@ -0,0 +1,250 @@
1
+ """
2
+ Report output formatting using Rich for terminal UI.
3
+
4
+ This module contains the ReportFormatter class responsible for rendering
5
+ reports to the terminal with Rich tables, progress bars, and formatted output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List, Optional
11
+
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+
15
+ from src.models.report import DetailedResource, ResourceSummary, SnapshotMetadata
16
+ from src.utils.pagination import paginate_resources
17
+
18
+
19
+ class ReportFormatter:
20
+ """
21
+ Formats and renders reports using Rich terminal UI.
22
+
23
+ Handles rendering of headers, summaries, tables, and progress bars
24
+ for snapshot resource reports.
25
+ """
26
+
27
+ def __init__(self, console: Optional[Console] = None) -> None:
28
+ """
29
+ Initialize formatter with Rich Console.
30
+
31
+ Args:
32
+ console: Rich Console instance (creates default if not provided)
33
+ """
34
+ self.console = console or Console()
35
+
36
+ def format_summary(
37
+ self,
38
+ metadata: SnapshotMetadata,
39
+ summary: ResourceSummary,
40
+ has_filters: bool = False,
41
+ ) -> None:
42
+ """
43
+ Format and display complete summary report.
44
+
45
+ Orchestrates all render methods to display header, service breakdown,
46
+ region breakdown, and type breakdown.
47
+
48
+ Args:
49
+ metadata: Snapshot metadata for header
50
+ summary: Resource summary with aggregated counts
51
+ has_filters: Whether filters were applied (shows "Filtered" indicator)
52
+ """
53
+ # Render header
54
+ self._render_header(metadata, has_filters)
55
+
56
+ # Add spacing
57
+ self.console.print()
58
+
59
+ # Render summary sections
60
+ self.console.print("📊 [bold]Resource Summary[/bold]\n")
61
+ self.console.print(f"Total Resources: [bold cyan]{summary.total_count:,}[/bold cyan]\n")
62
+
63
+ if summary.total_count > 0:
64
+ # Render service breakdown
65
+ self._render_service_breakdown(summary)
66
+
67
+ # Render region breakdown
68
+ self._render_region_breakdown(summary)
69
+
70
+ # Render type breakdown (top 10)
71
+ self._render_type_breakdown(summary)
72
+
73
+ def _render_header(self, metadata: SnapshotMetadata, has_filters: bool = False) -> None:
74
+ """
75
+ Render report header with snapshot metadata.
76
+
77
+ Args:
78
+ metadata: Snapshot metadata
79
+ has_filters: Whether to show "Filtered" indicator
80
+ """
81
+ title = f"Snapshot Report: {metadata.name}"
82
+ if has_filters:
83
+ title += " (Filtered)"
84
+
85
+ # Create header panel
86
+ header_content = f"""[bold]Inventory:[/bold] {metadata.inventory_name}
87
+ [bold]Account ID:[/bold] {metadata.account_id}
88
+ [bold]Created:[/bold] {metadata.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}
89
+ [bold]Regions:[/bold] {metadata.region_summary}"""
90
+
91
+ panel = Panel(
92
+ header_content,
93
+ title=title,
94
+ border_style="blue",
95
+ expand=False,
96
+ )
97
+
98
+ self.console.print(panel)
99
+
100
+ def _render_service_breakdown(self, summary: ResourceSummary) -> None:
101
+ """
102
+ Render service breakdown with progress bars.
103
+
104
+ Args:
105
+ summary: Resource summary with service counts
106
+ """
107
+ if not summary.by_service:
108
+ return
109
+
110
+ self.console.print("[bold]By Service:[/bold]")
111
+
112
+ # Get top services (sorted by count)
113
+ top_services = summary.top_services(limit=10)
114
+
115
+ for service, count in top_services:
116
+ percentage = (count / summary.total_count) * 100
117
+ bar_length = int((count / summary.total_count) * 30)
118
+ bar = "█" * bar_length + "░" * (30 - bar_length)
119
+
120
+ self.console.print(f" {service:15} {count:5} {bar} ({percentage:.1f}%)")
121
+
122
+ self.console.print()
123
+
124
+ def _render_region_breakdown(self, summary: ResourceSummary) -> None:
125
+ """
126
+ Render region breakdown with progress bars.
127
+
128
+ Args:
129
+ summary: Resource summary with region counts
130
+ """
131
+ if not summary.by_region:
132
+ return
133
+
134
+ self.console.print("[bold]By Region:[/bold]")
135
+
136
+ # Get top regions (sorted by count)
137
+ top_regions = summary.top_regions(limit=10)
138
+
139
+ for region, count in top_regions:
140
+ percentage = (count / summary.total_count) * 100
141
+ bar_length = int((count / summary.total_count) * 30)
142
+ bar = "█" * bar_length + "░" * (30 - bar_length)
143
+
144
+ self.console.print(f" {region:15} {count:5} {bar} ({percentage:.1f}%)")
145
+
146
+ self.console.print()
147
+
148
+ def _render_type_breakdown(self, summary: ResourceSummary) -> None:
149
+ """
150
+ Render resource type breakdown showing top 10 types.
151
+
152
+ Args:
153
+ summary: Resource summary with type counts
154
+ """
155
+ if not summary.by_type:
156
+ return
157
+
158
+ self.console.print("[bold]Top 10 Resource Types:[/bold]")
159
+
160
+ # Get top 10 resource types
161
+ sorted_types = sorted(
162
+ summary.by_type.items(),
163
+ key=lambda x: x[1],
164
+ reverse=True,
165
+ )[:10]
166
+
167
+ for resource_type, count in sorted_types:
168
+ percentage = (count / summary.total_count) * 100
169
+ self.console.print(f" {resource_type:45} {count:5} ({percentage:.1f}%)")
170
+
171
+ self.console.print()
172
+
173
+ def format_detailed(
174
+ self,
175
+ metadata: SnapshotMetadata,
176
+ resources: List[DetailedResource],
177
+ page_size: int = 100,
178
+ ) -> None:
179
+ """
180
+ Format and display detailed resource view with pagination.
181
+
182
+ Args:
183
+ metadata: Snapshot metadata for header
184
+ resources: List of detailed resources to display
185
+ page_size: Number of resources per page (default: 100)
186
+ """
187
+ # Render header
188
+ self._render_header(metadata, has_filters=False)
189
+ self.console.print()
190
+
191
+ total_resources = len(resources)
192
+ self.console.print(f"Total Resources: [bold cyan]{total_resources:,}[/bold cyan]\n")
193
+
194
+ if total_resources == 0:
195
+ return
196
+
197
+ # Paginate resources
198
+ pages = list(paginate_resources(resources, page_size=page_size))
199
+ total_pages = len(pages)
200
+
201
+ # Display pages
202
+ for page_num, page in enumerate(pages, start=1):
203
+ for idx, resource in enumerate(page, start=1):
204
+ global_idx = (page_num - 1) * page_size + idx
205
+ self._render_detailed_resource(resource, global_idx, total_resources)
206
+
207
+ # Show pagination info (but no prompts - non-interactive for now)
208
+ if page_num < total_pages:
209
+ self.console.print(f"\n[dim]─── Page {page_num} of {total_pages} ───[/dim]\n")
210
+
211
+ def _render_detailed_resource(
212
+ self,
213
+ resource: DetailedResource,
214
+ index: int,
215
+ total: int,
216
+ ) -> None:
217
+ """
218
+ Render a single detailed resource.
219
+
220
+ Args:
221
+ resource: DetailedResource to display
222
+ index: Resource index (1-based)
223
+ total: Total number of resources
224
+ """
225
+ self.console.print("─" * 65)
226
+ self.console.print(f"[bold]Resource {index}/{total}[/bold]\n")
227
+
228
+ self.console.print(f"[bold]ARN:[/bold] {resource.arn}")
229
+ self.console.print(f"[bold]Type:[/bold] {resource.resource_type}")
230
+ self.console.print(f"[bold]Name:[/bold] {resource.name}")
231
+ self.console.print(f"[bold]Region:[/bold] {resource.region}")
232
+
233
+ # Show creation date if available
234
+ if resource.created_at:
235
+ age_str = ""
236
+ if resource.age_days is not None:
237
+ age_str = f" ({resource.age_days} days ago)"
238
+ created_str = resource.created_at.strftime("%Y-%m-%d %H:%M:%S UTC")
239
+ self.console.print(f"[bold]Created:[/bold] {created_str}{age_str}")
240
+
241
+ # Show tags
242
+ self.console.print()
243
+ if resource.tags:
244
+ self.console.print("[bold]Tags:[/bold]")
245
+ for key, value in resource.tags.items():
246
+ self.console.print(f" {key:20} {value}")
247
+ else:
248
+ self.console.print("[dim]No tags[/dim]")
249
+
250
+ self.console.print()