devops-aws-costlens 1.0.1__tar.gz → 1.2.0__tar.gz

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.
Files changed (21) hide show
  1. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/PKG-INFO +1 -1
  2. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/__init__.py +1 -1
  3. devops_aws_costlens-1.2.0/aws_costlens/app_controller.py +505 -0
  4. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/aws_api.py +2 -1
  5. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/cli_commands.py +16 -9
  6. devops_aws_costlens-1.2.0/aws_costlens/common_utils.py +323 -0
  7. devops_aws_costlens-1.2.0/aws_costlens/console_setup.py +46 -0
  8. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/cost_controller.py +105 -64
  9. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/profiles_controller.py +7 -4
  10. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/report_exporter.py +2 -1
  11. devops_aws_costlens-1.2.0/aws_costlens/visuals.py +87 -0
  12. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/pyproject.toml +1 -1
  13. devops_aws_costlens-1.0.1/aws_costlens/app_controller.py +0 -344
  14. devops_aws_costlens-1.0.1/aws_costlens/common_utils.py +0 -309
  15. devops_aws_costlens-1.0.1/aws_costlens/visuals.py +0 -38
  16. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/.gitignore +0 -0
  17. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/LICENSE +0 -0
  18. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/README.md +0 -0
  19. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/main.py +0 -0
  20. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/models.py +0 -0
  21. {devops_aws_costlens-1.0.1 → devops_aws_costlens-1.2.0}/aws_costlens/pdf_renderer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-aws-costlens
3
- Version: 1.0.1
3
+ Version: 1.2.0
4
4
  Summary: AWS Cost Intelligence Tool - Terminal-based cost and resource dashboard.
5
5
  Project-URL: Homepage, https://github.com/Calza36/aws-costlens
6
6
  Project-URL: Source, https://github.com/Calza36/aws-costlens
