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.

@@ -0,0 +1,189 @@
1
+ """
2
+ Snapshot report generation logic.
3
+
4
+ This module contains the SnapshotReporter class responsible for generating
5
+ reports from snapshot data, including summary generation, filtering, and
6
+ detailed resource views.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Generator, Optional
12
+
13
+ from src.models.report import DetailedResource, FilterCriteria, FilteredResource, ResourceSummary, SnapshotMetadata
14
+
15
+ if TYPE_CHECKING:
16
+ from src.models.snapshot import Snapshot
17
+
18
+
19
+ class SnapshotReporter:
20
+ """
21
+ Generates reports from snapshot data.
22
+
23
+ This class handles extracting metadata, generating summaries, and
24
+ preparing data for formatting and display.
25
+ """
26
+
27
+ def __init__(self, snapshot: Snapshot) -> None:
28
+ """
29
+ Initialize reporter with a snapshot.
30
+
31
+ Args:
32
+ snapshot: The snapshot to generate reports from
33
+ """
34
+ self.snapshot = snapshot
35
+
36
+ def _extract_metadata(self) -> SnapshotMetadata:
37
+ """
38
+ Extract metadata from snapshot for report header.
39
+
40
+ Returns:
41
+ SnapshotMetadata with snapshot information
42
+ """
43
+ return SnapshotMetadata(
44
+ name=self.snapshot.name,
45
+ created_at=self.snapshot.created_at,
46
+ account_id=self.snapshot.account_id,
47
+ regions=self.snapshot.regions,
48
+ inventory_name=self.snapshot.inventory_name,
49
+ total_resource_count=len(self.snapshot.resources),
50
+ )
51
+
52
+ def generate_summary(self) -> ResourceSummary:
53
+ """
54
+ Generate aggregated resource summary from snapshot.
55
+
56
+ Uses streaming single-pass aggregation for memory efficiency.
57
+ Does not load entire dataset into memory.
58
+
59
+ Returns:
60
+ ResourceSummary with aggregated counts
61
+ """
62
+ summary = ResourceSummary()
63
+
64
+ # Single-pass streaming aggregation
65
+ for resource in self.snapshot.resources:
66
+ summary.total_count += 1
67
+
68
+ # Extract service from resource type
69
+ # Handle both formats: "AWS::EC2::Instance" and "ec2:instance"
70
+ if "::" in resource.resource_type:
71
+ # CloudFormation format: AWS::EC2::Instance
72
+ parts = resource.resource_type.split("::")
73
+ service = parts[1] if len(parts) >= 2 else "Unknown"
74
+ else:
75
+ # Simplified format: ec2:instance
76
+ service = (
77
+ resource.resource_type.split(":")[0] if ":" in resource.resource_type else resource.resource_type
78
+ )
79
+
80
+ # Aggregate by service
81
+ summary.by_service[service] = summary.by_service.get(service, 0) + 1
82
+
83
+ # Aggregate by region
84
+ summary.by_region[resource.region] = summary.by_region.get(resource.region, 0) + 1
85
+
86
+ # Aggregate by type
87
+ summary.by_type[resource.resource_type] = summary.by_type.get(resource.resource_type, 0) + 1
88
+
89
+ return summary
90
+
91
+ def get_filtered_resources(self, criteria: FilterCriteria) -> Generator[FilteredResource, None, None]:
92
+ """
93
+ Get filtered resources as generator (memory-efficient streaming).
94
+
95
+ Args:
96
+ criteria: Filter criteria with resource types and/or regions
97
+
98
+ Yields:
99
+ FilteredResource objects matching the criteria
100
+ """
101
+ for resource in self.snapshot.resources:
102
+ # Convert Resource to FilteredResource for matching
103
+ filtered_resource = FilteredResource(
104
+ arn=resource.arn,
105
+ resource_type=resource.resource_type,
106
+ name=resource.name,
107
+ region=resource.region,
108
+ )
109
+
110
+ # Apply filter criteria
111
+ if criteria.matches_resource(filtered_resource):
112
+ yield filtered_resource
113
+
114
+ def generate_filtered_summary(self, criteria: FilterCriteria) -> ResourceSummary:
115
+ """
116
+ Generate summary for filtered resources only.
117
+
118
+ Args:
119
+ criteria: Filter criteria
120
+
121
+ Returns:
122
+ ResourceSummary with counts for filtered resources only
123
+ """
124
+ summary = ResourceSummary()
125
+
126
+ # Single-pass streaming aggregation of filtered resources
127
+ for filtered_resource in self.get_filtered_resources(criteria):
128
+ summary.total_count += 1
129
+
130
+ # Extract service from resource type
131
+ if "::" in filtered_resource.resource_type:
132
+ parts = filtered_resource.resource_type.split("::")
133
+ service = parts[1] if len(parts) >= 2 else "Unknown"
134
+ else:
135
+ service = (
136
+ filtered_resource.resource_type.split(":")[0]
137
+ if ":" in filtered_resource.resource_type
138
+ else filtered_resource.resource_type
139
+ )
140
+
141
+ # Aggregate by service
142
+ summary.by_service[service] = summary.by_service.get(service, 0) + 1
143
+
144
+ # Aggregate by region
145
+ summary.by_region[filtered_resource.region] = summary.by_region.get(filtered_resource.region, 0) + 1
146
+
147
+ # Aggregate by type
148
+ summary.by_type[filtered_resource.resource_type] = (
149
+ summary.by_type.get(filtered_resource.resource_type, 0) + 1
150
+ )
151
+
152
+ return summary
153
+
154
+ def get_detailed_resources(
155
+ self, criteria: Optional[FilterCriteria] = None
156
+ ) -> Generator[DetailedResource, None, None]:
157
+ """
158
+ Get detailed resource information as generator.
159
+
160
+ Args:
161
+ criteria: Optional filter criteria to limit resources
162
+
163
+ Yields:
164
+ DetailedResource objects with full information (tags, creation date, etc.)
165
+ """
166
+ for resource in self.snapshot.resources:
167
+ # Apply filtering if criteria provided
168
+ if criteria:
169
+ filtered_resource = FilteredResource(
170
+ arn=resource.arn,
171
+ resource_type=resource.resource_type,
172
+ name=resource.name,
173
+ region=resource.region,
174
+ )
175
+ if not criteria.matches_resource(filtered_resource):
176
+ continue
177
+
178
+ # Convert Resource to DetailedResource
179
+ detailed_resource = DetailedResource(
180
+ arn=resource.arn,
181
+ resource_type=resource.resource_type,
182
+ name=resource.name,
183
+ region=resource.region,
184
+ tags=resource.tags,
185
+ created_at=resource.created_at,
186
+ config_hash=resource.config_hash,
187
+ )
188
+
189
+ yield detailed_resource
src/utils/export.py CHANGED
@@ -4,7 +4,10 @@ import csv
4
4
  import json
5
5
  import logging
6
6
  from pathlib import Path
7
- from typing import Any, Dict, List
7
+ from typing import TYPE_CHECKING, Any, Dict, List
8
+
9
+ if TYPE_CHECKING:
10
+ from src.models.report import DetailedResource, ResourceSummary, SnapshotMetadata
8
11
 
9
12
  logger = logging.getLogger(__name__)
10
13
 
@@ -85,3 +88,218 @@ def flatten_dict(d: Dict[str, Any], parent_key: str = "", sep: str = "_") -> Dic
85
88
  else:
86
89
  items.append((new_key, v))
87
90
  return dict(items)
91
+
92
+
93
+ def detect_format(filepath: str) -> str:
94
+ """
95
+ Detect export format from file extension.
96
+
97
+ Args:
98
+ filepath: Path to file
99
+
100
+ Returns:
101
+ Format string: 'json', 'csv', or 'txt'
102
+
103
+ Raises:
104
+ ValueError: If format is not supported
105
+ """
106
+ path = Path(filepath)
107
+ extension = path.suffix.lower()
108
+
109
+ if extension == ".json":
110
+ return "json"
111
+ elif extension == ".csv":
112
+ return "csv"
113
+ elif extension == ".txt":
114
+ return "txt"
115
+ else:
116
+ raise ValueError(f"Unsupported export format '{extension}'. " f"Supported formats: .json, .csv, .txt")
117
+
118
+
119
+ def export_report_json(
120
+ filepath: str,
121
+ metadata: "SnapshotMetadata",
122
+ summary: "ResourceSummary",
123
+ resources: List["DetailedResource"],
124
+ ) -> Path:
125
+ """
126
+ Export snapshot report to JSON format.
127
+
128
+ Args:
129
+ filepath: Destination file path
130
+ metadata: Snapshot metadata
131
+ summary: Resource summary
132
+ resources: List of detailed resources
133
+
134
+ Returns:
135
+ Path to exported file
136
+
137
+ Raises:
138
+ FileExistsError: If file already exists
139
+ FileNotFoundError: If parent directory doesn't exist
140
+ """
141
+ path = Path(filepath)
142
+
143
+ # Check if file already exists
144
+ if path.exists():
145
+ raise FileExistsError(f"Export file '{filepath}' already exists")
146
+
147
+ # Check if parent directory exists
148
+ if not path.parent.exists():
149
+ raise FileNotFoundError(f"Parent directory '{path.parent}' does not exist")
150
+
151
+ # Build report data structure
152
+ report_data = {
153
+ "snapshot_metadata": {
154
+ "name": metadata.name,
155
+ "created_at": metadata.created_at.isoformat(),
156
+ "account_id": metadata.account_id,
157
+ "regions": metadata.regions,
158
+ "inventory_name": metadata.inventory_name,
159
+ "total_resource_count": metadata.total_resource_count,
160
+ },
161
+ "summary": {
162
+ "total_count": summary.total_count,
163
+ "by_service": dict(summary.by_service),
164
+ "by_region": dict(summary.by_region),
165
+ "by_type": dict(summary.by_type),
166
+ },
167
+ "resources": [
168
+ {
169
+ "arn": r.arn,
170
+ "resource_type": r.resource_type,
171
+ "name": r.name,
172
+ "region": r.region,
173
+ "tags": r.tags,
174
+ "created_at": r.created_at.isoformat() if r.created_at else None,
175
+ "config_hash": r.config_hash,
176
+ }
177
+ for r in resources
178
+ ],
179
+ }
180
+
181
+ # Write to file
182
+ with open(path, "w", encoding="utf-8") as f:
183
+ json.dump(report_data, f, indent=2)
184
+
185
+ logger.info(f"Exported report to JSON: {path}")
186
+ return path
187
+
188
+
189
+ def export_report_csv(filepath: str, resources: List["DetailedResource"]) -> Path:
190
+ """
191
+ Export resources to CSV format.
192
+
193
+ Args:
194
+ filepath: Destination file path
195
+ resources: List of detailed resources
196
+
197
+ Returns:
198
+ Path to exported file
199
+
200
+ Raises:
201
+ FileExistsError: If file already exists
202
+ FileNotFoundError: If parent directory doesn't exist
203
+ """
204
+ path = Path(filepath)
205
+
206
+ # Check if file already exists
207
+ if path.exists():
208
+ raise FileExistsError(f"Export file '{filepath}' already exists")
209
+
210
+ # Check if parent directory exists
211
+ if not path.parent.exists():
212
+ raise FileNotFoundError(f"Parent directory '{path.parent}' does not exist")
213
+
214
+ # Write CSV
215
+ with open(path, "w", newline="", encoding="utf-8") as f:
216
+ writer = csv.writer(f)
217
+
218
+ # Write header
219
+ writer.writerow(["ARN", "ResourceType", "Name", "Region", "CreatedAt", "Tags"])
220
+
221
+ # Write resources
222
+ for resource in resources:
223
+ writer.writerow(
224
+ [
225
+ resource.arn,
226
+ resource.resource_type,
227
+ resource.name,
228
+ resource.region,
229
+ resource.created_at.isoformat() if resource.created_at else "",
230
+ json.dumps(resource.tags) if resource.tags else "{}",
231
+ ]
232
+ )
233
+
234
+ logger.info(f"Exported {len(resources)} resources to CSV: {path}")
235
+ return path
236
+
237
+
238
+ def export_report_txt(
239
+ filepath: str,
240
+ metadata: "SnapshotMetadata",
241
+ summary: "ResourceSummary",
242
+ ) -> Path:
243
+ """
244
+ Export report summary to plain text format.
245
+
246
+ Args:
247
+ filepath: Destination file path
248
+ metadata: Snapshot metadata
249
+ summary: Resource summary
250
+
251
+ Returns:
252
+ Path to exported file
253
+
254
+ Raises:
255
+ FileExistsError: If file already exists
256
+ FileNotFoundError: If parent directory doesn't exist
257
+ """
258
+ path = Path(filepath)
259
+
260
+ # Check if file already exists
261
+ if path.exists():
262
+ raise FileExistsError(f"Export file '{filepath}' already exists")
263
+
264
+ # Check if parent directory exists
265
+ if not path.parent.exists():
266
+ raise FileNotFoundError(f"Parent directory '{path.parent}' does not exist")
267
+
268
+ # Build text content
269
+ lines = []
270
+ lines.append("=" * 65)
271
+ lines.append(f"Snapshot Report: {metadata.name}")
272
+ lines.append("=" * 65)
273
+ lines.append("")
274
+ lines.append(f"Inventory: {metadata.inventory_name}")
275
+ lines.append(f"Account ID: {metadata.account_id}")
276
+ lines.append(f"Created: {metadata.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
277
+ lines.append(f"Regions: {metadata.region_summary}")
278
+ lines.append("")
279
+ lines.append("─" * 65)
280
+ lines.append("")
281
+ lines.append("Resource Summary")
282
+ lines.append("")
283
+ lines.append(f"Total Resources: {summary.total_count:,}")
284
+ lines.append("")
285
+
286
+ if summary.by_service:
287
+ lines.append("By Service:")
288
+ for service, count in summary.top_services(limit=10):
289
+ percentage = (count / summary.total_count) * 100 if summary.total_count > 0 else 0
290
+ lines.append(f" {service:20} {count:5} ({percentage:.1f}%)")
291
+ lines.append("")
292
+
293
+ if summary.by_region:
294
+ lines.append("By Region:")
295
+ for region, count in summary.top_regions(limit=10):
296
+ percentage = (count / summary.total_count) * 100 if summary.total_count > 0 else 0
297
+ lines.append(f" {region:20} {count:5} ({percentage:.1f}%)")
298
+ lines.append("")
299
+
300
+ # Write to file
301
+ with open(path, "w", encoding="utf-8") as f:
302
+ f.write("\n".join(lines))
303
+
304
+ logger.info(f"Exported report to TXT: {path}")
305
+ return path
@@ -0,0 +1,41 @@
1
+ """
2
+ Terminal pagination utilities for large resource lists.
3
+
4
+ This module provides pagination functionality for displaying large datasets
5
+ in the terminal with user-friendly navigation controls.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Generator, List, TypeVar
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ def paginate_resources(items: List[T], page_size: int = 100) -> Generator[List[T], None, None]:
16
+ """
17
+ Paginate a list of items into pages of specified size.
18
+
19
+ This is a memory-efficient generator that yields pages of items
20
+ without loading everything into memory at once.
21
+
22
+ Args:
23
+ items: List of items to paginate
24
+ page_size: Number of items per page (default: 100)
25
+
26
+ Yields:
27
+ Lists of items, each containing up to page_size items
28
+
29
+ Example:
30
+ >>> resources = list(range(250))
31
+ >>> for page in paginate_resources(resources, page_size=100):
32
+ ... print(f"Page has {len(page)} items")
33
+ Page has 100 items
34
+ Page has 100 items
35
+ Page has 50 items
36
+ """
37
+ if not items:
38
+ return
39
+
40
+ for i in range(0, len(items), page_size):
41
+ yield items[i : i + page_size]