stageflow-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stageflow/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from .core import (
2
+ Event, EventSpec, InputSpec,
3
+ JsonLogic,
4
+ Node, ConditionNode, Condition, ParallelNode, TerminalNode, StageNode,
5
+ Session, SessionResult,
6
+ BaseStage, get_stage, register_stage, get_stages, get_stages_by_category,
7
+ Context, DotDict,
8
+ Pipeline,
9
+ )
10
+
11
+ from .docs import generate_stages_yaml, generate_stages_json
12
+ from . import builtins
13
+
14
+
15
+ __all__ = [
16
+ "Event", "EventSpec", "InputSpec",
17
+ "JsonLogic",
18
+ "Node", "ConditionNode", "Condition", "ParallelNode", "TerminalNode", "StageNode",
19
+ "Session", "SessionResult",
20
+ "BaseStage", "get_stage", "register_stage", "get_stages", "get_stages_by_category",
21
+ "Context", "DotDict",
22
+ "Pipeline",
23
+ "generate_stages_yaml", "generate_stages_json",
24
+ "builtins",
25
+ ]
@@ -0,0 +1,34 @@
1
+ from .vars import (
2
+ SetValueStage,
3
+ CopyValueStage,
4
+ IncrementStage,
5
+ MergeDictStage,
6
+ )
7
+ from .lists import (
8
+ AppendListStage,
9
+ ExtendListStage,
10
+ )
11
+ from .logic import AssertStage, FailStage, LogStage, SleepStage
12
+ from .strings import ConcatStage, TemplateStage
13
+ from .dicts import PickKeysStage, DropKeysStage
14
+ from .lists_extra import FilterListStage, UniqueListStage, PopListStage
15
+
16
+ __all__ = [
17
+ "SetValueStage",
18
+ "CopyValueStage",
19
+ "IncrementStage",
20
+ "MergeDictStage",
21
+ "AppendListStage",
22
+ "ExtendListStage",
23
+ "AssertStage",
24
+ "FailStage",
25
+ "LogStage",
26
+ "SleepStage",
27
+ "ConcatStage",
28
+ "TemplateStage",
29
+ "PickKeysStage",
30
+ "DropKeysStage",
31
+ "FilterListStage",
32
+ "UniqueListStage",
33
+ "PopListStage",
34
+ ]
@@ -0,0 +1,59 @@
1
+ from stageflow.core.stage import BaseStage, register_stage
2
+
3
+
4
+ @register_stage("PickKeysStage")
5
+ class PickKeysStage(BaseStage):
6
+ """
7
+ description: "Pick only specified keys from a dict and return new object"
8
+ arguments:
9
+ src:
10
+ type: object
11
+ description: "Source dict"
12
+ config:
13
+ keys:
14
+ type: list
15
+ description: "Keys to keep in the result"
16
+ outputs:
17
+ result:
18
+ type: object
19
+ description: "Dict containing only picked keys"
20
+ """
21
+ category = "builtin.dicts"
22
+
23
+ async def run(self):
24
+ args = self.get_arguments()
25
+ src_val = args.get("src") or {}
26
+ keys = self.config.get("keys", [])
27
+ if not isinstance(src_val, dict):
28
+ raise ValueError("PickKeysStage expects dict in arguments.src")
29
+ picked = {k: src_val[k] for k in keys if k in src_val}
30
+ self.set_outputs({"result": picked})
31
+
32
+
33
+ @register_stage("DropKeysStage")
34
+ class DropKeysStage(BaseStage):
35
+ """
36
+ description: "Remove specified keys from dict and return cleaned object"
37
+ arguments:
38
+ src:
39
+ type: object
40
+ description: "Source dict"
41
+ config:
42
+ keys:
43
+ type: list
44
+ description: "Keys to remove from the dict"
45
+ outputs:
46
+ result:
47
+ type: object
48
+ description: "Dict without removed keys"
49
+ """
50
+ category = "builtin.dicts"
51
+
52
+ async def run(self):
53
+ args = self.get_arguments()
54
+ keys = set(self.config.get("keys", []))
55
+ src_val = args.get("src") or {}
56
+ if not isinstance(src_val, dict):
57
+ raise ValueError("DropKeysStage expects dict in arguments.src")
58
+ cleaned = {k: v for k, v in src_val.items() if k not in keys}
59
+ self.set_outputs({"result": cleaned})
@@ -0,0 +1,71 @@
1
+ from stageflow.core.stage import BaseStage, register_stage
2
+
3
+
4
+ @register_stage("AppendListStage")
5
+ class AppendListStage(BaseStage):
6
+ """
7
+ description: "Append value from args/config to list (creates list if missing)"
8
+ arguments:
9
+ list:
10
+ type: list
11
+ description: "List to append to (from context)"
12
+ value:
13
+ type: any
14
+ description: "Value to append (overrides config)"
15
+ config:
16
+ value:
17
+ type: any
18
+ description: "Fallback value when argument is missing"
19
+ outputs:
20
+ list:
21
+ type: list
22
+ description: "Resulting list after append"
23
+ """
24
+ category = "builtin.lists"
25
+
26
+ async def run(self):
27
+ args = self.get_arguments()
28
+ value = args.get("value", self.config.get("value"))
29
+ lst = args.get("list", None)
30
+ if lst is None:
31
+ lst = []
32
+ if not isinstance(lst, list):
33
+ raise ValueError("AppendListStage expects list")
34
+ lst.append(value)
35
+ self.set_outputs({"list": lst})
36
+
37
+
38
+ @register_stage("ExtendListStage")
39
+ class ExtendListStage(BaseStage):
40
+ """
41
+ description: "Extend list with items from arguments (or default list)"
42
+ arguments:
43
+ list:
44
+ type: list
45
+ description: "Base list to extend"
46
+ items:
47
+ type: list
48
+ description: "Items to extend the list with"
49
+ config:
50
+ default_list:
51
+ type: list
52
+ description: "Fallback list when argument is missing"
53
+ outputs:
54
+ list:
55
+ type: list
56
+ description: "Resulting list after extend"
57
+ """
58
+ category = "builtin.lists"
59
+
60
+ async def run(self):
61
+ args = self.get_arguments()
62
+ lst = args.get("list", self.config.get("default_list", []))
63
+ if lst is None:
64
+ lst = []
65
+ if not isinstance(lst, list):
66
+ raise ValueError("ExtendListStage expects list")
67
+ src_val = args.get("items", [])
68
+ if not isinstance(src_val, (list, tuple)):
69
+ raise ValueError("Source for extend is not a list/tuple")
70
+ lst.extend(src_val)
71
+ self.set_outputs({"list": lst})
@@ -0,0 +1,110 @@
1
+ from stageflow.core.stage import BaseStage, register_stage
2
+ from stageflow.core.jsonlogic import JsonLogic
3
+ from stageflow.core.context import Context
4
+
5
+
6
+ @register_stage("FilterListStage")
7
+ class FilterListStage(BaseStage):
8
+ """
9
+ description: "Filter list items by JsonLogic condition using item_path binding"
10
+ arguments:
11
+ items:
12
+ type: list
13
+ description: "List to filter"
14
+ config:
15
+ condition:
16
+ type: object
17
+ description: "JsonLogic condition evaluated for each item"
18
+ item_path:
19
+ type: string
20
+ description: "Context key to bind current item during evaluation"
21
+ outputs:
22
+ list:
23
+ type: list
24
+ description: "Filtered list"
25
+ """
26
+ category = "builtin.lists"
27
+
28
+ async def run(self):
29
+ args = self.get_arguments()
30
+ condition = self.config.get("condition")
31
+ item_path = self.config.get("item_path", "item")
32
+ if condition is None:
33
+ raise ValueError("FilterListStage requires config.condition")
34
+ items = args.get("items", [])
35
+ if not isinstance(items, list):
36
+ raise ValueError("FilterListStage expects list in arguments.items")
37
+ base_payload = self.session.context.payload
38
+ result = []
39
+ for item in items:
40
+ temp_ctx = Context(payload=dict(base_payload))
41
+ temp_ctx.set(item_path, item)
42
+ if JsonLogic(condition).evaluate(temp_ctx):
43
+ result.append(item)
44
+ self.set_outputs({"list": result})
45
+
46
+
47
+ @register_stage("UniqueListStage")
48
+ class UniqueListStage(BaseStage):
49
+ """
50
+ description: "Deduplicate list while preserving original order"
51
+ arguments:
52
+ items:
53
+ type: list
54
+ description: "List to deduplicate"
55
+ outputs:
56
+ list:
57
+ type: list
58
+ description: "List with unique items"
59
+ """
60
+ category = "builtin.lists"
61
+
62
+ async def run(self):
63
+ args = self.get_arguments()
64
+ items = args.get("items", [])
65
+ if not isinstance(items, list):
66
+ raise ValueError("UniqueListStage expects list in arguments.items")
67
+ seen = set()
68
+ out = []
69
+ for item in items:
70
+ if item not in seen:
71
+ seen.add(item)
72
+ out.append(item)
73
+ self.set_outputs({"list": out})
74
+
75
+
76
+ @register_stage("PopListStage")
77
+ class PopListStage(BaseStage):
78
+ """
79
+ description: "Pop element from list (default last) and return list+popped value"
80
+ arguments:
81
+ items:
82
+ type: list
83
+ description: "List to pop from"
84
+ index:
85
+ type: int
86
+ description: "Index to pop (overrides config)"
87
+ config:
88
+ index:
89
+ type: int
90
+ description: "Default index to pop, -1 means last element"
91
+ outputs:
92
+ list:
93
+ type: list
94
+ description: "List after pop"
95
+ popped:
96
+ type: any
97
+ description: "Popped value"
98
+ """
99
+ category = "builtin.lists"
100
+
101
+ async def run(self):
102
+ args = self.get_arguments()
103
+ index = args.get("index", self.config.get("index", -1))
104
+ lst = args.get("items", [])
105
+ if not isinstance(lst, list):
106
+ raise ValueError("PopListStage expects list in arguments.items")
107
+ if not lst:
108
+ return
109
+ value = lst.pop(index)
110
+ self.set_outputs({"list": lst, "popped": value})
@@ -0,0 +1,84 @@
1
+ import asyncio
2
+ from stageflow.core.stage import BaseStage, register_stage
3
+ from stageflow.core.jsonlogic import JsonLogic
4
+
5
+
6
+ @register_stage("AssertStage")
7
+ class AssertStage(BaseStage):
8
+ """
9
+ description: "Validate JsonLogic condition against context, raise on failure"
10
+ config:
11
+ condition:
12
+ type: object
13
+ description: "JsonLogic condition to check"
14
+ message:
15
+ type: string
16
+ description: "Error message when condition fails"
17
+ outputs: {}
18
+ """
19
+ category = "builtin.logic"
20
+
21
+ async def run(self):
22
+ condition = self.config.get("condition")
23
+ if not condition:
24
+ raise ValueError("AssertStage requires config.condition")
25
+ ok = JsonLogic(condition).evaluate(self.session.context)
26
+ if not ok:
27
+ raise AssertionError(self.config.get("message", "assertion failed"))
28
+
29
+
30
+ @register_stage("FailStage")
31
+ class FailStage(BaseStage):
32
+ """
33
+ description: "Always raise a runtime error with provided message"
34
+ config:
35
+ message:
36
+ type: string
37
+ description: "Message for raised error"
38
+ outputs: {}
39
+ """
40
+ category = "builtin.logic"
41
+
42
+ async def run(self):
43
+ msg = self.config.get("message", "fail")
44
+ raise RuntimeError(msg)
45
+
46
+
47
+ @register_stage("LogStage")
48
+ class LogStage(BaseStage):
49
+ """
50
+ description: "Emit log event with message and payload resolved from context paths"
51
+ config:
52
+ message:
53
+ type: string
54
+ description: "Log message"
55
+ paths:
56
+ type: object
57
+ description: "Mapping of payload fields to context paths"
58
+ outputs: {}
59
+ """
60
+ category = "builtin.logic"
61
+
62
+ async def run(self):
63
+ payload = {"message": self.config.get("message", "")}
64
+ paths = self.config.get("paths", {})
65
+ for key, path in paths.items():
66
+ payload[key] = self.session.context.get(path)
67
+ self.emit("log", payload)
68
+
69
+
70
+ @register_stage("SleepStage")
71
+ class SleepStage(BaseStage):
72
+ """
73
+ description: "Async sleep for configured number of seconds"
74
+ config:
75
+ seconds:
76
+ type: number
77
+ description: "Duration to sleep in seconds"
78
+ outputs: {}
79
+ """
80
+ category = "builtin.logic"
81
+
82
+ async def run(self):
83
+ sec = self.config.get("seconds", 0)
84
+ await asyncio.sleep(sec)
@@ -0,0 +1,81 @@
1
+ from stageflow.core.stage import BaseStage, register_stage
2
+
3
+
4
+ @register_stage("ConcatStage")
5
+ class ConcatStage(BaseStage):
6
+ """
7
+ description: "Concatenate parts (values or context paths) with separator"
8
+ arguments:
9
+ parts:
10
+ type: list
11
+ description: "List of values or context paths to concatenate"
12
+ separator:
13
+ type: string
14
+ description: "Separator overriding config"
15
+ config:
16
+ parts:
17
+ type: list
18
+ description: "Default parts when argument is missing"
19
+ separator:
20
+ type: string
21
+ description: "Default separator"
22
+ output_key:
23
+ type: string
24
+ description: "Context key for resulting string"
25
+ outputs:
26
+ value:
27
+ type: string
28
+ description: "Concatenated string"
29
+ """
30
+ category = "builtin.strings"
31
+
32
+ async def run(self):
33
+ args = self.get_arguments()
34
+ parts = args.get("parts", self.config.get("parts", []))
35
+ sep = args.get("separator", self.config.get("separator", ""))
36
+ out_key = self.config.get("output_key", "value")
37
+ values = []
38
+ for p in parts:
39
+ if isinstance(p, str):
40
+ values.append(str(self.session.context.get(p, p)))
41
+ else:
42
+ values.append(str(p))
43
+ self.set_outputs({out_key: sep.join(values)})
44
+
45
+
46
+ @register_stage("TemplateStage")
47
+ class TemplateStage(BaseStage):
48
+ """
49
+ description: "Format template string with values pulled from context paths"
50
+ arguments:
51
+ template:
52
+ type: string
53
+ description: "Template overriding config"
54
+ config:
55
+ template:
56
+ type: string
57
+ description: "Default template string"
58
+ output_key:
59
+ type: string
60
+ description: "Context key for rendered value"
61
+ values:
62
+ type: object
63
+ description: "Mapping placeholder -> context path"
64
+ outputs:
65
+ value:
66
+ type: string
67
+ description: "Rendered string"
68
+ """
69
+ category = "builtin.strings"
70
+
71
+ async def run(self):
72
+ args = self.get_arguments()
73
+ template = self.config.get("template") or args.get("template")
74
+ out_key = self.config.get("output_key", "value")
75
+ values_cfg = self.config.get("values", {})
76
+ if template is None:
77
+ raise ValueError("TemplateStage requires template")
78
+ values = {}
79
+ for key, path in values_cfg.items():
80
+ values[key] = self.session.context.get(path)
81
+ self.set_outputs({out_key: template.format(**values)})
@@ -0,0 +1,118 @@
1
+ from stageflow.core.stage import BaseStage, register_stage
2
+
3
+
4
+ @register_stage("SetValueStage")
5
+ class SetValueStage(BaseStage):
6
+ """
7
+ description: "Set value from arguments or config to the target path"
8
+ arguments:
9
+ value:
10
+ type: any
11
+ description: "Value to set (overrides config)"
12
+ config:
13
+ value:
14
+ type: any
15
+ description: "Fallback value when argument is missing"
16
+ outputs:
17
+ value:
18
+ type: any
19
+ description: "Value that was written"
20
+ """
21
+ category = "builtin.vars"
22
+
23
+ async def run(self):
24
+ args = self.get_arguments()
25
+ value = args.get("value", self.config.get("value"))
26
+ if value is None and "value" not in args and "value" not in self.config:
27
+ raise ValueError("SetValueStage requires value via arguments or config")
28
+ self.set_outputs({"value": value})
29
+
30
+
31
+ @register_stage("CopyValueStage")
32
+ class CopyValueStage(BaseStage):
33
+ """
34
+ description: "Copy value from arguments (or default) to output path"
35
+ arguments:
36
+ value:
37
+ type: any
38
+ description: "Value to copy"
39
+ config:
40
+ default:
41
+ type: any
42
+ description: "Default value when argument is missing"
43
+ outputs:
44
+ value:
45
+ type: any
46
+ description: "Copied value"
47
+ """
48
+ category = "builtin.vars"
49
+
50
+ async def run(self):
51
+ args = self.get_arguments()
52
+ value = args.get("value", None)
53
+ if value is None:
54
+ value = self.config.get("default")
55
+ self.set_outputs({"value": value})
56
+
57
+
58
+ @register_stage("IncrementStage")
59
+ class IncrementStage(BaseStage):
60
+ """
61
+ description: "Increment numeric value by delta (from args or config)"
62
+ arguments:
63
+ current:
64
+ type: number
65
+ description: "Current numeric value"
66
+ delta:
67
+ type: number
68
+ description: "Delta overriding config"
69
+ config:
70
+ delta:
71
+ type: number
72
+ description: "Default delta (1 if missing)"
73
+ outputs:
74
+ value:
75
+ type: number
76
+ description: "Result after increment"
77
+ """
78
+ category = "builtin.vars"
79
+
80
+ async def run(self):
81
+ args = self.get_arguments()
82
+ current = args.get("current", 0)
83
+ delta = args.get("delta", self.config.get("delta", 1))
84
+ if not isinstance(current, (int, float)):
85
+ raise ValueError("IncrementStage current is not numeric")
86
+ self.set_outputs({"value": current + delta})
87
+
88
+
89
+ @register_stage("MergeDictStage")
90
+ class MergeDictStage(BaseStage):
91
+ """
92
+ description: "Shallow merge src dict into dst (config default if missing)"
93
+ arguments:
94
+ src:
95
+ type: object
96
+ description: "Dict with overrides"
97
+ dst:
98
+ type: object
99
+ description: "Base dict to merge into"
100
+ config:
101
+ default_dst:
102
+ type: object
103
+ description: "Fallback base dict if dst is missing"
104
+ outputs:
105
+ merged:
106
+ type: object
107
+ description: "Merged dict result"
108
+ """
109
+ category = "builtin.vars"
110
+
111
+ async def run(self):
112
+ args = self.get_arguments()
113
+ src_val = args.get("src") or {}
114
+ dst_val = args.get("dst") or self.config.get("default_dst", {})
115
+ if not isinstance(src_val, dict) or not isinstance(dst_val, dict):
116
+ raise ValueError("MergeDictStage expects dict values")
117
+ merged = {**dst_val, **src_val}
118
+ self.set_outputs({"merged": merged})
@@ -0,0 +1,7 @@
1
+ from .event import Event, EventSpec, InputSpec # noqa: F401
2
+ from .jsonlogic import JsonLogic # noqa: F401
3
+ from .node import Node, ConditionNode, Condition, ParallelNode, TerminalNode, StageNode # noqa: F401
4
+ from .session import Session, SessionResult # noqa: F401
5
+ from .stage import BaseStage, get_stage, register_stage, get_stages, get_stages_by_category # noqa: F401
6
+ from .context import Context, DotDict # noqa: F401
7
+ from .pipeline import Pipeline # noqa: F401
@@ -0,0 +1,86 @@
1
+ from typing import Any
2
+
3
+
4
+ class DotDict(dict):
5
+ def __getattr__(self, item):
6
+ try:
7
+ value = self[item]
8
+ if isinstance(value, dict) and not isinstance(value, DotDict):
9
+ value = DotDict(value)
10
+ self[item] = value
11
+ return value
12
+ except KeyError:
13
+ raise AttributeError(item)
14
+
15
+ def __setattr__(self, key, value):
16
+ self[key] = value
17
+
18
+ def __delattr__(self, key):
19
+ try:
20
+ del self[key]
21
+ except KeyError:
22
+ raise AttributeError(key)
23
+
24
+
25
+ class Context:
26
+ def __init__(self, payload: dict[str, Any] | None = None):
27
+ self.payload: DotDict = DotDict(payload or {})
28
+
29
+ def to_dict(self) -> dict[str, Any]:
30
+ return dict(self._deep_copy(self.payload))
31
+
32
+ @classmethod
33
+ def from_dict(cls, data: dict[str, Any]) -> "Context":
34
+ return cls(payload=data)
35
+
36
+ def get(self, path: str, default=None):
37
+ parts = path.split(".")
38
+ if parts and parts[0] == "payload":
39
+ parts = parts[1:]
40
+ cur: Any = self.payload
41
+ for p in parts:
42
+ if isinstance(cur, dict):
43
+ cur = cur.get(p, default)
44
+ elif isinstance(cur, list):
45
+ try:
46
+ idx = int(p)
47
+ cur = cur[idx]
48
+ except (ValueError, IndexError):
49
+ return default
50
+ else:
51
+ return default
52
+ return cur
53
+
54
+ def set(self, path: str, value: Any):
55
+ parts = path.split(".")
56
+ if parts and parts[0] == "payload":
57
+ parts = parts[1:]
58
+ cur = self.payload
59
+ for p in parts[:-1]:
60
+ if isinstance(cur, dict):
61
+ cur = cur.setdefault(p, DotDict())
62
+ elif isinstance(cur, list):
63
+ idx = int(p)
64
+ while len(cur) <= idx:
65
+ cur.append(DotDict())
66
+ cur = cur[idx]
67
+ else:
68
+ raise ValueError(f"Can't traverse into {type(cur)}")
69
+
70
+ last = parts[-1]
71
+ if isinstance(cur, dict):
72
+ cur[last] = value
73
+ elif isinstance(cur, list):
74
+ idx = int(last)
75
+ while len(cur) <= idx:
76
+ cur.append(None)
77
+ cur[idx] = value
78
+ else:
79
+ raise ValueError(f"Can't set into {type(cur)}")
80
+
81
+ def _deep_copy(self, obj: Any) -> Any:
82
+ if isinstance(obj, dict):
83
+ return {k: self._deep_copy(v) for k, v in obj.items()}
84
+ if isinstance(obj, list):
85
+ return [self._deep_copy(v) for v in obj]
86
+ return obj