fast-clean-architecture 1.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.
Files changed (30) hide show
  1. fast_clean_architecture/__init__.py +24 -0
  2. fast_clean_architecture/cli.py +480 -0
  3. fast_clean_architecture/config.py +506 -0
  4. fast_clean_architecture/exceptions.py +63 -0
  5. fast_clean_architecture/generators/__init__.py +11 -0
  6. fast_clean_architecture/generators/component_generator.py +1039 -0
  7. fast_clean_architecture/generators/config_updater.py +308 -0
  8. fast_clean_architecture/generators/package_generator.py +174 -0
  9. fast_clean_architecture/generators/template_validator.py +546 -0
  10. fast_clean_architecture/generators/validation_config.py +75 -0
  11. fast_clean_architecture/generators/validation_metrics.py +193 -0
  12. fast_clean_architecture/templates/__init__.py +7 -0
  13. fast_clean_architecture/templates/__init__.py.j2 +26 -0
  14. fast_clean_architecture/templates/api.py.j2 +65 -0
  15. fast_clean_architecture/templates/command.py.j2 +26 -0
  16. fast_clean_architecture/templates/entity.py.j2 +49 -0
  17. fast_clean_architecture/templates/external.py.j2 +61 -0
  18. fast_clean_architecture/templates/infrastructure_repository.py.j2 +69 -0
  19. fast_clean_architecture/templates/model.py.j2 +38 -0
  20. fast_clean_architecture/templates/query.py.j2 +26 -0
  21. fast_clean_architecture/templates/repository.py.j2 +57 -0
  22. fast_clean_architecture/templates/schemas.py.j2 +32 -0
  23. fast_clean_architecture/templates/service.py.j2 +109 -0
  24. fast_clean_architecture/templates/value_object.py.j2 +34 -0
  25. fast_clean_architecture/utils.py +553 -0
  26. fast_clean_architecture-1.0.0.dist-info/METADATA +541 -0
  27. fast_clean_architecture-1.0.0.dist-info/RECORD +30 -0
  28. fast_clean_architecture-1.0.0.dist-info/WHEEL +4 -0
  29. fast_clean_architecture-1.0.0.dist-info/entry_points.txt +2 -0
  30. fast_clean_architecture-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,506 @@
1
+ """Configuration management for Fast Clean Architecture."""
2
+
3
+ from pathlib import Path
4
+ from typing import Dict, List, Optional, Union
5
+
6
+ import re
7
+ import yaml
8
+ from pydantic import BaseModel, Field
9
+
10
+ from .exceptions import ConfigurationError, ValidationError
11
+ from .utils import generate_timestamp
12
+
13
+
14
+ class ComponentInfo(BaseModel):
15
+ """Information about a component."""
16
+
17
+ name: str
18
+ file_path: Optional[str] = None
19
+ created_at: str = Field(default_factory=generate_timestamp)
20
+ updated_at: str = Field(default_factory=generate_timestamp)
21
+
22
+
23
+ class ComponentsConfig(BaseModel):
24
+ """Configuration for component default imports."""
25
+
26
+ # Component lists
27
+ entities: List[ComponentInfo] = Field(default_factory=list)
28
+ repositories: List[ComponentInfo] = Field(default_factory=list)
29
+ value_objects: List[ComponentInfo] = Field(default_factory=list)
30
+ services: List[ComponentInfo] = Field(default_factory=list)
31
+ commands: List[ComponentInfo] = Field(default_factory=list)
32
+ queries: List[ComponentInfo] = Field(default_factory=list)
33
+ models: List[ComponentInfo] = Field(default_factory=list)
34
+ external: List[ComponentInfo] = Field(default_factory=list)
35
+ internal: List[ComponentInfo] = Field(default_factory=list)
36
+ api: List[ComponentInfo] = Field(default_factory=list)
37
+ schemas: List[ComponentInfo] = Field(default_factory=list)
38
+
39
+ default_imports: Dict[str, List[str]] = Field(
40
+ default={
41
+ "entities": ["dataclasses", "typing", "datetime"],
42
+ "repositories": ["typing"],
43
+ "services": ["typing"],
44
+ "api": ["fastapi", "typing"],
45
+ "schemas": ["pydantic", "typing"],
46
+ }
47
+ )
48
+
49
+
50
+ class NamingConfig(BaseModel):
51
+ """Configuration for naming conventions."""
52
+
53
+ snake_case: bool = True
54
+ auto_pluralize: bool = True
55
+
56
+
57
+ class TemplatesConfig(BaseModel):
58
+ """Configuration for custom templates."""
59
+
60
+ entity: Optional[str] = None
61
+ repository: Optional[str] = None
62
+ service: Optional[str] = None
63
+ api: Optional[str] = None
64
+ schemas: Optional[str] = None
65
+ command: Optional[str] = None
66
+ query: Optional[str] = None
67
+ model: Optional[str] = None
68
+ external: Optional[str] = None
69
+ value_object: Optional[str] = None
70
+
71
+
72
+ class DomainComponents(BaseModel):
73
+ """Domain layer components."""
74
+
75
+ entities: List[ComponentInfo] = Field(default_factory=list)
76
+ repositories: List[ComponentInfo] = Field(default_factory=list)
77
+ value_objects: List[ComponentInfo] = Field(default_factory=list)
78
+ services: List[ComponentInfo] = Field(default_factory=list)
79
+
80
+
81
+ class ApplicationComponents(BaseModel):
82
+ """Application layer components."""
83
+
84
+ commands: List[ComponentInfo] = Field(default_factory=list)
85
+ queries: List[ComponentInfo] = Field(default_factory=list)
86
+ services: List[ComponentInfo] = Field(default_factory=list)
87
+
88
+
89
+ class InfrastructureComponents(BaseModel):
90
+ """Infrastructure layer components."""
91
+
92
+ repositories: List[ComponentInfo] = Field(default_factory=list)
93
+ external: List[ComponentInfo] = Field(default_factory=list)
94
+ models: List[ComponentInfo] = Field(default_factory=list)
95
+
96
+
97
+ class PresentationComponents(BaseModel):
98
+ """Presentation layer components."""
99
+
100
+ api: List[ComponentInfo] = Field(default_factory=list)
101
+ schemas: List[ComponentInfo] = Field(default_factory=list)
102
+
103
+
104
+ class ModuleComponents(BaseModel):
105
+ """Components within a module."""
106
+
107
+ domain: ComponentsConfig = Field(default_factory=ComponentsConfig)
108
+ application: ComponentsConfig = Field(default_factory=ComponentsConfig)
109
+ infrastructure: ComponentsConfig = Field(default_factory=ComponentsConfig)
110
+ presentation: ComponentsConfig = Field(default_factory=ComponentsConfig)
111
+
112
+
113
+ class ModuleConfig(BaseModel):
114
+ """Configuration for a module."""
115
+
116
+ description: str = ""
117
+ created_at: str = Field(default_factory=generate_timestamp)
118
+ updated_at: str = Field(default_factory=generate_timestamp)
119
+ components: ModuleComponents = Field(default_factory=ModuleComponents)
120
+
121
+
122
+ class SystemConfig(BaseModel):
123
+ """Configuration for a system context."""
124
+
125
+ description: str = ""
126
+ created_at: str = Field(default_factory=generate_timestamp)
127
+ updated_at: str = Field(default_factory=generate_timestamp)
128
+ modules: Dict[str, ModuleConfig] = Field(default_factory=dict)
129
+
130
+
131
+ class ProjectConfig(BaseModel):
132
+ """Configuration for the project."""
133
+
134
+ name: str = ""
135
+ description: str = ""
136
+ version: str = ""
137
+ base_path: Optional[Path] = None
138
+ created_at: str = Field(default_factory=generate_timestamp)
139
+ updated_at: str = Field(default_factory=generate_timestamp)
140
+ systems: Dict[str, SystemConfig] = Field(default_factory=dict)
141
+
142
+
143
+ class Config(BaseModel):
144
+ """Main configuration model."""
145
+
146
+ project: ProjectConfig = Field(default_factory=ProjectConfig)
147
+ templates: TemplatesConfig = Field(default_factory=TemplatesConfig)
148
+ naming: NamingConfig = Field(default_factory=NamingConfig)
149
+ components: ComponentsConfig = Field(default_factory=ComponentsConfig)
150
+
151
+ @classmethod
152
+ def load_from_file(cls, config_path: Path) -> "Config":
153
+ """Load configuration from YAML file with enhanced security validation."""
154
+ try:
155
+ if not config_path.exists():
156
+ raise ConfigurationError(f"Configuration file not found: {config_path}")
157
+
158
+ # Check file size to prevent DoS attacks
159
+ file_size = config_path.stat().st_size
160
+ if file_size > 10 * 1024 * 1024: # 10MB limit
161
+ raise ConfigurationError(
162
+ f"Configuration file too large: {file_size} bytes"
163
+ )
164
+
165
+ with open(config_path, "r", encoding="utf-8") as f:
166
+ # Use safe_load for security
167
+ data = yaml.safe_load(f) or {}
168
+
169
+ # Validate loaded data structure
170
+ if not isinstance(data, dict):
171
+ raise ConfigurationError(
172
+ "Configuration file must contain a YAML object/dictionary"
173
+ )
174
+
175
+ # Sanitize loaded data before creating config object
176
+ sanitized_data = cls._sanitize_loaded_data(data)
177
+
178
+ return cls(**sanitized_data)
179
+ except yaml.YAMLError as e:
180
+ raise ConfigurationError(f"Invalid YAML in config file: {e}")
181
+ except Exception as e:
182
+ raise ConfigurationError(f"Error loading config: {e}")
183
+
184
+ @staticmethod
185
+ def _sanitize_loaded_data(data: dict) -> dict:
186
+ """Sanitize data loaded from YAML file to prevent injection attacks.
187
+
188
+ Args:
189
+ data: Raw data loaded from YAML file
190
+
191
+ Returns:
192
+ Sanitized data safe for Config object creation
193
+ """
194
+
195
+ def sanitize_value(value):
196
+ if value is None:
197
+ return None
198
+ elif isinstance(value, bool):
199
+ return value
200
+ elif isinstance(value, (int, float)):
201
+ # Validate numeric ranges
202
+ if isinstance(value, float) and (
203
+ value != value or abs(value) == float("inf")
204
+ ):
205
+ return 0.0
206
+ return value
207
+ elif isinstance(value, str):
208
+ # Remove control characters and YAML-specific dangerous patterns
209
+ sanitized = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", value)
210
+ sanitized = re.sub(r"^[!&*|>%@`]", "", sanitized)
211
+ # Limit string length
212
+ if len(sanitized) > 10000:
213
+ sanitized = sanitized[:10000]
214
+ return sanitized
215
+ elif isinstance(value, list):
216
+ return [sanitize_value(item) for item in value]
217
+ elif isinstance(value, dict):
218
+ sanitized_dict = {}
219
+ for k, v in value.items():
220
+ # Sanitize keys
221
+ if not isinstance(k, str):
222
+ k = str(k)
223
+ sanitized_key = re.sub(
224
+ r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", k
225
+ )
226
+ sanitized_key = re.sub(r"^[!&*|>%@`]", "", sanitized_key)
227
+ if len(sanitized_key) > 1000:
228
+ sanitized_key = sanitized_key[:1000]
229
+ if sanitized_key: # Skip empty keys
230
+ sanitized_dict[sanitized_key] = sanitize_value(v)
231
+ return sanitized_dict
232
+ else:
233
+ # Convert unknown types to string and sanitize
234
+ return sanitize_value(str(value))
235
+
236
+ return sanitize_value(data)
237
+
238
+ @classmethod
239
+ def create_default(cls) -> "Config":
240
+ """Create default configuration without saving to file."""
241
+ timestamp = generate_timestamp()
242
+
243
+ return cls(
244
+ project=ProjectConfig(
245
+ name="my_project",
246
+ description="My FastAPI project",
247
+ version="0.1.0",
248
+ created_at=timestamp,
249
+ updated_at=timestamp,
250
+ )
251
+ )
252
+
253
+ @classmethod
254
+ def create_default_config(cls, config_path: Path) -> "Config":
255
+ """Create default configuration."""
256
+ timestamp = generate_timestamp()
257
+ project_name = config_path.parent.name
258
+
259
+ config = cls(
260
+ project=ProjectConfig(
261
+ name=project_name,
262
+ created_at=timestamp,
263
+ updated_at=timestamp,
264
+ )
265
+ )
266
+
267
+ config.save_to_file(config_path)
268
+ return config
269
+
270
+ def save_to_file(self, config_path: Path) -> None:
271
+ """Save configuration to YAML file with atomic write and backup."""
272
+ import tempfile
273
+ from datetime import datetime
274
+
275
+ backup_path = None
276
+ temp_path = None
277
+
278
+ try:
279
+ # Create timestamped backup if file exists
280
+ if config_path.exists():
281
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
282
+ backup_path = config_path.with_suffix(f".yaml.backup.{timestamp}")
283
+ backup_path.write_bytes(config_path.read_bytes())
284
+
285
+ # Clean up old backups (keep only last 5)
286
+ self._cleanup_old_backups(config_path)
287
+
288
+ # Ensure parent directory exists
289
+ config_path.parent.mkdir(parents=True, exist_ok=True)
290
+
291
+ # Write to temporary file first (atomic write)
292
+ with tempfile.NamedTemporaryFile(
293
+ mode="w",
294
+ encoding="utf-8",
295
+ dir=config_path.parent,
296
+ delete=False,
297
+ suffix=".tmp",
298
+ ) as f:
299
+ temp_path = Path(f.name)
300
+ # Sanitize configuration data before dumping
301
+ raw_data = self.model_dump(exclude_none=True)
302
+ config_data = self._sanitize_config_data(raw_data)
303
+
304
+ # Ensure we have a dict for YAML serialization
305
+ if not isinstance(config_data, dict):
306
+ raise ConfigurationError(
307
+ "Sanitized configuration data must be a dictionary"
308
+ )
309
+
310
+ # Use safe_dump for security
311
+ yaml.safe_dump(
312
+ config_data,
313
+ f,
314
+ default_flow_style=False,
315
+ sort_keys=False,
316
+ indent=2,
317
+ allow_unicode=True,
318
+ )
319
+
320
+ # Atomic move
321
+ temp_path.replace(config_path)
322
+
323
+ except Exception as e:
324
+ # Clean up temporary file on error
325
+ if temp_path and temp_path.exists():
326
+ try:
327
+ temp_path.unlink()
328
+ except:
329
+ pass
330
+
331
+ # Restore from backup if available
332
+ if backup_path and backup_path.exists() and not config_path.exists():
333
+ try:
334
+ backup_path.rename(config_path)
335
+ except:
336
+ pass
337
+
338
+ raise ConfigurationError(f"Error saving config: {e}")
339
+
340
+ def _sanitize_config_data(
341
+ self, data: Union[dict, list, str, int, float, bool, None]
342
+ ) -> Union[dict, list, str, int, float, bool, None]:
343
+ """Sanitize configuration data to prevent injection attacks and ensure safe YAML output.
344
+
345
+ Args:
346
+ data: The configuration data to sanitize
347
+
348
+ Returns:
349
+ Sanitized configuration data safe for YAML serialization
350
+ """
351
+ if data is None:
352
+ return None
353
+ elif isinstance(data, bool):
354
+ return data
355
+ elif isinstance(data, (int, float)):
356
+ # Validate numeric ranges to prevent potential issues
357
+ if isinstance(data, float) and (data != data or abs(data) == float("inf")):
358
+ return 0.0 # Replace NaN and infinity with safe default
359
+ return data
360
+ elif isinstance(data, str):
361
+ # Remove potentially dangerous characters and control sequences
362
+ sanitized = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", data)
363
+ # Remove YAML-specific dangerous patterns
364
+ sanitized = re.sub(r"^[!&*|>%@`]", "", sanitized)
365
+ # Limit string length to prevent DoS
366
+ if len(sanitized) > 10000:
367
+ sanitized = sanitized[:10000]
368
+ return sanitized
369
+ elif isinstance(data, list):
370
+ # Recursively sanitize list items
371
+ return [self._sanitize_config_data(item) for item in data]
372
+ elif isinstance(data, dict):
373
+ # Recursively sanitize dictionary values and validate keys
374
+ sanitized_dict = {}
375
+ for key, value in data.items():
376
+ # Sanitize keys (must be strings for YAML)
377
+ if not isinstance(key, str):
378
+ key = str(key)
379
+ # Remove dangerous characters from keys
380
+ sanitized_key = re.sub(
381
+ r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", key
382
+ )
383
+ sanitized_key = re.sub(r"^[!&*|>%@`]", "", sanitized_key)
384
+ if len(sanitized_key) > 1000:
385
+ sanitized_key = sanitized_key[:1000]
386
+
387
+ # Skip empty keys
388
+ if not sanitized_key:
389
+ continue
390
+
391
+ sanitized_dict[sanitized_key] = self._sanitize_config_data(value)
392
+ return sanitized_dict
393
+ else:
394
+ # Convert unknown types to string and sanitize
395
+ return self._sanitize_config_data(str(data))
396
+
397
+ def _cleanup_old_backups(self, config_path: Path, keep_count: int = 5) -> None:
398
+ """Clean up old backup files, keeping only the most recent ones."""
399
+ try:
400
+ backup_pattern = f"{config_path.name}.backup.*"
401
+ backup_files = list(config_path.parent.glob(backup_pattern))
402
+
403
+ if len(backup_files) > keep_count:
404
+ # Sort by modification time (newest first)
405
+ backup_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
406
+
407
+ # Remove old backups
408
+ for old_backup in backup_files[keep_count:]:
409
+ try:
410
+ old_backup.unlink()
411
+ except OSError:
412
+ # Ignore errors when cleaning up old backups
413
+ pass
414
+ except Exception:
415
+ # Don't fail the main operation if backup cleanup fails
416
+ pass
417
+
418
+ def add_system(self, system_name: str, description: str = "") -> None:
419
+ """Add a new system context."""
420
+ if system_name in self.project.systems:
421
+ raise ValidationError(f"System '{system_name}' already exists")
422
+
423
+ timestamp = generate_timestamp()
424
+
425
+ self.project.systems[system_name] = SystemConfig(
426
+ description=description,
427
+ created_at=timestamp,
428
+ updated_at=timestamp,
429
+ )
430
+ self.project.updated_at = timestamp
431
+
432
+ def add_module(
433
+ self, system_name: str, module_name: str, description: str = ""
434
+ ) -> None:
435
+ """Add a new module to a system."""
436
+ if system_name not in self.project.systems:
437
+ raise ValidationError(f"System '{system_name}' not found")
438
+
439
+ if module_name in self.project.systems[system_name].modules:
440
+ raise ValidationError(f"Module '{module_name}' already exists")
441
+
442
+ timestamp = generate_timestamp()
443
+
444
+ self.project.systems[system_name].modules[module_name] = ModuleConfig(
445
+ description=description,
446
+ created_at=timestamp,
447
+ updated_at=timestamp,
448
+ )
449
+ self.project.systems[system_name].updated_at = timestamp
450
+ self.project.updated_at = timestamp
451
+
452
+ def add_component(
453
+ self,
454
+ system_name: str,
455
+ module_name: str,
456
+ layer: str,
457
+ component_type: str,
458
+ component_name: str,
459
+ file_path: Optional[str] = None,
460
+ ) -> None:
461
+ """Add a component to a module."""
462
+ if system_name not in self.project.systems:
463
+ raise ConfigurationError(f"System '{system_name}' does not exist")
464
+
465
+ if module_name not in self.project.systems[system_name].modules:
466
+ raise ValidationError(f"Module '{module_name}' not found")
467
+
468
+ # Validate layer
469
+ valid_layers = ["domain", "application", "infrastructure", "presentation"]
470
+ if layer not in valid_layers:
471
+ raise ValidationError(f"Invalid layer: {layer}")
472
+
473
+ timestamp = generate_timestamp()
474
+ module = self.project.systems[system_name].modules[module_name]
475
+
476
+ # Get the layer components
477
+ layer_components = getattr(module.components, layer)
478
+
479
+ # Validate component type for the layer
480
+ if not hasattr(layer_components, component_type):
481
+ raise ValidationError(f"Invalid component type: {component_type}")
482
+
483
+ # Get the specific component type list
484
+ component_list = getattr(layer_components, component_type)
485
+
486
+ # Check if component already exists
487
+ existing_component = next(
488
+ (comp for comp in component_list if comp.name == component_name), None
489
+ )
490
+
491
+ if existing_component is None:
492
+ # Create new component
493
+ component_info = ComponentInfo(
494
+ name=component_name,
495
+ file_path=file_path,
496
+ created_at=timestamp,
497
+ updated_at=timestamp,
498
+ )
499
+ component_list.append(component_info)
500
+ # Sort by name to keep alphabetical order
501
+ component_list.sort(key=lambda x: x.name)
502
+
503
+ # Update timestamps
504
+ module.updated_at = timestamp
505
+ self.project.systems[system_name].updated_at = timestamp
506
+ self.project.updated_at = timestamp
@@ -0,0 +1,63 @@
1
+ """Custom exceptions for Fast Clean Architecture."""
2
+
3
+
4
+ class FastCleanArchitectureError(Exception):
5
+ """Base exception for all Fast Clean Architecture errors."""
6
+
7
+ pass
8
+
9
+
10
+ class ConfigurationError(FastCleanArchitectureError):
11
+ """Raised when there's an issue with configuration."""
12
+
13
+ pass
14
+
15
+
16
+ class ValidationError(FastCleanArchitectureError):
17
+ """Raised when validation fails."""
18
+
19
+ pass
20
+
21
+
22
+ class FileConflictError(FastCleanArchitectureError):
23
+ """Raised when there's a file or directory conflict."""
24
+
25
+ pass
26
+
27
+
28
+ class TemplateError(FastCleanArchitectureError):
29
+ """Raised when there's an issue with template rendering."""
30
+
31
+ pass
32
+
33
+
34
+ class TemplateValidationError(TemplateError):
35
+ """Base class for template validation errors."""
36
+
37
+ pass
38
+
39
+
40
+ class TemplateMissingVariablesError(TemplateValidationError):
41
+ """Raised when required template variables are missing."""
42
+
43
+ def __init__(self, missing_vars: set, message: str = None):
44
+ self.missing_vars = missing_vars
45
+ if message is None:
46
+ message = f"Missing required template variables: {', '.join(sorted(missing_vars))}"
47
+ super().__init__(message)
48
+
49
+
50
+ class TemplateUndefinedVariableError(TemplateValidationError):
51
+ """Raised when template contains undefined variables during rendering."""
52
+
53
+ def __init__(self, variable_name: str, message: str = None):
54
+ self.variable_name = variable_name
55
+ if message is None:
56
+ message = f"Undefined template variable: {variable_name}"
57
+ super().__init__(message)
58
+
59
+
60
+ class ComponentError(FastCleanArchitectureError):
61
+ """Raised when there's an issue with component generation."""
62
+
63
+ pass
@@ -0,0 +1,11 @@
1
+ """Code generators for Fast Clean Architecture."""
2
+
3
+ from .package_generator import PackageGenerator
4
+ from .component_generator import ComponentGenerator
5
+ from .config_updater import ConfigUpdater
6
+
7
+ __all__ = [
8
+ "PackageGenerator",
9
+ "ComponentGenerator",
10
+ "ConfigUpdater",
11
+ ]