iam-policy-validator 1.4.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 iam-policy-validator might be problematic. Click here for more details.

Files changed (56) hide show
  1. iam_policy_validator-1.4.0.dist-info/METADATA +1022 -0
  2. iam_policy_validator-1.4.0.dist-info/RECORD +56 -0
  3. iam_policy_validator-1.4.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.4.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.4.0.dist-info/licenses/LICENSE +21 -0
  6. iam_validator/__init__.py +27 -0
  7. iam_validator/__main__.py +11 -0
  8. iam_validator/__version__.py +7 -0
  9. iam_validator/checks/__init__.py +27 -0
  10. iam_validator/checks/action_condition_enforcement.py +727 -0
  11. iam_validator/checks/action_resource_constraint.py +151 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +70 -0
  14. iam_validator/checks/policy_size.py +151 -0
  15. iam_validator/checks/policy_type_validation.py +299 -0
  16. iam_validator/checks/principal_validation.py +282 -0
  17. iam_validator/checks/resource_validation.py +108 -0
  18. iam_validator/checks/security_best_practices.py +536 -0
  19. iam_validator/checks/sid_uniqueness.py +170 -0
  20. iam_validator/checks/utils/__init__.py +1 -0
  21. iam_validator/checks/utils/policy_level_checks.py +143 -0
  22. iam_validator/checks/utils/sensitive_action_matcher.py +252 -0
  23. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  24. iam_validator/commands/__init__.py +25 -0
  25. iam_validator/commands/analyze.py +434 -0
  26. iam_validator/commands/base.py +48 -0
  27. iam_validator/commands/cache.py +392 -0
  28. iam_validator/commands/download_services.py +260 -0
  29. iam_validator/commands/post_to_pr.py +86 -0
  30. iam_validator/commands/validate.py +539 -0
  31. iam_validator/core/__init__.py +14 -0
  32. iam_validator/core/access_analyzer.py +666 -0
  33. iam_validator/core/access_analyzer_report.py +643 -0
  34. iam_validator/core/aws_fetcher.py +880 -0
  35. iam_validator/core/aws_global_conditions.py +137 -0
  36. iam_validator/core/check_registry.py +469 -0
  37. iam_validator/core/cli.py +134 -0
  38. iam_validator/core/config_loader.py +452 -0
  39. iam_validator/core/defaults.py +393 -0
  40. iam_validator/core/formatters/__init__.py +27 -0
  41. iam_validator/core/formatters/base.py +147 -0
  42. iam_validator/core/formatters/console.py +59 -0
  43. iam_validator/core/formatters/csv.py +170 -0
  44. iam_validator/core/formatters/enhanced.py +434 -0
  45. iam_validator/core/formatters/html.py +672 -0
  46. iam_validator/core/formatters/json.py +33 -0
  47. iam_validator/core/formatters/markdown.py +63 -0
  48. iam_validator/core/formatters/sarif.py +187 -0
  49. iam_validator/core/models.py +298 -0
  50. iam_validator/core/policy_checks.py +656 -0
  51. iam_validator/core/policy_loader.py +396 -0
  52. iam_validator/core/pr_commenter.py +338 -0
  53. iam_validator/core/report.py +859 -0
  54. iam_validator/integrations/__init__.py +28 -0
  55. iam_validator/integrations/github_integration.py +795 -0
  56. iam_validator/integrations/ms_teams.py +442 -0
