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.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- 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)
|