runon 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.
runon/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """runon — run shell programs locally, on one host, or across a group."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .errors import (
6
+ ConfigError,
7
+ ProgramInvalid,
8
+ RunonError,
9
+ UnknownGroup,
10
+ UnknownHost,
11
+ UnknownProgram,
12
+ )
13
+ from .inventory import Group, Host, Inventory
14
+ from .program import Program, Workspace
15
+ from .transport import FakeTransport, LocalTransport, Result, SSHTransport, Transport
16
+
17
+ __all__ = [
18
+ "ConfigError",
19
+ "FakeTransport",
20
+ "Group",
21
+ "Host",
22
+ "Inventory",
23
+ "LocalTransport",
24
+ "Program",
25
+ "ProgramInvalid",
26
+ "Result",
27
+ "RunonError",
28
+ "SSHTransport",
29
+ "Transport",
30
+ "UnknownGroup",
31
+ "UnknownHost",
32
+ "UnknownProgram",
33
+ "Workspace",
34
+ "__version__",
35
+ ]
runon/cli.py ADDED
@@ -0,0 +1,267 @@
1
+ """The command surface.
2
+
3
+ Three scopes — local, host, group — sharing the same verbs, because where the
4
+ work happens and what the work is are separate questions and the CLI should not
5
+ tangle them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__, inventory, runner
15
+ from . import program as program_mod
16
+ from .errors import RunonError
17
+ from .picker import choose
18
+ from .program import Workspace
19
+ from .report import emit
20
+ from .transport import LocalTransport, Result, SSHTransport
21
+
22
+ REMOTE_VERBS = ("copy", "copy-program", "run-program", "copy-run-program")
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog="runon",
28
+ description="Run shell programs on your machine, one server, or a named group.",
29
+ )
30
+ parser.add_argument("--version", action="version", version=f"runon {__version__}")
31
+ parser.add_argument(
32
+ "-C", "--directory", type=Path, help="workspace to use (default: search up)"
33
+ )
34
+ parser.add_argument("--inventory", type=Path, help="inventory file (default: search up)")
35
+ sub = parser.add_subparsers(dest="scope", required=True)
36
+
37
+ # -- local ---------------------------------------------------------------
38
+ local = sub.add_parser("local", help="run on this machine")
39
+ local_sub = local.add_subparsers(dest="verb", required=True)
40
+ run_local = local_sub.add_parser("run-program", help="run a program here")
41
+ _add_program_args(run_local)
42
+ layout = local_sub.add_parser("run-layout", help="open a predefined terminal layout")
43
+ layout.add_argument("--layout", "-l", help="layout name (prompts if omitted)")
44
+
45
+ # -- host ----------------------------------------------------------------
46
+ host = sub.add_parser("host", help="run on one machine")
47
+ host.add_argument("--host", "-H", required=True, help="inventory name, or user@address")
48
+ _add_remote_verbs(host)
49
+
50
+ # -- group ---------------------------------------------------------------
51
+ group = sub.add_parser("group", help="run on every machine in a group")
52
+ group.add_argument("--group", "-g", required=True, help="group name from the inventory")
53
+ group.add_argument("--parallel", "-j", type=int, default=1, help="hosts at once (default 1)")
54
+ _add_remote_verbs(group)
55
+
56
+ # -- list / init / new ---------------------------------------------------
57
+ listing = sub.add_parser("list", help="show what is available")
58
+ listing.add_argument(
59
+ "what", choices=["programs", "hosts", "groups", "layouts"], nargs="?", default="programs"
60
+ )
61
+ init = sub.add_parser("init", help="scaffold a workspace here")
62
+ init.add_argument("--force", action="store_true", help="write into a non-empty directory")
63
+ new = sub.add_parser("new-program", help="create a program from the template")
64
+ new.add_argument("name")
65
+
66
+ return parser
67
+
68
+
69
+ def _add_program_args(parser: argparse.ArgumentParser) -> None:
70
+ parser.add_argument("--program", "-p", help="program name (prompts if omitted)")
71
+ parser.add_argument("--verbose", "-v", action="store_true", help="show output from successes")
72
+ parser.add_argument("--dry-run", action="store_true", help="print what would happen")
73
+ parser.add_argument("args", nargs="*", help="arguments passed through to main.sh")
74
+
75
+
76
+ def _add_remote_verbs(parser: argparse.ArgumentParser) -> None:
77
+ verbs = parser.add_subparsers(dest="verb", required=True)
78
+ for verb in REMOTE_VERBS:
79
+ sub = verbs.add_parser(verb, help=_verb_help(verb))
80
+ if verb == "copy":
81
+ sub.add_argument("--local-dir", type=Path, required=True)
82
+ sub.add_argument("--remote-dir", required=True)
83
+ sub.add_argument("--verbose", "-v", action="store_true")
84
+ sub.add_argument("--dry-run", action="store_true")
85
+ else:
86
+ _add_program_args(sub)
87
+
88
+
89
+ def _verb_help(verb: str) -> str:
90
+ return {
91
+ "copy": "copy a local file or directory to the target(s)",
92
+ "copy-program": "copy a program (and the functions library) to the target(s)",
93
+ "run-program": "run an already-copied program on the target(s)",
94
+ "copy-run-program": "copy and run in one step",
95
+ }[verb]
96
+
97
+
98
+ def main(argv: list[str] | None = None) -> int:
99
+ parser = build_parser()
100
+ args = parser.parse_args(argv)
101
+ try:
102
+ return _dispatch(args)
103
+ except RunonError as exc:
104
+ # Expected failures print what went wrong, not where in our code it did.
105
+ print(f"runon: {exc}", file=sys.stderr)
106
+ return 2
107
+ except KeyboardInterrupt:
108
+ print("\ninterrupted", file=sys.stderr)
109
+ return 130
110
+
111
+
112
+ def _dispatch(args: argparse.Namespace) -> int:
113
+ start = args.directory or Path.cwd()
114
+ workspace = program_mod.find_workspace(start) or Workspace(root=start)
115
+ inv = inventory.load(args.inventory, start=start)
116
+
117
+ if args.scope == "init":
118
+ return _init(start, force=args.force)
119
+ if args.scope == "new-program":
120
+ return _new_program(workspace, args.name)
121
+ if args.scope == "list":
122
+ return _list(workspace, inv, args.what)
123
+ if args.scope == "local":
124
+ return _local(workspace, args)
125
+ return _remote(workspace, inv, args)
126
+
127
+
128
+ def _resolve_program(workspace: Workspace, name: str | None):
129
+ # An explicitly empty --program is almost always an unset shell variable.
130
+ # Falling through to the picker would make a scripted run do nothing and
131
+ # still report success, so it is refused instead.
132
+ if name is not None and not name.strip():
133
+ raise RunonError("--program was given an empty value")
134
+
135
+ programs = workspace.programs()
136
+ # Checked before the name is looked up: someone who has not set up a
137
+ # workspace yet needs to be told that, not told their program is missing
138
+ # from an empty list.
139
+ if not programs:
140
+ raise RunonError(
141
+ f"no programs found under {workspace.programs_path}\n\n"
142
+ "Run 'runon init' here to scaffold a workspace, or use -C to point at one."
143
+ )
144
+ if name:
145
+ return workspace.program(program_mod.validate_name(name))
146
+ return choose(programs)
147
+
148
+
149
+ def _local(workspace: Workspace, args) -> int:
150
+ host = inventory.Host(name="local", address="localhost")
151
+ transport = LocalTransport()
152
+
153
+ if args.verb == "run-layout":
154
+ layouts = workspace.layouts()
155
+ if not layouts:
156
+ raise RunonError(f"no layouts found under {workspace.layouts_path}")
157
+ if args.layout:
158
+ chosen = next((lay for lay in layouts if lay.name == args.layout), None)
159
+ if chosen is None:
160
+ raise RunonError(f"no layout named {args.layout!r}")
161
+ else:
162
+ chosen = choose(layouts)
163
+ if chosen is None:
164
+ return 0
165
+ script = workspace.layouts_path / f"{chosen.name}.sh"
166
+ result = transport.run(host, f"sh {script}")
167
+ return emit([result], verbose=True)
168
+
169
+ program = _resolve_program(workspace, args.program)
170
+ if program is None:
171
+ return 0
172
+ if args.dry_run:
173
+ print(f"would run {program.name} on the local machine")
174
+ return 0
175
+ result = runner.run_program(transport, host, workspace, program, args=args.args, remote=False)
176
+ return emit([result], verbose=args.verbose)
177
+
178
+
179
+ def _remote(workspace: Workspace, inv: inventory.Inventory, args) -> int:
180
+ if args.scope == "host":
181
+ hosts = [inv.host(args.host)]
182
+ parallel = 1
183
+ else:
184
+ hosts = inv.group(args.group)
185
+ parallel = max(1, args.parallel)
186
+ if not hosts:
187
+ raise RunonError("no hosts selected")
188
+
189
+ transport = SSHTransport()
190
+
191
+ if args.verb == "copy":
192
+ if args.dry_run:
193
+ _print_plan(hosts, f"copy {args.local_dir} -> {args.remote_dir}")
194
+ return 0
195
+ results = runner.fan_out(
196
+ hosts, lambda h: transport.copy(h, args.local_dir, args.remote_dir), parallel=parallel
197
+ )
198
+ return emit(results, verbose=args.verbose)
199
+
200
+ program = _resolve_program(workspace, args.program)
201
+ if program is None:
202
+ return 0
203
+ if args.dry_run:
204
+ _print_plan(hosts, f"{args.verb} {program.name}")
205
+ return 0
206
+
207
+ def work(host) -> Result | list[Result]:
208
+ if args.verb == "copy-program":
209
+ return runner.copy_program(transport, host, workspace, program)
210
+ if args.verb == "run-program":
211
+ return runner.run_program(transport, host, workspace, program, args=args.args)
212
+ copied = runner.copy_program(transport, host, workspace, program)
213
+ failed = [r for r in copied if not r.ok]
214
+ if failed:
215
+ return failed[0]
216
+ return runner.run_program(transport, host, workspace, program, args=args.args)
217
+
218
+ results = runner.fan_out(hosts, work, parallel=parallel)
219
+ return emit(results, verbose=args.verbose)
220
+
221
+
222
+ def _print_plan(hosts, action: str) -> None:
223
+ print(f"would {action} on:")
224
+ for host in hosts:
225
+ print(f" {host.name} ({host.ssh_target})")
226
+
227
+
228
+ def _list(workspace: Workspace, inv: inventory.Inventory, what: str) -> int:
229
+ if what == "programs":
230
+ items = workspace.programs()
231
+ if not items:
232
+ print(f"no programs under {workspace.programs_path}")
233
+ print("Run 'runon init' here to scaffold a workspace.")
234
+ return 0
235
+ width = max(len(p.name) for p in items)
236
+ for p in items:
237
+ print(f" {p.name:<{width}} {p.description}".rstrip())
238
+ elif what == "layouts":
239
+ for lay in workspace.layouts():
240
+ print(f" {lay.name}")
241
+ elif what == "hosts":
242
+ for name, host in sorted(inv.hosts.items()):
243
+ print(f" {name:<20} {host.ssh_target}")
244
+ else:
245
+ for name, grp in sorted(inv.groups.items()):
246
+ print(f" {name:<20} {', '.join(grp.hosts)}")
247
+ return 0
248
+
249
+
250
+ def _init(root: Path, *, force: bool) -> int:
251
+ from .scaffold import write_workspace
252
+
253
+ created = write_workspace(root, force=force)
254
+ for path in created:
255
+ print(f" created {path.relative_to(root)}")
256
+ print("\nTry: runon list programs")
257
+ print(" runon local run-program --program hello-world")
258
+ return 0
259
+
260
+
261
+ def _new_program(workspace: Workspace, name: str) -> int:
262
+ from .scaffold import write_program
263
+
264
+ program_mod.validate_name(name)
265
+ path = write_program(workspace.programs_path, name)
266
+ print(f" created {path}")
267
+ return 0
runon/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Errors a user can act on.
2
+
3
+ Every one of these is printed without a traceback, because a stack trace tells
4
+ an operator nothing they can use about a typo'd host name.
5
+ """
6
+
7
+
8
+ class RunonError(Exception):
9
+ """Base for anything we expect and can explain."""
10
+
11
+
12
+ class ConfigError(RunonError):
13
+ pass
14
+
15
+
16
+ class UnknownHost(RunonError):
17
+ pass
18
+
19
+
20
+ class UnknownGroup(RunonError):
21
+ pass
22
+
23
+
24
+ class UnknownProgram(RunonError):
25
+ pass
26
+
27
+
28
+ class ProgramInvalid(RunonError):
29
+ pass
30
+
31
+
32
+ class RemoteFailure(RunonError):
33
+ pass
runon/inventory.py ADDED
@@ -0,0 +1,142 @@
1
+ """Who the hosts are, and which groups they belong to.
2
+
3
+ One TOML file rather than a directory tree of JSON: an operator should be able
4
+ to read the whole inventory in one screen and diff it in a review.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tomllib
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from .errors import ConfigError, UnknownGroup, UnknownHost
14
+
15
+ DEFAULT_FILENAME = "inventory.toml"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Host:
20
+ """A machine runon can reach.
21
+
22
+ ``address`` is handed to ssh untouched, so anything ssh understands works:
23
+ a bare hostname, user@host, or a Host alias out of ~/.ssh/config.
24
+ """
25
+
26
+ name: str
27
+ address: str
28
+ port: int | None = None
29
+ user: str | None = None
30
+ #: Free-form, exported to programs as RUNON_VAR_<KEY>.
31
+ vars: dict[str, str] = field(default_factory=dict)
32
+
33
+ @property
34
+ def ssh_target(self) -> str:
35
+ return f"{self.user}@{self.address}" if self.user else self.address
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Group:
40
+ name: str
41
+ hosts: tuple[str, ...]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Inventory:
46
+ hosts: dict[str, Host]
47
+ groups: dict[str, Group]
48
+ source: Path | None = None
49
+
50
+ def host(self, name: str) -> Host:
51
+ """Resolves a name, falling back to treating it as a literal address.
52
+
53
+ This is what collapses the original tool's separate "named instance" and
54
+ "ad-hoc target" commands into one: `--host web-1` uses the inventory,
55
+ `--host root@10.0.0.4` just works, and neither needs its own verb.
56
+ """
57
+ if name in self.hosts:
58
+ return self.hosts[name]
59
+ if _looks_like_address(name):
60
+ return Host(name=name, address=name)
61
+ raise UnknownHost(
62
+ f"no host named {name!r} in the inventory, and it does not look like an address.\n"
63
+ f"Known hosts: {', '.join(sorted(self.hosts)) or '(none)'}"
64
+ )
65
+
66
+ def group(self, name: str) -> list[Host]:
67
+ if name not in self.groups:
68
+ raise UnknownGroup(
69
+ f"no group named {name!r}.\n"
70
+ f"Known groups: {', '.join(sorted(self.groups)) or '(none)'}"
71
+ )
72
+ return [self.host(h) for h in self.groups[name].hosts]
73
+
74
+
75
+ def _looks_like_address(value: str) -> bool:
76
+ """True for things ssh would accept but an inventory lookup missed."""
77
+ return "@" in value or "." in value or value == "localhost"
78
+
79
+
80
+ def load(path: Path | None = None, *, start: Path | None = None) -> Inventory:
81
+ """Reads an inventory, or returns an empty one when there is no file.
82
+
83
+ An absent inventory is not an error: running programs on the local machine
84
+ needs no hosts at all, and requiring a config file to do that would be
85
+ friction for no reason.
86
+ """
87
+ if path is None:
88
+ path = find(start or Path.cwd())
89
+ if path is None:
90
+ return Inventory(hosts={}, groups={})
91
+ if not path.is_file():
92
+ raise ConfigError(f"inventory file not found: {path}")
93
+
94
+ try:
95
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
96
+ except tomllib.TOMLDecodeError as exc:
97
+ raise ConfigError(f"{path} is not valid TOML: {exc}") from exc
98
+
99
+ return _parse(raw, path)
100
+
101
+
102
+ def find(start: Path) -> Path | None:
103
+ """Walks up from `start` looking for an inventory, like git finds .git."""
104
+ for directory in [start, *start.parents]:
105
+ candidate = directory / DEFAULT_FILENAME
106
+ if candidate.is_file():
107
+ return candidate
108
+ return None
109
+
110
+
111
+ def _parse(raw: dict, source: Path) -> Inventory:
112
+ hosts: dict[str, Host] = {}
113
+ for name, spec in (raw.get("hosts") or {}).items():
114
+ if not isinstance(spec, dict):
115
+ raise ConfigError(f"{source}: host {name!r} must be a table")
116
+ address = spec.get("address", name)
117
+ port = spec.get("port")
118
+ if port is not None and not isinstance(port, int):
119
+ raise ConfigError(f"{source}: host {name!r} has a non-integer port")
120
+ hosts[name] = Host(
121
+ name=name,
122
+ address=str(address),
123
+ port=port,
124
+ user=spec.get("user"),
125
+ vars={str(k): str(v) for k, v in (spec.get("vars") or {}).items()},
126
+ )
127
+
128
+ groups: dict[str, Group] = {}
129
+ for name, spec in (raw.get("groups") or {}).items():
130
+ members = spec.get("hosts") if isinstance(spec, dict) else spec
131
+ if not isinstance(members, list) or not all(isinstance(m, str) for m in members):
132
+ raise ConfigError(f"{source}: group {name!r} must list host names")
133
+ # Catching this here means a typo in a group fails before runon has
134
+ # half-finished a rollout across the hosts it could resolve.
135
+ unknown = [m for m in members if m not in hosts and not _looks_like_address(m)]
136
+ if unknown:
137
+ raise ConfigError(
138
+ f"{source}: group {name!r} refers to unknown hosts: {', '.join(unknown)}"
139
+ )
140
+ groups[name] = Group(name=name, hosts=tuple(members))
141
+
142
+ return Inventory(hosts=hosts, groups=groups, source=source)
runon/picker.py ADDED
@@ -0,0 +1,39 @@
1
+ """Choosing a program when you did not name one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .program import Program
8
+
9
+
10
+ def choose(programs: list[Program], *, stream=None, prompt_stream=None) -> Program | None:
11
+ """Numbered menu on stderr, so stdout stays pipeable.
12
+
13
+ Returns None if the user aborts, which the caller treats as a clean exit
14
+ rather than an error — changing your mind is not a failure.
15
+ """
16
+ stream = stream or sys.stdin
17
+ out = prompt_stream or sys.stderr
18
+
19
+ if not programs:
20
+ return None
21
+ if len(programs) == 1:
22
+ return programs[0]
23
+
24
+ for index, program in enumerate(programs, 1):
25
+ suffix = f" — {program.description}" if program.description else ""
26
+ print(f" {index:>2}. {program.name}{suffix}", file=out)
27
+ print("", file=out)
28
+
29
+ while True:
30
+ print(f"Select 1-{len(programs)} (or blank to cancel): ", end="", file=out, flush=True)
31
+ raw = stream.readline()
32
+ if not raw:
33
+ return None
34
+ raw = raw.strip()
35
+ if raw == "":
36
+ return None
37
+ if raw.isdigit() and 1 <= int(raw) <= len(programs):
38
+ return programs[int(raw) - 1]
39
+ print(f" not a choice: {raw!r}", file=out)
runon/program.py ADDED
@@ -0,0 +1,127 @@
1
+ """Programs and functions: the unit of work, and the reason the tool is small.
2
+
3
+ A program is a directory with a ``main.sh``. Adding a capability means adding a
4
+ directory — the tool itself never changes. That is the whole design: the CLI
5
+ knows how to *reach* machines, and shell knows what to *do* on them, and neither
6
+ has to learn the other's job.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from .errors import ProgramInvalid, UnknownProgram
16
+
17
+ ENTRY_POINT = "main.sh"
18
+ PROGRAMS_DIR = "programs"
19
+ FUNCTIONS_DIR = "functions"
20
+ LAYOUTS_DIR = "layouts"
21
+
22
+ _NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Program:
27
+ name: str
28
+ path: Path
29
+
30
+ @property
31
+ def entry_point(self) -> Path:
32
+ return self.path / ENTRY_POINT
33
+
34
+ @property
35
+ def description(self) -> str:
36
+ """The first ``# comment`` line of main.sh, if there is one.
37
+
38
+ Cheap convention, no metadata file: a program that describes itself in a
39
+ comment is a program whose description cannot drift out of date.
40
+ """
41
+ try:
42
+ for line in self.entry_point.read_text(encoding="utf-8").splitlines()[:10]:
43
+ stripped = line.strip()
44
+ if stripped.startswith("#!"):
45
+ continue
46
+ if stripped.startswith("#"):
47
+ text = stripped.lstrip("#").strip()
48
+ if text:
49
+ return text
50
+ elif stripped:
51
+ break
52
+ except OSError:
53
+ pass
54
+ return ""
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Workspace:
59
+ """The directory holding programs/, functions/ and layouts/."""
60
+
61
+ root: Path
62
+
63
+ @property
64
+ def programs_path(self) -> Path:
65
+ return self.root / PROGRAMS_DIR
66
+
67
+ @property
68
+ def functions_path(self) -> Path:
69
+ return self.root / FUNCTIONS_DIR
70
+
71
+ @property
72
+ def layouts_path(self) -> Path:
73
+ return self.root / LAYOUTS_DIR
74
+
75
+ def programs(self) -> list[Program]:
76
+ if not self.programs_path.is_dir():
77
+ return []
78
+ found = [
79
+ Program(name=child.name, path=child)
80
+ for child in sorted(self.programs_path.iterdir())
81
+ if child.is_dir() and (child / ENTRY_POINT).is_file()
82
+ ]
83
+ return found
84
+
85
+ def program(self, name: str) -> Program:
86
+ for program in self.programs():
87
+ if program.name == name:
88
+ return program
89
+ available = ", ".join(p.name for p in self.programs()) or "(none)"
90
+ raise UnknownProgram(f"no program named {name!r}.\nAvailable: {available}")
91
+
92
+ def functions(self) -> list[Path]:
93
+ if not self.functions_path.is_dir():
94
+ return []
95
+ return sorted(p for p in self.functions_path.iterdir() if p.suffix == ".sh")
96
+
97
+ def layouts(self) -> list[Program]:
98
+ """Layouts are programs too — a layout is just a script that opens panes."""
99
+ if not self.layouts_path.is_dir():
100
+ return []
101
+ return [
102
+ Program(name=child.stem, path=self.layouts_path)
103
+ for child in sorted(self.layouts_path.iterdir())
104
+ if child.suffix == ".sh"
105
+ ]
106
+
107
+
108
+ def find_workspace(start: Path) -> Workspace | None:
109
+ """Walks up looking for a directory containing programs/."""
110
+ for directory in [start, *start.parents]:
111
+ if (directory / PROGRAMS_DIR).is_dir():
112
+ return Workspace(root=directory)
113
+ return None
114
+
115
+
116
+ def validate_name(name: str) -> str:
117
+ """Program names become path components and shell arguments.
118
+
119
+ Rejecting the awkward ones here means nothing downstream has to quote
120
+ defensively, and `--program ../../etc` is refused rather than resolved.
121
+ """
122
+ if not _NAME.match(name):
123
+ raise ProgramInvalid(
124
+ f"{name!r} is not a valid program name. "
125
+ "Use letters, digits, dot, dash or underscore, starting with a letter or digit."
126
+ )
127
+ return name
runon/report.py ADDED
@@ -0,0 +1,42 @@
1
+ """Printing results in a way an operator can scan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Iterable
7
+
8
+ from .transport import Result
9
+
10
+
11
+ def emit(results: Iterable[Result], *, verbose: bool = False, stream=None) -> int:
12
+ """Prints one line per host and returns the exit code for the whole run.
13
+
14
+ Non-zero if any host failed, because a rollout that worked on nine of ten
15
+ machines has not worked.
16
+ """
17
+ stream = stream or sys.stdout
18
+ results = list(results)
19
+ failures = 0
20
+
21
+ for result in results:
22
+ status = "ok" if result.ok else f"FAILED ({result.exit_code})"
23
+ print(f"{result.host:<24} {status}", file=stream)
24
+ if result.ok and verbose and result.stdout.strip():
25
+ _indent(result.stdout, stream)
26
+ if not result.ok:
27
+ failures += 1
28
+ # Always show why a failure failed. Needing a flag to see the error
29
+ # means running it twice.
30
+ if result.stderr.strip():
31
+ _indent(result.stderr, stream)
32
+ elif result.stdout.strip():
33
+ _indent(result.stdout, stream)
34
+
35
+ if len(results) > 1:
36
+ print(f"\n{len(results) - failures}/{len(results)} ok", file=stream)
37
+ return 1 if failures else 0
38
+
39
+
40
+ def _indent(text: str, stream) -> None:
41
+ for line in text.rstrip().splitlines():
42
+ print(f" {line}", file=stream)