rundot 0.6.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.
- rundot/__init__.py +0 -0
- rundot/backends/__init__.py +244 -0
- rundot/backends/python.py +51 -0
- rundot/backends/shell.py +80 -0
- rundot/cli.py +513 -0
- rundot/inputs.py +88 -0
- rundot/library.py +69 -0
- rundot/runbook.py +216 -0
- rundot/runner.py +305 -0
- rundot/settings.py +3 -0
- rundot/transcript.py +139 -0
- rundot/validate.py +323 -0
- rundot-0.6.0.dist-info/METADATA +132 -0
- rundot-0.6.0.dist-info/RECORD +17 -0
- rundot-0.6.0.dist-info/WHEEL +4 -0
- rundot-0.6.0.dist-info/entry_points.txt +6 -0
- rundot-0.6.0.dist-info/licenses/LICENSE +21 -0
rundot/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import inspect
|
|
3
|
+
import os
|
|
4
|
+
import selectors
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib.metadata import EntryPoint
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
ENTRY_POINT_GROUP = "rundot.backends"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Problem:
|
|
18
|
+
"""Something a backend found wrong with a block."""
|
|
19
|
+
|
|
20
|
+
message: str
|
|
21
|
+
line: int | None = None # line in the block, counting its first line as 1; None if it's about the whole block
|
|
22
|
+
warning: bool = False # warnings are reported but don't stop the runbook running
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Output(Protocol):
|
|
26
|
+
"""Where a backend sends each line a block prints, without its newline, as soon as it's printed.
|
|
27
|
+
|
|
28
|
+
stderr is True for a line the block wrote to stderr. A backend that calls output(line) still works; its
|
|
29
|
+
lines are shown as stdout.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __call__(self, line: str, *, stderr: bool = False) -> None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Backend(Protocol):
|
|
36
|
+
"""Runs `run` blocks for one or more languages.
|
|
37
|
+
|
|
38
|
+
Backends are classes registered under the `rundot.backends` entry point group. Each is created
|
|
39
|
+
once, with no arguments, when backends are loaded.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Read-only, so a backend can set them as plain class attributes.
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str: ...
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def languages(self) -> tuple[str, ...]:
|
|
48
|
+
"""The fence languages this backend runs, e.g. ("sh", "bash")."""
|
|
49
|
+
|
|
50
|
+
def validate(self, code: str) -> list[Problem]:
|
|
51
|
+
"""Return the problems found in a block, without running it. An empty list means it's fine.
|
|
52
|
+
|
|
53
|
+
code is the block as written, so it can contain <NAME> placeholders. Replacing them with
|
|
54
|
+
something this language accepts is up to the backend. Any error stops the runbook running;
|
|
55
|
+
warnings are only reported.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def run(
|
|
59
|
+
self,
|
|
60
|
+
code: str,
|
|
61
|
+
*,
|
|
62
|
+
frontmatter: Mapping[str, Any],
|
|
63
|
+
env: Mapping[str, str],
|
|
64
|
+
cwd: Path,
|
|
65
|
+
output: Output,
|
|
66
|
+
) -> int:
|
|
67
|
+
"""Run a block and return its exit code, where 0 means it succeeded.
|
|
68
|
+
|
|
69
|
+
Call output with each line the block prints, without its newline, as soon as it's printed, and
|
|
70
|
+
stderr=True for a line it wrote to stderr.
|
|
71
|
+
Don't print anything directly; rundot owns the terminal. frontmatter is the runbook's
|
|
72
|
+
frontmatter, read-only. env is the block's complete environment and cwd is where it runs.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Interrupted(KeyboardInterrupt):
|
|
77
|
+
"""Ctrl-C was pressed while a block ran. The block has exited, with exit_code."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, exit_code: int):
|
|
80
|
+
super().__init__(exit_code)
|
|
81
|
+
self.exit_code = exit_code
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def stream(command: Sequence[str], *, env: Mapping[str, str], cwd: Path, output: Callable[..., None]) -> int:
|
|
85
|
+
"""Run command, passing each line it prints to output as it's printed, and return its exit code.
|
|
86
|
+
|
|
87
|
+
If output takes a stderr keyword, as the output rundot gives a backend does, each line written to stderr is
|
|
88
|
+
passed with stderr=True. stdout and stderr are read apart then, so two lines written to them at almost the
|
|
89
|
+
same moment can arrive in either order. Otherwise they're merged, so lines keep the order they were printed
|
|
90
|
+
in. The command shares rundot's terminal, so it can still ask for a password on it, as git, ssh and gpg do.
|
|
91
|
+
This is how the built-in backends run blocks, and other backends can use it too.
|
|
92
|
+
|
|
93
|
+
Ctrl-C reaches the command as well as rundot. On the first press, rundot waits for the command to
|
|
94
|
+
stop, still passing on what it prints. A second press kills it and everything it started, and stops
|
|
95
|
+
reading, so nothing can keep rundot waiting. Either way, once the command has exited, Interrupted
|
|
96
|
+
is raised with its exit code.
|
|
97
|
+
"""
|
|
98
|
+
marks_stderr = _takes_stderr(output)
|
|
99
|
+
stderr = subprocess.PIPE if marks_stderr else subprocess.STDOUT
|
|
100
|
+
interrupted = False
|
|
101
|
+
with subprocess.Popen(command, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=stderr) as process:
|
|
102
|
+
pipes = {process.stdout: False, process.stderr: True} if marks_stderr else {process.stdout: False}
|
|
103
|
+
with selectors.DefaultSelector() as selector:
|
|
104
|
+
for pipe, is_stderr in pipes.items():
|
|
105
|
+
assert pipe is not None # set because it's a pipe
|
|
106
|
+
selector.register(pipe, selectors.EVENT_READ, _Lines(output, is_stderr if marks_stderr else None))
|
|
107
|
+
while True:
|
|
108
|
+
try:
|
|
109
|
+
_read_all(selector)
|
|
110
|
+
break
|
|
111
|
+
except KeyboardInterrupt:
|
|
112
|
+
if not interrupted:
|
|
113
|
+
# The terminal sent Ctrl-C to the command too, so let it stop in its own way.
|
|
114
|
+
interrupted = True
|
|
115
|
+
continue
|
|
116
|
+
_kill_tree(process.pid)
|
|
117
|
+
# Stop reading, so nothing still holding the output open can keep rundot waiting.
|
|
118
|
+
break
|
|
119
|
+
process.wait()
|
|
120
|
+
if interrupted:
|
|
121
|
+
raise Interrupted(process.returncode)
|
|
122
|
+
return process.returncode
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _takes_stderr(output: Callable[..., None]) -> bool:
|
|
126
|
+
"""Whether output can be called with stderr=, so stderr lines can be marked rather than merged."""
|
|
127
|
+
try:
|
|
128
|
+
parameters = inspect.signature(output).parameters
|
|
129
|
+
except (TypeError, ValueError): # builtins such as list.append may have no signature to read
|
|
130
|
+
return False
|
|
131
|
+
return "stderr" in parameters
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class _Lines:
|
|
136
|
+
"""Splits what one pipe gives into lines, passing each to output. Only newlines end a line."""
|
|
137
|
+
|
|
138
|
+
output: Callable[..., None]
|
|
139
|
+
stderr: bool | None # None: output doesn't take stderr, so it's called with the line alone
|
|
140
|
+
partial: bytes = b""
|
|
141
|
+
|
|
142
|
+
def feed(self, data: bytes) -> None:
|
|
143
|
+
*lines, self.partial = (self.partial + data).split(b"\n")
|
|
144
|
+
for line in lines:
|
|
145
|
+
self._send(line)
|
|
146
|
+
|
|
147
|
+
def finish(self) -> None:
|
|
148
|
+
"""The pipe has closed: pass on a last line that had no newline."""
|
|
149
|
+
if self.partial:
|
|
150
|
+
self._send(self.partial)
|
|
151
|
+
self.partial = b""
|
|
152
|
+
|
|
153
|
+
def _send(self, line: bytes) -> None:
|
|
154
|
+
text = line.decode("utf-8", errors="replace")
|
|
155
|
+
if self.stderr is None:
|
|
156
|
+
self.output(text)
|
|
157
|
+
else:
|
|
158
|
+
self.output(text, stderr=self.stderr)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _read_all(selector: selectors.BaseSelector) -> None:
|
|
162
|
+
"""Read every registered pipe until each has closed, passing on lines as they arrive."""
|
|
163
|
+
while selector.get_map():
|
|
164
|
+
for key, _ in selector.select():
|
|
165
|
+
data = os.read(key.fd, 65536)
|
|
166
|
+
if data:
|
|
167
|
+
key.data.feed(data)
|
|
168
|
+
else:
|
|
169
|
+
key.data.finish()
|
|
170
|
+
selector.unregister(key.fileobj)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _kill_tree(pid: int) -> None:
|
|
174
|
+
"""Kill a process and every process it started, however deep.
|
|
175
|
+
|
|
176
|
+
Each one is stopped first, so none can start another between being found and being killed.
|
|
177
|
+
"""
|
|
178
|
+
found = [pid]
|
|
179
|
+
for parent in found:
|
|
180
|
+
_signal(parent, signal.SIGSTOP)
|
|
181
|
+
found.extend(child for child in _children(parent) if child not in found)
|
|
182
|
+
for process in found:
|
|
183
|
+
_signal(process, signal.SIGKILL)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _children(pid: int) -> list[int]:
|
|
187
|
+
"""The processes pid started that are still running."""
|
|
188
|
+
listing = subprocess.run(["ps", "-A", "-o", "pid=", "-o", "ppid="], capture_output=True, text=True).stdout
|
|
189
|
+
return [int(child) for child, parent in (row.split() for row in listing.splitlines()) if int(parent) == pid]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _signal(pid: int, signal_number: int) -> None:
|
|
193
|
+
"""Signal a process, unless it has already gone."""
|
|
194
|
+
try:
|
|
195
|
+
os.kill(pid, signal_number)
|
|
196
|
+
except ProcessLookupError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class BackendError(Exception):
|
|
201
|
+
"""Backends can't be loaded, e.g. two of them claim the same language."""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class LoadedBackend:
|
|
206
|
+
backend: Backend
|
|
207
|
+
entry_point: EntryPoint # where it was registered, e.g. rundot's own pyproject.toml or another package's
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def installed_entry_points() -> Iterable[EntryPoint]:
|
|
211
|
+
"""Every backend registered under the rundot.backends group by an installed package, rundot included."""
|
|
212
|
+
return importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_backend_sources(entry_points: Iterable[EntryPoint] | None = None) -> list[LoadedBackend]:
|
|
216
|
+
"""Load each backend once, with where it came from, from the installed entry points unless others are given.
|
|
217
|
+
|
|
218
|
+
Raises BackendError if two backends claim the same language.
|
|
219
|
+
"""
|
|
220
|
+
if entry_points is None:
|
|
221
|
+
entry_points = installed_entry_points()
|
|
222
|
+
loaded: list[LoadedBackend] = []
|
|
223
|
+
claimed: dict[str, LoadedBackend] = {}
|
|
224
|
+
for entry_point in entry_points:
|
|
225
|
+
source = LoadedBackend(entry_point.load()(), entry_point)
|
|
226
|
+
for language in source.backend.languages:
|
|
227
|
+
if language in claimed:
|
|
228
|
+
other = claimed[language]
|
|
229
|
+
raise BackendError(
|
|
230
|
+
f"language {language!r} is claimed by both {other.backend.name!r} ({other.entry_point.value}) "
|
|
231
|
+
f"and {source.backend.name!r} ({entry_point.value})"
|
|
232
|
+
)
|
|
233
|
+
claimed[language] = source
|
|
234
|
+
loaded.append(source)
|
|
235
|
+
return loaded
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def load_backends(entry_points: Iterable[EntryPoint] | None = None) -> dict[str, Backend]:
|
|
239
|
+
"""Load backends keyed by language, from the installed entry points unless others are given."""
|
|
240
|
+
return {
|
|
241
|
+
language: source.backend
|
|
242
|
+
for source in load_backend_sources(entry_points)
|
|
243
|
+
for language in source.backend.languages
|
|
244
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
import warnings
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rundot.backends import Problem, stream
|
|
10
|
+
from rundot.inputs import PLACEHOLDER
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PythonBackend:
|
|
14
|
+
"""Runs each block as a Python script, with the runbook's python command or rundot's own interpreter."""
|
|
15
|
+
|
|
16
|
+
name = "python"
|
|
17
|
+
languages = ("python", "py")
|
|
18
|
+
|
|
19
|
+
def validate(self, code: str) -> list[Problem]:
|
|
20
|
+
# Swap each placeholder for its bare name, which is a plain identifier wherever it appears, so the code
|
|
21
|
+
# still compiles and keeps its line numbers. compile() uses rundot's own interpreter, so syntax only the
|
|
22
|
+
# runbook's python understands isn't caught.
|
|
23
|
+
source = PLACEHOLDER.sub(lambda match: match.group(1), code)
|
|
24
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
25
|
+
warnings.simplefilter("always", SyntaxWarning)
|
|
26
|
+
try:
|
|
27
|
+
compile(source, "<block>", "exec")
|
|
28
|
+
except SyntaxError as e:
|
|
29
|
+
return [Problem(e.msg, line=e.lineno)]
|
|
30
|
+
return [
|
|
31
|
+
Problem(str(warning.message), line=warning.lineno, warning=True)
|
|
32
|
+
for warning in caught
|
|
33
|
+
if issubclass(warning.category, SyntaxWarning)
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def run(
|
|
37
|
+
self,
|
|
38
|
+
code: str,
|
|
39
|
+
*,
|
|
40
|
+
frontmatter: Mapping[str, Any],
|
|
41
|
+
env: Mapping[str, str],
|
|
42
|
+
cwd: Path,
|
|
43
|
+
output: Callable[[str], None],
|
|
44
|
+
) -> int:
|
|
45
|
+
python = frontmatter.get("python")
|
|
46
|
+
command = shlex.split(python) if isinstance(python, str) else [sys.executable]
|
|
47
|
+
# A file rather than -c, so any interpreter command works, e.g. `uv run python`.
|
|
48
|
+
with tempfile.TemporaryDirectory(prefix="rundot-") as directory:
|
|
49
|
+
script = Path(directory) / "block.py"
|
|
50
|
+
script.write_text(code, encoding="utf-8")
|
|
51
|
+
return stream([*command, str(script)], env=env, cwd=cwd, output=output)
|
rundot/backends/shell.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rundot.backends import Problem, stream
|
|
10
|
+
from rundot.inputs import PLACEHOLDER
|
|
11
|
+
|
|
12
|
+
# How bash -n reports each problem, e.g. "bash: line 3: syntax error near unexpected token `fi'".
|
|
13
|
+
_BASH_ERROR = re.compile(r"^\S*bash: line (\d+): (.*)$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ShellBackend:
|
|
17
|
+
"""Runs each block as one bash script that stops at the first failing command."""
|
|
18
|
+
|
|
19
|
+
name = "shell"
|
|
20
|
+
languages = ("sh", "bash", "shell")
|
|
21
|
+
|
|
22
|
+
def validate(self, code: str) -> list[Problem]:
|
|
23
|
+
# Bash reads <NAME> as a redirect, so swap each placeholder for its bare name, which is a plain word
|
|
24
|
+
# wherever it appears.
|
|
25
|
+
script = PLACEHOLDER.sub(lambda match: match.group(1), code)
|
|
26
|
+
return _bash_errors(script) or _shellcheck_warnings(script)
|
|
27
|
+
|
|
28
|
+
def run(
|
|
29
|
+
self,
|
|
30
|
+
code: str,
|
|
31
|
+
*,
|
|
32
|
+
frontmatter: Mapping[str, Any],
|
|
33
|
+
env: Mapping[str, str],
|
|
34
|
+
cwd: Path,
|
|
35
|
+
output: Callable[[str], None],
|
|
36
|
+
) -> int:
|
|
37
|
+
# The flags match `set -eo pipefail` without adding a line, so the script bash runs is exactly the
|
|
38
|
+
# block. The last argument is $0, which bash puts at the start of its error messages.
|
|
39
|
+
return stream(["bash", "-e", "-o", "pipefail", "-c", code, "rundot"], env=env, cwd=cwd, output=output)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _bash_errors(script: str) -> list[Problem]:
|
|
43
|
+
# The script goes in on stdin because bash -c numbers lines from 0 on macOS.
|
|
44
|
+
result = subprocess.run(["bash", "-n"], input=script, capture_output=True, text=True, errors="replace")
|
|
45
|
+
if result.returncode == 0:
|
|
46
|
+
return []
|
|
47
|
+
problems = []
|
|
48
|
+
for line in result.stderr.splitlines():
|
|
49
|
+
if match := _BASH_ERROR.match(line):
|
|
50
|
+
number, message = int(match.group(1)), match.group(2)
|
|
51
|
+
# After a syntax error bash prints the offending source line in quotes, which adds nothing.
|
|
52
|
+
if problems and problems[-1].line == number and message.startswith("`") and message.endswith("'"):
|
|
53
|
+
continue
|
|
54
|
+
problems.append(Problem(message, line=number))
|
|
55
|
+
elif line:
|
|
56
|
+
problems.append(Problem(line))
|
|
57
|
+
return problems or [Problem(f"bash -n failed with exit code {result.returncode}")]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _shellcheck() -> str | None:
|
|
61
|
+
"""Where shellcheck is installed, or None if it isn't."""
|
|
62
|
+
return shutil.which("shellcheck")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _shellcheck_warnings(script: str) -> list[Problem]:
|
|
66
|
+
"""shellcheck's findings, all as warnings, or nothing if shellcheck isn't installed."""
|
|
67
|
+
shellcheck = _shellcheck()
|
|
68
|
+
if shellcheck is None:
|
|
69
|
+
return []
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
[shellcheck, "--shell=bash", "--format=json", "-"], input=script, capture_output=True, text=True
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
findings = json.loads(result.stdout)
|
|
75
|
+
except json.JSONDecodeError:
|
|
76
|
+
return [Problem(f"shellcheck failed: {result.stderr.strip() or result.stdout.strip()}", warning=True)]
|
|
77
|
+
return [
|
|
78
|
+
Problem(f"SC{finding['code']}: {finding['message']}", line=finding["line"], warning=True)
|
|
79
|
+
for finding in findings
|
|
80
|
+
]
|