clankloop 0.0.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.
clankloop/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+ """clankloop — Integrate agents into deterministic workflows.
3
+
4
+ Clankloop compiles workflow specification files ("loopfiles") into graphs of
5
+ tasks, runs or analyzes them, and reports results.
6
+
7
+ Typical usage::
8
+
9
+ from clankloop import compile_pipeline, Runner
10
+
11
+ runtime = compile_pipeline(yaml_data, "my_pipeline")
12
+ runner = Runner()
13
+ runner.register(runtime)
14
+ runner.run("my_pipeline", parameters, environ={})
15
+
16
+ At runtime a compiled :class:`~clankloop.runner.Pipeline` is executed by a
17
+ :class:`~clankloop.runner.Runner`, which drives the
18
+ :class:`~clankloop.core.graph.ExecutionGraph` forward through conditions and actions.
19
+ """
20
+
21
+ from clankloop.loopfile import compile_pipeline
22
+ from clankloop.runner import (
23
+ Runner,
24
+ Pipeline,
25
+ )
26
+ from clankloop.core.errors import (
27
+ ClankloopError,
28
+ UnexpectedTerminationError,
29
+ UnknownTaskError,
30
+ InfiniteLoopError,
31
+ UnassignedDataDependencyError,
32
+ MissingParameterError,
33
+ UnknownParameterError,
34
+ )
35
+
36
+ __all__ = [
37
+ "compile_pipeline",
38
+ "Runner",
39
+ "Pipeline",
40
+ "ClankloopError",
41
+ "UnexpectedTerminationError",
42
+ "UnknownTaskError",
43
+ "InfiniteLoopError",
44
+ "UnassignedDataDependencyError",
45
+ "MissingParameterError",
46
+ "UnknownParameterError",
47
+ ]
clankloop/cli.py ADDED
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """Command-line interface for clankloop.
3
+
4
+ The CLI provides three subcommands:
5
+
6
+ - ``run`` — Execute a named pipeline from a loopfile.
7
+ - ``plantuml`` — Export a pipeline's execution graph as a PlantUML state diagram.
8
+ - ``taskcar`` — Run a pipeline as a taskcar task script: reads a JSON object
9
+ of parameters from stdin, runs the pipeline with subprocess output
10
+ discarded, and prints a taskcar-protocol JSON result on stdout.
11
+ """
12
+
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Sequence, TextIO
20
+ import logging
21
+ from importlib.metadata import version as pkg_version
22
+ import yaml
23
+
24
+ from clankloop.loopfile import compile_pipeline
25
+ from clankloop.loopfile.versions import SUPPORTED_VERSIONS, CURRENT_VERSION, normalize_version
26
+ from clankloop.runner import Runner
27
+ from clankloop.core.graph import UnexpectedTerminationError, IOChannels
28
+ from clankloop.logger import setup_logging
29
+
30
+ logger = logging.getLogger("clankloop")
31
+
32
+ #: Taskcar task-script output when the pipeline succeeds with no declarable
33
+ #: result data or new tasks. The loopfile schema does not yet declare result
34
+ #: exports or task spawning; until it does, both fields are empty. taskcar
35
+ #: treats non-zero exit as task failure, so this is only reached on success.
36
+ TASKCAR_EMPTY_RESULT: dict[str, object] = {"data": {}, "new_tasks": []}
37
+
38
+
39
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
40
+ """Build the argument parser and parse *argv*.
41
+
42
+ Args:
43
+ argv: Optional list of arguments (defaults to ``sys.argv[1:]``).
44
+
45
+ Returns:
46
+ The parsed namespace with ``command``, ``loopfile``, ``pipeline``,
47
+ and command-specific options.
48
+ """
49
+ parser = argparse.ArgumentParser(
50
+ prog="clankloop",
51
+ description="Clank Loop — Compile and execute pipeline graphs.",
52
+ )
53
+
54
+ parser.add_argument(
55
+ "--version",
56
+ action="version",
57
+ version=f"%(prog)s {pkg_version('clankloop')}",
58
+ )
59
+
60
+ parser.add_argument(
61
+ "-l",
62
+ "--loglevel",
63
+ nargs="?",
64
+ default="WARNING",
65
+ help="Set the logging level (default: WARNING)",
66
+ )
67
+ subparsers = parser.add_subparsers(dest="command", required=True)
68
+
69
+ # run command
70
+ run_parser = subparsers.add_parser("run", help="Compile and run a pipeline")
71
+ run_parser.add_argument(
72
+ "loopfile",
73
+ help="Path to the loopfile YAML definition",
74
+ )
75
+ run_parser.add_argument(
76
+ "pipeline",
77
+ help="The name of the pipeline to run",
78
+ )
79
+ run_parser.add_argument(
80
+ "-p",
81
+ "--parameter",
82
+ action="append",
83
+ metavar="KEY=VALUE",
84
+ help="Define or override parameter values (can be used multiple times)",
85
+ )
86
+ run_parser.add_argument(
87
+ "-i",
88
+ "--interactive",
89
+ action="store_true",
90
+ default=False,
91
+ help="Allow interactive tasks to inherit the terminal (stdin/stdout/stderr)",
92
+ )
93
+
94
+ # plantuml command
95
+ plant_parser = subparsers.add_parser(
96
+ "plantuml",
97
+ help="Export a pipeline's execution graph as a PlantUML activity diagram",
98
+ )
99
+ plant_parser.add_argument(
100
+ "loopfile",
101
+ help="Path to the loopfile YAML definition",
102
+ )
103
+ plant_parser.add_argument(
104
+ "pipeline",
105
+ help="The name of the pipeline to inspect",
106
+ )
107
+
108
+ # taskcar command
109
+ taskcar_parser = subparsers.add_parser(
110
+ "taskcar",
111
+ help="Run a pipeline as a taskcar task script (JSON params on stdin, JSON result on stdout)",
112
+ )
113
+ taskcar_parser.add_argument(
114
+ "loopfile",
115
+ help="Path to the loopfile YAML definition",
116
+ )
117
+ taskcar_parser.add_argument(
118
+ "pipeline",
119
+ help="The name of the pipeline to run",
120
+ )
121
+
122
+ return parser.parse_args(argv)
123
+
124
+
125
+ def parse_parameter(parameter_strings: list[str] | None) -> dict[str, str]:
126
+ """Parse ``KEY=VALUE`` parameter override strings into a dict.
127
+
128
+ Args:
129
+ parameter_strings: List of ``"KEY=VALUE"`` strings (or ``None``).
130
+
131
+ Returns:
132
+ A mapping of parameter names to their string values.
133
+
134
+ Raises:
135
+ ValueError: If any string does not contain an ``=`` sign.
136
+ """
137
+ parameters: dict[str, str] = {}
138
+ if not parameter_strings:
139
+ return parameters
140
+
141
+ for item in parameter_strings:
142
+ if "=" not in item:
143
+ raise ValueError(
144
+ f"Parameter override must be in KEY=VALUE format, got: {item!r}"
145
+ )
146
+ key, value = item.split("=", 1)
147
+ parameters[key.strip()] = value.strip()
148
+
149
+ return parameters
150
+
151
+
152
+ def parse_parameters_from_json(raw: str) -> dict[str, str]:
153
+ """Parse a JSON object from *raw* and return it as a ``dict[str, str]``.
154
+
155
+ Every top-level value must be a string.
156
+ """
157
+ try:
158
+ obj = json.loads(raw)
159
+ except json.JSONDecodeError as exc:
160
+ raise ValueError(f"stdin is not valid JSON: {exc}") from exc
161
+
162
+ if not isinstance(obj, dict):
163
+ raise ValueError(f"stdin JSON must be an object, got {type(obj).__name__!r}")
164
+
165
+ result: dict[str, str] = {}
166
+ for key, value in obj.items():
167
+ if not isinstance(value, str):
168
+ raise ValueError(
169
+ f"Parameter {key!r} must be a string, got {type(value).__name__!r}"
170
+ )
171
+ result[key] = value
172
+
173
+ return result
174
+
175
+
176
+ def main(argv: Sequence[str] | None = None, *, stdin: TextIO | None = None) -> int:
177
+ """Entry point for the ``clankloop`` CLI.
178
+
179
+ Dispatches to the requested subcommand (``run``, ``plantuml``, or
180
+ ``taskcar``), compiles the loopfile, and executes or inspects the
181
+ named pipeline.
182
+
183
+ Args:
184
+ argv: Optional argument list (defaults to ``sys.argv[1:]``).
185
+ stdin: Optional stdin stream for the ``taskcar`` subcommand
186
+ (defaults to ``sys.stdin``).
187
+
188
+ Returns:
189
+ Exit code: ``0`` on success, ``1`` on error.
190
+ """
191
+ try:
192
+ args = parse_args(argv)
193
+
194
+ if args.command == "taskcar":
195
+ setup_logging(args.loglevel, json_format=True, stream=sys.stderr)
196
+ else:
197
+ setup_logging(args.loglevel)
198
+
199
+ loopfile_path = Path(args.loopfile)
200
+ if not loopfile_path.exists():
201
+ print(f"Error: Loopfile {args.loopfile!r} not found", file=sys.stderr)
202
+ return 1
203
+
204
+ with loopfile_path.open() as f:
205
+ yaml_data = yaml.safe_load(f)
206
+
207
+ version = normalize_version(yaml_data.get("version", CURRENT_VERSION))
208
+ if version not in SUPPORTED_VERSIONS:
209
+ raise ValueError(
210
+ f"unsupported loopfile version: {version!r}; supported: {SUPPORTED_VERSIONS}"
211
+ )
212
+
213
+ runtime = compile_pipeline(yaml_data, args.pipeline)
214
+ runner = Runner()
215
+ runner.register(runtime)
216
+
217
+ if args.command == "plantuml":
218
+ print(runner.to_plantuml(runtime.name))
219
+ return 0
220
+
221
+ parameters: dict[str, str] = {}
222
+
223
+ if args.command == "run":
224
+ parameters = parse_parameter(args.parameter)
225
+ elif args.command == "taskcar":
226
+ in_stream = stdin if stdin is not None else sys.stdin
227
+ parameters = parse_parameters_from_json(in_stream.read())
228
+
229
+ try:
230
+ if args.command == "run":
231
+ run_stdin = None
232
+ if args.interactive:
233
+ run_stdin = stdin if stdin is not None else sys.stdin
234
+ runner.run(
235
+ runtime.name,
236
+ parameters,
237
+ environ=os.environ,
238
+ io=IOChannels(
239
+ stdin=run_stdin,
240
+ stdout=sys.stdout,
241
+ stderr=sys.stderr,
242
+ ),
243
+ interactive=args.interactive,
244
+ )
245
+ elif args.command == "taskcar":
246
+ runner.run(runtime.name, parameters, environ=os.environ)
247
+ except UnexpectedTerminationError as e:
248
+ logger.error("Pipeline %r terminated unexpectedly: %s", runtime.name, e)
249
+ logger.debug(
250
+ f"Pipeline {runtime.name!r} execution details",
251
+ extra={"pipeline": runtime},
252
+ )
253
+ return 1
254
+ logger.info("Pipeline %r completed successfully", runtime.name)
255
+
256
+ if args.command == "taskcar":
257
+ print(json.dumps(TASKCAR_EMPTY_RESULT))
258
+
259
+ return 0
260
+
261
+ except Exception as e:
262
+ print(f"Error: {e}", file=sys.stderr)
263
+ return 1
264
+
265
+
266
+ if __name__ == "__main__":
267
+ sys.exit(main())
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env python3
2
+ """Core execution primitives.
3
+
4
+ This package contains the runtime graph engine (:mod:`clankloop.core.graph`)
5
+ and the environment/templating system (:mod:`clankloop.core.env`).
6
+ """
clankloop/core/bash.py ADDED
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env python3
2
+ """Bash subprocess execution behind the :class:`~clankloop.core.types.Execution` protocol.
3
+
4
+ The only module in the core that touches ``asyncio`` or ``subprocess``. It
5
+ renders the command template, extracts pipeline-declared env vars, and routes
6
+ through one of three IO paths (passthrough / pumped-interactive /
7
+ pumped-captured) based on whether the supplied streams are real OS file
8
+ descriptors.
9
+ """
10
+
11
+
12
+ import asyncio
13
+ import logging
14
+ import subprocess
15
+ from typing import Any, Coroutine, TextIO
16
+
17
+ from clankloop.core.env import ClankTemplate, EnvPath
18
+ from clankloop.core.types import (
19
+ ExecutionContext,
20
+ ExecutionResult,
21
+ IOChannels,
22
+ extract_env,
23
+ )
24
+
25
+ logger = logging.getLogger("clankloop")
26
+
27
+
28
+ def _stream_is_fd(stream: TextIO | None) -> bool:
29
+ """Whether *stream* is a real OS file (has a usable fileno)."""
30
+ if stream is None:
31
+ return False
32
+ try:
33
+ stream.fileno()
34
+ except (AttributeError, OSError, ValueError):
35
+ return False
36
+ return True
37
+
38
+
39
+ def _all_streams_are_fds(io: IOChannels) -> bool:
40
+ """Whether every channel of *io* is a real OS file.
41
+
42
+ Pass-through (inheriting the tty) requires all three to be file
43
+ descriptors; in-memory streams (tests, capsys capture) must be pumped.
44
+ """
45
+ return _stream_is_fd(io.stdin) and _stream_is_fd(io.stdout) and _stream_is_fd(io.stderr)
46
+
47
+
48
+ class BashExecution:
49
+ """Executes a bash command with environment extraction and template rendering.
50
+
51
+ The command string may be a :class:`~clankloop.core.env.ClankTemplate` that
52
+ is rendered against the environment before execution.
53
+ """
54
+
55
+ def __init__(self, name: str, cmds: str | ClankTemplate, exports: dict[str, EnvPath]):
56
+ self.name = name
57
+ self.cmds = cmds
58
+ self.exports = exports
59
+ self.rendered_cmds: str | None = None
60
+
61
+ def consumes(self) -> frozenset[str]:
62
+ """Template identifiers in the command."""
63
+ if isinstance(self.cmds, ClankTemplate):
64
+ return frozenset(self.cmds.get_identifiers())
65
+ return frozenset()
66
+
67
+ def execute(self, context: ExecutionContext) -> ExecutionResult:
68
+ # Pipeline-declared values override the base process environment.
69
+ proc_env = {**context.environ, **extract_env(context.env, self.exports)}
70
+
71
+ logger.debug(
72
+ f"Task details {self.name}: {self.cmds} {type(self.cmds)}",
73
+ extra={
74
+ "task": self.name,
75
+ "cmds": self.cmds,
76
+ },
77
+ )
78
+
79
+ if isinstance(self.cmds, ClankTemplate):
80
+ logger.info("Rendering cmd template")
81
+ self.rendered_cmds = self.cmds.render(context.env)
82
+ else:
83
+ self.rendered_cmds = self.cmds
84
+
85
+ script = "set -euo pipefail\n" + self.rendered_cmds
86
+ argv = ["bash", "-c", script]
87
+
88
+ io = context.io
89
+ if io.stdin is not None and _all_streams_are_fds(io):
90
+ # Interactive with a real terminal: inherit the tty directly so
91
+ # full-screen apps (nano) work. No capture.
92
+ return self._run_passthrough(argv, proc_env, io)
93
+ if io.stdin is not None:
94
+ # Interactive with in-memory streams (tests): pump stdin from the
95
+ # source and stdout/stderr to the sinks, without capturing.
96
+ return asyncio.run(self._run_pumped(argv, proc_env, io, capture=False))
97
+ # Non-interactive: stdin closed (DEVNULL); stdout/stderr pumped to the
98
+ # sinks (if any) and captured for SetActions / conditions.
99
+ return asyncio.run(self._run_pumped(argv, proc_env, io, capture=True))
100
+
101
+ def _run_passthrough(
102
+ self, argv: list[str], env: dict[str, str], io: IOChannels
103
+ ) -> ExecutionResult:
104
+ assert io.stdin is not None and io.stdout is not None and io.stderr is not None
105
+ completed = subprocess.run(
106
+ argv,
107
+ env=env,
108
+ stdin=io.stdin,
109
+ stdout=io.stdout,
110
+ stderr=io.stderr,
111
+ )
112
+ return ExecutionResult(stdout="", stderr="", returncode=completed.returncode)
113
+
114
+ async def _run_pumped(
115
+ self,
116
+ argv: list[str],
117
+ env: dict[str, str],
118
+ io: IOChannels,
119
+ *,
120
+ capture: bool,
121
+ ) -> ExecutionResult:
122
+ proc = await asyncio.create_subprocess_exec(
123
+ *argv,
124
+ env=env,
125
+ stdin=asyncio.subprocess.PIPE if io.stdin is not None else subprocess.DEVNULL,
126
+ stdout=asyncio.subprocess.PIPE,
127
+ stderr=asyncio.subprocess.PIPE,
128
+ )
129
+ assert proc.stdout is not None and proc.stderr is not None
130
+
131
+ tasks: list[Coroutine[Any, Any, None]] = []
132
+ if io.stdin is not None:
133
+ assert proc.stdin is not None
134
+ tasks.append(self._feed(io.stdin, proc.stdin))
135
+ out_buf: list[str] = []
136
+ err_buf: list[str] = []
137
+ tasks.append(self._pump(proc.stdout, io.stdout, out_buf if capture else None))
138
+ tasks.append(self._pump(proc.stderr, io.stderr, err_buf if capture else None))
139
+ await asyncio.gather(*tasks)
140
+ rc = await proc.wait()
141
+ return ExecutionResult(
142
+ stdout="".join(out_buf) if capture else "",
143
+ stderr="".join(err_buf) if capture else "",
144
+ returncode=rc,
145
+ )
146
+
147
+ @staticmethod
148
+ async def _feed(source: TextIO, sink: asyncio.StreamWriter) -> None:
149
+ data = source.read().encode()
150
+ if data:
151
+ try:
152
+ sink.write(data)
153
+ await sink.drain()
154
+ except (BrokenPipeError, ConnectionResetError):
155
+ pass
156
+ try:
157
+ sink.close()
158
+ except (BrokenPipeError, ConnectionResetError):
159
+ pass
160
+
161
+ @staticmethod
162
+ async def _pump(
163
+ source: asyncio.StreamReader,
164
+ sink: TextIO | None,
165
+ buf: list[str] | None,
166
+ ) -> None:
167
+ while True:
168
+ chunk = await source.read(4096)
169
+ if not chunk:
170
+ break
171
+ text = chunk.decode()
172
+ if buf is not None:
173
+ buf.append(text)
174
+ if sink is not None:
175
+ sink.write(text)
176
+ sink.flush()