reverse-diagrams 1.3.4__py3-none-any.whl → 2.0.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.
@@ -1,26 +1,221 @@
1
- """Save results."""
1
+ """Save results with enhanced error handling and validation."""
2
2
  import json
3
3
  import logging
4
4
  from pathlib import Path
5
+ from typing import Any, Union, Optional
6
+ import os
5
7
 
6
- from colorama import Fore
8
+ from ..config import get_config
9
+ from ..utils.progress import get_progress_tracker
7
10
 
11
+ logger = logging.getLogger(__name__)
8
12
 
9
- def save_results(results, filename, directory_path="."):
13
+
14
+ def save_results(
15
+ results: Any,
16
+ filename: str,
17
+ directory_path: Union[str, Path] = ".",
18
+ indent: Optional[int] = None,
19
+ validate_json: bool = True
20
+ ) -> bool:
10
21
  """
11
- Save results to a file.
22
+ Save results to a JSON file with proper error handling.
12
23
 
13
- :param directory_path:
14
- :param results:
15
- :param filename:
24
+ Args:
25
+ results: Data to save
26
+ filename: Output filename
27
+ directory_path: Directory to save the file
28
+ indent: JSON indentation (uses config default if None)
29
+ validate_json: Whether to validate JSON before saving
16
30
 
17
- :return: None. Saves results to a file.
31
+ Returns:
32
+ True if successful, False otherwise
18
33
  """