@@ -1,3 +1,3 @@
1
1
  """AWS CostLens - AWS Cost Intelligence Tool."""
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "1.2.0"
@@ -0,0 +1,505 @@
1
+ """Main application controller for AWS CostLens."""
2
+
3
+ import os
4
+ from collections import defaultdict
5
+ from datetime import datetime
6
+ from typing import Any, Dict, List, Optional, Tuple, Union
7
+
8
+ import boto3
9
+ from rich import box
10
+ from rich.console import Console
11
+ from rich.progress import track
12
+ from rich.status import Status
13
+ from rich.table import Column, Table
14
+
15
+ # Setup UTF-8 console for Windows
16
+ from aws_costlens.console_setup import setup_console
17
+ setup_console()
18
+
19
+ from aws_costlens.aws_api import (
20
+ get_accessible_regions,
21
+ get_account_id,
22
+ get_aws_profiles,
23
+ get_budgets,
24
+ get_stopped_instances,
25
+ get_unused_eips,
26
+ get_unused_volumes,
27
+ get_untagged_resources,
28
+ )
29
+ from aws_costlens.cost_controller import (
30
+ export_to_csv,
31
+ export_to_json,
32
+ get_cost_data,
33
+ get_trend,
34
+ )
35
+ from aws_costlens.report_exporter import ExportHandler
36
+ from aws_costlens.common_utils import (
37
+ clean_rich_tags,
38
+ export_audit_report_to_csv,
39
+ export_audit_report_to_json,
40
+ export_audit_report_to_pdf,
41
+ export_cost_dashboard_to_pdf,
42
+ export_trend_data_to_json,
43
+ )
44
+ from aws_costlens.profiles_controller import process_combined_profiles, process_single_profile
45
+ from aws_costlens.visuals import create_trend_bars
46
+ from aws_costlens.models import ProfileData
47
+
48
+ console = Console(force_terminal=True, legacy_windows=False)
49
+
50
+
51
+ def run_dashboard(
52
+ profiles: Optional[List[str]] = None,
53
+ regions: Optional[List[str]] = None,
54
+ all_profiles: bool = False,
55
+ combine: bool = False,
56
+ audit: bool = False,
57
+ trend: bool = False,
58
+ report_name: Optional[str] = None,
59
+ report_types: Optional[List[str]] = None,
60
+ output_dir: Optional[str] = None,
61
+ s3_bucket: Optional[str] = None,
62
+ s3_prefix: Optional[str] = None,
63
+ time_range: Optional[Union[int, str]] = None,
64
+ tags: Optional[Dict[str, str]] = None,
65
+ ) -> int:
66
+ """
67
+ Run the AWS CostLens application.
68
+
69
+ Args:
70
+ profiles: List of AWS profiles to process
71
+ regions: List of regions to check
72
+ all_profiles: Process all available profiles
73
+ combine: Merge results from multiple profiles
74
+ audit: Run resource scan
75
+ trend: Show cost history
76
+ report_name: Base name for report files
77
+ report_types: List of export formats (pdf, csv, json)
78
+ output_dir: Output directory for reports
79
+ s3_bucket: Optional S3 bucket for uploads
80
+ s3_prefix: Optional S3 path
81
+ time_range: Custom time range
82
+ tags: Tag filters
83
+ """
84
+ # Initialize profiles
85
+ with Status("[bright_cyan]🔄 Connecting to AWS...", spinner="dots12", speed=0.1):
86
+ profiles_to_use = _initialize_profiles(profiles, all_profiles)
87
+ if not profiles_to_use:
88
+ return 1
89
+
90
+ # Run audit report if requested
91
+ if audit:
92
+ _run_audit_report(profiles_to_use, regions, report_name, report_types, output_dir, s3_bucket, s3_prefix)
93
+ return 0
94
+
95
+ # Run trend analysis if requested
96
+ if trend:
97
+ _run_trend_analysis(profiles_to_use, combine, report_name, report_types, output_dir, s3_bucket, s3_prefix, tags)
98
+ return 0
99
+
100
+ # Run main cost dashboard
101
+ _run_cost_dashboard(
102
+ profiles_to_use=profiles_to_use,
103
+ user_regions=regions,
104
+ combine=combine,
105
+ report_name=report_name,
106
+ report_types=report_types,
107
+ output_dir=output_dir,
108
+ s3_bucket=s3_bucket,
109
+ s3_prefix=s3_prefix,
110
+ time_range=time_range,
111
+ tags=tags,
112
+ )
113
+ return 0
114
+
115
+
116
+ def _initialize_profiles(profiles: Optional[List[str]], all_profiles: bool) -> List[str]:
117
+ """Initialize AWS profiles based on arguments."""
118
+ available_profiles = get_aws_profiles()
119
+ if not available_profiles:
120
+ console.log("[bold red]No AWS profiles found. Please configure AWS CLI first.[/]")
121
+ return []
122
+
123
+ profiles_to_use = []
124
+ if profiles:
125
+ for profile in profiles:
126
+ if profile in available_profiles:
127
+ profiles_to_use.append(profile)
128
+ else:
129
+ console.log(f"[yellow]Warning: Profile '{profile}' not found in AWS configuration[/]")
130
+ if not profiles_to_use:
131
+ console.log("[bold red]None of the specified profiles were found in AWS configuration.[/]")
132
+ return []
133
+ elif all_profiles:
134
+ profiles_to_use = available_profiles
135
+ else:
136
+ if "default" in available_profiles:
137
+ profiles_to_use = ["default"]
138
+ else:
139
+ profiles_to_use = available_profiles
140
+ console.log("[yellow]No default profile found. Using all available profiles.[/]")
141
+
142
+ return profiles_to_use
143
+
144
+
145
+ def _run_audit_report(
146
+ profiles_to_use: List[str],
147
+ regions: Optional[List[str]],
148
+ report_name: Optional[str],
149
+ report_types: Optional[List[str]],
150
+ output_dir: Optional[str],
151
+ s3_bucket: Optional[str],
152
+ s3_prefix: Optional[str],
153
+ ) -> None:
154
+ """Generate and export a resource scan report."""
155
+ console.print("[bold bright_green]⚡ Scanning resources...[/]")
156
+ console.print("[dim]Checking: EC2, RDS, Lambda, ELBv2[/]\n")
157
+
158
+ table = Table(
159
+ Column("Profile", justify="center"),
160
+ Column("Account ID", justify="center"),
161
+ Column("Untagged Resources"),
162
+ Column("Stopped EC2 Instances"),
163
+ Column("Unused Volumes"),
164
+ Column("Unused EIPs"),
165
+ Column("Budget Alerts"),
166
+ title="🔍 Resource Scan Results",
167
+ show_lines=True,
168
+ box=box.ASCII_DOUBLE_HEAD,
169
+ style="bright_green",
170
+ )
171
+
172
+ audit_data = []
173
+ raw_audit_data = []
174
+ nl = "\n"
175
+ comma_nl = ",\n"
176
+
177
+ for profile in profiles_to_use:
178
+ session = boto3.Session(profile_name=profile)
179
+ account_id = get_account_id(session) or "Unknown"
180
+ check_regions = regions or get_accessible_regions(session)
181
+
182
+ try:
183
+ untagged = get_untagged_resources(session, check_regions)
184
+ anomalies = []
185
+ for service, region_map in untagged.items():
186
+ if region_map:
187
+ service_block = f"[bright_yellow]{service}[/]:\n"
188
+ for region, ids in region_map.items():
189
+ if ids:
190
+ ids_block = "\n".join(f"[orange1]{res_id}[/]" for res_id in ids)
191
+ service_block += f"\n{region}:\n{ids_block}\n"
192
+ anomalies.append(service_block)
193
+ if not any(region_map for region_map in untagged.values()):
194
+ anomalies = ["None"]
195
+ except Exception as e:
196
+ anomalies = [f"Error: {str(e)}"]
197
+
198
+ stopped = get_stopped_instances(session, check_regions)
199
+ stopped_list = [f"{r}:\n[gold1]{nl.join(ids)}[/]" for r, ids in stopped.items()] or ["None"]
200
+
201
+ unused_vols = get_unused_volumes(session, check_regions)
202
+ vols_list = [f"{r}:\n[dark_orange]{nl.join(ids)}[/]" for r, ids in unused_vols.items()] or ["None"]
203
+
204
+ unused_eips = get_unused_eips(session, check_regions)
205
+ eips_list = [f"{r}:\n{comma_nl.join(ids)}" for r, ids in unused_eips.items()] or ["None"]
206
+
207
+ budget_data = get_budgets(session)
208
+ alerts = []
209
+ for b in budget_data:
210
+ if b["actual"] > b["limit"]:
211
+ alerts.append(f"[red1]{b['name']}[/]: ${b['actual']:.2f} > ${b['limit']:.2f}")
212
+ if not alerts:
213
+ alerts = ["No budgets exceeded"]
214
+
215
+ audit_data.append({
216
+ "profile": profile,
217
+ "account_id": account_id,
218
+ "untagged_resources": clean_rich_tags("\n".join(anomalies)),
219
+ "stopped_instances": clean_rich_tags("\n".join(stopped_list)),
220
+ "unused_volumes": clean_rich_tags("\n".join(vols_list)),
221
+ "unused_eips": clean_rich_tags("\n".join(eips_list)),
222
+ "budget_alerts": clean_rich_tags("\n".join(alerts)),
223
+ })
224
+
225
+ raw_audit_data.append({
226
+ "profile": profile,
227
+ "account_id": account_id,
228
+ "untagged_resources": untagged,
229
+ "stopped_instances": stopped,
230
+ "unused_volumes": unused_vols,
231
+ "unused_eips": unused_eips,
232
+ "budget_alerts": budget_data,
233
+ })
234
+
235
+ table.add_row(
236
+ f"[dark_magenta]{profile}[/]",
237
+ account_id,
238
+ "\n".join(anomalies),
239
+ "\n".join(stopped_list),
240
+ "\n".join(vols_list),
241
+ "\n".join(eips_list),
242
+ "\n".join(alerts),
243
+ )
244
+
245
+ console.print(table)
246
+ # Note already shown at the start of scan
247
+
248
+ # Export if requested
249
+ if report_name and report_types:
250
+ export_handler = ExportHandler(
251
+ output_dir=output_dir or os.getcwd(),
252
+ s3_bucket=s3_bucket,
253
+ s3_prefix=s3_prefix,
254
+ profile=profiles_to_use[0] if profiles_to_use else None,
255
+ )
256
+
257
+ for report_type in report_types:
258
+ if report_type == "csv":
259
+ csv_content = export_audit_report_to_csv(audit_data)
260
+ export_handler.save_csv(csv_content, f"{report_name}.csv")
261
+ elif report_type == "json":
262
+ json_content = export_audit_report_to_json(raw_audit_data)
263
+ export_handler.save_json(json_content, f"{report_name}.json")
264
+ elif report_type == "pdf":
265
+ pdf_bytes = export_audit_report_to_pdf(audit_data, report_name)
266
+ export_handler.save_pdf(pdf_bytes, f"{report_name}.pdf")
267
+
268
+
269
+ def _run_trend_analysis(
270
+ profiles_to_use: List[str],
271
+ combine: bool,
272
+ report_name: Optional[str],
273
+ report_types: Optional[List[str]],
274
+ output_dir: Optional[str],
275
+ s3_bucket: Optional[str],
276
+ s3_prefix: Optional[str],
277
+ tags: Optional[Dict[str, str]],
278
+ ) -> None:
279
+ """Analyze and display cost trends."""
280
+ console.print("[bold bright_magenta]📊 Loading cost history...[/]")
281
+ raw_trend_data = []
282
+
283
+ if combine:
284
+ account_profiles = defaultdict(list)
285
+ for profile in profiles_to_use:
286
+ try:
287
+ session = boto3.Session(profile_name=profile)
288
+ account_id = get_account_id(session)
289
+ if account_id:
290
+ account_profiles[account_id].append(profile)
291
+ except Exception as e:
292
+ console.print(f"[red]Error checking account ID for profile {profile}: {str(e)}[/]")
293
+
294
+ for account_id, profile_list in account_profiles.items():
295
+ try:
296
+ primary_profile = profile_list[0]
297
+ session = boto3.Session(profile_name=primary_profile)
298
+ cost_data = get_trend(session, tags)
299
+ trend_data = cost_data.get("monthly_costs")
300
+
301
+ if not trend_data:
302
+ console.print(f"[yellow]No trend data available for account {account_id}[/]")
303
+ continue
304
+
305
+ profiles_str = ", ".join(profile_list)
306
+ console.print(f"\n[bright_yellow]Account: {account_id} (Profiles: {profiles_str})[/]")
307
+ raw_trend_data.append(cost_data)
308
+ create_trend_bars(trend_data)
309
+ except Exception as e:
310
+ console.print(f"[red]Error getting trend for account {account_id}: {str(e)}[/]")
311
+ else:
312
+ for profile in profiles_to_use:
313
+ try:
314
+ session = boto3.Session(profile_name=profile)
315
+ cost_data = get_trend(session, tags)
316
+ trend_data = cost_data.get("monthly_costs")
317
+ account_id = cost_data.get("account_id", "Unknown")
318
+
319
+ if not trend_data:
320
+ console.print(f"[yellow]No trend data available for profile {profile}[/]")
321
+ continue
322
+
323
+ console.print(f"\n[bright_yellow]Account: {account_id} (Profile: {profile})[/]")
324
+ raw_trend_data.append(cost_data)
325
+ create_trend_bars(trend_data)
326
+ except Exception as e:
327
+ console.print(f"[red]Error getting trend for profile {profile}: {str(e)}[/]")
328
+
329
+ # Export if requested
330
+ if raw_trend_data and report_name and report_types:
331
+ export_handler = ExportHandler(
332
+ output_dir=output_dir or os.getcwd(),
333
+ s3_bucket=s3_bucket,
334
+ s3_prefix=s3_prefix,
335
+ profile=profiles_to_use[0] if profiles_to_use else None,
336
+ )
337
+
338
+ if "json" in report_types:
339
+ json_content = export_trend_data_to_json(raw_trend_data, report_name)
340
+ export_handler.save_json(json_content, f"{report_name}_trend.json")
341
+
342
+
343
+ def _get_display_table_period_info(
344
+ profiles_to_use: List[str], time_range: Optional[Union[int, str]]
345
+ ) -> Tuple[str, str, str, str]:
346
+ """Get period information for the display table."""
347
+ if profiles_to_use:
348
+ try:
349
+ sample_session = boto3.Session(profile_name=profiles_to_use[0])
350
+ sample_cost_data = get_cost_data(sample_session, time_range)
351
+ previous_period_name = sample_cost_data.get("previous_period_name", "Last Month Due")
352
+ current_period_name = sample_cost_data.get("current_period_name", "Current Month Cost")
353
+ previous_period_dates = f"{sample_cost_data['previous_period_start']} to {sample_cost_data['previous_period_end']}"
354
+ current_period_dates = f"{sample_cost_data['current_period_start']} to {sample_cost_data['current_period_end']}"
355
+ return (previous_period_name, current_period_name, previous_period_dates, current_period_dates)
356
+ except Exception:
357
+ pass
358
+ return "Last Month Due", "Current Month Cost", "N/A", "N/A"
359
+
360
+
361
+ def create_display_table(
362
+ previous_period_dates: str,
363
+ current_period_dates: str,
364
+ previous_period_name: str = "Last Month Due",
365
+ current_period_name: str = "Current Month Cost",
366
+ ) -> Table:
367
+ """Create and configure the display table with dynamic column names."""
368
+ return Table(
369
+ Column("AWS Account Profile", justify="center", vertical="middle"),
370
+ Column(f"{previous_period_name}\n({previous_period_dates})", justify="center", vertical="middle"),
371
+ Column(f"{current_period_name}\n({current_period_dates})", justify="center", vertical="middle"),
372
+ Column("Previous Costs by Service", vertical="middle"),
373
+ Column("Current Costs by Service", vertical="middle"),
374
+ Column("Budget Status", vertical="middle"),
375
+ Column("EC2 Summary", justify="center", vertical="middle"),
376
+ title="💰 Spending Overview",
377
+ caption="CostLens",
378
+ box=box.ASCII_DOUBLE_HEAD,
379
+ show_lines=True,
380
+ style="bright_cyan",
381
+ )
382
+
383
+
384
+ def add_profile_to_table(table: Table, profile_data: ProfileData) -> None:
385
+ """Add profile data to the display table."""
386
+ if profile_data["success"]:
387
+ percentage_change = profile_data.get("percent_change_in_total_cost")
388
+ change_text = ""
389
+
390
+ if percentage_change is not None:
391
+ if percentage_change > 0:
392
+ change_text = f"\n\n[bright_red]⬆ {percentage_change:.2f}%"
393
+ elif percentage_change < 0:
394
+ change_text = f"\n\n[bright_green]⬇ {abs(percentage_change):.2f}%"
395
+ elif percentage_change == 0:
396
+ change_text = "\n\n[bright_yellow]➡ 0.00%[/]"
397
+
398
+ current_month_with_change = f"[bold red]${profile_data['current_month']:.2f}[/]{change_text}"
399
+
400
+ table.add_row(
401
+ f"[bright_magenta]Profile: {profile_data['profile']}\nAccount: {profile_data['account_id']}[/]",
402
+ f"[bold red]${profile_data['last_month']:.2f}[/]",
403
+ current_month_with_change,
404
+ "[bright_green]" + "\n".join(profile_data["previous_service_costs_formatted"]) + "[/]",
405
+ "[bright_green]" + "\n".join(profile_data["service_costs_formatted"]) + "[/]",
406
+ "[bright_yellow]" + "\n\n".join(profile_data["budget_info"]) + "[/]",
407
+ "\n".join(profile_data["ec2_summary_formatted"]),
408
+ )
409
+ else:
410
+ table.add_row(
411
+ f"[bright_magenta]{profile_data['profile']}[/]",
412
+ "[red]Error[/]",
413
+ "[red]Error[/]",
414
+ "[red]Error[/]",
415
+ f"[red]Failed to process profile: {profile_data['error']}[/]",
416
+ "[red]N/A[/]",
417
+ "[red]N/A[/]",
418
+ )
419
+
420
+
421
+ def _run_cost_dashboard(
422
+ profiles_to_use: List[str],
423
+ user_regions: Optional[List[str]],
424
+ combine: bool,
425
+ report_name: Optional[str],
426
+ report_types: Optional[List[str]],
427
+ output_dir: Optional[str],
428
+ s3_bucket: Optional[str],
429
+ s3_prefix: Optional[str],
430
+ time_range: Optional[Union[int, str]],
431
+ tags: Optional[Dict[str, str]],
432
+ ) -> None:
433
+ """Run cost dashboard and generate reports."""
434
+ with Status("[bright_cyan]💰 Preparing dashboard...", spinner="dots12", speed=0.1):
435
+ (
436
+ previous_period_name,
437
+ current_period_name,
438
+ previous_period_dates,
439
+ current_period_dates,
440
+ ) = _get_display_table_period_info(profiles_to_use, time_range)
441
+
442
+ table = create_display_table(
443
+ previous_period_dates,
444
+ current_period_dates,
445
+ previous_period_name,
446
+ current_period_name,
447
+ )
448
+
449
+ export_data: List[ProfileData] = []
450
+
451
+ if combine:
452
+ account_profiles = defaultdict(list)
453
+ for profile in profiles_to_use:
454
+ try:
455
+ session = boto3.Session(profile_name=profile)
456
+ current_account_id = get_account_id(session)
457
+ if current_account_id:
458
+ account_profiles[current_account_id].append(profile)
459
+ else:
460
+ console.log(f"[yellow]Could not determine account ID for profile {profile}[/]")
461
+ except Exception as e:
462
+ console.log(f"[bold red]Error checking account ID for profile {profile}: {str(e)}[/]")
463
+
464
+ for account_id_key, profile_list in track(
465
+ account_profiles.items(), description="[bright_cyan]Retrieving AWS costs..."
466
+ ):
467
+ if len(profile_list) > 1:
468
+ profile_data = process_combined_profiles(
469
+ account_id_key, profile_list, user_regions, time_range, tags
470
+ )
471
+ else:
472
+ profile_data = process_single_profile(
473
+ profile_list[0], user_regions, time_range, tags
474
+ )
475
+ export_data.append(profile_data)
476
+ add_profile_to_table(table, profile_data)
477
+ else:
478
+ for profile in track(profiles_to_use, description="[bright_cyan]Retrieving AWS costs..."):
479
+ profile_data = process_single_profile(profile, user_regions, time_range, tags)
480
+ export_data.append(profile_data)
481
+ add_profile_to_table(table, profile_data)
482
+
483
+ console.print(table)
484
+
485
+ # Export if requested
486
+ if report_name and report_types:
487
+ export_handler = ExportHandler(
488
+ output_dir=output_dir or os.getcwd(),
489
+ s3_bucket=s3_bucket,
490
+ s3_prefix=s3_prefix,
491
+ profile=profiles_to_use[0] if profiles_to_use else None,
492
+ )
493
+
494
+ for report_type in report_types:
495
+ if report_type == "csv":
496
+ csv_content = export_to_csv(export_data, report_name, previous_period_dates, current_period_dates)
497
+ export_handler.save_csv(csv_content, f"{report_name}.csv")
498
+ elif report_type == "json":
499
+ json_content = export_to_json(export_data, report_name)
500
+ export_handler.save_json(json_content, f"{report_name}.json")
501
+ elif report_type == "pdf":
502
+ pdf_bytes = export_cost_dashboard_to_pdf(
503
+ export_data, report_name, previous_period_dates, current_period_dates
504
+ )
505
+ export_handler.save_pdf(pdf_bytes, f"{report_name}.pdf")
@@ -10,7 +10,8 @@ from rich.console import Console
10
10
 
