lab-env 0.0.2__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.
- lab_env/__init__.py +10 -0
- lab_env/cli/__init__.py +5 -0
- lab_env/cli/app.py +90 -0
- lab_env/cli/callbacks.py +15 -0
- lab_env/cli/cmd_doctor.py +20 -0
- lab_env/cli/cmd_hosts.py +27 -0
- lab_env/cli/cmd_init.py +34 -0
- lab_env/cli/cmd_remote.py +113 -0
- lab_env/cli/cmd_shell.py +168 -0
- lab_env/cli/context.py +13 -0
- lab_env/config/__init__.py +15 -0
- lab_env/config/classes.py +40 -0
- lab_env/config/initialize.py +88 -0
- lab_env/config/load.py +164 -0
- lab_env/diagnostics.py +78 -0
- lab_env/remote.py +128 -0
- lab_env/shell/__init__.py +34 -0
- lab_env/shell/aliases.py +53 -0
- lab_env/shell/environment.py +122 -0
- lab_env/shell/functions.py +34 -0
- lab_env/shell/installation.py +131 -0
- lab_env/shell/models.py +28 -0
- lab_env/shell/paths.py +46 -0
- lab_env/shell/rendering.py +99 -0
- lab_env-0.0.2.dist-info/METADATA +115 -0
- lab_env-0.0.2.dist-info/RECORD +30 -0
- lab_env-0.0.2.dist-info/WHEEL +5 -0
- lab_env-0.0.2.dist-info/entry_points.txt +2 -0
- lab_env-0.0.2.dist-info/licenses/LICENSE +674 -0
- lab_env-0.0.2.dist-info/top_level.txt +1 -0
lab_env/__init__.py
ADDED
lab_env/cli/__init__.py
ADDED
lab_env/cli/app.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Typer application for the lab-env command-line interface."""
|
|
2
|
+
|
|
3
|
+
# --------------------------------------------------
|
|
4
|
+
# import necessary modules
|
|
5
|
+
# --------------------------------------------------
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from lab_env.cli.callbacks import version_callback
|
|
14
|
+
from lab_env.cli.cmd_doctor import cmd_doctor
|
|
15
|
+
from lab_env.cli.cmd_hosts import cmd_hosts
|
|
16
|
+
from lab_env.cli.cmd_init import cmd_init
|
|
17
|
+
from lab_env.cli.cmd_remote import cmd_connect, cmd_pull, cmd_push
|
|
18
|
+
from lab_env.cli.cmd_shell import shell_app
|
|
19
|
+
from lab_env.cli.context import CliContext
|
|
20
|
+
from lab_env.config import default_config_path
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------
|
|
23
|
+
# create Typer application
|
|
24
|
+
# --------------------------------------------------
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="lab",
|
|
27
|
+
help="Set up and inspect consistent research computing environments.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------
|
|
34
|
+
# callbacks: defined in callbacks.py
|
|
35
|
+
# - version_callback: handles the --version option
|
|
36
|
+
# --------------------------------------------------
|
|
37
|
+
@app.callback()
|
|
38
|
+
def main(
|
|
39
|
+
context: typer.Context,
|
|
40
|
+
config: Annotated[
|
|
41
|
+
Path | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--config",
|
|
44
|
+
help="Personal TOML configuration path.",
|
|
45
|
+
dir_okay=False,
|
|
46
|
+
),
|
|
47
|
+
] = None,
|
|
48
|
+
version: Annotated[
|
|
49
|
+
bool,
|
|
50
|
+
typer.Option(
|
|
51
|
+
"--version",
|
|
52
|
+
"-V",
|
|
53
|
+
callback=version_callback,
|
|
54
|
+
is_eager=True,
|
|
55
|
+
help="Show the installed version and exit.",
|
|
56
|
+
),
|
|
57
|
+
] = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Configure shared state for lab-env commands."""
|
|
60
|
+
|
|
61
|
+
del version
|
|
62
|
+
context.obj = CliContext(config_path=config or default_config_path())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------
|
|
66
|
+
# register commands: provided in cmd_*.py modules
|
|
67
|
+
# --------------------------------------------------
|
|
68
|
+
|
|
69
|
+
# init command
|
|
70
|
+
app.command(name="init", rich_help_panel="Configuration")(cmd_init)
|
|
71
|
+
|
|
72
|
+
# hosts command
|
|
73
|
+
app.command(name="hosts", rich_help_panel="Configuration")(cmd_hosts)
|
|
74
|
+
|
|
75
|
+
# doctor command
|
|
76
|
+
app.command(name="doctor", rich_help_panel="Diagnostics")(cmd_doctor)
|
|
77
|
+
|
|
78
|
+
# remote commands
|
|
79
|
+
app.command(name="connect", rich_help_panel="Remote")(cmd_connect)
|
|
80
|
+
app.command(name="pull", rich_help_panel="Remote")(cmd_pull)
|
|
81
|
+
app.command(name="push", rich_help_panel="Remote")(cmd_push)
|
|
82
|
+
|
|
83
|
+
# shell command
|
|
84
|
+
app.add_typer(shell_app, name="shell", rich_help_panel="Shell")
|
|
85
|
+
|
|
86
|
+
# --------------------------------------------------
|
|
87
|
+
# main entry point
|
|
88
|
+
# --------------------------------------------------
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
app()
|
lab_env/cli/callbacks.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shared callbacks for the lab-env command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from lab_env import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def version_callback(value: bool) -> None:
|
|
11
|
+
"""Print the installed package version and exit."""
|
|
12
|
+
|
|
13
|
+
if value:
|
|
14
|
+
typer.echo(f"lab {__version__}")
|
|
15
|
+
raise typer.Exit()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Run local lab-env diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from lab_env.cli.context import CliContext
|
|
8
|
+
from lab_env.diagnostics import run_diagnostics
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_doctor(context: typer.Context) -> None:
|
|
12
|
+
"""Check local configuration and tools without network activity."""
|
|
13
|
+
|
|
14
|
+
cli_context: CliContext = context.obj
|
|
15
|
+
results = run_diagnostics(cli_context.config_path)
|
|
16
|
+
for result in results:
|
|
17
|
+
typer.echo(f"[{result.status}] {result.name}: {result.detail}")
|
|
18
|
+
|
|
19
|
+
if any(result.status == "error" for result in results):
|
|
20
|
+
raise typer.Exit(1)
|
lab_env/cli/cmd_hosts.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""List configured lab host aliases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from lab_env.cli.context import CliContext
|
|
8
|
+
from lab_env.config import ConfigError, load_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_hosts(context: typer.Context) -> None:
|
|
12
|
+
"""List configured host aliases without making connections."""
|
|
13
|
+
|
|
14
|
+
cli_context: CliContext = context.obj
|
|
15
|
+
try:
|
|
16
|
+
config = load_config(cli_context.config_path)
|
|
17
|
+
except ConfigError as error:
|
|
18
|
+
typer.echo(f"error: {error}", err=True)
|
|
19
|
+
raise typer.Exit(2) from error
|
|
20
|
+
|
|
21
|
+
if not config.hosts:
|
|
22
|
+
typer.echo("no hosts configured")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
for host_name, host in sorted(config.hosts.items()):
|
|
26
|
+
description = f" - {host.description}" if host.description else ""
|
|
27
|
+
typer.echo(f"{host_name}: {host.destination}{description}")
|
lab_env/cli/cmd_init.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Initialize a personal lab-env configuration."""
|
|
2
|
+
|
|
3
|
+
# --------------------------------------------------
|
|
4
|
+
# load necessary modules
|
|
5
|
+
# --------------------------------------------------
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from lab_env.cli.context import CliContext
|
|
11
|
+
from lab_env.config import initialize_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# --------------------------------------------------
|
|
15
|
+
# init command
|
|
16
|
+
# --------------------------------------------------
|
|
17
|
+
def cmd_init(context: typer.Context) -> None:
|
|
18
|
+
"""Create a personal configuration file without changing shell dotfiles."""
|
|
19
|
+
|
|
20
|
+
cli_context: CliContext = context.obj
|
|
21
|
+
try:
|
|
22
|
+
config_path = initialize_config(cli_context.config_path)
|
|
23
|
+
except FileExistsError:
|
|
24
|
+
typer.echo(
|
|
25
|
+
f"configuration already exists: {cli_context.config_path.expanduser()}",
|
|
26
|
+
err=True,
|
|
27
|
+
)
|
|
28
|
+
raise typer.Exit(1) from None
|
|
29
|
+
except OSError as error:
|
|
30
|
+
typer.echo(f"error: cannot create configuration: {error}", err=True)
|
|
31
|
+
raise typer.Exit(1) from error
|
|
32
|
+
|
|
33
|
+
typer.echo(f"created configuration: {config_path}")
|
|
34
|
+
typer.echo("no shell startup files were modified")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Connect to configured hosts and transfer files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from lab_env.cli.context import CliContext
|
|
10
|
+
from lab_env.config import ConfigError, HostConfig, load_config
|
|
11
|
+
from lab_env.remote import (
|
|
12
|
+
RemoteCommandError,
|
|
13
|
+
build_connect_command,
|
|
14
|
+
build_transfer_command,
|
|
15
|
+
format_command,
|
|
16
|
+
resolve_host,
|
|
17
|
+
run_command,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_cli_host(context: typer.Context, host_name: str) -> HostConfig:
|
|
22
|
+
"""Load one host or convert configuration errors to CLI failures."""
|
|
23
|
+
|
|
24
|
+
cli_context: CliContext = context.obj
|
|
25
|
+
try:
|
|
26
|
+
config = load_config(cli_context.config_path)
|
|
27
|
+
return resolve_host(config, host_name)
|
|
28
|
+
except (ConfigError, RemoteCommandError) as error:
|
|
29
|
+
typer.echo(f"error: {error}", err=True)
|
|
30
|
+
raise typer.Exit(2) from error
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _execute(arguments: list[str], dry_run: bool) -> None:
|
|
34
|
+
"""Run one command and preserve its process exit status."""
|
|
35
|
+
|
|
36
|
+
if dry_run:
|
|
37
|
+
typer.echo(format_command(arguments))
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
result = run_command(arguments)
|
|
42
|
+
except RemoteCommandError as error:
|
|
43
|
+
typer.echo(f"error: {error}", err=True)
|
|
44
|
+
raise typer.Exit(1) from error
|
|
45
|
+
|
|
46
|
+
if result.return_code:
|
|
47
|
+
raise typer.Exit(result.return_code)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_connect(
|
|
51
|
+
context: typer.Context,
|
|
52
|
+
host_name: Annotated[str, typer.Argument(help="Configured host name.")],
|
|
53
|
+
remote_args: Annotated[
|
|
54
|
+
list[str] | None,
|
|
55
|
+
typer.Argument(help="Optional command and arguments to run remotely."),
|
|
56
|
+
] = None,
|
|
57
|
+
dry_run: Annotated[
|
|
58
|
+
bool,
|
|
59
|
+
typer.Option("--dry-run", help="Print the SSH command without running it."),
|
|
60
|
+
] = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Open SSH or run a command on a configured host."""
|
|
63
|
+
|
|
64
|
+
host = _load_cli_host(context, host_name)
|
|
65
|
+
arguments = build_connect_command(host, remote_args or ())
|
|
66
|
+
_execute(arguments, dry_run)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_pull(
|
|
70
|
+
context: typer.Context,
|
|
71
|
+
host_name: Annotated[str, typer.Argument(help="Configured host name.")],
|
|
72
|
+
remote_path: Annotated[str, typer.Argument(help="Remote source path.")],
|
|
73
|
+
local_path: Annotated[
|
|
74
|
+
str,
|
|
75
|
+
typer.Argument(help="Local destination path."),
|
|
76
|
+
] = ".",
|
|
77
|
+
dry_run: Annotated[
|
|
78
|
+
bool,
|
|
79
|
+
typer.Option("--dry-run", help="Print the rsync command without running it."),
|
|
80
|
+
] = False,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Pull files from a configured host with resumable rsync."""
|
|
83
|
+
|
|
84
|
+
host = _load_cli_host(context, host_name)
|
|
85
|
+
arguments = build_transfer_command(
|
|
86
|
+
"pull",
|
|
87
|
+
host,
|
|
88
|
+
remote_path,
|
|
89
|
+
local_path,
|
|
90
|
+
)
|
|
91
|
+
_execute(arguments, dry_run)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cmd_push(
|
|
95
|
+
context: typer.Context,
|
|
96
|
+
host_name: Annotated[str, typer.Argument(help="Configured host name.")],
|
|
97
|
+
local_path: Annotated[str, typer.Argument(help="Local source path.")],
|
|
98
|
+
remote_path: Annotated[str, typer.Argument(help="Remote destination path.")],
|
|
99
|
+
dry_run: Annotated[
|
|
100
|
+
bool,
|
|
101
|
+
typer.Option("--dry-run", help="Print the rsync command without running it."),
|
|
102
|
+
] = False,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Push files to a configured host with resumable rsync."""
|
|
105
|
+
|
|
106
|
+
host = _load_cli_host(context, host_name)
|
|
107
|
+
arguments = build_transfer_command(
|
|
108
|
+
"push",
|
|
109
|
+
host,
|
|
110
|
+
local_path,
|
|
111
|
+
remote_path,
|
|
112
|
+
)
|
|
113
|
+
_execute(arguments, dry_run)
|
lab_env/cli/cmd_shell.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Preview and manage lab-env shell startup integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from lab_env.cli.context import CliContext
|
|
11
|
+
from lab_env.config import ConfigError, load_config
|
|
12
|
+
from lab_env.shell import (
|
|
13
|
+
ShellIntegrationError,
|
|
14
|
+
ShellPaths,
|
|
15
|
+
install_shell,
|
|
16
|
+
integration_status,
|
|
17
|
+
render_generated_shell,
|
|
18
|
+
render_managed_block,
|
|
19
|
+
resolve_shell_paths,
|
|
20
|
+
uninstall_shell,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
shell_app = typer.Typer(
|
|
24
|
+
help="Preview and manage shell startup integration.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_cli_shell_paths(
|
|
31
|
+
context: typer.Context,
|
|
32
|
+
shell_name: str | None,
|
|
33
|
+
rc_path: Path | None,
|
|
34
|
+
) -> ShellPaths:
|
|
35
|
+
"""Resolve shell paths or convert validation errors to CLI failures."""
|
|
36
|
+
|
|
37
|
+
cli_context: CliContext = context.obj
|
|
38
|
+
try:
|
|
39
|
+
return resolve_shell_paths(
|
|
40
|
+
cli_context.config_path,
|
|
41
|
+
shell=shell_name,
|
|
42
|
+
rc_path=rc_path,
|
|
43
|
+
)
|
|
44
|
+
except ShellIntegrationError as error:
|
|
45
|
+
typer.echo(f"error: {error}", err=True)
|
|
46
|
+
raise typer.Exit(2) from error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@shell_app.command("preview")
|
|
50
|
+
def shell_preview_command(
|
|
51
|
+
context: typer.Context,
|
|
52
|
+
shell_name: Annotated[
|
|
53
|
+
str | None,
|
|
54
|
+
typer.Option("--shell", help="Shell to configure: bash or zsh."),
|
|
55
|
+
] = None,
|
|
56
|
+
rc_path: Annotated[
|
|
57
|
+
Path | None,
|
|
58
|
+
typer.Option("--rc", help="Override the shell startup file."),
|
|
59
|
+
] = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Show generated files and dotfile changes without writing anything."""
|
|
62
|
+
|
|
63
|
+
cli_context: CliContext = context.obj
|
|
64
|
+
try:
|
|
65
|
+
load_config(cli_context.config_path)
|
|
66
|
+
except ConfigError as error:
|
|
67
|
+
typer.echo(f"error: {error}", err=True)
|
|
68
|
+
raise typer.Exit(2) from error
|
|
69
|
+
|
|
70
|
+
paths = _resolve_cli_shell_paths(context, shell_name, rc_path)
|
|
71
|
+
typer.echo(f"shell: {paths.shell}")
|
|
72
|
+
typer.echo(f"startup file: {paths.rc_path}")
|
|
73
|
+
typer.echo(f"generated file: {paths.generated_path}")
|
|
74
|
+
typer.echo("\ngenerated shell file:\n")
|
|
75
|
+
typer.echo(render_generated_shell(cli_context.config_path), nl=False)
|
|
76
|
+
typer.echo("\nmanaged startup block:\n")
|
|
77
|
+
typer.echo(render_managed_block(paths.generated_path), nl=False)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@shell_app.command("install")
|
|
81
|
+
def shell_install_command(
|
|
82
|
+
context: typer.Context,
|
|
83
|
+
shell_name: Annotated[
|
|
84
|
+
str | None,
|
|
85
|
+
typer.Option("--shell", help="Shell to configure: bash or zsh."),
|
|
86
|
+
] = None,
|
|
87
|
+
rc_path: Annotated[
|
|
88
|
+
Path | None,
|
|
89
|
+
typer.Option("--rc", help="Override the shell startup file."),
|
|
90
|
+
] = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Install an idempotent managed block in the shell startup file."""
|
|
93
|
+
|
|
94
|
+
cli_context: CliContext = context.obj
|
|
95
|
+
try:
|
|
96
|
+
load_config(cli_context.config_path)
|
|
97
|
+
paths = resolve_shell_paths(
|
|
98
|
+
cli_context.config_path,
|
|
99
|
+
shell=shell_name,
|
|
100
|
+
rc_path=rc_path,
|
|
101
|
+
)
|
|
102
|
+
result = install_shell(paths, cli_context.config_path)
|
|
103
|
+
except (ConfigError, OSError, ShellIntegrationError) as error:
|
|
104
|
+
typer.echo(f"error: {error}", err=True)
|
|
105
|
+
raise typer.Exit(1) from error
|
|
106
|
+
|
|
107
|
+
if result.changed:
|
|
108
|
+
typer.echo(f"installed lab-env shell integration in {paths.rc_path}")
|
|
109
|
+
if result.backup_path is not None:
|
|
110
|
+
typer.echo(f"backup: {result.backup_path}")
|
|
111
|
+
else:
|
|
112
|
+
typer.echo(f"shell integration already installed: {paths.rc_path}")
|
|
113
|
+
typer.echo(f"generated: {paths.generated_path}")
|
|
114
|
+
typer.echo(f"activate now: source {paths.rc_path}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@shell_app.command("status")
|
|
118
|
+
def shell_status_command(
|
|
119
|
+
context: typer.Context,
|
|
120
|
+
shell_name: Annotated[
|
|
121
|
+
str | None,
|
|
122
|
+
typer.Option("--shell", help="Shell to inspect: bash or zsh."),
|
|
123
|
+
] = None,
|
|
124
|
+
rc_path: Annotated[
|
|
125
|
+
Path | None,
|
|
126
|
+
typer.Option("--rc", help="Override the shell startup file."),
|
|
127
|
+
] = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Report whether shell integration is installed and complete."""
|
|
130
|
+
|
|
131
|
+
paths = _resolve_cli_shell_paths(context, shell_name, rc_path)
|
|
132
|
+
status = integration_status(paths)
|
|
133
|
+
typer.echo(f"{paths.shell}: {status}")
|
|
134
|
+
typer.echo(f"startup file: {paths.rc_path}")
|
|
135
|
+
typer.echo(f"generated file: {paths.generated_path}")
|
|
136
|
+
if status == "incomplete":
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@shell_app.command("uninstall")
|
|
141
|
+
def shell_uninstall_command(
|
|
142
|
+
context: typer.Context,
|
|
143
|
+
shell_name: Annotated[
|
|
144
|
+
str | None,
|
|
145
|
+
typer.Option("--shell", help="Shell to configure: bash or zsh."),
|
|
146
|
+
] = None,
|
|
147
|
+
rc_path: Annotated[
|
|
148
|
+
Path | None,
|
|
149
|
+
typer.Option("--rc", help="Override the shell startup file."),
|
|
150
|
+
] = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Remove only lab-env-owned shell startup content."""
|
|
153
|
+
|
|
154
|
+
paths = _resolve_cli_shell_paths(context, shell_name, rc_path)
|
|
155
|
+
try:
|
|
156
|
+
previous_status = integration_status(paths)
|
|
157
|
+
backup_path = uninstall_shell(paths)
|
|
158
|
+
except (OSError, ShellIntegrationError) as error:
|
|
159
|
+
typer.echo(f"error: {error}", err=True)
|
|
160
|
+
raise typer.Exit(1) from error
|
|
161
|
+
|
|
162
|
+
if previous_status == "not installed":
|
|
163
|
+
typer.echo(f"shell integration is not installed: {paths.rc_path}")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
typer.echo(f"removed lab-env shell integration from {paths.rc_path}")
|
|
167
|
+
if backup_path is not None:
|
|
168
|
+
typer.echo(f"backup: {backup_path}")
|
lab_env/cli/context.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shared state for lab-env CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class CliContext:
|
|
11
|
+
"""Values shared by lab subcommands."""
|
|
12
|
+
|
|
13
|
+
config_path: Path
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Public configuration API for lab-env."""
|
|
2
|
+
|
|
3
|
+
from lab_env.config.classes import ConfigError, HostConfig, LabConfig, ShellConfig
|
|
4
|
+
from lab_env.config.initialize import default_config_path, initialize_config
|
|
5
|
+
from lab_env.config.load import load_config
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ConfigError",
|
|
9
|
+
"HostConfig",
|
|
10
|
+
"LabConfig",
|
|
11
|
+
"ShellConfig",
|
|
12
|
+
"default_config_path",
|
|
13
|
+
"initialize_config",
|
|
14
|
+
"load_config",
|
|
15
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Configuration data classes and shared schema constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
SCHEMA_VERSION = 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(ValueError):
|
|
11
|
+
"""Raised when personal configuration cannot be loaded or validated."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class HostConfig:
|
|
16
|
+
"""Connection metadata for one configured host."""
|
|
17
|
+
|
|
18
|
+
destination: str
|
|
19
|
+
description: str | None = None
|
|
20
|
+
ssh_command: str = "ssh"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class ShellConfig:
|
|
25
|
+
"""Shell aliases generated by lab-env."""
|
|
26
|
+
|
|
27
|
+
initialize_conda: bool = True
|
|
28
|
+
conda_init_path: str | None = None
|
|
29
|
+
default_aliases: bool = True
|
|
30
|
+
disabled_aliases: tuple[str, ...] = ()
|
|
31
|
+
aliases: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class LabConfig:
|
|
36
|
+
"""Validated personal lab-env configuration."""
|
|
37
|
+
|
|
38
|
+
schema_version: int = SCHEMA_VERSION
|
|
39
|
+
hosts: dict[str, HostConfig] = field(default_factory=dict)
|
|
40
|
+
shell: ShellConfig = field(default_factory=ShellConfig)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Configuration path resolution and initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from lab_env.config.classes import SCHEMA_VERSION
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_config_path() -> Path:
|
|
13
|
+
"""Return the platform-neutral user configuration path."""
|
|
14
|
+
|
|
15
|
+
configured_path = os.environ.get("LAB_ENV_CONFIG")
|
|
16
|
+
if configured_path:
|
|
17
|
+
return Path(configured_path).expanduser()
|
|
18
|
+
|
|
19
|
+
config_root = os.environ.get("XDG_CONFIG_HOME")
|
|
20
|
+
if config_root:
|
|
21
|
+
return Path(config_root).expanduser() / "lab-env" / "config.toml"
|
|
22
|
+
return Path.home() / ".config" / "lab-env" / "config.toml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def initialize_config(path: Path) -> Path:
|
|
26
|
+
"""Create a default personal configuration without replacing existing data.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
path: Destination configuration path.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The resolved path written to disk.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
FileExistsError: If the destination already exists.
|
|
36
|
+
OSError: If the destination cannot be created.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
resolved_path = path.expanduser()
|
|
40
|
+
resolved_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
41
|
+
created_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
42
|
+
config_text = (
|
|
43
|
+
"# --------------------------------------------------\n"
|
|
44
|
+
"# lab-env user configuration\n"
|
|
45
|
+
f"# created at: {created_at}\n"
|
|
46
|
+
"# persistent user-owned file; make and keep your changes here\n"
|
|
47
|
+
"# lab init will never overwrite this file\n"
|
|
48
|
+
"# --------------------------------------------------\n\n"
|
|
49
|
+
"# --------------------------------------------------\n"
|
|
50
|
+
"# basic settings: config format used by lab-env\n"
|
|
51
|
+
"# --------------------------------------------------\n"
|
|
52
|
+
"# Do not change this value manually.\n"
|
|
53
|
+
f"schema_version = {SCHEMA_VERSION}\n\n"
|
|
54
|
+
"# --------------------------------------------------\n"
|
|
55
|
+
"# shell configuration: controls generated shell integration\n"
|
|
56
|
+
"# --------------------------------------------------\n"
|
|
57
|
+
"[shell]\n"
|
|
58
|
+
"# Make conda activate available for common Miniforge/Miniconda installations.\n"
|
|
59
|
+
"initialize_conda = true\n"
|
|
60
|
+
"# Optional explicit path for custom or module-provided Conda installations.\n"
|
|
61
|
+
'# conda_init_path = "/path/to/etc/profile.d/conda.sh"\n'
|
|
62
|
+
"# Generate the standard aliases defined by lab-env.\n"
|
|
63
|
+
"default_aliases = true\n"
|
|
64
|
+
'# Skip selected defaults, for example: ["rm", "cp"].\n'
|
|
65
|
+
"disabled_aliases = []\n\n"
|
|
66
|
+
"# --------------------------------------------------\n"
|
|
67
|
+
"# user-defined aliases: persistent commands and default overrides\n"
|
|
68
|
+
"# --------------------------------------------------\n"
|
|
69
|
+
"[shell.aliases]\n"
|
|
70
|
+
'# gs = "git status"\n'
|
|
71
|
+
"# work = 'cd \"$HOME/work\"'\n"
|
|
72
|
+
'# ll = "eza --long --header"\n\n'
|
|
73
|
+
"# --------------------------------------------------\n"
|
|
74
|
+
"# remote hosts: named systems used by connect, pull, and push\n"
|
|
75
|
+
"# --------------------------------------------------\n"
|
|
76
|
+
"# Define each as [hosts.NAME]; authentication remains in ~/.ssh/config.\n"
|
|
77
|
+
"[hosts]\n\n"
|
|
78
|
+
"# Example named host and its available fields:\n"
|
|
79
|
+
"# [hosts.cluster]\n"
|
|
80
|
+
'# destination = "user@cluster.example.edu"\n'
|
|
81
|
+
'# description = "Research cluster"\n'
|
|
82
|
+
'# ssh_command = "ssh"\n'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
with resolved_path.open("x", encoding="utf-8") as stream:
|
|
86
|
+
stream.write(config_text)
|
|
87
|
+
|
|
88
|
+
return resolved_path.resolve()
|