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.
- {aws_inventory_manager-0.2.0.dist-info → aws_inventory_manager-0.3.0.dist-info}/METADATA +31 -5
- {aws_inventory_manager-0.2.0.dist-info → aws_inventory_manager-0.3.0.dist-info}/RECORD +12 -8
- src/cli/main.py +273 -0
- src/models/report.py +276 -0
- src/snapshot/report_formatter.py +250 -0
- src/snapshot/reporter.py +189 -0
- src/utils/export.py +219 -1
- src/utils/pagination.py +41 -0
- {aws_inventory_manager-0.2.0.dist-info → aws_inventory_manager-0.3.0.dist-info}/WHEEL +0 -0
- {aws_inventory_manager-0.2.0.dist-info → aws_inventory_manager-0.3.0.dist-info}/entry_points.txt +0 -0
- {aws_inventory_manager-0.2.0.dist-info → aws_inventory_manager-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {aws_inventory_manager-0.2.0.dist-info → aws_inventory_manager-0.3.0.dist-info}/top_level.txt +0 -0
src/snapshot/reporter.py
ADDED
|
@@ -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
|
src/utils/pagination.py
ADDED
|
@@ -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]
|
|
File without changes
|
{aws_inventory_manager-0.2.0.dist-info → aws_inventory_manager-0.3.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{aws_inventory_manager-0.2.0.dist-info → aws_inventory_manager-0.3.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{aws_inventory_manager-0.2.0.dist-info → aws_inventory_manager-0.3.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|