hexdag 0.5.0.dev1__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 (261) hide show
  1. hexdag/__init__.py +116 -0
  2. hexdag/__main__.py +30 -0
  3. hexdag/adapters/executors/__init__.py +5 -0
  4. hexdag/adapters/executors/local_executor.py +316 -0
  5. hexdag/builtin/__init__.py +6 -0
  6. hexdag/builtin/adapters/__init__.py +51 -0
  7. hexdag/builtin/adapters/anthropic/__init__.py +5 -0
  8. hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
  9. hexdag/builtin/adapters/database/__init__.py +6 -0
  10. hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
  11. hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
  12. hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
  13. hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
  14. hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
  15. hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
  16. hexdag/builtin/adapters/local/README.md +59 -0
  17. hexdag/builtin/adapters/local/__init__.py +7 -0
  18. hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
  19. hexdag/builtin/adapters/memory/__init__.py +47 -0
  20. hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
  21. hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
  22. hexdag/builtin/adapters/memory/schemas.py +57 -0
  23. hexdag/builtin/adapters/memory/session_memory.py +178 -0
  24. hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
  25. hexdag/builtin/adapters/memory/state_memory.py +280 -0
  26. hexdag/builtin/adapters/mock/README.md +89 -0
  27. hexdag/builtin/adapters/mock/__init__.py +15 -0
  28. hexdag/builtin/adapters/mock/hexdag.toml +50 -0
  29. hexdag/builtin/adapters/mock/mock_database.py +225 -0
  30. hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
  31. hexdag/builtin/adapters/mock/mock_llm.py +177 -0
  32. hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
  33. hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
  34. hexdag/builtin/adapters/openai/__init__.py +5 -0
  35. hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
  36. hexdag/builtin/adapters/secret/__init__.py +7 -0
  37. hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
  38. hexdag/builtin/adapters/unified_tool_router.py +280 -0
  39. hexdag/builtin/macros/__init__.py +17 -0
  40. hexdag/builtin/macros/conversation_agent.py +390 -0
  41. hexdag/builtin/macros/llm_macro.py +151 -0
  42. hexdag/builtin/macros/reasoning_agent.py +423 -0
  43. hexdag/builtin/macros/tool_macro.py +380 -0
  44. hexdag/builtin/nodes/__init__.py +38 -0
  45. hexdag/builtin/nodes/_discovery.py +123 -0
  46. hexdag/builtin/nodes/agent_node.py +696 -0
  47. hexdag/builtin/nodes/base_node_factory.py +242 -0
  48. hexdag/builtin/nodes/composite_node.py +926 -0
  49. hexdag/builtin/nodes/data_node.py +201 -0
  50. hexdag/builtin/nodes/expression_node.py +487 -0
  51. hexdag/builtin/nodes/function_node.py +454 -0
  52. hexdag/builtin/nodes/llm_node.py +491 -0
  53. hexdag/builtin/nodes/loop_node.py +920 -0
  54. hexdag/builtin/nodes/mapped_input.py +518 -0
  55. hexdag/builtin/nodes/port_call_node.py +269 -0
  56. hexdag/builtin/nodes/tool_call_node.py +195 -0
  57. hexdag/builtin/nodes/tool_utils.py +390 -0
  58. hexdag/builtin/prompts/__init__.py +68 -0
  59. hexdag/builtin/prompts/base.py +422 -0
  60. hexdag/builtin/prompts/chat_prompts.py +303 -0
  61. hexdag/builtin/prompts/error_correction_prompts.py +320 -0
  62. hexdag/builtin/prompts/tool_prompts.py +160 -0
  63. hexdag/builtin/tools/builtin_tools.py +84 -0
  64. hexdag/builtin/tools/database_tools.py +164 -0
  65. hexdag/cli/__init__.py +17 -0
  66. hexdag/cli/__main__.py +7 -0
  67. hexdag/cli/commands/__init__.py +27 -0
  68. hexdag/cli/commands/build_cmd.py +812 -0
  69. hexdag/cli/commands/create_cmd.py +208 -0
  70. hexdag/cli/commands/docs_cmd.py +293 -0
  71. hexdag/cli/commands/generate_types_cmd.py +252 -0
  72. hexdag/cli/commands/init_cmd.py +188 -0
  73. hexdag/cli/commands/pipeline_cmd.py +494 -0
  74. hexdag/cli/commands/plugin_dev_cmd.py +529 -0
  75. hexdag/cli/commands/plugins_cmd.py +441 -0
  76. hexdag/cli/commands/studio_cmd.py +101 -0
  77. hexdag/cli/commands/validate_cmd.py +221 -0
  78. hexdag/cli/main.py +84 -0
  79. hexdag/core/__init__.py +83 -0
  80. hexdag/core/config/__init__.py +20 -0
  81. hexdag/core/config/loader.py +479 -0
  82. hexdag/core/config/models.py +150 -0
  83. hexdag/core/configurable.py +294 -0
  84. hexdag/core/context/__init__.py +37 -0
  85. hexdag/core/context/execution_context.py +378 -0
  86. hexdag/core/docs/__init__.py +26 -0
  87. hexdag/core/docs/extractors.py +678 -0
  88. hexdag/core/docs/generators.py +890 -0
  89. hexdag/core/docs/models.py +120 -0
  90. hexdag/core/domain/__init__.py +10 -0
  91. hexdag/core/domain/dag.py +1225 -0
  92. hexdag/core/exceptions.py +234 -0
  93. hexdag/core/expression_parser.py +569 -0
  94. hexdag/core/logging.py +449 -0
  95. hexdag/core/models/__init__.py +17 -0
  96. hexdag/core/models/base.py +138 -0
  97. hexdag/core/orchestration/__init__.py +46 -0
  98. hexdag/core/orchestration/body_executor.py +481 -0
  99. hexdag/core/orchestration/components/__init__.py +97 -0
  100. hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
  101. hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
  102. hexdag/core/orchestration/components/execution_coordinator.py +360 -0
  103. hexdag/core/orchestration/components/health_check_manager.py +176 -0
  104. hexdag/core/orchestration/components/input_mapper.py +143 -0
  105. hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
  106. hexdag/core/orchestration/components/node_executor.py +377 -0
  107. hexdag/core/orchestration/components/secret_manager.py +202 -0
  108. hexdag/core/orchestration/components/wave_executor.py +158 -0
  109. hexdag/core/orchestration/constants.py +17 -0
  110. hexdag/core/orchestration/events/README.md +312 -0
  111. hexdag/core/orchestration/events/__init__.py +104 -0
  112. hexdag/core/orchestration/events/batching.py +330 -0
  113. hexdag/core/orchestration/events/decorators.py +139 -0
  114. hexdag/core/orchestration/events/events.py +573 -0
  115. hexdag/core/orchestration/events/observers/__init__.py +30 -0
  116. hexdag/core/orchestration/events/observers/core_observers.py +690 -0
  117. hexdag/core/orchestration/events/observers/models.py +111 -0
  118. hexdag/core/orchestration/events/taxonomy.py +269 -0
  119. hexdag/core/orchestration/hook_context.py +237 -0
  120. hexdag/core/orchestration/hooks.py +437 -0
  121. hexdag/core/orchestration/models.py +418 -0
  122. hexdag/core/orchestration/orchestrator.py +910 -0
  123. hexdag/core/orchestration/orchestrator_factory.py +275 -0
  124. hexdag/core/orchestration/port_wrappers.py +327 -0
  125. hexdag/core/orchestration/prompt/__init__.py +32 -0
  126. hexdag/core/orchestration/prompt/template.py +332 -0
  127. hexdag/core/pipeline_builder/__init__.py +21 -0
  128. hexdag/core/pipeline_builder/component_instantiator.py +386 -0
  129. hexdag/core/pipeline_builder/include_tag.py +265 -0
  130. hexdag/core/pipeline_builder/pipeline_config.py +133 -0
  131. hexdag/core/pipeline_builder/py_tag.py +223 -0
  132. hexdag/core/pipeline_builder/tag_discovery.py +268 -0
  133. hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
  134. hexdag/core/pipeline_builder/yaml_validator.py +569 -0
  135. hexdag/core/ports/__init__.py +65 -0
  136. hexdag/core/ports/api_call.py +133 -0
  137. hexdag/core/ports/database.py +489 -0
  138. hexdag/core/ports/embedding.py +215 -0
  139. hexdag/core/ports/executor.py +237 -0
  140. hexdag/core/ports/file_storage.py +117 -0
  141. hexdag/core/ports/healthcheck.py +87 -0
  142. hexdag/core/ports/llm.py +551 -0
  143. hexdag/core/ports/memory.py +70 -0
  144. hexdag/core/ports/observer_manager.py +130 -0
  145. hexdag/core/ports/secret.py +145 -0
  146. hexdag/core/ports/tool_router.py +94 -0
  147. hexdag/core/ports_builder.py +623 -0
  148. hexdag/core/protocols.py +273 -0
  149. hexdag/core/resolver.py +304 -0
  150. hexdag/core/schema/__init__.py +9 -0
  151. hexdag/core/schema/generator.py +742 -0
  152. hexdag/core/secrets.py +242 -0
  153. hexdag/core/types.py +413 -0
  154. hexdag/core/utils/async_warnings.py +206 -0
  155. hexdag/core/utils/schema_conversion.py +78 -0
  156. hexdag/core/utils/sql_validation.py +86 -0
  157. hexdag/core/validation/secure_json.py +148 -0
  158. hexdag/core/yaml_macro.py +517 -0
  159. hexdag/mcp_server.py +3120 -0
  160. hexdag/studio/__init__.py +10 -0
  161. hexdag/studio/build_ui.py +92 -0
  162. hexdag/studio/server/__init__.py +1 -0
  163. hexdag/studio/server/main.py +100 -0
  164. hexdag/studio/server/routes/__init__.py +9 -0
  165. hexdag/studio/server/routes/execute.py +208 -0
  166. hexdag/studio/server/routes/export.py +558 -0
  167. hexdag/studio/server/routes/files.py +207 -0
  168. hexdag/studio/server/routes/plugins.py +419 -0
  169. hexdag/studio/server/routes/validate.py +220 -0
  170. hexdag/studio/ui/index.html +13 -0
  171. hexdag/studio/ui/package-lock.json +2992 -0
  172. hexdag/studio/ui/package.json +31 -0
  173. hexdag/studio/ui/postcss.config.js +6 -0
  174. hexdag/studio/ui/public/hexdag.svg +5 -0
  175. hexdag/studio/ui/src/App.tsx +251 -0
  176. hexdag/studio/ui/src/components/Canvas.tsx +408 -0
  177. hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
  178. hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
  179. hexdag/studio/ui/src/components/Header.tsx +181 -0
  180. hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
  181. hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
  182. hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
  183. hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
  184. hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
  185. hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
  186. hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
  187. hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
  188. hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
  189. hexdag/studio/ui/src/components/index.ts +8 -0
  190. hexdag/studio/ui/src/index.css +92 -0
  191. hexdag/studio/ui/src/main.tsx +10 -0
  192. hexdag/studio/ui/src/types/index.ts +123 -0
  193. hexdag/studio/ui/src/vite-env.d.ts +1 -0
  194. hexdag/studio/ui/tailwind.config.js +29 -0
  195. hexdag/studio/ui/tsconfig.json +37 -0
  196. hexdag/studio/ui/tsconfig.node.json +13 -0
  197. hexdag/studio/ui/vite.config.ts +35 -0
  198. hexdag/visualization/__init__.py +69 -0
  199. hexdag/visualization/dag_visualizer.py +1020 -0
  200. hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
  201. hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
  202. hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
  203. hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
  204. hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
  205. hexdag_plugins/.gitignore +43 -0
  206. hexdag_plugins/README.md +73 -0
  207. hexdag_plugins/__init__.py +1 -0
  208. hexdag_plugins/azure/LICENSE +21 -0
  209. hexdag_plugins/azure/README.md +414 -0
  210. hexdag_plugins/azure/__init__.py +21 -0
  211. hexdag_plugins/azure/azure_blob_adapter.py +450 -0
  212. hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
  213. hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
  214. hexdag_plugins/azure/azure_openai_adapter.py +415 -0
  215. hexdag_plugins/azure/pyproject.toml +107 -0
  216. hexdag_plugins/azure/tests/__init__.py +1 -0
  217. hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
  218. hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
  219. hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
  220. hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
  221. hexdag_plugins/hexdag_etl/README.md +168 -0
  222. hexdag_plugins/hexdag_etl/__init__.py +53 -0
  223. hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
  224. hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
  225. hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
  226. hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
  227. hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
  228. hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
  229. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
  230. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
  231. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
  232. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
  233. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
  234. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
  235. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
  236. hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
  237. hexdag_plugins/hexdag_etl/test_transform.py +54 -0
  238. hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
  239. hexdag_plugins/mysql_adapter/LICENSE +21 -0
  240. hexdag_plugins/mysql_adapter/README.md +224 -0
  241. hexdag_plugins/mysql_adapter/__init__.py +6 -0
  242. hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
  243. hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
  244. hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
  245. hexdag_plugins/storage/README.md +184 -0
  246. hexdag_plugins/storage/__init__.py +19 -0
  247. hexdag_plugins/storage/file/__init__.py +5 -0
  248. hexdag_plugins/storage/file/local.py +325 -0
  249. hexdag_plugins/storage/ports/__init__.py +5 -0
  250. hexdag_plugins/storage/ports/vector_store.py +236 -0
  251. hexdag_plugins/storage/sql/__init__.py +7 -0
  252. hexdag_plugins/storage/sql/base.py +187 -0
  253. hexdag_plugins/storage/sql/mysql.py +27 -0
  254. hexdag_plugins/storage/sql/postgresql.py +27 -0
  255. hexdag_plugins/storage/tests/__init__.py +1 -0
  256. hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
  257. hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
  258. hexdag_plugins/storage/vector/__init__.py +7 -0
  259. hexdag_plugins/storage/vector/chromadb.py +223 -0
  260. hexdag_plugins/storage/vector/in_memory.py +285 -0
  261. hexdag_plugins/storage/vector/pgvector.py +502 -0
