eosh 0.2.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.
Files changed (73) hide show
  1. eosh/__init__.py +34 -0
  2. eosh/__main__.py +23 -0
  3. eosh/_config.py +367 -0
  4. eosh/colors.py +63 -0
  5. eosh/commands.py +785 -0
  6. eosh/completion.py +952 -0
  7. eosh/completion_cache.py +77 -0
  8. eosh/context.py +189 -0
  9. eosh/decorators/__init__.py +263 -0
  10. eosh/decorators/bg.py +39 -0
  11. eosh/decorators/quiet.py +39 -0
  12. eosh/decorators/retry.py +46 -0
  13. eosh/decorators/time.py +44 -0
  14. eosh/decorators/watch.py +660 -0
  15. eosh/history.py +36 -0
  16. eosh/lineedit.py +1442 -0
  17. eosh/notify.py +404 -0
  18. eosh/parsing.py +226 -0
  19. eosh/paths.py +18 -0
  20. eosh/pipeline.py +668 -0
  21. eosh/process.py +465 -0
  22. eosh/prompt.py +77 -0
  23. eosh/recipes/__init__.py +208 -0
  24. eosh/recipes/_awsut_agentcore/__init__.py +46 -0
  25. eosh/recipes/_awsut_agentcore/harness.py +721 -0
  26. eosh/recipes/_awsut_agentcore/memory.py +1209 -0
  27. eosh/recipes/_awsut_agentcore/render.py +337 -0
  28. eosh/recipes/_awsut_common.py +533 -0
  29. eosh/recipes/_awsut_sagemaker/__init__.py +36 -0
  30. eosh/recipes/_awsut_sagemaker/hub.py +866 -0
  31. eosh/recipes/_awsut_sagemaker/hyperpod.py +1948 -0
  32. eosh/recipes/_awsut_sagemaker/jobs.py +1041 -0
  33. eosh/recipes/_awsut_sagemaker/render.py +300 -0
  34. eosh/recipes/_awsut_sagemaker/studio.py +1584 -0
  35. eosh/recipes/_missing.py +168 -0
  36. eosh/recipes/aws.py +239 -0
  37. eosh/recipes/awsut.py +1649 -0
  38. eosh/recipes/chmod.py +58 -0
  39. eosh/recipes/chown.py +117 -0
  40. eosh/recipes/cp.py +80 -0
  41. eosh/recipes/curl.py +108 -0
  42. eosh/recipes/df.py +33 -0
  43. eosh/recipes/du.py +82 -0
  44. eosh/recipes/find.py +60 -0
  45. eosh/recipes/git.py +295 -0
  46. eosh/recipes/grep.py +54 -0
  47. eosh/recipes/kill.py +92 -0
  48. eosh/recipes/ls.py +44 -0
  49. eosh/recipes/lsof.py +56 -0
  50. eosh/recipes/make.py +219 -0
  51. eosh/recipes/mv.py +52 -0
  52. eosh/recipes/ps.py +73 -0
  53. eosh/recipes/rm.py +55 -0
  54. eosh/recipes/rsync.py +143 -0
  55. eosh/recipes/scp.py +97 -0
  56. eosh/recipes/ssh.py +92 -0
  57. eosh/recipes/tail.py +23 -0
  58. eosh/recipes/tar.py +170 -0
  59. eosh/recipes/terraform.py +374 -0
  60. eosh/recipes/top.py +84 -0
  61. eosh/recipes/unzip.py +96 -0
  62. eosh/recipes/zip.py +53 -0
  63. eosh/scrollbar.py +122 -0
  64. eosh/shell.py +4253 -0
  65. eosh/terminal.py +446 -0
  66. eosh/tui.py +1082 -0
  67. eosh/variables.py +253 -0
  68. eosh-0.2.0.dist-info/METADATA +637 -0
  69. eosh-0.2.0.dist-info/RECORD +73 -0
  70. eosh-0.2.0.dist-info/WHEEL +5 -0
  71. eosh-0.2.0.dist-info/entry_points.txt +2 -0
  72. eosh-0.2.0.dist-info/licenses/LICENSE +21 -0
  73. eosh-0.2.0.dist-info/top_level.txt +1 -0
