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 ADDED
@@ -0,0 +1,10 @@
1
+ """Tools for consistent research computing environments."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("lab-env")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,5 @@
1
+ """Command-line interface for lab-env."""
2
+
3
+ from lab_env.cli.app import app
4
+
5
+ __all__ = ["app"]
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()
@@ -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)
@@ -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}")
@@ -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)
@@ -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()