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,677 @@
1
+ """
2
+ Quantum expression evaluator.
3
+
4
+ One evaluator, built on `ast.parse` with a node whitelist, to replace the two
5
+ parallel hand-rolled ones (6 `_evaluate_*` methods in runtime/component.py, 4
6
+ more in runtime/renderer.py) and the six `eval()` calls they rely on. See
7
+ FRAMEWORK_PLAN.md Fase 2.1.
8
+
9
+ Two things this fixes by construction:
10
+
11
+ - **Security.** `eval()` with an emptied `__builtins__` is not a sandbox — it
12
+ is bypassable through attribute traversal
13
+ (`().__class__.__mro__[1].__subclasses__()`). A whitelist of AST node types
14
+ cannot be talked out of its own grammar. This closes P0.1 of
15
+ PUBLIC_RELEASE_PLAN.md.
16
+ - **Coercion.** `{a + b}` where the values arrived as strings — from an LLM
17
+ tool call, a form field, a query parameter — concatenated instead of adding.
18
+ Numeric-looking operands are coerced for arithmetic and comparison.
19
+
20
+ The grammar is deliberately small, and it was measured rather than guessed:
21
+ across 1,224 expressions in examples/*.q, 57% are a bare variable or a dotted
22
+ access, and the whole arithmetic/comparison/call surface is about 15%.
23
+ """
24
+
25
+ import ast
26
+ import contextvars
27
+ import math
28
+ import operator
29
+ import re
30
+ from typing import Any, Callable, Dict, Optional
31
+
32
+ from quantum.core.expression_stdlib import STDLIB
33
+
34
+ # EXPR-8: in a condition, a key a scope does not have (`session.userId`
35
+ # before login) is absent, not a failure — see ExpressionEvaluator.evaluate.
36
+ _SCOPES = ('session', 'application', 'request', 'cookie', 'form', 'query')
37
+ _ABSENT_SCOPE_KEY_IS_NONE = contextvars.ContextVar('absent_scope_key_is_none', default=False)
38
+
39
+
40
+ class ExpressionError(Exception):
41
+ """Raised when an expression cannot be evaluated."""
42
+
43
+
44
+ class UndefinedError(ExpressionError):
45
+ """A name, key or attribute that does not exist.
46
+
47
+ Kept apart from the other failures because a condition is a presence test
48
+ (EXPR-5): `<q:if condition="flash">` is false before there is a flash, but
49
+ `a === b` or a misspelled function is still an error.
50
+ """
51
+
52
+
53
+ # `{n}`, `{n,}` and `{n,m}` are regex quantifiers, not Quantum expressions.
54
+ # They occur for real: examples/form_validation.q has pattern="\d{10,11}" and
55
+ # examples/python-scripting.q has r'[a-zA-Z]{2,}'. The old evaluator left them
56
+ # alone only by accident — it failed to evaluate them and handed the literal
57
+ # back. This evaluator would happily read `{10,11}` as the tuple (10, 11) and
58
+ # silently corrupt the validation pattern, so the case is excluded on purpose.
59
+ #
60
+ # The underlying gap is that Quantum has no escape for a literal brace. Until
61
+ # it has one, this heuristic is the seam, and it is deliberately narrow:
62
+ # digits and at most one comma, nothing else.
63
+ _REGEX_QUANTIFIER = re.compile(r'^\d+\s*(,\s*\d*)?$')
64
+
65
+
66
+ def is_regex_quantifier(content: str) -> bool:
67
+ """True when a `{...}` group should be left as literal text.
68
+
69
+ A q: attribute that is ONLY `{5}` is the number 5 (EXPR-7) — the runtime
70
+ checks that case before calling this. Inside other text, `[0-9]{3}` stays a
71
+ quantifier.
72
+ """
73
+ return bool(_REGEX_QUANTIFIER.match(content.strip()))
74
+
75
+
76
+ # Node types the evaluator accepts. Anything else is refused by construction —
77
+ # notably Lambda, comprehensions, walrus, imports, and any form of statement.
78
+ _ALLOWED_NODES = (
79
+ ast.Expression, ast.Constant, ast.Name, ast.Load,
80
+ ast.BinOp, ast.UnaryOp, ast.BoolOp, ast.Compare, ast.IfExp,
81
+ ast.Attribute, ast.Subscript, ast.Slice,
82
+ ast.Dict, ast.List, ast.Tuple, ast.Set,
83
+ ast.Call, ast.keyword,
84
+ # operators
85
+ ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow,
86
+ ast.USub, ast.UAdd, ast.Not,
87
+ ast.And, ast.Or,
88
+ ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.In, ast.NotIn,
89
+ )
90
+
91
+ _BIN_OPS: Dict[type, Callable[[Any, Any], Any]] = {
92
+ ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
93
+ ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
94
+ ast.Mod: operator.mod, ast.Pow: operator.pow,
95
+ }
96
+
97
+ _CMP_OPS: Dict[type, Callable[[Any, Any], Any]] = {
98
+ ast.Eq: operator.eq, ast.NotEq: operator.ne,
99
+ ast.Lt: operator.lt, ast.LtE: operator.le,
100
+ ast.Gt: operator.gt, ast.GtE: operator.ge,
101
+ ast.In: lambda a, b: a in b, ast.NotIn: lambda a, b: a not in b,
102
+ }
103
+
104
+ _UNARY_OPS: Dict[type, Callable[[Any], Any]] = {
105
+ ast.USub: operator.neg, ast.UAdd: operator.pos, ast.Not: operator.not_,
106
+ }
107
+
108
+
109
+ def coerce_number(value: Any) -> Any:
110
+ """Turn a numeric-looking string into a number, leave everything else.
111
+
112
+ This is the difference between {a + b} adding and concatenating when the
113
+ values came from an LLM, a form post or a query parameter — all of which
114
+ deliver strings.
115
+ """
116
+ if isinstance(value, bool) or not isinstance(value, str):
117
+ return value
118
+ text = value.strip()
119
+ if not text:
120
+ return value
121
+ try:
122
+ return int(text)
123
+ except ValueError:
124
+ pass
125
+ try:
126
+ return float(text)
127
+ except ValueError:
128
+ return value
129
+
130
+
131
+ def _names_of(target: Any) -> list:
132
+ """The names a reader could plausibly have meant, for a suggestion."""
133
+ if isinstance(target, dict):
134
+ return [k for k in target if isinstance(k, str) and not k.startswith('_')]
135
+ return [a for a in dir(target) if not a.startswith('_')]
136
+
137
+
138
+ def _suggest(wanted: str, target: Any, limit: int = 3) -> str:
139
+ """`, did you mean x, y?` — or nothing when there is no near match.
140
+
141
+ The reason this exists: writing a real screen against Quantum, the two
142
+ failures that cost the most time were {p.name} (var= had been silently
143
+ discarded, so `p` did not exist) and {projects.recordCount} (the metadata
144
+ lives on `projects_result`). Both were resolvable in seconds from a list of
145
+ what WAS in scope, and neither message offered one. See DOGFOOD_NOTES.md.
146
+ """
147
+ import difflib
148
+ names = _names_of(target)
149
+ if not names:
150
+ return ""
151
+ close = difflib.get_close_matches(wanted, names, n=limit, cutoff=0.6)
152
+ if close:
153
+ return ", did you mean " + " or ".join(repr(c) for c in close) + "?"
154
+ # No near match: naming what IS available beats naming what is not, but
155
+ # only while the list is short enough to read.
156
+ if len(names) <= 8:
157
+ return " (in scope: " + ", ".join(sorted(names)) + ")"
158
+ return ""
159
+
160
+
161
+ # A template is evaluated inside a request. An expression that never returns
162
+ # is a hung worker, and these two do it with three characters:
163
+ #
164
+ # {9**9**9} -> does not return
165
+ # {10**100000000} -> does not return
166
+ # ({'a' * 999999999} allocated ~1 GB; since EXPR-7 `*` needs numbers.)
167
+ #
168
+ # No dunder, no escape, no attacker — a typo in a template is enough, and there
169
+ # is no timeout anywhere above this to catch it. The caps are far above any
170
+ # real expression: 4096 bits is a 1233-digit number.
171
+ _MAX_POW_BITS = 4096
172
+
173
+
174
+ def _guard_pow(left: Any, right: Any) -> None:
175
+ """Recusa `a ** b` grande demais — inteiro OU float.
176
+
177
+ Exigia `isinstance` int nos DOIS lados, entao qualquer mistura passava
178
+ direto: `2.0 ** 100000000` e `2 ** 1e9` chegavam ao operador e
179
+ levantavam `OverflowError`, que nao e `ExpressionError` — subia pelo
180
+ handler da requisicao como um 500 sem explicacao, em vez da mensagem que
181
+ esta funcao existe para dar.
182
+ """
183
+ if isinstance(left, bool) or isinstance(right, bool):
184
+ return
185
+ if not isinstance(left, (int, float)) or not isinstance(right, (int, float)):
186
+ return
187
+ if right < 0 or left in (0, 1, -1):
188
+ return
189
+ try:
190
+ if isinstance(left, int) and isinstance(right, int):
191
+ # bits(left**right) ≈ right * bits(left)
192
+ bits = right * max(left.bit_length(), 1)
193
+ else:
194
+ base = abs(float(left))
195
+ if base <= 1.0:
196
+ return
197
+ bits = float(right) * math.log2(base)
198
+ except (OverflowError, ValueError):
199
+ # O proprio calculo do tamanho estourou: e grande demais.
200
+ bits = float('inf')
201
+
202
+ if bits > _MAX_POW_BITS:
203
+ raise ExpressionError(
204
+ f"{left}**{right} is too large to compute "
205
+ f"(over {_MAX_POW_BITS} bits); this would hang the request"
206
+ )
207
+
208
+
209
+ def _both_numeric(left: Any, right: Any) -> bool:
210
+ return isinstance(left, (int, float)) and isinstance(right, (int, float)) \
211
+ and not isinstance(left, bool) and not isinstance(right, bool)
212
+
213
+
214
+
215
+ def _normalise_js_operators(source: str) -> str:
216
+ """Rewrite JavaScript-style `!x`, `a && b` and `a || b` into Python's
217
+ `not`, `and` and `or`.
218
+
219
+ Real .q files use them ({!result.success} appears across the examples, and
220
+ docs/guide/conditionals.md teaches `age >= 18 && hasLicense`), so they are
221
+ part of the language surface. `&&` and `||` were never translated: the
222
+ condition failed to parse and, read as false, took the else branch for
223
+ every input. `!=` is left alone, and nothing inside a string literal is
224
+ touched.
225
+ """
226
+ if '!' not in source and '&&' not in source and '||' not in source:
227
+ return source
228
+
229
+ out = []
230
+ i = 0
231
+ quote = None
232
+ while i < len(source):
233
+ ch = source[i]
234
+ if quote:
235
+ out.append(ch)
236
+ if ch == quote and (i == 0 or source[i - 1] != "\\"):
237
+ quote = None
238
+ i += 1
239
+ continue
240
+ if ch in ('"', "'"):
241
+ quote = ch
242
+ out.append(ch)
243
+ i += 1
244
+ continue
245
+ if ch == '!' and i + 1 < len(source) and source[i + 1] != '=':
246
+ out.append('not ')
247
+ i += 1
248
+ continue
249
+ if source[i:i + 2] in ('&&', '||'):
250
+ out.append(' and ' if ch == '&' else ' or ')
251
+ i += 2
252
+ continue
253
+ out.append(ch)
254
+ i += 1
255
+ return ''.join(out)
256
+
257
+
258
+ # EXPR-10: what a JavaScript habit is called here. `Math.ceil(x)` and
259
+ # `text.split(' ')` are the first things people write; the bare "only named
260
+ # function calls are allowed" did not say what to write instead.
261
+ _JS_EQUIVALENTS = {
262
+ 'Math.ceil': 'ceil(x)', 'Math.floor': 'floor(x)', 'Math.round': 'round(x)',
263
+ 'Math.max': 'max(a, b)', 'Math.min': 'min(a, b)', 'Math.abs': 'abs(x)',
264
+ 'Date.now': 'now()', 'parseInt': 'int(x)', 'parseFloat': 'float(x)',
265
+ 'split': "split(text, ' ')", 'join': "join(items, ', ')", 'toUpperCase': 'upper(text)',
266
+ 'toLowerCase': 'lower(text)', 'trim': 'trim(text)', 'replace': 'replace(text, old, new)',
267
+ 'includes': 'contains(text, part)', 'indexOf': 'contains(text, part)',
268
+ }
269
+
270
+
271
+ def _method_hint(func: ast.AST) -> str:
272
+ if not isinstance(func, ast.Attribute):
273
+ return ""
274
+ base = func.value.id if isinstance(func.value, ast.Name) else None
275
+ chave = f"{base}.{func.attr}" if base in ('Math', 'Date') else func.attr
276
+ if chave in _JS_EQUIVALENTS:
277
+ escrito = chave if '.' in chave else f"x.{func.attr}"
278
+ return f"; write {_JS_EQUIVALENTS[chave]} instead of {escrito}(...)"
279
+ return f"; call functions by name, e.g. {func.attr}(x), not x.{func.attr}()"
280
+
281
+
282
+ def names_read(expression: str) -> set:
283
+ """The root names an expression reads — `user` in `user.name > 1` — not the
284
+ functions it calls. An expression that does not parse reads nothing here;
285
+ evaluating it reports the error."""
286
+ try:
287
+ tree = ast.parse(_normalise_js_operators(expression.strip()), mode='eval')
288
+ except SyntaxError:
289
+ return set()
290
+ chamadas = {id(n.func) for n in ast.walk(tree) if isinstance(n, ast.Call)}
291
+ return {n.id for n in ast.walk(tree)
292
+ if isinstance(n, ast.Name) and id(n) not in chamadas}
293
+
294
+
295
+ class ExpressionEvaluator:
296
+ """Evaluates Quantum `{...}` expressions against a variable context."""
297
+
298
+ def __init__(self, functions: Optional[Dict[str, Callable]] = None,
299
+ max_length: int = 2000,
300
+ function_resolver: Optional[Callable[[str], Optional[Callable]]] = None):
301
+ """
302
+ Args:
303
+ functions: extra callables available to expressions.
304
+ max_length: refuse expressions longer than this.
305
+ function_resolver: consulted for a name that is not in
306
+ `functions`. This is how a component's own q:function
307
+ becomes callable from `{somaTotal(itens)}` — the registry
308
+ is filled while the component executes, so it cannot be
309
+ handed over as a dict when the evaluator is built.
310
+ """
311
+ # Stdlib plus whatever the component registered (q:function).
312
+ self.functions: Dict[str, Callable] = dict(STDLIB)
313
+ if functions:
314
+ self.functions.update(functions)
315
+ self.max_length = max_length
316
+ self.function_resolver = function_resolver
317
+
318
+ # -- public API --------------------------------------------------------
319
+
320
+ def evaluate(self, expression: str, context: Dict[str, Any],
321
+ absent_scope_key_is_none: bool = False) -> Any:
322
+ """Evaluate an expression, raising ExpressionError on any failure.
323
+
324
+ absent_scope_key_is_none (conditions, EXPR-8): `session.x` where the
325
+ scope has no `x` reads as None instead of raising. A condition is a
326
+ presence test, and a scope key that is not there yet is the ordinary
327
+ case (a page runs before login) — raising made the WHOLE condition
328
+ false, so `not session.authenticated` was false for a visitor with no
329
+ session and a guard written that way never fired.
330
+ """
331
+ marca = _ABSENT_SCOPE_KEY_IS_NONE.set(absent_scope_key_is_none)
332
+ try:
333
+ return self._evaluate(expression, context)
334
+ finally:
335
+ _ABSENT_SCOPE_KEY_IS_NONE.reset(marca)
336
+
337
+ def _evaluate(self, expression: str, context: Dict[str, Any]) -> Any:
338
+ if expression is None:
339
+ raise ExpressionError("expression is None")
340
+
341
+ source = expression.strip()
342
+ if not source:
343
+ raise ExpressionError("empty expression")
344
+ if len(source) > self.max_length:
345
+ raise ExpressionError(f"expression exceeds {self.max_length} characters")
346
+
347
+ source = _normalise_js_operators(source)
348
+
349
+ try:
350
+ tree = ast.parse(source, mode='eval')
351
+ except SyntaxError as exc:
352
+ raise ExpressionError(f"invalid expression {source!r}: {exc.msg}") from exc
353
+
354
+ self._validate(tree, source)
355
+ try:
356
+ return self._eval(tree.body, context)
357
+ except RecursionError as exc:
358
+ # A guarda de profundidade cobre a EXPRESSAO; o contexto tambem
359
+ # pode ser fundo (um dict que referencia a si mesmo, por
360
+ # exemplo). Uma expressao de template nunca pode derrubar a
361
+ # requisicao com um erro que nao seja ExpressionError.
362
+ raise ExpressionError(
363
+ f"evaluating {source[:60]!r} ran out of stack") from exc
364
+
365
+ def try_evaluate(self, expression: str, context: Dict[str, Any],
366
+ default: Any = None) -> Any:
367
+ """Evaluate, returning `default` instead of raising.
368
+
369
+ For render paths, where a bad expression should not take down the page.
370
+ """
371
+ try:
372
+ return self.evaluate(expression, context)
373
+ except ExpressionError:
374
+ return default
375
+
376
+ # -- validation --------------------------------------------------------
377
+
378
+ # Profundidade maxima da arvore. `_eval` desce recursivamente, e o
379
+ # limite de recursao do Python e ~1000 quadros COMPARTILHADOS com tudo
380
+ # que ja esta na pilha (Flask, o executor, o renderer). `{-------...1}`
381
+ # com mil sinais levantava RecursionError — que nao e ExpressionError e
382
+ # portanto virava 500 — e ainda deixava o interpretador raspando o
383
+ # limite. Cem niveis e mais fundo do que qualquer expressao de template
384
+ # real e fica bem longe da borda.
385
+ MAX_DEPTH = 100
386
+
387
+ def _validate(self, tree: ast.AST, source: str):
388
+ self._check_depth(tree.body if isinstance(tree, ast.Expression)
389
+ else tree, source)
390
+ for node in ast.walk(tree):
391
+ if not isinstance(node, _ALLOWED_NODES):
392
+ raise ExpressionError(
393
+ f"{type(node).__name__} is not allowed in expressions "
394
+ f"({source!r})"
395
+ )
396
+ # Dunder traversal is the classic eval sandbox escape.
397
+ if isinstance(node, ast.Attribute) and node.attr.startswith('__'):
398
+ raise ExpressionError(
399
+ f"attribute {node.attr!r} is not accessible ({source!r})"
400
+ )
401
+ if isinstance(node, ast.Constant) and isinstance(node.value, str) \
402
+ and node.value.startswith('__'):
403
+ raise ExpressionError(f"dunder string is not allowed ({source!r})")
404
+
405
+ def _check_depth(self, root: ast.AST, source: str) -> None:
406
+ """Mede a profundidade sem recursao — medir recursivamente teria o
407
+ mesmo problema que a medicao existe para evitar."""
408
+ pilha = [(root, 1)]
409
+ while pilha:
410
+ node, nivel = pilha.pop()
411
+ if nivel > self.MAX_DEPTH:
412
+ raise ExpressionError(
413
+ f"expression is nested more than {self.MAX_DEPTH} levels "
414
+ f"deep ({source[:60]!r}...); this would exhaust the stack"
415
+ )
416
+ for filho in ast.iter_child_nodes(node):
417
+ pilha.append((filho, nivel + 1))
418
+
419
+ # -- evaluation --------------------------------------------------------
420
+
421
+ def _eval(self, node: ast.AST, ctx: Dict[str, Any]) -> Any:
422
+ if isinstance(node, ast.Constant):
423
+ return node.value
424
+
425
+ if isinstance(node, ast.Name):
426
+ return self._lookup(node.id, ctx)
427
+
428
+ if isinstance(node, ast.BinOp):
429
+ left = self._eval(node.left, ctx)
430
+ right = self._eval(node.right, ctx)
431
+ op = _BIN_OPS.get(type(node.op))
432
+ if op is None:
433
+ raise ExpressionError(f"unsupported operator {type(node.op).__name__}")
434
+
435
+ # String + string stays concatenation only when neither side is a
436
+ # number in disguise.
437
+ cl, cr = coerce_number(left), coerce_number(right)
438
+ if _both_numeric(cl, cr):
439
+ left, right = cl, cr
440
+ elif isinstance(node.op, ast.Add) and not (
441
+ (isinstance(left, str) and isinstance(right, str))
442
+ or (isinstance(left, list) and isinstance(right, list))):
443
+ # EXPR-7: '+' adds numbers or joins two texts / two lists. The
444
+ # mixed case used to surface Python's "can only concatenate
445
+ # str (not "int") to str".
446
+ raise ExpressionError(
447
+ f"'+' needs two numbers, two texts or two lists, got {left!r} and {right!r}")
448
+ elif not isinstance(node.op, ast.Add):
449
+ # EXPR-7: arithmetic is on numbers. Python would repeat text
450
+ # ('ab' * 3), format it ('%s' % x) or fail with its own
451
+ # message; `n * factorial(n - 1)` whose base case returned the
452
+ # text '{1}' produced '{1}{1}{1}{1}{1}' and no error.
453
+ simbolo = {ast.Sub: '-', ast.Mult: '*', ast.Div: '/', ast.FloorDiv: '//',
454
+ ast.Mod: '%', ast.Pow: '**'}[type(node.op)]
455
+ raise ExpressionError(
456
+ f"'{simbolo}' needs two numbers, got {left!r} and {right!r}")
457
+
458
+ # Refuse what would not finish, before attempting it.
459
+ if isinstance(node.op, ast.Pow):
460
+ _guard_pow(left, right)
461
+
462
+ try:
463
+ return op(left, right)
464
+ except ZeroDivisionError:
465
+ raise ExpressionError("division by zero")
466
+ except TypeError as exc:
467
+ raise ExpressionError(f"cannot apply operator: {exc}") from exc
468
+ except (OverflowError, MemoryError) as exc:
469
+ # `1e300 ** 2` cabe na estimativa de bits e estoura assim
470
+ # mesmo, porque float tem expoente maximo. Sem isto o erro
471
+ # nao e ExpressionError e sobe como 500.
472
+ raise ExpressionError(
473
+ f"the result is too large to represent: {exc}") from exc
474
+
475
+ if isinstance(node, ast.UnaryOp):
476
+ operand = self._eval(node.operand, ctx)
477
+ op = _UNARY_OPS.get(type(node.op))
478
+ if op is None:
479
+ raise ExpressionError(f"unsupported unary {type(node.op).__name__}")
480
+ if isinstance(node.op, (ast.USub, ast.UAdd)):
481
+ operand = coerce_number(operand)
482
+ return op(operand)
483
+
484
+ if isinstance(node, ast.BoolOp):
485
+ # EXPR-6: short-circuit, as in Python. Every operand used to be
486
+ # evaluated first, so `user and user.name` failed with "variable
487
+ # 'user' is not defined"-style errors exactly when the left side
488
+ # was there to guard the right.
489
+ valor = False
490
+ for operando in node.values:
491
+ valor = self._eval(operando, ctx)
492
+ if isinstance(node.op, ast.And) and not valor:
493
+ return valor
494
+ if isinstance(node.op, ast.Or) and valor:
495
+ return valor
496
+ return valor
497
+
498
+ if isinstance(node, ast.Compare):
499
+ left = self._eval(node.left, ctx)
500
+ for op_node, comparator in zip(node.ops, node.comparators):
501
+ right = self._eval(comparator, ctx)
502
+ op = _CMP_OPS.get(type(op_node))
503
+ if op is None:
504
+ raise ExpressionError(f"unsupported comparison {type(op_node).__name__}")
505
+ cl, cr = coerce_number(left), coerce_number(right)
506
+ if _both_numeric(cl, cr):
507
+ left_cmp, right_cmp = cl, cr
508
+ else:
509
+ left_cmp, right_cmp = left, right
510
+ try:
511
+ if not op(left_cmp, right_cmp):
512
+ return False
513
+ except TypeError as exc:
514
+ raise ExpressionError(f"cannot compare: {exc}") from exc
515
+ left = right
516
+ return True
517
+
518
+ if isinstance(node, ast.IfExp):
519
+ return self._eval(node.body, ctx) if self._eval(node.test, ctx) \
520
+ else self._eval(node.orelse, ctx)
521
+
522
+ if isinstance(node, ast.Attribute):
523
+ # `session.userId` e escrito como acesso a atributo, mas o
524
+ # contexto guarda a variavel de escopo sob a CHAVE PLANA
525
+ # pontilhada 'session.userId' — nao existe objeto `session`.
526
+ # Sem isto, uma variavel de sessao PRESENTE levantava, e quem
527
+ # chamava traduzia isso em '' ou em False: `<q:set
528
+ # value="{session.userId}"/>` gravava vazio num INSERT, e
529
+ # `<q:if condition="{session.userRole == 'admin'}">` era False
530
+ # nos dois ramos. A documentacao ensina exatamente esses dois
531
+ # padroes.
532
+ plano = self._dotted_name(node)
533
+ if plano is not None and plano in ctx:
534
+ return ctx[plano]
535
+ if (_ABSENT_SCOPE_KEY_IS_NONE.get() and isinstance(node.value, ast.Name)
536
+ and node.value.id in _SCOPES):
537
+ escopo = ctx.get(node.value.id)
538
+ if escopo is None or (isinstance(escopo, dict) and node.attr not in escopo):
539
+ return None
540
+ return self._attribute(self._eval(node.value, ctx), node.attr)
541
+
542
+ if isinstance(node, ast.Subscript):
543
+ target = self._eval(node.value, ctx)
544
+ key = self._eval(node.slice, ctx) if not isinstance(node.slice, ast.Slice) \
545
+ else slice(
546
+ self._eval(node.slice.lower, ctx) if node.slice.lower else None,
547
+ self._eval(node.slice.upper, ctx) if node.slice.upper else None,
548
+ )
549
+ try:
550
+ if isinstance(target, dict):
551
+ return target.get(key)
552
+ return target[key]
553
+ except (IndexError, KeyError) as exc:
554
+ raise UndefinedError(f"cannot index: {exc}") from exc
555
+ except TypeError as exc:
556
+ raise ExpressionError(f"cannot index: {exc}") from exc
557
+
558
+ if isinstance(node, ast.Dict):
559
+ return {self._eval(k, ctx): self._eval(v, ctx)
560
+ for k, v in zip(node.keys, node.values)}
561
+
562
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
563
+ values = [self._eval(e, ctx) for e in node.elts]
564
+ return set(values) if isinstance(node, ast.Set) else \
565
+ (tuple(values) if isinstance(node, ast.Tuple) else values)
566
+
567
+ if isinstance(node, ast.Call):
568
+ return self._call(node, ctx)
569
+
570
+ raise ExpressionError(f"cannot evaluate {type(node).__name__}")
571
+
572
+ # -- helpers -----------------------------------------------------------
573
+
574
+ @staticmethod
575
+ def _dotted_name(node: ast.Attribute):
576
+ """'session.userId' de um Attribute, ou None se a base nao for um nome.
577
+
578
+ So encadeamento puro de nomes vira chave plana: `a.b.c` sim,
579
+ `f(x).b` ou `itens[0].nome` nao — nesses a busca por chave seria
580
+ errada, e o acesso a atributo de verdade e que vale.
581
+ """
582
+ partes = []
583
+ atual = node
584
+ while isinstance(atual, ast.Attribute):
585
+ partes.append(atual.attr)
586
+ atual = atual.value
587
+ if not isinstance(atual, ast.Name):
588
+ return None
589
+ partes.append(atual.id)
590
+ return '.'.join(reversed(partes))
591
+
592
+ def _lookup(self, name: str, ctx: Dict[str, Any]) -> Any:
593
+ if name in ctx:
594
+ return ctx[name]
595
+ if name == 'true':
596
+ return True
597
+ if name == 'false':
598
+ return False
599
+ if name == 'null':
600
+ return None
601
+ raise UndefinedError(
602
+ f"variable {name!r} is not defined{_suggest(name, ctx)}"
603
+ )
604
+
605
+ def _attribute(self, target: Any, attr: str) -> Any:
606
+ if isinstance(target, dict):
607
+ if attr in target:
608
+ return target[attr]
609
+ raise UndefinedError(
610
+ f"key {attr!r} not found{_suggest(attr, target)}"
611
+ )
612
+ if isinstance(target, (list, tuple, str)) and attr == 'length':
613
+ return len(target)
614
+ if hasattr(target, attr):
615
+ valor = getattr(target, attr)
616
+ # Um atributo NUNCA pode entregar um callable.
617
+ #
618
+ # `_call` so aceita funcao com nome (bloqueia `x.metodo()`) e ha
619
+ # guarda de dunder sobre a AST — mas ambos eram contornados assim:
620
+ #
621
+ # max([alvo], key='{0.__class__.__base__}'.format)
622
+ #
623
+ # `'...'.format` e um ACESSO A ATRIBUTO que devolve um metodo
624
+ # ligado; passado como `key=` para uma funcao da stdlib, ela o
625
+ # INVOCA, e str.format faz travessia de atributo em tempo de
626
+ # execucao — onde a guarda de dunder, que olha o texto, nao
627
+ # alcanca. Reproduzido: a expressao acima executou uma @property
628
+ # do objeto do contexto.
629
+ #
630
+ # Um template le dado (`user.nome`), nao pega metodo para passar
631
+ # adiante. Recusar callable fecha a classe inteira de gadget, nao
632
+ # so o `format`.
633
+ if callable(valor) and not isinstance(valor, type):
634
+ raise ExpressionError(
635
+ f"attribute {attr!r} is a method, not data; expressions "
636
+ f"read values, they do not pass functions around"
637
+ )
638
+ return valor
639
+ if isinstance(target, (list, tuple)):
640
+ # The q:query case: `projects` is the rows and `projects_result`
641
+ # holds recordCount, so {projects.recordCount} is the natural
642
+ # first guess and it is wrong. Say so instead of going quiet.
643
+ raise ExpressionError(
644
+ f"{type(target).__name__} of {len(target)} items has no "
645
+ f"{attr!r}; use .length for the count, or the query's "
646
+ f"<name>_result for its metadata"
647
+ )
648
+ raise UndefinedError(
649
+ f"attribute {attr!r} not found on {type(target).__name__}"
650
+ f"{_suggest(attr, target)}"
651
+ )
652
+
653
+ def _call(self, node: ast.Call, ctx: Dict[str, Any]) -> Any:
654
+ if not isinstance(node.func, ast.Name):
655
+ raise ExpressionError("only named function calls are allowed" + _method_hint(node.func))
656
+ fn = self.functions.get(node.func.id)
657
+ if fn is None and self.function_resolver is not None:
658
+ # A q:function declared by the component being executed.
659
+ # {countElements(myArray)} used to resolve to nothing at all: the
660
+ # placeholder was left in the text and the surrounding q:set then
661
+ # failed with "could not convert string to float:
662
+ # '{sumNumbers(myNumbers)}'".
663
+ fn = self.function_resolver(node.func.id)
664
+ if fn is None:
665
+ raise ExpressionError(
666
+ f"function {node.func.id!r} is not defined"
667
+ + (f"; write {_JS_EQUIVALENTS[node.func.id]}" if node.func.id in _JS_EQUIVALENTS
668
+ else _suggest(node.func.id, self.functions))
669
+ )
670
+ args = [self._eval(a, ctx) for a in node.args]
671
+ kwargs = {kw.arg: self._eval(kw.value, ctx) for kw in node.keywords}
672
+ try:
673
+ return fn(*args, **kwargs)
674
+ except ExpressionError:
675
+ raise
676
+ except Exception as exc: # noqa: BLE001
677
+ raise ExpressionError(f"{node.func.id}() failed: {exc}") from exc