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 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)
@@ -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
+ ]