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 +47 -0
- clankloop/cli.py +267 -0
- clankloop/core/__init__.py +6 -0
- clankloop/core/bash.py +176 -0
- clankloop/core/env.py +292 -0
- clankloop/core/errors.py +79 -0
- clankloop/core/graph.py +272 -0
- clankloop/core/lts.py +330 -0
- clankloop/core/types.py +230 -0
- clankloop/logger.py +179 -0
- clankloop/loopfile/__init__.py +53 -0
- clankloop/loopfile/v1/__init__.py +4 -0
- clankloop/loopfile/v1/compiler.py +247 -0
- clankloop/loopfile/v1/loopfile.py +107 -0
- clankloop/loopfile/v1/paths.py +47 -0
- clankloop/loopfile/v2/__init__.py +7 -0
- clankloop/loopfile/v2/clankshed_module.py +121 -0
- clankloop/loopfile/v2/compiler.py +331 -0
- clankloop/loopfile/v2/git_module.py +72 -0
- clankloop/loopfile/v2/loopfile.py +152 -0
- clankloop/loopfile/v2/module.py +69 -0
- clankloop/loopfile/v2/module_registry.py +53 -0
- clankloop/loopfile/v2/paths.py +38 -0
- clankloop/loopfile/v2/toposort.py +77 -0
- clankloop/loopfile/v2/workdir_module.py +40 -0
- clankloop/loopfile/versions.py +19 -0
- clankloop/plantuml.py +88 -0
- clankloop/runner.py +188 -0
- clankloop/tracer.py +74 -0
- clankloop-0.0.0.dist-info/METADATA +20 -0
- clankloop-0.0.0.dist-info/RECORD +34 -0
- clankloop-0.0.0.dist-info/WHEEL +4 -0
- clankloop-0.0.0.dist-info/entry_points.txt +2 -0
- clankloop-0.0.0.dist-info/licenses/LICENSE +674 -0
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())
|
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()
|