dagflows 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.
dagflows/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """
2
+ dagflows - author and run workflow nodes.
3
+ """
4
+
5
+ from importlib import import_module
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from ._runtime.content import ContentType
9
+ from ._runtime.envelope import Ctx, Input, Inputs
10
+ from ._runtime.errors import (
11
+ EXECUTION,
12
+ INFRASTRUCTURE,
13
+ PERMANENT,
14
+ TIMEOUT,
15
+ Fail,
16
+ InputTooLarge,
17
+ InputUnavailable,
18
+ OutputTooLarge,
19
+ )
20
+ from ._runtime.result import Result
21
+ from ._runtime.runner import run
22
+
23
+ if TYPE_CHECKING:
24
+ from ._authoring.config import ExecutionConfig, RetryConfig
25
+ from ._authoring.node import NodeRef
26
+ from ._authoring.workflow import Workflow
27
+
28
+ # Authoring names resolve on first use rather than at import. A node running in
29
+ # a microVM imports this module and touches none of them, so the guest never
30
+ # loads code that only ever runs on a laptop or in the builder.
31
+ _AUTHORING = {
32
+ "ExecutionConfig": "._authoring.config",
33
+ "RetryConfig": "._authoring.config",
34
+ "NodeRef": "._authoring.node",
35
+ "Workflow": "._authoring.workflow",
36
+ }
37
+
38
+
39
+ def __getattr__(name: str) -> Any:
40
+ module = _AUTHORING.get(name)
41
+ if module is None:
42
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
43
+
44
+ return getattr(import_module(module, __name__), name)
45
+
46
+
47
+ def __dir__() -> list[str]:
48
+ return sorted(__all__)
49
+
50
+
51
+ __all__ = [
52
+ "ContentType",
53
+ "Ctx",
54
+ "Input",
55
+ "Inputs",
56
+ "Result",
57
+ "Fail",
58
+ "InputTooLarge",
59
+ "InputUnavailable",
60
+ "OutputTooLarge",
61
+ "PERMANENT",
62
+ "INFRASTRUCTURE",
63
+ "TIMEOUT",
64
+ "EXECUTION",
65
+ "run",
66
+ "Workflow",
67
+ "ExecutionConfig",
68
+ "RetryConfig",
69
+ "NodeRef",
70
+ ]
71
+
72
+ __version__ = "0.1.0"
dagflows/__main__.py ADDED
@@ -0,0 +1,67 @@
1
+ """The python SDK's command line interface.
2
+
3
+ python -m dagflows build manifest <module> emit dagflows-manifest.json
4
+ python -m dagflows build validate <module> check a project without writing
5
+ python -m dagflows dev run <entrypoint> run a node locally
6
+ python -m dagflows dev fixture <entrypoint> write a starting fixture
7
+
8
+ Invoked via `python -m dagflows` to avoid command collisions with the standalone
9
+ CLI and to ensure execution uses the current Python environment.
10
+
11
+ The internal `invoke` command is reserved for platform runtime execution and
12
+ reports failures via output envelopes. All other CLI commands report failures
13
+ to stderr with standard process exit codes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+
20
+ from ._cli import USAGE as USAGE_CODE
21
+ from ._cli import CommandError, report, take_flag
22
+
23
+ HELP = """python -m dagflows - author and run workflow nodes
24
+
25
+ build manifest <module> [-o dagflows-manifest.json]
26
+ build validate <module>
27
+ dev run <entrypoint> [--input <parent>=<file|json>] [options]
28
+ dev fixture <entrypoint> [-o fixture.json]
29
+
30
+ --json machine readable output, on any command above
31
+
32
+ Exit codes: 0 success, 1 the operation failed, 2 the command was wrong."""
33
+
34
+
35
+ def main(argv: list[str]) -> int:
36
+ # The internal invoke command handles runtime execution directly.
37
+ if argv and argv[0] == "invoke":
38
+ from ._runtime.dispatch import main as invoke
39
+
40
+ return invoke(argv)
41
+
42
+ argv, as_json = take_flag(argv, "--json")
43
+
44
+ if not argv or argv[0] in ("-h", "--help", "help"):
45
+ print(HELP)
46
+ return 0 if argv else USAGE_CODE
47
+
48
+ command, rest = argv[0], argv[1:]
49
+
50
+ try:
51
+ match command:
52
+ case "build":
53
+ from ._cli.build import main as build
54
+
55
+ return build(rest, as_json)
56
+ case "dev":
57
+ from ._cli.dev import main as dev
58
+
59
+ return dev(rest, as_json)
60
+ case _:
61
+ raise CommandError(f"unknown command {command!r}\n\n{HELP}", USAGE_CODE)
62
+ except CommandError as error:
63
+ return report(command, error, as_json)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1 @@
1
+ """The authoring half: runs on a laptop and in the builder, never in a microVM."""
@@ -0,0 +1,58 @@
1
+ """Per-node settings that reach the manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ def _non_negative(name: str, value: int | None) -> None:
9
+ if value is not None and value < 0:
10
+ raise ValueError(f"{name} cannot be negative, got {value}")
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ExecutionConfig:
15
+ """Resource and timeout configuration for a node."""
16
+
17
+ timeout: int | None = None
18
+ memory_limit_mb: int | None = None
19
+ milli_cores: int | None = None
20
+ # Declaring this is what gets the node a multipart upload, letting it emit
21
+ # more than it can hold. The platform may issue less than asked for, so a
22
+ # node reads what it actually got from ctx rather than assuming.
23
+ max_output_mb: int | None = None
24
+
25
+ def __post_init__(self) -> None:
26
+ _non_negative("timeout", self.timeout)
27
+ _non_negative("memory_limit_mb", self.memory_limit_mb)
28
+ _non_negative("milli_cores", self.milli_cores)
29
+ _non_negative("max_output_mb", self.max_output_mb)
30
+
31
+ def as_config(self) -> dict[str, int]:
32
+ """The settings that belong in the node's config map."""
33
+ out: dict[str, int] = {}
34
+ if self.memory_limit_mb is not None:
35
+ out["memory_limit_mb"] = self.memory_limit_mb
36
+ if self.milli_cores is not None:
37
+ out["milli_cores"] = self.milli_cores
38
+ if self.max_output_mb is not None:
39
+ out["max_output_mb"] = self.max_output_mb
40
+ return out
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class RetryConfig:
45
+ """Retry configuration for a node."""
46
+
47
+ max_attempts: int = 0
48
+ initial_backoff_ms: int = 1000
49
+
50
+ def __post_init__(self) -> None:
51
+ _non_negative("max_attempts", self.max_attempts)
52
+ _non_negative("initial_backoff_ms", self.initial_backoff_ms)
53
+
54
+ def as_manifest(self) -> dict[str, int]:
55
+ return {
56
+ "max_attempts": self.max_attempts,
57
+ "initial_backoff_ms": self.initial_backoff_ms,
58
+ }
@@ -0,0 +1,44 @@
1
+ """Node handles.
2
+
3
+ ``depends`` takes these, never strings: an undefined name is then a NameError
4
+ at import, before anything is built, and an editor can list exactly the nodes
5
+ that exist. Renaming a node in a visual builder is routine, which is why the
6
+ one place a string survives is ``external_node``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ from typing import Any, Callable
13
+
14
+
15
+ class NodeRef:
16
+ """A registered node reference that remains directly callable."""
17
+
18
+ def __init__(self, key: str, func: Callable[..., Any] | None, entrypoint: str = "") -> None:
19
+ self.key = key
20
+ self.entrypoint = entrypoint
21
+ self._func = func
22
+
23
+ if func is not None:
24
+ functools.update_wrapper(self, func)
25
+
26
+ @property
27
+ def external(self) -> bool:
28
+ """Whether this node lives in another project."""
29
+ return self._func is None
30
+
31
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
32
+ if self._func is None:
33
+ raise TypeError(
34
+ f"{self.key!r} is an external node declared in this project, "
35
+ "so it has no body here to call"
36
+ )
37
+
38
+ return self._func(*args, **kwargs)
39
+
40
+ def __repr__(self) -> str:
41
+ if self.external:
42
+ return f"<NodeRef {self.key!r} external>"
43
+
44
+ return f"<NodeRef {self.key!r} {self.entrypoint}>"
@@ -0,0 +1,211 @@
1
+ """Declaring a workflow and emitting its manifest.
2
+
3
+ Node bodies never run here. Decoration records metadata and ``manifest()``
4
+ serialises the registry, so importing a workflow module is safe on a laptop
5
+ and inside the builder alike.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Iterable, Sequence
14
+
15
+ from .config import ExecutionConfig, RetryConfig
16
+ from .node import NodeRef
17
+
18
+ # Builder validation constants and patterns.
19
+ MANIFEST_VERSION = 1
20
+ LANGUAGE = "python"
21
+ NODE_KEY = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$")
22
+ RUNTIME_VERSION = re.compile(r"^[0-9][0-9A-Za-z.\-]{0,31}$")
23
+
24
+ # Registry of declared workflows in the current process.
25
+ _declared: list[Workflow] = []
26
+
27
+
28
+ def declared() -> list[Workflow]:
29
+ """Workflows created so far in this interpreter."""
30
+ return list(_declared)
31
+
32
+
33
+ def _entrypoint(func: Callable[..., Any]) -> str:
34
+ """The module:function form the platform dispatches on."""
35
+ module = getattr(func, "__module__", "")
36
+
37
+ if module == "__main__":
38
+ module = _module_of_main(func)
39
+
40
+ if not module:
41
+ raise ValueError(
42
+ f"cannot tell which module defines {func.__name__!r}; emit the manifest with "
43
+ "`python -m dagflows build manifest <module>` so the node is imported under its real name"
44
+ )
45
+
46
+ return f"{module}:{func.__name__}"
47
+
48
+
49
+ def _module_of_main(func: Callable[..., Any]) -> str:
50
+ """Recover a dotted name for a module being run as a script."""
51
+ main = sys.modules.get("__main__")
52
+ path = getattr(main, "__file__", "")
53
+ if not path:
54
+ return ""
55
+
56
+ try:
57
+ relative = Path(path).resolve().relative_to(Path.cwd())
58
+ except ValueError:
59
+ # Outside the project root.
60
+ return ""
61
+
62
+ return ".".join(relative.with_suffix("").parts)
63
+
64
+
65
+ class Workflow:
66
+ """Workflow definition and node registry for a project."""
67
+
68
+ def __init__(
69
+ self,
70
+ name: str = "",
71
+ *,
72
+ version: str = "",
73
+ max_concurrent_nodes: int = 0,
74
+ max_cycle_count: int = 0,
75
+ ) -> None:
76
+ if version and not RUNTIME_VERSION.match(version):
77
+ raise ValueError(
78
+ f"version {version!r} invalid, want a version like '3.12', not an image reference"
79
+ )
80
+
81
+ if max_concurrent_nodes < 0 or max_cycle_count < 0:
82
+ raise ValueError("workflow limits cannot be negative")
83
+
84
+ self.name = name
85
+ self.version = version
86
+ self.max_concurrent_nodes = max_concurrent_nodes
87
+ self.max_cycle_count = max_cycle_count
88
+ self._nodes: list[dict[str, Any]] = []
89
+ self._keys: set[str] = set()
90
+ _declared.append(self)
91
+
92
+ def node(
93
+ self,
94
+ key: str = "",
95
+ *,
96
+ depends: Sequence[NodeRef] = (),
97
+ execution: ExecutionConfig | None = None,
98
+ retry: RetryConfig | None = None,
99
+ config: dict[str, Any] | None = None,
100
+ type: str = "",
101
+ ) -> Callable[[Callable[..., Any]], NodeRef]:
102
+ """Register the decorated function as a workflow node."""
103
+
104
+ def register(func: Callable[..., Any]) -> NodeRef:
105
+ node_key = key or func.__name__
106
+ self._claim(node_key)
107
+
108
+ local, external = _split_depends(node_key, depends)
109
+ entry: dict[str, Any] = {"key": node_key, "entrypoint": _entrypoint(func)}
110
+
111
+ if type:
112
+ entry["type"] = type
113
+
114
+ if local:
115
+ entry["depends"] = local
116
+
117
+ if external:
118
+ entry["external_depends"] = external
119
+
120
+ settings = dict(config or {})
121
+
122
+ if execution is not None:
123
+ settings.update(execution.as_config())
124
+ if execution.timeout is not None:
125
+ entry["timeout_seconds"] = execution.timeout
126
+
127
+ if settings:
128
+ entry["config"] = settings
129
+
130
+ if retry is not None:
131
+ entry["retry"] = retry.as_manifest()
132
+
133
+ self._nodes.append(entry)
134
+ return NodeRef(node_key, func, entry["entrypoint"])
135
+
136
+ return register
137
+
138
+ def external_node(self, key: str) -> NodeRef:
139
+ """Declare an external dependency on a node defined in another project."""
140
+ _check_key(key)
141
+ return NodeRef(key, None)
142
+
143
+ def manifest(self) -> dict[str, Any]:
144
+ """The dagflows-manifest.json body for this project."""
145
+ if not self._nodes:
146
+ raise ValueError("this workflow declares no nodes, so there is nothing to build")
147
+
148
+ for node in self._nodes:
149
+ for parent in node.get("depends", ()):
150
+ if parent not in self._keys:
151
+ # Reject node references from other workflow instances.
152
+ raise ValueError(
153
+ f"node {node['key']!r} depends on {parent!r}, which this workflow "
154
+ f"does not define; its nodes are: {', '.join(sorted(self._keys))}"
155
+ )
156
+
157
+ runtime: dict[str, Any] = {"language": LANGUAGE}
158
+ if self.version:
159
+ runtime["version"] = self.version
160
+
161
+ out: dict[str, Any] = {"v": MANIFEST_VERSION, "runtime": runtime}
162
+
163
+ if self.name:
164
+ settings: dict[str, Any] = {"name": self.name}
165
+
166
+ if self.max_concurrent_nodes:
167
+ settings["max_concurrent_nodes"] = self.max_concurrent_nodes
168
+
169
+ if self.max_cycle_count:
170
+ settings["max_cycle_count"] = self.max_cycle_count
171
+
172
+ out["workflow"] = settings
173
+
174
+ out["nodes"] = [dict(node) for node in self._nodes]
175
+ return out
176
+
177
+ def _claim(self, key: str) -> None:
178
+ _check_key(key)
179
+
180
+ if key in self._keys:
181
+ raise ValueError(f"duplicate node key {key!r} in this project")
182
+
183
+ self._keys.add(key)
184
+
185
+
186
+ def _check_key(key: str) -> None:
187
+ if not NODE_KEY.match(key):
188
+ raise ValueError(
189
+ f"node key {key!r} invalid, want a letter then letters, digits, _ or -, max 64 chars"
190
+ )
191
+
192
+
193
+ def _split_depends(node_key: str, depends: Iterable[Any]) -> tuple[list[str], list[str]]:
194
+ """Separate this project's parents from the ones another project defines."""
195
+ local: list[str] = []
196
+ external: list[str] = []
197
+
198
+ for parent in depends:
199
+ if isinstance(parent, str):
200
+ raise TypeError(
201
+ f"node {node_key!r} depends on the string {parent!r}; pass the handle the "
202
+ "decorator returned, or wf.external_node(...) for a node in another project"
203
+ )
204
+
205
+ if not isinstance(parent, NodeRef):
206
+ raise TypeError(
207
+ f"node {node_key!r} depends on {parent!r}, which is not a node handle"
208
+ )
209
+ (external if parent.external else local).append(parent.key)
210
+
211
+ return local, external
@@ -0,0 +1,57 @@
1
+ """
2
+ Developer CLI commands, exit codes, and output formatting helpers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import sys
9
+ from typing import Any
10
+
11
+ # Standard CLI process exit codes.
12
+ OK = 0
13
+ FAILED = 1 # the operation ran and did not succeed
14
+ USAGE = 2 # the command was wrong
15
+
16
+
17
+ class CommandError(Exception):
18
+ """Base exception for user-facing CLI command errors."""
19
+
20
+ def __init__(self, message: str, code: int = FAILED) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.code = code
24
+
25
+
26
+ def fail(message: str) -> CommandError:
27
+ return CommandError(message, FAILED)
28
+
29
+
30
+ def misuse(message: str) -> CommandError:
31
+ return CommandError(message, USAGE)
32
+
33
+
34
+ def report(command: str, error: CommandError, as_json: bool) -> int:
35
+ """Emit an error message to stderr or structured JSON."""
36
+ if as_json:
37
+ emit({"ok": False, "command": command, "error": error.message}, as_json=True)
38
+ else:
39
+ print(f"dagflows {command}: {error.message}", file=sys.stderr)
40
+ return error.code
41
+
42
+
43
+ def emit(payload: dict[str, Any], as_json: bool, human: str = "") -> None:
44
+ """Emit command output as structured JSON or human-readable text."""
45
+ if as_json:
46
+ json.dump(payload, sys.stdout, indent=2)
47
+ sys.stdout.write("\n")
48
+ return
49
+
50
+ if human:
51
+ print(human)
52
+
53
+
54
+ def take_flag(argv: list[str], *names: str) -> tuple[list[str], bool]:
55
+ """Extract a boolean flag from argv and return remaining arguments."""
56
+ rest = [item for item in argv if item not in names]
57
+ return rest, len(rest) != len(argv)