django-cfg 1.4.10__py3-none-any.whl → 1.4.11__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.
- django_cfg/apps/agents/management/commands/create_agent.py +1 -1
- django_cfg/apps/agents/management/commands/orchestrator_status.py +3 -3
- django_cfg/apps/newsletter/serializers.py +40 -3
- django_cfg/apps/newsletter/views/campaigns.py +12 -3
- django_cfg/apps/newsletter/views/emails.py +14 -3
- django_cfg/apps/newsletter/views/subscriptions.py +12 -2
- django_cfg/apps/payments/views/api/currencies.py +49 -6
- django_cfg/apps/payments/views/api/webhooks.py +72 -7
- django_cfg/apps/payments/views/overview/serializers.py +34 -1
- django_cfg/apps/payments/views/overview/views.py +2 -1
- django_cfg/apps/payments/views/serializers/payments.py +6 -6
- django_cfg/apps/urls.py +106 -45
- django_cfg/core/base/config_model.py +2 -2
- django_cfg/core/constants.py +1 -1
- django_cfg/core/generation/integration_generators/__init__.py +1 -1
- django_cfg/core/generation/integration_generators/api.py +72 -49
- django_cfg/core/integration/display/startup.py +30 -22
- django_cfg/core/integration/url_integration.py +15 -16
- django_cfg/dashboard/sections/documentation.py +391 -0
- django_cfg/management/commands/check_endpoints.py +11 -160
- django_cfg/management/commands/check_settings.py +13 -348
- django_cfg/management/commands/clear_constance.py +13 -201
- django_cfg/management/commands/create_token.py +13 -321
- django_cfg/management/commands/generate_clients.py +23 -0
- django_cfg/management/commands/list_urls.py +13 -306
- django_cfg/management/commands/migrate_all.py +13 -126
- django_cfg/management/commands/migrator.py +13 -396
- django_cfg/management/commands/rundramatiq.py +15 -247
- django_cfg/management/commands/rundramatiq_simulator.py +12 -429
- django_cfg/management/commands/runserver_ngrok.py +15 -160
- django_cfg/management/commands/script.py +12 -488
- django_cfg/management/commands/show_config.py +12 -215
- django_cfg/management/commands/show_urls.py +12 -342
- django_cfg/management/commands/superuser.py +15 -295
- django_cfg/management/commands/task_clear.py +14 -217
- django_cfg/management/commands/task_status.py +13 -248
- django_cfg/management/commands/test_email.py +15 -86
- django_cfg/management/commands/test_telegram.py +14 -61
- django_cfg/management/commands/test_twilio.py +15 -105
- django_cfg/management/commands/tree.py +13 -383
- django_cfg/management/commands/validate_openapi.py +10 -0
- django_cfg/middleware/README.md +1 -1
- django_cfg/middleware/user_activity.py +3 -3
- django_cfg/models/__init__.py +2 -2
- django_cfg/models/api/drf/spectacular.py +6 -6
- django_cfg/models/django/__init__.py +2 -2
- django_cfg/models/django/openapi.py +238 -0
- django_cfg/modules/django_admin/management/__init__.py +0 -0
- django_cfg/modules/django_admin/management/commands/__init__.py +0 -0
- django_cfg/modules/django_admin/management/commands/check_endpoints.py +169 -0
- django_cfg/modules/django_admin/management/commands/check_settings.py +355 -0
- django_cfg/modules/django_admin/management/commands/clear_constance.py +208 -0
- django_cfg/modules/django_admin/management/commands/create_token.py +328 -0
- django_cfg/modules/django_admin/management/commands/list_urls.py +313 -0
- django_cfg/modules/django_admin/management/commands/migrate_all.py +133 -0
- django_cfg/modules/django_admin/management/commands/migrator.py +403 -0
- django_cfg/modules/django_admin/management/commands/script.py +496 -0
- django_cfg/modules/django_admin/management/commands/show_config.py +225 -0
- django_cfg/modules/django_admin/management/commands/show_urls.py +361 -0
- django_cfg/modules/django_admin/management/commands/superuser.py +302 -0
- django_cfg/modules/django_admin/management/commands/tree.py +390 -0
- django_cfg/modules/django_client/__init__.py +20 -0
- django_cfg/modules/django_client/apps.py +35 -0
- django_cfg/modules/django_client/core/__init__.py +56 -0
- django_cfg/modules/django_client/core/archive/__init__.py +11 -0
- django_cfg/modules/django_client/core/archive/manager.py +134 -0
- django_cfg/modules/django_client/core/cli/__init__.py +12 -0
- django_cfg/modules/django_client/core/cli/main.py +235 -0
- django_cfg/modules/django_client/core/config/__init__.py +18 -0
- django_cfg/modules/django_client/core/config/config.py +188 -0
- django_cfg/modules/django_client/core/config/group.py +101 -0
- django_cfg/modules/django_client/core/config/service.py +209 -0
- django_cfg/modules/django_client/core/generator/__init__.py +115 -0
- django_cfg/modules/django_client/core/generator/base.py +767 -0
- django_cfg/modules/django_client/core/generator/python.py +751 -0
- django_cfg/modules/django_client/core/generator/templates/python/__init__.py.jinja +9 -0
- django_cfg/modules/django_client/core/generator/templates/python/api_wrapper.py.jinja +130 -0
- django_cfg/modules/django_client/core/generator/templates/python/app_init.py.jinja +6 -0
- django_cfg/modules/django_client/core/generator/templates/python/client/app_client.py.jinja +18 -0
- django_cfg/modules/django_client/core/generator/templates/python/client/flat_client.py.jinja +38 -0
- django_cfg/modules/django_client/core/generator/templates/python/client/main_client.py.jinja +50 -0
- django_cfg/modules/django_client/core/generator/templates/python/client/main_client_file.py.jinja +13 -0
- django_cfg/modules/django_client/core/generator/templates/python/client/operation_method.py.jinja +7 -0
- django_cfg/modules/django_client/core/generator/templates/python/client/sub_client.py.jinja +11 -0
- django_cfg/modules/django_client/core/generator/templates/python/client_file.py.jinja +13 -0
- django_cfg/modules/django_client/core/generator/templates/python/main_init.py.jinja +50 -0
- django_cfg/modules/django_client/core/generator/templates/python/models/app_models.py.jinja +17 -0
- django_cfg/modules/django_client/core/generator/templates/python/models/enum_class.py.jinja +15 -0
- django_cfg/modules/django_client/core/generator/templates/python/models/enums.py.jinja +8 -0
- django_cfg/modules/django_client/core/generator/templates/python/models/models.py.jinja +17 -0
- django_cfg/modules/django_client/core/generator/templates/python/models/schema_class.py.jinja +19 -0
- django_cfg/modules/django_client/core/generator/templates/python/utils/logger.py.jinja +255 -0
- django_cfg/modules/django_client/core/generator/templates/python/utils/schema.py.jinja +12 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/app_index.ts.jinja +2 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/client/app_client.ts.jinja +18 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/client/client.ts.jinja +327 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/client/flat_client.ts.jinja +109 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/client/main_client_file.ts.jinja +9 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/client/operation.ts.jinja +61 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/client/sub_client.ts.jinja +15 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/client_file.ts.jinja +9 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/index.ts.jinja +5 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/main_index.ts.jinja +206 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/models/app_models.ts.jinja +8 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/models/enums.ts.jinja +4 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/models/models.ts.jinja +8 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/utils/errors.ts.jinja +114 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/utils/http.ts.jinja +98 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/utils/logger.ts.jinja +251 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/utils/schema.ts.jinja +7 -0
- django_cfg/modules/django_client/core/generator/templates/typescript/utils/storage.ts.jinja +114 -0
- django_cfg/modules/django_client/core/generator/typescript.py +872 -0
- django_cfg/modules/django_client/core/groups/__init__.py +13 -0
- django_cfg/modules/django_client/core/groups/detector.py +178 -0
- django_cfg/modules/django_client/core/groups/manager.py +314 -0
- django_cfg/modules/django_client/core/ir/__init__.py +57 -0
- django_cfg/modules/django_client/core/ir/context.py +387 -0
- django_cfg/modules/django_client/core/ir/operation.py +518 -0
- django_cfg/modules/django_client/core/ir/schema.py +353 -0
- django_cfg/modules/django_client/core/parser/__init__.py +74 -0
- django_cfg/modules/django_client/core/parser/base.py +648 -0
- django_cfg/modules/django_client/core/parser/models/__init__.py +74 -0
- django_cfg/modules/django_client/core/parser/models/base.py +212 -0
- django_cfg/modules/django_client/core/parser/models/components.py +160 -0
- django_cfg/modules/django_client/core/parser/models/openapi.py +203 -0
- django_cfg/modules/django_client/core/parser/models/operation.py +207 -0
- django_cfg/modules/django_client/core/parser/models/schema.py +266 -0
- django_cfg/modules/django_client/core/parser/openapi30.py +56 -0
- django_cfg/modules/django_client/core/parser/openapi31.py +64 -0
- django_cfg/modules/django_client/core/validation/__init__.py +22 -0
- django_cfg/modules/django_client/core/validation/checker.py +134 -0
- django_cfg/modules/django_client/core/validation/fixer.py +216 -0
- django_cfg/modules/django_client/core/validation/reporter.py +480 -0
- django_cfg/modules/django_client/core/validation/rules/__init__.py +11 -0
- django_cfg/modules/django_client/core/validation/rules/base.py +96 -0
- django_cfg/modules/django_client/core/validation/rules/type_hints.py +288 -0
- django_cfg/modules/django_client/core/validation/safety.py +266 -0
- django_cfg/modules/django_client/management/__init__.py +3 -0
- django_cfg/modules/django_client/management/commands/__init__.py +3 -0
- django_cfg/modules/django_client/management/commands/generate_client.py +422 -0
- django_cfg/modules/django_client/management/commands/validate_openapi.py +343 -0
- django_cfg/modules/django_client/spectacular/__init__.py +9 -0
- django_cfg/modules/django_client/spectacular/enum_naming.py +192 -0
- django_cfg/modules/django_client/urls.py +72 -0
- django_cfg/modules/django_email/management/__init__.py +0 -0
- django_cfg/modules/django_email/management/commands/__init__.py +0 -0
- django_cfg/modules/django_email/management/commands/test_email.py +93 -0
- django_cfg/modules/django_logging/django_logger.py +6 -6
- django_cfg/modules/django_ngrok/management/__init__.py +0 -0
- django_cfg/modules/django_ngrok/management/commands/__init__.py +0 -0
- django_cfg/modules/django_ngrok/management/commands/runserver_ngrok.py +167 -0
- django_cfg/modules/django_tasks/management/__init__.py +0 -0
- django_cfg/modules/django_tasks/management/commands/__init__.py +0 -0
- django_cfg/modules/django_tasks/management/commands/rundramatiq.py +254 -0
- django_cfg/modules/django_tasks/management/commands/rundramatiq_simulator.py +437 -0
- django_cfg/modules/django_tasks/management/commands/task_clear.py +226 -0
- django_cfg/modules/django_tasks/management/commands/task_status.py +257 -0
- django_cfg/modules/django_telegram/management/__init__.py +0 -0
- django_cfg/modules/django_telegram/management/commands/__init__.py +0 -0
- django_cfg/modules/django_telegram/management/commands/test_telegram.py +68 -0
- django_cfg/modules/django_twilio/management/__init__.py +0 -0
- django_cfg/modules/django_twilio/management/commands/__init__.py +0 -0
- django_cfg/modules/django_twilio/management/commands/test_twilio.py +112 -0
- django_cfg/modules/django_unfold/callbacks/main.py +16 -5
- django_cfg/modules/django_unfold/callbacks/revolution.py +41 -36
- django_cfg/pyproject.toml +2 -6
- django_cfg/registry/third_party.py +5 -7
- django_cfg/routing/callbacks.py +1 -1
- django_cfg/static/admin/css/prose-unfold.css +666 -0
- django_cfg/templates/admin/index.html +8 -0
- django_cfg/templates/admin/index_new.html +13 -0
- django_cfg/templates/admin/layouts/dashboard_with_tabs.html +15 -3
- django_cfg/templates/admin/sections/documentation_section.html +172 -0
- django_cfg/templates/admin/snippets/tabs/documentation_tab.html +231 -0
- {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/METADATA +2 -2
- {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/RECORD +180 -59
- django_cfg/management/commands/generate.py +0 -107
- /django_cfg/models/django/{revolution.py → revolution_legacy.py} +0 -0
- {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/WHEEL +0 -0
- {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,288 @@
|
|
1
|
+
"""Type hint validation rule for SerializerMethodField."""
|
2
|
+
|
3
|
+
import ast
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import List
|
6
|
+
|
7
|
+
from .base import Issue, Severity, ValidationRule
|
8
|
+
|
9
|
+
|
10
|
+
class TypeHintRule(ValidationRule):
|
11
|
+
"""
|
12
|
+
Add type hints to SerializerMethodField methods.
|
13
|
+
|
14
|
+
Checks: get_* methods without return type annotations
|
15
|
+
Fixes: Adds inferred type hints based on method name patterns
|
16
|
+
"""
|
17
|
+
|
18
|
+
@property
|
19
|
+
def rule_id(self) -> str:
|
20
|
+
return "type-hint-001"
|
21
|
+
|
22
|
+
@property
|
23
|
+
def name(self) -> str:
|
24
|
+
return "SerializerMethodField Type Hints"
|
25
|
+
|
26
|
+
@property
|
27
|
+
def description(self) -> str:
|
28
|
+
return "Ensures all get_* methods have return type hints"
|
29
|
+
|
30
|
+
def check(self, file_path: Path) -> List[Issue]:
|
31
|
+
"""Find methods missing type hints."""
|
32
|
+
issues = []
|
33
|
+
|
34
|
+
try:
|
35
|
+
content = file_path.read_text(encoding='utf-8')
|
36
|
+
tree = ast.parse(content)
|
37
|
+
except (SyntaxError, UnicodeDecodeError):
|
38
|
+
# Skip files with syntax errors or encoding issues
|
39
|
+
return []
|
40
|
+
|
41
|
+
for node in ast.walk(tree):
|
42
|
+
if not isinstance(node, ast.FunctionDef):
|
43
|
+
continue
|
44
|
+
|
45
|
+
# Only process get_* methods
|
46
|
+
if not node.name.startswith('get_'):
|
47
|
+
continue
|
48
|
+
|
49
|
+
# Skip if already has type hint
|
50
|
+
if node.returns is not None:
|
51
|
+
continue
|
52
|
+
|
53
|
+
# Skip private methods
|
54
|
+
if node.name.startswith('_get_'):
|
55
|
+
continue
|
56
|
+
|
57
|
+
# Infer type hint
|
58
|
+
inferred_type = self._infer_type(node)
|
59
|
+
|
60
|
+
issues.append(Issue(
|
61
|
+
rule_id=self.rule_id,
|
62
|
+
severity=Severity.WARNING,
|
63
|
+
file=file_path,
|
64
|
+
line=node.lineno,
|
65
|
+
column=node.col_offset,
|
66
|
+
message=f"Method {node.name}() missing return type hint",
|
67
|
+
suggestion=f"Add: -> {inferred_type}",
|
68
|
+
auto_fixable=True,
|
69
|
+
context={
|
70
|
+
'method_name': node.name,
|
71
|
+
'inferred_type': inferred_type,
|
72
|
+
'line_number': node.lineno,
|
73
|
+
}
|
74
|
+
))
|
75
|
+
|
76
|
+
return issues
|
77
|
+
|
78
|
+
def can_fix(self, issue: Issue) -> bool:
|
79
|
+
"""All type hint issues are auto-fixable."""
|
80
|
+
return issue.auto_fixable and issue.rule_id == self.rule_id
|
81
|
+
|
82
|
+
def fix(self, issue: Issue, skip_imports: bool = False) -> bool:
|
83
|
+
"""
|
84
|
+
Add type hint to method.
|
85
|
+
|
86
|
+
Args:
|
87
|
+
issue: Issue to fix
|
88
|
+
skip_imports: If True, skip adding imports (for batch processing)
|
89
|
+
"""
|
90
|
+
file_path = issue.file
|
91
|
+
inferred_type = issue.context['inferred_type']
|
92
|
+
line_number = issue.context['line_number']
|
93
|
+
|
94
|
+
try:
|
95
|
+
# Read file
|
96
|
+
content = file_path.read_text(encoding='utf-8')
|
97
|
+
lines = content.splitlines(keepends=True)
|
98
|
+
|
99
|
+
# Ensure line exists
|
100
|
+
if line_number > len(lines):
|
101
|
+
return False
|
102
|
+
|
103
|
+
# Find method definition line
|
104
|
+
target_line = lines[line_number - 1]
|
105
|
+
|
106
|
+
# Add type hint (preserve indentation and formatting)
|
107
|
+
if ')' in target_line and ':' in target_line:
|
108
|
+
# def method(self, obj): → def method(self, obj) -> Type:
|
109
|
+
modified_line = target_line.replace('):', f") -> {inferred_type}:", 1)
|
110
|
+
lines[line_number - 1] = modified_line
|
111
|
+
|
112
|
+
# Add imports if needed (unless skipping for batch mode)
|
113
|
+
if not skip_imports:
|
114
|
+
imports_needed = self._get_required_imports(inferred_type)
|
115
|
+
if imports_needed:
|
116
|
+
lines = self._add_imports(lines, imports_needed)
|
117
|
+
|
118
|
+
# Write back
|
119
|
+
file_path.write_text(''.join(lines), encoding='utf-8')
|
120
|
+
return True
|
121
|
+
|
122
|
+
except Exception as e:
|
123
|
+
print(f"Error fixing {issue.context['method_name']}: {e}")
|
124
|
+
return False
|
125
|
+
|
126
|
+
return False
|
127
|
+
|
128
|
+
def fix_batch(self, issues: list[Issue]) -> bool:
|
129
|
+
"""
|
130
|
+
Fix multiple issues in same file at once.
|
131
|
+
This prevents line number shifting from import additions.
|
132
|
+
"""
|
133
|
+
if not issues:
|
134
|
+
return True
|
135
|
+
|
136
|
+
file_path = issues[0].file
|
137
|
+
|
138
|
+
try:
|
139
|
+
# Read file once
|
140
|
+
content = file_path.read_text(encoding='utf-8')
|
141
|
+
lines = content.splitlines(keepends=True)
|
142
|
+
|
143
|
+
# Sort issues by line number in reverse order
|
144
|
+
sorted_issues = sorted(issues, key=lambda i: i.context['line_number'], reverse=True)
|
145
|
+
|
146
|
+
# Collect all required imports
|
147
|
+
all_imports = set()
|
148
|
+
|
149
|
+
# Fix all methods (from bottom to top)
|
150
|
+
for issue in sorted_issues:
|
151
|
+
inferred_type = issue.context['inferred_type']
|
152
|
+
line_number = issue.context['line_number']
|
153
|
+
|
154
|
+
if line_number > len(lines):
|
155
|
+
continue
|
156
|
+
|
157
|
+
target_line = lines[line_number - 1]
|
158
|
+
|
159
|
+
if ')' in target_line and ':' in target_line:
|
160
|
+
# Add type hint
|
161
|
+
modified_line = target_line.replace('):', f") -> {inferred_type}:", 1)
|
162
|
+
lines[line_number - 1] = modified_line
|
163
|
+
|
164
|
+
# Collect imports
|
165
|
+
imports_needed = self._get_required_imports(inferred_type)
|
166
|
+
for imp in imports_needed:
|
167
|
+
all_imports.add(imp)
|
168
|
+
|
169
|
+
# Add all imports at once
|
170
|
+
if all_imports:
|
171
|
+
lines = self._add_imports(lines, list(all_imports))
|
172
|
+
|
173
|
+
# Write back once
|
174
|
+
file_path.write_text(''.join(lines), encoding='utf-8')
|
175
|
+
return True
|
176
|
+
|
177
|
+
except Exception as e:
|
178
|
+
print(f"Error in batch fix: {e}")
|
179
|
+
return False
|
180
|
+
|
181
|
+
def _infer_type(self, node: ast.FunctionDef) -> str:
|
182
|
+
"""Infer return type from method name and body."""
|
183
|
+
name = node.name
|
184
|
+
|
185
|
+
# Pattern matching for common cases
|
186
|
+
if any(pattern in name for pattern in ['can_', 'is_', 'has_']):
|
187
|
+
return 'bool'
|
188
|
+
|
189
|
+
if name.endswith('_count') or name.endswith('_total'):
|
190
|
+
return 'int'
|
191
|
+
|
192
|
+
if any(pattern in name for pattern in ['get_children', 'get_items', 'get_replies', 'get_list']):
|
193
|
+
return 'List[Dict[str, Any]]'
|
194
|
+
|
195
|
+
if name.endswith('_display') or name.endswith('_text'):
|
196
|
+
return 'str'
|
197
|
+
|
198
|
+
# Analyze return statements
|
199
|
+
for child in ast.walk(node):
|
200
|
+
if isinstance(child, ast.Return) and child.value:
|
201
|
+
# Constant values
|
202
|
+
if isinstance(child.value, ast.Constant):
|
203
|
+
if isinstance(child.value.value, bool):
|
204
|
+
return 'bool'
|
205
|
+
elif isinstance(child.value.value, int):
|
206
|
+
return 'int'
|
207
|
+
elif isinstance(child.value.value, str):
|
208
|
+
return 'str'
|
209
|
+
elif child.value.value is None:
|
210
|
+
return 'Optional[Any]'
|
211
|
+
|
212
|
+
# List/Dict literals
|
213
|
+
elif isinstance(child.value, ast.List):
|
214
|
+
return 'List[Any]'
|
215
|
+
elif isinstance(child.value, ast.Dict):
|
216
|
+
return 'Dict[str, Any]'
|
217
|
+
|
218
|
+
# Method calls that suggest serializer
|
219
|
+
elif isinstance(child.value, ast.Call):
|
220
|
+
# SomeSerializer(...).data pattern
|
221
|
+
if hasattr(child.value.func, 'attr') and child.value.func.attr == 'data':
|
222
|
+
# Check if many=True in call
|
223
|
+
for keyword in getattr(child.value, 'keywords', []):
|
224
|
+
if keyword.arg == 'many' and isinstance(keyword.value, ast.Constant):
|
225
|
+
if keyword.value.value is True:
|
226
|
+
return 'List[Dict[str, Any]]'
|
227
|
+
return 'Dict[str, Any]'
|
228
|
+
|
229
|
+
# Default fallback
|
230
|
+
return 'Any'
|
231
|
+
|
232
|
+
def _get_required_imports(self, type_hint: str) -> List[str]:
|
233
|
+
"""Get required imports for type hint."""
|
234
|
+
imports = set()
|
235
|
+
|
236
|
+
if 'List' in type_hint:
|
237
|
+
imports.add('List')
|
238
|
+
if 'Dict' in type_hint:
|
239
|
+
imports.add('Dict')
|
240
|
+
if 'Optional' in type_hint:
|
241
|
+
imports.add('Optional')
|
242
|
+
if 'Any' in type_hint or not imports:
|
243
|
+
imports.add('Any')
|
244
|
+
|
245
|
+
if imports:
|
246
|
+
return [f"from typing import {', '.join(sorted(imports))}"]
|
247
|
+
return []
|
248
|
+
|
249
|
+
def _add_imports(self, lines: List[str], imports: List[str]) -> List[str]:
|
250
|
+
"""Add imports to file if not already present."""
|
251
|
+
content = ''.join(lines)
|
252
|
+
|
253
|
+
for import_line in imports:
|
254
|
+
# Skip if import already exists
|
255
|
+
if import_line in content:
|
256
|
+
continue
|
257
|
+
|
258
|
+
# Check if similar import exists
|
259
|
+
import_parts = set(import_line.split('import ')[1].replace(' ', '').split(','))
|
260
|
+
|
261
|
+
# Try to merge with existing typing imports
|
262
|
+
merged = False
|
263
|
+
for i, line in enumerate(lines):
|
264
|
+
if line.strip().startswith('from typing import'):
|
265
|
+
existing_parts = set(line.split('import ')[1].replace(' ', '').replace('\n', '').split(','))
|
266
|
+
combined = sorted(existing_parts | import_parts)
|
267
|
+
lines[i] = f"from typing import {', '.join(combined)}\n"
|
268
|
+
merged = True
|
269
|
+
break
|
270
|
+
|
271
|
+
if merged:
|
272
|
+
continue
|
273
|
+
|
274
|
+
# Find where to insert (after other imports)
|
275
|
+
insert_idx = 0
|
276
|
+
found_import = False
|
277
|
+
for i, line in enumerate(lines):
|
278
|
+
stripped = line.strip()
|
279
|
+
if stripped.startswith('import ') or stripped.startswith('from '):
|
280
|
+
found_import = True
|
281
|
+
insert_idx = i + 1
|
282
|
+
elif found_import and stripped and not stripped.startswith('#'):
|
283
|
+
# Found first non-import, non-comment line
|
284
|
+
break
|
285
|
+
|
286
|
+
lines.insert(insert_idx, import_line + '\n')
|
287
|
+
|
288
|
+
return lines
|
@@ -0,0 +1,266 @@
|
|
1
|
+
"""
|
2
|
+
Safety Manager for Code Modifications.
|
3
|
+
|
4
|
+
Ensures all code modifications are:
|
5
|
+
- Backed up before changes
|
6
|
+
- Syntax-validated after changes
|
7
|
+
- Revertible via rollback
|
8
|
+
- Logged for audit trail
|
9
|
+
"""
|
10
|
+
|
11
|
+
import ast
|
12
|
+
import shutil
|
13
|
+
from datetime import datetime, timedelta
|
14
|
+
from pathlib import Path
|
15
|
+
from typing import Dict, List, Optional
|
16
|
+
import logging
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
class SafetyManager:
|
22
|
+
"""
|
23
|
+
Manages backups and rollbacks for safe code modification.
|
24
|
+
|
25
|
+
Example:
|
26
|
+
>>> safety = SafetyManager(workspace=Path('.'))
|
27
|
+
>>> transaction_id = safety.start_transaction()
|
28
|
+
>>> safety.backup_file(Path('serializers.py'))
|
29
|
+
>>> # ... modify file ...
|
30
|
+
>>> if safety.validate_syntax(Path('serializers.py')):
|
31
|
+
... safety.commit_transaction()
|
32
|
+
... else:
|
33
|
+
... safety.rollback_transaction()
|
34
|
+
"""
|
35
|
+
|
36
|
+
def __init__(self, workspace: Path):
|
37
|
+
"""
|
38
|
+
Initialize safety manager.
|
39
|
+
|
40
|
+
Args:
|
41
|
+
workspace: Root directory for project (usually project root)
|
42
|
+
"""
|
43
|
+
self.workspace = workspace
|
44
|
+
self.backup_dir = workspace / '.validation_backups'
|
45
|
+
self.transaction_id: Optional[str] = None
|
46
|
+
self.backed_up_files: Dict[Path, Path] = {}
|
47
|
+
|
48
|
+
# Ensure backup directory exists
|
49
|
+
self.backup_dir.mkdir(exist_ok=True)
|
50
|
+
|
51
|
+
def start_transaction(self) -> str:
|
52
|
+
"""
|
53
|
+
Start a new modification transaction.
|
54
|
+
|
55
|
+
Returns:
|
56
|
+
Transaction ID (timestamp-based)
|
57
|
+
"""
|
58
|
+
self.transaction_id = f"fix_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
|
59
|
+
self.backed_up_files = {}
|
60
|
+
|
61
|
+
logger.info(f"Started transaction: {self.transaction_id}")
|
62
|
+
return self.transaction_id
|
63
|
+
|
64
|
+
def backup_file(self, file_path: Path) -> Path:
|
65
|
+
"""
|
66
|
+
Create backup of file before modification.
|
67
|
+
|
68
|
+
Args:
|
69
|
+
file_path: Path to file to backup
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
Path to backup file
|
73
|
+
|
74
|
+
Raises:
|
75
|
+
RuntimeError: If no active transaction
|
76
|
+
FileNotFoundError: If file doesn't exist
|
77
|
+
"""
|
78
|
+
if not self.transaction_id:
|
79
|
+
raise RuntimeError("No active transaction. Call start_transaction() first.")
|
80
|
+
|
81
|
+
if not file_path.exists():
|
82
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
83
|
+
|
84
|
+
# Create transaction backup directory
|
85
|
+
transaction_dir = self.backup_dir / self.transaction_id
|
86
|
+
transaction_dir.mkdir(parents=True, exist_ok=True)
|
87
|
+
|
88
|
+
# Create backup with relative path structure
|
89
|
+
relative_path = file_path.relative_to(self.workspace)
|
90
|
+
backup_path = transaction_dir / relative_path
|
91
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
92
|
+
|
93
|
+
# Copy file
|
94
|
+
shutil.copy2(file_path, backup_path)
|
95
|
+
self.backed_up_files[file_path] = backup_path
|
96
|
+
|
97
|
+
logger.debug(f"Backed up: {file_path} → {backup_path}")
|
98
|
+
return backup_path
|
99
|
+
|
100
|
+
def validate_syntax(self, file_path: Path) -> bool:
|
101
|
+
"""
|
102
|
+
Validate Python syntax after modification.
|
103
|
+
|
104
|
+
Args:
|
105
|
+
file_path: Path to Python file to validate
|
106
|
+
|
107
|
+
Returns:
|
108
|
+
True if syntax is valid, False otherwise
|
109
|
+
"""
|
110
|
+
try:
|
111
|
+
content = file_path.read_text(encoding='utf-8')
|
112
|
+
ast.parse(content)
|
113
|
+
logger.debug(f"Syntax valid: {file_path}")
|
114
|
+
return True
|
115
|
+
except SyntaxError as e:
|
116
|
+
logger.error(f"Syntax error in {file_path}: {e}")
|
117
|
+
return False
|
118
|
+
except Exception as e:
|
119
|
+
logger.error(f"Error validating {file_path}: {e}")
|
120
|
+
return False
|
121
|
+
|
122
|
+
def rollback_file(self, file_path: Path) -> bool:
|
123
|
+
"""
|
124
|
+
Rollback a single file to its backup.
|
125
|
+
|
126
|
+
Args:
|
127
|
+
file_path: Path to file to rollback
|
128
|
+
|
129
|
+
Returns:
|
130
|
+
True if rollback successful, False otherwise
|
131
|
+
"""
|
132
|
+
backup_path = self.backed_up_files.get(file_path)
|
133
|
+
if not backup_path or not backup_path.exists():
|
134
|
+
logger.warning(f"No backup found for: {file_path}")
|
135
|
+
return False
|
136
|
+
|
137
|
+
try:
|
138
|
+
shutil.copy2(backup_path, file_path)
|
139
|
+
logger.info(f"Rolled back: {file_path}")
|
140
|
+
return True
|
141
|
+
except Exception as e:
|
142
|
+
logger.error(f"Error rolling back {file_path}: {e}")
|
143
|
+
return False
|
144
|
+
|
145
|
+
def commit_transaction(self) -> bool:
|
146
|
+
"""
|
147
|
+
Finalize successful transaction.
|
148
|
+
|
149
|
+
Keeps backups for 7 days then schedules cleanup.
|
150
|
+
|
151
|
+
Returns:
|
152
|
+
True if commit successful
|
153
|
+
"""
|
154
|
+
if not self.transaction_id:
|
155
|
+
logger.warning("No active transaction to commit")
|
156
|
+
return False
|
157
|
+
|
158
|
+
logger.info(f"Committed transaction: {self.transaction_id}")
|
159
|
+
|
160
|
+
# Schedule cleanup (delete backups after 7 days)
|
161
|
+
self._schedule_cleanup(days=7)
|
162
|
+
|
163
|
+
# Clear transaction state
|
164
|
+
transaction_id = self.transaction_id
|
165
|
+
self.transaction_id = None
|
166
|
+
self.backed_up_files = {}
|
167
|
+
|
168
|
+
return True
|
169
|
+
|
170
|
+
def rollback_transaction(self) -> bool:
|
171
|
+
"""
|
172
|
+
Revert all changes in current transaction.
|
173
|
+
|
174
|
+
Returns:
|
175
|
+
True if all files rolled back successfully
|
176
|
+
"""
|
177
|
+
if not self.transaction_id:
|
178
|
+
logger.warning("No active transaction to rollback")
|
179
|
+
return False
|
180
|
+
|
181
|
+
logger.warning(f"Rolling back transaction: {self.transaction_id}")
|
182
|
+
|
183
|
+
success = True
|
184
|
+
for file_path in self.backed_up_files.keys():
|
185
|
+
if not self.rollback_file(file_path):
|
186
|
+
success = False
|
187
|
+
|
188
|
+
# Clear transaction state
|
189
|
+
self.transaction_id = None
|
190
|
+
self.backed_up_files = {}
|
191
|
+
|
192
|
+
return success
|
193
|
+
|
194
|
+
def _schedule_cleanup(self, days: int = 7) -> None:
|
195
|
+
"""
|
196
|
+
Schedule cleanup of old backups.
|
197
|
+
|
198
|
+
Note: Currently just marks with timestamp.
|
199
|
+
Actual cleanup should be done by separate process.
|
200
|
+
|
201
|
+
Args:
|
202
|
+
days: Keep backups for this many days
|
203
|
+
"""
|
204
|
+
cleanup_marker = self.backup_dir / self.transaction_id / '.cleanup_after'
|
205
|
+
cleanup_date = datetime.now() + timedelta(days=days)
|
206
|
+
cleanup_marker.write_text(cleanup_date.isoformat())
|
207
|
+
|
208
|
+
def cleanup_old_backups(self, days: int = 7) -> int:
|
209
|
+
"""
|
210
|
+
Remove backups older than specified days.
|
211
|
+
|
212
|
+
Args:
|
213
|
+
days: Remove backups older than this many days
|
214
|
+
|
215
|
+
Returns:
|
216
|
+
Number of backup directories removed
|
217
|
+
"""
|
218
|
+
removed = 0
|
219
|
+
cutoff_date = datetime.now() - timedelta(days=days)
|
220
|
+
|
221
|
+
for transaction_dir in self.backup_dir.iterdir():
|
222
|
+
if not transaction_dir.is_dir():
|
223
|
+
continue
|
224
|
+
|
225
|
+
# Check if marked for cleanup
|
226
|
+
cleanup_marker = transaction_dir / '.cleanup_after'
|
227
|
+
if cleanup_marker.exists():
|
228
|
+
cleanup_date = datetime.fromisoformat(cleanup_marker.read_text())
|
229
|
+
if datetime.now() >= cleanup_date:
|
230
|
+
shutil.rmtree(transaction_dir)
|
231
|
+
removed += 1
|
232
|
+
logger.info(f"Removed old backup: {transaction_dir.name}")
|
233
|
+
|
234
|
+
return removed
|
235
|
+
|
236
|
+
def list_backups(self) -> List[Dict[str, any]]:
|
237
|
+
"""
|
238
|
+
List all available backups.
|
239
|
+
|
240
|
+
Returns:
|
241
|
+
List of backup info dicts
|
242
|
+
"""
|
243
|
+
backups = []
|
244
|
+
|
245
|
+
for transaction_dir in sorted(self.backup_dir.iterdir()):
|
246
|
+
if not transaction_dir.is_dir():
|
247
|
+
continue
|
248
|
+
|
249
|
+
# Parse transaction ID
|
250
|
+
timestamp_str = transaction_dir.name.replace('fix_', '')
|
251
|
+
try:
|
252
|
+
timestamp = datetime.strptime(timestamp_str[:15], '%Y%m%d_%H%M%S')
|
253
|
+
except ValueError:
|
254
|
+
continue
|
255
|
+
|
256
|
+
# Count files
|
257
|
+
files = list(transaction_dir.rglob('*.py'))
|
258
|
+
|
259
|
+
backups.append({
|
260
|
+
'transaction_id': transaction_dir.name,
|
261
|
+
'timestamp': timestamp,
|
262
|
+
'file_count': len(files),
|
263
|
+
'path': transaction_dir,
|
264
|
+
})
|
265
|
+
|
266
|
+
return backups
|