dify-player 0.1.0__tar.gz

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 (55) hide show
  1. dify_player-0.1.0/PKG-INFO +8 -0
  2. dify_player-0.1.0/README.md +148 -0
  3. dify_player-0.1.0/dify_player/__init__.py +7 -0
  4. dify_player-0.1.0/dify_player/__main__.py +4 -0
  5. dify_player-0.1.0/dify_player/cli.py +73 -0
  6. dify_player-0.1.0/dify_player/dify_importer/__init__.py +4 -0
  7. dify_player-0.1.0/dify_player/dify_importer/graph_parser.py +103 -0
  8. dify_player-0.1.0/dify_player/dify_importer/http_body_converter.py +56 -0
  9. dify_player-0.1.0/dify_player/dify_importer/node_converters/__init__.py +23 -0
  10. dify_player-0.1.0/dify_player/dify_importer/node_converters/assigner.py +85 -0
  11. dify_player-0.1.0/dify_player/dify_importer/node_converters/code.py +64 -0
  12. dify_player-0.1.0/dify_player/dify_importer/node_converters/end.py +44 -0
  13. dify_player-0.1.0/dify_player/dify_importer/node_converters/http_request.py +64 -0
  14. dify_player-0.1.0/dify_player/dify_importer/node_converters/if_else.py +89 -0
  15. dify_player-0.1.0/dify_player/dify_importer/node_converters/llm.py +142 -0
  16. dify_player-0.1.0/dify_player/dify_importer/node_converters/loop.py +144 -0
  17. dify_player-0.1.0/dify_player/dify_importer/node_converters/start.py +7 -0
  18. dify_player-0.1.0/dify_player/dify_importer/node_converters/template_transform.py +85 -0
  19. dify_player-0.1.0/dify_player/dify_importer/node_converters/variable_aggregator.py +43 -0
  20. dify_player-0.1.0/dify_player/dify_importer/plan_serializer.py +35 -0
  21. dify_player-0.1.0/dify_player/dify_importer/reference_converter.py +232 -0
  22. dify_player-0.1.0/dify_player/dify_importer/workflow_loader.py +95 -0
  23. dify_player-0.1.0/dify_player/dify_importer/workflow_normalizer.py +227 -0
  24. dify_player-0.1.0/dify_player/dify_workflow_importer.py +3 -0
  25. dify_player-0.1.0/dify_player/event_logger.py +82 -0
  26. dify_player-0.1.0/dify_player/exceptions.py +44 -0
  27. dify_player-0.1.0/dify_player/input_resolver.py +29 -0
  28. dify_player-0.1.0/dify_player/models.py +122 -0
  29. dify_player-0.1.0/dify_player/nodes/__init__.py +52 -0
  30. dify_player-0.1.0/dify_player/nodes/assigner.py +43 -0
  31. dify_player-0.1.0/dify_player/nodes/code.py +42 -0
  32. dify_player-0.1.0/dify_player/nodes/end.py +29 -0
  33. dify_player-0.1.0/dify_player/nodes/http.py +112 -0
  34. dify_player-0.1.0/dify_player/nodes/if_else.py +44 -0
  35. dify_player-0.1.0/dify_player/nodes/llm_azure_chat.py +333 -0
  36. dify_player-0.1.0/dify_player/nodes/start.py +21 -0
  37. dify_player-0.1.0/dify_player/nodes/template.py +23 -0
  38. dify_player-0.1.0/dify_player/nodes/variable_aggregator.py +33 -0
  39. dify_player-0.1.0/dify_player/plan_loader.py +763 -0
  40. dify_player-0.1.0/dify_player/runtime.py +627 -0
  41. dify_player-0.1.0/dify_player/value_renderer.py +71 -0
  42. dify_player-0.1.0/dify_player/workflow_engine.py +82 -0
  43. dify_player-0.1.0/dify_player/workflow_executor.py +185 -0
  44. dify_player-0.1.0/dify_player.egg-info/PKG-INFO +8 -0
  45. dify_player-0.1.0/dify_player.egg-info/SOURCES.txt +53 -0
  46. dify_player-0.1.0/dify_player.egg-info/dependency_links.txt +1 -0
  47. dify_player-0.1.0/dify_player.egg-info/requires.txt +3 -0
  48. dify_player-0.1.0/dify_player.egg-info/top_level.txt +1 -0
  49. dify_player-0.1.0/pyproject.toml +20 -0
  50. dify_player-0.1.0/setup.cfg +4 -0
  51. dify_player-0.1.0/tests/test_assigner.py +301 -0
  52. dify_player-0.1.0/tests/test_cli.py +392 -0
  53. dify_player-0.1.0/tests/test_dify_workflow_importer.py +1497 -0
  54. dify_player-0.1.0/tests/test_runtime.py +1992 -0
  55. dify_player-0.1.0/tests/test_workflow_engine.py +45 -0
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: dify-player
3
+ Version: 0.1.0
4
+ Summary: Minimal workflow runner for hand-authored Dify-like plans.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: Jinja2<4,>=3.1
7
+ Requires-Dist: PyYAML<7,>=6
8
+ Requires-Dist: httpx<0.29,>=0.27
@@ -0,0 +1,148 @@
1
+ # dify-player
2
+
3
+ 最小構成の Dify workflow ランナーです。
4
+ 現在は strict plan JSON の実行、最小 Dify YAML の import、Python からの async ライブラリ利用をサポートしています。
5
+
6
+ ## Setup
7
+
8
+ ```bash
9
+ python3 -m venv .venv
10
+ source .venv/bin/activate
11
+ pip install -e .
12
+ ```
13
+
14
+ ## Library Usage
15
+
16
+ ### Quick Start
17
+
18
+ ライブラリとして使う場合は `WorkflowEngine` を利用します。
19
+ `run_plan_data(...)` は strict plan JSON 相当の dict をそのまま受け取れます。
20
+
21
+ ```python
22
+ from dify_player import WorkflowEngine
23
+ from dify_player.models import to_data
24
+
25
+ engine = WorkflowEngine()
26
+
27
+ result = await engine.run_plan_data(
28
+ plan_data=plan_data,
29
+ inputs=inputs,
30
+ )
31
+
32
+ if result.status == "succeeded":
33
+ print(result.outputs)
34
+ else:
35
+ print(result.error.code, result.error.message)
36
+
37
+ payload = to_data(result)
38
+ ```
39
+
40
+ 返り値は `RunResult` です。`result.outputs`、`result.error`、`result.nodes` に実行結果が入ります。
41
+ HTTP レスポンスや JSON 化が必要な場合は `dify_player.models.to_data(...)` を使って dict に変換できます。
42
+
43
+ ### FastAPI Example
44
+
45
+ FastAPI から呼ぶ場合は、アプリ lifespan で `httpx.AsyncClient` を 1 つ持ち、`WorkflowEngine` に注入するのが扱いやすいです。
46
+
47
+ ```python
48
+ import httpx
49
+ from fastapi import FastAPI, HTTPException
50
+
51
+ from dify_player import WorkflowEngine
52
+ from dify_player.exceptions import PlanValidationError
53
+ from dify_player.models import to_data
54
+
55
+ app = FastAPI()
56
+ engine = WorkflowEngine()
57
+
58
+
59
+ @app.on_event("startup")
60
+ async def startup() -> None:
61
+ app.state.http_client = httpx.AsyncClient()
62
+
63
+
64
+ @app.on_event("shutdown")
65
+ async def shutdown() -> None:
66
+ await app.state.http_client.aclose()
67
+
68
+
69
+ @app.post("/workflow/run")
70
+ async def run_workflow(payload: dict) -> dict:
71
+ try:
72
+ result = await engine.run_plan_data(
73
+ plan_data=payload["plan"],
74
+ inputs=payload["inputs"],
75
+ http_client=app.state.http_client,
76
+ )
77
+ except PlanValidationError as exc:
78
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
79
+
80
+ return to_data(result)
81
+ ```
82
+
83
+ ### Public Interface
84
+
85
+ `WorkflowEngine` には次の async API があります。
86
+
87
+ - `await engine.run_plan_data(plan_data=..., inputs=..., run_id=None, logger=None, http_client=None)`
88
+ - `await engine.run_plan(plan=..., inputs=..., run_id=None, logger=None, http_client=None)`
89
+ - `await engine.run_compiled_plan(compiled_plan=..., inputs=..., run_id=None, logger=None, http_client=None)`
90
+
91
+ 使い分けは次のとおりです。
92
+
93
+ - `run_plan_data`: Web API や JSON 入力から直接呼ぶ用途向け
94
+ - `run_plan`: すでに `Plan` オブジェクトを組み立てている内部利用向け
95
+ - `run_compiled_plan`: 同じ plan を何度も実行する用途向け
96
+
97
+ ### Logger And HTTP Client
98
+
99
+ `logger` を渡さない場合、ライブラリ利用時はファイルログを書きません。
100
+ 必要なら `run_id`, `log(...)`, `close()` を持つ logger を渡せます。
101
+
102
+ `http_client` は任意です。
103
+
104
+ - 渡した場合: 呼び出し側が所有し、このライブラリでは close しません
105
+ - 渡さない場合: 実行ごとに内部で `httpx.AsyncClient` を生成して close します
106
+
107
+ ### Validation Behavior
108
+
109
+ `run_plan_data(...)` は `plan_data` を `Plan` に変換する段階で `PlanValidationError` を送出することがあります。
110
+ 一方、実行フェーズの失敗は `RunResult(status="failed")` として返ります。
111
+
112
+ ## CLI
113
+
114
+ ### Run
115
+
116
+ 手書きまたは import 済みの strict plan JSON を実行します。
117
+
118
+ `plan.nodes[].name` は任意です。指定するとエラー表示とイベントログに `name [id]` 形式で出るため、Dify のどのノードか追いやすくなります。
119
+
120
+ ```bash
121
+ python -m dify_player run examples/hello/plan.json examples/hello/input.json
122
+ ```
123
+
124
+ ログを明示したい場合は `--log` を使います。
125
+
126
+ ```bash
127
+ python -m dify_player run examples/hello/plan.json examples/hello/input.json --log logs/run.jsonl
128
+ ```
129
+
130
+ ### Import
131
+
132
+ 最小 Dify workflow YAML を strict plan JSON に変換します。
133
+
134
+ ```bash
135
+ python -m dify_player import workflows/test_http_saas.yml output/plan.json
136
+ ```
137
+
138
+ 生成された `plan.json` は、そのまま `run` で実行できます。
139
+
140
+ ## Test HTTP Server
141
+
142
+ HTTP ノードの確認用に、固定ポート `18080` のローカルサーバを起動できます。
143
+
144
+ ```bash
145
+ python scripts/http_test_server.py
146
+ ```
147
+
148
+ このサーバは `GET /json`, `GET /slow`, `GET /not-found`, `POST /echo` を返します。
@@ -0,0 +1,7 @@
1
+ """Minimal workflow runner package."""
2
+
3
+ from dify_player.workflow_engine import WorkflowEngine
4
+
5
+ __all__ = ["__version__", "WorkflowEngine"]
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from dify_player.cli import main
2
+
3
+
4
+ raise SystemExit(main())
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from dify_player.dify_workflow_importer import load_dify_workflow_definition, plan_to_data
10
+ from dify_player.exceptions import PlanValidationError
11
+ from dify_player.models import to_data
12
+ from dify_player.plan_loader import compile_plan
13
+ from dify_player.workflow_executor import WorkflowExecutor
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(prog="dify_player")
18
+ subparsers = parser.add_subparsers(dest="command", required=True)
19
+
20
+ run_parser = subparsers.add_parser("run", help="Run a hand-authored workflow plan.")
21
+ run_parser.add_argument("plan", help="Path to a plan JSON file.")
22
+ run_parser.add_argument("input", help="Path to an input JSON file.")
23
+ run_parser.add_argument(
24
+ "--log",
25
+ dest="log_path",
26
+ help="Path to the JSONL log file. Defaults to logs/<timestamp>_<run_id>.jsonl.",
27
+ )
28
+
29
+ import_parser = subparsers.add_parser("import", help="Import a minimal Dify workflow YAML into a strict plan JSON.")
30
+ import_parser.add_argument("workflow", help="Path to a Dify workflow YAML file.")
31
+ import_parser.add_argument("plan", help="Path to write the imported strict plan JSON.")
32
+ return parser
33
+
34
+
35
+ async def async_main(argv: list[str] | None = None) -> int:
36
+ parser = build_parser()
37
+ args = parser.parse_args(argv)
38
+
39
+ if args.command == "import":
40
+ try:
41
+ plan = load_dify_workflow_definition(Path(args.workflow))
42
+ compile_plan(plan)
43
+ except (FileNotFoundError, OSError, PlanValidationError) as exc:
44
+ print(str(exc), file=sys.stderr)
45
+ return 1
46
+
47
+ output_path = Path(args.plan)
48
+ output_path.parent.mkdir(parents=True, exist_ok=True)
49
+ with output_path.open("w", encoding="utf-8") as handle:
50
+ json.dump(plan_to_data(plan), handle, ensure_ascii=False, indent=2)
51
+ handle.write("\n")
52
+ print(str(output_path))
53
+ return 0
54
+
55
+ if args.command != "run":
56
+ parser.error(f"unsupported command: {args.command}")
57
+
58
+ log_path = Path(args.log_path) if args.log_path else None
59
+ result = await WorkflowExecutor().run_plan_path(
60
+ plan_path=Path(args.plan),
61
+ input_path=Path(args.input),
62
+ log_path=log_path,
63
+ )
64
+ print(json.dumps(to_data(result), ensure_ascii=False))
65
+ return 0 if result.status == "succeeded" else 1
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ return asyncio.run(async_main(argv))
70
+
71
+
72
+ if __name__ == "__main__":
73
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ from dify_player.dify_importer.plan_serializer import plan_to_data
2
+ from dify_player.dify_importer.workflow_loader import load_dify_workflow_definition
3
+
4
+ __all__ = ["load_dify_workflow_definition", "plan_to_data"]
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from dify_player.exceptions import PlanValidationError
7
+
8
+ SUPPORTED_DIFY_NODE_TYPES = {
9
+ "start",
10
+ "http-request",
11
+ "llm",
12
+ "template-transform",
13
+ "if-else",
14
+ "variable-aggregator",
15
+ "code",
16
+ "loop",
17
+ "end",
18
+ }
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ParsedDifyGraph:
23
+ node_specs: dict[str, dict[str, Any]]
24
+ raw_edges: list[dict[str, Any]]
25
+
26
+
27
+ def parse_dify_graph(data: Any) -> ParsedDifyGraph:
28
+ if not isinstance(data, dict):
29
+ raise PlanValidationError("Dify workflow YAML must be an object at the top level")
30
+ if data.get("kind") != "app":
31
+ raise PlanValidationError("Dify workflow YAML must define kind=app")
32
+
33
+ app = data.get("app")
34
+ if not isinstance(app, dict) or app.get("mode") != "workflow":
35
+ raise PlanValidationError("Dify workflow YAML must define app.mode=workflow")
36
+
37
+ workflow = data.get("workflow")
38
+ if not isinstance(workflow, dict):
39
+ raise PlanValidationError("Dify workflow YAML must define workflow")
40
+
41
+ graph = workflow.get("graph")
42
+ if not isinstance(graph, dict):
43
+ raise PlanValidationError("Dify workflow YAML must define workflow.graph")
44
+
45
+ raw_nodes = graph.get("nodes")
46
+ raw_edges = graph.get("edges")
47
+ if not isinstance(raw_nodes, list) or not raw_nodes:
48
+ raise PlanValidationError("Dify workflow graph must define non-empty nodes")
49
+ if not isinstance(raw_edges, list):
50
+ raise PlanValidationError("Dify workflow graph must define edges as an array")
51
+
52
+ return ParsedDifyGraph(
53
+ node_specs=_build_node_specs(raw_nodes),
54
+ raw_edges=[_parse_raw_edge(raw_edge) for raw_edge in raw_edges],
55
+ )
56
+
57
+
58
+ def _build_node_specs(raw_nodes: list[Any]) -> dict[str, dict[str, Any]]:
59
+ node_specs: dict[str, dict[str, Any]] = {}
60
+ start_count = 0
61
+ end_count = 0
62
+
63
+ for raw_node in raw_nodes:
64
+ if not isinstance(raw_node, dict):
65
+ raise PlanValidationError("each Dify graph node must be an object")
66
+ node_id = raw_node.get("id")
67
+ data = raw_node.get("data")
68
+ if not isinstance(node_id, str) or not node_id:
69
+ raise PlanValidationError("each Dify graph node must define a non-empty string id")
70
+ if not isinstance(data, dict):
71
+ raise PlanValidationError(f"Dify node {node_id!r} must define data")
72
+
73
+ node_type = data.get("type")
74
+ if not isinstance(node_type, str) or not node_type:
75
+ raise PlanValidationError(f"Dify node {node_id!r} must define data.type")
76
+ if node_type not in SUPPORTED_DIFY_NODE_TYPES:
77
+ raise PlanValidationError(f"Dify node {node_id!r} has unsupported type {node_type!r}")
78
+ if node_id in node_specs:
79
+ raise PlanValidationError(f"duplicate Dify node id {node_id!r}")
80
+
81
+ if node_type == "start":
82
+ start_count += 1
83
+ if node_type == "end":
84
+ end_count += 1
85
+
86
+ node_specs[node_id] = data
87
+
88
+ if start_count != 1:
89
+ raise PlanValidationError("Dify workflow must contain exactly one start node")
90
+ if end_count != 1:
91
+ raise PlanValidationError("Dify workflow must contain exactly one end node")
92
+
93
+ return node_specs
94
+
95
+
96
+ def _parse_raw_edge(raw_edge: Any) -> dict[str, Any]:
97
+ if not isinstance(raw_edge, dict):
98
+ raise PlanValidationError("each Dify graph edge must be an object")
99
+ source = raw_edge.get("source")
100
+ target = raw_edge.get("target")
101
+ if not isinstance(source, str) or not isinstance(target, str):
102
+ raise PlanValidationError("Dify graph edge source and target must be strings")
103
+ return raw_edge
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from dify_player.exceptions import PlanValidationError
7
+ from dify_player.dify_importer.reference_converter import convert_value, replace_embedded_dify_references
8
+
9
+
10
+ def convert_http_body(raw_body: Any, *, node_specs: dict[str, dict[str, Any]], context: str) -> Any | None:
11
+ if raw_body in (None, ""):
12
+ return None
13
+ if not isinstance(raw_body, dict):
14
+ return convert_value(raw_body, node_specs=node_specs, context=context)
15
+
16
+ body_type = raw_body.get("type")
17
+ if body_type == "none":
18
+ return None
19
+ if body_type == "json":
20
+ return _convert_json_http_body(raw_body, node_specs=node_specs, context=context)
21
+
22
+ if "data" not in raw_body:
23
+ raise PlanValidationError(f"{context} must define data when body.type is not none")
24
+ return convert_value(raw_body["data"], node_specs=node_specs, context=context)
25
+
26
+
27
+ def _convert_json_http_body(raw_body: dict[str, Any], *, node_specs: dict[str, dict[str, Any]], context: str) -> dict[str, Any] | list[Any]:
28
+ raw_data = raw_body.get("data")
29
+ if not isinstance(raw_data, list) or len(raw_data) != 1:
30
+ raise PlanValidationError(f"{context} only supports a single raw JSON entry in Step 5")
31
+
32
+ entry = raw_data[0]
33
+ if not isinstance(entry, dict):
34
+ raise PlanValidationError(f"{context} must contain object entries")
35
+ if entry.get("key") != "":
36
+ raise PlanValidationError(f"{context} only supports an empty key for raw JSON entries in Step 5")
37
+ if entry.get("type") != "text":
38
+ raise PlanValidationError(f"{context} only supports text raw JSON entries in Step 5")
39
+
40
+ raw_value = entry.get("value")
41
+ if not isinstance(raw_value, str) or not raw_value:
42
+ raise PlanValidationError(f"{context} raw JSON entry must define a non-empty string value")
43
+
44
+ converted_json_text = replace_embedded_dify_references(
45
+ raw_value,
46
+ node_specs=node_specs,
47
+ context=f"{context} raw JSON value",
48
+ )
49
+ try:
50
+ converted_body = json.loads(converted_json_text)
51
+ except json.JSONDecodeError as exc:
52
+ raise PlanValidationError(f"{context} raw JSON value must be valid JSON after reference conversion") from exc
53
+
54
+ if not isinstance(converted_body, (dict, list)):
55
+ raise PlanValidationError(f"{context} raw JSON value must decode to an object or array")
56
+ return converted_body
@@ -0,0 +1,23 @@
1
+ from dify_player.dify_importer.node_converters.assigner import convert_assigner_node
2
+ from dify_player.dify_importer.node_converters.code import convert_code_node
3
+ from dify_player.dify_importer.node_converters.end import convert_end_node
4
+ from dify_player.dify_importer.node_converters.http_request import convert_http_request_node
5
+ from dify_player.dify_importer.node_converters.if_else import convert_if_else_node
6
+ from dify_player.dify_importer.node_converters.llm import convert_llm_node
7
+ from dify_player.dify_importer.node_converters.loop import convert_loop_node
8
+ from dify_player.dify_importer.node_converters.start import convert_start_node
9
+ from dify_player.dify_importer.node_converters.template_transform import convert_template_transform_node
10
+ from dify_player.dify_importer.node_converters.variable_aggregator import convert_variable_aggregator_node
11
+
12
+ __all__ = [
13
+ "convert_assigner_node",
14
+ "convert_code_node",
15
+ "convert_end_node",
16
+ "convert_http_request_node",
17
+ "convert_if_else_node",
18
+ "convert_llm_node",
19
+ "convert_loop_node",
20
+ "convert_start_node",
21
+ "convert_template_transform_node",
22
+ "convert_variable_aggregator_node",
23
+ ]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from dify_player.dify_importer.reference_converter import convert_value_selector
6
+ from dify_player.exceptions import PlanValidationError
7
+ from dify_player.models import Node
8
+
9
+
10
+ def convert_assigner_node(
11
+ *,
12
+ node_id: str,
13
+ node_name: str | None,
14
+ data: dict[str, Any],
15
+ node_specs: dict[str, dict[str, Any]],
16
+ ) -> Node:
17
+ raw_items = data.get("items")
18
+ if not isinstance(raw_items, list) or not raw_items:
19
+ raise PlanValidationError(f"Dify assigner node {node_id!r} must define items as a non-empty array")
20
+
21
+ inputs: dict[str, Any] = {}
22
+ assignments: list[dict[str, str]] = []
23
+ target_names: set[str] = set()
24
+ for index, raw_item in enumerate(raw_items):
25
+ if not isinstance(raw_item, dict):
26
+ raise PlanValidationError(f"Dify assigner node {node_id!r} items[{index}] must be an object")
27
+ if raw_item.get("input_type") != "variable":
28
+ raise PlanValidationError(f"Dify assigner node {node_id!r} only supports items[{index}].input_type=variable")
29
+ if raw_item.get("operation") != "over-write":
30
+ raise PlanValidationError(f"Dify assigner node {node_id!r} only supports items[{index}].operation=over-write")
31
+
32
+ output_name = raw_item.get("output_name")
33
+ if not isinstance(output_name, str) or not output_name:
34
+ raise PlanValidationError(f"Dify assigner node {node_id!r} items[{index}].output_name must be a non-empty string")
35
+
36
+ loop_node_id, target_name = _parse_target_selector(
37
+ raw_item.get("variable_selector"),
38
+ node_id=node_id,
39
+ item_index=index,
40
+ )
41
+ if output_name != target_name:
42
+ raise PlanValidationError(
43
+ f"Dify assigner node {node_id!r} items[{index}] output_name must match target loop variable {target_name!r}"
44
+ )
45
+ if output_name in target_names:
46
+ raise PlanValidationError(
47
+ f"Dify assigner node {node_id!r} may not assign the same target more than once: {output_name!r}"
48
+ )
49
+ target_names.add(output_name)
50
+
51
+ input_name = f"input_{index}"
52
+ inputs[input_name] = convert_value_selector(
53
+ raw_item.get("value"),
54
+ node_specs=node_specs,
55
+ context=f"Dify assigner node {node_id!r} items[{index}].value",
56
+ )
57
+ assignments.append(
58
+ {
59
+ "input_name": input_name,
60
+ "target_name": target_name,
61
+ "target_scope": "loop_variable",
62
+ "operation": "overwrite",
63
+ }
64
+ )
65
+ _ = loop_node_id
66
+
67
+ return Node(
68
+ id=node_id,
69
+ kind="assigner",
70
+ name=node_name,
71
+ inputs=inputs,
72
+ config={"assignments": assignments},
73
+ )
74
+
75
+
76
+ def _parse_target_selector(raw_value: Any, *, node_id: str, item_index: int) -> tuple[str, str]:
77
+ context = f"Dify assigner node {node_id!r} items[{item_index}].variable_selector"
78
+ if not isinstance(raw_value, list) or len(raw_value) != 2:
79
+ raise PlanValidationError(f"{context} must be exactly [loop_node_id, loop_var_name]")
80
+ loop_node_id, target_name = raw_value
81
+ if not isinstance(loop_node_id, str) or not loop_node_id:
82
+ raise PlanValidationError(f"{context}[0] must be a non-empty string loop node id")
83
+ if not isinstance(target_name, str) or not target_name:
84
+ raise PlanValidationError(f"{context}[1] must be a non-empty string loop variable name")
85
+ return loop_node_id, target_name
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from dify_player.dify_importer.reference_converter import convert_value_selector
6
+ from dify_player.exceptions import PlanValidationError
7
+ from dify_player.models import Node
8
+
9
+
10
+ def convert_code_node(
11
+ *,
12
+ node_id: str,
13
+ node_name: str | None,
14
+ data: dict[str, Any],
15
+ node_specs: dict[str, dict[str, Any]],
16
+ ) -> Node:
17
+ language = data.get("code_language")
18
+ if language != "python3":
19
+ raise PlanValidationError(f"Dify code node {node_id!r} only supports code_language=python3")
20
+
21
+ source = data.get("code")
22
+ if not isinstance(source, str):
23
+ raise PlanValidationError(f"Dify code node {node_id!r} must define code as a string")
24
+
25
+ raw_variables = data.get("variables", [])
26
+ if raw_variables is None:
27
+ raw_variables = []
28
+ if not isinstance(raw_variables, list):
29
+ raise PlanValidationError(f"Dify code node {node_id!r} variables must be an array")
30
+
31
+ inputs: dict[str, Any] = {}
32
+ for index, item in enumerate(raw_variables):
33
+ if not isinstance(item, dict):
34
+ raise PlanValidationError(f"Dify code node {node_id!r} variables[{index}] must be an object")
35
+ variable = item.get("variable")
36
+ if not isinstance(variable, str) or not variable:
37
+ raise PlanValidationError(f"Dify code node {node_id!r} variables[{index}].variable must be a non-empty string")
38
+ inputs[variable] = convert_value_selector(
39
+ item.get("value_selector"),
40
+ node_specs=node_specs,
41
+ context=f"Dify code node {node_id!r} variables[{index}].value_selector",
42
+ )
43
+
44
+ raw_outputs = data.get("outputs")
45
+ if not isinstance(raw_outputs, dict):
46
+ raise PlanValidationError(f"Dify code node {node_id!r} must define outputs as an object")
47
+
48
+ outputs: list[str] = []
49
+ for output_name in raw_outputs:
50
+ if not isinstance(output_name, str) or not output_name:
51
+ raise PlanValidationError(f"Dify code node {node_id!r} outputs must use non-empty string keys")
52
+ outputs.append(output_name)
53
+
54
+ return Node(
55
+ id=node_id,
56
+ kind="code",
57
+ name=node_name,
58
+ inputs=inputs,
59
+ config={
60
+ "language": "python3",
61
+ "source": source,
62
+ "outputs": outputs,
63
+ },
64
+ )
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from dify_player.dify_importer.reference_converter import convert_value_selector
6
+ from dify_player.exceptions import PlanValidationError
7
+ from dify_player.models import Node
8
+
9
+
10
+ def convert_end_node(
11
+ *,
12
+ node_id: str,
13
+ node_name: str | None,
14
+ data: dict[str, Any],
15
+ node_specs: dict[str, dict[str, Any]],
16
+ ) -> Node:
17
+ outputs = data.get("outputs", [])
18
+ if outputs in ([], None):
19
+ return Node(id=node_id, kind="end", name=node_name, inputs={}, config={"outputs": {}})
20
+ if not isinstance(outputs, list):
21
+ raise PlanValidationError(f"Dify end node {node_id!r} outputs must be an array")
22
+
23
+ inputs: dict[str, Any] = {}
24
+ config_outputs: dict[str, str] = {}
25
+ for index, item in enumerate(outputs):
26
+ if not isinstance(item, dict):
27
+ raise PlanValidationError(f"Dify end node {node_id!r} outputs[{index}] must be an object")
28
+ variable = item.get("variable")
29
+ if not isinstance(variable, str) or not variable:
30
+ raise PlanValidationError(f"Dify end node {node_id!r} outputs[{index}].variable must be a non-empty string")
31
+ inputs[variable] = convert_value_selector(
32
+ item.get("value_selector"),
33
+ node_specs=node_specs,
34
+ context=f"Dify end node {node_id!r} outputs[{index}].value_selector",
35
+ )
36
+ config_outputs[variable] = _build_end_output_reference(variable)
37
+
38
+ return Node(id=node_id, kind="end", name=node_name, inputs=inputs, config={"outputs": config_outputs})
39
+
40
+
41
+ def _build_end_output_reference(variable: str) -> str:
42
+ if variable.isidentifier():
43
+ return f"{{{{ inputs.{variable} }}}}"
44
+ return f"{{{{ inputs[{variable!r}] }}}}"