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.
src/models.py ADDED
@@ -0,0 +1,242 @@
1
+ """Data models with validation for AWS resources."""
2
+ from dataclasses import dataclass
3
+ from typing import List, Optional, Dict, Any
4
+ from enum import Enum
5
+ import re
6
+
7
+
8
+ class PrincipalType(Enum):
9
+ """Principal types for account assignments."""
10
+ USER = "USER"
11
+ GROUP = "GROUP"
12
+
13
+
14
+ class AccountStatus(Enum):
15
+ """AWS account status."""
16
+ ACTIVE = "ACTIVE"
17
+ SUSPENDED = "SUSPENDED"
18
+ PENDING_CLOSURE = "PENDING_CLOSURE"
19
+
20
+
21
+ @dataclass
22
+ class AWSAccount:
23
+ """AWS Account model with validation."""
24
+ id: str
25
+ name: str
26
+ email: str
27
+ status: AccountStatus = AccountStatus.ACTIVE
28
+
29
+ def __post_init__(self):
30
+ """Validate account data after initialization."""
31
+ if not self.id or not self.id.isdigit() or len(self.id) != 12:
32
+ raise ValueError(f"Invalid AWS account ID: {self.id}. Must be 12 digits.")
33
+
34
+ if not self.name or len(self.name.strip()) == 0:
35
+ raise ValueError("Account name cannot be empty.")
36
+
37
+ if not self._is_valid_email(self.email):
38
+ raise ValueError(f"Invalid email format: {self.email}")
39
+
40
+ @staticmethod
41
+ def _is_valid_email(email: str) -> bool:
42
+ """Validate email format."""
43
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
44
+ return bool(re.match(pattern, email))
45
+
46
+
47
+ @dataclass
48
+ class OrganizationalUnit:
49
+ """Organizational Unit model."""
50
+ id: str
51
+ name: str
52
+ parent_id: Optional[str] = None
53
+ accounts: List[AWSAccount] = None
54
+ child_ous: List['OrganizationalUnit'] = None
55
+
56
+ def __post_init__(self):
57
+ """Initialize collections if None."""
58
+ if self.accounts is None:
59
+ self.accounts = []
60
+ if self.child_ous is None:
61
+ self.child_ous = []
62
+
63
+ if not self.id or not self.id.startswith('ou-'):
64
+ raise ValueError(f"Invalid OU ID format: {self.id}")
65
+
66
+ if not self.name or len(self.name.strip()) == 0:
67
+ raise ValueError("OU name cannot be empty.")
68
+
69
+
70
+ @dataclass
71
+ class AWSOrganization:
72
+ """AWS Organization model."""
73
+ id: str
74
+ master_account_id: str
75
+ feature_set: str
76
+ root_id: str
77
+ organizational_units: List[OrganizationalUnit] = None
78
+ accounts: List[AWSAccount] = None
79
+
80
+ def __post_init__(self):
81
+ """Initialize collections if None."""
82
+ if self.organizational_units is None:
83
+ self.organizational_units = []
84
+ if self.accounts is None:
85
+ self.accounts = []
86
+
87
+ if not self.master_account_id.isdigit() or len(self.master_account_id) != 12:
88
+ raise ValueError(f"Invalid master account ID: {self.master_account_id}")
89
+
90
+
91
+ @dataclass
92
+ class IdentityStoreUser:
93
+ """Identity Store User model."""
94
+ user_id: str
95
+ username: str
96
+ display_name: Optional[str] = None
97
+ email: Optional[str] = None
98
+
99
+ def __post_init__(self):
100
+ """Validate user data."""
101
+ if not self.user_id:
102
+ raise ValueError("User ID cannot be empty.")
103
+
104
+ if not self.username:
105
+ raise ValueError("Username cannot be empty.")
106
+
107
+ if self.email and not AWSAccount._is_valid_email(self.email):
108
+ raise ValueError(f"Invalid email format: {self.email}")
109
+
110
+
111
+ @dataclass
112
+ class IdentityStoreGroup:
113
+ """Identity Store Group model."""
114
+ group_id: str
115
+ display_name: str
116
+ description: Optional[str] = None
117
+ members: List[IdentityStoreUser] = None
118
+
119
+ def __post_init__(self):
120
+ """Initialize members if None."""
121
+ if self.members is None:
122
+ self.members = []
123
+
124
+ if not self.group_id:
125
+ raise ValueError("Group ID cannot be empty.")
126
+
127
+ if not self.display_name:
128
+ raise ValueError("Group display name cannot be empty.")
129
+
130
+
131
+ @dataclass
132
+ class PermissionSet:
133
+ """Permission Set model."""
134
+ arn: str
135
+ name: str
136
+ description: Optional[str] = None
137
+ session_duration: Optional[str] = None
138
+
139
+ def __post_init__(self):
140
+ """Validate permission set data."""
141
+ if not self.arn or not self.arn.startswith('arn:aws:sso:::permissionSet/'):
142
+ raise ValueError(f"Invalid permission set ARN: {self.arn}")
143
+
144
+ if not self.name:
145
+ raise ValueError("Permission set name cannot be empty.")
146
+
147
+
148
+ @dataclass
149
+ class AccountAssignment:
150
+ """Account Assignment model."""
151
+ account_id: str
152
+ permission_set_arn: str
153
+ principal_type: PrincipalType
154
+ principal_id: str
155
+ principal_name: Optional[str] = None
156
+ permission_set_name: Optional[str] = None
157
+
158
+ def __post_init__(self):
159
+ """Validate assignment data."""
160
+ if not self.account_id.isdigit() or len(self.account_id) != 12:
161
+ raise ValueError(f"Invalid account ID: {self.account_id}")
162
+
163
+ if not self.permission_set_arn.startswith('arn:aws:sso:::permissionSet/'):
164
+ raise ValueError(f"Invalid permission set ARN: {self.permission_set_arn}")
165
+
166
+ if not self.principal_id:
167
+ raise ValueError("Principal ID cannot be empty.")
168
+
169
+
170
+ @dataclass
171
+ class DiagramConfig:
172
+ """Configuration for diagram generation."""
173
+ title: str
174
+ direction: str = "TB" # Top to Bottom
175
+ show_labels: bool = True
176
+ output_format: str = "png"
177
+ output_path: Optional[str] = None
178
+
179
+ def __post_init__(self):
180
+ """Validate diagram configuration."""
181
+ valid_directions = ["TB", "BT", "LR", "RL"]
182
+ if self.direction not in valid_directions:
183
+ raise ValueError(f"Invalid direction: {self.direction}. Must be one of {valid_directions}")
184
+
185
+ valid_formats = ["png", "svg", "pdf"]
186
+ if self.output_format not in valid_formats:
187
+ raise ValueError(f"Invalid output format: {self.output_format}. Must be one of {valid_formats}")
188
+
189
+
190
+ def validate_aws_response(response: Dict[str, Any], required_keys: List[str]) -> None:
191
+ """
192
+ Validate AWS API response contains required keys.
193
+
194
+ Args:
195
+ response: AWS API response dictionary
196
+ required_keys: List of required keys
197
+
198
+ Raises:
199
+ ValueError: If required keys are missing
200
+ """
201
+ missing_keys = [key for key in required_keys if key not in response]
202
+ if missing_keys:
203
+ raise ValueError(f"AWS response missing required keys: {missing_keys}")
204
+
205
+
206
+ def sanitize_name_for_diagram(name: str) -> str:
207
+ """
208
+ Sanitize name for use in diagram generation.
209
+
210
+ Args:
211
+ name: Original name
212
+
213
+ Returns:
214
+ Sanitized name safe for diagram use
215
+ """
216
+ if not name:
217
+ return "Unknown"
218
+
219
+ # Remove special characters and limit length
220
+ sanitized = re.sub(r'[^\w\s-]', '', name)
221
+ sanitized = re.sub(r'\s+', ' ', sanitized).strip()
222
+
223
+ # Limit length and add line breaks for long names
224
+ if len(sanitized) > 20:
225
+ words = sanitized.split()
226
+ lines = []
227
+ current_line = ""
228
+
229
+ for word in words:
230
+ if len(current_line + " " + word) <= 20:
231
+ current_line += " " + word if current_line else word
232
+ else:
233
+ if current_line:
234
+ lines.append(current_line)
235
+ current_line = word
236
+
237
+ if current_line:
238
+ lines.append(current_line)
239
+
240
+ sanitized = "\\n".join(lines[:3]) # Max 3 lines
241
+
242
+ return sanitized
@@ -0,0 +1,12 @@
1
+ """Plugin system for Reverse Diagrams."""
2
+
3
+ from .base import AWSServicePlugin, PluginManager
4
+ from .registry import get_plugin_manager, register_plugin, discover_plugins
5
+
6
+ __all__ = [
7
+ 'AWSServicePlugin',
8
+ 'PluginManager',
9
+ 'get_plugin_manager',
10
+ 'register_plugin',
11
+ 'discover_plugins'
12
+ ]
src/plugins/base.py ADDED
@@ -0,0 +1,292 @@
1
+ """Base plugin system for AWS service integrations."""
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict, Any, List, Optional, Type
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from ..aws.client_manager import AWSClientManager
9
+ from ..models import DiagramConfig
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class PluginMetadata:
16
+ """Metadata for a plugin."""
17
+ name: str
18
+ version: str
19
+ description: str
20
+ author: str
21
+ aws_services: List[str] # AWS services this plugin supports
22
+ dependencies: List[str] = None # Additional dependencies
23
+
24
+ def __post_init__(self):
25
+ if self.dependencies is None:
26
+ self.dependencies = []
27
+
28
+
29
+ class AWSServicePlugin(ABC):
30
+ """Base class for AWS service plugins."""
31
+
32
+ def __init__(self):
33
+ """Initialize the plugin."""
34
+ self._metadata: Optional[PluginMetadata] = None
35
+ self._client_manager: Optional[AWSClientManager] = None
36
+
37
+ @property
38
+ @abstractmethod
39
+ def metadata(self) -> PluginMetadata:
40
+ """Get plugin metadata."""
41
+ pass
42
+
43
+ @abstractmethod
44
+ def collect_data(self, client_manager: AWSClientManager, region: str, **kwargs) -> Dict[str, Any]:
45
+ """
46
+ Collect data from AWS services.
47
+
48
+ Args:
49
+ client_manager: AWS client manager
50
+ region: AWS region
51
+ **kwargs: Additional parameters
52
+
53
+ Returns:
54
+ Collected data dictionary
55
+ """
56
+ pass
57
+
58
+ @abstractmethod
59
+ def generate_diagram_code(self, data: Dict[str, Any], config: DiagramConfig) -> str:
60
+ """
61
+ Generate diagram code from collected data.
62
+
63
+ Args:
64
+ data: Data collected from AWS
65
+ config: Diagram configuration
66
+
67
+ Returns:
68
+ Python code for generating diagram
69
+ """
70
+ pass
71
+
72
+ def validate_data(self, data: Dict[str, Any]) -> bool:
73
+ """
74
+ Validate collected data.
75
+
76
+ Args:
77
+ data: Data to validate
78
+
79
+ Returns:
80
+ True if data is valid, False otherwise
81
+ """
82
+ return bool(data) # Default implementation
83
+
84
+ def get_required_permissions(self) -> List[str]:
85
+ """
86
+ Get list of required AWS permissions.
87
+
88
+ Returns:
89
+ List of IAM permission strings
90
+ """
91
+ return [] # Default implementation
92
+
93
+ def setup(self, client_manager: AWSClientManager) -> None:
94
+ """
95
+ Setup the plugin with client manager.
96
+
97
+ Args:
98
+ client_manager: AWS client manager
99
+ """
100
+ self._client_manager = client_manager
101
+ logger.debug(f"Plugin {self.metadata.name} setup complete")
102
+
103
+ def cleanup(self) -> None:
104
+ """Cleanup plugin resources."""
105
+ self._client_manager = None
106
+ logger.debug(f"Plugin {self.metadata.name} cleanup complete")
107
+
108
+
109
+ class PluginManager:
110
+ """Manager for AWS service plugins."""
111
+
112
+ def __init__(self):
113
+ """Initialize plugin manager."""
114
+ self._plugins: Dict[str, AWSServicePlugin] = {}
115
+ self._plugin_classes: Dict[str, Type[AWSServicePlugin]] = {}
116
+
117
+ def register_plugin(self, plugin_class: Type[AWSServicePlugin]) -> None:
118
+ """
119
+ Register a plugin class.
120
+
121
+ Args:
122
+ plugin_class: Plugin class to register
123
+ """
124
+ try:
125
+ # Create instance to get metadata
126
+ plugin_instance = plugin_class()
127
+ metadata = plugin_instance.metadata
128
+
129
+ if metadata.name in self._plugin_classes:
130
+ logger.warning(f"Plugin {metadata.name} is already registered, overriding")
131
+
132
+ self._plugin_classes[metadata.name] = plugin_class
133
+ logger.debug(f"Registered plugin: {metadata.name} v{metadata.version}")
134
+
135
+ except Exception as e:
136
+ logger.error(f"Failed to register plugin {plugin_class.__name__}: {e}")
137
+ raise
138
+
139
+ def load_plugin(self, plugin_name: str, client_manager: AWSClientManager) -> AWSServicePlugin:
140
+ """
141
+ Load and setup a plugin.
142
+
143
+ Args:
144
+ plugin_name: Name of the plugin to load
145
+ client_manager: AWS client manager
146
+
147
+ Returns:
148
+ Loaded plugin instance
149
+
150
+ Raises:
151
+ ValueError: If plugin is not found
152
+ """
153
+ if plugin_name not in self._plugin_classes:
154
+ raise ValueError(f"Plugin {plugin_name} not found. Available: {list(self._plugin_classes.keys())}")
155
+
156
+ if plugin_name in self._plugins:
157
+ logger.debug(f"Plugin {plugin_name} already loaded")
158
+ return self._plugins[plugin_name]
159
+
160
+ try:
161
+ plugin_class = self._plugin_classes[plugin_name]
162
+ plugin_instance = plugin_class()
163
+ plugin_instance.setup(client_manager)
164
+
165
+ self._plugins[plugin_name] = plugin_instance
166
+ logger.debug(f"Loaded plugin: {plugin_name}")
167
+
168
+ return plugin_instance
169
+
170
+ except Exception as e:
171
+ logger.error(f"Failed to load plugin {plugin_name}: {e}")
172
+ raise
173
+
174
+ def unload_plugin(self, plugin_name: str) -> None:
175
+ """
176
+ Unload a plugin.
177
+
178
+ Args:
179
+ plugin_name: Name of the plugin to unload
180
+ """
181
+ if plugin_name in self._plugins:
182
+ plugin = self._plugins[plugin_name]
183
+ plugin.cleanup()
184
+ del self._plugins[plugin_name]
185
+ logger.debug(f"Unloaded plugin: {plugin_name}")
186
+
187
+ def get_plugin(self, plugin_name: str) -> Optional[AWSServicePlugin]:
188
+ """
189
+ Get a loaded plugin.
190
+
191
+ Args:
192
+ plugin_name: Name of the plugin
193
+
194
+ Returns:
195
+ Plugin instance if loaded, None otherwise
196
+ """
197
+ return self._plugins.get(plugin_name)
198
+
199
+ def list_available_plugins(self) -> List[PluginMetadata]:
200
+ """
201
+ List all available plugins.
202
+
203
+ Returns:
204
+ List of plugin metadata
205
+ """
206
+ metadata_list = []
207
+ for plugin_class in self._plugin_classes.values():
208
+ try:
209
+ instance = plugin_class()
210
+ metadata_list.append(instance.metadata)
211
+ except Exception as e:
212
+ logger.warning(f"Failed to get metadata for plugin {plugin_class.__name__}: {e}")
213
+
214
+ return metadata_list
215
+
216
+ def list_loaded_plugins(self) -> List[str]:
217
+ """
218
+ List names of loaded plugins.
219
+
220
+ Returns:
221
+ List of loaded plugin names
222
+ """
223
+ return list(self._plugins.keys())
224
+
225
+ def get_plugins_for_service(self, aws_service: str) -> List[AWSServicePlugin]:
226
+ """
227
+ Get all loaded plugins that support a specific AWS service.
228
+
229
+ Args:
230
+ aws_service: AWS service name
231
+
232
+ Returns:
233
+ List of plugins supporting the service
234
+ """
235
+ matching_plugins = []
236
+
237
+ for plugin in self._plugins.values():
238
+ if aws_service in plugin.metadata.aws_services:
239
+ matching_plugins.append(plugin)
240
+
241
+ return matching_plugins
242
+
243
+ def cleanup_all(self) -> None:
244
+ """Cleanup all loaded plugins."""
245
+ for plugin_name in list(self._plugins.keys()):
246
+ self.unload_plugin(plugin_name)
247
+
248
+ logger.debug("All plugins cleaned up")
249
+
250
+
251
+ def discover_plugins_in_directory(directory: Path) -> List[Type[AWSServicePlugin]]:
252
+ """
253
+ Discover plugins in a directory.
254
+
255
+ Args:
256
+ directory: Directory to search for plugins
257
+
258
+ Returns:
259
+ List of discovered plugin classes
260
+ """
261
+ discovered_plugins = []
262
+
263
+ if not directory.exists() or not directory.is_dir():
264
+ logger.debug(f"Plugin directory {directory} does not exist")
265
+ return discovered_plugins
266
+
267
+ # Look for Python files in the directory
268
+ for plugin_file in directory.glob("*.py"):
269
+ if plugin_file.name.startswith("_"):
270
+ continue # Skip private files
271
+
272
+ try:
273
+ # Import the module dynamically
274
+ import importlib.util
275
+ spec = importlib.util.spec_from_file_location(plugin_file.stem, plugin_file)
276
+ if spec and spec.loader:
277
+ module = importlib.util.module_from_spec(spec)
278
+ spec.loader.exec_module(module)
279
+
280
+ # Look for plugin classes
281
+ for attr_name in dir(module):
282
+ attr = getattr(module, attr_name)
283
+ if (isinstance(attr, type) and
284
+ issubclass(attr, AWSServicePlugin) and
285
+ attr != AWSServicePlugin):
286
+ discovered_plugins.append(attr)
287
+ logger.debug(f"Discovered plugin class: {attr.__name__} in {plugin_file}")
288
+
289
+ except Exception as e:
290
+ logger.warning(f"Failed to load plugin from {plugin_file}: {e}")
291
+
292
+ return discovered_plugins
@@ -0,0 +1,12 @@
1
+ """Built-in plugins for Reverse Diagrams."""
2
+
3
+ # Import all built-in plugins to ensure they're available for discovery
4
+ from .ec2_plugin import EC2Plugin
5
+ from .organizations_plugin import OrganizationsPlugin
6
+ from .identity_center_plugin import IdentityCenterPlugin
7
+
8
+ __all__ = [
9
+ 'EC2Plugin',
10
+ 'OrganizationsPlugin',
11
+ 'IdentityCenterPlugin'
12
+ ]