dockhand-cli 0.3.1__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.
dockhand/__init__.py ADDED
@@ -0,0 +1,335 @@
1
+ from typing import Annotated, List
2
+
3
+ import typer
4
+
5
+ from dockhand.build import execute_build
6
+ from dockhand.client import get_client
7
+ from dockhand.config import DockerResubmitConfig, cli_config
8
+ from dockhand.constants import CONFIG_FILENAME
9
+ from dockhand.download import execute_download
10
+ from dockhand.history import execute_history
11
+ from dockhand.manage import execute_logs, execute_prune, execute_queue, execute_remove, execute_stats, execute_stop
12
+ from dockhand.queue import ts_make_urgent
13
+ from dockhand.resubmit import execute_resubmit
14
+ from dockhand.submit import execute_submit
15
+ from dockhand.tunnel import execute_tunnel
16
+ from dockhand.volumes import execute_volumes
17
+
18
+ __version__ = "0.2.0"
19
+
20
+ cli = typer.Typer(pretty_exceptions_show_locals=False)
21
+
22
+
23
+ class DockerDefault:
24
+ def __init__(self, key: str):
25
+ self.key = key
26
+
27
+ def __call__(self):
28
+ return getattr(cli_config.docker, self.key, None)
29
+
30
+ def __str__(self):
31
+ value = getattr(cli_config.docker, self.key, None)
32
+ return str(value)
33
+
34
+
35
+ class SyncDefault:
36
+ def __call__(self):
37
+ return cli_config.sync
38
+
39
+ def __str__(self):
40
+ return str(cli_config.sync)
41
+
42
+
43
+ def profile_callback(profile: str | None):
44
+ if profile is not None:
45
+ cli_config.load_profile(profile)
46
+
47
+
48
+ def version_callback(value: bool):
49
+ if value:
50
+ typer.echo(__version__)
51
+ raise typer.Exit()
52
+
53
+
54
+ @cli.callback()
55
+ def main(
56
+ profile: Annotated[
57
+ str, typer.Option("--profile", callback=profile_callback, help="Optional profile from config.")
58
+ ] = None,
59
+ version: Annotated[bool, typer.Option("--version", callback=version_callback)] = False,
60
+ ):
61
+ pass
62
+
63
+
64
+ @cli.command()
65
+ def submit(
66
+ commands: List[str],
67
+ imagename: Annotated[str, typer.Option(default_factory=DockerDefault("imagename"))],
68
+ gpus: Annotated[str, typer.Option(default_factory=DockerDefault("gpus"))],
69
+ sync: Annotated[bool, typer.Option(default_factory=SyncDefault())],
70
+ ports: Annotated[List[str], typer.Option("-p")] = [],
71
+ urgent: Annotated[bool, typer.Option("--urgent", help="Move to front of queue.")] = False,
72
+ slots: Annotated[int, typer.Option("--slots", help="Queue slots to reserve (e.g. number of CPUs).")] = None,
73
+ ):
74
+ """Sync code and queue a container run with the given command(s)."""
75
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
76
+ execute_submit(
77
+ cli_config.docker,
78
+ commands,
79
+ sync=sync,
80
+ imagename=imagename,
81
+ gpus=gpus,
82
+ ports=ports or None,
83
+ urgent=urgent,
84
+ slots=slots,
85
+ )
86
+
87
+
88
+ @cli.command()
89
+ def run(
90
+ commands: List[str],
91
+ imagename: Annotated[str, typer.Option(default_factory=DockerDefault("imagename"))],
92
+ gpus: Annotated[str, typer.Option(default_factory=DockerDefault("gpus"))],
93
+ ports: Annotated[List[str], typer.Option("-p")] = [],
94
+ urgent: Annotated[bool, typer.Option("--urgent", help="Move to front of queue.")] = False,
95
+ slots: Annotated[int, typer.Option("--slots", help="Queue slots to reserve (e.g. number of CPUs).")] = None,
96
+ ):
97
+ """Queue a container run without syncing code first."""
98
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
99
+ execute_submit(
100
+ cli_config.docker, commands, sync=False,
101
+ imagename=imagename, gpus=gpus, ports=ports or None, urgent=urgent, slots=slots,
102
+ )
103
+
104
+
105
+ @cli.command()
106
+ def install(
107
+ dockerfile: Annotated[str, typer.Option(default_factory=DockerDefault("dockerfile"))],
108
+ imagename: Annotated[str, typer.Option(default_factory=DockerDefault("imagename"))],
109
+ sync: Annotated[bool, typer.Option(default_factory=SyncDefault())],
110
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show docker build output.")] = False,
111
+ ):
112
+ """Install (build) the Docker image."""
113
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
114
+ execute_build(cli_config.docker, sync=sync, dockerfile=dockerfile, imagename=imagename, verbose=verbose)
115
+
116
+
117
+ @cli.command()
118
+ def logs(
119
+ id: Annotated[
120
+ str | None,
121
+ typer.Argument(help="Job ID. Defaults to last job."),
122
+ ] = None,
123
+ n: int | None = None,
124
+ follow: Annotated[bool, typer.Option("--follow", "-f", help="Stream output as the job runs.")] = False,
125
+ ):
126
+ """Show logs from a job (defaults to last)."""
127
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
128
+ job_id = int(id) if id is not None else None
129
+ execute_logs(cli_config.docker, job_id=job_id, n=n, follow=follow)
130
+
131
+
132
+ @cli.command()
133
+ def stop(
134
+ id: Annotated[
135
+ str | None,
136
+ typer.Argument(help="Job ID. Defaults to last running job."),
137
+ ] = None,
138
+ ):
139
+ """Stop a running job (defaults to last)."""
140
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
141
+ job_id = int(id) if id is not None else None
142
+ execute_stop(cli_config.docker, job_id=job_id)
143
+
144
+
145
+ @cli.command()
146
+ def jobs(
147
+ all: Annotated[
148
+ bool, typer.Option("--all", help="Show every job, including finished/failed (no 30-job limit).")
149
+ ] = False,
150
+ queue: Annotated[
151
+ bool,
152
+ typer.Option(
153
+ "--queue", help="Show the host's whole task-spooler queue, including other projects' jobs."
154
+ ),
155
+ ] = False,
156
+ ):
157
+ """List jobs, newest first (defaults to the last 30 active jobs)."""
158
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
159
+ if queue:
160
+ execute_queue(cli_config.docker)
161
+ return
162
+ execute_stats(cli_config.docker, all=all)
163
+
164
+
165
+ @cli.command()
166
+ def urgent(
167
+ id: Annotated[
168
+ str | None,
169
+ typer.Argument(help="Job ID to promote to the front of the queue. Defaults to last job."),
170
+ ] = None,
171
+ ):
172
+ """Promote a queued job to the front of the queue."""
173
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
174
+ from dockhand.history import get_history_entry, load_history
175
+ from dockhand.transport import entry_handle, transport_for_entry
176
+
177
+ if id is None:
178
+ history = load_history()
179
+ if not history:
180
+ typer.echo("No job history found. Provide a job ID.")
181
+ raise typer.Exit(1)
182
+ entry = history[-1]
183
+ else:
184
+ entry = get_history_entry(int(id))
185
+ if entry is None:
186
+ typer.echo(f"Job #{id} not found in history.")
187
+ raise typer.Exit(1)
188
+
189
+ local_id = entry["local_id"]
190
+ if transport_for_entry(entry).name != "task_spooler":
191
+ typer.echo(f"Job #{local_id} was not queued — 'urgent' only applies to queued jobs.")
192
+ raise typer.Exit(1)
193
+
194
+ with get_client() as client:
195
+ if ts_make_urgent(client, entry_handle(entry), cwd=cli_config.remote_path):
196
+ typer.echo(f"Job #{local_id} moved to front of queue.")
197
+ else:
198
+ typer.echo(f"Failed to promote job #{local_id}. It may have already started or finished.")
199
+ raise typer.Exit(1)
200
+
201
+
202
+ @cli.command()
203
+ def history():
204
+ """Show history of past Docker runs."""
205
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
206
+ execute_history(cli_config.docker)
207
+
208
+
209
+ @cli.command()
210
+ def volumes(
211
+ id: Annotated[
212
+ str | None,
213
+ typer.Argument(help="Job ID to use mounts from. Defaults to config."),
214
+ ] = None,
215
+ depth: Annotated[
216
+ int | None,
217
+ typer.Option("--depth", help="Maximum directory depth to display."),
218
+ ] = None,
219
+ ):
220
+ """List the full container filesystem as a tree."""
221
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
222
+ imagename, override_volumes = _resolve_volumes_overrides(id)
223
+ execute_volumes(cli_config.docker, depth=depth, imagename=imagename, volumes=override_volumes)
224
+
225
+
226
+ @cli.command()
227
+ def download(
228
+ path: Annotated[
229
+ str | None,
230
+ typer.Argument(help="Workdir-relative path to download (as shown by volumes)"),
231
+ ] = None,
232
+ local_path: Annotated[
233
+ str | None,
234
+ typer.Option("--local-path", "-l", help="Local destination path (defaults to project root)"),
235
+ ] = None,
236
+ list_only: Annotated[
237
+ bool,
238
+ typer.Option("--list", help="List files in docker-mounted volumes (same as volumes)"),
239
+ ] = False,
240
+ depth: Annotated[
241
+ int | None,
242
+ typer.Option("--depth", help="Maximum directory depth to display (only applies with --list)."),
243
+ ] = None,
244
+ id: Annotated[
245
+ str | None,
246
+ typer.Option("--id", help="Job ID to use mounts from (only applies with --list)."),
247
+ ] = None,
248
+ ):
249
+ """Download a file from a docker volume by its workdir-relative path.
250
+
251
+ Files are downloaded preserving their directory structure relative to project root."""
252
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
253
+ if list_only:
254
+ imagename, override_volumes = _resolve_volumes_overrides(id)
255
+ execute_volumes(cli_config.docker, depth=depth, imagename=imagename, volumes=override_volumes)
256
+ return
257
+ if path is None:
258
+ typer.echo("Error: Missing argument 'PATH'. Use --list to see available files.")
259
+ raise typer.Exit(1)
260
+ execute_download(cli_config.docker, path=path, local_path=local_path)
261
+
262
+
263
+ @cli.command()
264
+ def resubmit(
265
+ id: Annotated[
266
+ str | None,
267
+ typer.Argument(help="Job ID to resubmit. Defaults to last."),
268
+ ] = None,
269
+ commands: List[str] | None = None,
270
+ imagename: str | None = None,
271
+ gpus: str | None = None,
272
+ ):
273
+ """Resubmit a previous job (defaults to latest). Optionally with new parameters."""
274
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
275
+ config = DockerResubmitConfig(
276
+ container_id=id,
277
+ commands=commands,
278
+ imagename=imagename,
279
+ gpus=gpus,
280
+ )
281
+ execute_resubmit(cli_config.docker, config)
282
+
283
+
284
+ @cli.command()
285
+ def remove(
286
+ ids: Annotated[
287
+ List[str],
288
+ typer.Argument(help="Job IDs to remove from queue. Defaults to last queued job."),
289
+ ] = None,
290
+ from_history: bool = False,
291
+ ):
292
+ """Remove queued job(s) that haven't started yet (defaults to last)."""
293
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
294
+ job_ids = [int(i) for i in ids] if ids else None
295
+ execute_remove(cli_config.docker, job_ids=job_ids, from_history=from_history)
296
+
297
+
298
+ @cli.command()
299
+ def prune(
300
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
301
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would be removed without removing.")] = False,
302
+ ):
303
+ """Remove baked images no longer referenced by an active job."""
304
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
305
+ execute_prune(cli_config.docker, yes=yes, dry_run=dry_run)
306
+
307
+
308
+ @cli.command()
309
+ def tunnel(
310
+ container_id: str | None = None,
311
+ ports: Annotated[List[str], typer.Option("-p")] = [],
312
+ ):
313
+ """Forward docker container ports to localhost via SSH tunnel.
314
+
315
+ Defaults to ports from the last (or specified) run. Use -p to override."""
316
+ cli_config.check_docker(msg=f"docker requires a Docker configuration in '{CONFIG_FILENAME}'")
317
+ execute_tunnel(container_id=container_id, ports=ports or None)
318
+
319
+
320
+ def _resolve_volumes_overrides(id: str | None) -> tuple[str | None, list | None]:
321
+ """Look up imagename and volumes from history for a given job ID."""
322
+ if id is None:
323
+ return None, None
324
+ from dockhand.history import get_history_entry
325
+
326
+ entry = get_history_entry(int(id))
327
+ if entry is None:
328
+ typer.echo(f"Warning: Job #{id} not found in history — using config defaults.")
329
+ return None, None
330
+ cfg = entry["config"]
331
+ return cfg.get("imagename"), cfg.get("volumes")
332
+
333
+
334
+ if __name__ == "__main__":
335
+ cli()
dockhand/build.py ADDED
@@ -0,0 +1,35 @@
1
+ """Docker image building."""
2
+ from rich.progress import Progress, SpinnerColumn, TextColumn
3
+
4
+ from dockhand.client import get_client
5
+ from dockhand.config import DockerConfig, cli_config
6
+ from dockhand.error import error_and_exit
7
+ from dockhand.sync import execute_sync
8
+
9
+
10
+ def execute_build(
11
+ config: DockerConfig,
12
+ sync: bool,
13
+ dockerfile: str | None = None,
14
+ imagename: str | None = None,
15
+ verbose: bool = False,
16
+ ):
17
+ """Build the Docker image."""
18
+ if sync:
19
+ execute_sync(confirm_changes=True)
20
+
21
+ dockerfile = dockerfile or config.dockerfile
22
+ imagename = imagename or config.imagename
23
+ cmd = f"docker build -f {dockerfile} -t {imagename} ."
24
+
25
+ with get_client() as client:
26
+ if verbose:
27
+ returncode, _ = client.run(cmd, cwd=cli_config.remote_path, capture=False)
28
+ else:
29
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}")) as progress:
30
+ task = progress.add_task(description="Building image", total=None)
31
+ returncode, _ = client.run(cmd, cwd=cli_config.remote_path, capture=True)
32
+ progress.update(task, completed=True)
33
+
34
+ if returncode != 0:
35
+ error_and_exit(f"Build command failed with return code {returncode}.")
@@ -0,0 +1,38 @@
1
+ import socket
2
+
3
+ from dockhand.client.base import Client
4
+ from dockhand.client.local import LocalClient
5
+ from dockhand.client.ssh import SSHClient
6
+ from dockhand.config import cli_config
7
+
8
+
9
+ def _is_localhost(hostname: str) -> bool:
10
+ """Check if hostname is localhost or resolves to 127.0.0.1/::1."""
11
+ if hostname in ("localhost", "127.0.0.1", "::1"):
12
+ return True
13
+ try:
14
+ # Resolve hostname to IP and check if it's loopback
15
+ ip = socket.gethostbyname(hostname)
16
+ return ip.startswith("127.")
17
+ except (socket.gaierror, socket.error):
18
+ # If resolution fails, assume it's not localhost
19
+ return False
20
+
21
+
22
+ def get_client() -> Client:
23
+ # Check if hostname is localhost or not set - use local client
24
+ if cli_config.ssh is None or _is_localhost(cli_config.ssh.hostname):
25
+ return LocalClient()
26
+ else:
27
+ return SSHClient()
28
+
29
+
30
+ def get_client_for_host(hostname: str) -> Client:
31
+ """Return a client connected to a specific host (e.g. from job history).
32
+
33
+ Uses SSH credentials from config but overrides the hostname. Falls back
34
+ to LocalClient if the hostname resolves to localhost.
35
+ """
36
+ if _is_localhost(hostname):
37
+ return LocalClient()
38
+ return SSHClient(hostname=hostname)
@@ -0,0 +1,37 @@
1
+ import abc
2
+
3
+
4
+ class Client(abc.ABC):
5
+ def __enter__(self):
6
+ return self
7
+
8
+ def __exit__(self, exc_type, exc_val, exc_tb):
9
+ self.close()
10
+
11
+ def __del__(self):
12
+ self.close()
13
+
14
+ @abc.abstractmethod
15
+ def run(self, command: str, cwd: str | None = None, capture: bool = False) -> tuple[int, str]:
16
+ """Run a command. When capture=True, suppress live output to the terminal."""
17
+ pass
18
+
19
+ @abc.abstractmethod
20
+ def close(self):
21
+ pass
22
+
23
+ @abc.abstractmethod
24
+ def remove(self, path: str):
25
+ pass
26
+
27
+ @abc.abstractmethod
28
+ def exists(self, path: str) -> bool:
29
+ pass
30
+
31
+ @abc.abstractmethod
32
+ def load(self, path: str) -> str:
33
+ pass
34
+
35
+ @abc.abstractmethod
36
+ def save(self, path: str, contents: str):
37
+ pass
@@ -0,0 +1,45 @@
1
+ import os
2
+ import subprocess
3
+
4
+ import typer
5
+
6
+ from dockhand.client.base import Client
7
+
8
+
9
+ class LocalClient(Client):
10
+ def close(self):
11
+ pass
12
+
13
+ def run(self, command: str, cwd: str | None = None, capture: bool = False) -> tuple[int, str]:
14
+ # Ignore the cwd parameter since we assume that the user is running the command from the correct directory.
15
+ outputs = []
16
+ with subprocess.Popen(
17
+ command,
18
+ shell=True,
19
+ text=True,
20
+ stdout=subprocess.PIPE,
21
+ stderr=subprocess.STDOUT,
22
+ bufsize=1,
23
+ universal_newlines=True,
24
+ ) as process:
25
+ for line in process.stdout:
26
+ if not capture:
27
+ typer.echo(line, nl=False)
28
+ outputs.append(line)
29
+ returncode = process.wait()
30
+ output = "".join(outputs)
31
+ return returncode, output
32
+
33
+ def remove(self, path: str):
34
+ os.remove(path)
35
+
36
+ def exists(self, path):
37
+ return os.path.exists(path)
38
+
39
+ def load(self, path: str) -> str:
40
+ with open(path, "r") as f:
41
+ return f.read()
42
+
43
+ def save(self, path: str, contents: str):
44
+ with open(path, "w") as f:
45
+ f.write(contents)
dockhand/client/ssh.py ADDED
@@ -0,0 +1,53 @@
1
+ import fabric
2
+
3
+ from dockhand.client.base import Client
4
+ from dockhand.config import cli_config
5
+ from dockhand.constants import CONFIG_FILENAME
6
+
7
+
8
+ class SSHClient(Client):
9
+ def __init__(self, hostname: str | None = None):
10
+ super().__init__()
11
+
12
+ cli_config.check_ssh(msg=f"Please provide a SSH configuration in '{CONFIG_FILENAME}'.")
13
+
14
+ self.client = fabric.Connection(
15
+ host=hostname or cli_config.ssh.hostname,
16
+ user=cli_config.ssh.user,
17
+ connect_kwargs={"key_filename": cli_config.ssh.identityfile},
18
+ )
19
+
20
+ def close(self):
21
+ self.client.close()
22
+
23
+ def run(self, command: str, cwd: str | None = None, capture: bool = False) -> tuple[int, str]:
24
+ command = f'bash -l -c "{command}"'
25
+ hide = "both" if capture else None
26
+ if cwd is not None:
27
+ with self.client.cd(cwd):
28
+ result = self.client.run(command, warn=True, hide=hide)
29
+ else:
30
+ result = self.client.run(command, warn=True, hide=hide)
31
+ return result.exited, result.stdout
32
+
33
+ def remove(self, path: str):
34
+ sftp = self.client.sftp()
35
+ sftp.remove(path)
36
+
37
+ def exists(self, path):
38
+ sftp = self.client.sftp()
39
+ try:
40
+ sftp.stat(path)
41
+ return True
42
+ except FileNotFoundError:
43
+ return False
44
+
45
+ def load(self, path: str) -> str:
46
+ sftp = self.client.sftp()
47
+ with sftp.file(path, "r") as f:
48
+ return f.read().decode("utf-8")
49
+
50
+ def save(self, path: str, contents: str):
51
+ sftp = self.client.sftp()
52
+ with sftp.file(path, "w") as f:
53
+ f.write(contents)