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 +72 -0
- dagflows/__main__.py +67 -0
- dagflows/_authoring/__init__.py +1 -0
- dagflows/_authoring/config.py +58 -0
- dagflows/_authoring/node.py +44 -0
- dagflows/_authoring/workflow.py +211 -0
- dagflows/_cli/__init__.py +57 -0
- dagflows/_cli/build.py +198 -0
- dagflows/_cli/dev.py +274 -0
- dagflows/_runtime/__init__.py +1 -0
- dagflows/_runtime/content.py +30 -0
- dagflows/_runtime/decode.py +42 -0
- dagflows/_runtime/dispatch.py +115 -0
- dagflows/_runtime/encode.py +117 -0
- dagflows/_runtime/envelope.py +322 -0
- dagflows/_runtime/errors.py +49 -0
- dagflows/_runtime/http.py +223 -0
- dagflows/_runtime/jsonstream.py +114 -0
- dagflows/_runtime/multipart.py +124 -0
- dagflows/_runtime/outstream.py +70 -0
- dagflows/_runtime/result.py +326 -0
- dagflows/_runtime/runner.py +120 -0
- dagflows/py.typed +0 -0
- dagflows-0.1.0.dist-info/METADATA +282 -0
- dagflows-0.1.0.dist-info/RECORD +27 -0
- dagflows-0.1.0.dist-info/WHEEL +4 -0
- dagflows-0.1.0.dist-info/licenses/LICENSE +201 -0
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)
|