eosh/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Eolith Shell — a lightweight but powerful terminal shell environment."""
2
+
3
+ from . import notify
4
+ from .colors import ColorScheme, set_color_scheme
5
+ from .commands import arg, CmdParser, registry as command_registry
6
+ from .prompt import set_prompt
7
+ from .shell import (
8
+ passthrough_input,
9
+ passthrough_input_block,
10
+ passthrough_poll_key,
11
+ passthrough_run,
12
+ )
13
+ from .variables import Var, EnvVar, registry as var_registry
14
+
15
+ #: Single source of truth for the version string -- the ONLY place the literal
16
+ #: appears in this repo. pyproject.toml derives it via its dynamic ``version``
17
+ #: (``attr = "eosh.__version__"``), and ``make tag`` rewrites this line.
18
+ __version__ = "0.2.0"
19
+ __all__ = [
20
+ "arg",
21
+ "CmdParser",
22
+ "ColorScheme",
23
+ "notify",
24
+ "set_color_scheme",
25
+ "set_prompt",
26
+ "Var",
27
+ "EnvVar",
28
+ "var_registry",
29
+ "command_registry",
30
+ "passthrough_run",
31
+ "passthrough_input",
32
+ "passthrough_input_block",
33
+ "passthrough_poll_key",
34
+ ]
eosh/__main__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Entry point for eosh."""
2
+
3
+ import argparse
4
+
5
+ from . import __version__
6
+
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser(
10
+ prog="eosh",
11
+ description="A lightweight but powerful terminal shell.",
12
+ )
13
+ parser.add_argument("--version", action="version",
14
+ version=f"%(prog)s {__version__}")
15
+ parser.parse_args()
16
+
17
+ from .shell import Shell
18
+ shell = Shell()
19
+ shell.run()
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
eosh/_config.py ADDED
@@ -0,0 +1,367 @@
1
+ # eosh user configuration
2
+ # Define custom commands and completers here.
3
+
4
+ # ── Simple example: one positional argument ───────────────────────────────────
5
+
6
+ from eosh.commands import registry as command_registry, arg
7
+ from eosh.completion import ChoiceCompleter
8
+
9
+ @command_registry.command(
10
+ name="hello",
11
+ help="Greet someone by name.", # shell-facing description; no docstring needed
12
+ params=[arg("name", nargs="?", default="world", help="name of the person to greet",
13
+ completer=ChoiceCompleter(["world", "there"]))],
14
+ )
15
+ def hello(name):
16
+ print(f"Hello, {name}!")
17
+
18
+
19
+ # ── Multi-level sub-command example ───────────────────────────────────────────
20
+ #
21
+ # Build a command tree by:
22
+ # 1. Calling `command_registry.command(name, ...)` bare — returns a `Command` node.
23
+ # 2. Chaining `.command(...)` on the node for each child (group or leaf).
24
+ # 3. Using `@parent.command(name, ...)` as a decorator to attach a handler.
25
+ #
26
+ # Flags declared on a parent node are *inherited* by every leaf automatically;
27
+ # a leaf handler only needs to accept the kwargs it actually uses — the rest
28
+ # are filtered out before the call. So `--verbose` and `--dry-run` declared on
29
+ # the `deploy` root are usable on `deploy app`, `deploy rollback`, etc.
30
+
31
+ deploy = command_registry.command(
32
+ "deploy",
33
+ help="Deploy services to environments.",
34
+ params=[
35
+ # Inherited shared flags — visible on every sub-command.
36
+ arg("-v", "--verbose", action="store_true",
37
+ help="print details for each step"),
38
+ arg("-n", "--dry-run", action="store_true",
39
+ help="show steps, skip execution"),
40
+ ],
41
+ )
42
+
43
+
44
+ @deploy.command(
45
+ "app",
46
+ help="Deploy a service to an environment.",
47
+ params=[
48
+ # choices= drives argparse validation AND TAB completion simultaneously.
49
+ arg("environment", choices=["prod", "staging", "dev"],
50
+ help="target environment"),
51
+ arg("service", nargs="?", default="all",
52
+ choices=["api", "web", "worker"],
53
+ help="service to deploy, or 'all'"),
54
+ # Value-taking flags: completer= drives TAB completion for the value.
55
+ arg("-t", "--timeout", type=int, default=60, metavar="SECONDS",
56
+ help="deployment timeout in seconds",
57
+ completer=ChoiceCompleter(["30", "60", "120", "300"])),
58
+ arg("-b", "--branch", default="main", metavar="BRANCH",
59
+ help="git branch to deploy"),
60
+ ],
61
+ )
62
+ def deploy_app(environment, service, dry_run, verbose, timeout, branch):
63
+ import sys, time
64
+ # Inherited flags (dry_run, verbose) arrive as kwargs alongside this leaf's own.
65
+ # While this runs, press Ctrl+] to switch context without killing the deploy.
66
+ prefix = "[DRY RUN] " if dry_run else ""
67
+ print(f"{prefix}Deploying '{service}' to '{environment}' "
68
+ f"branch={branch!r} timeout={timeout}s")
69
+ for step, secs in [("Build image", 2),
70
+ ("Push to registry", 3),
71
+ ("Update deployment", 2),
72
+ ("Wait for rollout", 4),
73
+ ("Health checks", 2)]:
74
+ if verbose:
75
+ print(f" -> {step} ...", flush=True)
76
+ if not dry_run:
77
+ # Short sleeps with a flush touch sys.stdout each tick. In a
78
+ # pipeline, Ctrl-C closes the stage's stdout; the next flush()
79
+ # then raises promptly instead of grinding through the rest of
80
+ # the sleeps. Standalone, flush() is just a no-op cost.
81
+ for _ in range(secs * 10):
82
+ time.sleep(0.1)
83
+ sys.stdout.flush()
84
+ print(f" ok {step}")
85
+ print(f"{prefix}Done.")
86
+
87
+
88
+ @deploy.command(
89
+ "rollback",
90
+ help="Roll back a deployment to the previous revision.",
91
+ params=[
92
+ arg("environment", choices=["prod", "staging", "dev"],
93
+ help="target environment"),
94
+ arg("service", nargs="?", default="all",
95
+ choices=["api", "web", "worker"],
96
+ help="service to roll back, or 'all'"),
97
+ ],
98
+ )
99
+ def deploy_rollback(environment, service, dry_run, verbose):
100
+ prefix = "[DRY RUN] " if dry_run else ""
101
+ print(f"{prefix}Rolling back '{service}' in '{environment}'")
102
+ if verbose:
103
+ print(" -> fetching previous revision ...")
104
+ print(" -> swapping pointer ...")
105
+ print(f"{prefix}Done.")
106
+
107
+
108
+ @deploy.command(
109
+ "status",
110
+ help="Show deployment status for an environment.",
111
+ params=[
112
+ arg("environment", choices=["prod", "staging", "dev"],
113
+ help="environment to inspect"),
114
+ ],
115
+ )
116
+ def deploy_status(environment, verbose):
117
+ # Leaf only declares the inherited flags it cares about (verbose);
118
+ # dry_run is silently dropped because it's not in the signature.
119
+ print(f"Status for '{environment}':")
120
+ for name, ver in [("api", "v1.4.2"), ("web", "v1.4.2"), ("worker", "v1.4.1")]:
121
+ print(f" {name:8} {ver}")
122
+ if verbose:
123
+ print(f" deployed: 2026-05-26 14:32 UTC")
124
+
125
+
126
+ # ── Filter command (reads stdin, writes stdout) ───────────────────────────────
127
+ #
128
+ # Python @registry.command handlers are first-class pipeline stages: they can
129
+ # sit anywhere in a pipe (`producer | py_cmd | consumer`). Each stage runs
130
+ # on its own worker thread with `sys.stdin` / `sys.stdout` rebound to the
131
+ # pipe ends, so `print()` and iteration over `sys.stdin` "just work."
132
+ #
133
+ # Example: `cols` prints the requested whitespace-separated columns from
134
+ # each line — like `awk '{print $2, $5}'` but expressed in Python.
135
+ #
136
+ # ls -la | cols 9 5 # filename and size
137
+ # ps -A | cols 1 4 # PID and command
138
+ # printf 'a b c\nd e f\n' | cols 1 3 | grep d
139
+ #
140
+ # Caveats inherent to the in-process pipeline model — see
141
+ # doc/limitations.md for the full list:
142
+ #
143
+ # * If the consumer closes the pipe early (`cmd | head -1`), this stage
144
+ # gets a BrokenPipeError on its next print(); catching it is the
145
+ # well-behaved pattern for an unbounded producer.
146
+ # * Calling subprocess.run() from inside a piped Python command writes
147
+ # to the terminal, not the pipe, unless you pass stdout=sys.stdout.
148
+
149
+ import sys
150
+
151
+ @command_registry.command(
152
+ name="cols",
153
+ help="Print whitespace-separated columns by 1-based index from each stdin line.",
154
+ params=[
155
+ arg("indices", nargs="+", type=int, metavar="N",
156
+ help="1-based column number(s) to print"),
157
+ arg("-d", "--delim", default=" ", metavar="STR",
158
+ help="output delimiter (default: single space)"),
159
+ ],
160
+ )
161
+ def cols(indices, delim):
162
+ try:
163
+ for line in sys.stdin:
164
+ fields = line.split()
165
+ picked = [fields[i - 1] for i in indices if 0 < i <= len(fields)]
166
+ print(delim.join(picked))
167
+ except BrokenPipeError:
168
+ pass # downstream closed early (e.g. `... | head -1`) — normal
169
+
170
+
171
+ # ── Enable completion recipes for external commands ───────────────────────────
172
+
173
+ from eosh.recipes import enable
174
+ enable("*")
175
+
176
+
177
+ # ── Aliases ───────────────────────────────────────────────────────────────────
178
+ #
179
+ # Aliases expand the first token of a command line (bash-style):
180
+ #
181
+ # hp create ... → awsut sagemaker hyperpod create ...
182
+ # la /tmp → ls -la /tmp
183
+ #
184
+ # They participate in TAB completion: typing the alias name and pressing TAB
185
+ # completes the rest as if the expansion had been typed.
186
+
187
+ command_registry.alias("hp", "awsut sagemaker hyperpod")
188
+ command_registry.alias("la", "ls -la")
189
+
190
+
191
+ # ── Python-backed variables ───────────────────────────────────────────────────
192
+ #
193
+ # Register Vars to give `var NAME=VALUE` custom set logic and TAB completion
194
+ # for the value side. Two flavours:
195
+ #
196
+ # * EnvVar — 1-to-1 passthrough to a single os.environ key.
197
+ # * Var subclass — full control: write multiple env keys, validate, etc.
198
+ #
199
+ # At the prompt:
200
+ #
201
+ # eosh> var editor=<TAB> → vim, emacs, nano, code
202
+ # eosh> var editor=vim
203
+ # eosh> var http_proxy=http://... → sets HTTP_PROXY *and* HTTPS_PROXY
204
+
205
+ import os
206
+ from eosh.variables import registry as var_registry, EnvVar, Var
207
+
208
+ # Simple case: one logical name → one env var, with completion.
209
+ var_registry.register(EnvVar(
210
+ name="editor",
211
+ env_var="EDITOR",
212
+ completer=ChoiceCompleter(["vim", "emacs", "nano", "code"]),
213
+ description="Default text editor",
214
+ ))
215
+
216
+
217
+ # Custom case: one logical name → two env keys. Subclass Var when EnvVar
218
+ # isn't enough — e.g. when set() needs to write multiple env keys, validate
219
+ # the input, or trigger a side effect.
220
+ class _HttpProxyVar(Var):
221
+ """Sets HTTP_PROXY and HTTPS_PROXY together from one logical name."""
222
+
223
+ @property
224
+ def name(self) -> str:
225
+ return "http_proxy"
226
+
227
+ @property
228
+ def description(self) -> str:
229
+ return "HTTP/HTTPS proxy — sets HTTP_PROXY + HTTPS_PROXY"
230
+
231
+ @property
232
+ def env_keys(self) -> list[str]:
233
+ # Listed env keys are saved/restored on context switch.
234
+ return ["HTTP_PROXY", "HTTPS_PROXY"]
235
+
236
+ def get(self) -> str | None:
237
+ return os.environ.get("HTTP_PROXY")
238
+
239
+ def set(self, value: str) -> None:
240
+ os.environ["HTTP_PROXY"] = value
241
+ os.environ["HTTPS_PROXY"] = value
242
+
243
+ var_registry.register(_HttpProxyVar())
244
+
245
+
246
+ # ── Desktop notifications for long-running commands ───────────────────────────
247
+ #
248
+ # When a command runs longer than the threshold, eosh posts an OS
249
+ # notification as it finishes — you've probably switched to a browser by then.
250
+ # Backends are whatever the platform already ships (osascript / notify-send /
251
+ # a PowerShell toast), so there's nothing to install.
252
+ #
253
+ # At the prompt:
254
+ #
255
+ # eosh> var notify=off → disable for this session
256
+ # eosh> var notify_threshold=30 → only notify for commands ≥ 30s
257
+ #
258
+ # Both variables are process-global: unlike EnvVars above, they are not
259
+ # saved/restored on context switch.
260
+
261
+ from eosh import notify
262
+
263
+ # Default is 10 seconds; raise it if your day is full of 15-second builds.
264
+ notify.configure(threshold=10)
265
+
266
+ # Commands whose long runtime means "I sat in it", not "work finished".
267
+ # The defaults already cover editors, pagers, top, ssh, tmux and sub-shells;
268
+ # add your own interactive tools here.
269
+ notify.SKIP_COMMANDS.update({"psql", "mysql", "sqlite3", "ipython"})
270
+
271
+ # Replace the platform backend entirely — post to Slack, ntfy.sh, tmux, ...
272
+ # Runs on a daemon thread and must not write to the terminal.
273
+ #
274
+ # def my_notifier(title, message):
275
+ # import urllib.request
276
+ # urllib.request.urlopen(
277
+ # urllib.request.Request(
278
+ # "https://ntfy.sh/my-private-topic",
279
+ # data=f"{title}\n{message}".encode(),
280
+ # ),
281
+ # timeout=5,
282
+ # )
283
+ #
284
+ # notify.set_notifier(my_notifier)
285
+
286
+
287
+ # ── Customize the prompt ──────────────────────────────────────────────────────
288
+
289
+ from datetime import datetime
290
+ from eosh import set_prompt
291
+
292
+ def my_prompt(context_manager):
293
+ """Replicates the built-in default prompt: [context] parent/cwd HH:MM:SS [bg:N]>."""
294
+ CYAN_BOLD = "\033[1m\033[38;2;0;188;212m"
295
+ BLUE_BOLD = "\033[1m\033[38;2;100;149;237m"
296
+ GREEN = "\033[38;2;80;200;100m"
297
+ YELLOW = "\033[38;2;229;192;123m"
298
+ RESET = "\033[0m"
299
+
300
+ parts = []
301
+
302
+ ctx = context_manager.current()
303
+ if ctx and ctx.name != "default":
304
+ parts.append(f"{CYAN_BOLD}[{ctx.name}]{RESET}")
305
+
306
+ cwd = os.getcwd()
307
+ home = os.path.expanduser("~")
308
+ if cwd == home:
309
+ short_path = "~"
310
+ elif cwd.startswith(home + os.sep):
311
+ rel = cwd[len(home) + 1:]
312
+ rel_parts = rel.split(os.sep)
313
+ if len(rel_parts) <= 2:
314
+ short_path = "~/" + rel
315
+ else:
316
+ short_path = os.sep.join(rel_parts[-2:])
317
+ else:
318
+ abs_parts = cwd.lstrip(os.sep).split(os.sep)
319
+ if len(abs_parts) <= 2:
320
+ short_path = "/" + os.sep.join(abs_parts)
321
+ else:
322
+ short_path = os.sep.join(abs_parts[-2:])
323
+
324
+ timestamp = datetime.now().strftime("%H:%M:%S")
325
+ parts.append(f"{BLUE_BOLD}{short_path}{RESET}")
326
+ parts.append(f"{GREEN}{timestamp}{RESET}")
327
+
328
+ bg_count = 0
329
+ current_name = context_manager.current_name
330
+ for name, c in context_manager.contexts.items():
331
+ if name != current_name and c.process_slot and c.process_slot.is_alive():
332
+ bg_count += 1
333
+ if bg_count:
334
+ parts.append(f"{YELLOW}[bg:{bg_count}]{RESET}")
335
+
336
+ return " ".join(parts) + "> "
337
+
338
+ set_prompt(my_prompt)
339
+
340
+
341
+ # ── Color scheme ──────────────────────────────────────────────────────────────
342
+ #
343
+ # Choose a built-in scheme or define a fully custom one.
344
+ # Applies to TUI widgets — inline pickers and @watch's framing chrome
345
+ # (header / footer / scrollbar). Prompt colors are deliberately not part
346
+ # of the scheme: customize the prompt by passing a function to set_prompt().
347
+ # Built-in schemes: "dark" (default), "light".
348
+ #
349
+ # Uncomment one of the examples below:
350
+
351
+ from eosh import set_color_scheme, ColorScheme
352
+
353
+ # Built-in schemes (for light- or dark-background terminals):
354
+ # set_color_scheme("dark") # default
355
+ # set_color_scheme("light")
356
+
357
+ # Fully custom scheme — specify any subset of colors as (R, G, B) tuples:
358
+ # set_color_scheme(ColorScheme(
359
+ # picker_row_bg=(50, 50, 60), # non-selected picker row background
360
+ # picker_row_fg=(220, 220, 220), # non-selected picker row foreground
361
+ # picker_sel_bg=(80, 40, 160), # selected row background
362
+ # picker_sel_fg=(255, 255, 255), # selected row foreground
363
+ # scroll_thumb=(120, 120, 120), # scrollbar thumb (picker + @watch)
364
+ # scroll_track=(40, 40, 50), # scrollbar track (picker + @watch)
365
+ # statusbar_bg=(30, 30, 30), # picker bottom bar + @watch header/footer bg
366
+ # statusbar_fg=(200, 200, 200), # picker bottom bar + @watch header/footer fg
367
+ # ))
eosh/colors.py ADDED
@@ -0,0 +1,63 @@
1
+ """Color scheme support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ def _fg(r: int, g: int, b: int) -> str:
9
+ return f"\033[38;2;{r};{g};{b}m"
10
+
11
+
12
+ def _bg(r: int, g: int, b: int) -> str:
13
+ return f"\033[48;2;{r};{g};{b}m"
14
+
15
+
16
+ @dataclass
17
+ class ColorScheme:
18
+ # List rows — picker (and any future scrollable list widget)
19
+ picker_row_bg: tuple[int, int, int] = (68, 68, 68)
20
+ picker_row_fg: tuple[int, int, int] = (220, 220, 220)
21
+ picker_sel_bg: tuple[int, int, int] = (0, 95, 135)
22
+ picker_sel_fg: tuple[int, int, int] = (255, 255, 255)
23
+ # Scroll bar — shared by picker and @watch's body scroll
24
+ scroll_thumb: tuple[int, int, int] = (128, 128, 128)
25
+ scroll_track: tuple[int, int, int] = (48, 48, 48)
26
+ # Status bars — picker bottom hint bar + @watch header/footer
27
+ statusbar_bg: tuple[int, int, int] = (30, 30, 30)
28
+ statusbar_fg: tuple[int, int, int] = (200, 200, 200)
29
+
30
+
31
+ SCHEMES: dict[str, ColorScheme] = {
32
+ "dark": ColorScheme(),
33
+ "light": ColorScheme(
34
+ picker_row_bg=(220, 220, 220),
35
+ picker_row_fg=(30, 30, 30),
36
+ picker_sel_bg=(0, 100, 180),
37
+ picker_sel_fg=(255, 255, 255),
38
+ scroll_thumb=(160, 160, 160),
39
+ scroll_track=(200, 200, 200),
40
+ statusbar_bg=(195, 210, 225),
41
+ statusbar_fg=(60, 60, 80),
42
+ ),
43
+ }
44
+
45
+ _active: ColorScheme = SCHEMES["dark"]
46
+
47
+
48
+ def set_color_scheme(scheme: str | ColorScheme) -> None:
49
+ """Set the active color scheme by name or ColorScheme instance.
50
+
51
+ Available built-in names: "dark", "light".
52
+ """
53
+ global _active
54
+ if isinstance(scheme, str):
55
+ if scheme not in SCHEMES:
56
+ raise ValueError(f"Unknown color scheme {scheme!r}. Available: {sorted(SCHEMES)}")
57
+ _active = SCHEMES[scheme]
58
+ else:
59
+ _active = scheme
60
+
61
+
62
+ def get_color_scheme() -> ColorScheme:
63
+ return _active