paulrun 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.
paulrun/__init__.py ADDED
File without changes
@@ -0,0 +1,178 @@
1
+ import importlib.metadata
2
+ import os
3
+ import signal
4
+ import subprocess
5
+ from collections.abc import Callable, Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from importlib.metadata import EntryPoint
8
+ from pathlib import Path
9
+ from typing import Any, Protocol
10
+
11
+ ENTRY_POINT_GROUP = "paulrun.backends"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Problem:
16
+ """Something a backend found wrong with a block."""
17
+
18
+ message: str
19
+ line: int | None = None # line in the block, counting its first line as 1; None if it's about the whole block
20
+ warning: bool = False # warnings are reported but don't stop the runbook running
21
+
22
+
23
+ class Backend(Protocol):
24
+ """Runs `run` blocks for one or more languages.
25
+
26
+ Backends are classes registered under the `paulrun.backends` entry point group. Each is created
27
+ once, with no arguments, when backends are loaded.
28
+ """
29
+
30
+ # Read-only, so a backend can set them as plain class attributes.
31
+ @property
32
+ def name(self) -> str: ...
33
+
34
+ @property
35
+ def languages(self) -> tuple[str, ...]:
36
+ """The fence languages this backend runs, e.g. ("sh", "bash")."""
37
+
38
+ def validate(self, code: str) -> list[Problem]:
39
+ """Return the problems found in a block, without running it. An empty list means it's fine.
40
+
41
+ code is the block as written, so it can contain <NAME> placeholders. Replacing them with
42
+ something this language accepts is up to the backend. Any error stops the runbook running;
43
+ warnings are only reported.
44
+ """
45
+
46
+ def run(
47
+ self,
48
+ code: str,
49
+ *,
50
+ frontmatter: Mapping[str, Any],
51
+ env: Mapping[str, str],
52
+ cwd: Path,
53
+ output: Callable[[str], None],
54
+ ) -> int:
55
+ """Run a block and return its exit code, where 0 means it succeeded.
56
+
57
+ Call output with each line the block prints, without its newline, as soon as it's printed.
58
+ Don't print anything directly; paulrun owns the terminal. frontmatter is the runbook's
59
+ frontmatter, read-only. env is the block's complete environment and cwd is where it runs.
60
+ """
61
+
62
+
63
+ class Interrupted(KeyboardInterrupt):
64
+ """Ctrl-C was pressed while a block ran. The block has exited, with exit_code."""
65
+
66
+ def __init__(self, exit_code: int):
67
+ super().__init__(exit_code)
68
+ self.exit_code = exit_code
69
+
70
+
71
+ def stream(command: Sequence[str], *, env: Mapping[str, str], cwd: Path, output: Callable[[str], None]) -> int:
72
+ """Run command, passing each line it prints to output as it's printed, and return its exit code.
73
+
74
+ stdout and stderr are merged, so lines keep the order they were printed in. The command shares
75
+ paulrun's terminal, so it can still ask for a password on it, as git, ssh and gpg do. This is how
76
+ the built-in backends run blocks, and other backends can use it too.
77
+
78
+ Ctrl-C reaches the command as well as paulrun. On the first press, paulrun waits for the command to
79
+ stop, still passing on what it prints. A second press kills it and everything it started, and stops
80
+ reading, so nothing can keep paulrun waiting. Either way, once the command has exited, Interrupted
81
+ is raised with its exit code.
82
+ """
83
+ interrupted = False
84
+ with subprocess.Popen(command, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as process:
85
+ assert process.stdout is not None # set because stdout is a pipe
86
+ while True:
87
+ try:
88
+ # Read bytes so only newlines split lines; text mode would also split on carriage returns.
89
+ for raw in process.stdout:
90
+ output(raw.decode("utf-8", errors="replace").removesuffix("\n"))
91
+ break
92
+ except KeyboardInterrupt:
93
+ if not interrupted:
94
+ # The terminal sent Ctrl-C to the command too, so let it stop in its own way.
95
+ interrupted = True
96
+ continue
97
+ _kill_tree(process.pid)
98
+ # Stop reading, so nothing still holding the output open can keep paulrun waiting.
99
+ process.stdout.close()
100
+ break
101
+ process.wait()
102
+ if interrupted:
103
+ raise Interrupted(process.returncode)
104
+ return process.returncode
105
+
106
+
107
+ def _kill_tree(pid: int) -> None:
108
+ """Kill a process and every process it started, however deep.
109
+
110
+ Each one is stopped first, so none can start another between being found and being killed.
111
+ """
112
+ found = [pid]
113
+ for parent in found:
114
+ _signal(parent, signal.SIGSTOP)
115
+ found.extend(child for child in _children(parent) if child not in found)
116
+ for process in found:
117
+ _signal(process, signal.SIGKILL)
118
+
119
+
120
+ def _children(pid: int) -> list[int]:
121
+ """The processes pid started that are still running."""
122
+ listing = subprocess.run(["ps", "-A", "-o", "pid=", "-o", "ppid="], capture_output=True, text=True).stdout
123
+ return [int(child) for child, parent in (row.split() for row in listing.splitlines()) if int(parent) == pid]
124
+
125
+
126
+ def _signal(pid: int, signal_number: int) -> None:
127
+ """Signal a process, unless it has already gone."""
128
+ try:
129
+ os.kill(pid, signal_number)
130
+ except ProcessLookupError:
131
+ pass
132
+
133
+
134
+ class BackendError(Exception):
135
+ """Backends can't be loaded, e.g. two of them claim the same language."""
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class LoadedBackend:
140
+ backend: Backend
141
+ entry_point: EntryPoint # where it was registered, e.g. paulrun's own pyproject.toml or another package's
142
+
143
+
144
+ def installed_entry_points() -> Iterable[EntryPoint]:
145
+ """Every backend registered under the paulrun.backends group by an installed package, paulrun included."""
146
+ return importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
147
+
148
+
149
+ def load_backend_sources(entry_points: Iterable[EntryPoint] | None = None) -> list[LoadedBackend]:
150
+ """Load each backend once, with where it came from, from the installed entry points unless others are given.
151
+
152
+ Raises BackendError if two backends claim the same language.
153
+ """
154
+ if entry_points is None:
155
+ entry_points = installed_entry_points()
156
+ loaded: list[LoadedBackend] = []
157
+ claimed: dict[str, LoadedBackend] = {}
158
+ for entry_point in entry_points:
159
+ source = LoadedBackend(entry_point.load()(), entry_point)
160
+ for language in source.backend.languages:
161
+ if language in claimed:
162
+ other = claimed[language]
163
+ raise BackendError(
164
+ f"language {language!r} is claimed by both {other.backend.name!r} ({other.entry_point.value}) "
165
+ f"and {source.backend.name!r} ({entry_point.value})"
166
+ )
167
+ claimed[language] = source
168
+ loaded.append(source)
169
+ return loaded
170
+
171
+
172
+ def load_backends(entry_points: Iterable[EntryPoint] | None = None) -> dict[str, Backend]:
173
+ """Load backends keyed by language, from the installed entry points unless others are given."""
174
+ return {
175
+ language: source.backend
176
+ for source in load_backend_sources(entry_points)
177
+ for language in source.backend.languages
178
+ }
@@ -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 paulrun.backends import Problem, stream
10
+ from paulrun.inputs import PLACEHOLDER
11
+
12
+
13
+ class PythonBackend:
14
+ """Runs each block as a Python script, with the runbook's python command or paulrun'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 paulrun'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="paulrun-") 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 paulrun.backends import Problem, stream
10
+ from paulrun.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, "paulrun"], 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
+ ]
paulrun/cli.py ADDED
@@ -0,0 +1,233 @@
1
+ import sys
2
+ from contextlib import nullcontext
3
+ from dataclasses import dataclass
4
+ from importlib.metadata import EntryPoint
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+ from paulrun import runner, settings
12
+ from paulrun.backends import Backend, BackendError, load_backend_sources, load_backends
13
+ from paulrun.inputs import Input, InputError
14
+ from paulrun.runbook import Runbook, RunbookError, parse, parse_inputs, read_frontmatter
15
+ from paulrun.transcript import Transcript, TranscriptError
16
+ from paulrun.validate import Report, validate
17
+
18
+
19
+ @click.group("paulrun")
20
+ @click.version_option(settings.VERSION)
21
+ def cli() -> None:
22
+ """Run hand-written markdown runbooks."""
23
+
24
+
25
+ @cli.command()
26
+ @click.argument("runbook", type=click.Path(exists=True, dir_okay=False, path_type=Path))
27
+ @click.option("--dry", is_flag=True, help="Show every block with inputs filled in, without running anything.")
28
+ @click.option("--step", is_flag=True, help="Ask before each run block whether to run it, skip it or quit.")
29
+ def go(runbook: Path, dry: bool, step: bool) -> None:
30
+ """Run a runbook's steps in order, stopping at the first that fails."""
31
+ console = Console(highlight=False)
32
+ parsed, backends, report = _validated(runbook)
33
+ _print_report(console, runbook, report, summary_if_ok=False)
34
+ if not report.ok:
35
+ sys.exit(1)
36
+ # Nothing runs under --dry, so there's nothing to record.
37
+ transcript = Transcript.for_runbook(parsed) if parsed.output_path is not None and not dry else None
38
+
39
+ def sink(event: runner.Event) -> None:
40
+ _print_event(console, event, dry=dry)
41
+ if transcript is not None:
42
+ transcript(event)
43
+
44
+ try:
45
+ with transcript or nullcontext():
46
+ result = runner.run(parsed, backends, sink, ClickPrompter(console), dry=dry, step_by_step=step)
47
+ except (runner.RunnerError, InputError, TranscriptError) as e:
48
+ raise click.ClickException(str(e)) from e
49
+ if transcript is not None and transcript.written:
50
+ console.out(f"transcript: {transcript.path}")
51
+ if result.status not in ("ok", "dry"):
52
+ sys.exit(1)
53
+
54
+
55
+ @cli.command()
56
+ @click.argument("runbook", type=click.Path(exists=True, dir_okay=False, path_type=Path))
57
+ def check(runbook: Path) -> None:
58
+ """Check a runbook for problems without running anything. Exits 1 if there are any errors."""
59
+ console = Console(highlight=False)
60
+ _, _, report = _validated(runbook)
61
+ _print_report(console, runbook, report, summary_if_ok=True)
62
+ if not report.ok:
63
+ sys.exit(1)
64
+
65
+
66
+ @cli.command()
67
+ @click.argument("runbook", type=click.Path(exists=True, dir_okay=False, path_type=Path))
68
+ def inputs(runbook: Path) -> None:
69
+ """List the inputs a runbook needs, including ones its input blocks ask for part way through.
70
+
71
+ Frontmatter inputs are listed even if the rest of the runbook is broken.
72
+ """
73
+ console = Console(highlight=False)
74
+ try:
75
+ declared: list[tuple[Input, str | None]] = [
76
+ (input, None) for input in parse_inputs(read_frontmatter(runbook).get("inputs"))
77
+ ]
78
+ except RunbookError as e:
79
+ raise click.ClickException(str(e)) from e
80
+ declared += _input_blocks(runbook)
81
+ if not declared:
82
+ console.out("no inputs")
83
+ return
84
+ width = max(len(input.name) for input, _ in declared)
85
+ for input, step in declared:
86
+ console.out(f"{input.name:<{width}} {input.description}")
87
+ details = [f"pattern: {input.pattern}"] if input.pattern is not None else []
88
+ details += ["secret"] if input.secret else []
89
+ details += [f'asked in "{step}"'] if step is not None else []
90
+ for detail in details:
91
+ console.out(f"{'':<{width}} {detail}")
92
+
93
+
94
+ def _input_blocks(path: Path) -> list[tuple[Input, str | None]]:
95
+ """The inputs a runbook's input blocks declare, with the step each is in. Nothing if the body can't be read."""
96
+ try:
97
+ parsed = parse(path)
98
+ except RunbookError:
99
+ return []
100
+ return [(block.input, step.name) for step in parsed.steps for block in step.blocks if block.input is not None]
101
+
102
+
103
+ @cli.command("backends")
104
+ def list_backends() -> None:
105
+ """List the installed backends, the languages each runs, and the package that registered it."""
106
+ console = Console(highlight=False)
107
+ try:
108
+ sources = sorted(load_backend_sources(), key=lambda source: source.backend.name)
109
+ except BackendError as e:
110
+ raise click.ClickException(str(e)) from e
111
+ rows = [
112
+ (source.backend.name, ", ".join(source.backend.languages), _registered_by(source.entry_point))
113
+ for source in sources
114
+ ]
115
+ name_width = max((len(name) for name, _, _ in rows), default=0)
116
+ languages_width = max((len(languages) for _, languages, _ in rows), default=0)
117
+ for name, languages, registered_by in rows:
118
+ console.out(f"{name:<{name_width}} {languages:<{languages_width}} {registered_by}")
119
+
120
+
121
+ def _registered_by(entry_point: EntryPoint) -> str:
122
+ """The entry point's target, with the package and version that registered it when that's known."""
123
+ if entry_point.dist is None:
124
+ return entry_point.value
125
+ return f"{entry_point.value} ({entry_point.dist.name} {entry_point.dist.version})"
126
+
127
+
128
+ def _validated(path: Path) -> tuple[Runbook, dict[str, Backend], Report]:
129
+ """Parse and validate a runbook, turning anything that stops it being read into a clean error."""
130
+ try:
131
+ parsed = parse(path)
132
+ backends = load_backends()
133
+ except (RunbookError, BackendError) as e:
134
+ raise click.ClickException(str(e)) from e
135
+ return parsed, backends, validate(parsed, backends)
136
+
137
+
138
+ def _print_report(console: Console, path: Path, report: Report, *, summary_if_ok: bool) -> None:
139
+ """Print each finding as path:line: kind: message, then a count. A clean report prints only if asked."""
140
+ if not report.findings:
141
+ if summary_if_ok:
142
+ console.out(f"{path}: ok", style="green")
143
+ return
144
+ for finding in report.findings:
145
+ kind, style = ("warning", "yellow") if finding.warning else ("error", "red")
146
+ console.out(f"{path}:{finding.line}: {kind}: {finding.message}", style=style)
147
+ counts = [_count(len(report.errors), "error"), _count(len(report.warnings), "warning")]
148
+ console.out()
149
+ console.out(", ".join(count for count in counts if count), style="bold")
150
+
151
+
152
+ def _count(number: int, noun: str) -> str:
153
+ return f"{number} {noun}{'' if number == 1 else 's'}" if number else ""
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class ClickPrompter:
158
+ """Prompts in the terminal, hiding what's typed for secrets."""
159
+
160
+ console: Console
161
+
162
+ def ask(self, input: Input, problem: str | None) -> str:
163
+ if problem is not None:
164
+ # console.out, because a pattern such as [a-z] would be read as markup.
165
+ self.console.out(problem, style="red")
166
+ return _prompt(f"{input.name} ({input.description})", hide_input=input.secret)
167
+
168
+ def confirm(self, question: str) -> str:
169
+ # An empty default lets enter through as "", which the runner reads as no.
170
+ return _prompt(question, default="", show_default=False, prompt_suffix=" ")
171
+
172
+
173
+ def _prompt(text: str, **options: Any) -> str:
174
+ """click.prompt, but Ctrl-C raises KeyboardInterrupt so the runner can finish the run as interrupted.
175
+
176
+ Click turns both Ctrl-C and running out of input into Abort; only Ctrl-C is turned back.
177
+ """
178
+ try:
179
+ return click.prompt(text, **options)
180
+ except click.Abort as e:
181
+ if isinstance(e.__context__, KeyboardInterrupt):
182
+ raise KeyboardInterrupt from None
183
+ raise
184
+
185
+
186
+ def _print_event(console: Console, event: runner.Event, *, dry: bool = False) -> None:
187
+ # console.out never reads markup or emoji codes and never wraps, so code and output appear as written.
188
+ match event:
189
+ case runner.Overview(title=title, steps=steps, run_blocks=run_blocks, inputs=inputs):
190
+ console.out(title, style="bold")
191
+ if inputs:
192
+ console.out("inputs: " + " ".join(f"{name}={value}" for name, value in inputs))
193
+ for number, step in enumerate(steps, start=1):
194
+ console.out(f" {number}. {step.name}")
195
+ console.out(f"{run_blocks} run block{'' if run_blocks == 1 else 's'}")
196
+ if dry:
197
+ console.out("dry run: nothing will be run", style="yellow")
198
+ case runner.StepStarted(step=step):
199
+ console.out()
200
+ console.out(f"=== {step.name} ===", style="bold")
201
+ case runner.Docstring(text=text):
202
+ for line in text.splitlines():
203
+ console.out(line)
204
+ case runner.InputBlock(input=input) if dry:
205
+ console.out(f"input: {input.name} ({input.description}), asked here", style="yellow")
206
+ case runner.Confirm(text=text):
207
+ for line in text.splitlines():
208
+ console.out(line, style="yellow")
209
+ case runner.BlockStarted(code=code):
210
+ for line in runner.code_lines(code):
211
+ console.out(line, style="cyan")
212
+ case runner.BlockSkipped():
213
+ console.out("skipped (--step)", style="yellow")
214
+ case runner.OutputLine(text=text):
215
+ console.out(text)
216
+ case runner.BlockFinished(exit_code=exit_code, duration=duration):
217
+ console.out(
218
+ f"exit {exit_code} ({runner.format_duration(duration)})", style="green" if exit_code == 0 else "red"
219
+ )
220
+ case runner.RunFinished(status="ok", duration=duration):
221
+ console.out()
222
+ console.out(f"finished: ok ({runner.format_duration(duration)})", style="green")
223
+ case runner.RunFinished(status="dry"):
224
+ console.out()
225
+ console.out("finished: dry run, nothing was run", style="yellow")
226
+ case runner.RunFinished(status="aborted" | "interrupted" as status, step=step):
227
+ console.out()
228
+ where = "before starting" if step is None else f'at "{step.name}"'
229
+ console.out(f"finished: {status} {where}", style="red")
230
+ case runner.RunFinished(step=step, exit_code=exit_code):
231
+ assert step is not None # always set when a run fails
232
+ console.out()
233
+ console.out(f'finished: failed at "{step.name}" (exit {exit_code})', style="red")
paulrun/inputs.py ADDED
@@ -0,0 +1,74 @@
1
+ import re
2
+ from collections.abc import Iterable, Mapping
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+ PLACEHOLDER = re.compile(r"<([A-Z][A-Z0-9_]*)>")
7
+
8
+ MASK = "****"
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Input:
13
+ name: str
14
+ description: str
15
+ pattern: str | None = None
16
+ secret: bool = False
17
+
18
+
19
+ class Prompter(Protocol):
20
+ """Asks the person running the runbook for values and answers."""
21
+
22
+ def ask(self, input: Input, problem: str | None) -> str:
23
+ """Ask for an input's value, hiding what's typed if it's secret.
24
+
25
+ problem is None the first time, then says why the previous answer was refused.
26
+ """
27
+
28
+ def confirm(self, question: str) -> str:
29
+ """Ask a question, e.g. "Start? [y/N]", and return the answer as typed. Pressing enter gives ""."""
30
+
31
+
32
+ class InputError(Exception):
33
+ """An input can't be collected, e.g. its value from the environment doesn't match its pattern."""
34
+
35
+
36
+ def collect(
37
+ inputs: Iterable[Input], env: Mapping[str, str], prompter: Prompter, *, skip_secrets: bool = False
38
+ ) -> dict[str, str]:
39
+ """Get a value for each input, from env if it's set there, otherwise by asking until the answer matches.
40
+
41
+ With skip_secrets, secrets are left out entirely: not read from env, not asked for, and not in the result.
42
+ """
43
+ values = {}
44
+ for input in inputs:
45
+ if skip_secrets and input.secret:
46
+ continue
47
+ # An empty variable counts as unset, so a stray `VERSION=` can't release an empty version.
48
+ if value := env.get(input.name):
49
+ if not _matches(input, value):
50
+ raise InputError(f"{input.name} from the environment must match {input.pattern}")
51
+ else:
52
+ value = prompter.ask(input, None)
53
+ while not _matches(input, value):
54
+ value = prompter.ask(input, f"{input.name} must match {input.pattern}")
55
+ values[input.name] = value
56
+ return values
57
+
58
+
59
+ def _matches(input: Input, value: str) -> bool:
60
+ return input.pattern is None or re.fullmatch(input.pattern, value) is not None
61
+
62
+
63
+ def substitute(text: str, values: Mapping[str, str]) -> str:
64
+ """Replace <NAME> placeholders with their values. Unknown names are left alone."""
65
+ return PLACEHOLDER.sub(lambda m: values.get(m.group(1), m.group(0)), text)
66
+
67
+
68
+ def mask(text: str, secrets: Iterable[str]) -> str:
69
+ """Replace every occurrence of every secret with ****."""
70
+ # Longest first, so a secret that contains another is masked whole rather than leaving its tail showing.
71
+ ordered = sorted({secret for secret in secrets if secret}, key=len, reverse=True)
72
+ if not ordered:
73
+ return text
74
+ return re.sub("|".join(re.escape(secret) for secret in ordered), MASK, text)