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.
Files changed (333) hide show
  1. quantum/__init__.py +1 -0
  2. quantum/cli/__init__.py +2 -0
  3. quantum/cli/admin.py +131 -0
  4. quantum/cli/check.py +223 -0
  5. quantum/cli/commands/__init__.py +15 -0
  6. quantum/cli/commands/build.py +414 -0
  7. quantum/cli/commands/dev.py +185 -0
  8. quantum/cli/commands/docs.py +329 -0
  9. quantum/cli/commands/lint.py +523 -0
  10. quantum/cli/commands/migrate.py +548 -0
  11. quantum/cli/commands/new.py +622 -0
  12. quantum/cli/commands/serve.py +190 -0
  13. quantum/cli/commands/test.py +193 -0
  14. quantum/cli/deploy.py +810 -0
  15. quantum/cli/hot_reload.py +951 -0
  16. quantum/cli/jobs.py +356 -0
  17. quantum/cli/mq.py +582 -0
  18. quantum/cli/pkg.py +390 -0
  19. quantum/cli/runner.py +565 -0
  20. quantum/cli/server_process.py +159 -0
  21. quantum/cli/utils.py +334 -0
  22. quantum/compiler/__init__.py +30 -0
  23. quantum/compiler/base_generator.py +367 -0
  24. quantum/compiler/cli.py +295 -0
  25. quantum/compiler/expression_transformer.py +444 -0
  26. quantum/compiler/javascript/__init__.py +10 -0
  27. quantum/compiler/javascript/generator.py +659 -0
  28. quantum/compiler/optimizer.py +270 -0
  29. quantum/compiler/python/__init__.py +10 -0
  30. quantum/compiler/python/generator.py +883 -0
  31. quantum/compiler/python/runtime.py +863 -0
  32. quantum/compiler/transpiler.py +330 -0
  33. quantum/core/__init__.py +3 -0
  34. quantum/core/ast_nodes.py +2619 -0
  35. quantum/core/config_env.py +54 -0
  36. quantum/core/docs_links.py +8 -0
  37. quantum/core/expression_diagnostics.py +87 -0
  38. quantum/core/expression_stdlib.py +208 -0
  39. quantum/core/expressions.py +677 -0
  40. quantum/core/features/agents/src/__init__.py +30 -0
  41. quantum/core/features/agents/src/ast_node.py +540 -0
  42. quantum/core/features/conditionals/src/__init__.py +8 -0
  43. quantum/core/features/conditionals/src/ast_node.py +68 -0
  44. quantum/core/features/data_import/src/__init__.py +0 -0
  45. quantum/core/features/data_import/src/ast_node.py +292 -0
  46. quantum/core/features/data_import/src/runtime.py +533 -0
  47. quantum/core/features/dump/src/__init__.py +12 -0
  48. quantum/core/features/dump/src/ast_node.py +106 -0
  49. quantum/core/features/dump/src/parser.py +61 -0
  50. quantum/core/features/dump/src/runtime.py +246 -0
  51. quantum/core/features/functions/src/__init__.py +8 -0
  52. quantum/core/features/functions/src/ast_node.py +149 -0
  53. quantum/core/features/game_engine_2d/src/__init__.py +19 -0
  54. quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
  55. quantum/core/features/game_engine_2d/src/parser.py +983 -0
  56. quantum/core/features/invocation/src/__init__.py +0 -0
  57. quantum/core/features/invocation/src/ast_node.py +147 -0
  58. quantum/core/features/invocation/src/runtime.py +332 -0
  59. quantum/core/features/knowledge_base/src/__init__.py +6 -0
  60. quantum/core/features/knowledge_base/src/ast_node.py +113 -0
  61. quantum/core/features/knowledge_base/src/parser.py +82 -0
  62. quantum/core/features/logging/src/__init__.py +12 -0
  63. quantum/core/features/logging/src/ast_node.py +111 -0
  64. quantum/core/features/logging/src/parser.py +50 -0
  65. quantum/core/features/logging/src/runtime.py +190 -0
  66. quantum/core/features/loops/src/__init__.py +8 -0
  67. quantum/core/features/loops/src/ast_node.py +60 -0
  68. quantum/core/features/query/src/__init__.py +0 -0
  69. quantum/core/features/query/src/query_validators.py +20 -0
  70. quantum/core/features/state_management/src/__init__.py +11 -0
  71. quantum/core/features/state_management/src/ast_node.py +121 -0
  72. quantum/core/features/terminal_engine/src/__init__.py +21 -0
  73. quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
  74. quantum/core/features/terminal_engine/src/parser.py +361 -0
  75. quantum/core/features/testing_engine/src/__init__.py +41 -0
  76. quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
  77. quantum/core/features/testing_engine/src/parser.py +604 -0
  78. quantum/core/features/theming/src/__init__.py +48 -0
  79. quantum/core/features/theming/src/ast_node.py +137 -0
  80. quantum/core/features/theming/src/presets.py +405 -0
  81. quantum/core/features/ui_engine/src/__init__.py +20 -0
  82. quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
  83. quantum/core/features/ui_engine/src/parser.py +1192 -0
  84. quantum/core/features/websocket/src/__init__.py +24 -0
  85. quantum/core/features/websocket/src/ast_node.py +247 -0
  86. quantum/core/guards.py +53 -0
  87. quantum/core/html_compat.py +299 -0
  88. quantum/core/parser.py +1551 -0
  89. quantum/core/parser_registry.py +213 -0
  90. quantum/core/parsers/__init__.py +76 -0
  91. quantum/core/parsers/ai/__init__.py +12 -0
  92. quantum/core/parsers/ai/agent_parser.py +106 -0
  93. quantum/core/parsers/ai/knowledge_parser.py +86 -0
  94. quantum/core/parsers/ai/llm_parser.py +78 -0
  95. quantum/core/parsers/ai/team_parser.py +88 -0
  96. quantum/core/parsers/base.py +322 -0
  97. quantum/core/parsers/composition/__init__.py +10 -0
  98. quantum/core/parsers/composition/import_parser.py +50 -0
  99. quantum/core/parsers/composition/slot_parser.py +48 -0
  100. quantum/core/parsers/control_flow/__init__.py +11 -0
  101. quantum/core/parsers/control_flow/if_parser.py +71 -0
  102. quantum/core/parsers/control_flow/loop_parser.py +105 -0
  103. quantum/core/parsers/control_flow/set_parser.py +93 -0
  104. quantum/core/parsers/data/__init__.py +12 -0
  105. quantum/core/parsers/data/data_parser.py +229 -0
  106. quantum/core/parsers/data/invoke_parser.py +133 -0
  107. quantum/core/parsers/data/query_parser.py +213 -0
  108. quantum/core/parsers/data/transaction_parser.py +86 -0
  109. quantum/core/parsers/events/__init__.py +9 -0
  110. quantum/core/parsers/events/dispatch_event_parser.py +44 -0
  111. quantum/core/parsers/forms/__init__.py +11 -0
  112. quantum/core/parsers/forms/action_parser.py +79 -0
  113. quantum/core/parsers/forms/flash_parser.py +34 -0
  114. quantum/core/parsers/forms/redirect_parser.py +33 -0
  115. quantum/core/parsers/functions/__init__.py +11 -0
  116. quantum/core/parsers/functions/function_parser.py +98 -0
  117. quantum/core/parsers/functions/param_parser.py +73 -0
  118. quantum/core/parsers/functions/return_parser.py +31 -0
  119. quantum/core/parsers/html/__init__.py +10 -0
  120. quantum/core/parsers/html/component_call_parser.py +130 -0
  121. quantum/core/parsers/html/html_parser.py +115 -0
  122. quantum/core/parsers/jobs/__init__.py +11 -0
  123. quantum/core/parsers/jobs/job_parser.py +71 -0
  124. quantum/core/parsers/jobs/schedule_parser.py +62 -0
  125. quantum/core/parsers/jobs/thread_parser.py +57 -0
  126. quantum/core/parsers/messaging/__init__.py +17 -0
  127. quantum/core/parsers/messaging/message_ack_parser.py +30 -0
  128. quantum/core/parsers/messaging/message_nack_parser.py +30 -0
  129. quantum/core/parsers/messaging/message_parser.py +114 -0
  130. quantum/core/parsers/messaging/queue_parser.py +61 -0
  131. quantum/core/parsers/messaging/websocket_parser.py +121 -0
  132. quantum/core/parsers/routing/__init__.py +9 -0
  133. quantum/core/parsers/routing/route_parser.py +41 -0
  134. quantum/core/parsers/scripting/__init__.py +12 -0
  135. quantum/core/parsers/scripting/pyclass_parser.py +62 -0
  136. quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
  137. quantum/core/parsers/scripting/pyimport_parser.py +52 -0
  138. quantum/core/parsers/scripting/python_parser.py +49 -0
  139. quantum/core/parsers/services/__init__.py +12 -0
  140. quantum/core/parsers/services/dump_parser.py +52 -0
  141. quantum/core/parsers/services/file_parser.py +48 -0
  142. quantum/core/parsers/services/log_parser.py +46 -0
  143. quantum/core/parsers/services/mail_parser.py +65 -0
  144. quantum/core/tiers.py +164 -0
  145. quantum/core/xml_lines.py +81 -0
  146. quantum/packages/__init__.py +28 -0
  147. quantum/packages/manager.py +413 -0
  148. quantum/packages/manifest.py +351 -0
  149. quantum/packages/registry.py +399 -0
  150. quantum/packages/resolver.py +336 -0
  151. quantum/plugins/__init__.py +33 -0
  152. quantum/plugins/hooks.py +329 -0
  153. quantum/plugins/loader.py +479 -0
  154. quantum/plugins/manifest.py +336 -0
  155. quantum/plugins/registry.py +371 -0
  156. quantum/runtime/__init__.py +25 -0
  157. quantum/runtime/action_handler.py +532 -0
  158. quantum/runtime/adapters/__init__.py +88 -0
  159. quantum/runtime/adapters/memory_adapter.py +690 -0
  160. quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
  161. quantum/runtime/adapters/redis_adapter.py +582 -0
  162. quantum/runtime/adapters/sqlite_adapter.py +414 -0
  163. quantum/runtime/agent_service.py +1133 -0
  164. quantum/runtime/ast_cache.py +506 -0
  165. quantum/runtime/auth_service.py +267 -0
  166. quantum/runtime/component.py +1048 -0
  167. quantum/runtime/component_composer.py +133 -0
  168. quantum/runtime/component_resolver.py +174 -0
  169. quantum/runtime/conditions.py +54 -0
  170. quantum/runtime/database_service.py +595 -0
  171. quantum/runtime/db_schema.py +100 -0
  172. quantum/runtime/dev_panel.py +178 -0
  173. quantum/runtime/email_service.py +162 -0
  174. quantum/runtime/error_handler.py +295 -0
  175. quantum/runtime/execution_context.py +283 -0
  176. quantum/runtime/executor_registry.py +178 -0
  177. quantum/runtime/executors/__init__.py +71 -0
  178. quantum/runtime/executors/ai/__init__.py +12 -0
  179. quantum/runtime/executors/ai/agent_executor.py +217 -0
  180. quantum/runtime/executors/ai/knowledge_executor.py +114 -0
  181. quantum/runtime/executors/ai/llm_executor.py +153 -0
  182. quantum/runtime/executors/ai/team_executor.py +171 -0
  183. quantum/runtime/executors/base.py +264 -0
  184. quantum/runtime/executors/control_flow/__init__.py +12 -0
  185. quantum/runtime/executors/control_flow/if_executor.py +98 -0
  186. quantum/runtime/executors/control_flow/loop_executor.py +308 -0
  187. quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
  188. quantum/runtime/executors/control_flow/set_executor.py +464 -0
  189. quantum/runtime/executors/data/__init__.py +12 -0
  190. quantum/runtime/executors/data/data_executor.py +156 -0
  191. quantum/runtime/executors/data/invoke_executor.py +228 -0
  192. quantum/runtime/executors/data/query_executor.py +290 -0
  193. quantum/runtime/executors/data/transaction_executor.py +91 -0
  194. quantum/runtime/executors/jobs/__init__.py +11 -0
  195. quantum/runtime/executors/jobs/job_executor.py +190 -0
  196. quantum/runtime/executors/jobs/schedule_executor.py +132 -0
  197. quantum/runtime/executors/jobs/thread_executor.py +127 -0
  198. quantum/runtime/executors/messaging/__init__.py +17 -0
  199. quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
  200. quantum/runtime/executors/messaging/message_executor.py +174 -0
  201. quantum/runtime/executors/messaging/queue_executor.py +103 -0
  202. quantum/runtime/executors/messaging/websocket_executor.py +197 -0
  203. quantum/runtime/executors/scripting/__init__.py +11 -0
  204. quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
  205. quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
  206. quantum/runtime/executors/scripting/python_executor.py +249 -0
  207. quantum/runtime/executors/services/__init__.py +12 -0
  208. quantum/runtime/executors/services/dump_executor.py +72 -0
  209. quantum/runtime/executors/services/file_executor.py +89 -0
  210. quantum/runtime/executors/services/log_executor.py +77 -0
  211. quantum/runtime/executors/services/mail_executor.py +81 -0
  212. quantum/runtime/expression_cache.py +498 -0
  213. quantum/runtime/file_upload_service.py +326 -0
  214. quantum/runtime/function_registry.py +118 -0
  215. quantum/runtime/game_builder.py +166 -0
  216. quantum/runtime/game_code_generator.py +2371 -0
  217. quantum/runtime/game_templates.py +2006 -0
  218. quantum/runtime/godot_code_generator.py +4681 -0
  219. quantum/runtime/godot_templates.py +1449 -0
  220. quantum/runtime/job_executor.py +1599 -0
  221. quantum/runtime/knowledge_service.py +546 -0
  222. quantum/runtime/llm_cache.py +100 -0
  223. quantum/runtime/llm_providers.py +704 -0
  224. quantum/runtime/llm_service.py +287 -0
  225. quantum/runtime/local_server.py +28 -0
  226. quantum/runtime/logging_setup.py +141 -0
  227. quantum/runtime/message_broker.py +364 -0
  228. quantum/runtime/message_queue_service.py +571 -0
  229. quantum/runtime/param_validation.py +218 -0
  230. quantum/runtime/pypy_compat.py +317 -0
  231. quantum/runtime/python_bridge.py +698 -0
  232. quantum/runtime/query_validators.py +304 -0
  233. quantum/runtime/renderer.py +734 -0
  234. quantum/runtime/service_container.py +470 -0
  235. quantum/runtime/table_params.py +163 -0
  236. quantum/runtime/terminal_builder.py +76 -0
  237. quantum/runtime/terminal_code_generator.py +607 -0
  238. quantum/runtime/terminal_templates.py +243 -0
  239. quantum/runtime/testing_builder.py +77 -0
  240. quantum/runtime/testing_code_generator.py +833 -0
  241. quantum/runtime/testing_templates.py +85 -0
  242. quantum/runtime/ui_builder.py +222 -0
  243. quantum/runtime/ui_console.py +405 -0
  244. quantum/runtime/ui_desktop.py +56 -0
  245. quantum/runtime/ui_form_rules.py +193 -0
  246. quantum/runtime/ui_html_adapter.py +2567 -0
  247. quantum/runtime/ui_html_templates.py +2106 -0
  248. quantum/runtime/ui_mobile_adapter.py +1832 -0
  249. quantum/runtime/ui_mobile_templates.py +1003 -0
  250. quantum/runtime/ui_page_renderer.py +392 -0
  251. quantum/runtime/ui_pager.py +73 -0
  252. quantum/runtime/ui_table_edit.py +139 -0
  253. quantum/runtime/ui_textual_adapter.py +1866 -0
  254. quantum/runtime/ui_textual_templates.py +45 -0
  255. quantum/runtime/ui_tokens.py +465 -0
  256. quantum/runtime/ui_validator.py +365 -0
  257. quantum/runtime/ui_view.py +221 -0
  258. quantum/runtime/validators.py +256 -0
  259. quantum/runtime/web_server.py +2131 -0
  260. quantum/runtime/websocket_adapter.py +501 -0
  261. quantum/runtime/websocket_service.py +585 -0
  262. quantum/runtime/websocket_transport.py +289 -0
  263. quantum/runtime/wsgi.py +101 -0
  264. quantum/services.py +109 -0
  265. quantum/utils/__init__.py +1 -0
  266. quantum_admin/__init__.py +4 -0
  267. quantum_admin/assets/quantum-admin-connectors.js +123 -0
  268. quantum_admin/assets/quantum-admin.css +2058 -0
  269. quantum_admin/assets/quantum-source-highlight.js +106 -0
  270. quantum_admin/backend/__init__.py +4 -0
  271. quantum_admin/backend/audit_service.py +346 -0
  272. quantum_admin/backend/auth_service.py +394 -0
  273. quantum_admin/backend/component_discovery.py +766 -0
  274. quantum_admin/backend/config_generator.py +586 -0
  275. quantum_admin/backend/connector_service.py +1016 -0
  276. quantum_admin/backend/crud.py +557 -0
  277. quantum_admin/backend/database.py +226 -0
  278. quantum_admin/backend/db_setup_service.py +193 -0
  279. quantum_admin/backend/deploy_service.py +698 -0
  280. quantum_admin/backend/docker_service.py +422 -0
  281. quantum_admin/backend/environment_service.py +418 -0
  282. quantum_admin/backend/git_service.py +370 -0
  283. quantum_admin/backend/health_service.py +390 -0
  284. quantum_admin/backend/job_service.py +347 -0
  285. quantum_admin/backend/log_service.py +331 -0
  286. quantum_admin/backend/main.py +14426 -0
  287. quantum_admin/backend/models.py +986 -0
  288. quantum_admin/backend/pipeline_service.py +812 -0
  289. quantum_admin/backend/resource_manager.py +1118 -0
  290. quantum_admin/backend/schemas.py +419 -0
  291. quantum_admin/backend/secret_manager.py +277 -0
  292. quantum_admin/backend/settings_service.py +488 -0
  293. quantum_admin/backend/template_service.py +710 -0
  294. quantum_admin/backend/test_execution_service.py +364 -0
  295. quantum_admin/backend/test_generator.py +782 -0
  296. quantum_admin/backend/webhook_service.py +454 -0
  297. quantum_admin/backend/websocket_manager.py +360 -0
  298. quantum_admin/components/admin/_layout/AdminShell.q +121 -0
  299. quantum_admin/components/admin/agents.q +107 -0
  300. quantum_admin/components/admin/app/[name].q +558 -0
  301. quantum_admin/components/admin/applications.q +160 -0
  302. quantum_admin/components/admin/component/[...path].q +205 -0
  303. quantum_admin/components/admin/components.q +59 -0
  304. quantum_admin/components/admin/connectors.q +283 -0
  305. quantum_admin/components/admin/dashboard.q +86 -0
  306. quantum_admin/components/admin/database.q +95 -0
  307. quantum_admin/components/admin/features.q +69 -0
  308. quantum_admin/components/admin/index.q +12 -0
  309. quantum_admin/components/admin/jobs.q +75 -0
  310. quantum_admin/components/admin/login.q +65 -0
  311. quantum_admin/components/admin/logout.q +11 -0
  312. quantum_admin/components/admin/settings.q +174 -0
  313. quantum_admin/components/admin/source.q +45 -0
  314. quantum_admin/components/admin/tests.q +73 -0
  315. quantum_admin/run.py +13 -0
  316. quantum_admin/services/__init__.py +16 -0
  317. quantum_admin/services/_base.py +84 -0
  318. quantum_admin/services/_component_test_generator.py +205 -0
  319. quantum_admin/services/_yaml_editor.py +82 -0
  320. quantum_admin/services/apps.py +330 -0
  321. quantum_admin/services/auth.py +61 -0
  322. quantum_admin/services/components.py +210 -0
  323. quantum_admin/services/connectors.py +164 -0
  324. quantum_admin/services/projects.py +189 -0
  325. quantum_admin/services/repository.py +223 -0
  326. quantum_admin/services/settings.py +150 -0
  327. quantum_admin/services/yaml_import.py +118 -0
  328. quantum_framework-0.18.0.dist-info/METADATA +281 -0
  329. quantum_framework-0.18.0.dist-info/RECORD +333 -0
  330. quantum_framework-0.18.0.dist-info/WHEEL +5 -0
  331. quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
  332. quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
  333. 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()