django-spire 0.16.13__py3-none-any.whl → 0.17.1__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_spire/consts.py +1 -1
- django_spire/core/management/commands/spire_startapp.py +84 -46
- django_spire/core/management/commands/spire_startapp_pkg/__init__.py +60 -0
- django_spire/core/management/commands/spire_startapp_pkg/builder.py +91 -0
- django_spire/core/management/commands/spire_startapp_pkg/config.py +115 -0
- django_spire/core/management/commands/spire_startapp_pkg/filesystem.py +125 -0
- django_spire/core/management/commands/spire_startapp_pkg/generator.py +167 -0
- django_spire/core/management/commands/spire_startapp_pkg/maps.py +783 -25
- django_spire/core/management/commands/spire_startapp_pkg/permissions.py +147 -0
- django_spire/core/management/commands/spire_startapp_pkg/processor.py +144 -57
- django_spire/core/management/commands/spire_startapp_pkg/registry.py +89 -0
- django_spire/core/management/commands/spire_startapp_pkg/reporter.py +245 -108
- django_spire/core/management/commands/spire_startapp_pkg/resolver.py +86 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/apps.py.template +15 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/forms.py.template +18 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/intelligence/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/intelligence/bots.py.template +18 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/intelligence/intel.py.template +7 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/intelligence/prompts.py.template +32 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/migrations/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/models.py.template +52 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/querysets.py.template +20 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/seeding/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/seeding/seed.py.template +6 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/seeding/seeder.py.template +26 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/services/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/services/factory_service.py.template +12 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/services/intelligence_service.py.template +12 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/services/processor_service.py.template +12 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/services/service.py.template +22 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/services/transformation_service.py.template +12 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_intelligence/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_intelligence/test_bots.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_models.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_factory_service.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_intelligence_service.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_processor_service.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_service.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_transformation_service.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_urls/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_urls/test_form_urls.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_urls/test_page_urls.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_views/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_views/test_form_views.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_views/test_page_views.py.template +8 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/__init__.py.template +9 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/form_urls.py.template +15 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/page_urls.py.template +11 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/views/__init__.py.template +0 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/views/form_views.py.template +134 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/views/page_views.py.template +44 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/{spirechildapp_detail_card.html → ${detail_card_template_name}.html.template} +7 -7
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/{spirechildapp_form_card.html → ${form_card_template_name}.html.template} +2 -2
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_card_template_name}.html.template +18 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/form/{spirechildapp_form.html → ${form_template_name}.html.template} +4 -4
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/${item_template_name}.html.template +24 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/{spirechildapp_detail_page.html → ${detail_page_template_name}.html.template} +3 -3
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/{spirechildapp_form_page.html → ${form_page_template_name}.html.template} +1 -1
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/{spirechildapp_list_page.html → ${list_page_template_name}.html.template} +2 -2
- django_spire/core/management/commands/spire_startapp_pkg/user_input.py +252 -0
- django_spire/core/management/commands/spire_startapp_pkg/validator.py +96 -0
- django_spire/core/middleware/__init__.py +1 -2
- django_spire/profiling/__init__.py +13 -0
- django_spire/profiling/middleware/__init__.py +6 -0
- django_spire/{core → profiling}/middleware/profiling.py +63 -58
- django_spire/profiling/panel.py +345 -0
- django_spire/profiling/templates/panel.html +166 -0
- {django_spire-0.16.13.dist-info → django_spire-0.17.1.dist-info}/METADATA +1 -1
- {django_spire-0.16.13.dist-info → django_spire-0.17.1.dist-info}/RECORD +75 -23
- django_spire/core/management/commands/spire_startapp_pkg/constants.py +0 -4
- django_spire/core/management/commands/spire_startapp_pkg/manager.py +0 -176
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/spirechildapp_list_card.html +0 -18
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/spirechildapp_item.html +0 -24
- {django_spire-0.16.13.dist-info → django_spire-0.17.1.dist-info}/WHEEL +0 -0
- {django_spire-0.16.13.dist-info → django_spire-0.17.1.dist-info}/licenses/LICENSE.md +0 -0
- {django_spire-0.16.13.dist-info → django_spire-0.17.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from typing_extensions import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from django.core.management.base import CommandError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from django_spire.core.management.commands.spire_startapp_pkg.reporter import Reporter
|
|
11
|
+
from django_spire.core.management.commands.spire_startapp_pkg.validator import AppValidator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UserInputCollector:
|
|
15
|
+
"""
|
|
16
|
+
Collects user input for Django app creation through an interactive wizard.
|
|
17
|
+
|
|
18
|
+
This class guides users through a step-by-step process to gather all
|
|
19
|
+
necessary configuration for creating a new Django app.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, reporter: Reporter, validator: AppValidator):
|
|
23
|
+
"""
|
|
24
|
+
Initializes the collector with a reporter and validator.
|
|
25
|
+
|
|
26
|
+
:param reporter: Reporter instance for displaying prompts and messages.
|
|
27
|
+
:param validator: Validator for checking user input validity.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
self.reporter = reporter
|
|
31
|
+
self.validator = validator
|
|
32
|
+
|
|
33
|
+
def collect_all_inputs(self) -> dict[str, str]:
|
|
34
|
+
"""
|
|
35
|
+
Collects all required user inputs for app creation.
|
|
36
|
+
|
|
37
|
+
Guides the user through an 8-step wizard to gather app path, names,
|
|
38
|
+
labels, and configuration options.
|
|
39
|
+
|
|
40
|
+
:return: Dictionary containing all collected user inputs.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
self.reporter.write('\n[App Creation Wizard]\n\n', self.reporter.style_success)
|
|
44
|
+
|
|
45
|
+
app_path = self._collect_app_path()
|
|
46
|
+
components = app_path.split('.')
|
|
47
|
+
|
|
48
|
+
app_name = self._collect_app_name(components)
|
|
49
|
+
app_label = self._collect_app_label(components, app_name)
|
|
50
|
+
model_name = self._collect_model_name(app_name)
|
|
51
|
+
model_name_plural = self._collect_model_name_plural(model_name)
|
|
52
|
+
db_table_name = self._collect_db_table_name(app_label)
|
|
53
|
+
model_permission_path = self._collect_model_permission_path(app_path, model_name)
|
|
54
|
+
|
|
55
|
+
permission_data = self._collect_permission_inheritance(components)
|
|
56
|
+
|
|
57
|
+
verbose_name, verbose_name_plural = self._derive_verbose_names(model_name, model_name_plural)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
'app_path': app_path,
|
|
61
|
+
'app_name': app_name,
|
|
62
|
+
'model_name': model_name,
|
|
63
|
+
'model_name_plural': model_name_plural,
|
|
64
|
+
'app_label': app_label,
|
|
65
|
+
'db_table_name': db_table_name,
|
|
66
|
+
'model_permission_path': model_permission_path,
|
|
67
|
+
'verbose_name': verbose_name,
|
|
68
|
+
'verbose_name_plural': verbose_name_plural,
|
|
69
|
+
**permission_data,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def _collect_app_label(self, components: list[str], app_name: str) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Prompts the user for the Django app label.
|
|
75
|
+
|
|
76
|
+
:param components: List of app path components.
|
|
77
|
+
:param app_name: Name of the app.
|
|
78
|
+
:return: User-provided or default app label.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
parent_parts = components[1:-1] if len(components) > 1 else []
|
|
82
|
+
default = '_'.join(parent_parts).lower() + '_' + app_name.lower() if parent_parts else app_name.lower()
|
|
83
|
+
return self._collect_input('Enter the app label', default, '3/8')
|
|
84
|
+
|
|
85
|
+
def _collect_app_name(self, components: list[str]) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Prompts the user for the app name.
|
|
88
|
+
|
|
89
|
+
:param components: List of app path components.
|
|
90
|
+
:return: User-provided or default app name.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
default = components[-1]
|
|
94
|
+
return self._collect_input('Enter the app name', default, '2/8')
|
|
95
|
+
|
|
96
|
+
def _collect_app_path(self) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Prompts the user for the app path and validates it.
|
|
99
|
+
|
|
100
|
+
:return: Validated app path in dot notation.
|
|
101
|
+
:raises CommandError: If the app path is empty or invalid.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
app_path = self._collect_simple_input('Enter the app path (e.g., "app.human_resource.employee.skill")', '1/8')
|
|
105
|
+
|
|
106
|
+
if not app_path:
|
|
107
|
+
self.reporter.write('\n', self.reporter.style_notice)
|
|
108
|
+
|
|
109
|
+
message = 'The app path is required'
|
|
110
|
+
raise CommandError(message)
|
|
111
|
+
|
|
112
|
+
components = app_path.split('.')
|
|
113
|
+
self.validator.validate_app_path(components)
|
|
114
|
+
|
|
115
|
+
return app_path
|
|
116
|
+
|
|
117
|
+
def _collect_db_table_name(self, app_label: str) -> str:
|
|
118
|
+
"""
|
|
119
|
+
Prompts the user for the database table name.
|
|
120
|
+
|
|
121
|
+
:param app_label: App label to use as default.
|
|
122
|
+
:return: User-provided or default database table name.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
return self._collect_input('Enter the database table name', app_label, '6/8')
|
|
126
|
+
|
|
127
|
+
def _collect_input(self, prompt: str, default: str, step_number: str) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Prompts the user for input with a default value.
|
|
130
|
+
|
|
131
|
+
:param prompt: Prompt message to display.
|
|
132
|
+
:param default: Default value if user presses Enter.
|
|
133
|
+
:param step_number: Step number in the wizard (e.g., '1/8').
|
|
134
|
+
:return: User-provided input or default value.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
self.reporter.write(f'\n[{step_number}]: {prompt} (default: "{default}")', self.reporter.style_notice)
|
|
138
|
+
user_input = input('Press Enter to use default or type a custom value: ').strip()
|
|
139
|
+
return user_input if user_input else default
|
|
140
|
+
|
|
141
|
+
def _collect_model_name(self, app_name: str) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Prompts the user for the model class name.
|
|
144
|
+
|
|
145
|
+
:param app_name: App name to derive default from.
|
|
146
|
+
:return: User-provided or default model name in TitleCase.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
default = ''.join(word.title() for word in app_name.split('_'))
|
|
150
|
+
return self._collect_input('Enter the model name', default, '4/8')
|
|
151
|
+
|
|
152
|
+
def _collect_model_name_plural(self, model_name: str) -> str:
|
|
153
|
+
"""
|
|
154
|
+
Prompts the user for the plural form of the model name.
|
|
155
|
+
|
|
156
|
+
:param model_name: Singular model name.
|
|
157
|
+
:return: User-provided or default plural model name.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
default = model_name + 's'
|
|
161
|
+
return self._collect_input('Enter the model name plural', default, '5/8')
|
|
162
|
+
|
|
163
|
+
def _collect_model_permission_path(self, app_path: str, model_name: str) -> str:
|
|
164
|
+
"""
|
|
165
|
+
Prompts the user for the model permission path.
|
|
166
|
+
|
|
167
|
+
:param app_path: Dot-separated app path.
|
|
168
|
+
:param model_name: Model class name.
|
|
169
|
+
:return: User-provided or default model permission path.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
default = f'{app_path}.models.{model_name}'
|
|
173
|
+
return self._collect_input('Enter the model permission path', default, '7/8')
|
|
174
|
+
|
|
175
|
+
def _collect_permission_inheritance(self, components: list[str]) -> dict[str, str]:
|
|
176
|
+
"""
|
|
177
|
+
Collects permission inheritance configuration if applicable.
|
|
178
|
+
|
|
179
|
+
Prompts the user about inheriting permissions from parent apps
|
|
180
|
+
and collects necessary parent model information.
|
|
181
|
+
|
|
182
|
+
:param components: List of app path components.
|
|
183
|
+
:return: Dictionary containing permission inheritance settings.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
if len(components) <= 2:
|
|
187
|
+
return {'inherit_permissions': False, 'parent_permission_prefix': '', 'parent_model_instance_name': ''}
|
|
188
|
+
|
|
189
|
+
if not self._should_inherit_permissions():
|
|
190
|
+
return {'inherit_permissions': False, 'parent_permission_prefix': '', 'parent_model_instance_name': ''}
|
|
191
|
+
|
|
192
|
+
parent_parts = components[1:-1]
|
|
193
|
+
parent_name = components[-2]
|
|
194
|
+
parent_model_class = ''.join(word.title() for word in parent_name.split('_'))
|
|
195
|
+
|
|
196
|
+
self.reporter.write('\n[Permission Inheritance Configuration]\n', self.reporter.style_notice)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
'inherit_permissions': True,
|
|
200
|
+
'parent_permission_prefix': self._collect_input(
|
|
201
|
+
'Enter the parent permission prefix',
|
|
202
|
+
'_'.join(parent_parts).lower(),
|
|
203
|
+
'1/3'
|
|
204
|
+
),
|
|
205
|
+
'parent_model_instance_name': self._collect_input(
|
|
206
|
+
'Enter the parent model instance name',
|
|
207
|
+
parent_name.lower(),
|
|
208
|
+
'2/3'
|
|
209
|
+
),
|
|
210
|
+
'parent_model_path': self._collect_input(
|
|
211
|
+
'Enter the parent model path',
|
|
212
|
+
'.'.join(components[:-1]) + f'.models.{parent_model_class}',
|
|
213
|
+
'3/3'
|
|
214
|
+
),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
def _collect_simple_input(self, prompt: str, step_number: str) -> str:
|
|
218
|
+
"""
|
|
219
|
+
Prompts the user for simple input without a default value.
|
|
220
|
+
|
|
221
|
+
:param prompt: Prompt message to display.
|
|
222
|
+
:param step_number: Step number in the wizard.
|
|
223
|
+
:return: User-provided input.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
return input(f'[{step_number}]: {prompt}: ').strip()
|
|
227
|
+
|
|
228
|
+
def _derive_verbose_names(self, model_name: str, model_name_plural: str) -> tuple[str, str]:
|
|
229
|
+
"""
|
|
230
|
+
Derives human-readable verbose names from model names.
|
|
231
|
+
|
|
232
|
+
Converts CamelCase model names to space-separated words.
|
|
233
|
+
|
|
234
|
+
:param model_name: Singular model name in CamelCase.
|
|
235
|
+
:param model_name_plural: Plural model name in CamelCase.
|
|
236
|
+
:return: Tuple of (verbose_name, verbose_name_plural).
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
verbose_name = re.sub(r'(?<!^)(?=[A-Z])', ' ', model_name)
|
|
240
|
+
verbose_name_plural = re.sub(r'(?<!^)(?=[A-Z])', ' ', model_name_plural)
|
|
241
|
+
return verbose_name, verbose_name_plural
|
|
242
|
+
|
|
243
|
+
def _should_inherit_permissions(self) -> bool:
|
|
244
|
+
"""
|
|
245
|
+
Prompts the user to confirm permission inheritance.
|
|
246
|
+
|
|
247
|
+
:return: True if user wants to inherit permissions, False otherwise.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
self.reporter.write('\n[8/8]: Do you want this app to inherit permissions from its parent? (y/n)', self.reporter.style_notice)
|
|
251
|
+
user_input = input('Default is "n": ').strip().lower()
|
|
252
|
+
return user_input == 'y'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from django.core.management.base import CommandError
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from django_spire.core.management.commands.spire_startapp_pkg.filesystem import FileSystemInterface
|
|
9
|
+
from django_spire.core.management.commands.spire_startapp_pkg.registry import AppRegistryInterface
|
|
10
|
+
from django_spire.core.management.commands.spire_startapp_pkg.reporter import ReporterInterface
|
|
11
|
+
from django_spire.core.management.commands.spire_startapp_pkg.resolver import PathResolverInterface
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AppValidator:
|
|
15
|
+
"""
|
|
16
|
+
Validates Django app paths and configurations.
|
|
17
|
+
|
|
18
|
+
This class performs validation checks to ensure app paths are properly
|
|
19
|
+
formatted, don't conflict with existing apps, and use valid root apps.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
reporter: ReporterInterface,
|
|
25
|
+
registry: AppRegistryInterface,
|
|
26
|
+
path_resolver: PathResolverInterface,
|
|
27
|
+
filesystem: FileSystemInterface
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Initializes the validator with required dependencies.
|
|
31
|
+
|
|
32
|
+
:param reporter: Reporter for displaying error messages.
|
|
33
|
+
:param registry: Registry for checking installed apps.
|
|
34
|
+
:param path_resolver: Path resolver for determining file locations.
|
|
35
|
+
:param filesystem: File system interface for checking file existence.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
self._filesystem = filesystem
|
|
39
|
+
self._path_resolver = path_resolver
|
|
40
|
+
self._registry = registry
|
|
41
|
+
self._reporter = reporter
|
|
42
|
+
|
|
43
|
+
def validate_app_format(self, app_path: str) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Validates that an app path uses dot notation.
|
|
46
|
+
|
|
47
|
+
:param app_path: App path to validate.
|
|
48
|
+
:raises CommandError: If the app path doesn't contain dots.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
if '.' not in app_path:
|
|
52
|
+
self._reporter.write('\n', self._reporter.style_notice)
|
|
53
|
+
|
|
54
|
+
message = 'Invalid app name format. The app path must use dot notation (e.g., "parent.child").'
|
|
55
|
+
raise CommandError(message)
|
|
56
|
+
|
|
57
|
+
def validate_app_path(self, components: list[str]) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Validates that an app path doesn't already exist.
|
|
60
|
+
|
|
61
|
+
:param components: List of app path components.
|
|
62
|
+
:raises CommandError: If an app already exists at the destination path.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
destination = self._path_resolver.get_app_destination(components)
|
|
66
|
+
|
|
67
|
+
if self._filesystem.has_content(destination):
|
|
68
|
+
self._reporter.write('\n', self._reporter.style_notice)
|
|
69
|
+
|
|
70
|
+
message = (
|
|
71
|
+
f'The app already exists at {destination}. '
|
|
72
|
+
'Please remove the existing app or choose a different name.'
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
raise CommandError(message)
|
|
76
|
+
|
|
77
|
+
def validate_root_app(self, components: list[str]) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Validates that the root app component is registered in Django.
|
|
80
|
+
|
|
81
|
+
:param components: List of app path components.
|
|
82
|
+
:raises CommandError: If the root app is not a valid registered app.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
valid_roots = self._registry.get_valid_root_apps()
|
|
86
|
+
root = components[0]
|
|
87
|
+
|
|
88
|
+
if root not in valid_roots:
|
|
89
|
+
self._reporter.write('\n', self._reporter.style_notice)
|
|
90
|
+
|
|
91
|
+
message = (
|
|
92
|
+
f'Invalid root app "{root}". '
|
|
93
|
+
f'Valid root apps: {", ".join(sorted(valid_roots))}.'
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
raise CommandError(message)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from django_spire.core.middleware.maintenance import MaintenanceMiddleware
|
|
4
|
-
from django_spire.core.middleware.profiling import ProfilingMiddleware
|
|
5
4
|
|
|
6
5
|
|
|
7
|
-
__all__ = ['MaintenanceMiddleware'
|
|
6
|
+
__all__ = ['MaintenanceMiddleware']
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
lock = threading.Lock()
|
|
4
|
+
|
|
5
|
+
from django_spire.profiling.middleware.profiling import ProfilingMiddleware
|
|
6
|
+
from django_spire.profiling.panel import ProfilingPanel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'ProfilingMiddleware',
|
|
11
|
+
'ProfilingPanel',
|
|
12
|
+
'lock'
|
|
13
|
+
]
|
|
@@ -5,20 +5,42 @@ import threading
|
|
|
5
5
|
import time
|
|
6
6
|
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing_extensions import
|
|
8
|
+
from typing_extensions import TYPE_CHECKING
|
|
9
9
|
|
|
10
10
|
from django.conf import settings
|
|
11
11
|
from django.utils.deprecation import MiddlewareMixin
|
|
12
12
|
|
|
13
|
+
from django_spire.profiling import lock
|
|
14
|
+
|
|
13
15
|
try:
|
|
14
16
|
from pyinstrument import Profiler
|
|
15
17
|
except ImportError:
|
|
16
18
|
Profiler = None
|
|
17
19
|
|
|
18
20
|
if TYPE_CHECKING:
|
|
21
|
+
from typing_extensions import Any, Callable
|
|
22
|
+
|
|
19
23
|
from django.http import HttpRequest, HttpResponse
|
|
20
24
|
|
|
21
25
|
|
|
26
|
+
IGNORE_EXTENSION = [
|
|
27
|
+
'.eot',
|
|
28
|
+
'.gif',
|
|
29
|
+
'.ico',
|
|
30
|
+
'.jpeg',
|
|
31
|
+
'.jpg',
|
|
32
|
+
'.js',
|
|
33
|
+
'.map',
|
|
34
|
+
'.pdf',
|
|
35
|
+
'.png',
|
|
36
|
+
'.svg',
|
|
37
|
+
'.ttf',
|
|
38
|
+
'.txt',
|
|
39
|
+
'.woff',
|
|
40
|
+
'.woff2',
|
|
41
|
+
'.zip',
|
|
42
|
+
]
|
|
43
|
+
|
|
22
44
|
IGNORE_PATH = [
|
|
23
45
|
'/__',
|
|
24
46
|
'/__debug__/',
|
|
@@ -36,12 +58,13 @@ IGNORE_PATH = [
|
|
|
36
58
|
'/debug/',
|
|
37
59
|
'/debug-toolbar/',
|
|
38
60
|
'/django_glue/',
|
|
61
|
+
'/django_spire/theme/json/get_config/',
|
|
39
62
|
'/docs/',
|
|
40
63
|
'/favicon.ico',
|
|
41
64
|
'/media/',
|
|
42
65
|
'/openapi/',
|
|
43
66
|
'/redoc/',
|
|
44
|
-
'/robots.txt',
|
|
67
|
+
'/robots.txt/',
|
|
45
68
|
'/schema/',
|
|
46
69
|
'/sitemap.xml',
|
|
47
70
|
'/static/',
|
|
@@ -50,37 +73,15 @@ IGNORE_PATH = [
|
|
|
50
73
|
]
|
|
51
74
|
|
|
52
75
|
|
|
53
|
-
IGNORE_EXTENSION = [
|
|
54
|
-
'.css',
|
|
55
|
-
'.eot',
|
|
56
|
-
'.gif',
|
|
57
|
-
'.ico',
|
|
58
|
-
'.jpeg',
|
|
59
|
-
'.jpg',
|
|
60
|
-
'.js',
|
|
61
|
-
'.map',
|
|
62
|
-
'.pdf',
|
|
63
|
-
'.png',
|
|
64
|
-
'.svg',
|
|
65
|
-
'.ttf',
|
|
66
|
-
'.txt',
|
|
67
|
-
'.woff',
|
|
68
|
-
'.woff2',
|
|
69
|
-
'.zip',
|
|
70
|
-
]
|
|
71
|
-
|
|
72
|
-
|
|
73
76
|
class ProfilingMiddleware(MiddlewareMixin):
|
|
74
77
|
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
|
75
78
|
super().__init__(get_response)
|
|
76
79
|
|
|
77
|
-
if Profiler is None:
|
|
78
|
-
message = 'pyinstrument is required for profiling.'
|
|
79
|
-
raise ImportError(message)
|
|
80
|
-
|
|
81
80
|
configuration = {
|
|
82
81
|
'PROFILING_DIR': os.getenv('PROFILING_DIR', '.profile'),
|
|
83
82
|
'PROFILING_ENABLED': os.getenv('PROFILING_ENABLED', 'False') == 'True',
|
|
83
|
+
'PROFILING_MAX_FILES': int(os.getenv('PROFILING_MAX_FILES', '10')),
|
|
84
|
+
'PROFILE_THRESHOLD': float(os.getenv('PROFILE_THRESHOLD', '0')),
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
directory = configuration.get('PROFILING_DIR', '.profile')
|
|
@@ -97,32 +98,28 @@ class ProfilingMiddleware(MiddlewareMixin):
|
|
|
97
98
|
self.directory.mkdir(exist_ok=True)
|
|
98
99
|
|
|
99
100
|
self.enabled = configuration.get('PROFILING_ENABLED', False)
|
|
100
|
-
self.
|
|
101
|
+
self.threshold = configuration.get('PROFILE_THRESHOLD', 0)
|
|
102
|
+
self.maximum = configuration.get('PROFILING_MAX_FILES', 10)
|
|
101
103
|
|
|
102
104
|
self.count = 0
|
|
103
105
|
self.lock = threading.Lock()
|
|
104
106
|
|
|
105
|
-
def
|
|
106
|
-
files = self.directory.glob('*.html')
|
|
107
|
-
|
|
107
|
+
def _remove_profiles(self) -> None:
|
|
108
|
+
files = list(self.directory.glob('*.html'))
|
|
109
|
+
|
|
110
|
+
if len(files) <= self.maximum:
|
|
111
|
+
return
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
key=lambda p: p.stat().st_mtime,
|
|
113
|
+
files.sort(
|
|
114
|
+
key=lambda p: p.stat().st_mtime if p.exists() else 0,
|
|
111
115
|
reverse=True
|
|
112
116
|
)
|
|
113
117
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if len(profiles) > maximum:
|
|
117
|
-
for profile in profiles[maximum:]:
|
|
118
|
+
for profile in files[self.maximum:]:
|
|
119
|
+
if profile.exists():
|
|
118
120
|
profile.unlink()
|
|
119
121
|
|
|
120
|
-
def _save_profile(
|
|
121
|
-
self,
|
|
122
|
-
profiler: Profiler,
|
|
123
|
-
request: HttpRequest,
|
|
124
|
-
duration_ms: float
|
|
125
|
-
) -> None:
|
|
122
|
+
def _save_profile(self, profiler: Profiler, request: HttpRequest, duration: float) -> None:
|
|
126
123
|
with self.lock:
|
|
127
124
|
timestamp = int(time.time() * 1000)
|
|
128
125
|
method = request.method
|
|
@@ -131,21 +128,29 @@ class ProfilingMiddleware(MiddlewareMixin):
|
|
|
131
128
|
if not path or path == '_':
|
|
132
129
|
path = 'root'
|
|
133
130
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
profiler.write_html(path)
|
|
131
|
+
profileid = request._profiling_id
|
|
132
|
+
filename = f'{timestamp}_{method}_{path}_{duration:.1f}ms_{profileid}.html'
|
|
133
|
+
filepath = self.directory / filename
|
|
138
134
|
|
|
139
|
-
|
|
135
|
+
with lock:
|
|
136
|
+
profiler.write_html(str(filepath))
|
|
137
|
+
self._remove_profiles()
|
|
140
138
|
|
|
141
|
-
def
|
|
139
|
+
def _should_skip(self, request: HttpRequest) -> bool:
|
|
142
140
|
path = request.path
|
|
143
141
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
path_match = any(
|
|
143
|
+
path.startswith(pattern) or pattern in path
|
|
144
|
+
for pattern in IGNORE_PATH
|
|
147
145
|
)
|
|
148
146
|
|
|
147
|
+
extension_match = any(
|
|
148
|
+
path.endswith(extension) or extension in path
|
|
149
|
+
for extension in IGNORE_EXTENSION
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return path_match or extension_match
|
|
153
|
+
|
|
149
154
|
def process_view(
|
|
150
155
|
self,
|
|
151
156
|
request: HttpRequest,
|
|
@@ -159,7 +164,7 @@ class ProfilingMiddleware(MiddlewareMixin):
|
|
|
159
164
|
if not self.enabled:
|
|
160
165
|
return None
|
|
161
166
|
|
|
162
|
-
if self.
|
|
167
|
+
if self._should_skip(request):
|
|
163
168
|
return None
|
|
164
169
|
|
|
165
170
|
with self.lock:
|
|
@@ -167,7 +172,7 @@ class ProfilingMiddleware(MiddlewareMixin):
|
|
|
167
172
|
request._profiling_id = self.count
|
|
168
173
|
|
|
169
174
|
profiler = Profiler(interval=0.001)
|
|
170
|
-
|
|
175
|
+
start = time.time()
|
|
171
176
|
profiler.start()
|
|
172
177
|
|
|
173
178
|
try:
|
|
@@ -177,15 +182,15 @@ class ProfilingMiddleware(MiddlewareMixin):
|
|
|
177
182
|
response.render()
|
|
178
183
|
except Exception:
|
|
179
184
|
profiler.stop()
|
|
180
|
-
|
|
181
|
-
self._save_profile(profiler, request,
|
|
185
|
+
duration = (time.time() - start) * 1000
|
|
186
|
+
self._save_profile(profiler, request, duration)
|
|
182
187
|
|
|
183
188
|
raise
|
|
184
189
|
else:
|
|
185
190
|
profiler.stop()
|
|
186
|
-
|
|
191
|
+
duration = (time.time() - start) * 1000
|
|
187
192
|
|
|
188
|
-
if
|
|
189
|
-
self._save_profile(profiler, request,
|
|
193
|
+
if duration >= self.threshold:
|
|
194
|
+
self._save_profile(profiler, request, duration)
|
|
190
195
|
|
|
191
196
|
return response
|