@@ -0,0 +1,392 @@
1
+ """Cache management command for IAM Policy Validator."""
2
+
3
+ import argparse
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from iam_validator.commands.base import Command
11
+ from iam_validator.core.aws_fetcher import AWSServiceFetcher
12
+ from iam_validator.core.config_loader import ConfigLoader
13
+
14
+ logger = logging.getLogger(__name__)
15
+ console = Console()
16
+
17
+
18
+ class CacheCommand(Command):
19
+ """Manage AWS service definition cache."""
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ return "cache"
24
+
25
+ @property
26
+ def help(self) -> str:
27
+ return "Manage AWS service definition cache"
28
+
29
+ @property
30
+ def epilog(self) -> str:
31
+ return """
32
+ Examples:
33
+ # Show cache information
34
+ iam-validator cache info
35
+
36
+ # List all cached services
37
+ iam-validator cache list
38
+
39
+ # Clear all cached AWS service definitions
40
+ iam-validator cache clear
41
+
42
+ # Refresh cache (clear and pre-fetch common services)
43
+ iam-validator cache refresh
44
+
45
+ # Pre-fetch common AWS services
46
+ iam-validator cache prefetch
47
+
48
+ # Show cache location
49
+ iam-validator cache location
50
+ """
51
+
52
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
53
+ """Add cache command arguments."""
54
+ subparsers = parser.add_subparsers(dest="cache_action", help="Cache action to perform")
55
+
56
+ # Info subcommand
57
+ info_parser = subparsers.add_parser("info", help="Show cache information and statistics")
58
+ info_parser.add_argument(
59
+ "--config",
60
+ type=str,
61
+ help="Path to configuration file",
62
+ )
63
+
64
+ # List subcommand
65
+ list_parser = subparsers.add_parser("list", help="List all cached AWS services")
66
+ list_parser.add_argument(
67
+ "--config",
68
+ type=str,
69
+ help="Path to configuration file",
70
+ )
71
+ list_parser.add_argument(
72
+ "--format",
73
+ choices=["table", "columns", "simple"],
74
+ default="table",
75
+ help="Output format (default: table)",
76
+ )
77
+
78
+ # Clear subcommand
79
+ clear_parser = subparsers.add_parser(
80
+ "clear", help="Clear all cached AWS service definitions"
81
+ )
82
+ clear_parser.add_argument(
83
+ "--config",
84
+ type=str,
85
+ help="Path to configuration file",
86
+ )
87
+
88
+ # Refresh subcommand
89
+ refresh_parser = subparsers.add_parser(
90
+ "refresh", help="Clear cache and pre-fetch common AWS services"
91
+ )
92
+ refresh_parser.add_argument(
93
+ "--config",
94
+ type=str,
95
+ help="Path to configuration file",
96
+ )
97
+
98
+ # Prefetch subcommand
99
+ prefetch_parser = subparsers.add_parser(
100
+ "prefetch", help="Pre-fetch common AWS services (without clearing)"
101
+ )
102
+ prefetch_parser.add_argument(
103
+ "--config",
104
+ type=str,
105
+ help="Path to configuration file",
106
+ )
107
+
108
+ # Location subcommand
109
+ location_parser = subparsers.add_parser("location", help="Show cache directory location")
110
+ location_parser.add_argument(
111
+ "--config",
112
+ type=str,
113
+ help="Path to configuration file",
114
+ )
115
+
116
+ async def execute(self, args: argparse.Namespace) -> int:
117
+ """Execute cache command."""
118
+ if not hasattr(args, "cache_action") or not args.cache_action:
119
+ console.print("[red]Error:[/red] No cache action specified")
120
+ console.print("Use 'iam-validator cache --help' for available actions")
121
+ return 1
122
+
123
+ # Load config to get cache settings
124
+ config_path = getattr(args, "config", None)
125
+ config = ConfigLoader.load_config(explicit_path=config_path, allow_missing=True)
126
+
127
+ cache_enabled = config.get_setting("cache_enabled", True)
128
+ cache_ttl_hours = config.get_setting("cache_ttl_hours", 168)
129
+ cache_directory = config.get_setting("cache_directory", None)
130
+ cache_ttl_seconds = cache_ttl_hours * 3600
131
+
132
+ # Get cache directory (even if caching is disabled, for info purposes)
133
+ cache_dir = AWSServiceFetcher._get_cache_directory(cache_directory)
134
+
135
+ action = args.cache_action
136
+
137
+ if action == "info":
138
+ return await self._show_info(cache_dir, cache_enabled, cache_ttl_hours)
139
+ elif action == "list":
140
+ output_format = getattr(args, "format", "table")
141
+ return self._list_cached_services(cache_dir, output_format)
142
+ elif action == "clear":
143
+ return await self._clear_cache(cache_dir, cache_enabled)
144
+ elif action == "refresh":
145
+ return await self._refresh_cache(cache_enabled, cache_ttl_seconds, cache_directory)
146
+ elif action == "prefetch":
147
+ return await self._prefetch_services(cache_enabled, cache_ttl_seconds, cache_directory)
148
+ elif action == "location":
149
+ return self._show_location(cache_dir)
150
+ else:
151
+ console.print(f"[red]Error:[/red] Unknown cache action: {action}")
152
+ return 1
153
+
154
+ async def _show_info(self, cache_dir: Path, cache_enabled: bool, cache_ttl_hours: int) -> int:
155
+ """Show cache information and statistics."""
156
+ table = Table(title="Cache Information")
157
+ table.add_column("Setting", style="cyan", no_wrap=True)
158
+ table.add_column("Value", style="white")
159
+
160
+ # Cache status
161
+ table.add_row(
162
+ "Status", "[green]Enabled[/green]" if cache_enabled else "[red]Disabled[/red]"
163
+ )
164
+
165
+ # Cache location
166
+ table.add_row("Location", str(cache_dir))
167
+
168
+ # Cache exists?
169
+ exists = cache_dir.exists()
170
+ table.add_row("Exists", "[green]Yes[/green]" if exists else "[yellow]No[/yellow]")
171
+
172
+ # Cache TTL
173
+ ttl_days = cache_ttl_hours / 24
174
+ table.add_row("TTL", f"{cache_ttl_hours} hours ({ttl_days:.1f} days)")
175
+
176
+ if exists:
177
+ # Count cached files
178
+ cache_files = list(cache_dir.glob("*.json"))
179
+ table.add_row("Cached Services", str(len(cache_files)))
180
+
181
+ # Calculate cache size
182
+ total_size = sum(f.stat().st_size for f in cache_files)
183
+ size_mb = total_size / (1024 * 1024)
184
+ table.add_row("Cache Size", f"{size_mb:.2f} MB")
185
+
186
+ # Show some cached services
187
+ if cache_files:
188
+ service_names = []
189
+ for f in cache_files[:5]:
190
+ name = f.stem.split("_")[0] if "_" in f.stem else f.stem
191
+ service_names.append(name)
192
+ sample = ", ".join(service_names)
193
+ if len(cache_files) > 5:
194
+ sample += f", ... ({len(cache_files) - 5} more)"
195
+ table.add_row("Sample Services", sample)
196
+
197
+ console.print(table)
198
+ return 0
199
+
200
+ def _list_cached_services(self, cache_dir: Path, output_format: str) -> int:
201
+ """List all cached AWS services."""
202
+ if not cache_dir.exists():
203
+ console.print("[yellow]Cache directory does not exist[/yellow]")
204
+ return 0
205
+
206
+ cache_files = list(cache_dir.glob("*.json"))
207
+
208
+ if not cache_files:
209
+ console.print("[yellow]No services cached yet[/yellow]")
210
+ return 0
211
+
212
+ # Extract service names from filenames
213
+ services = []
214
+ for f in cache_files:
215
+ # Handle both formats: "service_hash.json" and "services_list.json"
216
+ if f.stem == "services_list":
217
+ continue # Skip the services list file
218
+
219
+ # Extract service name (before underscore or full name)
220
+ name = f.stem.split("_")[0] if "_" in f.stem else f.stem
221
+
222
+ # Get file stats
223
+ size = f.stat().st_size
224
+ mtime = f.stat().st_mtime
225
+
226
+ services.append({"name": name, "size": size, "file": f.name, "mtime": mtime})
227
+
228
+ # Sort by service name
229
+ services.sort(key=lambda x: x["name"])
230
+
231
+ if output_format == "table":
232
+ self._print_services_table(services)
233
+ elif output_format == "columns":
234
+ self._print_services_columns(services)
235
+ else: # simple
236
+ self._print_services_simple(services)
237
+
238
+ return 0
239
+
240
+ def _print_services_table(self, services: list[dict]) -> None:
241
+ """Print services in a nice table format."""
242
+ from datetime import datetime
243
+
244
+ table = Table(title=f"Cached AWS Services ({len(services)} total)")
245
+ table.add_column("Service", style="cyan", no_wrap=True)
246
+ table.add_column("Cache File", style="white")
247
+ table.add_column("Size", style="yellow", justify="right")
248
+ table.add_column("Cached", style="green")
249
+
250
+ for svc in services:
251
+ size_kb = svc["size"] / 1024
252
+ cached_time = datetime.fromtimestamp(svc["mtime"]).strftime("%Y-%m-%d %H:%M")
253
+
254
+ table.add_row(svc["name"], svc["file"], f"{size_kb:.1f} KB", cached_time)
255
+
256
+ console.print(table)
257
+
258
+ def _print_services_columns(self, services: list[dict]) -> None:
259
+ """Print services in columns format (like ls)."""
260
+ from rich.columns import Columns
261
+
262
+ console.print(f"[cyan]Cached AWS Services ({len(services)} total):[/cyan]\n")
263
+
264
+ service_names = [f"[green]{svc['name']}[/green]" for svc in services]
265
+ console.print(Columns(service_names, equal=True, expand=False))
266
+
267
+ def _print_services_simple(self, services: list[dict]) -> None:
268
+ """Print services in simple list format."""
269
+ console.print(f"[cyan]Cached AWS Services ({len(services)} total):[/cyan]\n")
270
+
271
+ for svc in services:
272
+ console.print(svc["name"])
273
+
274
+ async def _clear_cache(self, cache_dir: Path, cache_enabled: bool) -> int:
275
+ """Clear all cached AWS service definitions."""
276
+ if not cache_enabled:
277
+ console.print("[yellow]Warning:[/yellow] Cache is disabled in config")
278
+ return 0
279
+
280
+ if not cache_dir.exists():
281
+ console.print("[yellow]Cache directory does not exist, nothing to clear[/yellow]")
282
+ return 0
283
+
284
+ # Count files before deletion
285
+ cache_files = list(cache_dir.glob("*.json"))
286
+ file_count = len(cache_files)
287
+
288
+ if file_count == 0:
289
+ console.print("[yellow]Cache is already empty[/yellow]")
290
+ return 0
291
+
292
+ # Delete cache files
293
+ deleted = 0
294
+ failed = 0
295
+ for cache_file in cache_files:
296
+ try:
297
+ cache_file.unlink()
298
+ deleted += 1
299
+ except Exception as e:
300
+ logger.error(f"Failed to delete {cache_file}: {e}")
301
+ failed += 1
302
+
303
+ if failed == 0:
304
+ console.print(f"[green]✓[/green] Cleared {deleted} cached service definitions")
305
+ else:
306
+ console.print(
307
+ f"[yellow]![/yellow] Cleared {deleted} files, failed to delete {failed} files"
308
+ )
309
+ return 1
310
+
311
+ return 0
312
+
313
+ async def _refresh_cache(
314
+ self, cache_enabled: bool, cache_ttl_seconds: int, cache_directory: str | None
315
+ ) -> int:
316
+ """Clear cache and pre-fetch common services."""
317
+ if not cache_enabled:
318
+ console.print("[red]Error:[/red] Cache is disabled in config")
319
+ console.print("Enable cache by setting 'cache_enabled: true' in your config")
320
+ return 1
321
+
322
+ console.print("[cyan]Refreshing cache...[/cyan]")
323
+
324
+ # Create fetcher and clear cache
325
+ async with AWSServiceFetcher(
326
+ enable_cache=cache_enabled,
327
+ cache_ttl=cache_ttl_seconds,
328
+ cache_dir=cache_directory,
329
+ prefetch_common=False, # Don't prefetch yet, we'll do it after clearing
330
+ ) as fetcher:
331
+ # Clear existing cache
332
+ console.print("Clearing old cache...")
333
+ await fetcher.clear_caches()
334
+
335
+ # Prefetch common services
336
+ console.print("Fetching fresh AWS service definitions...")
337
+ services = await fetcher.fetch_services()
338
+ console.print(f"[green]✓[/green] Fetched list of {len(services)} AWS services")
339
+
340
+ # Prefetch common services
341
+ console.print("Pre-fetching common services...")
342
+ prefetched = 0
343
+ for service_name in fetcher.COMMON_SERVICES:
344
+ try:
345
+ await fetcher.fetch_service_by_name(service_name)
346
+ prefetched += 1
347
+ except Exception as e:
348
+ logger.warning(f"Failed to prefetch {service_name}: {e}")
349
+
350
+ console.print(f"[green]✓[/green] Pre-fetched {prefetched} common services")
351
+
352
+ console.print("[green]✓[/green] Cache refreshed successfully")
353
+ return 0
354
+
355
+ async def _prefetch_services(
356
+ self, cache_enabled: bool, cache_ttl_seconds: int, cache_directory: str | None
357
+ ) -> int:
358
+ """Pre-fetch common AWS services without clearing cache."""
359
+ if not cache_enabled:
360
+ console.print("[red]Error:[/red] Cache is disabled in config")
361
+ console.print("Enable cache by setting 'cache_enabled: true' in your config")
362
+ return 1
363
+
364
+ console.print("[cyan]Pre-fetching common AWS services...[/cyan]")
365
+
366
+ async with AWSServiceFetcher(
367
+ enable_cache=cache_enabled,
368
+ cache_ttl=cache_ttl_seconds,
369
+ cache_dir=cache_directory,
370
+ prefetch_common=True, # Enable prefetching
371
+ ) as fetcher:
372
+ # Prefetching happens in __aenter__, just wait for it
373
+ prefetched = len(fetcher._prefetched_services)
374
+ total = len(fetcher.COMMON_SERVICES)
375
+
376
+ console.print(
377
+ f"[green]✓[/green] Pre-fetched {prefetched}/{total} common services successfully"
378
+ )
379
+
380
+ return 0
381
+
382
+ def _show_location(self, cache_dir: Path) -> int:
383
+ """Show cache directory location."""
384
+ console.print(f"[cyan]Cache directory:[/cyan] {cache_dir}")
385
+
386
+ if cache_dir.exists():
387
+ console.print("[green]✓[/green] Directory exists")
388
+ else:
389
+ console.print("[yellow]![/yellow] Directory does not exist yet")
390
+ console.print("It will be created automatically when caching is used")
391
+
392
+ return 0
@@ -0,0 +1,260 @@
1
+ """Download AWS service definitions command."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+ from rich.console import Console
12
+ from rich.progress import (
13
+ BarColumn,
14
+ Progress,
15
+ TaskID,
16
+ TextColumn,
17
+ TimeRemainingColumn,
18
+ )
19
+
20
+ from iam_validator.commands.base import Command
21
+
22
+ logger = logging.getLogger(__name__)
23
+ console = Console()
24
+
25
+ BASE_URL = "https://servicereference.us-east-1.amazonaws.com/"
26
+ DEFAULT_OUTPUT_DIR = Path("aws_services")
27
+
28
+
29
+ class DownloadServicesCommand(Command):
30
+ """Download all AWS service definition JSON files."""
31
+
32
+ @property
33
+ def name(self) -> str:
34
+ return "sync-services"
35
+
36
+ @property
37
+ def help(self) -> str:
38
+ return "Sync/download all AWS service definitions for offline use"
39
+
40
+ @property
41
+ def epilog(self) -> str:
42
+ return """
43
+ Examples:
44
+ # Sync all AWS service definitions to default directory (aws_services/)
45
+ iam-validator sync-services
46
+
47
+ # Sync to a custom directory
48
+ iam-validator sync-services --output-dir /path/to/backup
49
+
50
+ # Limit concurrent downloads
51
+ iam-validator sync-services --max-concurrent 5
52
+
53
+ # Enable verbose output
54
+ iam-validator sync-services --log-level debug
55
+
56
+ Directory structure:
57
+ aws_services/
58
+ _manifest.json # Metadata about the download
59
+ _services.json # List of all services
60
+ s3.json # Individual service definitions
61
+ ec2.json
62
+ iam.json
63
+ ...
64
+
65
+ This command is useful for:
66
+ - Creating offline backups of AWS service definitions
67
+ - Avoiding API rate limiting during development
68
+ - Ensuring consistent service definitions across environments
69
+ """
70
+
71
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
72
+ """Add sync-services command arguments."""
73
+ parser.add_argument(
74
+ "--output-dir",
75
+ type=Path,
76
+ default=DEFAULT_OUTPUT_DIR,
77
+ help=f"Output directory for downloaded files (default: {DEFAULT_OUTPUT_DIR})",
78
+ )
79
+
80
+ parser.add_argument(
81
+ "--max-concurrent",
82
+ type=int,
83
+ default=10,
84
+ help="Maximum number of concurrent downloads (default: 10)",
85
+ )
86
+
87
+ async def execute(self, args: argparse.Namespace) -> int:
88
+ """Execute the sync-services command."""
89
+ output_dir = args.output_dir
90
+ max_concurrent = args.max_concurrent
91
+
92
+ try:
93
+ await self._download_all_services(output_dir, max_concurrent)
94
+ return 0
95
+ except Exception as e:
96
+ console.print(f"[red]Error:[/red] {e}")
97
+ logger.error(f"Download failed: {e}", exc_info=True)
98
+ return 1
99
+
100
+ async def _download_services_list(self, client: httpx.AsyncClient) -> list[dict]:
101
+ """Download the list of all AWS services.
102
+
103
+ Args:
104
+ client: HTTP client for making requests
105
+
106
+ Returns:
107
+ List of service info dictionaries
108
+ """
109
+ console.print(f"[cyan]Fetching services list from {BASE_URL}...[/cyan]")
110
+
111
+ try:
112
+ response = await client.get(BASE_URL, timeout=30.0)
113
+ response.raise_for_status()
114
+ services = response.json()
115
+
116
+ console.print(f"[green]✓[/green] Found {len(services)} AWS services")
117
+ return services
118
+ except Exception as e:
119
+ logger.error(f"Failed to fetch services list: {e}")
120
+ raise
121
+
122
+ async def _download_service_detail(
123
+ self,
124
+ client: httpx.AsyncClient,
125
+ service_name: str,
126
+ service_url: str,
127
+ semaphore: asyncio.Semaphore,
128
+ progress: Progress,
129
+ task_id: TaskID,
130
+ ) -> tuple[str, dict | None]:
131
+ """Download detailed JSON for a single service.
132
+
133
+ Args:
134
+ client: HTTP client for making requests
135
+ service_name: Name of the service
136
+ service_url: URL to fetch service details
137
+ semaphore: Semaphore to limit concurrent requests
138
+ progress: Progress bar instance
139
+ task_id: Progress task ID
140
+
141
+ Returns:
142
+ Tuple of (service_name, service_data) or (service_name, None) if failed
143
+ """
144
+ async with semaphore:
145
+ try:
146
+ logger.debug(f"Downloading {service_name}...")
147
+ response = await client.get(service_url, timeout=30.0)
148
+ response.raise_for_status()
149
+ data = response.json()
150
+ logger.debug(f"✓ Downloaded {service_name}")
151
+ progress.update(task_id, advance=1)
152
+ return service_name, data
153
+ except Exception as e:
154
+ logger.error(f"✗ Failed to download {service_name}: {e}")
155
+ progress.update(task_id, advance=1)
156
+ return service_name, None
157
+
158
+ async def _download_all_services(self, output_dir: Path, max_concurrent: int = 10) -> None:
159
+ """Download all AWS service definitions.
160
+
161
+ Args:
162
+ output_dir: Directory to save the downloaded files
163
+ max_concurrent: Maximum number of concurrent downloads
164
+ """
165
+ # Create output directory
166
+ output_dir.mkdir(parents=True, exist_ok=True)
167
+ console.print(f"[cyan]Output directory:[/cyan] {output_dir.absolute()}\n")
168
+
169
+ # Create HTTP client with connection pooling
170
+ async with httpx.AsyncClient(
171
+ limits=httpx.Limits(max_connections=max_concurrent, max_keepalive_connections=5),
172
+ timeout=httpx.Timeout(30.0),
173
+ ) as client:
174
+ # Download services list
175
+ services = await self._download_services_list(client)
176
+
177
+ # Save services list (underscore prefix for easy discovery at top of directory)
178
+ services_file = output_dir / "_services.json"
179
+ with open(services_file, "w") as f:
180
+ json.dump(services, f, indent=2)
181
+ console.print(f"[green]✓[/green] Saved services list to {services_file}\n")
182
+
183
+ # Download all service details with rate limiting and progress bar
184
+ semaphore = asyncio.Semaphore(max_concurrent)
185
+ tasks = []
186
+
187
+ # Set up progress bar
188
+ with Progress(
189
+ TextColumn("[progress.description]{task.description}"),
190
+ BarColumn(),
191
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
192
+ TextColumn("({task.completed}/{task.total})"),
193
+ TimeRemainingColumn(),
194
+ console=console,
195
+ ) as progress:
196
+ task_id = progress.add_task(
197
+ "[cyan]Downloading service definitions...", total=len(services)
198
+ )
199
+
200
+ for item in services:
201
+ service_name = item.get("service")
202
+ service_url = item.get("url")
203
+
204
+ if service_name and service_url:
205
+ task = self._download_service_detail(
206
+ client, service_name, service_url, semaphore, progress, task_id
207
+ )
208
+ tasks.append(task)
209
+
210
+ # Download all services concurrently
211
+ results = await asyncio.gather(*tasks)
212
+
213
+ # Save individual service files
214
+ successful = 0
215
+ failed = 0
216
+
217
+ console.print("\n[cyan]Saving service definitions...[/cyan]")
218
+
219
+ for service_name, data in results:
220
+ if data is not None:
221
+ # Normalize filename (lowercase, safe characters)
222
+ filename = f"{service_name.lower().replace(' ', '_')}.json"
223
+ service_file = output_dir / filename
224
+
225
+ with open(service_file, "w") as f:
226
+ json.dump(data, f, indent=2)
227
+
228
+ successful += 1
229
+ else:
230
+ failed += 1
231
+
232
+ # Create manifest with metadata
233
+ manifest = {
234
+ "download_date": datetime.now(timezone.utc).isoformat(),
235
+ "total_services": len(services),
236
+ "successful_downloads": successful,
237
+ "failed_downloads": failed,
238
+ "base_url": BASE_URL,
239
+ }
240
+
241
+ manifest_file = output_dir / "_manifest.json"
242
+ with open(manifest_file, "w") as f:
243
+ json.dump(manifest, f, indent=2)
244
+
245
+ # Print summary
246
+ console.print(f"\n{'=' * 60}")
247
+ console.print("[bold cyan]Download Summary:[/bold cyan]")
248
+ console.print(f" Total services: {len(services)}")
249
+ console.print(f" [green]Successful:[/green] {successful}")
250
+ if failed > 0:
251
+ console.print(f" [red]Failed:[/red] {failed}")
252
+ console.print(f" Output directory: {output_dir.absolute()}")
253
+ console.print(f" Manifest: {manifest_file}")
254
+ console.print(f"{'=' * 60}")
255
+
256
+ if failed > 0:
257
+ console.print(
258
+ "\n[yellow]Warning:[/yellow] Some services failed to download. "
259
+ "Check the logs for details."
260
+ )