sshfc 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.
sshfc/core/config.py ADDED
@@ -0,0 +1,329 @@
1
+ # Copyright (C) 2026 Stephen T. Jibson.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Load the allowed command set from TOML policy files.
5
+
6
+ A policy is a single ``.toml`` file or a directory of them (read in sorted order; only ``*.toml`` names not starting
7
+ with ``.``). Each file has a flat ``[commands."<path>"]`` table per command, keyed by the full command path::
8
+
9
+ [commands.podman]
10
+ help = "Manage pods, containers and images."
11
+ run = "/usr/bin/podman"
12
+
13
+ [commands."podman ps"]
14
+ help = "List containers."
15
+ options."--format" = { help = "Go template.", go_template = ["ID", "Image"] }
16
+
17
+ Merging is order-independent: each command path is defined by one file, except that the same path may appear in
18
+ several files if every definition is identical (so independent files can share a parent such as ``podman``).
19
+ ``accept``/``reject`` examples aren't part of the definition; they're combined.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import stat
26
+ import tomllib
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import TYPE_CHECKING, Any, Final
30
+
31
+ from sshfc.core.parser import CommandRoot, SpecError
32
+ from sshfc.core.spec import Command, Flag, Option, Positional
33
+ from sshfc.core.validators import NAMED, GoTemplateFormat, Pattern
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Collection
37
+
38
+ from sshfc.core.spec import Validator
39
+
40
+ DEFAULT_PATH: Final = Path("/etc/sshfc/commands.d")
41
+
42
+ _ROOT_UID: Final = 0
43
+ _NAME: Final = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*")
44
+ _TOP_LEVEL_KEYS: Final = frozenset({"commands"})
45
+ _COMMAND_KEYS: Final = frozenset(
46
+ {"help", "run", "end_of_options", "options", "flags", "positionals", "accept", "reject"}
47
+ )
48
+ _EXAMPLE_KEYS: Final = frozenset({"accept", "reject"})
49
+ _VALIDATOR_KEYS: Final = frozenset({"validator", "pattern", "go_template"})
50
+
51
+
52
+ class ConfigError(Exception):
53
+ """Raised when the policy can't be loaded: missing, insecure, malformed, or inconsistent."""
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Policy:
58
+ """A loaded policy."""
59
+
60
+ files: list[Path]
61
+ commands: list[Command]
62
+ root: CommandRoot
63
+
64
+
65
+ @dataclass
66
+ class _Entry:
67
+ """One command path, merged across files."""
68
+
69
+ definition: dict[str, Any]
70
+ sources: list[str]
71
+ accept: list[Any] = field(default_factory=list)
72
+ reject: list[Any] = field(default_factory=list)
73
+
74
+
75
+ def policy_files(path: Path) -> list[Path]:
76
+ """Return the policy files at path (a file, or a directory of ``*.toml`` files)."""
77
+ if path.is_dir():
78
+ return sorted(p for p in path.iterdir() if p.name.endswith(".toml") and not p.name.startswith("."))
79
+ if path.is_file():
80
+ return [path]
81
+ msg = f"{path}: no such file or directory"
82
+ raise ConfigError(msg)
83
+
84
+
85
+ def check_ownership(path: Path, trusted_uids: Collection[int] = (_ROOT_UID,)) -> None:
86
+ """Check that path, its symlink target, and every ancestor directory can only be changed by a trusted user.
87
+
88
+ Each must be owned by a trusted user (root) and not writable by group or others. A world-writable ancestor is
89
+ accepted only if it has the sticky bit (e.g. /tmp), since others can't then replace what's inside it.
90
+ """
91
+ try:
92
+ real = path.resolve(strict=True)
93
+ except OSError as e:
94
+ msg = f"{path}: {e.strerror}"
95
+ raise ConfigError(msg) from e
96
+ for depth, current in enumerate([real, *real.parents]):
97
+ mode = current.stat().st_mode
98
+ if current.stat().st_uid not in trusted_uids:
99
+ msg = f"{current}: must be owned by root"
100
+ raise ConfigError(msg)
101
+ sticky_ancestor = depth > 0 and stat.S_ISDIR(mode) and mode & stat.S_ISVTX
102
+ if mode & (stat.S_IWGRP | stat.S_IWOTH) and not sticky_ancestor:
103
+ msg = f"{current}: must not be writable by group or others"
104
+ raise ConfigError(msg)
105
+
106
+
107
+ def load_policy(path: Path, *, check_owners: bool = True, trusted_uids: Collection[int] = (_ROOT_UID,)) -> Policy:
108
+ """Load, merge, and validate the policy at path; raise ConfigError on any problem."""
109
+ files = policy_files(path)
110
+ if check_owners:
111
+ check_ownership(path, trusted_uids)
112
+ for file in files:
113
+ check_ownership(file, trusted_uids)
114
+
115
+ entries: dict[str, _Entry] = {}
116
+ for file in files:
117
+ for key, table in _read(file).items():
118
+ _merge(entries, key, table, file.name)
119
+
120
+ commands = [_build(entries, key) for key in sorted(entries) if " " not in key]
121
+ for key, entry in entries.items():
122
+ parent = key.rpartition(" ")[0]
123
+ if parent and parent not in entries:
124
+ msg = f"{_where(entry, key)}: parent command {parent!r} is not defined"
125
+ raise ConfigError(msg)
126
+
127
+ try:
128
+ root = CommandRoot(commands)
129
+ except SpecError as e:
130
+ source = e.command.source if e.command is not None else None
131
+ msg = f"{source}: {e}" if source else str(e)
132
+ raise ConfigError(msg) from e
133
+ return Policy(files=files, commands=commands, root=root)
134
+
135
+
136
+ def _read(file: Path) -> dict[str, Any]:
137
+ """Read one policy file and return its commands table."""
138
+ try:
139
+ with file.open("rb") as f:
140
+ data = tomllib.load(f)
141
+ except (OSError, tomllib.TOMLDecodeError) as e:
142
+ msg = f"{file.name}: {e}"
143
+ raise ConfigError(msg) from e
144
+ _check_keys(data, _TOP_LEVEL_KEYS, file.name)
145
+ commands = data.get("commands", {})
146
+ if not isinstance(commands, dict):
147
+ msg = f"{file.name}: 'commands' must be a table"
148
+ raise ConfigError(msg)
149
+ return commands
150
+
151
+
152
+ def _merge(entries: dict[str, _Entry], key: str, table: Any, source: str) -> None: # noqa: ANN401
153
+ """Merge one file's definition of a command path into entries."""
154
+ where = f"{source}: commands.{key!r}"
155
+ names = key.split(" ")
156
+ if not all(_NAME.fullmatch(n) for n in names):
157
+ msg = f"{where}: a command path is names separated by single spaces; names use letters, digits and _.-"
158
+ raise ConfigError(msg)
159
+ if not isinstance(table, dict):
160
+ msg = f"{where}: must be a table"
161
+ raise ConfigError(msg)
162
+ definition = {k: v for k, v in table.items() if k not in _EXAMPLE_KEYS}
163
+ entry = entries.get(key)
164
+ if entry is None:
165
+ entry = entries[key] = _Entry(definition=definition, sources=[source])
166
+ elif entry.definition != definition:
167
+ msg = (
168
+ f"commands.{key!r} is defined differently in {entry.sources[0]} and {source}; "
169
+ "a command shared by several files must be identical in each"
170
+ )
171
+ raise ConfigError(msg)
172
+ else:
173
+ entry.sources.append(source)
174
+ entry.accept.extend(_list(table, "accept", where))
175
+ entry.reject.extend(_list(table, "reject", where))
176
+
177
+
178
+ def _build(entries: dict[str, _Entry], key: str) -> Command:
179
+ """Build the Command for a path, and its subcommands, from merged entries."""
180
+ entry = entries[key]
181
+ where = _where(entry, key)
182
+ table = entry.definition
183
+ _check_keys(table, _COMMAND_KEYS, where)
184
+
185
+ args: list[Option | Flag | Positional] = []
186
+ for name, arg in _table(table, "options", where).items():
187
+ arg_where = f"{where}: options.{name!r}"
188
+ required = _bool(_as_table(arg, arg_where), "required", arg_where, default=False)
189
+ help_, validator = _validated_arg({k: v for k, v in arg.items() if k != "required"}, arg_where)
190
+ args.append(Option(name=name, help=help_, validator=validator, required=required))
191
+ for name, arg in _table(table, "flags", where).items():
192
+ arg_where = f"{where}: flags.{name!r}"
193
+ _check_keys(_as_table(arg, arg_where), frozenset({"help", "required"}), arg_where)
194
+ required = _bool(arg, "required", arg_where, default=False)
195
+ args.append(Flag(name=name, help=_str(arg, "help", arg_where, required=True), required=required))
196
+ for index, arg in enumerate(_list(table, "positionals", where)):
197
+ arg_where = f"{where}: positionals[{index}]"
198
+ name = _str(_as_table(arg, arg_where), "name", arg_where, required=True)
199
+ help_, validator = _validated_arg({k: v for k, v in arg.items() if k != "name"}, arg_where)
200
+ args.append(Positional(name=name, help=help_, validator=validator))
201
+
202
+ prefix = f"{key} "
203
+ children = sorted(k for k in entries if k.startswith(prefix) and " " not in k.removeprefix(prefix))
204
+ return Command(
205
+ name=key.rpartition(" ")[2],
206
+ help=_str(table, "help", where, required=True),
207
+ run=_str(table, "run", where),
208
+ args=args,
209
+ subcommands=[_build(entries, child) for child in children],
210
+ end_of_options=_bool(table, "end_of_options", where, default=True),
211
+ accept=[_accept_example(ex, f"{where}: accept") for ex in entry.accept],
212
+ reject=[_example_str(ex, f"{where}: reject") for ex in entry.reject],
213
+ source=", ".join(entry.sources),
214
+ )
215
+
216
+
217
+ def _validated_arg(arg: Any, where: str) -> tuple[str, Validator]: # noqa: ANN401
218
+ """Return (help, validator) for an option or positional table."""
219
+ table = _as_table(arg, where)
220
+ _check_keys(table, frozenset({"help", "description"}) | _VALIDATOR_KEYS, where)
221
+ help_ = _str(table, "help", where, required=True)
222
+ kinds = sorted(_VALIDATOR_KEYS & table.keys())
223
+ if len(kinds) != 1:
224
+ msg = f"{where}: needs exactly one of {', '.join(sorted(_VALIDATOR_KEYS))}"
225
+ raise ConfigError(msg)
226
+ kind = kinds[0]
227
+ if "description" in table and kind != "pattern":
228
+ msg = f"{where}: 'description' is only used with 'pattern'"
229
+ raise ConfigError(msg)
230
+
231
+ if kind == "validator":
232
+ name = _str(table, "validator", where, required=True)
233
+ if name not in NAMED:
234
+ msg = f"{where}: unknown validator {name!r} (choose from {', '.join(sorted(NAMED))})"
235
+ raise ConfigError(msg)
236
+ return help_, NAMED[name]
237
+ if kind == "pattern":
238
+ regex = _str(table, "pattern", where, required=True)
239
+ try:
240
+ re.compile(regex)
241
+ except re.error as e:
242
+ msg = f"{where}: invalid pattern: {e}"
243
+ raise ConfigError(msg) from e
244
+ return help_, Pattern(regex, _str(table, "description", where, required=True))
245
+ fields = _str_list(table, "go_template", where)
246
+ if not fields or not all(re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", f) for f in fields):
247
+ msg = f"{where}: go_template must be a non-empty list of field names"
248
+ raise ConfigError(msg)
249
+ template = GoTemplateFormat(fields)
250
+ return f"{help_} Fields: {', '.join(template.fields)}.", template
251
+
252
+
253
+ def _accept_example(example: Any, where: str) -> str | tuple[str, str]: # noqa: ANN401
254
+ """Return an accept example: a command line, or a (command line, expected argv) pair."""
255
+ if isinstance(example, dict):
256
+ _check_keys(example, frozenset({"command", "argv"}), where)
257
+ return (_str(example, "command", where, required=True), _str(example, "argv", where, required=True))
258
+ return _example_str(example, where)
259
+
260
+
261
+ def _example_str(example: Any, where: str) -> str: # noqa: ANN401
262
+ """Return an example command line."""
263
+ if not isinstance(example, str):
264
+ msg = f"{where}: examples must be strings (or, for accept, {{ command = ..., argv = ... }} tables)"
265
+ raise ConfigError(msg)
266
+ return example
267
+
268
+
269
+ def _where(entry: _Entry, key: str) -> str:
270
+ """Describe where a command is defined, for error messages."""
271
+ return f"{', '.join(entry.sources)}: commands.{key!r}"
272
+
273
+
274
+ def _check_keys(table: dict[str, Any], allowed: frozenset[str], where: str) -> None:
275
+ """Reject unknown keys, so a typo can't be silently ignored."""
276
+ if unknown := sorted(table.keys() - allowed):
277
+ msg = f"{where}: unknown key(s) {', '.join(map(repr, unknown))} (allowed: {', '.join(sorted(allowed))})"
278
+ raise ConfigError(msg)
279
+
280
+
281
+ def _as_table(value: Any, where: str) -> dict[str, Any]: # noqa: ANN401
282
+ """Return value if it's a table."""
283
+ if not isinstance(value, dict):
284
+ msg = f"{where}: must be a table"
285
+ raise ConfigError(msg)
286
+ return value
287
+
288
+
289
+ def _table(table: dict[str, Any], key: str, where: str) -> dict[str, Any]:
290
+ """Return an optional sub-table."""
291
+ return _as_table(table.get(key, {}), f"{where}: {key}")
292
+
293
+
294
+ def _list(table: dict[str, Any], key: str, where: str) -> list[Any]:
295
+ """Return an optional list."""
296
+ value = table.get(key, [])
297
+ if not isinstance(value, list):
298
+ msg = f"{where}: {key} must be a list"
299
+ raise ConfigError(msg)
300
+ return value
301
+
302
+
303
+ def _str_list(table: dict[str, Any], key: str, where: str) -> list[str]:
304
+ """Return an optional list of strings."""
305
+ value = _list(table, key, where)
306
+ if not all(isinstance(v, str) for v in value):
307
+ msg = f"{where}: {key} must be a list of strings"
308
+ raise ConfigError(msg)
309
+ return value
310
+
311
+
312
+ def _str(table: dict[str, Any], key: str, where: str, *, required: bool = False) -> Any: # noqa: ANN401
313
+ """Return an optional (or required, non-empty) string."""
314
+ value = table.get(key)
315
+ if value is None and not required:
316
+ return None
317
+ if not isinstance(value, str) or (required and not value.strip()):
318
+ msg = f"{where}: {key} must be a{' non-empty' if required else ''} string"
319
+ raise ConfigError(msg)
320
+ return value
321
+
322
+
323
+ def _bool(table: dict[str, Any], key: str, where: str, *, default: bool) -> bool:
324
+ """Return an optional boolean."""
325
+ value = table.get(key, default)
326
+ if not isinstance(value, bool):
327
+ msg = f"{where}: {key} must be true or false"
328
+ raise ConfigError(msg)
329
+ return value
sshfc/core/executor.py ADDED
@@ -0,0 +1,58 @@
1
+ # Copyright (C) 2026 Stephen T. Jibson.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Run an accepted command's argv."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import pwd
10
+ import subprocess
11
+ import sys
12
+ from typing import TYPE_CHECKING, Final
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping, Sequence
16
+
17
+ _PATH: Final = "/usr/bin:/bin"
18
+
19
+ # Variables passed through from sshfc's own environment, if set. Everything else is dropped.
20
+ _PASSTHROUGH: Final = (
21
+ "LANG",
22
+ "LC_ALL",
23
+ "TERM",
24
+ "XDG_RUNTIME_DIR", # Needed by rootless podman.
25
+ )
26
+
27
+ # Exit statuses for failures to start the program, following shell conventions.
28
+ _EXIT_CANNOT_EXECUTE: Final = 126
29
+ _EXIT_NOT_FOUND: Final = 127
30
+ _EXIT_SIGNAL_BASE: Final = 128
31
+
32
+
33
+ def minimal_env(environ: Mapping[str, str] | None = None) -> dict[str, str]:
34
+ """Return the environment to run commands with.
35
+
36
+ PATH is fixed, and HOME/USER/LOGNAME come from the passwd entry rather than the inherited environment.
37
+ """
38
+ environ = os.environ if environ is None else environ
39
+ user = pwd.getpwuid(os.getuid())
40
+ env = {"PATH": _PATH, "HOME": user.pw_dir, "USER": user.pw_name, "LOGNAME": user.pw_name}
41
+ env.update({name: environ[name] for name in _PASSTHROUGH if name in environ})
42
+ return env
43
+
44
+
45
+ def execute(argv: Sequence[str]) -> int:
46
+ """Run argv without a shell, with stdin/stdout/stderr passed through; return an exit status for sshfc."""
47
+ try:
48
+ # argv was rebuilt from a validated parse (never the raw command string), and no shell is involved.
49
+ result = subprocess.run(argv, shell=False, env=minimal_env(), check=False) # noqa: S603
50
+ except FileNotFoundError:
51
+ print(f"SSH command failed: {argv[0]}: not found", file=sys.stderr)
52
+ return _EXIT_NOT_FOUND
53
+ except PermissionError:
54
+ print(f"SSH command failed: {argv[0]}: permission denied", file=sys.stderr)
55
+ return _EXIT_CANNOT_EXECUTE
56
+ if result.returncode < 0: # Killed by a signal.
57
+ return _EXIT_SIGNAL_BASE - result.returncode
58
+ return result.returncode
sshfc/core/parser.py ADDED
@@ -0,0 +1,240 @@
1
+ # Copyright (C) 2026 Stephen T. Jibson.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Strict parser built from command specs; renders the argv to run for an accepted command."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import shlex
10
+ from typing import TYPE_CHECKING, Any, Final, NoReturn
11
+
12
+ from sshfc.core.spec import Command, Flag, Option, Positional
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ # _SubParsersAction is only generic in the type stubs, not at runtime.
18
+ _SubParsersActionBase = argparse._SubParsersAction[argparse.ArgumentParser] # noqa: SLF001
19
+ else:
20
+ _SubParsersActionBase = argparse._SubParsersAction # noqa: SLF001
21
+
22
+ # Namespace keys used internally; prefixed so they can't collide with anything argparse derives.
23
+ _LEAF: Final = "_sshfc_leaf"
24
+ _ARG: Final = "_sshfc_arg_{index}"
25
+ _SUBCOMMAND: Final = "_sshfc_subcommand:{prog}"
26
+
27
+
28
+ class InvalidCommandError(Exception):
29
+ """Raised when a command is invalid."""
30
+
31
+
32
+ class SpecError(Exception):
33
+ """Raised when a command spec is malformed (a maintainer error, not an SSH user error)."""
34
+
35
+ def __init__(self, message: str, command: Command | None = None) -> None:
36
+ """Initialize the error with the command whose spec is malformed, if any."""
37
+ super().__init__(message)
38
+ self.command = command
39
+
40
+
41
+ class _ParseError(Exception):
42
+ """Raised by _StrictArgumentParser instead of exiting."""
43
+
44
+ def __init__(self, prog: str, message: str) -> None:
45
+ """Initialize the error with the prog (subcommand path) of the parser that raised it."""
46
+ super().__init__(message)
47
+ self.prog = prog
48
+
49
+
50
+ class _SubcommandsAction(_SubParsersActionBase):
51
+ """Subparsers action that gives each subcommand a clean prog and uses its help as its description."""
52
+
53
+ def add_parser(self, name: str, **kwargs: Any) -> argparse.ArgumentParser: # noqa: ANN401
54
+ """Add a subcommand parser."""
55
+ # argparse joins the prefix and name with a space, which leaves a leading space when the root prog is "".
56
+ kwargs.setdefault("prog", f"{self._prog_prefix} {name}".strip())
57
+ if "help" in kwargs:
58
+ kwargs.setdefault("description", kwargs["help"])
59
+ return super().add_parser(name, **kwargs)
60
+
61
+
62
+ class _StrictArgumentParser(argparse.ArgumentParser):
63
+ """ArgumentParser that never exits on error and never matches abbreviated options.
64
+
65
+ Subparsers are created with the parent's class, so every parser in the tree inherits this behavior.
66
+ """
67
+
68
+ def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
69
+ """Initialize the parser."""
70
+ kwargs.setdefault("allow_abbrev", False)
71
+ super().__init__(*args, **kwargs)
72
+ self.register("action", "parsers", _SubcommandsAction)
73
+
74
+ def parse_known_args( # type: ignore[override]
75
+ self, args: Sequence[str] | None = None, namespace: argparse.Namespace | None = None
76
+ ) -> tuple[argparse.Namespace, list[str]]:
77
+ """Parse args, rejecting unrecognized args and requiring a subcommand wherever subcommands are defined.
78
+
79
+ Both checks happen here, in the parser that owns the args, so the error names the right subcommand path.
80
+ argparse's own ``required=True`` check only names the metavar; this lists the valid choices too.
81
+ """
82
+ parsed, extras = super().parse_known_args(args, namespace)
83
+ if extras:
84
+ self.error(f"unrecognized arguments: {' '.join(extras)}")
85
+ for action in self._actions:
86
+ if isinstance(action, argparse._SubParsersAction) and getattr(parsed, action.dest, None) is None: # noqa: SLF001
87
+ choices = ", ".join(action.choices)
88
+ self.error(f"the following arguments are required: {action.metavar} (choose from {choices})")
89
+ return parsed, extras
90
+
91
+ def error(self, message: str) -> NoReturn:
92
+ """Raise instead of printing usage and exiting."""
93
+ raise _ParseError(self.prog, message)
94
+
95
+
96
+ class CommandRoot:
97
+ """Parser for the allowed command set."""
98
+
99
+ _REJECTED_MSG: Final = "SSH command rejected: {name} -- {error}"
100
+ _REJECTED_NO_NAME_MSG: Final = "SSH command rejected: {error}"
101
+
102
+ def __init__(self, commands: Sequence[Command]) -> None:
103
+ """Build the parser from command specs; raise SpecError if a spec is malformed."""
104
+ self._parser = _StrictArgumentParser(prog="")
105
+ group = self._add_subcommands(self._parser, metavar="command", title="available commands")
106
+ self._check_unique([c.name for c in commands], "top-level commands", None)
107
+ for command in commands:
108
+ self._add_command(group, command, path=(), run=None)
109
+
110
+ def parse(self, cmd_str: str) -> list[str]:
111
+ """Validate a command line and return the argv to run; raise InvalidCommandError if it isn't allowed."""
112
+ argv, _ = self.parse_command(cmd_str)
113
+ return argv
114
+
115
+ def parse_command(self, cmd_str: str) -> tuple[list[str], Command]:
116
+ """Like parse(), but also return the matched leaf command (e.g. for its source)."""
117
+ try:
118
+ args = shlex.split(cmd_str)
119
+ except ValueError as e:
120
+ raise InvalidCommandError(self._REJECTED_NO_NAME_MSG.format(error=e)) from e
121
+ try:
122
+ namespace = self._parser.parse_args(args)
123
+ except _ParseError as e:
124
+ msg = (
125
+ self._REJECTED_MSG.format(name=e.prog, error=e)
126
+ if e.prog
127
+ else self._REJECTED_NO_NAME_MSG.format(error=e)
128
+ )
129
+ raise InvalidCommandError(msg) from e
130
+ path: tuple[Command, ...] = getattr(namespace, _LEAF)
131
+ return self._render(namespace), path[-1]
132
+
133
+ @staticmethod
134
+ def _add_subcommands(
135
+ parser: argparse.ArgumentParser, metavar: str = "subcommand", title: str = "available subcommands"
136
+ ) -> argparse._SubParsersAction[argparse.ArgumentParser]:
137
+ """Add a group of subcommands to a parser.
138
+
139
+ The subcommand is required, but that is enforced by _StrictArgumentParser.parse_known_args() rather than
140
+ argparse's ``required=True`` so the error message can list the valid choices.
141
+ """
142
+ dest = _SUBCOMMAND.format(prog=parser.prog)
143
+ return parser.add_subparsers(title=title, dest=dest, metavar=metavar)
144
+
145
+ def _add_command(
146
+ self,
147
+ group: argparse._SubParsersAction[argparse.ArgumentParser],
148
+ command: Command,
149
+ path: tuple[Command, ...],
150
+ run: str | None,
151
+ ) -> None:
152
+ """Add a command (and, recursively, its subcommands) to a subcommand group."""
153
+ path = (*path, command)
154
+ where = " ".join(c.name for c in path)
155
+ if command.run is not None:
156
+ if not command.run.startswith("/"):
157
+ msg = f"{where}: run must be an absolute path, got {command.run!r}"
158
+ raise SpecError(msg, command)
159
+ run = command.run
160
+
161
+ parser = group.add_parser(command.name, help=command.help)
162
+
163
+ if command.subcommands:
164
+ if command.args:
165
+ msg = f"{where}: only leaf commands (without subcommands) may have args"
166
+ raise SpecError(msg, command)
167
+ self._check_unique([c.name for c in command.subcommands], f"{where} subcommands", command)
168
+ sub_group = self._add_subcommands(parser)
169
+ for subcommand in command.subcommands:
170
+ self._add_command(sub_group, subcommand, path, run)
171
+ return
172
+
173
+ if run is None:
174
+ msg = f"{where}: no run path (set run= on this command or an ancestor)"
175
+ raise SpecError(msg, command)
176
+ self._add_args(parser, command, where)
177
+ parser.set_defaults(**{_LEAF: path})
178
+
179
+ def _add_args(self, parser: argparse.ArgumentParser, command: Command, where: str) -> None:
180
+ """Add a leaf command's options, flags and positionals to its parser."""
181
+ self._check_unique([a.name for a in command.args], f"{where} args", command)
182
+ for index, arg in enumerate(command.args):
183
+ dest = _ARG.format(index=index)
184
+ match arg:
185
+ case Option():
186
+ self._check_option_name(arg.name, where, command)
187
+ metavar = arg.name.lstrip("-").upper()
188
+ parser.add_argument(
189
+ arg.name, dest=dest, type=arg.validator, metavar=metavar, required=arg.required, help=arg.help
190
+ )
191
+ case Flag():
192
+ self._check_option_name(arg.name, where, command)
193
+ parser.add_argument(arg.name, dest=dest, action="store_true", required=arg.required, help=arg.help)
194
+ case Positional():
195
+ if arg.name.startswith("-"):
196
+ msg = f"{where}: positional names must not start with '-', got {arg.name!r}"
197
+ raise SpecError(msg, command)
198
+ parser.add_argument(dest, type=arg.validator, metavar=arg.name, help=arg.help)
199
+
200
+ @staticmethod
201
+ def _check_option_name(name: str, where: str, command: Command) -> None:
202
+ """Check that an option or flag name looks like one."""
203
+ if not name.startswith("-") or name in {"-", "--", "-h", "--help"}:
204
+ msg = f"{where}: invalid option name {name!r}"
205
+ raise SpecError(msg, command)
206
+
207
+ @staticmethod
208
+ def _check_unique(names: Sequence[str], where: str, command: Command | None) -> None:
209
+ """Check that names are unique."""
210
+ if duplicates := sorted({n for n in names if names.count(n) > 1}):
211
+ msg = f"{where}: duplicate names {duplicates}"
212
+ raise SpecError(msg, command)
213
+
214
+ @staticmethod
215
+ def _render(namespace: argparse.Namespace) -> list[str]:
216
+ """Build the argv to run from the parsed namespace -- never from the original command string."""
217
+ path: tuple[Command, ...] = getattr(namespace, _LEAF)
218
+ argv: list[str] = []
219
+ for command in path:
220
+ if command.run is not None:
221
+ argv = [command.run]
222
+ else:
223
+ argv.append(command.name)
224
+
225
+ leaf = path[-1]
226
+ positionals: list[str] = []
227
+ for index, arg in enumerate(leaf.args):
228
+ value = getattr(namespace, _ARG.format(index=index))
229
+ match arg:
230
+ case Option() if value is not None:
231
+ argv.extend([arg.name, value])
232
+ case Flag() if value:
233
+ argv.append(arg.name)
234
+ case Positional():
235
+ positionals.append(value)
236
+ if positionals:
237
+ if leaf.end_of_options:
238
+ argv.append("--")
239
+ argv.extend(positionals)
240
+ return argv