quantum-framework 0.18.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.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/admin.py +131 -0
- quantum/cli/check.py +223 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +414 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +548 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +565 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2619 -0
- quantum/core/config_env.py +54 -0
- quantum/core/docs_links.py +8 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +208 -0
- quantum/core/expressions.py +677 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +292 -0
- quantum/core/features/data_import/src/runtime.py +533 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +147 -0
- quantum/core/features/invocation/src/runtime.py +332 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +121 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
- quantum/core/features/ui_engine/src/parser.py +1192 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/guards.py +53 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1551 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +71 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +93 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +229 -0
- quantum/core/parsers/data/invoke_parser.py +133 -0
- quantum/core/parsers/data/query_parser.py +213 -0
- quantum/core/parsers/data/transaction_parser.py +86 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +79 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +98 -0
- quantum/core/parsers/functions/param_parser.py +73 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +164 -0
- quantum/core/xml_lines.py +81 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +25 -0
- quantum/runtime/action_handler.py +532 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +1048 -0
- quantum/runtime/component_composer.py +133 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/conditions.py +54 -0
- quantum/runtime/database_service.py +595 -0
- quantum/runtime/db_schema.py +100 -0
- quantum/runtime/dev_panel.py +178 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +283 -0
- quantum/runtime/executor_registry.py +178 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +264 -0
- quantum/runtime/executors/control_flow/__init__.py +12 -0
- quantum/runtime/executors/control_flow/if_executor.py +98 -0
- quantum/runtime/executors/control_flow/loop_executor.py +308 -0
- quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
- quantum/runtime/executors/control_flow/set_executor.py +464 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +156 -0
- quantum/runtime/executors/data/invoke_executor.py +228 -0
- quantum/runtime/executors/data/query_executor.py +290 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +546 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/local_server.py +28 -0
- quantum/runtime/logging_setup.py +141 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +218 -0
- quantum/runtime/pypy_compat.py +317 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +734 -0
- quantum/runtime/service_container.py +470 -0
- quantum/runtime/table_params.py +163 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +222 -0
- quantum/runtime/ui_console.py +405 -0
- quantum/runtime/ui_desktop.py +56 -0
- quantum/runtime/ui_form_rules.py +193 -0
- quantum/runtime/ui_html_adapter.py +2567 -0
- quantum/runtime/ui_html_templates.py +2106 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_page_renderer.py +392 -0
- quantum/runtime/ui_pager.py +73 -0
- quantum/runtime/ui_table_edit.py +139 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/ui_view.py +221 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +2131 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/services.py +109 -0
- quantum/utils/__init__.py +1 -0
- quantum_admin/__init__.py +4 -0
- quantum_admin/assets/quantum-admin-connectors.js +123 -0
- quantum_admin/assets/quantum-admin.css +2058 -0
- quantum_admin/assets/quantum-source-highlight.js +106 -0
- quantum_admin/backend/__init__.py +4 -0
- quantum_admin/backend/audit_service.py +346 -0
- quantum_admin/backend/auth_service.py +394 -0
- quantum_admin/backend/component_discovery.py +766 -0
- quantum_admin/backend/config_generator.py +586 -0
- quantum_admin/backend/connector_service.py +1016 -0
- quantum_admin/backend/crud.py +557 -0
- quantum_admin/backend/database.py +226 -0
- quantum_admin/backend/db_setup_service.py +193 -0
- quantum_admin/backend/deploy_service.py +698 -0
- quantum_admin/backend/docker_service.py +422 -0
- quantum_admin/backend/environment_service.py +418 -0
- quantum_admin/backend/git_service.py +370 -0
- quantum_admin/backend/health_service.py +390 -0
- quantum_admin/backend/job_service.py +347 -0
- quantum_admin/backend/log_service.py +331 -0
- quantum_admin/backend/main.py +14426 -0
- quantum_admin/backend/models.py +986 -0
- quantum_admin/backend/pipeline_service.py +812 -0
- quantum_admin/backend/resource_manager.py +1118 -0
- quantum_admin/backend/schemas.py +419 -0
- quantum_admin/backend/secret_manager.py +277 -0
- quantum_admin/backend/settings_service.py +488 -0
- quantum_admin/backend/template_service.py +710 -0
- quantum_admin/backend/test_execution_service.py +364 -0
- quantum_admin/backend/test_generator.py +782 -0
- quantum_admin/backend/webhook_service.py +454 -0
- quantum_admin/backend/websocket_manager.py +360 -0
- quantum_admin/components/admin/_layout/AdminShell.q +121 -0
- quantum_admin/components/admin/agents.q +107 -0
- quantum_admin/components/admin/app/[name].q +558 -0
- quantum_admin/components/admin/applications.q +160 -0
- quantum_admin/components/admin/component/[...path].q +205 -0
- quantum_admin/components/admin/components.q +59 -0
- quantum_admin/components/admin/connectors.q +283 -0
- quantum_admin/components/admin/dashboard.q +86 -0
- quantum_admin/components/admin/database.q +95 -0
- quantum_admin/components/admin/features.q +69 -0
- quantum_admin/components/admin/index.q +12 -0
- quantum_admin/components/admin/jobs.q +75 -0
- quantum_admin/components/admin/login.q +65 -0
- quantum_admin/components/admin/logout.q +11 -0
- quantum_admin/components/admin/settings.q +174 -0
- quantum_admin/components/admin/source.q +45 -0
- quantum_admin/components/admin/tests.q +73 -0
- quantum_admin/run.py +13 -0
- quantum_admin/services/__init__.py +16 -0
- quantum_admin/services/_base.py +84 -0
- quantum_admin/services/_component_test_generator.py +205 -0
- quantum_admin/services/_yaml_editor.py +82 -0
- quantum_admin/services/apps.py +330 -0
- quantum_admin/services/auth.py +61 -0
- quantum_admin/services/components.py +210 -0
- quantum_admin/services/connectors.py +164 -0
- quantum_admin/services/projects.py +189 -0
- quantum_admin/services/repository.py +223 -0
- quantum_admin/services/settings.py +150 -0
- quantum_admin/services/yaml_import.py +118 -0
- quantum_framework-0.18.0.dist-info/METADATA +281 -0
- quantum_framework-0.18.0.dist-info/RECORD +333 -0
- quantum_framework-0.18.0.dist-info/WHEEL +5 -0
- quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quantum Admin - Component Test Generator
|
|
3
|
+
|
|
4
|
+
(Copied from components/admin/_test_generator.py, which generated imports from
|
|
5
|
+
the old src/ layout — `from core.parser import ...` — so every generated test
|
|
6
|
+
failed with ModuleNotFoundError. The imports now use the quantum package.)
|
|
7
|
+
|
|
8
|
+
Analyzes a .q component file and generates pytest test code
|
|
9
|
+
based on the component's structure (actions, queries, feature tags).
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ComponentTestGenerator:
|
|
17
|
+
def __init__(self, component_path, base_dir):
|
|
18
|
+
self.component_path = component_path
|
|
19
|
+
self.base_dir = base_dir
|
|
20
|
+
self.full_path = os.path.join(base_dir, 'components', component_path)
|
|
21
|
+
self.content = ''
|
|
22
|
+
self.component_name = ''
|
|
23
|
+
self.actions = []
|
|
24
|
+
self.queries = []
|
|
25
|
+
self.feature_tags = set()
|
|
26
|
+
|
|
27
|
+
def analyze(self):
|
|
28
|
+
"""Parse the .q file and extract structure."""
|
|
29
|
+
if not os.path.isfile(self.full_path):
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
with open(self.full_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
33
|
+
self.content = f.read()
|
|
34
|
+
|
|
35
|
+
# Component name
|
|
36
|
+
m = re.search(r'<q:component\s+name="([^"]+)"', self.content)
|
|
37
|
+
if m:
|
|
38
|
+
self.component_name = m.group(1)
|
|
39
|
+
else:
|
|
40
|
+
self.component_name = os.path.splitext(os.path.basename(self.component_path))[0]
|
|
41
|
+
|
|
42
|
+
# Actions with their params
|
|
43
|
+
self.actions = []
|
|
44
|
+
action_pattern = re.compile(
|
|
45
|
+
r'<q:action\s+name="([^"]+)"[^>]*>(.*?)</q:action>',
|
|
46
|
+
re.DOTALL
|
|
47
|
+
)
|
|
48
|
+
param_pattern = re.compile(
|
|
49
|
+
r'<q:param\s+([^>]+)/>'
|
|
50
|
+
)
|
|
51
|
+
for am in action_pattern.finditer(self.content):
|
|
52
|
+
action_name = am.group(1)
|
|
53
|
+
action_body = am.group(2)
|
|
54
|
+
params = []
|
|
55
|
+
for pm in param_pattern.finditer(action_body):
|
|
56
|
+
attrs = pm.group(1)
|
|
57
|
+
pname = ''
|
|
58
|
+
ptype = 'string'
|
|
59
|
+
prequired = False
|
|
60
|
+
nm = re.search(r'name="([^"]+)"', attrs)
|
|
61
|
+
if nm:
|
|
62
|
+
pname = nm.group(1)
|
|
63
|
+
tm = re.search(r'type="([^"]+)"', attrs)
|
|
64
|
+
if tm:
|
|
65
|
+
ptype = tm.group(1)
|
|
66
|
+
if 'required="true"' in attrs:
|
|
67
|
+
prequired = True
|
|
68
|
+
if pname:
|
|
69
|
+
params.append({
|
|
70
|
+
'name': pname,
|
|
71
|
+
'type': ptype,
|
|
72
|
+
'required': prequired,
|
|
73
|
+
})
|
|
74
|
+
self.actions.append({
|
|
75
|
+
'name': action_name,
|
|
76
|
+
'params': params,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
# Queries
|
|
80
|
+
self.queries = []
|
|
81
|
+
query_pattern = re.compile(r'<q:query\s+name="([^"]+)"')
|
|
82
|
+
for qm in query_pattern.finditer(self.content):
|
|
83
|
+
self.queries.append(qm.group(1))
|
|
84
|
+
|
|
85
|
+
# Feature tags
|
|
86
|
+
tag_pattern = re.compile(r'<q:(\w+)')
|
|
87
|
+
self.feature_tags = set(tag_pattern.findall(self.content))
|
|
88
|
+
self.feature_tags.discard('component')
|
|
89
|
+
self.feature_tags.discard('param')
|
|
90
|
+
|
|
91
|
+
def generate(self):
|
|
92
|
+
"""Generate pytest code based on analysis."""
|
|
93
|
+
lines = []
|
|
94
|
+
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
95
|
+
sanitized = self.component_path.replace('/', '_').replace('.q', '')
|
|
96
|
+
|
|
97
|
+
# Header
|
|
98
|
+
lines.append(f'"""')
|
|
99
|
+
lines.append(f'Auto-generated tests for {self.component_name}')
|
|
100
|
+
lines.append(f'Component: components/{self.component_path}')
|
|
101
|
+
lines.append(f'Generated: {ts}')
|
|
102
|
+
lines.append(f'"""')
|
|
103
|
+
lines.append(f'import os')
|
|
104
|
+
lines.append(f'import sys')
|
|
105
|
+
lines.append(f'import pytest')
|
|
106
|
+
lines.append(f'')
|
|
107
|
+
lines.append(f'# Setup path')
|
|
108
|
+
lines.append(f"BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))")
|
|
109
|
+
lines.append(f'')
|
|
110
|
+
lines.append(f"COMPONENT_PATH = os.path.join(BASE_DIR, 'components', '{self.component_path}')")
|
|
111
|
+
lines.append(f'')
|
|
112
|
+
|
|
113
|
+
# Smoke test class
|
|
114
|
+
lines.append(f'class TestSmoke{self.component_name}:')
|
|
115
|
+
lines.append(f' """Smoke tests: verify the component parses without errors."""')
|
|
116
|
+
lines.append(f'')
|
|
117
|
+
lines.append(f' def test_component_file_exists(self):')
|
|
118
|
+
lines.append(f' """Component .q file should exist on disk."""')
|
|
119
|
+
lines.append(f' assert os.path.isfile(COMPONENT_PATH), f"Component file not found: {{COMPONENT_PATH}}"')
|
|
120
|
+
lines.append(f'')
|
|
121
|
+
lines.append(f' def test_parse_without_errors(self):')
|
|
122
|
+
lines.append(f' """Component should parse into a valid AST."""')
|
|
123
|
+
lines.append(f' from quantum.core.parser import QuantumParser')
|
|
124
|
+
lines.append(f' parser = QuantumParser()')
|
|
125
|
+
lines.append(f' with open(COMPONENT_PATH, "r", encoding="utf-8") as f:')
|
|
126
|
+
lines.append(f' source = f.read()')
|
|
127
|
+
lines.append(f' ast = parser.parse(source)')
|
|
128
|
+
lines.append(f' assert ast is not None, "Parser returned None"')
|
|
129
|
+
lines.append(f'')
|
|
130
|
+
|
|
131
|
+
# Action test classes
|
|
132
|
+
for action in self.actions:
|
|
133
|
+
aname = action['name']
|
|
134
|
+
class_name = aname[0].upper() + aname[1:]
|
|
135
|
+
lines.append(f'')
|
|
136
|
+
lines.append(f'class TestAction{class_name}:')
|
|
137
|
+
lines.append(f' """Tests for action: {aname}"""')
|
|
138
|
+
lines.append(f'')
|
|
139
|
+
|
|
140
|
+
required_params = [p for p in action['params'] if p['required']]
|
|
141
|
+
all_params = action['params']
|
|
142
|
+
|
|
143
|
+
if all_params:
|
|
144
|
+
lines.append(f' def test_valid_params(self):')
|
|
145
|
+
lines.append(f' """Action {aname} should accept all required params."""')
|
|
146
|
+
lines.append(f' from quantum.core.parser import QuantumParser')
|
|
147
|
+
lines.append(f' parser = QuantumParser()')
|
|
148
|
+
lines.append(f' with open(COMPONENT_PATH, "r", encoding="utf-8") as f:')
|
|
149
|
+
lines.append(f' source = f.read()')
|
|
150
|
+
lines.append(f' ast = parser.parse(source)')
|
|
151
|
+
lines.append(f' # Verify the action node exists in AST')
|
|
152
|
+
lines.append(f' found = False')
|
|
153
|
+
lines.append(f' for node in getattr(ast, "children", getattr(ast, "statements", [])):')
|
|
154
|
+
lines.append(f' if hasattr(node, "name") and getattr(node, "name", None) == "{aname}":')
|
|
155
|
+
lines.append(f' found = True')
|
|
156
|
+
lines.append(f' break')
|
|
157
|
+
lines.append(f' assert found, "Action {aname} not found in AST"')
|
|
158
|
+
lines.append(f'')
|
|
159
|
+
|
|
160
|
+
for param in required_params:
|
|
161
|
+
pname = param['name']
|
|
162
|
+
lines.append(f' def test_missing_{pname}(self):')
|
|
163
|
+
lines.append(f' """Omitting required param {pname} should be detectable."""')
|
|
164
|
+
lines.append(f' # The action requires "{pname}" — verifying param definition exists')
|
|
165
|
+
lines.append(f' import re')
|
|
166
|
+
lines.append(f' with open(COMPONENT_PATH, "r", encoding="utf-8") as f:')
|
|
167
|
+
lines.append(f' source = f.read()')
|
|
168
|
+
lines.append(f' pattern = r\'<q:param\\s+[^>]*name="{pname}"[^>]*required="true"\'')
|
|
169
|
+
lines.append(f' assert re.search(pattern, source), "Required param {pname} not found in component"')
|
|
170
|
+
lines.append(f'')
|
|
171
|
+
|
|
172
|
+
# Query test classes
|
|
173
|
+
for qname in self.queries:
|
|
174
|
+
class_name = qname[0].upper() + qname[1:]
|
|
175
|
+
lines.append(f'')
|
|
176
|
+
lines.append(f'class TestQuery{class_name}:')
|
|
177
|
+
lines.append(f' """Tests for query: {qname}"""')
|
|
178
|
+
lines.append(f'')
|
|
179
|
+
lines.append(f' def test_query_node_exists(self):')
|
|
180
|
+
lines.append(f' """AST should contain a QueryNode with name={qname}."""')
|
|
181
|
+
lines.append(f' from quantum.core.parser import QuantumParser')
|
|
182
|
+
lines.append(f' from quantum.core.ast_nodes import QueryNode')
|
|
183
|
+
lines.append(f' parser = QuantumParser()')
|
|
184
|
+
lines.append(f' with open(COMPONENT_PATH, "r", encoding="utf-8") as f:')
|
|
185
|
+
lines.append(f' source = f.read()')
|
|
186
|
+
lines.append(f' ast = parser.parse(source)')
|
|
187
|
+
lines.append(f' # Walk AST to find QueryNode')
|
|
188
|
+
lines.append(f' def find_queries(node):')
|
|
189
|
+
lines.append(f' results = []')
|
|
190
|
+
lines.append(f' if isinstance(node, QueryNode):')
|
|
191
|
+
lines.append(f' results.append(node)')
|
|
192
|
+
lines.append(f' for child in getattr(node, "children", getattr(node, "statements", [])):')
|
|
193
|
+
lines.append(f' results.extend(find_queries(child))')
|
|
194
|
+
lines.append(f' return results')
|
|
195
|
+
lines.append(f' queries = find_queries(ast)')
|
|
196
|
+
lines.append(f' names = [getattr(q, "name", "") for q in queries]')
|
|
197
|
+
lines.append(f' assert "{qname}" in names, f"QueryNode {qname} not found. Found: {{names}}"')
|
|
198
|
+
lines.append(f'')
|
|
199
|
+
|
|
200
|
+
# Summary
|
|
201
|
+
lines.append(f'')
|
|
202
|
+
lines.append(f'# Summary: {len(self.actions)} action(s), {len(self.queries)} query/queries, tags: {", ".join(sorted(self.feature_tags)) or "none"}')
|
|
203
|
+
lines.append(f'')
|
|
204
|
+
|
|
205
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Edita valores de um YAML de configuração preservando comentários e ordem.
|
|
2
|
+
|
|
3
|
+
components/admin/settings.q regravava quantum.config.yaml com yaml.dump: todo
|
|
4
|
+
comentário do arquivo sumia — inclusive os avisos de segurança sobre host e
|
|
5
|
+
debug —, e um erro de escrita era engolido. Sem ruamel.yaml no projeto, este
|
|
6
|
+
editor cobre o caso do admin: chaves de primeiro nível dentro de uma seção
|
|
7
|
+
(`server.port`), valores escalares.
|
|
8
|
+
|
|
9
|
+
Nunca grava algo diferente do pedido: depois de editar o texto, relê o
|
|
10
|
+
resultado com yaml.safe_load e confere que (1) cada chave pedida tem o valor
|
|
11
|
+
pedido e (2) todo o resto continua igual. Se não, levanta e o arquivo fica como
|
|
12
|
+
estava. A gravação é atômica.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, Tuple
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class YamlEditError(RuntimeError):
|
|
25
|
+
"""A edição não produziria exatamente o arquivo pedido; nada foi gravado."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _formatar(valor: Any) -> str:
|
|
29
|
+
texto = yaml.safe_dump(valor, default_flow_style=True, allow_unicode=True).strip()
|
|
30
|
+
if texto.endswith("..."):
|
|
31
|
+
texto = texto[:-3].strip()
|
|
32
|
+
return texto
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def editar_texto(texto: str, valores: Dict[Tuple[str, str], Any]) -> str:
|
|
36
|
+
linhas = texto.splitlines()
|
|
37
|
+
for (secao, chave), valor in valores.items():
|
|
38
|
+
inicio = next((i for i, l in enumerate(linhas) if re.match(rf"^{re.escape(secao)}\s*:\s*(#.*)?$", l)), None)
|
|
39
|
+
if inicio is None:
|
|
40
|
+
if linhas and linhas[-1].strip():
|
|
41
|
+
linhas.append("")
|
|
42
|
+
linhas.extend([f"{secao}:", f" {chave}: {_formatar(valor)}"])
|
|
43
|
+
continue
|
|
44
|
+
fim = next((i for i in range(inicio + 1, len(linhas))
|
|
45
|
+
if linhas[i] and not linhas[i].startswith((" ", "\t", "#"))), len(linhas))
|
|
46
|
+
filhos = [i for i in range(inicio + 1, fim) if re.match(r"^(\s+)[^\s#]", linhas[i])]
|
|
47
|
+
recuo = re.match(r"^(\s+)", linhas[filhos[0]]).group(1) if filhos else " "
|
|
48
|
+
alvo = next((i for i in filhos if re.match(rf"^{re.escape(recuo)}{re.escape(chave)}\s*:", linhas[i])), None)
|
|
49
|
+
if alvo is None:
|
|
50
|
+
linhas.insert(inicio + 1, f"{recuo}{chave}: {_formatar(valor)}")
|
|
51
|
+
continue
|
|
52
|
+
m = re.match(rf"^({re.escape(recuo)}{re.escape(chave)}\s*:\s*)(.*?)(\s+#.*)?$", linhas[alvo])
|
|
53
|
+
linhas[alvo] = f"{m.group(1)}{_formatar(valor)}{m.group(3) or ''}"
|
|
54
|
+
return "\n".join(linhas) + ("\n" if texto.endswith("\n") or not texto else "")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def gravar_valores(arquivo: Path, valores: Dict[Tuple[str, str], Any]) -> Dict:
|
|
58
|
+
"""Aplica `valores` ao arquivo (criando se não existir) e devolve o YAML resultante."""
|
|
59
|
+
arquivo = Path(arquivo)
|
|
60
|
+
original = arquivo.read_text(encoding="utf-8") if arquivo.is_file() else ""
|
|
61
|
+
antes = yaml.safe_load(original) or {}
|
|
62
|
+
if not isinstance(antes, dict):
|
|
63
|
+
raise YamlEditError(f"{arquivo} does not contain a mapping")
|
|
64
|
+
|
|
65
|
+
novo_texto = editar_texto(original, valores)
|
|
66
|
+
try:
|
|
67
|
+
depois = yaml.safe_load(novo_texto) or {}
|
|
68
|
+
except yaml.YAMLError as exc:
|
|
69
|
+
raise YamlEditError(f"editing {arquivo} would produce invalid YAML: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
esperado = copy.deepcopy(antes)
|
|
72
|
+
for (secao, chave), valor in valores.items():
|
|
73
|
+
if not isinstance(esperado.get(secao), dict):
|
|
74
|
+
esperado[secao] = {}
|
|
75
|
+
esperado[secao][chave] = valor
|
|
76
|
+
if depois != esperado:
|
|
77
|
+
raise YamlEditError(f"editing {arquivo} would change more than was asked; nothing was written")
|
|
78
|
+
|
|
79
|
+
temporario = arquivo.with_name(arquivo.name + ".tmp")
|
|
80
|
+
temporario.write_text(novo_texto, encoding="utf-8")
|
|
81
|
+
os.replace(temporario, arquivo)
|
|
82
|
+
return depois
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Serviços da tela de uma aplicação (components/admin/app/[name].q).
|
|
2
|
+
|
|
3
|
+
admin.projects.by_name / files / update / config / save_config
|
|
4
|
+
admin.environments.list / create / update / delete / create_defaults
|
|
5
|
+
admin.servers.status / start / stop / log
|
|
6
|
+
|
|
7
|
+
O que a tela fazia e estava quebrado, e mudou:
|
|
8
|
+
- "iniciar servidor" rodava src/cli/runner.py, que não existe desde a troca
|
|
9
|
+
de src/ para quantum/: o processo morria na hora, o PID era gravado e o
|
|
10
|
+
botão não fazia nada. Agora roda `python -m quantum.cli.runner start` na
|
|
11
|
+
pasta do projeto;
|
|
12
|
+
- "parar" no Windows usava taskkill /F sem /T: o filho do reloader
|
|
13
|
+
sobrevivia e seguia servindo. Agora usa quantum.cli.server_process.stop_server
|
|
14
|
+
com o .quantum.pid que o próprio servidor grava (pai e filho);
|
|
15
|
+
- salvar a config gravava qualquer caminho e engolia erro de escrita.
|
|
16
|
+
Agora o caminho fica dentro da raiz, o YAML precisa ser um mapeamento e a
|
|
17
|
+
gravação é atômica;
|
|
18
|
+
- ambientes ficavam embutidos no projects.yaml; agora são os do
|
|
19
|
+
environment_service do backend (banco), com variáveis, porta e branch.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
from quantum.services import service
|
|
32
|
+
from quantum_admin.services._base import connectors, pasta_de_configuracao, raiz, sessao, status_do_processo
|
|
33
|
+
from quantum_admin.services.projects import ProjectError, _pasta, get_project
|
|
34
|
+
|
|
35
|
+
STATUS_VALIDOS = ("active", "archived", "error")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _projeto(db, project_id):
|
|
39
|
+
from quantum_admin.backend import crud
|
|
40
|
+
projeto = crud.get_project(db, int(project_id))
|
|
41
|
+
if projeto is None:
|
|
42
|
+
raise ProjectError(f"no project with id {project_id}")
|
|
43
|
+
return projeto
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------- projeto
|
|
47
|
+
|
|
48
|
+
@service("admin.projects.update")
|
|
49
|
+
def update_project(project_id: int, name: str = "", description: str = None, status: str = ""):
|
|
50
|
+
from quantum_admin.backend import models
|
|
51
|
+
with sessao() as db:
|
|
52
|
+
projeto = _projeto(db, project_id)
|
|
53
|
+
nome = (name or "").strip()
|
|
54
|
+
if nome and nome.lower() != (projeto.name or "").lower():
|
|
55
|
+
if len(nome) < 2:
|
|
56
|
+
raise ProjectError("the project name needs at least 2 characters")
|
|
57
|
+
if db.query(models.Project).filter(models.Project.name.ilike(nome)).first():
|
|
58
|
+
raise ProjectError(f"a project named {nome!r} already exists")
|
|
59
|
+
if nome:
|
|
60
|
+
projeto.name = nome
|
|
61
|
+
if description is not None:
|
|
62
|
+
projeto.description = description.strip()
|
|
63
|
+
if status:
|
|
64
|
+
if status not in STATUS_VALIDOS:
|
|
65
|
+
raise ProjectError(f"status must be one of {', '.join(STATUS_VALIDOS)}")
|
|
66
|
+
projeto.status = status
|
|
67
|
+
return get_project(project_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@service("admin.projects.config")
|
|
71
|
+
def project_config(project_id: int):
|
|
72
|
+
"""O quantum.config.yaml do projeto, em texto (para o editor) e interpretado."""
|
|
73
|
+
with sessao() as db:
|
|
74
|
+
caminho = _projeto(db, project_id).source_path
|
|
75
|
+
arquivo = _pasta(caminho) / "quantum.config.yaml"
|
|
76
|
+
texto = arquivo.read_text(encoding="utf-8") if arquivo.is_file() else ""
|
|
77
|
+
try:
|
|
78
|
+
interpretado = yaml.safe_load(texto) or {}
|
|
79
|
+
erro = None
|
|
80
|
+
except yaml.YAMLError as exc:
|
|
81
|
+
interpretado, erro = {}, str(exc)
|
|
82
|
+
return {"path": str(arquivo), "exists": arquivo.is_file(), "text": texto,
|
|
83
|
+
"server": (interpretado.get("server") or {}) if isinstance(interpretado, dict) else {},
|
|
84
|
+
"error": erro}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@service("admin.projects.save_config")
|
|
88
|
+
def save_project_config(project_id: int, config_yaml: str):
|
|
89
|
+
try:
|
|
90
|
+
interpretado = yaml.safe_load(config_yaml or "")
|
|
91
|
+
except yaml.YAMLError as exc:
|
|
92
|
+
raise ProjectError(f"invalid YAML: {exc}")
|
|
93
|
+
if interpretado is not None and not isinstance(interpretado, dict):
|
|
94
|
+
raise ProjectError("the configuration must be a YAML mapping (key: value)")
|
|
95
|
+
with sessao() as db:
|
|
96
|
+
caminho = _projeto(db, project_id).source_path
|
|
97
|
+
pasta = _pasta(caminho)
|
|
98
|
+
pasta.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
arquivo = pasta / "quantum.config.yaml"
|
|
100
|
+
temporario = arquivo.with_name(arquivo.name + ".tmp")
|
|
101
|
+
temporario.write_text(config_yaml or "", encoding="utf-8")
|
|
102
|
+
os.replace(temporario, arquivo)
|
|
103
|
+
return project_config(project_id)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@service("admin.projects.by_name")
|
|
107
|
+
def project_by_name(name: str):
|
|
108
|
+
"""O projeto da URL /admin/app/<nome> (nome sem diferença de maiúsculas)."""
|
|
109
|
+
from quantum_admin.backend import models
|
|
110
|
+
with sessao() as db:
|
|
111
|
+
projeto = db.query(models.Project).filter(models.Project.name.ilike((name or "").strip())).first()
|
|
112
|
+
if projeto is None:
|
|
113
|
+
raise ProjectError(f"no project named {name!r}")
|
|
114
|
+
project_id = projeto.id
|
|
115
|
+
return get_project(project_id)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _rota(relativo: str) -> str:
|
|
119
|
+
"""components/loja/index.q -> /loja (ROUTE-1)."""
|
|
120
|
+
partes = relativo[:-2].split("/")
|
|
121
|
+
if partes[-1] == "index":
|
|
122
|
+
partes = partes[:-1]
|
|
123
|
+
return "/" + "/".join(partes)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@service("admin.projects.files")
|
|
127
|
+
def project_files(project_id: int):
|
|
128
|
+
"""Componentes, rotas, estáticos e tamanho em disco da pasta do projeto."""
|
|
129
|
+
import re
|
|
130
|
+
with sessao() as db:
|
|
131
|
+
caminho = _projeto(db, project_id).source_path
|
|
132
|
+
pasta = _pasta(caminho)
|
|
133
|
+
componentes = []
|
|
134
|
+
pasta_componentes = pasta / "components"
|
|
135
|
+
for arquivo in sorted(pasta_componentes.rglob("*.q")) if pasta_componentes.is_dir() else []:
|
|
136
|
+
relativo = arquivo.relative_to(pasta_componentes).as_posix()
|
|
137
|
+
conteudo = arquivo.read_text(encoding="utf-8", errors="ignore")
|
|
138
|
+
nome = re.search(r'<q:component\s+name="([^"]+)"', conteudo)
|
|
139
|
+
componentes.append({
|
|
140
|
+
"name": nome.group(1) if nome else arquivo.stem, "path": relativo,
|
|
141
|
+
"source": arquivo.relative_to(raiz()).as_posix(), "lines": conteudo.count("\n") + 1,
|
|
142
|
+
"size": arquivo.stat().st_size, "route": _rota(relativo), "dynamic": "[" in relativo,
|
|
143
|
+
"tags": sorted(set(re.findall(r"<q:(\w+)", conteudo)) - {"component", "param"}),
|
|
144
|
+
})
|
|
145
|
+
estaticos = pasta / "static"
|
|
146
|
+
tamanho = sum(f.stat().st_size for f in pasta.rglob("*") if f.is_file()) if pasta.is_dir() else 0
|
|
147
|
+
return {"components": componentes,
|
|
148
|
+
"static_files": sum(1 for f in estaticos.rglob("*") if f.is_file()) if estaticos.is_dir() else 0,
|
|
149
|
+
"disk_size": f"{tamanho / 1048576:.1f} MB" if tamanho >= 1048576 else f"{tamanho / 1024:.1f} KB"}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --------------------------------------------------------------------------- ambientes
|
|
153
|
+
|
|
154
|
+
def _ambiente_publico(env) -> dict:
|
|
155
|
+
variaveis = json.loads(env.env_vars_json) if env.env_vars_json else {}
|
|
156
|
+
return {"id": env.id, "project_id": env.project_id, "name": env.name, "display_name": env.display_name,
|
|
157
|
+
"order": env.order, "port": env.port, "branch": env.branch, "health_url": env.health_url,
|
|
158
|
+
"requires_approval": bool(env.requires_approval), "is_active": bool(env.is_active),
|
|
159
|
+
"variables": variaveis,
|
|
160
|
+
# O mesmo formato que create/update aceitam: NOME=valor por linha.
|
|
161
|
+
"variables_text": "\n".join(f"{k}={v}" for k, v in variaveis.items())}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _variaveis(texto_ou_dict):
|
|
165
|
+
"""'CHAVE=valor' por linha (como o formulário da tela) ou um dict."""
|
|
166
|
+
if isinstance(texto_ou_dict, dict):
|
|
167
|
+
return {str(k): str(v) for k, v in texto_ou_dict.items()}
|
|
168
|
+
variaveis = {}
|
|
169
|
+
for linha in (texto_ou_dict or "").splitlines():
|
|
170
|
+
linha = linha.strip()
|
|
171
|
+
if not linha or linha.startswith("#"):
|
|
172
|
+
continue
|
|
173
|
+
if "=" not in linha:
|
|
174
|
+
raise ProjectError(f"variable line must be NAME=value: {linha!r}")
|
|
175
|
+
chave, valor = linha.split("=", 1)
|
|
176
|
+
variaveis[chave.strip()] = valor.strip()
|
|
177
|
+
return variaveis
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _servico_de_ambientes(db):
|
|
181
|
+
from quantum_admin.backend.environment_service import EnvironmentService
|
|
182
|
+
return EnvironmentService(db=db)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@service("admin.environments.list")
|
|
186
|
+
def list_environments(project_id: int):
|
|
187
|
+
with sessao() as db:
|
|
188
|
+
_projeto(db, project_id)
|
|
189
|
+
return [_ambiente_publico(e) for e in _servico_de_ambientes(db).list_environments(int(project_id),
|
|
190
|
+
active_only=False)]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@service("admin.environments.create")
|
|
194
|
+
def create_environment(project_id: int, name: str, display_name: str = "", port: int = None,
|
|
195
|
+
branch: str = "main", variables="", health_url: str = ""):
|
|
196
|
+
nome = (name or "").strip().lower()
|
|
197
|
+
if not nome:
|
|
198
|
+
raise ProjectError("environment name is required")
|
|
199
|
+
with sessao() as db:
|
|
200
|
+
_projeto(db, project_id)
|
|
201
|
+
servico = _servico_de_ambientes(db)
|
|
202
|
+
if servico.get_environment_by_name(int(project_id), nome):
|
|
203
|
+
raise ProjectError(f"environment {nome!r} already exists in this project")
|
|
204
|
+
env = servico.create_environment(int(project_id), {
|
|
205
|
+
"name": nome, "display_name": display_name or None, "port": int(port) if port else None,
|
|
206
|
+
"branch": branch or "main", "env_vars": _variaveis(variables), "health_url": health_url or None})
|
|
207
|
+
return _ambiente_publico(env)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@service("admin.environments.update")
|
|
211
|
+
def update_environment(environment_id: int, display_name: str = None, port: int = None, branch: str = None,
|
|
212
|
+
variables=None, health_url: str = None):
|
|
213
|
+
dados = {k: v for k, v in {"display_name": display_name, "branch": branch, "health_url": health_url}.items()
|
|
214
|
+
if v is not None}
|
|
215
|
+
if port not in (None, ""):
|
|
216
|
+
dados["port"] = int(port)
|
|
217
|
+
with sessao() as db:
|
|
218
|
+
servico = _servico_de_ambientes(db)
|
|
219
|
+
env = servico.update_environment(int(environment_id), dados) if dados else servico.get_environment(
|
|
220
|
+
int(environment_id))
|
|
221
|
+
if env is None:
|
|
222
|
+
raise ProjectError(f"no environment with id {environment_id}")
|
|
223
|
+
if variables is not None:
|
|
224
|
+
servico.set_env_vars(int(environment_id), _variaveis(variables))
|
|
225
|
+
env = servico.get_environment(int(environment_id))
|
|
226
|
+
return _ambiente_publico(env)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@service("admin.environments.delete")
|
|
230
|
+
def delete_environment(environment_id: int):
|
|
231
|
+
with sessao() as db:
|
|
232
|
+
if not _servico_de_ambientes(db).delete_environment(int(environment_id)):
|
|
233
|
+
raise ProjectError(f"no environment with id {environment_id}")
|
|
234
|
+
return {"deleted": int(environment_id)}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@service("admin.environments.create_defaults")
|
|
238
|
+
def create_default_environments(project_id: int):
|
|
239
|
+
with sessao() as db:
|
|
240
|
+
_projeto(db, project_id)
|
|
241
|
+
return [_ambiente_publico(e) for e in _servico_de_ambientes(db).create_default_environments(int(project_id))]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --------------------------------------------------------------------------- servidor do projeto
|
|
245
|
+
|
|
246
|
+
def _pasta_do_projeto(project_id):
|
|
247
|
+
with sessao() as db:
|
|
248
|
+
projeto = _projeto(db, project_id)
|
|
249
|
+
nome, caminho = projeto.name, projeto.source_path
|
|
250
|
+
pasta = _pasta(caminho)
|
|
251
|
+
if not (pasta / "quantum.config.yaml").is_file():
|
|
252
|
+
raise ProjectError(f"{caminho}/quantum.config.yaml not found; save a configuration first")
|
|
253
|
+
return nome, pasta
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _log(nome):
|
|
257
|
+
pasta = pasta_de_configuracao() / "logs"
|
|
258
|
+
pasta.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
return pasta / f"{nome}.log"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@service("admin.servers.status")
|
|
263
|
+
def server_status(project_id: int):
|
|
264
|
+
nome, pasta = _pasta_do_projeto(project_id)
|
|
265
|
+
from quantum.cli.server_process import pid_alive, read_pids
|
|
266
|
+
arquivo = pasta / ".quantum.pid"
|
|
267
|
+
try:
|
|
268
|
+
vivos = [p for p in read_pids(arquivo) if pid_alive(p)]
|
|
269
|
+
except (OSError, ValueError):
|
|
270
|
+
vivos = []
|
|
271
|
+
processo = status_do_processo(nome)
|
|
272
|
+
return {"running": bool(vivos) or processo["running"], "pids": vivos or ([processo["pid"]] if processo["pid"] else [])}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@service("admin.servers.start")
|
|
276
|
+
def start_server(project_id: int, port: int = None, wait: float = 15.0):
|
|
277
|
+
"""Sobe `quantum start` na pasta do projeto e espera o arquivo de PID aparecer."""
|
|
278
|
+
nome, pasta = _pasta_do_projeto(project_id)
|
|
279
|
+
if server_status(project_id)["running"]:
|
|
280
|
+
raise ProjectError(f"{nome} is already running")
|
|
281
|
+
comando = [sys.executable, "-m", "quantum.cli.runner", "start", "--config", "quantum.config.yaml"]
|
|
282
|
+
if port:
|
|
283
|
+
comando += ["--port", str(int(port))]
|
|
284
|
+
import quantum
|
|
285
|
+
ambiente = dict(os.environ)
|
|
286
|
+
pacote = str(Path(quantum.__file__).resolve().parents[1])
|
|
287
|
+
ambiente["PYTHONPATH"] = os.pathsep.join(p for p in (pacote, ambiente.get("PYTHONPATH")) if p)
|
|
288
|
+
log = open(_log(nome), "a", encoding="utf-8")
|
|
289
|
+
opcoes = {"stdout": log, "stderr": subprocess.STDOUT, "cwd": str(pasta), "env": ambiente}
|
|
290
|
+
if os.name == "nt":
|
|
291
|
+
opcoes["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
292
|
+
processo = subprocess.Popen(comando, **opcoes)
|
|
293
|
+
log.close()
|
|
294
|
+
pids = pasta_de_configuracao() / "pids"
|
|
295
|
+
pids.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
(pids / f"{nome}.pid").write_text(str(processo.pid), encoding="utf-8")
|
|
297
|
+
|
|
298
|
+
limite = time.time() + float(wait)
|
|
299
|
+
while time.time() < limite:
|
|
300
|
+
if processo.poll() is not None:
|
|
301
|
+
raise ProjectError(f"{nome} exited with code {processo.returncode}; see the log: "
|
|
302
|
+
f"{server_log(project_id, 20)['text']}")
|
|
303
|
+
if (pasta / ".quantum.pid").is_file():
|
|
304
|
+
return server_status(project_id)
|
|
305
|
+
time.sleep(0.2)
|
|
306
|
+
return server_status(project_id)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@service("admin.servers.stop")
|
|
310
|
+
def stop_server(project_id: int):
|
|
311
|
+
nome, pasta = _pasta_do_projeto(project_id)
|
|
312
|
+
from quantum.cli import server_process
|
|
313
|
+
arquivo = pasta / ".quantum.pid"
|
|
314
|
+
if arquivo.is_file():
|
|
315
|
+
server_process.stop_server(arquivo)
|
|
316
|
+
else:
|
|
317
|
+
processo = status_do_processo(nome)
|
|
318
|
+
if processo["running"]:
|
|
319
|
+
server_process._terminate(processo["pid"], True)
|
|
320
|
+
(pasta_de_configuracao() / "pids" / f"{nome}.pid").unlink(missing_ok=True)
|
|
321
|
+
return server_status(project_id)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@service("admin.servers.log")
|
|
325
|
+
def server_log(project_id: int, lines: int = 50):
|
|
326
|
+
with sessao() as db:
|
|
327
|
+
nome = _projeto(db, project_id).name
|
|
328
|
+
arquivo = _log(nome)
|
|
329
|
+
texto = arquivo.read_text(encoding="utf-8", errors="replace") if arquivo.is_file() else ""
|
|
330
|
+
return {"text": "".join(texto.splitlines(keepends=True)[-int(lines):])}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Login das telas .q do admin (admin.auth.*).
|
|
2
|
+
|
|
3
|
+
Usa o auth_service do backend — as mesmas credenciais do FastAPI
|
|
4
|
+
(ADMIN_PASSWORD, ou a senha gerada e impressa na subida). A tela de login .q
|
|
5
|
+
chama admin.auth.login e, se `ok`, grava na sessão o que AUTH-1 exige
|
|
6
|
+
(session.authenticated, session.sessionExpiry, session.userRole).
|
|
7
|
+
|
|
8
|
+
Acrescenta o que a biblioteca não tinha: depois de MAX_FALHAS tentativas
|
|
9
|
+
erradas para o mesmo usuário em JANELA segundos, o login fica bloqueado por
|
|
10
|
+
JANELA segundos — sem isso, nada limitava tentar senhas em sequência. A
|
|
11
|
+
mensagem de falha é a mesma para usuário inexistente e senha errada.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from quantum.services import service
|
|
18
|
+
|
|
19
|
+
MAX_FALHAS = 5
|
|
20
|
+
JANELA = 300
|
|
21
|
+
|
|
22
|
+
_falhas = {}
|
|
23
|
+
_trava = threading.Lock()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _auth():
|
|
27
|
+
from quantum_admin.backend.auth_service import get_auth_service
|
|
28
|
+
return get_auth_service()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _bloqueado_ate(usuario, agora):
|
|
32
|
+
with _trava:
|
|
33
|
+
recentes = [t for t in _falhas.get(usuario, []) if agora - t < JANELA]
|
|
34
|
+
_falhas[usuario] = recentes
|
|
35
|
+
return recentes[0] + JANELA if len(recentes) >= MAX_FALHAS else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@service("admin.auth.login")
|
|
39
|
+
def login(username: str, password: str):
|
|
40
|
+
usuario = (username or "").strip()
|
|
41
|
+
agora = time.time()
|
|
42
|
+
ate = _bloqueado_ate(usuario, agora)
|
|
43
|
+
if ate is not None:
|
|
44
|
+
return {"ok": False, "locked": True,
|
|
45
|
+
"error": f"too many failed attempts; try again in {int(ate - agora) + 1} seconds"}
|
|
46
|
+
resultado = _auth().authenticate(usuario, password or "") if usuario and password else None
|
|
47
|
+
if not resultado:
|
|
48
|
+
with _trava:
|
|
49
|
+
_falhas.setdefault(usuario, []).append(agora)
|
|
50
|
+
return {"ok": False, "locked": False, "error": "invalid username or password"}
|
|
51
|
+
with _trava:
|
|
52
|
+
_falhas.pop(usuario, None)
|
|
53
|
+
horas = max(1, int(resultado.get("expires_in", 3600) // 3600))
|
|
54
|
+
return {"ok": True, "username": resultado["user"]["username"], "role": resultado["user"]["role"],
|
|
55
|
+
"expires_in_hours": horas}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _reset():
|
|
59
|
+
"""Testes."""
|
|
60
|
+
with _trava:
|
|
61
|
+
_falhas.clear()
|