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,2131 @@
1
+ """
2
+ Quantum Web Server - Serves .q components as HTML pages
3
+
4
+ ColdFusion-style simplicity: Just run `quantum start` and it works! 🪄
5
+ """
6
+
7
+ import sys
8
+ import os
9
+ from html import escape as html_escape
10
+ import re
11
+ import signal
12
+ import socket
13
+ import time
14
+ import hashlib
15
+ import yaml
16
+ import logging
17
+ from pathlib import Path
18
+ from flask import Flask, Response, request, send_from_directory, render_template_string, session, redirect, abort
19
+ from dataclasses import dataclass
20
+ from typing import Dict, Any, List, Optional
21
+ import secrets
22
+
23
+ # Fix imports
24
+
25
+ from quantum.core.parser import QuantumParser, QuantumParseError
26
+ from quantum.core.ast_nodes import ActionNode
27
+ from werkzeug.exceptions import HTTPException
28
+ from quantum.runtime.component import ComponentRuntime
29
+ from quantum.runtime.executors.control_flow.redirect_executor import PageRedirect
30
+ from quantum.runtime.renderer import HTMLRenderer
31
+ from quantum.runtime.execution_context import ExecutionContext
32
+ from quantum.runtime.action_handler import ActionHandler
33
+ from quantum.runtime.auth_service import AuthService, AuthorizationError
34
+ from quantum.runtime.error_handler import ErrorHandler, QuantumError
35
+ from quantum.runtime.dev_panel import note as dev_note, note_scopes as dev_scopes
36
+ # Imported lazily in methods to avoid circular import via runtime/__init__.py
37
+ _logging_setup = None
38
+
39
+ def _get_logging_setup():
40
+ global _logging_setup
41
+ if _logging_setup is None:
42
+ from quantum.runtime import logging_setup as _mod
43
+ _logging_setup = _mod
44
+ return _logging_setup
45
+
46
+
47
+ @dataclass
48
+ class DynamicRoute:
49
+ """A file-based route containing [param] segments."""
50
+ pattern: re.Pattern
51
+ file_path: Path
52
+ param_names: List[str]
53
+ static_segment_count: int
54
+
55
+
56
+
57
+ class UnknownActionError(Exception):
58
+ """A POST named no q:action, or one that does not exist on the page."""
59
+
60
+
61
+ class ConfigError(Exception):
62
+ """quantum.config.yaml is unreadable or invalid."""
63
+
64
+
65
+ # Top-level sections the engine understands. A typo like `servr:` used to be
66
+ # accepted in silence, so the whole section was ignored.
67
+ _KNOWN_SECTIONS = {
68
+ 'server', 'paths', 'defaults', 'datasources', 'database', 'llm',
69
+ 'performance', 'security', 'logging', 'development', 'deploy',
70
+ 'components', 'jobs', 'messaging', 'services',
71
+ }
72
+
73
+ # The keys each of these sections actually has; anything else is a warning.
74
+ _KNOWN_KEYS = {
75
+ 'security': {'secret_key', 'login_url', 'python_scripting', 'max_content_length',
76
+ 'xss_protection'},
77
+ 'performance': {'cache_templates', 'cache_ttl', 'cache_max_size'},
78
+ 'server': {'port', 'host', 'debug', 'reload'},
79
+ 'paths': {'components', 'static', 'logs', 'uploads', 'migrations'},
80
+ 'logging': {'level', 'format', 'console', 'file', 'filename'},
81
+ }
82
+
83
+ _DATASOURCE_KEYS = {'driver', 'type', 'database', 'host', 'port', 'username', 'user', 'password'}
84
+
85
+ # (section, key) -> expected python type(s)
86
+ _TYPED = {
87
+ ('server', 'port'): int,
88
+ ('server', 'host'): str,
89
+ ('server', 'debug'): bool,
90
+ ('server', 'reload'): bool,
91
+ ('security', 'python_scripting'): bool,
92
+ ('security', 'max_content_length'): int,
93
+ ('llm', 'timeout'): int,
94
+ ('llm', 'base_url'): str,
95
+ }
96
+
97
+
98
+ def _validate_config(config: dict, source: str) -> None:
99
+ """Fail fast on a bad config instead of at the first request.
100
+
101
+ `port: oitenta` used to be accepted and blew up much later inside
102
+ app.run(); a datasource with no driver failed only when a query ran. The
103
+ boot is where an operator is watching, so that is where it should break.
104
+ """
105
+ log = logging.getLogger('quantum')
106
+ problems = []
107
+
108
+ for section, key in _TYPED:
109
+ block = config.get(section)
110
+ if not isinstance(block, dict) or key not in block:
111
+ continue
112
+ value = block[key]
113
+ expected = _TYPED[(section, key)]
114
+ # bool is a subclass of int; check it first so True does not pass as a port
115
+ if expected is int and isinstance(value, bool):
116
+ problems.append(f"{section}.{key}: expected a number, got {value!r}")
117
+ elif not isinstance(value, expected):
118
+ problems.append(
119
+ f"{section}.{key}: expected {expected.__name__}, got "
120
+ f"{type(value).__name__} ({value!r})"
121
+ )
122
+
123
+ for name, ds in (config.get('datasources') or {}).items():
124
+ if not isinstance(ds, dict):
125
+ problems.append(f"datasources.{name}: expected a mapping")
126
+ continue
127
+ if not ds.get('driver'):
128
+ problems.append(
129
+ f"datasources.{name}: missing 'driver' (sqlite, postgresql, mysql)"
130
+ )
131
+ if not ds.get('database'):
132
+ problems.append(f"datasources.{name}: missing 'database'")
133
+
134
+ login_url = (config.get('security') or {}).get('login_url')
135
+ if login_url is not None and (not isinstance(login_url, str) or not login_url.startswith('/')
136
+ or login_url.startswith('//')):
137
+ # A full URL would make every require_auth page an open redirect (AUTH-4).
138
+ problems.append(f"security.login_url: must be a path on this server, like /login; got {login_url!r}")
139
+
140
+ if problems:
141
+ bullet = "\n - "
142
+ raise ConfigError(
143
+ f"{source} has {len(problems)} problem(s):"
144
+ + bullet + bullet.join(problems)
145
+ )
146
+
147
+ # Unknown sections are a warning, not an error: a newer config read by an
148
+ # older engine is a real situation, and refusing to boot over it would be
149
+ # worse than saying so.
150
+ # DB-7: a datasource key nothing reads. The blog declared `sqlite_path:`
151
+ # next to `database:`; the engine read `database:` and the sqlite_path was
152
+ # the file with the tables.
153
+ for nome, ds in (config.get('datasources') or {}).items():
154
+ ignoradas = sorted(k for k in (ds or {}) if k not in _DATASOURCE_KEYS) if isinstance(ds, dict) else []
155
+ if ignoradas:
156
+ log.warning("%s: datasources.%s: %s %s not read — the keys are: %s.",
157
+ source, nome, ', '.join(ignoradas), 'is' if len(ignoradas) == 1 else 'are',
158
+ ', '.join(sorted(_DATASOURCE_KEYS)))
159
+
160
+ # Keys nothing reads, in sections that promise protection or speed. A
161
+ # `csrf_protection: true` that does nothing is worse than none: whoever
162
+ # wrote it believes the app is protected.
163
+ for secao, conhecidas in _KNOWN_KEYS.items():
164
+ ignoradas = sorted(k for k in (config.get(secao) or {}) if k not in conhecidas)
165
+ if ignoradas:
166
+ log.warning(
167
+ "%s: %s.%s %s not implemented and ignored — nothing enforces %s.",
168
+ source, secao, ', '.join(ignoradas), 'is' if len(ignoradas) == 1 else 'are',
169
+ 'it' if len(ignoradas) == 1 else 'them')
170
+
171
+ unknown = [k for k in config if k not in _KNOWN_SECTIONS]
172
+ if unknown:
173
+ log.warning(
174
+ "%s has unrecognised top-level section(s): %s. They are ignored — "
175
+ "check for a typo.", source, ', '.join(sorted(unknown))
176
+ )
177
+
178
+
179
+ class QuantumWebServer:
180
+ """
181
+ Quantum Web Server - Magic happens here!
182
+
183
+ Automatically serves .q files as HTML pages.
184
+ No configuration needed (but you can customize via quantum.config.yaml)
185
+ """
186
+
187
+ PID_FILE = '.quantum.pid'
188
+
189
+ def __init__(self, config_path: str = 'quantum.config.yaml', hot_reload: bool = False, hot_reload_port: int = None):
190
+ """
191
+ Initialize Quantum Web Server.
192
+
193
+ Args:
194
+ config_path: Path to configuration file
195
+ hot_reload: Enable hot reload mode (inject client script)
196
+ hot_reload_port: WebSocket port for hot reload
197
+ """
198
+ self._start_time = time.monotonic()
199
+ self.config = self._load_config(config_path)
200
+ self.logger = _get_logging_setup().setup_logging(self.config)
201
+
202
+ # Disable Flask's built-in static serving - we use our own serve_static route
203
+ self.app = Flask(__name__, static_folder=None)
204
+
205
+ # Hot reload configuration
206
+ self.hot_reload_enabled = hot_reload
207
+ self.hot_reload_port = hot_reload_port or 35729
208
+
209
+ # Setup secret key for sessions (flash messages)
210
+ # Use env var or config for stable key across Gunicorn workers
211
+ self.app.secret_key = (
212
+ os.environ.get('QUANTUM_SECRET_KEY')
213
+ or self.config.get('security', {}).get('secret_key')
214
+ # DEV-2: while developing, a key that survives the reloader — which
215
+ # is a new process — so saving a file does not log everyone out.
216
+ or (self._dev_secret_key(config_path) if self.config['server'].get('debug') else None)
217
+ or secrets.token_hex(32)
218
+ )
219
+ # AUTH-5: the session cookie carries the login. HttpOnly keeps it from
220
+ # page scripts; SameSite=Lax keeps another site from submitting a form
221
+ # (a q:action) with it. Left to the browser's default before.
222
+ self.app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax')
223
+ # CFG-2: security.max_content_length (16 MB by default) was read into
224
+ # the config and never handed to Flask, so a request body — an upload
225
+ # — had no size limit at all.
226
+ self.app.config['MAX_CONTENT_LENGTH'] = (
227
+ (self.config.get('security') or {}).get('max_content_length') or 16 * 1024 * 1024)
228
+
229
+ self.parser = QuantumParser()
230
+ self.template_cache: Dict[str, Any] = {} # AST cache
231
+ # Flash helpers only (read/set the flash in the session). q:action
232
+ # bodies run in a runtime built for each request (ACT-10), never in a
233
+ # shared one.
234
+ self.action_handler = ActionHandler(ComponentRuntime(config=self.config))
235
+
236
+ # Phase F: Application scope (global state shared across all users)
237
+ self.application_scope: Dict[str, Any] = {}
238
+
239
+ # M12 (DEV-1): the /_dev panel records the last requests — only while
240
+ # developing. With debug off nothing is recorded and /_dev is a 404.
241
+ from quantum.runtime.dev_panel import DevPanel
242
+ self.dev_panel = DevPanel() if self.config['server'].get('debug') else None
243
+
244
+ # Setup routes
245
+ self._setup_routes()
246
+ self._setup_request_hooks()
247
+
248
+ # Build dynamic route table for [param] segments
249
+ self._dynamic_routes: List[DynamicRoute] = []
250
+ self._build_dynamic_routes()
251
+
252
+
253
+ def _load_config(self, config_path: str) -> Dict[str, Any]:
254
+ """
255
+ Load configuration from YAML file with sensible defaults.
256
+
257
+ Args:
258
+ config_path: Path to config file
259
+
260
+ Returns:
261
+ Configuration dictionary
262
+ """
263
+ default_config = {
264
+ 'server': {
265
+ 'port': 8080,
266
+ # Loopback and no debugger by default. The old defaults were
267
+ # host 0.0.0.0 + debug True, which put the Werkzeug interactive
268
+ # debugger — an eval console — on every network interface: RCE
269
+ # over the LAN from an unauthenticated browser tab, on the very
270
+ # first `quantum start`. Binding wider or turning the debugger
271
+ # on is now an explicit choice in quantum.config.yaml, and
272
+ # start() refuses the dangerous combination (see _guard_debug).
273
+ 'host': '127.0.0.1',
274
+ 'reload': True,
275
+ 'debug': False
276
+ },
277
+ 'paths': {
278
+ 'components': './components',
279
+ 'static': './static',
280
+ 'logs': './logs'
281
+ },
282
+ 'defaults': {
283
+ 'component_type': 'pure',
284
+ 'interactive': False,
285
+ 'charset': 'utf-8',
286
+ 'timeout': 30
287
+ },
288
+ 'performance': {
289
+ 'cache_templates': True,
290
+ 'cache_ttl': 300,
291
+ 'cache_max_size': 100
292
+ },
293
+ 'security': {
294
+ 'xss_protection': True,
295
+ 'max_content_length': 16 * 1024 * 1024 # 16 MB
296
+ },
297
+ 'logging': {
298
+ 'level': 'INFO',
299
+ 'console': True,
300
+ 'file': True,
301
+ 'filename': 'quantum.log'
302
+ }
303
+ }
304
+
305
+ # Try to load config file
306
+ config_file = Path(config_path)
307
+ if config_file.exists():
308
+ try:
309
+ with open(config_file, 'r', encoding='utf-8') as f:
310
+ from quantum.core.config_env import expand_env
311
+ user_config = expand_env(yaml.safe_load(f) or {})
312
+ # Deep merge user config with defaults
313
+ for key, value in user_config.items():
314
+ if key in default_config and isinstance(default_config[key], dict) and isinstance(value, dict):
315
+ default_config[key].update(value)
316
+ else:
317
+ default_config[key] = value
318
+ except Exception as e:
319
+ # Do NOT fall back to defaults. This used to log a warning and
320
+ # carry on, which meant a single typo in quantum.config.yaml
321
+ # silently discarded the operator's datasources, host binding
322
+ # and security settings and ran the server on defaults — the
323
+ # exact opposite of what they configured, with one WARNING
324
+ # line as the only sign.
325
+ raise ConfigError(
326
+ f"could not read {config_path}: {e}\n"
327
+ f"Fix the file, or move it aside to run on defaults "
328
+ f"deliberately."
329
+ )
330
+
331
+ _validate_config(default_config, config_path)
332
+ return default_config
333
+
334
+
335
+ def _setup_routes(self):
336
+ """Setup Flask routes for serving .q components"""
337
+
338
+ @self.app.route('/', methods=['GET', 'POST', 'PUT', 'DELETE'])
339
+ def index():
340
+ """Serve index.q or show welcome page"""
341
+ return self._serve_component('index')
342
+
343
+ @self.app.route('/static/<path:filepath>')
344
+ def serve_static(filepath):
345
+ """Serve static files (CSS, JS, images)"""
346
+ static_dir = self.config['paths']['static']
347
+ # Convert relative path to absolute for send_from_directory
348
+ if not os.path.isabs(static_dir):
349
+ static_dir = os.path.abspath(static_dir)
350
+ return send_from_directory(static_dir, filepath)
351
+
352
+ @self.app.route('/<path:component_path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
353
+ def dynamic_route(component_path):
354
+ """Dynamically serve any .q component"""
355
+ # Remove .q extension if provided
356
+ if component_path.endswith('.q'):
357
+ component_path = component_path[:-2]
358
+
359
+ return self._serve_component(component_path)
360
+
361
+ if self.dev_panel is not None:
362
+ @self.app.route('/_dev')
363
+ @self.app.route('/_dev/<int:numero>')
364
+ def dev_panel(numero=None):
365
+ """M12: what the last requests did (debug only, this machine only).
366
+
367
+ It shows sessions and query parameters. `debug` is dropped for
368
+ the Werkzeug console off loopback, but the config flag — and a
369
+ gunicorn bind — can still be public, so the panel checks who
370
+ asks: from any other address it does not exist."""
371
+ if request.remote_addr not in ('127.0.0.1', '::1'):
372
+ abort(404)
373
+ return Response(self.dev_panel.render(numero), mimetype='text/html')
374
+
375
+ @self.app.route('/health')
376
+ def health_check():
377
+ """Health check endpoint for container orchestration."""
378
+ import json
379
+ return Response(
380
+ json.dumps({
381
+ 'status': 'healthy',
382
+ 'service': 'quantum',
383
+ 'version': '1.0.0'
384
+ }),
385
+ status=200,
386
+ mimetype='application/json'
387
+ )
388
+
389
+ @self.app.route('/_partial/<path:component_path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
390
+ def serve_partial(component_path):
391
+ """
392
+ Serve partial component for HTMX requests (Phase B)
393
+
394
+ Partials return only the component HTML without full page wrapper.
395
+ Used for HTMX-style progressive enhancement.
396
+ """
397
+ # Remove .q extension if provided
398
+ if component_path.endswith('.q'):
399
+ component_path = component_path[:-2]
400
+
401
+ return self._serve_component(component_path, partial=True)
402
+
403
+ @self.app.errorhandler(404)
404
+ def not_found(error):
405
+ """404 handler with helpful message"""
406
+ return self._render_error_page(
407
+ title="404 - Component Not Found",
408
+ message=f"The component you're looking for doesn't exist.",
409
+ details=f"Requested path: {request.path}",
410
+ suggestion="Check your components directory and try again."
411
+ ), 404
412
+
413
+ @self.app.errorhandler(500)
414
+ def server_error(error):
415
+ """500 handler with error details"""
416
+ return self._render_error_page(
417
+ title="500 - Server Error",
418
+ message="An error occurred while processing your component.",
419
+ details=str(error),
420
+ suggestion="Check the component syntax and server logs."
421
+ ), 500
422
+
423
+
424
+ def _setup_request_hooks(self):
425
+ """Setup before/after request hooks for request logging."""
426
+ server_logger = logging.getLogger('quantum.server')
427
+
428
+ @self.app.before_request
429
+ def before_request_hook():
430
+ request._quantum_start = time.monotonic()
431
+ if self.dev_panel is not None and not request.path.startswith(('/_dev', '/static/', '/favicon')):
432
+ request._quantum_dev = self.dev_panel.start(request.method, request.full_path.rstrip('?'))
433
+
434
+ @self.app.after_request
435
+ def after_request_hook(response):
436
+ token = getattr(request, '_quantum_dev', None)
437
+ if token is not None:
438
+ self.dev_panel.finish(token, response.status_code, response.headers.get('Location'),
439
+ session.get('flash'))
440
+ start = getattr(request, '_quantum_start', None)
441
+ if start is not None:
442
+ duration_ms = (time.monotonic() - start) * 1000
443
+ _ls = _get_logging_setup()
444
+ color = _ls.get_status_color(response.status_code)
445
+ reset = _ls.get_reset()
446
+ server_logger.info(
447
+ f"{request.method} {request.path} "
448
+ f"{color}{response.status_code}{reset} "
449
+ f"{duration_ms:.0f}ms"
450
+ )
451
+ return response
452
+
453
+ def _build_dynamic_routes(self) -> None:
454
+ """Scan components directory for files with [param] segments and build regex routes."""
455
+ components_dir = Path(self.config['paths']['components'])
456
+ if not components_dir.is_dir():
457
+ return
458
+
459
+ routes = []
460
+ for q_file in components_dir.rglob('*.q'):
461
+ rel = q_file.relative_to(components_dir)
462
+ rel_str = str(rel).replace('\\', '/')
463
+
464
+ # Only process files whose path contains a [param] segment
465
+ if '[' not in rel_str:
466
+ continue
467
+
468
+ # Strip .q extension
469
+ route_path = rel_str[:-2] # remove '.q'
470
+
471
+ # Strip trailing /index for directory-index style dynamic routes
472
+ if route_path.endswith('/index'):
473
+ route_path = route_path[:-6]
474
+
475
+ # Extract param names and build regex
476
+ parts = route_path.split('/')
477
+ param_names = []
478
+ regex_parts = []
479
+ static_count = 0
480
+
481
+ for part in parts:
482
+ # Catch-all: [...param] captures remaining path segments
483
+ m_catchall = re.fullmatch(r'\[\.\.\.(\w+)\]', part)
484
+ # Single segment: [param]
485
+ m = re.fullmatch(r'\[(\w+)\]', part)
486
+ if m_catchall:
487
+ param_names.append(m_catchall.group(1))
488
+ regex_parts.append(f'(?P<{m_catchall.group(1)}>.+)')
489
+ elif m:
490
+ param_names.append(m.group(1))
491
+ regex_parts.append(f'(?P<{m.group(1)}>[^/]+)')
492
+ else:
493
+ static_count += 1
494
+ regex_parts.append(re.escape(part))
495
+
496
+ pattern = re.compile('^' + '/'.join(regex_parts) + '$')
497
+ routes.append(DynamicRoute(
498
+ pattern=pattern,
499
+ file_path=q_file,
500
+ param_names=param_names,
501
+ static_segment_count=static_count,
502
+ ))
503
+
504
+ # Sort: more static segments = higher priority (checked first)
505
+ routes.sort(key=lambda r: -r.static_segment_count)
506
+ self._dynamic_routes = routes
507
+
508
+ def _match_dynamic_route(self, component_path: str):
509
+ """Try to match a component path against dynamic routes.
510
+
511
+ Returns:
512
+ Tuple of (file_path, params_dict) or (None, {})
513
+ """
514
+ for route in self._dynamic_routes:
515
+ m = route.pattern.match(component_path)
516
+ if m:
517
+ return route.file_path, m.groupdict()
518
+ return None, {}
519
+
520
+ def _serve_component(self, component_path: str, partial: bool = False) -> Response:
521
+ """
522
+ Load, parse, execute, and render a .q component.
523
+
524
+ Args:
525
+ component_path: Path to component (without .q extension)
526
+ partial: If True, return only component HTML for HTMX (Phase B)
527
+
528
+ Returns:
529
+ Flask Response with rendered HTML
530
+ """
531
+
532
+ # Build full file path
533
+ components_dir = self.config['paths']['components']
534
+
535
+ # Containment check BEFORE touching the filesystem. `component_path`
536
+ # comes straight from the URL, and Flask only normalises `..` for
537
+ # clients that send a normalised path — a raw socket or
538
+ # `curl --path-as-is` sends `GET /../../x` verbatim, and Werkzeug
539
+ # passes the literal `..` through. Without this, GET /../../pwned
540
+ # resolved to a .q two directories above the web root and executed it;
541
+ # since a .q can carry <q:python>, that is unauthenticated RCE on the
542
+ # default config (host 0.0.0.0, python_scripting on). Reproduced via
543
+ # raw socket before adding this. See test_path_traversal.py.
544
+ if not self._is_within_components(component_path):
545
+ abort(404)
546
+
547
+ # ROUTE-3: a file or folder whose name starts with `_` exists to be
548
+ # imported, not visited. A layout like admin/AdminShell.q was served
549
+ # at /admin/AdminShell and answered 500 (its required props missing).
550
+ if any(parte.startswith('_') for parte in component_path.replace('\\', '/').split('/')):
551
+ abort(404)
552
+
553
+ file_path = Path(components_dir) / f'{component_path}.q'
554
+ path_params = {}
555
+
556
+ # Resolution priority:
557
+ # 1. Exact match: components/admin/app/blog.q
558
+ # 2. Directory index: components/admin/app/blog/index.q
559
+ # 3. Dynamic segment: components/admin/app/[name].q
560
+ # 4. 404
561
+ if not file_path.exists():
562
+ # Try index.q in subdirectory
563
+ index_path = Path(components_dir) / component_path / 'index.q'
564
+ if index_path.exists():
565
+ file_path = index_path
566
+ else:
567
+ # Try dynamic route matching
568
+ dyn_file, dyn_params = self._match_dynamic_route(component_path)
569
+ if dyn_file:
570
+ file_path = dyn_file
571
+ path_params = dyn_params
572
+ else:
573
+ # For index route, show welcome page; for other routes, return 404
574
+ if component_path == 'index':
575
+ return self._render_welcome_page()
576
+ else:
577
+ abort(404)
578
+
579
+ dev_note(component=str(file_path))
580
+ try:
581
+ # Check cache
582
+ cache_enabled = self.config['performance']['cache_templates']
583
+ cache_key = str(file_path)
584
+
585
+ if cache_enabled and cache_key in self.template_cache:
586
+ ast = self.template_cache[cache_key]
587
+ else:
588
+ # Parse component
589
+ ast = self.parser.parse_file(str(file_path))
590
+
591
+ # Cache if enabled
592
+ if cache_enabled:
593
+ self.template_cache[cache_key] = ast
594
+
595
+ # Phase G: Authentication & Security - Check authorization
596
+ if 'quantum_session' not in session:
597
+ session['quantum_session'] = {}
598
+
599
+ session_data = session['quantum_session']
600
+
601
+ # Check if component requires authentication
602
+ if getattr(ast, 'require_auth', False):
603
+ if not AuthService.is_authenticated(session_data):
604
+ # Not authenticated - redirect to login
605
+ session['redirect_after_login'] = request.path
606
+ return redirect(getattr(ast, 'login_url', None) or self._login_url())
607
+
608
+ # Check session expiry
609
+ if AuthService.is_session_expired(session_data):
610
+ # Session expired - logout and redirect to login
611
+ AuthService.logout(session_data)
612
+ session.modified = True
613
+ return redirect((getattr(ast, 'login_url', None) or self._login_url()) + '?expired=true')
614
+
615
+ # Check role requirement
616
+ if ast.require_role:
617
+ if not AuthService.has_role(session_data, ast.require_role):
618
+ # Forbidden - user doesn't have required role
619
+ return Response(
620
+ f"<h1>403 Forbidden</h1><p>Required role: {ast.require_role}</p>",
621
+ status=403,
622
+ mimetype='text/html'
623
+ )
624
+
625
+ # Check permission requirement
626
+ if ast.require_permission:
627
+ if not AuthService.has_permission(session_data, ast.require_permission):
628
+ # Forbidden - user doesn't have required permission
629
+ return Response(
630
+ f"<h1>403 Forbidden</h1><p>Required permission: {ast.require_permission}</p>",
631
+ status=403,
632
+ mimetype='text/html'
633
+ )
634
+
635
+ # UI-13: a cell of an editable <ui:table> posts to the generated action.
636
+ if request.method == 'POST' and request.form.get('action') == '__edit':
637
+ return self._edit_cell(ast, path_params)
638
+
639
+ # Check if this is an action request (POST/PUT/DELETE)
640
+ if request.method in ['POST', 'PUT', 'DELETE', 'PATCH']:
641
+ # Look for q:action in component
642
+ try:
643
+ action_node = self._find_action_in_component(ast)
644
+ except UnknownActionError as e:
645
+ return Response(f"<h1>400 Bad Request</h1><p>{html_escape(str(e))}</p>",
646
+ status=400, mimetype='text/html')
647
+
648
+ if action_node:
649
+ dev_note(action=action_node.name)
650
+ # AUTH-6: the page's guards run before its action. Without
651
+ # this a guard protected the GET only, and the POST ran
652
+ # the action with no session.
653
+ guard_runtime = ComponentRuntime(config=self.config)
654
+ try:
655
+ guard_runtime.run_guards(ast, self._page_params(path_params, consume_flash=False))
656
+ except PageRedirect as r:
657
+ return self._page_redirect(r, guard_runtime)
658
+ session['quantum_session'] = guard_runtime.execution_context.session_vars
659
+
660
+ # Handle action — in THIS request's runtime (ACT-10). It
661
+ # used to run in one ComponentRuntime shared by every
662
+ # request and thread, which never registered the page's
663
+ # q:functions: calling one inside an action was a 500.
664
+ # guard_runtime has entered this component with this
665
+ # request's scopes.
666
+ try:
667
+ redirect_url, status_code = ActionHandler(guard_runtime).handle_action(
668
+ action_node, route_params=path_params)
669
+ except PageRedirect as r:
670
+ # q:redirect reached through a q:function or q:loop in the action
671
+ return self._page_redirect(r)
672
+
673
+ if redirect_url:
674
+ return redirect(redirect_url, code=status_code)
675
+ if status_code >= 400:
676
+ # A real error occurred in the action body. Report it as
677
+ # an actual error response instead of a redirect
678
+ # disguised as one (a 500 with a Location header reads
679
+ # as success to most clients).
680
+ return Response(
681
+ f"<h1>{status_code} Error</h1><p>The action could not be completed. Check server logs for details.</p>",
682
+ status=status_code,
683
+ mimetype='text/html'
684
+ )
685
+ # If no redirect and no error, fall through to render component
686
+
687
+ params = self._page_params(path_params)
688
+
689
+ # Execute component (runs queries, loops, functions, etc.)
690
+ runtime = ComponentRuntime(config=self.config)
691
+ try:
692
+ runtime.execute_component(ast, params)
693
+ except PageRedirect as r:
694
+ # ACT-7: q:redirect outside an action ends the page.
695
+ return self._page_redirect(r, runtime)
696
+ finally:
697
+ dev_scopes(runtime.execution_context)
698
+
699
+ # Phase F: Sync session back to Flask session
700
+ session['quantum_session'] = runtime.execution_context.session_vars
701
+ session.modified = True
702
+
703
+ # Debug: log session state after execution
704
+ if self.config['server']['debug'] and request.method == 'POST':
705
+ debug_logger = logging.getLogger('quantum.runtime')
706
+ debug_logger.debug(f"POST form_data: {dict(request.form)}")
707
+ debug_logger.debug(f"session_vars after exec: {runtime.execution_context.session_vars}")
708
+ debug_logger.debug(f"app_vars keys: {list(runtime.execution_context.application_vars.keys())}")
709
+
710
+ # Render to HTML using runtime's execution context
711
+ renderer = HTMLRenderer(runtime.execution_context,
712
+ components_dir=self.config['paths']['components'],
713
+ function_resolver=runtime._resolve_expression_function,
714
+ config=self.config)
715
+ # UI-9: after a failed validation, the form shows the values sent
716
+ # and each error by its field — once, like the flash.
717
+ renderer.form_state = session.pop('form_state', None)
718
+ renderer._ui_action_nodes = {s.name: s for s in ast.statements if isinstance(s, ActionNode)}
719
+ from quantum.runtime.ui_table_edit import query_nodes
720
+ renderer._query_nodes = query_nodes(ast)
721
+
722
+ # UI-3: a renderer that is not a browser (the console) asks for the
723
+ # page as a tree of view nodes instead of HTML. Same page, same
724
+ # runtime, same actions — only the drawing differs.
725
+ if 'application/vnd.quantum.view+json' in (request.headers.get('Accept') or ''):
726
+ from quantum.runtime.ui_view import UIViewBuilder, page_title
727
+ import json as _json
728
+ corpo = {
729
+ 'title': page_title(ast, renderer),
730
+ 'flash': params.get('flash', ''), 'flashType': params.get('flashType', ''),
731
+ 'view': UIViewBuilder(renderer).build(
732
+ [s for s in ast.statements if not isinstance(s, ActionNode)]),
733
+ }
734
+ return Response(_json.dumps(corpo, ensure_ascii=False, default=str),
735
+ mimetype='application/vnd.quantum.view+json')
736
+
737
+ try:
738
+ html = renderer.render(ast)
739
+ except PageRedirect as r:
740
+ # A component called from the markup redirected.
741
+ return self._page_redirect(r, runtime)
742
+ # UI-12: a search field refreshes an element by id; one that is not
743
+ # on the page would make every keystroke swap nothing, silently.
744
+ for alvo in sorted(getattr(renderer, '_search_targets', ()) or ()):
745
+ if not re.search(r'(?<![\w-])id="' + re.escape(alvo) + '"', html):
746
+ raise ValueError(f'<ui:input search="{alvo}">: there is no element with id="{alvo}" '
747
+ f'on this page to refresh (UI-12)')
748
+
749
+ # Phase B: For partial requests, return only component HTML
750
+ if partial:
751
+ return Response(html, mimetype='text/html')
752
+
753
+ # For full page requests, add HTMX support
754
+ if '<html' in html.lower():
755
+ # Component already has full HTML structure - inject HTMX into it
756
+ full_html = self._inject_htmx(html)
757
+ else:
758
+ # Fragment component - wrap with full page + HTMX
759
+ from quantum.runtime.ui_view import page_title
760
+ full_html = self._wrap_with_htmx(html, component_path, page_title(ast, renderer))
761
+
762
+ # Extract inline CSS and JS to external files
763
+ full_html = self._extract_inline_assets(full_html)
764
+
765
+ # Pretty-print HTML for readable View Source
766
+ full_html = self._prettify_html(full_html)
767
+
768
+ return Response(full_html, mimetype='text/html')
769
+
770
+ except QuantumParseError as e:
771
+ dev_note(error=str(e))
772
+ # Enhanced parse error with context
773
+ enhanced_error = ErrorHandler.handle_parse_error(e, component_path)
774
+
775
+ if self.config['server']['debug']:
776
+ # Show enhanced error in debug mode
777
+ # The message says what to change; the generic XML hints of
778
+ # ErrorHandler were wrong for most parse errors (a statement in
779
+ # markup is not an XML syntax problem).
780
+ return self._render_error_page(
781
+ title="Parse Error",
782
+ message=f"Could not parse component: {component_path}",
783
+ details=str(e),
784
+ suggestion="",
785
+ location=self._error_location(e, file_path),
786
+ ), 400
787
+ else:
788
+ return self._render_error_page(
789
+ title="Parse Error",
790
+ message="Could not parse component",
791
+ details="Enable debug mode for details",
792
+ suggestion=""
793
+ ), 400
794
+
795
+ except QuantumError as e:
796
+ dev_note(error=str(e))
797
+ logging.getLogger('quantum.server').error("page %s failed: %s", component_path, e)
798
+ # Already enhanced error
799
+ return self._render_error_page(
800
+ title="Quantum Error",
801
+ message=e.message,
802
+ details=str(e),
803
+ suggestion=e.suggestion or "Check the error details above",
804
+ location=self._error_location(e, file_path) if self.config['server']['debug'] else None,
805
+ ), 500
806
+
807
+ except HTTPException:
808
+ # 413 (CFG-2), 400 for a malformed form: Flask answers these with
809
+ # their own status. Caught below they became a 500.
810
+ raise
811
+
812
+ except Exception as e:
813
+ dev_note(error=f'{type(e).__name__}: {e}')
814
+ # The page says little in production; the log must say everything.
815
+ # This branch used to leave no trace at all: a 500 with nothing to read.
816
+ logging.getLogger('quantum.server').exception(
817
+ "page %s failed: %s", component_path, e)
818
+ if self.config['server']['debug']:
819
+ # Enhanced runtime error
820
+ enhanced_error = ErrorHandler.handle_runtime_error(e, str(component_path), component_path)
821
+
822
+ import traceback
823
+ return self._render_error_page(
824
+ title="Runtime Error",
825
+ message=f"Error in component: {component_path}",
826
+ details=str(e),
827
+ suggestion="",
828
+ location=self._error_location(e, file_path),
829
+ python_traceback=traceback.format_exc(),
830
+ ), 500
831
+ else:
832
+ # Generic error in production
833
+ return self._render_error_page(
834
+ title="Error",
835
+ message="An error occurred",
836
+ details="Enable debug mode for more details.",
837
+ suggestion=""
838
+ ), 500
839
+
840
+ def _get_static_dir(self) -> str:
841
+ """Resolve and ensure the static directory exists."""
842
+ static_dir = self.config['paths']['static']
843
+ if not os.path.isabs(static_dir):
844
+ static_dir = os.path.abspath(static_dir)
845
+ os.makedirs(static_dir, exist_ok=True)
846
+ return static_dir
847
+
848
+ def _extract_inline_assets(self, html: str) -> str:
849
+ """
850
+ Extract inline <style> and <script> blocks to external files.
851
+
852
+ Replaces inline CSS with <link rel="stylesheet"> and inline JS
853
+ (without src attribute) with <script src="...">.
854
+
855
+ Args:
856
+ html: Full HTML document string
857
+
858
+ Returns:
859
+ HTML with inline assets replaced by external file references
860
+ """
861
+ static_dir = self._get_static_dir()
862
+
863
+ # --- Extract CSS ---
864
+ style_pattern = re.compile(r'<style[^>]*>(.*?)</style>', re.DOTALL | re.IGNORECASE)
865
+ css_matches = style_pattern.findall(html)
866
+
867
+ if css_matches:
868
+ all_css = '\n'.join(css_matches)
869
+ css_hash = hashlib.md5(all_css.encode()).hexdigest()[:10]
870
+ css_filename = f'styles-{css_hash}.css'
871
+ css_path = os.path.join(static_dir, css_filename)
872
+
873
+ if not os.path.exists(css_path):
874
+ with open(css_path, 'w', encoding='utf-8') as f:
875
+ f.write(all_css)
876
+
877
+ html = style_pattern.sub('', html)
878
+
879
+ # Root-relative on purpose. A bare "static/..." is resolved
880
+ # against the current path, so a page at /admin/projects asked for
881
+ # /admin/static/... and rendered with no CSS at all — silently,
882
+ # since a 404 on a stylesheet does not fail the page.
883
+ link_tag = f'<link rel="stylesheet" href="/static/{css_filename}">'
884
+ if '</head>' in html:
885
+ html = html.replace('</head>', f' {link_tag}\n </head>', 1)
886
+ elif '</HEAD>' in html:
887
+ html = html.replace('</HEAD>', f' {link_tag}\n </HEAD>', 1)
888
+
889
+ # --- Extract inline JS (skip <script src="..."> tags) ---
890
+ script_pattern = re.compile(
891
+ r'<script(?![^>]*\bsrc\b)[^>]*>(.*?)</script>', re.DOTALL | re.IGNORECASE
892
+ )
893
+ js_matches = script_pattern.findall(html)
894
+
895
+ if js_matches:
896
+ all_js = '\n'.join(m.strip() for m in js_matches)
897
+ js_hash = hashlib.md5(all_js.encode()).hexdigest()[:10]
898
+ js_filename = f'scripts-{js_hash}.js'
899
+ js_path = os.path.join(static_dir, js_filename)
900
+
901
+ if not os.path.exists(js_path):
902
+ with open(js_path, 'w', encoding='utf-8') as f:
903
+ f.write(all_js)
904
+
905
+ html = script_pattern.sub('', html)
906
+
907
+ # Root-relative — same reason as the stylesheet above.
908
+ script_tag = f'<script src="/static/{js_filename}"></script>'
909
+ if '</body>' in html:
910
+ html = html.replace('</body>', f' {script_tag}\n </body>', 1)
911
+ elif '</BODY>' in html:
912
+ html = html.replace('</BODY>', f' {script_tag}\n </BODY>', 1)
913
+
914
+ return html
915
+
916
+ def _prettify_html(self, html: str) -> str:
917
+ """
918
+ Pretty-print HTML with indentation for readable View Source output.
919
+
920
+ Adds newlines and 2-space indentation around block-level elements
921
+ while preserving content inside whitespace-sensitive elements
922
+ (<pre>, <textarea>) verbatim.
923
+
924
+ Args:
925
+ html: Full HTML document string
926
+
927
+ Returns:
928
+ Indented HTML string
929
+ """
930
+ BLOCK_ELEMENTS = {
931
+ 'html', 'head', 'body', 'header', 'footer', 'main', 'section',
932
+ 'nav', 'article', 'aside', 'div', 'ul', 'ol', 'li', 'table',
933
+ 'thead', 'tbody', 'tr', 'th', 'td', 'form', 'fieldset',
934
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote',
935
+ 'figure', 'figcaption', 'details', 'summary',
936
+ 'title', 'script', 'style', 'noscript', 'template',
937
+ }
938
+ VOID_ELEMENTS = {
939
+ 'br', 'hr', 'img', 'input', 'meta', 'link', 'col', 'area',
940
+ 'base', 'embed', 'source', 'track', 'wbr',
941
+ }
942
+ INLINE_ELEMENTS = {
943
+ 'a', 'span', 'strong', 'em', 'b', 'i', 'u', 'small', 'sub',
944
+ 'sup', 'abbr', 'cite', 'code', 'label', 'button', 'select',
945
+ 'option', 'time', 'mark',
946
+ }
947
+ PRESERVE_ELEMENTS = {'pre', 'textarea'}
948
+
949
+ # Tokenize: split into tags and text segments
950
+ token_re = re.compile(r'(<[^>]+>)')
951
+ tokens = token_re.split(html)
952
+
953
+ tag_name_re = re.compile(r'^<\s*/?\s*([a-zA-Z][a-zA-Z0-9]*)', re.IGNORECASE)
954
+
955
+ depth = 0
956
+ output = []
957
+ preserve_depth = 0
958
+
959
+ def _at_line_start():
960
+ """Check if we're at the start of a line (last output ends with newline or empty)."""
961
+ return not output or output[-1].endswith('\n')
962
+
963
+ for token in tokens:
964
+ if not token:
965
+ continue
966
+
967
+ is_tag = token.startswith('<')
968
+
969
+ if is_tag:
970
+ tag_match = tag_name_re.match(token)
971
+ tag_name = tag_match.group(1).lower() if tag_match else ''
972
+ is_closing = token.startswith('</')
973
+ is_self_closing = token.rstrip().endswith('/>')
974
+ is_doctype = token.upper().startswith('<!DOCTYPE')
975
+ is_comment = token.startswith('<!--')
976
+
977
+ # Handle preserve elements
978
+ if tag_name in PRESERVE_ELEMENTS:
979
+ if is_closing:
980
+ preserve_depth = max(preserve_depth - 1, 0)
981
+ output.append(token)
982
+ output.append('\n')
983
+ elif not is_self_closing:
984
+ preserve_depth += 1
985
+ indent = ' ' * depth
986
+ if not _at_line_start():
987
+ output.append('\n')
988
+ output.append(f'{indent}{token}')
989
+ else:
990
+ indent = ' ' * depth
991
+ output.append(f'{indent}{token}\n')
992
+ continue
993
+
994
+ # Inside preserved content — output verbatim
995
+ if preserve_depth > 0:
996
+ output.append(token)
997
+ continue
998
+
999
+ if is_doctype or is_comment:
1000
+ indent = ' ' * depth
1001
+ if not _at_line_start():
1002
+ output.append('\n')
1003
+ output.append(f'{indent}{token}\n')
1004
+ elif tag_name in BLOCK_ELEMENTS:
1005
+ if is_closing:
1006
+ depth = max(depth - 1, 0)
1007
+ indent = ' ' * depth
1008
+ if not _at_line_start():
1009
+ output.append('\n')
1010
+ output.append(f'{indent}{token}\n')
1011
+ elif is_self_closing:
1012
+ indent = ' ' * depth
1013
+ if not _at_line_start():
1014
+ output.append('\n')
1015
+ output.append(f'{indent}{token}\n')
1016
+ else:
1017
+ indent = ' ' * depth
1018
+ if not _at_line_start():
1019
+ output.append('\n')
1020
+ output.append(f'{indent}{token}\n')
1021
+ depth += 1
1022
+ elif tag_name in VOID_ELEMENTS:
1023
+ indent = ' ' * depth
1024
+ if not _at_line_start():
1025
+ output.append('\n')
1026
+ output.append(f'{indent}{token}\n')
1027
+ elif tag_name in INLINE_ELEMENTS:
1028
+ # Inline elements stay on same line as surrounding content
1029
+ if _at_line_start():
1030
+ indent = ' ' * depth
1031
+ output.append(f'{indent}{token}')
1032
+ else:
1033
+ output.append(token)
1034
+ else:
1035
+ # Unknown tags — treat as inline
1036
+ output.append(token)
1037
+ else:
1038
+ # Text node
1039
+ if preserve_depth > 0:
1040
+ output.append(token)
1041
+ else:
1042
+ stripped = token.strip()
1043
+ if stripped:
1044
+ if _at_line_start():
1045
+ indent = ' ' * depth
1046
+ output.append(f'{indent}{stripped}\n')
1047
+ else:
1048
+ output.append(stripped)
1049
+
1050
+ result = ''.join(output)
1051
+ # Clean up multiple blank lines
1052
+ result = re.sub(r'\n{3,}', '\n\n', result)
1053
+ return result.strip() + '\n'
1054
+
1055
+ def _get_hot_reload_script(self) -> str:
1056
+ """
1057
+ Get the hot reload client script.
1058
+
1059
+ Returns:
1060
+ JavaScript code for hot reload client
1061
+ """
1062
+ if not self.hot_reload_enabled:
1063
+ return ''
1064
+
1065
+ return f"""
1066
+ <!-- Quantum Hot Reload Client -->
1067
+ <script>
1068
+ window.__QUANTUM_HOT_RELOAD_CONFIG = {{
1069
+ host: 'localhost',
1070
+ port: {self.hot_reload_port},
1071
+ enableLogging: true,
1072
+ enableToasts: true,
1073
+ preserveState: true
1074
+ }};
1075
+ </script>
1076
+ <script>
1077
+ (function() {{
1078
+ 'use strict';
1079
+ var config = window.__QUANTUM_HOT_RELOAD_CONFIG || {{}};
1080
+ var WS_URL = 'ws://' + (config.host || 'localhost') + ':' + (config.port || 35729);
1081
+ var RECONNECT_INTERVAL = 1000;
1082
+ var MAX_RECONNECT_ATTEMPTS = 30;
1083
+ var socket = null;
1084
+ var reconnectAttempts = 0;
1085
+ var overlay = null;
1086
+ var isConnected = false;
1087
+ var preservedState = {{}};
1088
+
1089
+ function log(msg) {{
1090
+ if (config.enableLogging !== false) console.log('[Hot Reload] ' + msg);
1091
+ }}
1092
+
1093
+ function connect() {{
1094
+ if (socket && socket.readyState === WebSocket.OPEN) return;
1095
+ try {{
1096
+ socket = new WebSocket(WS_URL);
1097
+ socket.onopen = function() {{
1098
+ log('Connected to dev server');
1099
+ reconnectAttempts = 0;
1100
+ isConnected = true;
1101
+ hideOverlay();
1102
+ showToast('Hot reload connected', 'success');
1103
+ }};
1104
+ socket.onclose = function() {{
1105
+ isConnected = false;
1106
+ log('Disconnected from dev server');
1107
+ scheduleReconnect();
1108
+ }};
1109
+ socket.onerror = function() {{ log('WebSocket error'); }};
1110
+ socket.onmessage = function(e) {{
1111
+ try {{ handleMessage(JSON.parse(e.data)); }} catch (err) {{}}
1112
+ }};
1113
+ }} catch (e) {{
1114
+ log('Failed to connect: ' + e);
1115
+ scheduleReconnect();
1116
+ }}
1117
+ }}
1118
+
1119
+ function scheduleReconnect() {{
1120
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {{
1121
+ showOverlay({{type: 'connection_lost', message: 'Lost connection to dev server. Restart with: quantum dev'}});
1122
+ return;
1123
+ }}
1124
+ reconnectAttempts++;
1125
+ setTimeout(connect, RECONNECT_INTERVAL);
1126
+ }}
1127
+
1128
+ function handleMessage(data) {{
1129
+ if (data.type === 'reload') {{
1130
+ log('Reload: ' + data.reloadType);
1131
+ if (data.reloadType === 'css') {{
1132
+ reloadCSS();
1133
+ showToast('Styles updated', 'info');
1134
+ }} else {{
1135
+ preserveState();
1136
+ showToast('Reloading...', 'info');
1137
+ setTimeout(function() {{ location.reload(); }}, 100);
1138
+ }}
1139
+ }} else if (data.type === 'error') {{
1140
+ showOverlay(data.error);
1141
+ }} else if (data.type === 'clear_error') {{
1142
+ hideOverlay();
1143
+ }}
1144
+ }}
1145
+
1146
+ function reloadCSS() {{
1147
+ var links = document.querySelectorAll('link[rel="stylesheet"]');
1148
+ var ts = Date.now();
1149
+ links.forEach(function(l) {{ l.href = l.href.split('?')[0] + '?_hr=' + ts; }});
1150
+ log('CSS reloaded');
1151
+ }}
1152
+
1153
+ function preserveState() {{
1154
+ preservedState = {{}};
1155
+ document.querySelectorAll('form').forEach(function(f, i) {{
1156
+ var id = f.id || 'form_' + i;
1157
+ preservedState[id] = {{}};
1158
+ f.querySelectorAll('input,textarea,select').forEach(function(inp) {{
1159
+ if (inp.name) {{
1160
+ preservedState[id][inp.name] = (inp.type === 'checkbox' || inp.type === 'radio') ? inp.checked : inp.value;
1161
+ }}
1162
+ }});
1163
+ }});
1164
+ preservedState._scroll = {{x: window.scrollX, y: window.scrollY}};
1165
+ try {{ sessionStorage.setItem('__quantum_hr_state', JSON.stringify(preservedState)); }} catch(e) {{}}
1166
+ }}
1167
+
1168
+ function restoreState() {{
1169
+ try {{
1170
+ var s = sessionStorage.getItem('__quantum_hr_state');
1171
+ if (!s) return;
1172
+ var state = JSON.parse(s);
1173
+ sessionStorage.removeItem('__quantum_hr_state');
1174
+ Object.keys(state).forEach(function(fid) {{
1175
+ if (fid.startsWith('_')) return;
1176
+ var form = document.getElementById(fid) || document.forms[fid.replace('form_', '')];
1177
+ if (!form) return;
1178
+ Object.keys(state[fid]).forEach(function(n) {{
1179
+ var inp = form.querySelector('[name="' + n + '"]');
1180
+ if (inp) {{
1181
+ if (inp.type === 'checkbox' || inp.type === 'radio') inp.checked = state[fid][n];
1182
+ else inp.value = state[fid][n];
1183
+ }}
1184
+ }});
1185
+ }});
1186
+ if (state._scroll) setTimeout(function() {{ window.scrollTo(state._scroll.x, state._scroll.y); }}, 100);
1187
+ }} catch(e) {{}}
1188
+ }}
1189
+
1190
+ function showOverlay(err) {{
1191
+ hideOverlay();
1192
+ overlay = document.createElement('div');
1193
+ overlay.id = '__quantum_hr_overlay';
1194
+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.9);z-index:999999;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;';
1195
+ var c = document.createElement('div');
1196
+ c.style.cssText = 'max-width:800px;padding:40px;background:#1e1e1e;border-radius:8px;color:#fff;';
1197
+ var titleColor = err.type === 'connection_lost' ? '#ffc107' : '#f44336';
1198
+ c.innerHTML = '<h2 style="color:' + titleColor + ';margin:0 0 16px;">' + (err.type === 'connection_lost' ? 'Connection Lost' : 'Parse Error') + '</h2>' +
1199
+ (err.file ? '<p style="color:#888;margin:0 0 12px;font-size:14px;">File: ' + err.file + '</p>' : '') +
1200
+ '<pre style="background:#2d2d2d;padding:16px;border-radius:4px;overflow:auto;max-height:400px;color:#ff6b6b;white-space:pre-wrap;">' + escapeHtml(err.message) + '</pre>' +
1201
+ '<p style="color:#888;margin:16px 0 0;font-size:13px;">Fix the error and save to reload.</p>';
1202
+ overlay.appendChild(c);
1203
+ document.body.appendChild(overlay);
1204
+ overlay.onclick = function(e) {{ if (e.target === overlay) hideOverlay(); }};
1205
+ }}
1206
+
1207
+ function hideOverlay() {{
1208
+ if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
1209
+ overlay = null;
1210
+ }}
1211
+
1212
+ function showToast(msg, type) {{
1213
+ if (config.enableToasts === false) return;
1214
+ var old = document.getElementById('__quantum_hr_toast');
1215
+ if (old) old.parentNode.removeChild(old);
1216
+ var t = document.createElement('div');
1217
+ t.id = '__quantum_hr_toast';
1218
+ var bg = {{success:'#4caf50',error:'#f44336',warning:'#ff9800',info:'#2196f3'}}[type] || '#2196f3';
1219
+ t.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:12px 20px;background:' + bg + ';color:#fff;border-radius:4px;font-family:system-ui;font-size:14px;z-index:999998;box-shadow:0 2px 8px rgba(0,0,0,0.2);';
1220
+ t.textContent = msg;
1221
+ document.body.appendChild(t);
1222
+ setTimeout(function() {{ if (t.parentNode) {{ t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function() {{ if (t.parentNode) t.parentNode.removeChild(t); }}, 300); }} }}, 2000);
1223
+ }}
1224
+
1225
+ function escapeHtml(text) {{
1226
+ var d = document.createElement('div');
1227
+ d.textContent = text;
1228
+ return d.innerHTML;
1229
+ }}
1230
+
1231
+ setInterval(function() {{
1232
+ if (socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({{type:'ping'}}));
1233
+ }}, 30000);
1234
+
1235
+ if (document.readyState === 'loading') {{
1236
+ document.addEventListener('DOMContentLoaded', function() {{ restoreState(); connect(); }});
1237
+ }} else {{
1238
+ restoreState(); connect();
1239
+ }}
1240
+ }})();
1241
+ </script>"""
1242
+
1243
+ # Attributes and API calls that mean a page actually needs htmx. Kept
1244
+ # deliberately broad — injecting an unused script is wasteful, but failing
1245
+ # to inject a needed one breaks the page, so ambiguity favours injecting.
1246
+ _HTMX_MARKERS = ('hx-', 'data-hx-', 'htmx.')
1247
+
1248
+ def _needs_htmx(self, html: str) -> bool:
1249
+ """Whether this page uses htmx at all.
1250
+
1251
+ Every served page used to get `<script src="https://unpkg.com/...">`
1252
+ unconditionally. Measured across the repo: 5 of 268 .q files carry an
1253
+ htmx marker, so 98% of pages pulled a third-party CDN dependency they
1254
+ never touched — a runtime network requirement for an offline or
1255
+ homelab deployment, which is the audience the project declares
1256
+ (FRAMEWORK_PLAN Fase 0.3), and a script a restrictive CSP blocks
1257
+ outright.
1258
+
1259
+ A handful of other components (htmx_counter.q, htmx_click.q,
1260
+ htmx_add_todo.q) are htmx TARGETS — fragments returned into a page
1261
+ that drives them — and carry no markers of their own. They are served
1262
+ as partials, which never got the injection either way.
1263
+ """
1264
+ return any(marker in html for marker in self._HTMX_MARKERS)
1265
+
1266
+ def _inject_htmx(self, html: str) -> str:
1267
+ """
1268
+ Inject HTMX scripts into an existing full HTML document.
1269
+
1270
+ Used when the component already renders a complete HTML page
1271
+ (e.g. type="page" components), to avoid double-wrapping.
1272
+
1273
+ Args:
1274
+ html: Full HTML document string
1275
+
1276
+ Returns:
1277
+ HTML document with HTMX script and config injected, or unchanged
1278
+ when the page does not use htmx.
1279
+ """
1280
+ if not self._needs_htmx(html):
1281
+ # Still inject hot reload, which is independent of htmx.
1282
+ return self._inject_scripts_only(html, self._get_hot_reload_script())
1283
+
1284
+ htmx_head = f'\n <script src="{self._htmx_url()}"></script>'
1285
+
1286
+ # Guarded: this used to call htmx.config directly, so a CDN that was
1287
+ # slow, blocked or offline turned into "htmx is not defined" on every
1288
+ # page — an error about the framework's own injected script, in an app
1289
+ # that may not use htmx at all.
1290
+ htmx_body = """
1291
+ <script>
1292
+ if (window.htmx) {
1293
+ htmx.config.defaultSwapStyle = "innerHTML";
1294
+ htmx.config.defaultSwapDelay = 0;
1295
+ htmx.config.historyCacheSize = 10;
1296
+ if (window.location.hostname === 'localhost') {
1297
+ htmx.logAll();
1298
+ }
1299
+ } else {
1300
+ console.warn('Quantum: this page uses htmx but the library did not load.');
1301
+ }
1302
+ </script>"""
1303
+
1304
+ # Add hot reload script if enabled
1305
+ hot_reload_script = self._get_hot_reload_script()
1306
+
1307
+ # Inject HTMX library before </head>
1308
+ if '</head>' in html:
1309
+ html = html.replace('</head>', htmx_head + '\n </head>', 1)
1310
+ elif '</HEAD>' in html:
1311
+ html = html.replace('</HEAD>', htmx_head + '\n </HEAD>', 1)
1312
+
1313
+ # Inject HTMX config and hot reload script before </body>
1314
+ return self._inject_scripts_only(html, htmx_body + hot_reload_script)
1315
+
1316
+ def _htmx_url(self) -> str:
1317
+ """Where to load htmx from.
1318
+
1319
+ Prefers a vendored copy at static/vendor/htmx.min.js so an offline or
1320
+ air-gapped deployment works; falls back to the CDN. Vendoring the file
1321
+ is the remaining half of this fix — see DOGFOOD_NOTES.md item 8.
1322
+ """
1323
+ vendored = Path(self.config['paths']['static']) / 'vendor' / 'htmx.min.js'
1324
+ if vendored.is_file():
1325
+ return '/static/vendor/htmx.min.js'
1326
+ return self.config.get('server', {}).get(
1327
+ 'htmx_url', 'https://unpkg.com/htmx.org@1.9.10'
1328
+ )
1329
+
1330
+ def _inject_scripts_only(self, html: str, scripts: str) -> str:
1331
+ """Append scripts before </body> and guarantee a DOCTYPE."""
1332
+ if scripts:
1333
+ if '</body>' in html:
1334
+ html = html.replace('</body>', scripts + '\n </body>', 1)
1335
+ elif '</BODY>' in html:
1336
+ html = html.replace('</BODY>', scripts + '\n </BODY>', 1)
1337
+
1338
+ # Ensure DOCTYPE is present. A .q cannot emit one itself — a DOCTYPE is
1339
+ # only valid before the root element, and the root element is
1340
+ # <q:component>. See DOGFOOD_NOTES.md item 4.
1341
+ if not html.strip().lower().startswith('<!doctype'):
1342
+ html = '<!DOCTYPE html>\n' + html
1343
+
1344
+ return html
1345
+
1346
+ def _wrap_with_htmx(self, html: str, component_path: str, title: str = '') -> str:
1347
+ """
1348
+ Wrap component HTML with HTMX library support (Phase B).
1349
+
1350
+ Adds:
1351
+ - HTMX library from CDN
1352
+ - Minimal CSS reset
1353
+ - HTMX configuration
1354
+ - Hot reload client (when enabled)
1355
+
1356
+ Args:
1357
+ html: Rendered component HTML
1358
+ component_path: Component name for title
1359
+
1360
+ Returns:
1361
+ Full HTML page with HTMX support
1362
+ """
1363
+ hot_reload_script = self._get_hot_reload_script()
1364
+
1365
+ # Only when the fragment actually uses htmx — see _needs_htmx().
1366
+ #
1367
+ # The config block is emitted with the tag, not independently. Making
1368
+ # only the <script src> conditional left `htmx.config...` running on
1369
+ # every page with the library absent, which turned a page that merely
1370
+ # did not use htmx into a page that threw "htmx is not defined" — a
1371
+ # regression this path introduced and the full-document path did not,
1372
+ # because only that one had been examined.
1373
+ uses_htmx = self._needs_htmx(html)
1374
+ htmx_tag = (f'<script src="{self._htmx_url()}"></script>'
1375
+ if uses_htmx else '')
1376
+ htmx_config = """<script>
1377
+ if (window.htmx) {
1378
+ htmx.config.defaultSwapStyle = "innerHTML";
1379
+ htmx.config.defaultSwapDelay = 0;
1380
+ htmx.config.historyCacheSize = 10;
1381
+ if (window.location.hostname === 'localhost') {
1382
+ htmx.logAll();
1383
+ }
1384
+ } else {
1385
+ console.warn('Quantum: this page uses htmx but the library did not load.');
1386
+ }
1387
+ </script>""" if uses_htmx else ''
1388
+
1389
+ return f"""<!DOCTYPE html>
1390
+ <html lang="en">
1391
+ <head>
1392
+ <meta charset="UTF-8">
1393
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1394
+ <title>{html_escape(title or component_path)}</title>
1395
+
1396
+ {htmx_tag}
1397
+
1398
+ <style>
1399
+ /* Minimal CSS reset */
1400
+ * {{
1401
+ margin: 0;
1402
+ padding: 0;
1403
+ box-sizing: border-box;
1404
+ }}
1405
+
1406
+ body {{
1407
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
1408
+ line-height: 1.6;
1409
+ }}
1410
+
1411
+ /* HTMX Loading indicators */
1412
+ .htmx-indicator {{
1413
+ display: none;
1414
+ }}
1415
+
1416
+ .htmx-request .htmx-indicator {{
1417
+ display: inline;
1418
+ }}
1419
+
1420
+ .htmx-request.htmx-indicator {{
1421
+ display: inline;
1422
+ }}
1423
+
1424
+ /* Loading spinner */
1425
+ .htmx-indicator:after {{
1426
+ content: "⏳";
1427
+ margin-left: 8px;
1428
+ }}
1429
+ </style>
1430
+ </head>
1431
+ <body>
1432
+ {html}
1433
+
1434
+ {htmx_config}
1435
+ {hot_reload_script}
1436
+ </body>
1437
+ </html>"""
1438
+
1439
+ def _render_welcome_page(self) -> Response:
1440
+ """
1441
+ Render welcome page when index.q doesn't exist.
1442
+
1443
+ Returns:
1444
+ Flask Response with welcome HTML
1445
+ """
1446
+ components_dir = self.config['paths']['components']
1447
+
1448
+ html = f"""
1449
+ <!DOCTYPE html>
1450
+ <html lang="en">
1451
+ <head>
1452
+ <meta charset="UTF-8">
1453
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1454
+ <title>Welcome to Quantum</title>
1455
+ <style>
1456
+ body {{
1457
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1458
+ max-width: 800px;
1459
+ margin: 50px auto;
1460
+ padding: 20px;
1461
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1462
+ color: white;
1463
+ }}
1464
+ .container {{
1465
+ background: rgba(255, 255, 255, 0.1);
1466
+ backdrop-filter: blur(10px);
1467
+ border-radius: 15px;
1468
+ padding: 40px;
1469
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
1470
+ }}
1471
+ h1 {{ margin-top: 0; font-size: 3em; }}
1472
+ code {{
1473
+ background: rgba(0, 0, 0, 0.3);
1474
+ padding: 2px 8px;
1475
+ border-radius: 4px;
1476
+ font-family: 'Courier New', monospace;
1477
+ }}
1478
+ .box {{
1479
+ background: rgba(255, 255, 255, 0.1);
1480
+ padding: 20px;
1481
+ border-radius: 10px;
1482
+ margin: 20px 0;
1483
+ }}
1484
+ a {{
1485
+ color: #fff;
1486
+ text-decoration: underline;
1487
+ }}
1488
+ </style>
1489
+ </head>
1490
+ <body>
1491
+ <div class="container">
1492
+ <h1>🚀 Quantum is Running!</h1>
1493
+ <p>Your Quantum server is successfully running and ready to serve components.</p>
1494
+
1495
+ <div class="box">
1496
+ <h2>🎯 Quick Start</h2>
1497
+ <p>Create your first component:</p>
1498
+ <p><code>mkdir -p {components_dir}</code></p>
1499
+ <p><code>nano {components_dir}/index.q</code></p>
1500
+ <pre style="background: rgba(0,0,0,0.3); padding: 15px; border-radius: 8px;">
1501
+ &lt;q:component name="HomePage"&gt;
1502
+ &lt;html&gt;
1503
+ &lt;body&gt;
1504
+ &lt;h1&gt;Hello from Quantum!&lt;/h1&gt;
1505
+ &lt;p&gt;This is my first component.&lt;/p&gt;
1506
+ &lt;/body&gt;
1507
+ &lt;/html&gt;
1508
+ &lt;/q:component&gt;</pre>
1509
+ <p>Then refresh this page!</p>
1510
+ </div>
1511
+
1512
+ <div class="box">
1513
+ <h2>📚 Learn More</h2>
1514
+ <ul>
1515
+ <li><a href="https://github.com/danielgregorio/quantum">Documentation</a></li>
1516
+ <li><a href="/examples">Example Components</a></li>
1517
+ <li><a href="/static">Static Files</a></li>
1518
+ </ul>
1519
+ </div>
1520
+
1521
+ <div class="box">
1522
+ <h2>⚙️ Configuration</h2>
1523
+ <p>Server is running with:</p>
1524
+ <ul>
1525
+ <li>Port: <code>{self.config['server']['port']}</code></li>
1526
+ <li>Components directory: <code>{components_dir}</code></li>
1527
+ <li>Debug mode: <code>{self.config['server'].get('debug', False)}</code></li>
1528
+ <li>Auto-reload: <code>{self.config['server'].get('reload', False)}</code></li>
1529
+ </ul>
1530
+ <p>Edit <code>quantum.config.yaml</code> to customize.</p>
1531
+ </div>
1532
+ </div>
1533
+ </body>
1534
+ </html>
1535
+ """
1536
+
1537
+ return Response(html, mimetype='text/html')
1538
+
1539
+
1540
+ def _edit_cell(self, ast, path_params) -> Response:
1541
+ """UI-13: the generated action of an editable <ui:table>.
1542
+
1543
+ It edits only a table and column the page declares editable, runs the
1544
+ page's guards first (AUTH-6), validates the value with the column's
1545
+ rules from the schema (UI-10) and updates one row by its key through
1546
+ the database service — so /_dev shows it like any action."""
1547
+ from quantum.runtime.table_params import TableParamsError, columns_of, param_from_column
1548
+ from quantum.runtime.ui_table_edit import EDIT_ACTION, find_editable_table
1549
+ from quantum.runtime.action_handler import ValidationError
1550
+
1551
+ tabela = request.form.get('__table', '')
1552
+ coluna = request.form.get('__column', '')
1553
+ chave = request.form.get('__key', '')
1554
+ valor = request.form.get('value')
1555
+ dev_note(action=f'{EDIT_ACTION} {tabela}.{coluna}')
1556
+ alvo = find_editable_table(ast, tabela, coluna)
1557
+ if alvo is None:
1558
+ return Response(f'<h1>400 Bad Request</h1><p>{html_escape(tabela)}.{html_escape(coluna)} '
1559
+ f'is not editable on this page.</p>', status=400, mimetype='text/html')
1560
+
1561
+ guard_runtime = ComponentRuntime(config=self.config)
1562
+ try:
1563
+ guard_runtime.run_guards(ast, self._page_params(path_params, consume_flash=False))
1564
+ except PageRedirect as r:
1565
+ return self._page_redirect(r, guard_runtime)
1566
+
1567
+ volta = request.referrer or request.path
1568
+ try:
1569
+ colunas = {c.name: c for c in columns_of(self.config, alvo.edit_datasource, tabela)}
1570
+ except TableParamsError as exc:
1571
+ raise ValueError(f'<ui:table edit="{tabela}">: {exc} (UI-13)') from exc
1572
+ chaves = [c for c in colunas.values() if c.primary_key]
1573
+ if coluna not in colunas or colunas[coluna].primary_key or len(chaves) != 1:
1574
+ return Response('<h1>400 Bad Request</h1>', status=400, mimetype='text/html')
1575
+ param = param_from_column(colunas[coluna])
1576
+ param.name = 'value'
1577
+
1578
+ class _Acao: # what the validation reads of an action: where FKs are
1579
+ table_datasource = alvo.edit_datasource
1580
+ try:
1581
+ validados = ActionHandler(guard_runtime)._validate_parameters(
1582
+ [param], {'value': valor} if valor is not None else {}, _Acao)
1583
+ except ValidationError as erro:
1584
+ self.action_handler._set_flash_message(str(erro), 'error')
1585
+ session['form_state'] = {'action': EDIT_ACTION, 'cell': f'{coluna}@{chave}',
1586
+ 'errors': {'value': erro.fields.get('value', str(erro))},
1587
+ 'values': {'value': valor or ''}}
1588
+ session.modified = True
1589
+ return redirect(volta)
1590
+ novo = validados.get('value')
1591
+ if str(param.type) == 'boolean':
1592
+ novo = 1 if str(novo).lower() in ('true', '1', 'on', 'yes') else 0
1593
+ chave_valor = int(chave) if 'INT' in (chaves[0].type or '').upper() and chave.lstrip('-').isdigit() else chave
1594
+ resultado = guard_runtime.services.database.execute_query(
1595
+ alvo.edit_datasource,
1596
+ f'UPDATE "{tabela}" SET "{coluna}" = :valor WHERE "{chaves[0].name}" = :chave',
1597
+ {'valor': novo, 'chave': chave_valor})
1598
+ if not resultado.affected_rows:
1599
+ self.action_handler._set_flash_message(f'No row of {tabela} with {chaves[0].name} = {chave}', 'error')
1600
+ else:
1601
+ self.action_handler._set_flash_message(f'Saved: {coluna}', 'success')
1602
+ return redirect(volta)
1603
+
1604
+ @staticmethod
1605
+ def _dev_secret_key(config_path: str) -> Optional[str]:
1606
+ """The development session key, kept in .quantum/dev-secret-key next to the config.
1607
+
1608
+ Only with `debug: true` and no configured key. Without it the key was
1609
+ random per process, and the reloader starts a new process on every
1610
+ save — every session (a login) was lost each time a file changed.
1611
+ """
1612
+ arquivo = Path(config_path).resolve().parent / '.quantum' / 'dev-secret-key'
1613
+ try:
1614
+ if arquivo.exists():
1615
+ chave = arquivo.read_text(encoding='utf-8').strip()
1616
+ if chave:
1617
+ return chave
1618
+ arquivo.parent.mkdir(parents=True, exist_ok=True)
1619
+ chave = secrets.token_hex(32)
1620
+ arquivo.write_text(chave, encoding='utf-8')
1621
+ try:
1622
+ os.chmod(arquivo, 0o600)
1623
+ except OSError:
1624
+ pass
1625
+ return chave
1626
+ except OSError:
1627
+ return None # read-only folder: a random key, as before
1628
+
1629
+ @staticmethod
1630
+ def _error_location(exc, default_file):
1631
+ """DEV-2: (file, line) of the .q line an error comes from, or None."""
1632
+ from quantum.core.xml_lines import error_location
1633
+ arquivo, linha = error_location(exc)
1634
+ if not linha:
1635
+ return None
1636
+ return (arquivo or str(default_file), linha)
1637
+
1638
+ @staticmethod
1639
+ def _source_snippet(arquivo: str, linha: int, contexto: int = 3) -> str:
1640
+ """The lines around `linha`, the failing one marked (DEV-2)."""
1641
+ try:
1642
+ linhas = Path(arquivo).read_text(encoding='utf-8').splitlines()
1643
+ except OSError:
1644
+ return ''
1645
+ if not 0 < linha <= len(linhas):
1646
+ return ''
1647
+ try:
1648
+ arquivo = os.path.relpath(arquivo)
1649
+ except ValueError:
1650
+ pass # another drive (Windows)
1651
+ partes = []
1652
+ for n in range(max(1, linha - contexto), min(len(linhas), linha + contexto) + 1):
1653
+ classe = ' class="marked"' if n == linha else ''
1654
+ partes.append(f'<tr{classe}><td class="ln">{n}</td>'
1655
+ f'<td><code>{html_escape(linhas[n - 1]) or " "}</code></td></tr>')
1656
+ return ('<div class="source"><div class="where">' + html_escape(arquivo) + f', line {linha}</div>'
1657
+ '<table>' + ''.join(partes) + '</table></div>')
1658
+
1659
+ def _render_error_page(self, title: str, message: str, details: str, suggestion: str,
1660
+ location=None, python_traceback: str = '') -> str:
1661
+ """
1662
+ Render error page with helpful information.
1663
+
1664
+ Everything is escaped: an error message can carry what the request
1665
+ sent (a query value, a form field). In debug mode, the page also shows
1666
+ the .q lines around the one that failed (DEV-2), links the SPEC rules
1667
+ the message cites, and the Python traceback, folded.
1668
+ """
1669
+ e = html_escape
1670
+ debug = self.config['server'].get('debug')
1671
+ trecho = self._source_snippet(*location) if (location and debug) else ''
1672
+ regras = ''
1673
+ if debug:
1674
+ ids = sorted(set(re.findall(r'\b(?:PARSE|RET|LOOP|IF|FN|ACT|ROUTE|COMP|DB|SET|AUTH|DATA|IA|EXPR|INV'
1675
+ r'|RUN|SCOPE|ERR|CFG|APP|SVC|UI|EXEC|DEV)-\d+\b',
1676
+ f'{message}\n{details}')))
1677
+ if ids:
1678
+ regras = '<p class="rules">Rules: ' + ', '.join(
1679
+ f'<a href="https://github.com/danielgregorio/quantum/blob/main/SPEC.md#:~:text={i}">{i}</a>'
1680
+ for i in ids) + '</p>'
1681
+ rastro = (f'<details class="trace"><summary>Python traceback</summary>'
1682
+ f'<div class="details">{e(python_traceback)}</div></details>') if (python_traceback and debug) else ''
1683
+ html = f"""
1684
+ <!DOCTYPE html>
1685
+ <html lang="en">
1686
+ <head>
1687
+ <meta charset="UTF-8">
1688
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1689
+ <title>{e(title)}</title>
1690
+ <style>
1691
+ body {{
1692
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1693
+ max-width: 900px;
1694
+ margin: 50px auto;
1695
+ padding: 20px;
1696
+ background: #f5f5f5;
1697
+ }}
1698
+ .error-container {{
1699
+ background: white;
1700
+ border-radius: 10px;
1701
+ padding: 40px;
1702
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
1703
+ border-left: 5px solid #e74c3c;
1704
+ }}
1705
+ h1 {{
1706
+ color: #e74c3c;
1707
+ margin-top: 0;
1708
+ }}
1709
+ .details {{
1710
+ background: #f9f9f9;
1711
+ border: 1px solid #ddd;
1712
+ padding: 15px;
1713
+ border-radius: 5px;
1714
+ font-family: 'Courier New', monospace;
1715
+ white-space: pre-wrap;
1716
+ overflow-x: auto;
1717
+ }}
1718
+ .suggestion {{
1719
+ background: #e8f5e9;
1720
+ border-left: 4px solid #4caf50;
1721
+ padding: 15px;
1722
+ margin-top: 20px;
1723
+ }}
1724
+ .source {{ margin: 16px 0; border: 1px solid #ddd; border-radius: 5px; overflow-x: auto; }}
1725
+ .source .where {{ background: #f0f0f0; padding: 6px 10px; font-size: 13px; color: #555; }}
1726
+ .source table {{ border-collapse: collapse; width: 100%; }}
1727
+ .source td {{ padding: 1px 10px; font: 13px/1.5 ui-monospace, 'Courier New', monospace; white-space: pre; }}
1728
+ .source td.ln {{ color: #999; text-align: right; width: 1%; user-select: none; }}
1729
+ .source tr.marked td {{ background: #fdecea; }}
1730
+ .source tr.marked td.ln {{ color: #c0392b; font-weight: bold; }}
1731
+ .trace {{ margin-top: 16px; }}
1732
+ @media (max-width: 600px) {{ body {{ margin: 0; padding: 12px; }} .error-container {{ padding: 16px; }} }}
1733
+ </style>
1734
+ </head>
1735
+ <body>
1736
+ <div class="error-container">
1737
+ <h1>⚠️ {e(title)}</h1>
1738
+ <p><strong>{e(message)}</strong></p>
1739
+
1740
+ {trecho}
1741
+
1742
+ {f'<div class="details">{e(details)}</div>' if details else ''}
1743
+
1744
+ {regras}
1745
+
1746
+ {f'<div class="suggestion"><strong>💡 Suggestion:</strong> {e(suggestion)}</div>' if suggestion else ''}
1747
+
1748
+ {rastro}
1749
+
1750
+ <p style="margin-top: 30px;">
1751
+ <a href="/">← Go back to home</a>
1752
+ </p>
1753
+ </div>
1754
+ </body>
1755
+ </html>
1756
+ """
1757
+
1758
+ return html
1759
+
1760
+
1761
+ def _page_params(self, path_params, consume_flash: bool = True) -> Dict[str, Any]:
1762
+ """The variables and scopes a page runs with (render, and guards before an action)."""
1763
+ # Prepare request parameters for component execution
1764
+ params = {}
1765
+
1766
+ # Add query parameters
1767
+ query_params = dict(request.args)
1768
+ if query_params:
1769
+ params['query'] = query_params
1770
+
1771
+ # Add form data if POST request (for non-action processing)
1772
+ if request.method == 'POST':
1773
+ form_data = dict(request.form)
1774
+ params['form'] = form_data
1775
+
1776
+ # Get flash message if present
1777
+ # ACT-3: always defined — '' when there is no message — so a page can
1778
+ # pass {flash} to a layout component without failing when there is
1779
+ # none (a missing name in a prop is an error, COMP-2).
1780
+ # Guards before an action read it without taking it from the next page.
1781
+ flash_data = (self.action_handler.get_flash_message() if consume_flash
1782
+ else session.get('flash'))
1783
+ params['flash'] = flash_data['message'] if flash_data else ''
1784
+ params['flashType'] = flash_data['type'] if flash_data else ''
1785
+
1786
+ # Phase F: Setup scopes for ExecutionContext
1787
+ # Session scope - user-specific, persistent (from Flask session)
1788
+ if 'quantum_session' not in session:
1789
+ session['quantum_session'] = {}
1790
+ params['_session_scope'] = session['quantum_session']
1791
+
1792
+ # Application scope - global, shared across all users
1793
+ params['_application_scope'] = self.application_scope
1794
+
1795
+ # Request scope - request-specific, cleared after response
1796
+ params['_request_scope'] = {
1797
+ 'method': request.method,
1798
+ 'path': request.path,
1799
+ 'url': request.url
1800
+ }
1801
+
1802
+ # Inject dynamic route path parameters (e.g. [name] -> name="blog")
1803
+ if path_params:
1804
+ params['path_params'] = path_params
1805
+ for key, value in path_params.items():
1806
+ params[key] = value
1807
+ return params
1808
+
1809
+ def _page_redirect(self, r, runtime=None):
1810
+ """Turn a q:redirect outside an action (PageRedirect) into the response."""
1811
+ if runtime is not None:
1812
+ # A logout page clears the session and redirects: keep what it set.
1813
+ session['quantum_session'] = runtime.execution_context.session_vars
1814
+ if r.flash:
1815
+ self.action_handler._set_flash_message(r.flash, r.flash_type)
1816
+ session.modified = True
1817
+ return redirect(r.url, code=r.status)
1818
+
1819
+ def _find_action_in_component(self, ast) -> Optional[ActionNode]:
1820
+ """
1821
+ Find q:action statement in component AST.
1822
+
1823
+ When a form submits a hidden field named 'action' with the action name,
1824
+ this method matches it to the correct ActionNode. If no 'action' field
1825
+ is provided or no match is found, falls back to the first ActionNode.
1826
+
1827
+ Args:
1828
+ ast: Component AST
1829
+
1830
+ Returns:
1831
+ ActionNode if found, None otherwise
1832
+ """
1833
+ # Check if component has statements
1834
+ if not hasattr(ast, 'statements'):
1835
+ return None
1836
+
1837
+ # Collect all ActionNodes
1838
+ action_nodes = [s for s in ast.statements if isinstance(s, ActionNode)]
1839
+
1840
+ if not action_nodes:
1841
+ return None
1842
+
1843
+ # If only one action, return it directly
1844
+ if len(action_nodes) == 1:
1845
+ return action_nodes[0]
1846
+
1847
+ # Multiple actions: match by 'action' form field (ACT-1)
1848
+ requested_action = request.form.get('action', '')
1849
+ for node in action_nodes:
1850
+ if requested_action and getattr(node, 'name', '') == requested_action:
1851
+ return node
1852
+
1853
+ # ACT-5: no silent fallback. This used to run the FIRST action when the
1854
+ # name was missing or matched nothing — a POST meant for "excluir" with
1855
+ # a typo ran "criar".
1856
+ available = ', '.join(getattr(n, 'name', '?') for n in action_nodes)
1857
+ if requested_action:
1858
+ raise UnknownActionError(
1859
+ f"No q:action named '{requested_action}' on this page. Available: {available}.")
1860
+ raise UnknownActionError(
1861
+ f"This page has several q:action ({available}); the form must say which one "
1862
+ f"with a field named 'action'.")
1863
+
1864
+
1865
+ def _count_component_files(self) -> int:
1866
+ """Count .q files in the components directory."""
1867
+ components_dir = Path(self.config['paths']['components'])
1868
+ if not components_dir.is_dir():
1869
+ return 0
1870
+ return sum(1 for _ in components_dir.rglob('*.q'))
1871
+
1872
+ def _count_routes(self):
1873
+ """Return (static_count, dynamic_count) of registered routes."""
1874
+ static_count = len([r for r in self.app.url_map.iter_rules()
1875
+ if '<' not in str(r)])
1876
+ dynamic_count = len(self._dynamic_routes)
1877
+ return static_count, dynamic_count
1878
+
1879
+ def _print_banner(self):
1880
+ """Print startup banner with server information."""
1881
+ port = self.config['server']['port']
1882
+ components_dir = self.config['paths']['components']
1883
+ log_config = self.config.get('logging', {})
1884
+ startup_ms = (time.monotonic() - self._start_time) * 1000
1885
+ file_count = self._count_component_files()
1886
+ static_routes, dynamic_routes = self._count_routes()
1887
+
1888
+ print()
1889
+ print("=" * 60)
1890
+ print(" QUANTUM WEB SERVER")
1891
+ print("=" * 60)
1892
+ print(f" URL: http://localhost:{port}")
1893
+ print(f" Components: {components_dir} ({file_count} files)")
1894
+ print(f" Routes: {static_routes} static + {dynamic_routes} dynamic")
1895
+ print(f" Log level: {log_config.get('level', 'INFO')}")
1896
+ if log_config.get('file', True):
1897
+ print(f" Log file: {log_config.get('filename', 'quantum.log')}")
1898
+ print(f" Auto-reload: {self.config['server'].get('reload', False)}")
1899
+ print(f" Debug mode: {self.config['server'].get('debug', False)}")
1900
+ if self.dev_panel is not None:
1901
+ print(f" Dev panel: http://localhost:{port}/_dev")
1902
+ if self.hot_reload_enabled:
1903
+ print(f" Hot Reload: ws://localhost:{self.hot_reload_port}")
1904
+ print(f" Startup time: {startup_ms:.0f}ms")
1905
+ print("=" * 60)
1906
+ print(" Press Ctrl+C to stop")
1907
+ print("=" * 60)
1908
+ print()
1909
+
1910
+ def _is_within_components(self, component_path: str) -> bool:
1911
+ """True only if `<components_dir>/<component_path>.q` stays inside the
1912
+ components directory after resolving `..`, symlinks and separators.
1913
+
1914
+ Also guards the index.q and dynamic-route lookups, since both derive
1915
+ their path from the same untrusted `component_path`.
1916
+ """
1917
+ # Reject the obvious traversal tokens outright — cheaper than resolving,
1918
+ # and covers the raw-socket `..` and backslash cases on Windows.
1919
+ if '..' in component_path.replace('\\', '/').split('/'):
1920
+ return False
1921
+ try:
1922
+ root = Path(self.config['paths']['components']).resolve()
1923
+ target = (root / f'{component_path}.q').resolve()
1924
+ except (OSError, ValueError, RuntimeError):
1925
+ return False
1926
+ # Python 3.9+: is_relative_to. The candidate must sit under the root.
1927
+ return target == root or root in target.parents
1928
+
1929
+ def _check_port_available(self, host: str, port: int) -> bool:
1930
+ """Check if a port is available for binding."""
1931
+ try:
1932
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1933
+ s.settimeout(1)
1934
+ result = s.connect_ex(('127.0.0.1' if host == '0.0.0.0' else host, port))
1935
+ return result != 0 # 0 means connection succeeded → port in use
1936
+ except OSError:
1937
+ return True # If we can't connect, assume port is free
1938
+
1939
+ def _login_url(self) -> str:
1940
+ """AUTH-4: where require_auth sends a visitor (security.login_url, default /login)."""
1941
+ # Validated at startup by _validate_config (a local path only).
1942
+ return ((self.config.get('security') or {}).get('login_url') or '/login').strip()
1943
+
1944
+ def _write_pid_file(self, is_reloader_child: bool = False):
1945
+ """Write .quantum.pid: this process, plus the parent under the reloader.
1946
+
1947
+ With `reload: true` Werkzeug serves from a CHILD process. Recording
1948
+ only the parent meant `quantum stop` killed the parent and left the
1949
+ child listening on the port. `quantum stop` reads every line.
1950
+ """
1951
+ pids = [os.getppid(), os.getpid()] if is_reloader_child else [os.getpid()]
1952
+ try:
1953
+ Path(self.PID_FILE).write_text(
1954
+ '\n'.join(map(str, pids)) + '\n', encoding='utf-8')
1955
+ except OSError as e:
1956
+ self.logger.warning(f"Could not write PID file: {e}")
1957
+
1958
+ def _remove_pid_file(self):
1959
+ """Remove .quantum.pid if it exists.
1960
+
1961
+ Only the process that started the server owns the file. The reloader
1962
+ child exits with code 3 on EVERY code change to be restarted, and its
1963
+ cleanup used to delete the file — so after the first hot reload
1964
+ `quantum stop` reported "No running server found" while it was up.
1965
+ """
1966
+ if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
1967
+ return
1968
+ try:
1969
+ Path(self.PID_FILE).unlink(missing_ok=True)
1970
+ except OSError:
1971
+ pass
1972
+
1973
+ def _cleanup(self):
1974
+ """Cleanup on shutdown: remove PID file, log shutdown."""
1975
+ self._remove_pid_file()
1976
+ self.logger.info("Server stopped")
1977
+
1978
+ def _register_signal_handlers(self):
1979
+ """Register signal handlers for graceful shutdown."""
1980
+ def _handler(signum, frame):
1981
+ self._cleanup()
1982
+ sys.exit(0)
1983
+
1984
+ signal.signal(signal.SIGINT, _handler)
1985
+ signal.signal(signal.SIGTERM, _handler)
1986
+ # Windows-specific: CTRL_BREAK_EVENT
1987
+ if hasattr(signal, 'SIGBREAK'):
1988
+ signal.signal(signal.SIGBREAK, _handler)
1989
+
1990
+ def start(self):
1991
+ """Start the Quantum web server."""
1992
+ host = self.config['server']['host']
1993
+ port = self.config['server']['port']
1994
+ debug = self.config['server'].get('debug', False)
1995
+ reload = self.config['server'].get('reload', False)
1996
+
1997
+ # The Werkzeug debugger is an eval console. Never expose it on a
1998
+ # non-loopback interface, even if the config asks for it — that is
1999
+ # unauthenticated RCE over the network. Drop the debugger rather than
2000
+ # refusing to start, so a misconfiguration degrades safely.
2001
+ if debug and host not in ('127.0.0.1', 'localhost', '::1'):
2002
+ self.logger.error(
2003
+ f"Refusing to run the debugger (debug: true) on {host}: the "
2004
+ f"Werkzeug console would be an eval prompt open to the network. "
2005
+ f"Serving with the debugger OFF. Use host 127.0.0.1 for a local "
2006
+ f"debug session."
2007
+ )
2008
+ debug = False
2009
+
2010
+ # Werkzeug's reloader re-executes this whole program in a child
2011
+ # process and sets WERKZEUG_RUN_MAIN in it. Without this guard the
2012
+ # child ran the port check, found the PARENT's own socket bound,
2013
+ # reported "port already in use" and returned — so `quantum start`
2014
+ # printed its success banner and then died, with nothing listening.
2015
+ # `reload: true` is the shipped default in quantum.config.yaml, which
2016
+ # means the documented way to start the server could not start it.
2017
+ is_reloader_child = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
2018
+
2019
+ if not is_reloader_child and not self._check_port_available(host, port):
2020
+ self.logger.error(
2021
+ f"Port {port} already in use. Stop the other process, or set "
2022
+ f"server.port in quantum.config.yaml."
2023
+ )
2024
+ # Non-zero: a supervisor, container healthcheck or CI step must be
2025
+ # able to tell a dead server from a live one. This used to return
2026
+ # None, so the CLI exited 0 while nothing was listening.
2027
+ return 1
2028
+
2029
+ self._write_pid_file(is_reloader_child)
2030
+ self._register_signal_handlers()
2031
+ self._print_banner()
2032
+
2033
+ try:
2034
+ self.app.run(
2035
+ host=host,
2036
+ port=port,
2037
+ debug=debug,
2038
+ use_reloader=reload
2039
+ )
2040
+ except KeyboardInterrupt:
2041
+ self.logger.info("Stopped by user")
2042
+ except OSError as e:
2043
+ # Losing the bind after the banner printed is the case most likely
2044
+ # to be mistaken for success.
2045
+ self.logger.error(f"Could not bind {host}:{port} — {e}")
2046
+ return 1
2047
+ except Exception as e:
2048
+ self.logger.error(f"Server error: {e}")
2049
+ return 1
2050
+ finally:
2051
+ self._cleanup()
2052
+ return 0
2053
+
2054
+
2055
+ # Convenience function for CLI
2056
+ def start_server(
2057
+ config_path: str = 'quantum.config.yaml',
2058
+ port: int = None,
2059
+ hot_reload: bool = False,
2060
+ hot_reload_port: int = None
2061
+ ):
2062
+ """
2063
+ Start Quantum web server.
2064
+
2065
+ Args:
2066
+ config_path: Path to configuration file
2067
+ port: Override port from config (optional)
2068
+ hot_reload: Enable hot reload mode
2069
+ hot_reload_port: WebSocket port for hot reload
2070
+ """
2071
+ server = QuantumWebServer(
2072
+ config_path,
2073
+ hot_reload=hot_reload,
2074
+ hot_reload_port=hot_reload_port
2075
+ )
2076
+
2077
+ # Override port if specified
2078
+ if port is not None:
2079
+ server.config['server']['port'] = port
2080
+
2081
+ return server.start()
2082
+
2083
+
2084
+ def create_app(config_path: str = 'quantum.config.yaml') -> Flask:
2085
+ """
2086
+ Application factory for a real WSGI server. This is the production path.
2087
+
2088
+ gunicorn 'quantum.runtime.web_server:create_app()' \
2089
+ --bind 0.0.0.0:8080 --workers 4
2090
+
2091
+ (The example here used to say `src.runtime.web_server`, the layout that
2092
+ stopped existing when src/ became the quantum/ package — so the one
2093
+ documented production entry point did not import.)
2094
+
2095
+ `quantum start` runs Flask's DEVELOPMENT server: single-process, not
2096
+ hardened, and now bound to loopback by default. Use it while writing the
2097
+ app, not to serve it.
2098
+
2099
+ Two things to set when you deploy behind gunicorn:
2100
+
2101
+ - QUANTUM_SECRET_KEY, or security.secret_key in the config. Without it
2102
+ each worker generates its OWN random key at import, so a session cookie
2103
+ signed by one worker is rejected by the next and users are logged out at
2104
+ random.
2105
+ - server.host: 0.0.0.0 in the config if the container needs it. Leave
2106
+ server.debug false — the server refuses the debugger off loopback
2107
+ anyway, because it is an eval console.
2108
+
2109
+ Args:
2110
+ config_path: Path to configuration file
2111
+
2112
+ Returns:
2113
+ Flask application instance
2114
+ """
2115
+ server = QuantumWebServer(config_path)
2116
+
2117
+ if not os.environ.get('QUANTUM_SECRET_KEY') and not (
2118
+ server.config.get('security', {}) or {}
2119
+ ).get('secret_key'):
2120
+ server.logger.warning(
2121
+ "No QUANTUM_SECRET_KEY and no security.secret_key: this worker "
2122
+ "generated a random session key at import. With more than one "
2123
+ "worker, session cookies signed by one are rejected by the others "
2124
+ "and users are logged out at random. Set one before serving."
2125
+ )
2126
+
2127
+ return server.app
2128
+
2129
+
2130
+ if __name__ == '__main__':
2131
+ start_server()