contree-cli 0.2.3__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.
@@ -0,0 +1,486 @@
1
+ """Interactive REPL for the contree shell."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import logging
6
+ import os
7
+ import shlex
8
+ import sys
9
+
10
+ from contree_cli import FORMATTER, IN_SHELL, SESSION_STORE, ArgumentsProtocol
11
+ from contree_cli.client import ApiError
12
+ from contree_cli.output import FORMATTERS, OutputFormatter
13
+ from contree_cli.shell.completer import ShellCompleter
14
+ from contree_cli.shell.parser import ShellArgumentParser, ShellParseError
15
+ from contree_cli.types import Colors
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ _PROMPT_BASE = "contree"
20
+
21
+ # Bare names that are forwarded as contree management commands.
22
+ _CONTREE_ALIASES = frozenset({"ls", "cat"})
23
+
24
+ # Bare editor names that map to ``contree file edit`` with EDITOR override.
25
+ _EDITOR_ALIASES = frozenset({"vim", "vi", "nano"})
26
+
27
+
28
+ class ContreeShell:
29
+ """Interactive REPL that dispatches to existing command handlers.
30
+
31
+ Commands prefixed with ``contree`` are dispatched as management
32
+ commands via argparse (e.g. ``contree ls /etc``). Everything else
33
+ is treated as an implicit ``run`` — the tokens are joined into a
34
+ shell expression and executed inside the current session container.
35
+
36
+ Several bare names are intercepted for convenience:
37
+
38
+ * ``ls``, ``cat`` — forwarded as the corresponding contree commands.
39
+ * ``vim``, ``vi``, ``nano`` — open ``contree file edit`` with the
40
+ host editor.
41
+ * ``cd`` — change the working directory for subsequent ``run``
42
+ commands (tracked in memory).
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ parser: ShellArgumentParser,
48
+ completer: ShellCompleter,
49
+ ) -> None:
50
+ self._parser = parser
51
+ self._completer = completer
52
+ self._cwd = ""
53
+ self._prev_cwd = ""
54
+
55
+ def _print_status_line(self) -> None:
56
+ """Print the info line (session/image) above the input prompt."""
57
+ session_key = ""
58
+ image_uuid = ""
59
+ depth = 0
60
+ try:
61
+ store = SESSION_STORE.get()
62
+ session = store.session
63
+ if session is not None:
64
+ session_key = session.session_key
65
+ image_uuid = session.current_image
66
+ depth = store.history_depth()
67
+ except (LookupError, Exception):
68
+ pass
69
+ counter = Colors.CYAN(f"[{depth}]")
70
+ line = (
71
+ Colors.GRAY("session:") + Colors.YELLOW(session_key)
72
+ + " " + counter + " "
73
+ + Colors.GRAY("image:") + Colors.GREEN(image_uuid)
74
+ )
75
+ print(line)
76
+
77
+ @property
78
+ def _prompt(self) -> str:
79
+ """Short input prompt — only cwd and branch, no ANSI length issues."""
80
+ cwd = self._cwd or "/"
81
+ branch = ""
82
+ try:
83
+ session = SESSION_STORE.get().session
84
+ if session is not None:
85
+ branch = session.active_branch
86
+ except (LookupError, Exception):
87
+ pass
88
+ branch_part = Colors.MAGENTA(f"({branch})") + " " if branch else ""
89
+ return branch_part + Colors.BOLD_BLUE(cwd) + " $ "
90
+
91
+ def run(self) -> None:
92
+ """Main REPL loop: readline setup -> input(prompt) -> dispatch."""
93
+ token = IN_SHELL.set(True)
94
+ self._setup_readline()
95
+ self._load_cwd()
96
+ print("contree interactive shell (type 'help' for commands, Ctrl-D to exit)")
97
+ try:
98
+ while True:
99
+ try:
100
+ self._print_status_line()
101
+ line = input(self._prompt)
102
+ except KeyboardInterrupt:
103
+ # Ctrl-C on empty prompt — print newline, continue
104
+ print()
105
+ continue
106
+ line = line.strip()
107
+ if not line:
108
+ continue
109
+ self._execute(line)
110
+ except EOFError:
111
+ # Ctrl-D — clean exit
112
+ print()
113
+ finally:
114
+ IN_SHELL.reset(token)
115
+
116
+ # ------------------------------------------------------------------
117
+ # Dispatch
118
+ # ------------------------------------------------------------------
119
+
120
+ def _execute(self, line: str) -> None:
121
+ """Parse line and dispatch to the appropriate command handler."""
122
+ # Tokenize
123
+ try:
124
+ tokens = shlex.split(line)
125
+ except ValueError as exc:
126
+ print(f"Parse error: {exc}", file=sys.stderr)
127
+ return
128
+
129
+ if not tokens:
130
+ return
131
+
132
+ cmd = tokens[0]
133
+
134
+ # Builtins (no prefix needed)
135
+ if cmd in ("exit", "quit"):
136
+ raise EOFError
137
+ if cmd == "help":
138
+ if len(tokens) > 1:
139
+ arg = tokens[1]
140
+ if arg in ("--format", "-f"):
141
+ self._print_format_help()
142
+ return
143
+ self._dispatch_contree([arg, "--help"])
144
+ else:
145
+ self._print_shell_help()
146
+ return
147
+
148
+ if cmd == "clear":
149
+ sys.stdout.write("\033[2J\033[H")
150
+ sys.stdout.flush()
151
+ return
152
+
153
+ # --format / -f — persistent format switching
154
+ if cmd in ("--format", "-f"):
155
+ self._handle_format_command(tokens[1:])
156
+ return
157
+
158
+ # cd / pwd — in-memory working directory
159
+ if cmd == "cd":
160
+ self._handle_cd(tokens[1:])
161
+ return
162
+ if cmd == "pwd":
163
+ print(self._cwd or "/")
164
+ return
165
+ if cmd == "history":
166
+ self._handle_history(tokens[1:])
167
+ return
168
+
169
+ # ls / cat — forwarded as contree commands when simple and no
170
+ # pending file uploads; otherwise fall back to run so pending
171
+ # files are injected into the container.
172
+ if cmd in _CONTREE_ALIASES:
173
+ args = tokens[1:]
174
+ if (
175
+ not self._has_pending_files()
176
+ and self._is_simple_alias(cmd, args)
177
+ ):
178
+ resolved = [cmd] + [self._resolve_path(a) for a in args]
179
+ self._dispatch_contree(resolved)
180
+ else:
181
+ self._dispatch_run(tokens)
182
+ return
183
+
184
+ # vim / vi / nano — host editor via "file edit"
185
+ if cmd in _EDITOR_ALIASES:
186
+ self._dispatch_edit(cmd, tokens[1:])
187
+ return
188
+
189
+ # "contree ..." — explicit management command via argparse
190
+ if cmd == "contree":
191
+ if len(tokens) == 1:
192
+ self._print_shell_help()
193
+ return
194
+ # Truncate at --help/-h and dispatch help for the prefix
195
+ help_tokens: list[str] = []
196
+ for t in tokens[1:]:
197
+ if t in ("--help", "-h"):
198
+ break
199
+ help_tokens.append(t)
200
+ if len(help_tokens) < len(tokens) - 1:
201
+ # --help/-h was found
202
+ if not help_tokens:
203
+ self._print_shell_help()
204
+ else:
205
+ self._dispatch_contree([help_tokens[0], "--help"])
206
+ return
207
+ if tokens[1] == "cd":
208
+ # Intercept cd to keep shell._cwd in sync
209
+ cd_args = tokens[2:]
210
+ if not cd_args:
211
+ print(self._cwd or "/")
212
+ else:
213
+ self._handle_cd(cd_args)
214
+ else:
215
+ self._dispatch_contree(tokens[1:])
216
+ return
217
+
218
+ # Everything else → implicit run in container
219
+ self._dispatch_run(tokens)
220
+
221
+ def _dispatch_contree(self, tokens: list[str]) -> None:
222
+ """Dispatch a contree management command via argparse."""
223
+ try:
224
+ ns = self._parser.parse_args(tokens)
225
+ except ShellParseError as exc:
226
+ # status=0 means --help was triggered (already printed)
227
+ if exc.status == 0:
228
+ return
229
+ if exc.usage:
230
+ print(exc.usage, file=sys.stderr, end="")
231
+ if exc.message:
232
+ print(f"error: {exc.message}", file=sys.stderr)
233
+ return
234
+
235
+ handler = ns.handler
236
+ loader: type[ArgumentsProtocol] = ns.load_args
237
+
238
+ # Per-command format override via -f/--format
239
+ fmt_token = None
240
+ fmt_name: str | None = getattr(ns, "output_format", None)
241
+ if fmt_name is not None:
242
+ fmt_token = FORMATTER.set(FORMATTERS[fmt_name]())
243
+
244
+ formatter = FORMATTER.get()
245
+
246
+ try:
247
+ handler(loader.from_args(ns))
248
+ except ApiError as exc:
249
+ print(f"API error: {exc}", file=sys.stderr)
250
+ except KeyboardInterrupt:
251
+ print()
252
+ except SystemExit:
253
+ # Some handlers may call sys.exit() — catch and continue
254
+ pass
255
+ except Exception as exc:
256
+ log.error("Command failed: %s", exc, exc_info=True)
257
+ finally:
258
+ formatter.flush()
259
+ if fmt_token is not None:
260
+ FORMATTER.reset(fmt_token)
261
+
262
+ def _dispatch_run(self, tokens: list[str]) -> None:
263
+ """Dispatch tokens as an implicit ``run`` in the container."""
264
+ from contree_cli.cli.run import RunArgs, cmd_run
265
+
266
+ args = RunArgs(command_args=tokens, shell=True, cwd=self._cwd)
267
+ formatter = FORMATTER.get()
268
+
269
+ try:
270
+ cmd_run(args)
271
+ except ApiError as exc:
272
+ print(f"API error: {exc}", file=sys.stderr)
273
+ except KeyboardInterrupt:
274
+ print()
275
+ except SystemExit:
276
+ pass
277
+ except Exception as exc:
278
+ log.error("Command failed: %s", exc, exc_info=True)
279
+ finally:
280
+ formatter.flush()
281
+
282
+ def _dispatch_edit(self, editor: str, args: list[str]) -> None:
283
+ """Open a container file in a host editor via ``file edit``."""
284
+ if not args:
285
+ print(f"Usage: {editor} <path>", file=sys.stderr)
286
+ return
287
+ resolved = self._resolve_path(args[0])
288
+ old_editor = os.environ.get("EDITOR")
289
+ os.environ["EDITOR"] = editor
290
+ try:
291
+ self._dispatch_contree(["file", "edit", resolved])
292
+ finally:
293
+ if old_editor is None:
294
+ os.environ.pop("EDITOR", None)
295
+ else:
296
+ os.environ["EDITOR"] = old_editor
297
+
298
+ # ------------------------------------------------------------------
299
+ # Path resolution helpers
300
+ # ------------------------------------------------------------------
301
+
302
+ _GLOB_CHARS = frozenset("*?[")
303
+
304
+ def _has_pending_files(self) -> bool:
305
+ """Return True when the session has files awaiting upload."""
306
+ try:
307
+ return bool(SESSION_STORE.get().pending_files())
308
+ except (LookupError, Exception):
309
+ return False
310
+
311
+ def _resolve_path(self, path: str) -> str:
312
+ """Resolve a container path via session store."""
313
+ if not path:
314
+ return path
315
+ return SESSION_STORE.get().resolve_path(path)
316
+
317
+ def _is_simple_alias(self, cmd: str, args: list[str]) -> bool:
318
+ """Check whether alias args are simple enough for contree dispatch.
319
+
320
+ Returns ``False`` (fall back to ``run``) when the arguments
321
+ contain flags, glob characters, or an unexpected number of
322
+ positional arguments for the given command.
323
+ """
324
+ for a in args:
325
+ if a.startswith("-"):
326
+ return False
327
+ if self._GLOB_CHARS & set(a):
328
+ return False
329
+ if cmd == "cat":
330
+ return len(args) == 1
331
+ if cmd == "ls":
332
+ return len(args) <= 1
333
+ return True
334
+
335
+ # ------------------------------------------------------------------
336
+ # cd — in-memory working directory
337
+ # ------------------------------------------------------------------
338
+
339
+ def _handle_cd(self, args: list[str]) -> None:
340
+ """Change the working directory for subsequent ``run`` commands."""
341
+ if not args:
342
+ # bare ``cd`` → reset to container WORKDIR
343
+ self._prev_cwd = self._cwd
344
+ self._cwd = ""
345
+ self._persist_cwd()
346
+ return
347
+
348
+ target = args[0]
349
+
350
+ if target == "-":
351
+ self._cwd, self._prev_cwd = self._prev_cwd, self._cwd
352
+ if self._cwd:
353
+ print(self._cwd)
354
+ self._persist_cwd()
355
+ return
356
+
357
+ new_cwd = SESSION_STORE.get().resolve_path(target)
358
+ self._prev_cwd = self._cwd
359
+ self._cwd = new_cwd
360
+ self._persist_cwd()
361
+
362
+ def _persist_cwd(self) -> None:
363
+ """Write the current working directory to session storage."""
364
+ try:
365
+ store = SESSION_STORE.get()
366
+ store.set_cwd(self._cwd)
367
+ except (LookupError, Exception):
368
+ pass
369
+
370
+ def _load_cwd(self) -> None:
371
+ """Restore the working directory from session storage."""
372
+ try:
373
+ store = SESSION_STORE.get()
374
+ self._cwd = store.get_cwd()
375
+ except (LookupError, Exception):
376
+ pass
377
+
378
+ # ------------------------------------------------------------------
379
+ # history — show shell history from SQLite
380
+ # ------------------------------------------------------------------
381
+
382
+ def _handle_history(self, args: list[str]) -> None:
383
+ """Print shell history from the session database."""
384
+ try:
385
+ store = SESSION_STORE.get()
386
+ lines = store.load_shell_history()
387
+ except (LookupError, Exception):
388
+ lines = []
389
+ if not lines:
390
+ print("(no history)")
391
+ return
392
+ # Optional: limit output with an argument (e.g. ``history 20``)
393
+ count = len(lines)
394
+ if args:
395
+ with contextlib.suppress(ValueError):
396
+ count = int(args[0])
397
+ for i, line in enumerate(lines[-count:], start=max(1, len(lines) - count + 1)):
398
+ print(f" {i:5d} {line}")
399
+
400
+ # ------------------------------------------------------------------
401
+ # --format / -f — persistent format switching
402
+ # ------------------------------------------------------------------
403
+
404
+ @staticmethod
405
+ def _format_name(formatter: OutputFormatter) -> str:
406
+ """Return the FORMATTERS key for *formatter*'s type."""
407
+ for name, cls in FORMATTERS.items():
408
+ if type(formatter) is cls:
409
+ return name
410
+ return type(formatter).__name__
411
+
412
+ def _handle_format_command(self, args: list[str]) -> None:
413
+ """Handle ``--format [NAME]`` as a shell builtin."""
414
+ if not args:
415
+ # Print current format name
416
+ print(self._format_name(FORMATTER.get()))
417
+ return
418
+ name = args[0]
419
+ if name not in FORMATTERS:
420
+ names = ", ".join(sorted(FORMATTERS))
421
+ print(
422
+ f"Unknown format {name!r}. Available: {names}",
423
+ file=sys.stderr,
424
+ )
425
+ return
426
+ FORMATTER.set(FORMATTERS[name]())
427
+
428
+ @staticmethod
429
+ def _print_format_help() -> None:
430
+ """Print help text for the --format / -f builtin."""
431
+ names = ", ".join(sorted(FORMATTERS))
432
+ print(
433
+ "Usage:\n"
434
+ " --format Show current output format\n"
435
+ " --format NAME Switch output format for the session\n"
436
+ f"\nAvailable formats: {names}\n"
437
+ "\nPer-command override: contree -f NAME <command>",
438
+ )
439
+
440
+ def _print_shell_help(self) -> None:
441
+ """Print full shell help including builtins and contree commands."""
442
+ fmt = ", ".join(sorted(FORMATTERS))
443
+ print(
444
+ "Shell builtins:\n"
445
+ " help [CMD] Show help (for CMD if given)\n"
446
+ " exit / quit Exit the shell (or Ctrl-D)\n"
447
+ " clear Clear the screen\n"
448
+ " cd [PATH] Change working directory (cd - to swap)\n"
449
+ " pwd Print working directory\n"
450
+ " history [N] Show command history (last N lines)\n"
451
+ " --format [NAME] Show or switch output format\n"
452
+ f" Available: {fmt}\n"
453
+ "\n"
454
+ "Editor aliases:\n"
455
+ " vim / vi / nano PATH Edit a file via contree file edit\n"
456
+ "\n"
457
+ "Contree commands (prefix with 'contree' or use directly):\n"
458
+ " ls [PATH] List files in container image\n"
459
+ " cat PATH Show file content from image\n"
460
+ " contree <CMD> Run any contree management command\n"
461
+ "\n"
462
+ "Anything else is executed as a command inside the container.\n"
463
+ "\n"
464
+ "Use 'help CMD' for detailed help on a specific command.",
465
+ )
466
+
467
+ # ------------------------------------------------------------------
468
+ # Readline
469
+ # ------------------------------------------------------------------
470
+
471
+ def _setup_readline(self) -> None:
472
+ """Configure readline for tab completion."""
473
+ try:
474
+ import readline
475
+ except ImportError:
476
+ return
477
+
478
+ readline.set_completer(self._completer.complete)
479
+ readline.set_completer_delims(" \t\n")
480
+
481
+ # macOS uses libedit which has different bind syntax
482
+ doc = getattr(readline, "__doc__", "") or ""
483
+ if "libedit" in doc:
484
+ readline.parse_and_bind("bind ^I rl_complete")
485
+ else:
486
+ readline.parse_and_bind("tab: complete")
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterator, MutableMapping
4
+
5
+ Handler = Callable[..., list[str]]
6
+
7
+
8
+ class PrefixRouter(MutableMapping[tuple[str, ...], Handler]):
9
+ """Dict-of-dicts trie mapping token paths to handler callables.
10
+
11
+ Keys are tuples of tokens, e.g. ``("contree", "session", "use")``.
12
+ Values are handler callables receiving ``(remaining, text) -> list[str]``.
13
+
14
+ The :meth:`resolve` method walks the trie as far as possible,
15
+ returning the deepest matching node and how many tokens were consumed.
16
+ """
17
+
18
+ __slots__ = ("_children", "_handler")
19
+
20
+ def __init__(self) -> None:
21
+ self._handler: Handler | None = None
22
+ self._children: dict[str, PrefixRouter] = {}
23
+
24
+ # -- Properties -----------------------------------------------------------
25
+
26
+ @property
27
+ def value(self) -> Handler | None:
28
+ """The handler stored at this node, or ``None``."""
29
+ return self._handler
30
+
31
+ @property
32
+ def children(self) -> dict[str, PrefixRouter]:
33
+ """Direct children mapping token → sub-router."""
34
+ return self._children
35
+
36
+ # -- MutableMapping interface ---------------------------------------------
37
+
38
+ def __getitem__(self, key: tuple[str, ...]) -> Handler:
39
+ node = self
40
+ for token in key:
41
+ try:
42
+ node = node._children[token]
43
+ except KeyError:
44
+ raise KeyError(key) from None
45
+ if node._handler is None:
46
+ raise KeyError(key)
47
+ return node._handler
48
+
49
+ def __setitem__(self, key: tuple[str, ...], value: Handler) -> None:
50
+ node = self
51
+ for token in key:
52
+ if token not in node._children:
53
+ node._children[token] = PrefixRouter()
54
+ node = node._children[token]
55
+ node._handler = value
56
+
57
+ def __delitem__(self, key: tuple[str, ...]) -> None:
58
+ node = self
59
+ for token in key:
60
+ try:
61
+ node = node._children[token]
62
+ except KeyError:
63
+ raise KeyError(key) from None
64
+ if node._handler is None:
65
+ raise KeyError(key)
66
+ node._handler = None
67
+
68
+ def __contains__(self, key: object) -> bool:
69
+ if not isinstance(key, tuple):
70
+ return False
71
+ node = self
72
+ for token in key:
73
+ if not isinstance(token, str):
74
+ return False
75
+ child = node._children.get(token)
76
+ if child is None:
77
+ return False
78
+ node = child
79
+ return node._handler is not None
80
+
81
+ def __iter__(self) -> Iterator[tuple[str, ...]]:
82
+ yield from self._iter_keys(())
83
+
84
+ def _iter_keys(
85
+ self, prefix: tuple[str, ...],
86
+ ) -> Iterator[tuple[str, ...]]:
87
+ if self._handler is not None:
88
+ yield prefix
89
+ for token, child in self._children.items():
90
+ yield from child._iter_keys((*prefix, token))
91
+
92
+ def __len__(self) -> int:
93
+ count = 1 if self._handler is not None else 0
94
+ for child in self._children.values():
95
+ count += len(child)
96
+ return count
97
+
98
+ def resolve(self, tokens: tuple[str, ...]) -> tuple[PrefixRouter, int]:
99
+ node = self
100
+ depth = 0
101
+ for token in tokens:
102
+ child = node._children.get(token)
103
+ if child is None:
104
+ break
105
+ node = child
106
+ depth += 1
107
+ return node, depth
108
+
109
+ def __repr__(self) -> str:
110
+ return (
111
+ f"PrefixRouter(handler={self._handler is not None}, "
112
+ f"children={list(self._children)})"
113
+ )
contree_cli/types.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Callable
6
+ from datetime import datetime, timezone
7
+ from enum import Enum
8
+
9
+ from contree_cli import SetupResult
10
+
11
+ IS_A_TTY = sys.stderr.isatty()
12
+
13
+
14
+ class ArgumentsFormatter(argparse.RawDescriptionHelpFormatter):
15
+ def _get_help_string(self, action: argparse.Action) -> str:
16
+ help_text = action.help or ""
17
+ if (
18
+ action.default not in (None, False, [], argparse.SUPPRESS)
19
+ and "%(default)" not in help_text
20
+ ):
21
+ help_text += " (default: %(default)s)"
22
+ return help_text
23
+
24
+
25
+ def get_command_docs(setup_fn: SetupFn) -> tuple[str | None, str | None]:
26
+ """Extract description and epilog from the module that defines *setup_fn*.
27
+
28
+ The module's ``__doc__`` becomes the description; an optional
29
+ module-level ``EPILOG`` variable becomes the epilog.
30
+ """
31
+ mod = sys.modules.get(setup_fn.__module__)
32
+ if mod is None:
33
+ return None, None
34
+ return mod.__doc__, getattr(mod, "EPILOG", None)
35
+
36
+
37
+ class Colors(Enum):
38
+ """
39
+ Each enum value is callable and can be used to wrap text in the corresponding color.
40
+
41
+ Example usage:
42
+ print(Colors.BOLD_RED("This is bold red text"))
43
+ """
44
+
45
+ DEFAULT = "\033[0m"
46
+ BOLD = "\033[1m"
47
+ BOLD_BLACK = "\033[1;30m"
48
+ BOLD_BLUE = "\033[1;34m"
49
+ BOLD_CYAN = "\033[1;36m"
50
+ BOLD_GRAY = "\033[1;90m"
51
+ BOLD_GREEN = "\033[1;32m"
52
+ BOLD_MAGENTA = "\033[1;35m"
53
+ BOLD_RED = "\033[1;31m"
54
+ BOLD_WHITE = "\033[1;37m"
55
+ BOLD_YELLOW = "\033[1;33m"
56
+ BLACK = "\033[30m"
57
+ BLUE = "\033[34m"
58
+ CYAN = "\033[36m"
59
+ GRAY = "\033[90m"
60
+ GREEN = "\033[32m"
61
+ MAGENTA = "\033[35m"
62
+ RED = "\033[31m"
63
+ WHITE = "\033[37m"
64
+ YELLOW = "\033[33m"
65
+
66
+ def __call__(self, text: str) -> str:
67
+ if not IS_A_TTY:
68
+ return text
69
+ return f"{self.value}{text}{Colors.DEFAULT.value}"
70
+
71
+ if sys.version_info >= (3, 11):
72
+ def parse_datetime(value: str) -> datetime:
73
+ """Parse an ISO 8601 datetime string from the API."""
74
+ return datetime.fromisoformat(value).astimezone(tz=timezone.utc)
75
+ else:
76
+ def parse_datetime(value: str) -> datetime:
77
+ """Parse an ISO 8601 datetime string from the API.
78
+
79
+ Python 3.10 ``fromisoformat`` does not accept the ``Z`` suffix.
80
+ """
81
+ if value.endswith("Z"):
82
+ value = value[:-1] + "+00:00"
83
+ return datetime.fromisoformat(value).astimezone(tz=timezone.utc)
84
+
85
+
86
+ SetupFn = Callable[[argparse.ArgumentParser], SetupResult]
87
+ COMMAND_REGISTRY: list[tuple[str, str, SetupFn, list[str]]] = []