@@ -0,0 +1,569 @@
1
+ """Safe expression parser for YAML conditional expressions.
2
+
3
+ This module provides a secure way to compile string expressions like
4
+ `"node.action == 'ACCEPT'"` into callable predicates without using eval().
5
+
6
+ Uses Python's AST module with a strict whitelist approach:
7
+ - Only allows comparison operators: ==, !=, <, >, <=, >=
8
+ - Only allows boolean operators: and, or, not
9
+ - Only allows membership: in, not in
10
+ - Only allows attribute access and subscript for data extraction
11
+ - Only allows whitelisted function calls (see ALLOWED_FUNCTIONS)
12
+
13
+ Examples
14
+ --------
15
+ Basic usage::
16
+
17
+ from hexdag.core.expression_parser import compile_expression
18
+
19
+ # Compile expression to predicate
20
+ pred = compile_expression("action == 'ACCEPT'")
21
+ result = pred({"action": "ACCEPT"}, {}) # True
22
+
23
+ # Nested attribute access
24
+ pred = compile_expression("node.response.status == 'success'")
25
+ result = pred({"node": {"response": {"status": "success"}}}, {}) # True
26
+
27
+ # Boolean operators
28
+ pred = compile_expression("count > 5 and active == True")
29
+ result = pred({"count": 10, "active": True}, {}) # True
30
+
31
+ # State access
32
+ pred = compile_expression("state.iteration < 10")
33
+ result = pred({}, {"iteration": 5}) # True
34
+
35
+ # Membership test
36
+ pred = compile_expression("status in ['pending', 'active']")
37
+ result = pred({"status": "active"}, {}) # True
38
+
39
+ # Built-in functions
40
+ pred = compile_expression("len(items) > 0")
41
+ result = pred({"items": [1, 2, 3]}, {}) # True
42
+
43
+ pred = compile_expression("upper(name) == 'JOHN'")
44
+ result = pred({"name": "john"}, {}) # True
45
+ """
46
+
47
+ import ast
48
+ import operator
49
+ from collections.abc import Callable
50
+ from datetime import UTC, datetime
51
+ from decimal import Decimal
52
+ from typing import Any
53
+
54
+ from hexdag.core.logging import get_logger
55
+
56
+ __all__ = ["compile_expression", "evaluate_expression", "ExpressionError", "ALLOWED_FUNCTIONS"]
57
+
58
+ logger = get_logger(__name__)
59
+
60
+
61
+ class ExpressionError(Exception):
62
+ """Raised when expression parsing or evaluation fails."""
63
+
64
+ def __init__(self, expression: str, reason: str) -> None:
65
+ self.expression = expression
66
+ self.reason = reason
67
+ super().__init__(f"Expression error in '{expression}': {reason}")
68
+
69
+
70
+ # Allowed comparison operators
71
+ _COMPARE_OPS: dict[type[ast.cmpop], Callable[[Any, Any], bool]] = {
72
+ ast.Eq: operator.eq,
73
+ ast.NotEq: operator.ne,
74
+ ast.Lt: operator.lt,
75
+ ast.LtE: operator.le,
76
+ ast.Gt: operator.gt,
77
+ ast.GtE: operator.ge,
78
+ ast.In: lambda x, y: x in y,
79
+ ast.NotIn: lambda x, y: x not in y,
80
+ ast.Is: operator.is_,
81
+ ast.IsNot: operator.is_not,
82
+ }
83
+
84
+ # Allowed boolean operators
85
+ _BOOL_OPS: dict[type[ast.boolop], Callable[..., bool]] = {
86
+ ast.And: lambda *args: all(args),
87
+ ast.Or: lambda *args: any(args),
88
+ }
89
+
90
+ # Allowed unary operators (return Any since they can be bool or numeric)
91
+ _UNARY_OPS: dict[type[ast.unaryop], Callable[..., Any]] = {
92
+ ast.Not: operator.not_,
93
+ ast.USub: operator.neg,
94
+ ast.UAdd: operator.pos,
95
+ }
96
+
97
+ # Safe built-in functions allowed in expressions
98
+ # These are carefully selected to avoid side effects and security risks
99
+ ALLOWED_FUNCTIONS: dict[str, Callable[..., Any]] = {
100
+ # Date/time functions
101
+ "now": lambda: datetime.now(),
102
+ "utcnow": lambda: datetime.now(UTC),
103
+ "timestamp": lambda dt: dt.timestamp() if isinstance(dt, datetime) else float(dt),
104
+ # Type conversion functions
105
+ "str": str,
106
+ "int": int,
107
+ "float": float,
108
+ "bool": bool,
109
+ # Math functions
110
+ "abs": abs,
111
+ "round": round,
112
+ "min": min,
113
+ "max": max,
114
+ "sum": sum,
115
+ # Collection functions
116
+ "len": len,
117
+ "all": all,
118
+ "any": any,
119
+ "sorted": sorted,
120
+ "reversed": lambda x: list(reversed(x)),
121
+ "list": list,
122
+ "set": set,
123
+ "dict": dict,
124
+ "tuple": tuple,
125
+ # String operations (wrapped to handle non-strings gracefully)
126
+ "lower": lambda s: s.lower() if isinstance(s, str) else str(s).lower(),
127
+ "upper": lambda s: s.upper() if isinstance(s, str) else str(s).upper(),
128
+ "strip": lambda s: s.strip() if isinstance(s, str) else str(s).strip(),
129
+ "lstrip": lambda s: s.lstrip() if isinstance(s, str) else str(s).lstrip(),
130
+ "rstrip": lambda s: s.rstrip() if isinstance(s, str) else str(s).rstrip(),
131
+ "split": lambda s, sep=None: s.split(sep) if isinstance(s, str) else [s],
132
+ "join": lambda sep, items: sep.join(str(i) for i in items),
133
+ "replace": lambda s, old, new: s.replace(old, new) if isinstance(s, str) else s,
134
+ "startswith": lambda s, prefix: s.startswith(prefix) if isinstance(s, str) else False,
135
+ "endswith": lambda s, suffix: s.endswith(suffix) if isinstance(s, str) else False,
136
+ "contains": lambda s, sub: sub in s if isinstance(s, str) else False,
137
+ # Conditional/utility functions
138
+ "default": lambda val, default: val if val is not None else default,
139
+ "coalesce": lambda *args: next((a for a in args if a is not None), None),
140
+ "isnone": lambda x: x is None,
141
+ "isempty": lambda x: x is None or x == "" or x == [] or x == {},
142
+ # Financial/precision math functions
143
+ "Decimal": Decimal,
144
+ "pow": pow,
145
+ "format": format,
146
+ }
147
+
148
+
149
+ def _get_function_name(func_node: ast.AST) -> str | None:
150
+ """Extract function name from a Call node's func attribute.
151
+
152
+ Parameters
153
+ ----------
154
+ func_node : ast.AST
155
+ The func attribute of an ast.Call node
156
+
157
+ Returns
158
+ -------
159
+ str | None
160
+ Function name if it's a simple Name node, None otherwise
161
+ """
162
+ if isinstance(func_node, ast.Name):
163
+ return func_node.id
164
+ return None
165
+
166
+
167
+ def _validate_ast(node: ast.AST, expression: str) -> None:
168
+ """Validate that an AST node only contains allowed operations.
169
+
170
+ Parameters
171
+ ----------
172
+ node : ast.AST
173
+ The AST node to validate
174
+ expression : str
175
+ Original expression for error messages
176
+
177
+ Raises
178
+ ------
179
+ ExpressionError
180
+ If the AST contains disallowed operations
181
+ """
182
+ allowed_types = (
183
+ ast.Expression,
184
+ ast.Compare,
185
+ ast.BoolOp,
186
+ ast.UnaryOp,
187
+ ast.BinOp,
188
+ ast.IfExp, # Ternary conditional: a if condition else b
189
+ ast.Attribute,
190
+ ast.Subscript,
191
+ ast.Name,
192
+ ast.Constant,
193
+ ast.Load,
194
+ ast.Index, # Python 3.8 compatibility
195
+ ast.Slice,
196
+ ast.Tuple,
197
+ ast.List,
198
+ ast.Dict,
199
+ ast.Call, # Now allowed for whitelisted functions
200
+ ast.keyword, # For keyword arguments in function calls
201
+ # Comparison operators
202
+ ast.Eq,
203
+ ast.NotEq,
204
+ ast.Lt,
205
+ ast.LtE,
206
+ ast.Gt,
207
+ ast.GtE,
208
+ ast.In,
209
+ ast.NotIn,
210
+ ast.Is,
211
+ ast.IsNot,
212
+ # Boolean operators
213
+ ast.And,
214
+ ast.Or,
215
+ ast.Not,
216
+ # Unary operators
217
+ ast.USub,
218
+ ast.UAdd,
219
+ # Binary operators (for arithmetic if needed)
220
+ ast.Add,
221
+ ast.Sub,
222
+ ast.Mult,
223
+ ast.Div,
224
+ ast.Mod,
225
+ )
226
+
227
+ # Check this node
228
+ if not isinstance(node, allowed_types):
229
+ raise ExpressionError(expression, f"Disallowed expression type: {type(node).__name__}")
230
+
231
+ # Check for function calls - only allow whitelisted functions
232
+ if isinstance(node, ast.Call):
233
+ func_name = _get_function_name(node.func)
234
+ if func_name is None:
235
+ raise ExpressionError(
236
+ expression,
237
+ "Only simple function calls are allowed (e.g., 'len(x)', not 'obj.method()')",
238
+ )
239
+ if func_name not in ALLOWED_FUNCTIONS:
240
+ allowed_list = ", ".join(sorted(ALLOWED_FUNCTIONS.keys()))
241
+ raise ExpressionError(
242
+ expression,
243
+ f"Function '{func_name}' is not allowed. Allowed functions: {allowed_list}",
244
+ )
245
+
246
+ # Recursively check all child nodes
247
+ for child in ast.iter_child_nodes(node):
248
+ _validate_ast(child, expression)
249
+
250
+
251
+ def _get_value(data: dict[str, Any], state: dict[str, Any], path: list[str]) -> Any:
252
+ """Extract value from data or state using a path.
253
+
254
+ Parameters
255
+ ----------
256
+ data : dict
257
+ Primary data dict (node outputs)
258
+ state : dict
259
+ Secondary state dict (loop state, etc.)
260
+ path : list[str]
261
+ Path components like ["node", "action"]
262
+
263
+ Returns
264
+ -------
265
+ Any
266
+ Extracted value or None if not found
267
+ """
268
+ if not path:
269
+ return None
270
+
271
+ # Check if first component refers to "state"
272
+ if path[0] == "state":
273
+ current: Any = state
274
+ path = path[1:]
275
+ else:
276
+ current = data
277
+
278
+ for key in path:
279
+ if current is None:
280
+ return None
281
+ if isinstance(current, dict):
282
+ current = current.get(key)
283
+ elif hasattr(current, key):
284
+ current = getattr(current, key)
285
+ else:
286
+ return None
287
+
288
+ return current
289
+
290
+
291
+ def _evaluate_node(node: ast.AST, data: dict[str, Any], state: dict[str, Any]) -> Any:
292
+ """Evaluate an AST node against data and state.
293
+
294
+ Parameters
295
+ ----------
296
+ node : ast.AST
297
+ AST node to evaluate
298
+ data : dict
299
+ Data dict for variable resolution
300
+ state : dict
301
+ State dict for state variable resolution
302
+
303
+ Returns
304
+ -------
305
+ Any
306
+ Result of evaluation
307
+ """
308
+ if isinstance(node, ast.Constant):
309
+ return node.value
310
+
311
+ if isinstance(node, ast.Name):
312
+ # Simple variable access
313
+ if node.id == "True":
314
+ return True
315
+ if node.id == "False":
316
+ return False
317
+ if node.id == "None":
318
+ return None
319
+ if node.id == "state":
320
+ return state
321
+ return data.get(node.id)
322
+
323
+ if isinstance(node, ast.Attribute):
324
+ # Build path for attribute access
325
+ path = _collect_attribute_path(node)
326
+ return _get_value(data, state, path)
327
+
328
+ if isinstance(node, ast.Subscript):
329
+ # Handle subscript access: data["key"] or data[0]
330
+ value = _evaluate_node(node.value, data, state)
331
+ # Handle slice (Python 3.9+ changed ast.Index)
332
+ if isinstance(node.slice, ast.Index): # Python 3.8
333
+ key = _evaluate_node(node.slice.value, data, state) # type: ignore[attr-defined]
334
+ else:
335
+ key = _evaluate_node(node.slice, data, state)
336
+ if value is None:
337
+ return None
338
+ if isinstance(value, dict):
339
+ return value.get(key)
340
+ if isinstance(value, (list, tuple)) and isinstance(key, int):
341
+ return value[key] if 0 <= key < len(value) else None
342
+ return None
343
+
344
+ if isinstance(node, ast.Compare):
345
+ # Handle chained comparisons: a < b < c
346
+ left = _evaluate_node(node.left, data, state)
347
+ result = True
348
+ for op, comparator in zip(node.ops, node.comparators, strict=False):
349
+ right = _evaluate_node(comparator, data, state)
350
+ op_func = _COMPARE_OPS.get(type(op))
351
+ if op_func is None:
352
+ raise ExpressionError("", f"Unsupported comparison: {type(op).__name__}")
353
+ try:
354
+ result = result and op_func(left, right)
355
+ except TypeError:
356
+ # Handle None comparisons gracefully
357
+ return False
358
+ left = right
359
+ return result
360
+
361
+ if isinstance(node, ast.BoolOp):
362
+ # Handle and/or with short-circuit evaluation
363
+ values = [_evaluate_node(v, data, state) for v in node.values]
364
+ op_func = _BOOL_OPS.get(type(node.op))
365
+ if op_func is None:
366
+ raise ExpressionError("", f"Unsupported boolean op: {type(node.op).__name__}")
367
+ return op_func(*values)
368
+
369
+ if isinstance(node, ast.UnaryOp):
370
+ operand = _evaluate_node(node.operand, data, state)
371
+ unary_op_func = _UNARY_OPS.get(type(node.op))
372
+ if unary_op_func is None:
373
+ raise ExpressionError("", f"Unsupported unary op: {type(node.op).__name__}")
374
+ return unary_op_func(operand)
375
+
376
+ if isinstance(node, ast.IfExp):
377
+ # Handle ternary conditional: a if condition else b
378
+ condition = _evaluate_node(node.test, data, state)
379
+ if condition:
380
+ return _evaluate_node(node.body, data, state)
381
+ return _evaluate_node(node.orelse, data, state)
382
+
383
+ if isinstance(node, ast.List):
384
+ return [_evaluate_node(elt, data, state) for elt in node.elts]
385
+
386
+ if isinstance(node, ast.Tuple):
387
+ return tuple(_evaluate_node(elt, data, state) for elt in node.elts)
388
+
389
+ if isinstance(node, ast.Dict):
390
+ keys = [_evaluate_node(k, data, state) if k else None for k in node.keys]
391
+ values = [_evaluate_node(v, data, state) for v in node.values]
392
+ return dict(zip(keys, values, strict=False))
393
+
394
+ if isinstance(node, ast.BinOp):
395
+ # Handle arithmetic operators
396
+ left = _evaluate_node(node.left, data, state)
397
+ right = _evaluate_node(node.right, data, state)
398
+ bin_ops = {
399
+ ast.Add: operator.add,
400
+ ast.Sub: operator.sub,
401
+ ast.Mult: operator.mul,
402
+ ast.Div: operator.truediv,
403
+ ast.Mod: operator.mod,
404
+ }
405
+ op_func = bin_ops.get(type(node.op))
406
+ if op_func is None:
407
+ raise ExpressionError("", f"Unsupported binary op: {type(node.op).__name__}")
408
+ return op_func(left, right)
409
+
410
+ if isinstance(node, ast.Call):
411
+ # Handle whitelisted function calls
412
+ func_name = _get_function_name(node.func)
413
+ if func_name is None or func_name not in ALLOWED_FUNCTIONS:
414
+ raise ExpressionError("", f"Unknown or disallowed function: {func_name}")
415
+
416
+ func = ALLOWED_FUNCTIONS[func_name]
417
+
418
+ # Evaluate arguments
419
+ args = [_evaluate_node(arg, data, state) for arg in node.args]
420
+
421
+ # Evaluate keyword arguments
422
+ kwargs = {}
423
+ for kw in node.keywords:
424
+ if kw.arg is not None:
425
+ kwargs[kw.arg] = _evaluate_node(kw.value, data, state)
426
+
427
+ try:
428
+ return func(*args, **kwargs)
429
+ except Exception as e:
430
+ raise ExpressionError("", f"Error calling {func_name}: {e}") from e
431
+
432
+ raise ExpressionError("", f"Unsupported AST node: {type(node).__name__}")
433
+
434
+
435
+ def _collect_attribute_path(node: ast.Attribute) -> list[str]:
436
+ """Collect attribute path from nested attribute access.
437
+
438
+ For `node.response.action`, returns ["node", "response", "action"]
439
+ """
440
+ path: list[str] = []
441
+ current: ast.AST = node
442
+
443
+ while isinstance(current, ast.Attribute):
444
+ path.append(current.attr)
445
+ current = current.value
446
+
447
+ if isinstance(current, ast.Name):
448
+ path.append(current.id)
449
+
450
+ return list(reversed(path))
451
+
452
+
453
+ def compile_expression(expression: str) -> Callable[[dict[str, Any], dict[str, Any]], bool]:
454
+ """Compile a string expression into a safe predicate function.
455
+
456
+ The compiled predicate takes two arguments:
457
+ - data: dict containing node outputs and other data
458
+ - state: dict containing loop state or other state variables
459
+
460
+ Parameters
461
+ ----------
462
+ expression : str
463
+ Expression string like "action == 'ACCEPT'" or "count > 5 and active"
464
+
465
+ Returns
466
+ -------
467
+ Callable[[dict, dict], bool]
468
+ Predicate function that returns True/False
469
+
470
+ Raises
471
+ ------
472
+ ExpressionError
473
+ If expression is invalid or contains disallowed operations
474
+
475
+ Examples
476
+ --------
477
+ >>> pred = compile_expression("action == 'ACCEPT'")
478
+ >>> pred({"action": "ACCEPT"}, {})
479
+ True
480
+
481
+ >>> pred = compile_expression("node.status in ['active', 'pending']")
482
+ >>> pred({"node": {"status": "active"}}, {})
483
+ True
484
+
485
+ >>> pred = compile_expression("state.iteration < 10")
486
+ >>> pred({}, {"iteration": 5})
487
+ True
488
+ """
489
+ if not expression or not expression.strip():
490
+ raise ExpressionError(expression, "Expression cannot be empty")
491
+
492
+ expression = expression.strip()
493
+
494
+ try:
495
+ tree = ast.parse(expression, mode="eval")
496
+ except SyntaxError as e:
497
+ raise ExpressionError(expression, f"Syntax error: {e.msg}") from e
498
+
499
+ # Validate AST is safe
500
+ _validate_ast(tree, expression)
501
+
502
+ def predicate(data: dict[str, Any], state: dict[str, Any]) -> bool:
503
+ """Evaluate the compiled expression."""
504
+ try:
505
+ result = _evaluate_node(tree.body, data, state)
506
+ return bool(result)
507
+ except Exception as e:
508
+ logger.warning(f"Expression '{expression}' evaluation failed: {e}")
509
+ return False
510
+
511
+ return predicate
512
+
513
+
514
+ def evaluate_expression(
515
+ expression: str,
516
+ data: dict[str, Any],
517
+ state: dict[str, Any] | None = None,
518
+ ) -> Any:
519
+ """Evaluate an expression and return the actual result value.
520
+
521
+ Unlike compile_expression which returns a boolean predicate, this function
522
+ returns the actual computed value of the expression. Use this for input_mapping
523
+ transformations where you need the actual value, not just True/False.
524
+
525
+ Parameters
526
+ ----------
527
+ expression : str
528
+ Expression string like "len(items)" or "upper(name)"
529
+ data : dict
530
+ Data dict for variable resolution
531
+ state : dict | None
532
+ Optional state dict for state variable resolution
533
+
534
+ Returns
535
+ -------
536
+ Any
537
+ The actual result of evaluating the expression
538
+
539
+ Raises
540
+ ------
541
+ ExpressionError
542
+ If expression is invalid or contains disallowed operations
543
+
544
+ Examples
545
+ --------
546
+ >>> evaluate_expression("len(items)", {"items": [1, 2, 3]})
547
+ 3
548
+
549
+ >>> evaluate_expression("upper(name)", {"name": "john"})
550
+ 'JOHN'
551
+
552
+ >>> evaluate_expression("price * quantity", {"price": 10, "quantity": 5})
553
+ 50
554
+ """
555
+ if not expression or not expression.strip():
556
+ raise ExpressionError(expression, "Expression cannot be empty")
557
+
558
+ expression = expression.strip()
559
+ state = state or {}
560
+
561
+ try:
562
+ tree = ast.parse(expression, mode="eval")
563
+ except SyntaxError as e:
564
+ raise ExpressionError(expression, f"Syntax error: {e.msg}") from e
565
+
566
+ # Validate AST is safe
567
+ _validate_ast(tree, expression)
568
+
569
+ return _evaluate_node(tree.body, data, state)