11
11
  from aws_costlens.models import BudgetInfo, EC2Summary, RegionName
12
12
 
13
- console = Console()
13
+ # Force UTF-8 and modern Windows terminal mode for Unicode support
14
+ console = Console(force_terminal=True, legacy_windows=False)
14
15
 
15
16
 
16
17
  def get_aws_profiles() -> List[str]:
@@ -4,24 +4,31 @@ import argparse
4
4
  import sys
5
5
  from typing import Dict, List, Optional
6
6
 
7
+ # Setup UTF-8 console for Windows
8
+ from aws_costlens.console_setup import setup_console
9
+ setup_console()
10
+
7
11
  from rich.console import Console
8
12
 
9
13
  from aws_costlens import __version__
10
14
  from aws_costlens.app_controller import run_dashboard
11
15
  from aws_costlens.common_utils import load_config_file
12
16
 
13
- console = Console()
17
+ # Force UTF-8 and modern Windows terminal mode for Unicode support
18
+ console = Console(force_terminal=True, legacy_windows=False)
14
19
 
15
20
 
16
21
  def welcome_banner() -> None:
17
- """Display the welcome banner."""
18
- banner = """
19
- [bold cyan]╔═══════════════════════════════════════════════╗
20
- ║ AWS CostLens - Cost Intelligence Tool ║
21
- ╚═══════════════════════════════════════════════╝[/]
22
- """
23
- console.print(banner)
24
- console.print(f"[dim]Version {__version__}[/]\n")
22
+ """Display the welcome banner with version."""
23
+ # Compact banner with version on the right
24
+ console.print()
25
+ console.print(
26
+ "[bold bright_cyan]╔═══════════════════════════════════════════════╗[/] "
27
+ f"[dim italic]v{__version__}[/]"
28
+ )
29
+ console.print("[bold bright_cyan]║ AWS CostLens - Cost Intelligence Tool ║[/]")
30
+ console.print("[bold bright_cyan]╚═══════════════════════════════════════════════╝[/]")
31
+ console.print() # Single line space before content
25
32
 
26
33
 
27
34
  def parse_tags(tag_strings: Optional[List[str]]) -> Optional[Dict[str, str]]: