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.
- fast_clean_architecture/__init__.py +24 -0
- fast_clean_architecture/cli.py +480 -0
- fast_clean_architecture/config.py +506 -0
- fast_clean_architecture/exceptions.py +63 -0
- fast_clean_architecture/generators/__init__.py +11 -0
- fast_clean_architecture/generators/component_generator.py +1039 -0
- fast_clean_architecture/generators/config_updater.py +308 -0
- fast_clean_architecture/generators/package_generator.py +174 -0
- fast_clean_architecture/generators/template_validator.py +546 -0
- fast_clean_architecture/generators/validation_config.py +75 -0
- fast_clean_architecture/generators/validation_metrics.py +193 -0
- fast_clean_architecture/templates/__init__.py +7 -0
- fast_clean_architecture/templates/__init__.py.j2 +26 -0
- fast_clean_architecture/templates/api.py.j2 +65 -0
- fast_clean_architecture/templates/command.py.j2 +26 -0
- fast_clean_architecture/templates/entity.py.j2 +49 -0
- fast_clean_architecture/templates/external.py.j2 +61 -0
- fast_clean_architecture/templates/infrastructure_repository.py.j2 +69 -0
- fast_clean_architecture/templates/model.py.j2 +38 -0
- fast_clean_architecture/templates/query.py.j2 +26 -0
- fast_clean_architecture/templates/repository.py.j2 +57 -0
- fast_clean_architecture/templates/schemas.py.j2 +32 -0
- fast_clean_architecture/templates/service.py.j2 +109 -0
- fast_clean_architecture/templates/value_object.py.j2 +34 -0
- fast_clean_architecture/utils.py +553 -0
- fast_clean_architecture-1.0.0.dist-info/METADATA +541 -0
- fast_clean_architecture-1.0.0.dist-info/RECORD +30 -0
- fast_clean_architecture-1.0.0.dist-info/WHEEL +4 -0
- fast_clean_architecture-1.0.0.dist-info/entry_points.txt +2 -0
- fast_clean_architecture-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,109 @@
|
|
1
|
+
"""{{ ServiceName }} application service.
|
2
|
+
|
3
|
+
Generated at: {{ generated_at }}
|
4
|
+
Generator version: {{ generator_version }}
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import List, Optional, Dict, Any
|
8
|
+
from uuid import UUID
|
9
|
+
|
10
|
+
from {{ entity_import }} import {{ EntityName }}
|
11
|
+
from {{ repository_import }} import {{ RepositoryName }}Repository
|
12
|
+
|
13
|
+
|
14
|
+
class {{ ServiceName }}Service:
|
15
|
+
"""Application service for {{ service_description }}."""
|
16
|
+
|
17
|
+
def __init__(self, repository: {{ RepositoryName }}Repository):
|
18
|
+
self._repository = repository
|
19
|
+
|
20
|
+
async def create_{{ entity_name }}(self, data: Dict[str, Any]) -> {{ EntityName }}:
|
21
|
+
"""Create a new {{ entity_name }}.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
data: Dictionary containing {{ entity_name }} data
|
25
|
+
|
26
|
+
Returns:
|
27
|
+
Created {{ entity_name }} instance
|
28
|
+
|
29
|
+
Raises:
|
30
|
+
ValueError: If data is invalid
|
31
|
+
"""
|
32
|
+
if not data:
|
33
|
+
raise ValueError("{{ EntityName }} data cannot be empty")
|
34
|
+
|
35
|
+
try:
|
36
|
+
{{ entity_name }} = {{ EntityName }}(**data)
|
37
|
+
return await self._repository.save({{ entity_name }})
|
38
|
+
except Exception as e:
|
39
|
+
raise ValueError(f"Failed to create {{ entity_name }}: {e}")
|
40
|
+
|
41
|
+
async def get_{{ entity_name }}(self, {{ entity_name }}_id: UUID) -> Optional[{{ EntityName }}]:
|
42
|
+
"""Get {{ entity_name }} by ID.
|
43
|
+
|
44
|
+
Args:
|
45
|
+
{{ entity_name }}_id: UUID of the {{ entity_name }}
|
46
|
+
|
47
|
+
Returns:
|
48
|
+
{{ EntityName }} instance if found, None otherwise
|
49
|
+
"""
|
50
|
+
if not {{ entity_name }}_id:
|
51
|
+
raise ValueError("{{ EntityName }} ID cannot be empty")
|
52
|
+
|
53
|
+
return await self._repository.get_by_id({{ entity_name }}_id)
|
54
|
+
|
55
|
+
async def list_{{ entity_name }}s(self, limit: Optional[int] = None, offset: int = 0) -> List[{{ EntityName }}]:
|
56
|
+
"""List {{ entity_name }}s with pagination.
|
57
|
+
|
58
|
+
Args:
|
59
|
+
limit: Maximum number of items to return
|
60
|
+
offset: Number of items to skip
|
61
|
+
|
62
|
+
Returns:
|
63
|
+
List of {{ entity_name }} instances
|
64
|
+
"""
|
65
|
+
return await self._repository.list_all(limit=limit, offset=offset)
|
66
|
+
|
67
|
+
async def update_{{ entity_name }}(self, {{ entity_name }}_id: UUID, data: Dict[str, Any]) -> Optional[{{ EntityName }}]:
|
68
|
+
"""Update {{ entity_name }}.
|
69
|
+
|
70
|
+
Args:
|
71
|
+
{{ entity_name }}_id: UUID of the {{ entity_name }}
|
72
|
+
data: Dictionary containing updated data
|
73
|
+
|
74
|
+
Returns:
|
75
|
+
Updated {{ entity_name }} instance if found, None otherwise
|
76
|
+
|
77
|
+
Raises:
|
78
|
+
ValueError: If data is invalid
|
79
|
+
"""
|
80
|
+
if not {{ entity_name }}_id:
|
81
|
+
raise ValueError("{{ EntityName }} ID cannot be empty")
|
82
|
+
|
83
|
+
if not data:
|
84
|
+
raise ValueError("Update data cannot be empty")
|
85
|
+
|
86
|
+
{{ entity_name }} = await self._repository.get_by_id({{ entity_name }}_id)
|
87
|
+
if {{ entity_name }}:
|
88
|
+
try:
|
89
|
+
for key, value in data.items():
|
90
|
+
if hasattr({{ entity_name }}, key):
|
91
|
+
setattr({{ entity_name }}, key, value)
|
92
|
+
return await self._repository.save({{ entity_name }})
|
93
|
+
except Exception as e:
|
94
|
+
raise ValueError(f"Failed to update {{ entity_name }}: {e}")
|
95
|
+
return None
|
96
|
+
|
97
|
+
async def delete_{{ entity_name }}(self, {{ entity_name }}_id: UUID) -> bool:
|
98
|
+
"""Delete {{ entity_name }}.
|
99
|
+
|
100
|
+
Args:
|
101
|
+
{{ entity_name }}_id: UUID of the {{ entity_name }}
|
102
|
+
|
103
|
+
Returns:
|
104
|
+
True if deleted successfully, False otherwise
|
105
|
+
"""
|
106
|
+
if not {{ entity_name }}_id:
|
107
|
+
raise ValueError("{{ EntityName }} ID cannot be empty")
|
108
|
+
|
109
|
+
return await self._repository.delete({{ entity_name }}_id)
|
@@ -0,0 +1,34 @@
|
|
1
|
+
"""
|
2
|
+
{{ value_object_name }} value object for {{ module_name }} module.
|
3
|
+
"""
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from typing import Any
|
6
|
+
|
7
|
+
|
8
|
+
@dataclass(frozen=True)
|
9
|
+
class {{ ValueObjectName }}:
|
10
|
+
"""Value object for {{ value_object_name.replace('_', ' ') }}."""
|
11
|
+
|
12
|
+
value: Any
|
13
|
+
|
14
|
+
def __post_init__(self):
|
15
|
+
"""Post-initialization validation."""
|
16
|
+
self._validate()
|
17
|
+
|
18
|
+
def _validate(self) -> None:
|
19
|
+
"""Validate the value object."""
|
20
|
+
if self.value is None:
|
21
|
+
raise ValueError("{{ ValueObjectName }} value cannot be None")
|
22
|
+
|
23
|
+
# Add specific validation logic here
|
24
|
+
|
25
|
+
def __str__(self) -> str:
|
26
|
+
return str(self.value)
|
27
|
+
|
28
|
+
def __eq__(self, other) -> bool:
|
29
|
+
if not isinstance(other, {{ ValueObjectName }}):
|
30
|
+
return False
|
31
|
+
return self.value == other.value
|
32
|
+
|
33
|
+
def __hash__(self) -> int:
|
34
|
+
return hash(self.value)
|
@@ -0,0 +1,553 @@
|
|
1
|
+
"""Utility functions for Fast Clean Architecture."""
|
2
|
+
|
3
|
+
import keyword
|
4
|
+
import re
|
5
|
+
import urllib.parse
|
6
|
+
import unicodedata
|
7
|
+
import threading
|
8
|
+
import fcntl
|
9
|
+
import os
|
10
|
+
from datetime import datetime, timezone
|
11
|
+
from pathlib import Path
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
13
|
+
|
14
|
+
|
15
|
+
def generate_timestamp() -> str:
|
16
|
+
"""Generate ISO 8601 timestamp in UTC with validation."""
|
17
|
+
try:
|
18
|
+
timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
19
|
+
# Validate the timestamp format
|
20
|
+
datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
21
|
+
return timestamp
|
22
|
+
except Exception as e:
|
23
|
+
raise ValueError(f"Failed to generate valid timestamp: {e}")
|
24
|
+
|
25
|
+
|
26
|
+
def get_timestamp() -> str:
|
27
|
+
"""Get current timestamp in ISO format."""
|
28
|
+
return datetime.now(timezone.utc).isoformat()
|
29
|
+
|
30
|
+
|
31
|
+
# File locking utilities
|
32
|
+
_file_locks = {}
|
33
|
+
_locks_lock = threading.Lock()
|
34
|
+
|
35
|
+
|
36
|
+
def get_file_lock(file_path: Union[str, Path]) -> threading.Lock:
|
37
|
+
"""Get or create a lock for a specific file path."""
|
38
|
+
file_path_str = str(file_path)
|
39
|
+
with _locks_lock:
|
40
|
+
if file_path_str not in _file_locks:
|
41
|
+
_file_locks[file_path_str] = threading.Lock()
|
42
|
+
return _file_locks[file_path_str]
|
43
|
+
|
44
|
+
|
45
|
+
def secure_file_operation(file_path: Union[str, Path], operation_func, *args, **kwargs):
|
46
|
+
"""Execute file operation with proper locking."""
|
47
|
+
lock = get_file_lock(file_path)
|
48
|
+
with lock:
|
49
|
+
return operation_func(*args, **kwargs)
|
50
|
+
|
51
|
+
|
52
|
+
def sanitize_error_message(
|
53
|
+
error_msg: str, sensitive_info: Optional[List[str]] = None
|
54
|
+
) -> str:
|
55
|
+
"""Sanitize error messages to prevent information disclosure."""
|
56
|
+
if sensitive_info is None:
|
57
|
+
sensitive_info = []
|
58
|
+
|
59
|
+
# Add common sensitive patterns
|
60
|
+
sensitive_patterns = [
|
61
|
+
r"/Users/[^/\s]+", # User home directories
|
62
|
+
r"/home/[^/\s]+", # Linux home directories
|
63
|
+
r"C:\\Users\\[^\\\s]+", # Windows user directories
|
64
|
+
r"/tmp/[^/\s]+", # Temporary directories
|
65
|
+
r"/var/[^/\s]+", # System directories
|
66
|
+
r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", # IP addresses
|
67
|
+
]
|
68
|
+
|
69
|
+
# Add user-provided sensitive info
|
70
|
+
sensitive_patterns.extend(sensitive_info)
|
71
|
+
|
72
|
+
sanitized_msg = error_msg
|
73
|
+
for pattern in sensitive_patterns:
|
74
|
+
sanitized_msg = re.sub(pattern, "[REDACTED]", sanitized_msg)
|
75
|
+
|
76
|
+
return sanitized_msg
|
77
|
+
|
78
|
+
|
79
|
+
def create_secure_error(error_type: str, operation: str, details: Optional[str] = None):
|
80
|
+
"""Create a secure error message without exposing sensitive information."""
|
81
|
+
from fast_clean_architecture.exceptions import ValidationError
|
82
|
+
|
83
|
+
base_msg = f"Failed to {operation}"
|
84
|
+
|
85
|
+
if details:
|
86
|
+
# Sanitize details before including
|
87
|
+
safe_details = sanitize_error_message(details)
|
88
|
+
return ValidationError(f"{base_msg}: {safe_details}")
|
89
|
+
|
90
|
+
return ValidationError(base_msg)
|
91
|
+
|
92
|
+
|
93
|
+
def to_snake_case(name: str) -> str:
|
94
|
+
"""Convert string to snake_case."""
|
95
|
+
# Replace hyphens and spaces with underscores
|
96
|
+
name = re.sub(r"[-\s]+", "_", name)
|
97
|
+
# Handle sequences of uppercase letters followed by lowercase letters
|
98
|
+
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
99
|
+
# Insert underscore before uppercase letters that follow lowercase letters or digits
|
100
|
+
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
101
|
+
return name.lower()
|
102
|
+
|
103
|
+
|
104
|
+
def to_pascal_case(name: str) -> str:
|
105
|
+
"""Convert string to PascalCase."""
|
106
|
+
# First convert to snake_case to normalize, then split and capitalize
|
107
|
+
snake_name = to_snake_case(name)
|
108
|
+
words = snake_name.split("_")
|
109
|
+
return "".join(word.capitalize() for word in words if word)
|
110
|
+
|
111
|
+
|
112
|
+
def to_camel_case(name: str) -> str:
|
113
|
+
"""Convert string to camelCase."""
|
114
|
+
pascal = to_pascal_case(name)
|
115
|
+
return pascal[0].lower() + pascal[1:] if pascal else ""
|
116
|
+
|
117
|
+
|
118
|
+
def pluralize(word: str) -> str:
|
119
|
+
"""Simple pluralization for English words."""
|
120
|
+
# Handle irregular plurals
|
121
|
+
irregular_plurals = {
|
122
|
+
"person": "people",
|
123
|
+
"child": "children",
|
124
|
+
"mouse": "mice",
|
125
|
+
"foot": "feet",
|
126
|
+
"tooth": "teeth",
|
127
|
+
"goose": "geese",
|
128
|
+
"man": "men",
|
129
|
+
"woman": "women",
|
130
|
+
}
|
131
|
+
|
132
|
+
# Handle uncountable nouns
|
133
|
+
uncountable = {"data", "sheep", "fish", "deer", "species", "series"}
|
134
|
+
|
135
|
+
if word.lower() in uncountable:
|
136
|
+
return word
|
137
|
+
|
138
|
+
if word.lower() in irregular_plurals:
|
139
|
+
return irregular_plurals[word.lower()]
|
140
|
+
|
141
|
+
# Regular pluralization rules
|
142
|
+
if word.endswith("y") and len(word) > 1 and word[-2] not in "aeiou":
|
143
|
+
return word[:-1] + "ies"
|
144
|
+
elif word.endswith(("s", "sh", "ch", "x", "z")):
|
145
|
+
return word + "es"
|
146
|
+
elif word.endswith("f"):
|
147
|
+
return word[:-1] + "ves"
|
148
|
+
elif word.endswith("fe"):
|
149
|
+
return word[:-2] + "ves"
|
150
|
+
else:
|
151
|
+
return word + "s"
|
152
|
+
|
153
|
+
|
154
|
+
def validate_python_identifier(name: str) -> bool:
|
155
|
+
"""Validate if string is a valid Python identifier."""
|
156
|
+
return (
|
157
|
+
name.isidentifier()
|
158
|
+
and not keyword.iskeyword(name)
|
159
|
+
and not name.startswith("__")
|
160
|
+
)
|
161
|
+
|
162
|
+
|
163
|
+
def sanitize_name(name: str) -> str:
|
164
|
+
"""Sanitize name to be a valid Python identifier."""
|
165
|
+
# Strip whitespace
|
166
|
+
name = name.strip()
|
167
|
+
|
168
|
+
# Remove invalid characters except letters, numbers, spaces, hyphens, underscores
|
169
|
+
sanitized = re.sub(r"[^a-zA-Z0-9\s\-_]", "", name)
|
170
|
+
|
171
|
+
# Convert to snake_case
|
172
|
+
sanitized = to_snake_case(sanitized)
|
173
|
+
|
174
|
+
# Remove leading/trailing underscores and collapse multiple underscores
|
175
|
+
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
|
176
|
+
|
177
|
+
# Handle names that start with numbers
|
178
|
+
if sanitized and sanitized[0].isdigit():
|
179
|
+
# Remove leading numbers
|
180
|
+
sanitized = re.sub(r"^[0-9_]+", "", sanitized)
|
181
|
+
|
182
|
+
# Ensure it's not empty
|
183
|
+
if not sanitized:
|
184
|
+
sanitized = "component"
|
185
|
+
|
186
|
+
return sanitized
|
187
|
+
|
188
|
+
|
189
|
+
def validate_name(name: str) -> None:
|
190
|
+
"""Validate component name for security and correctness.
|
191
|
+
|
192
|
+
Args:
|
193
|
+
name: The name to validate
|
194
|
+
|
195
|
+
Raises:
|
196
|
+
ValueError: If the name is invalid
|
197
|
+
TypeError: If the name is not a string
|
198
|
+
ValidationError: If the name contains security risks
|
199
|
+
"""
|
200
|
+
from fast_clean_architecture.exceptions import ValidationError
|
201
|
+
|
202
|
+
# Check for None or non-string types
|
203
|
+
if name is None:
|
204
|
+
raise TypeError("Name cannot be None")
|
205
|
+
|
206
|
+
if not isinstance(name, str):
|
207
|
+
raise TypeError(f"Name must be a string, got {type(name).__name__}")
|
208
|
+
|
209
|
+
# Check for empty or whitespace-only names
|
210
|
+
if not name or not name.strip():
|
211
|
+
raise ValueError("Name cannot be empty or whitespace-only")
|
212
|
+
|
213
|
+
# Check length limits
|
214
|
+
if len(name) > 100:
|
215
|
+
raise ValueError(f"Name too long: {len(name)} characters (max 100)")
|
216
|
+
|
217
|
+
# Check for path traversal attempts (including encoded and Unicode variants)
|
218
|
+
# First, decode any URL-encoded sequences
|
219
|
+
try:
|
220
|
+
decoded_name = urllib.parse.unquote(name)
|
221
|
+
# Apply Unicode normalization to handle Unicode attacks
|
222
|
+
normalized_name = unicodedata.normalize("NFKC", decoded_name)
|
223
|
+
except Exception:
|
224
|
+
# If decoding fails, treat as suspicious
|
225
|
+
raise ValidationError(
|
226
|
+
f"Invalid component name: suspicious encoding detected in '{name}'"
|
227
|
+
)
|
228
|
+
|
229
|
+
# Check for path traversal in original, decoded, and normalized forms
|
230
|
+
names_to_check = [name, decoded_name, normalized_name]
|
231
|
+
for check_name in names_to_check:
|
232
|
+
if ".." in check_name or "/" in check_name or "\\" in check_name:
|
233
|
+
raise ValidationError(
|
234
|
+
f"Invalid component name: path traversal detected in '{name}'"
|
235
|
+
)
|
236
|
+
|
237
|
+
# Check for encoded path traversal sequences
|
238
|
+
encoded_patterns = [
|
239
|
+
"%2e%2e",
|
240
|
+
"%2E%2E", # .. encoded
|
241
|
+
"%2f",
|
242
|
+
"%2F", # / encoded
|
243
|
+
"%5c",
|
244
|
+
"%5C", # \ encoded
|
245
|
+
"%252e",
|
246
|
+
"%252E", # double-encoded .
|
247
|
+
"%252f",
|
248
|
+
"%252F", # double-encoded /
|
249
|
+
"%255c",
|
250
|
+
"%255C", # double-encoded \
|
251
|
+
]
|
252
|
+
name_lower = name.lower()
|
253
|
+
for pattern in encoded_patterns:
|
254
|
+
if pattern in name_lower:
|
255
|
+
raise ValidationError(
|
256
|
+
f"Invalid component name: encoded path traversal detected in '{name}'"
|
257
|
+
)
|
258
|
+
|
259
|
+
# Check for Unicode path traversal variants
|
260
|
+
unicode_dots = ["\u002e", "\uff0e", "\u2024", "\u2025", "\u2026"]
|
261
|
+
unicode_slashes = ["\u002f", "\uff0f", "\u2044", "\u29f8"]
|
262
|
+
unicode_backslashes = ["\u005c", "\uff3c", "\u29f5", "\u29f9"]
|
263
|
+
|
264
|
+
for dot in unicode_dots:
|
265
|
+
for dot2 in unicode_dots:
|
266
|
+
if dot + dot2 in name:
|
267
|
+
raise ValidationError(
|
268
|
+
f"Invalid component name: Unicode path traversal detected in '{name}'"
|
269
|
+
)
|
270
|
+
|
271
|
+
for slash in unicode_slashes + unicode_backslashes:
|
272
|
+
if slash in name:
|
273
|
+
raise ValidationError(
|
274
|
+
f"Invalid component name: Unicode path separator detected in '{name}'"
|
275
|
+
)
|
276
|
+
|
277
|
+
# Check for shell injection attempts
|
278
|
+
dangerous_chars = [";", "&", "|", "`", "$", "(", ")", "<", ">", "'", '"']
|
279
|
+
for char in dangerous_chars:
|
280
|
+
if char in name:
|
281
|
+
raise ValidationError(
|
282
|
+
f"Invalid component name: dangerous character '{char}' in '{name}'"
|
283
|
+
)
|
284
|
+
|
285
|
+
# Check for special characters that could cause issues
|
286
|
+
invalid_chars = [
|
287
|
+
"@",
|
288
|
+
"#",
|
289
|
+
"%",
|
290
|
+
"*",
|
291
|
+
"+",
|
292
|
+
"=",
|
293
|
+
"?",
|
294
|
+
"[",
|
295
|
+
"]",
|
296
|
+
"{",
|
297
|
+
"}",
|
298
|
+
":",
|
299
|
+
" ",
|
300
|
+
"\t",
|
301
|
+
"\n",
|
302
|
+
"\r",
|
303
|
+
]
|
304
|
+
for char in invalid_chars:
|
305
|
+
if char in name:
|
306
|
+
raise ValidationError(
|
307
|
+
f"Invalid component name: invalid character '{char}' in '{name}'"
|
308
|
+
)
|
309
|
+
|
310
|
+
# Check for unicode control characters and dangerous unicode
|
311
|
+
for char in name:
|
312
|
+
if ord(char) < 32 or ord(char) in [
|
313
|
+
0x202E,
|
314
|
+
0x200B,
|
315
|
+
0xFEFF,
|
316
|
+
0x2028,
|
317
|
+
0x2029,
|
318
|
+
0xFFFE,
|
319
|
+
0xFFFF,
|
320
|
+
]:
|
321
|
+
raise ValidationError(
|
322
|
+
f"Invalid component name: dangerous unicode character in '{name}'"
|
323
|
+
)
|
324
|
+
|
325
|
+
# Check for environment variable patterns
|
326
|
+
if name.startswith("$") or "${" in name or "`" in name:
|
327
|
+
raise ValidationError(
|
328
|
+
f"Invalid component name: environment variable pattern detected in '{name}'"
|
329
|
+
)
|
330
|
+
|
331
|
+
# Check if name starts with a digit (invalid for Python identifiers)
|
332
|
+
if name and name[0].isdigit():
|
333
|
+
raise ValidationError(
|
334
|
+
f"Invalid component name: '{name}' cannot start with a digit"
|
335
|
+
)
|
336
|
+
|
337
|
+
# Ensure it would make a valid Python identifier after sanitization
|
338
|
+
sanitized = sanitize_name(name)
|
339
|
+
if not validate_python_identifier(sanitized):
|
340
|
+
raise ValidationError(
|
341
|
+
f"Invalid component name: '{name}' cannot be converted to valid Python identifier"
|
342
|
+
)
|
343
|
+
|
344
|
+
|
345
|
+
def get_template_variables(
|
346
|
+
system_name: str,
|
347
|
+
module_name: str,
|
348
|
+
component_name: str,
|
349
|
+
component_type: str,
|
350
|
+
**kwargs,
|
351
|
+
) -> dict:
|
352
|
+
"""Generate template variables for rendering."""
|
353
|
+
snake_name = to_snake_case(component_name)
|
354
|
+
pascal_name = to_pascal_case(component_name)
|
355
|
+
camel_name = to_camel_case(component_name)
|
356
|
+
|
357
|
+
# System and module variations
|
358
|
+
system_snake = to_snake_case(system_name)
|
359
|
+
system_pascal = to_pascal_case(system_name)
|
360
|
+
system_camel = to_camel_case(system_name)
|
361
|
+
|
362
|
+
module_snake = to_snake_case(module_name)
|
363
|
+
module_pascal = to_pascal_case(module_name)
|
364
|
+
module_camel = to_camel_case(module_name)
|
365
|
+
|
366
|
+
component_type_snake = to_snake_case(component_type)
|
367
|
+
component_type_pascal = to_pascal_case(component_type)
|
368
|
+
component_type_camel = to_camel_case(component_type)
|
369
|
+
|
370
|
+
variables = {
|
371
|
+
# System variations
|
372
|
+
"system_name": system_snake,
|
373
|
+
"SystemName": system_pascal,
|
374
|
+
"system_name_camel": system_camel,
|
375
|
+
# Module variations
|
376
|
+
"module_name": module_snake,
|
377
|
+
"ModuleName": module_pascal,
|
378
|
+
"module_name_camel": module_camel,
|
379
|
+
# Component variations
|
380
|
+
"component_name": snake_name,
|
381
|
+
"ComponentName": pascal_name,
|
382
|
+
"component_name_camel": camel_name,
|
383
|
+
# Component type variations
|
384
|
+
"component_type": component_type_snake,
|
385
|
+
"ComponentType": component_type_pascal,
|
386
|
+
"component_type_camel": component_type_camel,
|
387
|
+
# Common naming variations
|
388
|
+
"entity_name": snake_name,
|
389
|
+
"EntityName": pascal_name,
|
390
|
+
"entity_name_camel": camel_name,
|
391
|
+
"repository_name": snake_name,
|
392
|
+
"RepositoryName": pascal_name,
|
393
|
+
"repository_name_camel": camel_name,
|
394
|
+
"service_name": snake_name,
|
395
|
+
"ServiceName": pascal_name,
|
396
|
+
"service_name_camel": camel_name,
|
397
|
+
"router_name": snake_name,
|
398
|
+
"RouterName": pascal_name,
|
399
|
+
"router_name_camel": camel_name,
|
400
|
+
"schema_name": snake_name,
|
401
|
+
"SchemaName": pascal_name,
|
402
|
+
"schema_name_camel": camel_name,
|
403
|
+
"command_name": snake_name,
|
404
|
+
"CommandName": pascal_name,
|
405
|
+
"command_name_camel": camel_name,
|
406
|
+
"query_name": snake_name,
|
407
|
+
"QueryName": pascal_name,
|
408
|
+
"query_name_camel": camel_name,
|
409
|
+
"model_name": snake_name,
|
410
|
+
"ModelName": pascal_name,
|
411
|
+
"model_name_camel": camel_name,
|
412
|
+
"value_object_name": snake_name,
|
413
|
+
"ValueObjectName": pascal_name,
|
414
|
+
"value_object_name_camel": camel_name,
|
415
|
+
"external_service_name": snake_name,
|
416
|
+
"ExternalServiceName": pascal_name,
|
417
|
+
"external_service_name_camel": camel_name,
|
418
|
+
# File naming
|
419
|
+
"entity_file": f"{snake_name}.py",
|
420
|
+
"repository_file": f"{snake_name}_repository.py",
|
421
|
+
"service_file": f"{snake_name}_service.py",
|
422
|
+
"router_file": f"{snake_name}_router.py",
|
423
|
+
"schema_file": f"{snake_name}_schemas.py",
|
424
|
+
"command_file": f"{snake_name}.py",
|
425
|
+
"query_file": f"{snake_name}.py",
|
426
|
+
"model_file": f"{snake_name}_model.py",
|
427
|
+
"value_object_file": f"{snake_name}_value_object.py",
|
428
|
+
"external_service_file": f"{snake_name}_external_service.py",
|
429
|
+
# Resource naming (for APIs)
|
430
|
+
"resource_name": snake_name,
|
431
|
+
"resource_name_plural": pluralize(snake_name),
|
432
|
+
# Descriptions
|
433
|
+
"entity_description": f"{snake_name.replace('_', ' ')}",
|
434
|
+
"service_description": f"{snake_name.replace('_', ' ')} operations",
|
435
|
+
"module_description": f"{module_snake.replace('_', ' ')} module",
|
436
|
+
# Import paths (for better import management)
|
437
|
+
"domain_import_path": f"{system_snake}.{module_snake}.domain",
|
438
|
+
"application_import_path": f"{system_snake}.{module_snake}.application",
|
439
|
+
"infrastructure_import_path": f"{system_snake}.{module_snake}.infrastructure",
|
440
|
+
"presentation_import_path": f"{system_snake}.{module_snake}.presentation",
|
441
|
+
# Relative imports
|
442
|
+
"entity_import": f"..domain.entities.{snake_name}",
|
443
|
+
"repository_import": f"..domain.repositories.{snake_name}_repository",
|
444
|
+
"service_import": f"..application.services.{snake_name}_service",
|
445
|
+
# Timestamp for file generation
|
446
|
+
"generated_at": generate_timestamp(),
|
447
|
+
"generator_version": "1.0.0",
|
448
|
+
# Additional naming patterns
|
449
|
+
"table_name": pluralize(snake_name),
|
450
|
+
"collection_name": pluralize(snake_name),
|
451
|
+
"endpoint_prefix": f"/{pluralize(snake_name.replace('_', '-'))}",
|
452
|
+
# Type hints
|
453
|
+
"entity_type": pascal_name,
|
454
|
+
"repository_type": f"{pascal_name}Repository",
|
455
|
+
"service_type": f"{pascal_name}Service",
|
456
|
+
}
|
457
|
+
|
458
|
+
# Add any additional variables
|
459
|
+
variables.update(kwargs)
|
460
|
+
|
461
|
+
return variables
|
462
|
+
|
463
|
+
|
464
|
+
def ensure_directory(path: Path) -> None:
|
465
|
+
"""Ensure directory exists, create if it doesn't."""
|
466
|
+
path.mkdir(parents=True, exist_ok=True)
|
467
|
+
|
468
|
+
|
469
|
+
def get_layer_from_path(path: str) -> Optional[str]:
|
470
|
+
"""Extract layer name from file path."""
|
471
|
+
layers = ["domain", "application", "infrastructure", "presentation"]
|
472
|
+
path_parts = Path(path).parts
|
473
|
+
|
474
|
+
for layer in layers:
|
475
|
+
if layer in path_parts:
|
476
|
+
return layer
|
477
|
+
|
478
|
+
return None
|
479
|
+
|
480
|
+
|
481
|
+
def get_component_type_from_path(path: str) -> Optional[str]:
|
482
|
+
"""Extract component type from file path."""
|
483
|
+
component_types = [
|
484
|
+
"entities",
|
485
|
+
"repositories",
|
486
|
+
"value_objects", # domain
|
487
|
+
"services",
|
488
|
+
"commands",
|
489
|
+
"queries", # application
|
490
|
+
"models",
|
491
|
+
"external",
|
492
|
+
"internal", # infrastructure
|
493
|
+
"api",
|
494
|
+
"schemas", # presentation
|
495
|
+
]
|
496
|
+
|
497
|
+
path_parts = Path(path).parts
|
498
|
+
|
499
|
+
for comp_type in component_types:
|
500
|
+
if comp_type in path_parts:
|
501
|
+
return comp_type
|
502
|
+
|
503
|
+
return None
|
504
|
+
|
505
|
+
|
506
|
+
def parse_location_path(location: str) -> dict[str, str]:
|
507
|
+
"""Parse location path to extract system, module, layer, and component type.
|
508
|
+
|
509
|
+
Args:
|
510
|
+
location: Path like 'user_management/authentication/domain/entities'
|
511
|
+
|
512
|
+
Returns:
|
513
|
+
Dict with keys: system_name, module_name, layer, component_type
|
514
|
+
"""
|
515
|
+
from .exceptions import ValidationError
|
516
|
+
|
517
|
+
path_parts = Path(location).parts
|
518
|
+
|
519
|
+
if len(path_parts) != 4:
|
520
|
+
raise ValidationError(
|
521
|
+
f"Location must be in format: {{system}}/{{module}}/{{layer}}/{{component_type}}"
|
522
|
+
)
|
523
|
+
|
524
|
+
system_name = path_parts[0]
|
525
|
+
module_name = path_parts[1]
|
526
|
+
layer = path_parts[2]
|
527
|
+
component_type = path_parts[3]
|
528
|
+
|
529
|
+
# Validate layer
|
530
|
+
valid_layers = ["domain", "application", "infrastructure", "presentation"]
|
531
|
+
if layer not in valid_layers:
|
532
|
+
raise ValidationError(f"Invalid layer: {layer}. Must be one of {valid_layers}")
|
533
|
+
|
534
|
+
# Validate component type based on layer
|
535
|
+
layer_components = {
|
536
|
+
"domain": ["entities", "repositories", "value_objects"],
|
537
|
+
"application": ["services", "commands", "queries"],
|
538
|
+
"infrastructure": ["models", "repositories", "external", "internal"],
|
539
|
+
"presentation": ["api", "schemas"],
|
540
|
+
}
|
541
|
+
|
542
|
+
if component_type not in layer_components[layer]:
|
543
|
+
raise ValidationError(
|
544
|
+
f"Invalid component type '{component_type}' for layer '{layer}'. "
|
545
|
+
f"Valid types: {layer_components[layer]}"
|
546
|
+
)
|
547
|
+
|
548
|
+
return {
|
549
|
+
"system_name": system_name,
|
550
|
+
"module_name": module_name,
|
551
|
+
"layer": layer,
|
552
|
+
"component_type": component_type,
|
553
|
+
}
|