exosphere-cli 0.9.9__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.
exosphere/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ import importlib.metadata
2
+
3
+ from .config import Configuration
4
+
5
+ # Global Instances: configuration and GlobalState
6
+ # These are set at runtime and should be used as singletons
7
+ # to hold the global state and configuration.
8
+
9
+ app_config = Configuration() # Has default values out of the box
10
+
11
+ # Current software version, imported from pyproject metadata
12
+ __version__ = importlib.metadata.version("exosphere_cli")
13
+
14
+ __all__ = ["__version__", "app_config"]
exosphere/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ import logging
2
+ import sys
3
+ from typing import Annotated
4
+
5
+ # ------------------win32 readline monkeypatch---------------------
6
+ if sys.platform == "win32":
7
+ try:
8
+ # On windows, we use a wrapper module for pyreadline3 in order
9
+ # to provide readline compatibility.
10
+ from exosphere.compat import win32readline as readline
11
+
12
+ # This needs monkeypatched in order for click_shell to make use
13
+ # of it instead of its internal, broken, legacy pyreadline.
14
+ sys.modules["readline"] = readline
15
+ except ImportError:
16
+ sys.stderr.write(
17
+ "Warning: pyreadline3 not found. "
18
+ "Interactive shell may not enable all features.\n"
19
+ )
20
+ # -----------------------------------------------------------------
21
+
22
+ from click_shell import make_click_shell
23
+ from rich import print
24
+ from rich.panel import Panel
25
+ from typer import Argument, Context, Exit, Typer
26
+
27
+ from exosphere import __version__
28
+ from exosphere.commands import config, host, inventory, ui
29
+
30
+ banner = f"""[turquoise4]
31
+ ▗▖
32
+ ▐▌
33
+ ▟█▙ ▝█ █▘ ▟█▙ ▗▟██▖▐▙█▙ ▐▙██▖ ▟█▙ █▟█▌ ▟█▙
34
+ ▐▙▄▟▌ ▐█▌ ▐▛ ▜▌▐▙▄▖▘▐▛ ▜▌▐▛ ▐▌▐▙▄▟▌ █▘ ▐▙▄▟▌
35
+ ▐▛▀▀▘ ▗█▖ ▐▌ ▐▌ ▀▀█▖▐▌ ▐▌▐▌ ▐▌▐▛▀▀▘ █ ▐▛▀▀▘
36
+ ▝█▄▄▌ ▟▀▙ ▝█▄█▘▐▄▄▟▌▐█▄█▘▐▌ ▐▌▝█▄▄▌ █ ▝█▄▄▌
37
+ ▝▀▀ ▝▀ ▀▘ ▝▀▘ ▀▀▀ ▐▌▀▘ ▝▘ ▝▘ ▝▀▀ ▀ ▝▀▀
38
+ ▐▌ [green]v{__version__}[/green][/turquoise4]
39
+ """
40
+
41
+ app = Typer(
42
+ no_args_is_help=False,
43
+ )
44
+
45
+ # Setup commands from modules
46
+ app.add_typer(inventory.app, name="inventory")
47
+ app.add_typer(host.app, name="host")
48
+ app.add_typer(ui.app, name="ui")
49
+ app.add_typer(config.app, name="config")
50
+
51
+
52
+ @app.command(hidden=True)
53
+ def help(ctx: Context, command: Annotated[str | None, Argument()] = None):
54
+ """
55
+ Help for interactive REPL use
56
+
57
+ Provides help for the root REPL command when used interactively,
58
+ in a way that is friendler for that specific context.
59
+ If a command is specified, it will show help for that command.
60
+
61
+ This only applies when in the interactive REPL, commands (including
62
+ the root 'exosphere' program) will use the standard Typer help
63
+ system when invoked from the command line or non-interactively.
64
+ """
65
+
66
+ msg = "\nUse '<command> --help' or 'help <command>' for help on a specific command."
67
+
68
+ # Show root help if no command is specified
69
+ if not command:
70
+ if ctx.parent and getattr(ctx.parent, "command", None):
71
+ subcommands = getattr(ctx.parent.command, "commands", {})
72
+ lines = []
73
+ for name, cmd in subcommands.items():
74
+ if cmd.hidden:
75
+ continue
76
+ lines.append(
77
+ f"[cyan]{name:<11}[/cyan] {cmd.help or 'No description available.'}"
78
+ )
79
+ content = "\n".join(lines)
80
+ panel = Panel.fit(
81
+ content,
82
+ title="Commands",
83
+ title_align="left",
84
+ )
85
+ print("\nAvailable modules during interactive use:\n")
86
+ print(panel)
87
+ print(msg)
88
+ return
89
+
90
+ # Show command help if one is specified
91
+ subcommand = None
92
+ if ctx.parent and getattr(ctx.parent, "command", None):
93
+ subcommands = getattr(ctx.parent.command, "commands", None)
94
+ subcommand = subcommands.get(command) if subcommands else None
95
+ if subcommand:
96
+ subcommand.get_help(ctx)
97
+ print(f"\nUse '{str(subcommand.name)} <command> --help' for more details.")
98
+ return
99
+
100
+ # Fall through for unknown commands
101
+ print(f"[red]Unkown command '{command}'[/red]")
102
+ print(msg)
103
+
104
+
105
+ @app.callback(invoke_without_command=True)
106
+ def cli(ctx: Context) -> None:
107
+ """
108
+ Exosphere CLI
109
+
110
+ The main command-line interface for Exosphere.
111
+ It provides a REPL interface for interactive use as a prompt, but can
112
+ also be used to run commands directly from the command line.
113
+
114
+ Run without arguments to start the interactive mode.
115
+ """
116
+ if ctx.invoked_subcommand is None:
117
+ logger = logging.getLogger(__name__)
118
+ logger.info("Starting Exosphere REPL interface")
119
+
120
+ # Print the banner
121
+ print(banner)
122
+
123
+ # Start interactive REPL
124
+ repl = make_click_shell(
125
+ ctx,
126
+ prompt="exosphere> ",
127
+ )
128
+ repl.cmdloop()
129
+ Exit(0)
File without changes
@@ -0,0 +1,163 @@
1
+ import os
2
+ from typing import Annotated
3
+
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.pretty import Pretty
7
+ from rich.text import Text
8
+
9
+ from exosphere import app_config, context
10
+ from exosphere.config import Configuration
11
+
12
+ app = typer.Typer(
13
+ help="Runtime Configuration Commands",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+ console = Console()
18
+ err_console = Console(stderr=True)
19
+
20
+
21
+ @app.command()
22
+ def show(
23
+ option: Annotated[
24
+ str | None,
25
+ typer.Argument(help="Name of the option to show. All if not specified."),
26
+ ] = None,
27
+ full: Annotated[
28
+ bool,
29
+ typer.Option(
30
+ "--full",
31
+ "-f",
32
+ help="Show full configuration structure, including inventory.",
33
+ ),
34
+ ] = False,
35
+ ) -> None:
36
+ """
37
+ Show the current configuration.
38
+
39
+ Displays the current configuration options, or the value of a specific option
40
+ if specified.
41
+
42
+ If `--full` is specified, it will show the entire configuration structure,
43
+ including the inventory, beyond just the "options" section.
44
+ """
45
+
46
+ if full:
47
+ if option:
48
+ err_console.print(
49
+ "[yellow]Full configuration requested, ignoring option name.[/yellow]"
50
+ )
51
+ console.print(Pretty(app_config, expand_all=True, max_depth=None))
52
+ return
53
+
54
+ if option:
55
+ if option in app_config["options"]:
56
+ console.print(app_config["options"][option])
57
+ else:
58
+ err_console.print(
59
+ f"[red]Option '{option}' not found in configuration.[/red]"
60
+ )
61
+
62
+ return
63
+
64
+ console.print(Pretty(app_config["options"], expand_all=True))
65
+
66
+
67
+ @app.command()
68
+ def source(
69
+ env: Annotated[
70
+ bool,
71
+ typer.Option(
72
+ help="Show environment variables that affect the configuration.",
73
+ ),
74
+ ] = True,
75
+ ) -> None:
76
+ """
77
+ Show the configuration source, where it was loaded from.
78
+
79
+ Displays the path of the configuration file loaded, if any, and
80
+ any environment variables that affect the configuration.
81
+ """
82
+
83
+ if context.confpath:
84
+ console.print(f"{context.confpath}")
85
+ else:
86
+ err_console.print("No configuration loaded, using defaults.")
87
+
88
+ if not env:
89
+ return
90
+
91
+ env_lines: list[str] = []
92
+
93
+ prefix = "EXOSPHERE_OPTIONS_"
94
+
95
+ for key, value in os.environ.items():
96
+ if (
97
+ key.startswith(prefix)
98
+ and key.removeprefix(prefix).lower() in app_config["options"]
99
+ ):
100
+ env_lines.append(f"{key}={value}")
101
+
102
+ if env_lines:
103
+ console.print()
104
+ console.print("Environment variable overrides:\n")
105
+ for line in env_lines:
106
+ console.print(f" {line}")
107
+ console.print()
108
+
109
+
110
+ @app.command()
111
+ def diff(
112
+ full: Annotated[
113
+ bool,
114
+ typer.Option(
115
+ "--full",
116
+ "-f",
117
+ help="Show full configuration diff, including unmodified options.",
118
+ ),
119
+ ] = False,
120
+ ):
121
+ """
122
+ Show the differences between the current configuration and the defaults.
123
+
124
+ Exosphere follows convention over configuration, so your configuration
125
+ file can exclusively contain the options you want to change.
126
+
127
+ This command allows you to see exactly what has been changed, optionally
128
+ in its context, using the `--full` option.
129
+
130
+ For a full config dump, use the `show` command instead.
131
+ """
132
+
133
+ default_config = Configuration.DEFAULTS["options"]
134
+ current_config = app_config["options"]
135
+
136
+ for key in set(default_config) | set(current_config):
137
+ if default_config.get(key, None) != current_config.get(key, None):
138
+ break
139
+ else:
140
+ console.print("No differences found between current and default configuration.")
141
+ return
142
+
143
+ lines = []
144
+ for key in sorted(set(default_config) | set(current_config)):
145
+ default_value = default_config.get(key, None)
146
+ current_value = current_config.get(key, None)
147
+
148
+ line: Text | None
149
+
150
+ if default_value != current_value:
151
+ line = Text(f"{key!r}: {current_value!r},", style="bold green")
152
+ line.append(f" # default: {default_value!r}", style="yellow")
153
+ else:
154
+ line = Text(f"{key!r}: {current_value!r},", style="dim") if full else None
155
+
156
+ if line:
157
+ lines.append(line)
158
+
159
+ console.print("{")
160
+ for line in lines:
161
+ console.print(" ", end="")
162
+ console.print(line)
163
+ console.print("}")
@@ -0,0 +1,317 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from rich.panel import Panel
4
+ from rich.progress import (
5
+ Progress,
6
+ SpinnerColumn,
7
+ TextColumn,
8
+ TimeElapsedColumn,
9
+ )
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+ from typing_extensions import Annotated
13
+
14
+ from exosphere import app_config, context
15
+ from exosphere.objects import Host
16
+
17
+ # Steal the save function from inventory command
18
+ from .inventory import save as save_inventory
19
+
20
+ app = typer.Typer(
21
+ help="Host management commands",
22
+ no_args_is_help=True,
23
+ )
24
+
25
+ console = Console()
26
+ err_console = Console(stderr=True)
27
+
28
+
29
+ def _get_inventory():
30
+ """
31
+ Get the inventory from context
32
+ A convenience wrapper that bails if the inventory is not initialized.
33
+ """
34
+ if context.inventory is None:
35
+ typer.echo(
36
+ "Inventory is not initialized, are you running this module directly?",
37
+ err=True,
38
+ )
39
+ raise typer.Exit(code=1)
40
+
41
+ return context.inventory
42
+
43
+
44
+ def _get_host(name: str) -> Host | None:
45
+ """
46
+ Wraps inventory.get_host() to handle displaying errors on console
47
+ """
48
+
49
+ inventory = _get_inventory()
50
+
51
+ host = inventory.get_host(name)
52
+
53
+ if host is None:
54
+ err_console.print(
55
+ Panel.fit(
56
+ f"Host '{name}' not found in inventory.",
57
+ title="Error",
58
+ style="red",
59
+ )
60
+ )
61
+ return None
62
+
63
+ return host
64
+
65
+
66
+ @app.command()
67
+ def show(
68
+ name: Annotated[str, typer.Argument(help="Host from inventory to show")],
69
+ include_updates: Annotated[
70
+ bool,
71
+ typer.Option(
72
+ "--updates/--no-updates",
73
+ "-u/-n",
74
+ help="Show update details for the host",
75
+ ),
76
+ ] = True,
77
+ security_only: Annotated[
78
+ bool,
79
+ typer.Option(
80
+ "--security-only",
81
+ "-s",
82
+ help="Show only security updates for the host when displaying updates",
83
+ ),
84
+ ] = False,
85
+ ) -> None:
86
+ """
87
+ Show details of a specific host.
88
+
89
+ This command retrieves the host by name from the inventory
90
+ and displays its details in a rich format.
91
+ """
92
+ host = _get_host(name)
93
+
94
+ if host is None:
95
+ raise typer.Exit(code=1)
96
+
97
+ # Color security updates count
98
+ security_count = (
99
+ f"[red]{len(host.security_updates)}[/red]" if host.security_updates else "0"
100
+ )
101
+
102
+ # prepare host OS details
103
+ host_os_details = (
104
+ f"{host.flavor} {host.os} {host.version}"
105
+ if host.flavor != host.os
106
+ else f"{host.os} {host.version}"
107
+ )
108
+
109
+ if not host.last_refresh:
110
+ last_refresh = "[red]Never[/red]"
111
+ else:
112
+ # Format: "Fri May 21:04:43 EDT 2025"
113
+ last_refresh = host.last_refresh.strftime("%a %b %d %H:%M:%S %Y")
114
+
115
+ # Display host properties in a rich panel
116
+ console.print(
117
+ Panel.fit(
118
+ f"[bold]Host Name:[/bold] {host.name}\n"
119
+ f"[bold]IP Address:[/bold] {host.ip}\n"
120
+ f"[bold]Port:[/bold] {host.port}\n"
121
+ f"[bold]Online Status:[/bold] {'[bold green]Online[/bold green]' if host.online else '[red]Offline[/red]'}\n"
122
+ "\n"
123
+ f"[bold]Last Refreshed:[/bold] {last_refresh}\n"
124
+ f"[bold]Stale:[/bold] {'[yellow]Yes[/yellow]' if host.is_stale else 'No'}\n"
125
+ "\n"
126
+ f"[bold]Operating System:[/bold]\n"
127
+ f" {host_os_details}, using {host.package_manager}\n"
128
+ "\n"
129
+ f"[bold]Updates Available:[/bold] {len(host.updates)} updates, {security_count} security\n",
130
+ title=host.description if host.description else "Host Details",
131
+ )
132
+ )
133
+
134
+ if not include_updates:
135
+ # Warn for invalid set of arguments
136
+ if security_only:
137
+ err_console.print(
138
+ "[yellow]Warning: --security-only option is only valid with --updates, ignoring.[/yellow]"
139
+ )
140
+
141
+ raise typer.Exit(code=0)
142
+
143
+ update_list = host.updates if not security_only else host.security_updates
144
+
145
+ # Display updates in a rich table, if any
146
+ if not update_list:
147
+ console.print("[bold]No updates available for this host.[/bold]")
148
+ raise typer.Exit(code=0)
149
+
150
+ updates_table = Table(
151
+ "Name",
152
+ "Current Version",
153
+ "New Version",
154
+ "Security",
155
+ "Source",
156
+ title="Available Updates",
157
+ )
158
+
159
+ for update in update_list:
160
+ updates_table.add_row(
161
+ f"[bold]{update.name}[/bold]",
162
+ update.current_version if update.current_version else "(NEW)",
163
+ update.new_version,
164
+ "Yes" if update.security else "No",
165
+ Text(update.source or "N/A", no_wrap=True),
166
+ style="on bright_black" if update.security else "default",
167
+ )
168
+
169
+ console.print(updates_table)
170
+
171
+
172
+ @app.command()
173
+ def discover(
174
+ name: Annotated[str, typer.Argument(help="Host from inventory to discover")],
175
+ ) -> None:
176
+ """
177
+ Gather platform data for host.
178
+
179
+ This command retrieves the host by name from the inventory
180
+ and synchronizes its platform data.
181
+ """
182
+ host = _get_host(name)
183
+
184
+ if host is None:
185
+ raise typer.Exit(code=1)
186
+
187
+ with Progress(
188
+ SpinnerColumn(),
189
+ TextColumn("[progress.description]{task.description}"),
190
+ TimeElapsedColumn(),
191
+ ) as progress:
192
+ progress.add_task(f"Discovering platform for '{host.name}'", total=None)
193
+ try:
194
+ host.discover()
195
+ except Exception as e:
196
+ progress.console.print(
197
+ Panel.fit(
198
+ f"{str(e)}",
199
+ title="[red]Error[/red]",
200
+ style="red",
201
+ title_align="left",
202
+ )
203
+ )
204
+
205
+ if app_config["options"]["cache_autosave"]:
206
+ save_inventory()
207
+
208
+
209
+ @app.command()
210
+ def refresh(
211
+ name: Annotated[str, typer.Argument(help="Host from inventory to refresh")],
212
+ full: Annotated[
213
+ bool, typer.Option("--sync", "-s", help="Also refresh package catalog")
214
+ ] = False,
215
+ discover: Annotated[
216
+ bool, typer.Option("--discover", "-d", help="Also refresh platform information")
217
+ ] = False,
218
+ ) -> None:
219
+ """
220
+ Refresh the updates for a specific host.
221
+
222
+ This command retrieves the host by name from the inventory
223
+ and refreshes its updates.
224
+ """
225
+ host = _get_host(name)
226
+
227
+ if host is None:
228
+ raise typer.Exit(code=1)
229
+
230
+ with Progress(
231
+ SpinnerColumn(),
232
+ TextColumn("[progress.description]{task.description}"),
233
+ TimeElapsedColumn(),
234
+ ) as progress:
235
+ if discover:
236
+ task = progress.add_task(
237
+ f"Refreshing platform information for '{host.name}'", total=None
238
+ )
239
+ try:
240
+ host.discover()
241
+ except Exception as e:
242
+ progress.console.print(
243
+ Panel.fit(
244
+ f"{str(e)}",
245
+ title="[red]Error[/red]",
246
+ style="red",
247
+ title_align="left",
248
+ )
249
+ )
250
+ progress.stop_task(task)
251
+ raise typer.Exit(code=1)
252
+
253
+ progress.stop_task(task)
254
+
255
+ if full:
256
+ task = progress.add_task(
257
+ f"Refreshing package catalog for '{host.name}'", total=None
258
+ )
259
+ try:
260
+ host.refresh_catalog()
261
+ except Exception as e:
262
+ progress.console.print(
263
+ Panel.fit(
264
+ f"{str(e)}",
265
+ title="[red]Error[/red]",
266
+ style="red",
267
+ title_align="left",
268
+ )
269
+ )
270
+ progress.stop_task(task)
271
+ raise typer.Exit(code=1)
272
+
273
+ progress.stop_task(task)
274
+
275
+ task = progress.add_task(f"Refreshing updates for '{host.name}'", total=None)
276
+ try:
277
+ host.refresh_updates()
278
+ except Exception as e:
279
+ progress.console.print(
280
+ Panel.fit(
281
+ f"{str(e)}",
282
+ title="[red]Error[/red]",
283
+ style="red",
284
+ title_align="left",
285
+ )
286
+ )
287
+
288
+ if app_config["options"]["cache_autosave"]:
289
+ save_inventory()
290
+
291
+
292
+ @app.command()
293
+ def ping(
294
+ name: Annotated[str, typer.Argument(help="Host from inventory to ping")],
295
+ ) -> None:
296
+ """
297
+ Ping a specific host to check its reachability.
298
+
299
+ This command will also update a host's online status
300
+ based on the ping result.
301
+
302
+ The ping is is based on ssh connectivity.
303
+ """
304
+ host = _get_host(name)
305
+
306
+ if host is None:
307
+ raise typer.Exit(code=1)
308
+
309
+ if host.ping():
310
+ console.print(
311
+ f"Host [bold]{host.name}[/bold] is [bold green]Online[/bold green]."
312
+ )
313
+ else:
314
+ console.print(f"Host [bold]{host.name}[/bold] is [red]Offline[/red].")
315
+
316
+ if app_config["options"]["cache_autosave"]:
317
+ save_inventory()