19
- if not Path.exists(Path(directory_path)):
20
- Path.mkdir(Path(directory_path))
21
- logging.debug(f"Directory {directory_path} created")
22
- with open(f"{directory_path}/{filename}", "w") as f:
23
- json.dump(results, fp=f, indent=4)
24
- print(
25
- f"{Fore.YELLOW}ℹ️ The accounts are stored in {directory_path}/{filename} {Fore.RESET}"
34
+ config = get_config()
35
+ progress = get_progress_tracker()
36
+
37
+ if indent is None:
38
+ indent = config.output.json_indent
39
+
40
+ try:
41
+ # Ensure directory exists
42
+ directory = Path(directory_path)
43
+ if config.output.create_directories:
44
+ directory.mkdir(parents=True, exist_ok=True)
45
+
46
+ # Validate directory is writable
47
+ if not directory.exists():
48
+ raise FileNotFoundError(f"Directory {directory} does not exist")
49
+
50
+ if not os.access(directory, os.W_OK):
51
+ raise PermissionError(f"No write permission for directory {directory}")
52
+
53
+ # Prepare file path
54
+ file_path = directory / filename
55
+
56
+ # Validate JSON serialization if requested
57
+ if validate_json:
58
+ try:
59
+ json.dumps(results, indent=indent, default=str)
60
+ except (TypeError, ValueError) as e:
61
+ raise ValueError(f"Data is not JSON serializable: {e}")
62
+
63
+ # Write file
64
+ with file_path.open('w', encoding='utf-8') as f:
65
+ json.dump(results, f, indent=indent, default=str, ensure_ascii=False)
66
+
67
+ # Set file permissions
68
+ try:
69
+ os.chmod(file_path, config.output.file_permissions)
70
+ except OSError as e:
71
+ logger.warning(f"Could not set file permissions for {file_path}: {e}")
72
+
73
+ # Log success
74
+ file_size = file_path.stat().st_size
75
+ logger.debug(f"Saved {filename} ({file_size} bytes) to {directory}")
76
+
77
+ progress.show_success(
78
+ f"💾 Data saved successfully",
79
+ f"File: {file_path}\nSize: {file_size:,} bytes"
80
+ )
81
+
82
+ return True
83
+
84
+ except Exception as e:
85
+ logger.error(f"Failed to save {filename}: {e}")
86
+ progress.show_error(
87
+ f"Failed to save {filename}",
88
+ f"Error: {e}\nDirectory: {directory_path}"
26
89
  )
90
+ return False
91
+
92
+
93
+ def load_results(
94
+ filename: str,
95
+ directory_path: Union[str, Path] = ".",
96
+ validate_exists: bool = True
97
+ ) -> Optional[Any]:
98
+ """
99
+ Load results from a JSON file with error handling.
100
+
101
+ Args:
102
+ filename: Input filename
103
+ directory_path: Directory containing the file
104
+ validate_exists: Whether to validate file exists
105
+
106
+ Returns:
107
+ Loaded data or None if failed
108
+ """
109
+ progress = get_progress_tracker()
110
+
111
+ try:
112
+ file_path = Path(directory_path) / filename
113
+
114
+ if validate_exists and not file_path.exists():
115
+ raise FileNotFoundError(f"File {file_path} does not exist")
116
+
117
+ if not os.access(file_path, os.R_OK):
118
+ raise PermissionError(f"No read permission for file {file_path}")
119
+
120
+ with file_path.open('r', encoding='utf-8') as f:
121
+ data = json.load(f)
122
+
123
+ file_size = file_path.stat().st_size
124
+ logger.info(f"Loaded {filename} ({file_size} bytes) from {directory_path}")
125
+
126
+ return data
127
+
128
+ except Exception as e:
129
+ logger.error(f"Failed to load {filename}: {e}")
130
+ progress.show_error(
131
+ f"Failed to load {filename}",
132
+ f"Error: {e}\nPath: {Path(directory_path) / filename}"
133
+ )
134
+ return None
135
+
136
+
137
+ def backup_file(file_path: Union[str, Path], max_backups: int = 5) -> bool:
138
+ """
139
+ Create a backup of an existing file.
140
+
141
+ Args:
142
+ file_path: Path to file to backup
143
+ max_backups: Maximum number of backups to keep
144
+
145
+ Returns:
146
+ True if backup created successfully, False otherwise
147
+ """
148
+ try:
149
+ file_path = Path(file_path)
150
+
151
+ if not file_path.exists():
152
+ return True # No file to backup
153
+
154
+ # Create backup filename
155
+ backup_path = file_path.with_suffix(f"{file_path.suffix}.backup")
156
+
157
+ # If backup already exists, rotate backups
158
+ if backup_path.exists():
159
+ for i in range(max_backups - 1, 0, -1):
160
+ old_backup = file_path.with_suffix(f"{file_path.suffix}.backup.{i}")
161
+ new_backup = file_path.with_suffix(f"{file_path.suffix}.backup.{i + 1}")
162
+
163
+ if old_backup.exists():
164
+ if new_backup.exists():
165
+ new_backup.unlink()
166
+ old_backup.rename(new_backup)
167
+
168
+ # Move current backup to .backup.1
169
+ backup_1 = file_path.with_suffix(f"{file_path.suffix}.backup.1")
170
+ if backup_1.exists():
171
+ backup_1.unlink()
172
+ backup_path.rename(backup_1)
173
+
174
+ # Create new backup
175
+ import shutil
176
+ shutil.copy2(file_path, backup_path)
177
+
178
+ logger.debug(f"Created backup: {backup_path}")
179
+ return True
180
+
181
+ except Exception as e:
182
+ logger.warning(f"Failed to create backup for {file_path}: {e}")
183
+ return False
184
+
185
+
186
+ def ensure_directory_structure(base_path: Union[str, Path]) -> bool:
187
+ """
188
+ Ensure the standard directory structure exists.
189
+
190
+ Args:
191
+ base_path: Base directory path
192
+
193
+ Returns:
194
+ True if structure created successfully, False otherwise
195
+ """
196
+ try:
197
+ base_path = Path(base_path)
198
+
199
+ # Standard directories
200
+ directories = [
201
+ base_path,
202
+ base_path / "json",
203
+ base_path / "code",
204
+ base_path / "images"
205
+ ]
206
+
207
+ for directory in directories:
208
+ directory.mkdir(parents=True, exist_ok=True)
209
+
210
+ # Set permissions
211
+ try:
212
+ os.chmod(directory, 0o755)
213
+ except OSError:
214
+ pass
215
+
216
+ logger.info(f"Directory structure ensured at {base_path}")
217
+ return True
218
+
219
+ except Exception as e:
220
+ logger.error(f"Failed to create directory structure at {base_path}: {e}")
221
+ return False
src/reverse_diagrams.py CHANGED
@@ -1,23 +1,95 @@
1
1
  """Create graphs."""
2
2
  import argparse
3
3
  import logging
4
+ import sys
5
+ from pathlib import Path
4
6
 
5
7
  import argcomplete
6
- from boto3 import setup_default_session
8
+ from colorama import Fore
7
9
 
8
10
  from .aws.describe_identity_store import graph_identity_center
9
11
  from .aws.describe_organization import graph_organizations
12
+ from .aws.exceptions import AWSError, AWSCredentialsError, AWSPermissionError
10
13
  from .banner.banner import get_version
14
+ from .config import get_config, Config
11
15
  from .reports.console_view import watch_on_demand
12
16
  from .version import __version__
17
+ from .utils.progress import get_progress_tracker
18
+
19
+
20
+ def validate_arguments(args) -> None:
21
+ """
22
+ Validate command line arguments.
23
+
24
+ Args:
25
+ args: Parsed command line arguments
26
+
27
+ Raises:
28
+ ValueError: If arguments are invalid
29
+ """
30
+ # Validate region format
31
+ if args.region and not args.region.replace('-', '').replace('_', '').isalnum():
32
+ raise ValueError(f"Invalid AWS region format: {args.region}")
33
+
34
+ # Validate output directory path
35
+ if args.output_dir_path:
36
+ try:
37
+ Path(args.output_dir_path).resolve()
38
+ except Exception as e:
39
+ raise ValueError(f"Invalid output directory path: {e}")
40
+
41
+ # Ensure at least one operation is selected
42
+ if not any([args.graph_organization, args.graph_identity, args.commands == "watch", args.list_plugins, args.plugins]):
43
+ if not args.version:
44
+ raise ValueError("Please specify at least one operation: -o, -i, or watch command")
45
+
46
+
47
+ def setup_logging_from_args(args, config: Config) -> None:
48
+ """Setup logging based on command line arguments and configuration."""
49
+ if args.debug:
50
+ config.logging.level = "DEBUG"
51
+ config.setup_logging()
52
+ logging.info("Debug mode enabled")
53
+ else:
54
+ config.setup_logging()
55
+
56
+
57
+ def handle_aws_errors(func):
58
+ """Decorator to handle AWS-specific errors gracefully."""
59
+ def wrapper(*args, **kwargs):
60
+ try:
61
+ return func(*args, **kwargs)
62
+ except AWSCredentialsError as e:
63
+ logging.error(f"{Fore.RED}❌ AWS Credentials Error: {e}{Fore.RESET}")
64
+ logging.error(f"{Fore.YELLOW}💡 Please check your AWS credentials and try again.{Fore.RESET}")
65
+ sys.exit(1)
66
+ except AWSPermissionError as e:
67
+ logging.error(f"{Fore.RED}❌ AWS Permission Error: {e}{Fore.RESET}")
68
+ logging.error(f"{Fore.YELLOW}💡 Please check your AWS permissions and try again.{Fore.RESET}")
69
+ sys.exit(1)
70
+ except AWSError as e:
71
+ logging.error(f"{Fore.RED}❌ AWS Error: {e}{Fore.RESET}")
72
+ sys.exit(1)
73
+ except Exception as e:
74
+ logging.error(f"{Fore.RED}❌ Unexpected error: {e}{Fore.RESET}")
75
+ if logging.getLogger().isEnabledFor(logging.DEBUG):
76
+ logging.exception("Full traceback:")
77
+ sys.exit(1)
78
+ return wrapper
79
+
80
+
81
+ @handle_aws_errors
13
82
 
14
83
 
15
84
  def main() -> int:
16
85
  """
17
- Crete architecture diagram from your current state.
86
+ Create architecture diagram from your current state.
18
87
 
19
- :return:
88
+ :return: Exit code
20
89
  """
90
+ # Load configuration
91
+ config = get_config()
92
+
21
93
  # Initialize parser
22
94
  parser = argparse.ArgumentParser(
23
95
  prog="reverse_diagrams",
@@ -32,7 +104,7 @@ def main() -> int:
32
104
  "-od",
33
105
  "--output_dir_path",
34
106
  help="Name of folder to save the diagrams python code files",
35
- default="diagrams",
107
+ default=config.output.default_output_dir,
36
108
  )
37
109
  parser.add_argument("-r", "--region", help="AWS region", default="us-east-1")
38
110
  parser.add_argument(
@@ -54,6 +126,24 @@ def main() -> int:
54
126
  action="store_true",
55
127
  default=True,
56
128
  )
129
+ parser.add_argument(
130
+ "--plugin",
131
+ help="Use specific plugin for diagram generation (e.g., ec2, rds)",
132
+ action="append",
133
+ dest="plugins"
134
+ )
135
+ parser.add_argument(
136
+ "--list-plugins",
137
+ help="List available plugins",
138
+ action="store_true"
139
+ )
140
+ parser.add_argument(
141
+ "--concurrent",
142
+ help="Enable concurrent processing for better performance",
143
+ action="store_true",
144
+ default=True
145
+ )
146
+
57
147
  # Create subparsers
58
148
  subparsers = parser.add_subparsers(
59
149
  dest="commands",
@@ -61,14 +151,16 @@ def main() -> int:
61
151
  help="%(prog)s Commands",
62
152
  description="Command and functionalities",
63
153
  )
64
- # Create init subcommand options
154
+
155
+ # Create watch subcommand options
65
156
  watch_parser = subparsers.add_parser(
66
157
  name="watch",
67
158
  description="Create view of diagrams in console based on kind of diagram and json file.",
68
159
  help="Create pretty console view: \n"
69
160
  "For example: %(prog)s watch -wi diagrams/json/account_assignments.json ",
70
161
  )
71
- # Add idp options
162
+
163
+ # Add watch options
72
164
  watch_group = watch_parser.add_argument_group(
73
165
  "Create view of diagrams in console based on kind of diagram and json file."
74
166
  )
@@ -94,41 +186,242 @@ def main() -> int:
94
186
  parser.add_argument("-v", "--version", help="Show version", action="store_true")
95
187
  parser.add_argument("-d", "--debug", help="Debug Mode", action="store_true")
96
188
 
97
- # Read arguments from command line
98
- args = parser.parse_args()
99
189
  # Add autocomplete
100
190
  argcomplete.autocomplete(parser)
101
- logging.info(f"The arguments are {args}")
102
- if args.debug:
103
- logging.basicConfig(level=logging.DEBUG)
104
-
105
- diagrams_path = args.output_dir_path
106
-
107
- region = args.region
108
-
109
- if args.profile:
110
- profile = args.profile
111
- if profile is not None:
112
- setup_default_session(profile_name=profile)
113
-
114
- logging.info(f"Profile is: {profile}")
115
-
116
- if args.graph_organization:
117
- graph_organizations(
118
- diagrams_path=diagrams_path, region=region, auto=args.auto_create
119
- )
120
-
121
- if args.graph_identity:
122
- graph_identity_center(
123
- diagrams_path=diagrams_path, region=region, auto=args.auto_create
124
- )
125
- if args.commands == "watch":
126
- watch_on_demand(args=args)
127
-
128
- if args.version:
129
- get_version(version=__version__)
191
+
192
+ # Read arguments from command line
193
+ args = parser.parse_args()
194
+
195
+ try:
196
+ # Validate arguments
197
+ validate_arguments(args)
198
+
199
+ # Setup logging
200
+ setup_logging_from_args(args, config)
201
+
202
+ logging.debug(f"Starting reverse_diagrams with arguments: {vars(args)}")
203
+
204
+ # Handle list plugins request
205
+ if args.list_plugins:
206
+ from .plugins.registry import get_plugin_manager
207
+ manager = get_plugin_manager()
208
+ plugins = manager.list_available_plugins()
209
+
210
+ progress = get_progress_tracker()
211
+ if plugins:
212
+ progress.show_summary(
213
+ "Available Plugins",
214
+ [f"{p.name} v{p.version} - {p.description}" for p in plugins]
215
+ )
216
+ else:
217
+ progress.show_warning("No plugins available", "Install plugins or check plugin directories")
218
+ return 0
219
+
220
+ # Handle plugin-based diagram generation
221
+ if args.plugins:
222
+ from .plugins.registry import get_plugin_manager
223
+ from .models import DiagramConfig
224
+ from .aws.client_manager import get_client_manager
225
+
226
+ manager = get_plugin_manager()
227
+ client_manager = get_client_manager(region=args.region, profile=args.profile)
228
+ progress = get_progress_tracker()
229
+
230
+ # Create output directories
231
+ output_path = Path(args.output_dir_path)
232
+ output_path.mkdir(parents=True, exist_ok=True)
233
+ (output_path / "json").mkdir(exist_ok=True)
234
+ (output_path / "code").mkdir(exist_ok=True)
235
+
236
+ for plugin_name in args.plugins:
237
+ try:
238
+ plugin = manager.load_plugin(plugin_name, client_manager)
239
+
240
+ # Collect data
241
+ progress.show_success(f"🔌 Running {plugin_name} plugin")
242
+ data = plugin.collect_data(client_manager, args.region)
243
+
244
+ # Generate diagram
245
+ diagram_config = DiagramConfig(
246
+ title=f"{plugin_name.upper()} Infrastructure",
247
+ direction="TB",
248
+ output_format="png"
249
+ )
250
+
251
+ diagram_code = plugin.generate_diagram_code(data, diagram_config)
252
+
253
+ # Save diagram code
254
+ plugin_file = output_path / "code" / f"graph_{plugin_name}.py"
255
+ plugin_file.write_text(diagram_code)
256
+
257
+ # Save data
258
+ data_file = output_path / "json" / f"{plugin_name}_data.json"
259
+ import json
260
+ data_file.write_text(json.dumps(data, indent=2, default=str))
261
+
262
+ if args.auto_create:
263
+ import os
264
+ command = os.system(f"cd {output_path / 'code'} && python3 graph_{plugin_name}.py")
265
+ if command == 0:
266
+ progress.show_success(f"✅ {plugin_name} diagram created successfully")
267
+ else:
268
+ progress.show_error(f"Failed to create {plugin_name} diagram", f"Exit code: {command}")
269
+
270
+ except Exception as e:
271
+ progress.show_error(f"Plugin {plugin_name} failed", str(e))
272
+ if not args.debug:
273
+ continue
274
+ raise
275
+
276
+ return 0
277
+
278
+ # Handle version request
279
+ if args.version:
280
+ get_version(version=__version__)
281
+ return 0
282
+
283
+ # Handle watch command
284
+ if args.commands == "watch":
285
+ watch_on_demand(args=args)
286
+ return 0
287
+
288
+ # Setup AWS client manager
289
+ from .aws.client_manager import get_client_manager
290
+ client_manager = get_client_manager(region=args.region, profile=args.profile)
291
+
292
+ diagrams_path = args.output_dir_path
293
+ region = args.region
294
+
295
+ # Create output directories
296
+ output_path = Path(diagrams_path)
297
+ output_path.mkdir(parents=True, exist_ok=True)
298
+ (output_path / "json").mkdir(exist_ok=True)
299
+ (output_path / "code").mkdir(exist_ok=True)
300
+
301
+ logging.info(f"Output directory: {output_path.absolute()}")
302
+
303
+ # Execute requested operations using plugins
304
+ if args.graph_organization:
305
+ logging.info("Starting AWS Organizations diagram generation using plugin")
306
+ progress = get_progress_tracker()
307
+ try:
308
+ from .plugins.registry import get_plugin_manager
309
+ from .models import DiagramConfig
310
+
311
+ manager = get_plugin_manager()
312
+ plugin = manager.load_plugin("organizations", client_manager)
313
+
314
+ # Collect data
315
+ progress.show_success("🏢 Running Organizations plugin")
316
+ data = plugin.collect_data(client_manager, args.region)
317
+
318
+ # Generate diagram
319
+ diagram_config = DiagramConfig(
320
+ title="Organizations Structure",
321
+ direction="TB",
322
+ output_format="png"
323
+ )
324
+
325
+ diagram_code = plugin.generate_diagram_code(data, diagram_config)
326
+
327
+ # Save diagram code
328
+ org_file = output_path / "code" / "graph_org.py"
329
+ org_file.write_text(diagram_code)
330
+
331
+ # Save data
332
+ from .reports.save_results import save_results
333
+ save_results(data["accounts"], "organizations.json", str(output_path / "json"))
334
+ save_results(data["organizations_complete"], "organizations_complete.json", str(output_path / "json"))
335
+
336
+ if args.auto_create:
337
+ import os
338
+ command = os.system(f"cd {output_path / 'code'} && python3 graph_org.py")
339
+ if command == 0:
340
+ progress.show_success("✅ Organizations diagram created successfully")
341
+ else:
342
+ progress.show_error("Failed to create Organizations diagram", f"Exit code: {command}")
343
+
344
+ logging.info("AWS Organizations diagram generation completed")
345
+
346
+ except Exception as e:
347
+ logging.error(f"Organizations plugin failed: {e}")
348
+ # Fallback to original implementation with same client manager
349
+ logging.info("Falling back to original Organizations implementation")
350
+ # Pass the region and profile to ensure credentials are used
351
+ import os
352
+ os.environ['AWS_PROFILE'] = args.profile if args.profile else ''
353
+ graph_organizations(
354
+ diagrams_path=diagrams_path, region=region, auto=args.auto_create
355
+ )
130
356
 
131
- return 0
357
+ if args.graph_identity:
358
+ logging.info("Starting IAM Identity Center diagram generation using plugin")
359
+ progress = get_progress_tracker()
360
+ try:
361
+ from .plugins.registry import get_plugin_manager
362
+ from .models import DiagramConfig
363
+
364
+ manager = get_plugin_manager()
365
+ plugin = manager.load_plugin("identity-center", client_manager)
366
+
367
+ # Collect data
368
+ progress.show_success("🔐 Running Identity Center plugin")
369
+ data = plugin.collect_data(client_manager, args.region)
370
+
371
+ # Generate diagram
372
+ diagram_config = DiagramConfig(
373
+ title="IAM Identity Center",
374
+ direction="LR",
375
+ output_format="png"
376
+ )
377
+
378
+ diagram_code = plugin.generate_diagram_code(data, diagram_config)
379
+
380
+ # Save diagram code
381
+ sso_file = output_path / "code" / "graph_sso_complete.py"
382
+ sso_file.write_text(diagram_code)
383
+
384
+ # Save data
385
+ from .reports.save_results import save_results
386
+ save_results(data["final_account_assignments"], "account_assignments.json", str(output_path / "json"))
387
+ save_results(data["group_memberships"], "groups.json", str(output_path / "json"))
388
+
389
+ if args.auto_create:
390
+ import os
391
+ command = os.system(f"cd {output_path / 'code'} && python3 graph_sso_complete.py")
392
+ if command == 0:
393
+ progress.show_success("✅ Identity Center diagram created successfully")
394
+ else:
395
+ progress.show_error("Failed to create Identity Center diagram", f"Exit code: {command}")
396
+
397
+ logging.info("IAM Identity Center diagram generation completed")
398
+
399
+ except Exception as e:
400
+ logging.error(f"Identity Center plugin failed: {e}")
401
+ # Fallback to original implementation with same client manager
402
+ logging.info("Falling back to original Identity Center implementation")
403
+ # Pass the region and profile to ensure credentials are used
404
+ import os
405
+ os.environ['AWS_PROFILE'] = args.profile if args.profile else ''
406
+ graph_identity_center(
407
+ diagrams_path=diagrams_path, region=region, auto=args.auto_create
408
+ )
409
+
410
+ logging.info("All operations completed successfully")
411
+ return 0
412
+
413
+ except ValueError as e:
414
+ logging.error(f"{Fore.RED}❌ Invalid arguments: {e}{Fore.RESET}")
415
+ parser.print_help()
416
+ return 1
417
+ except KeyboardInterrupt:
418
+ logging.info(f"{Fore.YELLOW}⚠️ Operation cancelled by user{Fore.RESET}")
419
+ return 1
420
+ except Exception as e:
421
+ logging.error(f"{Fore.RED}❌ Unexpected error: {e}{Fore.RESET}")
422
+ if logging.getLogger().isEnabledFor(logging.DEBUG):
423
+ logging.exception("Full traceback:")
424
+ return 1
132
425
 
133
426
 
134
427
  if __name__ == "__main__":
src/utils/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Utility modules for Reverse Diagrams."""