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/config.py ADDED
@@ -0,0 +1,355 @@
1
+ import dataclasses
2
+ import json
3
+ from hashlib import sha256
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ import typer
8
+
9
+ from dockhand.constants import CONFIG_FILENAME, HISTORY_FILENAME
10
+ from dockhand.error import error_and_exit
11
+
12
+
13
+ @dataclasses.dataclass
14
+ class DockerVolumesConfig:
15
+ hostpath: str
16
+ containerpath: str
17
+ permissions: str
18
+
19
+
20
+ @dataclasses.dataclass
21
+ class DockerResubmitConfig:
22
+ container_id: str | None
23
+ commands: List[str] | None
24
+ imagename: str | None
25
+ gpus: str | None
26
+
27
+
28
+ CODE_DELIVERY_MODES = ("mount", "bake")
29
+
30
+
31
+ @dataclasses.dataclass
32
+ class DockerConfig:
33
+ dockerfile: str
34
+ imagename: str
35
+ volumes: list[DockerVolumesConfig]
36
+ ports: list[str]
37
+ gpus: str
38
+ containerworkdir: str
39
+ preserve_paths: list[str] = dataclasses.field(default_factory=list)
40
+ # None means "derive from queue.enabled" (bake when queued, mount otherwise).
41
+ code_delivery: str | None = None
42
+
43
+ @classmethod
44
+ def load(cls, config: dict):
45
+ if "docker" not in config:
46
+ return None
47
+
48
+ docker = config["docker"]
49
+ if "dockerfile" not in docker:
50
+ error_and_exit('"dockerfile" not found in docker config.')
51
+
52
+ if "volumes" not in docker:
53
+ error_and_exit('"volumes" not found in docker config.')
54
+
55
+ if "imagename" not in docker:
56
+ error_and_exit('"imagename" not found in docker config.')
57
+
58
+ if "gpus" not in docker:
59
+ docker["gpus"] = None
60
+
61
+ if "ports" not in docker:
62
+ docker["ports"] = None
63
+
64
+ if "containerworkdir" not in docker:
65
+ docker["containerworkdir"] = "/"
66
+
67
+ if "preserve_paths" not in docker:
68
+ docker["preserve_paths"] = []
69
+
70
+ code_delivery = docker.get("code_delivery")
71
+ if code_delivery is not None and code_delivery not in CODE_DELIVERY_MODES:
72
+ error_and_exit(
73
+ f"Invalid value for code_delivery in docker config. Expected one of {CODE_DELIVERY_MODES} "
74
+ f"but got {code_delivery!r}."
75
+ )
76
+
77
+ # Only pass fields that DockerConfig expects
78
+ return cls(
79
+ dockerfile=docker["dockerfile"],
80
+ imagename=docker["imagename"],
81
+ volumes=docker["volumes"],
82
+ ports=docker["ports"],
83
+ gpus=docker["gpus"],
84
+ containerworkdir=docker["containerworkdir"],
85
+ preserve_paths=docker["preserve_paths"],
86
+ code_delivery=code_delivery,
87
+ )
88
+
89
+ def resolve_code_delivery(self, queue_enabled: bool) -> str:
90
+ """Resolve the effective delivery mode, applying the queue-derived default."""
91
+ if self.code_delivery is not None:
92
+ return self.code_delivery
93
+ return "bake" if queue_enabled else "mount"
94
+
95
+ @classmethod
96
+ def validate(cls, config: dict) -> dict:
97
+ if not isinstance(config, dict):
98
+ error_and_exit(f"Invalid type for ssh option in config. Expected dictionary but got {type(config)}.")
99
+
100
+ output = {}
101
+
102
+ dockerfile = config.get("dockerfile")
103
+ if dockerfile is not None:
104
+ if not isinstance(dockerfile, str):
105
+ error_and_exit(f"Invalid type for dockerfile. Expected string, got {type(dockerfile).__name__}.")
106
+ output["dockerfile"] = dockerfile
107
+
108
+ imagename = config.get("imagename")
109
+ if imagename is not None:
110
+ if not isinstance(imagename, str):
111
+ error_and_exit(
112
+ f"Invalid type for compose_file option in docker config. Expected string but got {type(imagename)}."
113
+ )
114
+ output["imagename"] = imagename
115
+
116
+ volumes = config.get("volumes")
117
+ if volumes is not None:
118
+ if not isinstance(volumes, str):
119
+ error_and_exit(
120
+ f"Invalid type for compose_file option in docker config. Expected string but got {type(volumes)}."
121
+ )
122
+ output["volumes"] = volumes
123
+
124
+ ports = config.get("ports")
125
+ if ports is not None:
126
+ if not isinstance(ports, str):
127
+ error_and_exit(
128
+ f"Invalid type for compose_file option in docker config. Expected string but got {type(ports)}."
129
+ )
130
+ output["ports"] = ports
131
+
132
+ gpus = config.get("gpus")
133
+ if gpus is not None:
134
+ if not isinstance(gpus, str):
135
+ error_and_exit(
136
+ f"Invalid type for gpus option in docker config. Expected string but got {type(gpus).__name__}."
137
+ )
138
+ output["gpus"] = gpus
139
+
140
+ preserve_paths = config.get("preserve_paths")
141
+ if preserve_paths is not None:
142
+ if not isinstance(preserve_paths, list) or not all(isinstance(p, str) for p in preserve_paths):
143
+ error_and_exit(
144
+ "Invalid value for preserve_paths option in docker config. Expected a list of strings but got "
145
+ f"{preserve_paths!r}."
146
+ )
147
+ output["preserve_paths"] = preserve_paths
148
+
149
+ return output
150
+
151
+
152
+ @dataclasses.dataclass
153
+ class QueueConfig:
154
+ enabled: bool = False
155
+ tool: str = "task_spooler"
156
+ slots: int = 1
157
+
158
+ @classmethod
159
+ def load(cls, config: dict):
160
+ queue = config.get("queue", {})
161
+ if not isinstance(queue, dict):
162
+ error_and_exit(f"Invalid type for queue option in config. Expected dictionary but got {type(queue)}.")
163
+ enabled = queue.get("enabled", False)
164
+ tool = queue.get("tool", "task_spooler")
165
+ slots = cls._load_slots(queue, config.get("docker") or {})
166
+ return cls(enabled=enabled, tool=tool, slots=slots)
167
+
168
+ @staticmethod
169
+ def _load_slots(queue: dict, docker: dict) -> int:
170
+ slots = queue.get("slots")
171
+ if slots is None and isinstance(docker, dict) and "slots" in docker:
172
+ # Back-compat: slots used to live under the docker block.
173
+ typer.echo(
174
+ "Warning: 'docker.slots' is deprecated — move it to 'queue.slots'.",
175
+ err=True,
176
+ )
177
+ slots = docker["slots"]
178
+ if slots is None:
179
+ return 1
180
+ if not isinstance(slots, int) or isinstance(slots, bool) or slots < 1:
181
+ error_and_exit(
182
+ f"Invalid value for slots option in queue config. Expected a positive integer but got {slots!r}."
183
+ )
184
+ return slots
185
+
186
+
187
+ @dataclasses.dataclass
188
+ class SSHConfig:
189
+ hostname: str
190
+ user: str
191
+ identityfile: str
192
+
193
+ @classmethod
194
+ def load(cls, config: dict):
195
+ if "ssh" not in config:
196
+ return None
197
+
198
+ ssh = config["ssh"]
199
+ ssh = SSHConfig.validate(ssh)
200
+
201
+ if "hostname" not in ssh:
202
+ error_and_exit('"hostname" not found in SSH config.')
203
+
204
+ if "user" not in ssh:
205
+ error_and_exit('"user" not found in SSH config.')
206
+
207
+ if "identityfile" not in ssh:
208
+ error_and_exit('"identityfile" not found in SSH config')
209
+
210
+ return cls(**ssh)
211
+
212
+ @classmethod
213
+ def validate(cls, config: dict) -> dict:
214
+ if not isinstance(config, dict):
215
+ error_and_exit(f"Invalid type for ssh option in config. Expected dictionary but got {type(config)}.")
216
+
217
+ output = {}
218
+
219
+ hostname = config.get("hostname")
220
+ if hostname is not None:
221
+ if not isinstance(hostname, str):
222
+ error_and_exit(f"Invalid type for host option in ssh config. Expected string but got {type(hostname)}.")
223
+ output["hostname"] = hostname
224
+
225
+ user = config.get("user")
226
+ if user is not None:
227
+ if not isinstance(user, str):
228
+ error_and_exit(f"Invalid type for user option in ssh config. Expected string but got {type(user)}.")
229
+ output["user"] = user
230
+
231
+ identityfile = config.get("identityfile")
232
+ if identityfile is not None:
233
+ if not isinstance(identityfile, str):
234
+ error_and_exit(
235
+ f"Invalid type for identityfile option in ssh config. Expected string but got {type(identityfile)}."
236
+ )
237
+ output["identityfile"] = str(Path(identityfile).expanduser())
238
+
239
+ return output
240
+
241
+
242
+ @dataclasses.dataclass
243
+ class CLIConfig:
244
+ history_path: Path
245
+ project_root: Path
246
+ remote_path: str
247
+ sync: bool
248
+ profiles: dict | None
249
+ ssh: SSHConfig | None
250
+ docker: DockerConfig | None
251
+ queue: QueueConfig = dataclasses.field(default_factory=QueueConfig)
252
+
253
+ @classmethod
254
+ def load(cls):
255
+ project_root = cls.get_project_root()
256
+
257
+ git_path = project_root / ".git"
258
+ if not git_path.exists():
259
+ error_and_exit(f"Could not find git repository at '{git_path}'.")
260
+
261
+ path = project_root / CONFIG_FILENAME
262
+
263
+ try:
264
+ config = json.loads(path.read_text())
265
+ except json.JSONDecodeError as e:
266
+ error_and_exit(f"Error while parsing config file at '{path}':\n{e}")
267
+
268
+ if not isinstance(config, dict):
269
+ error_and_exit(f"Invalid type for config. Expected dictionary but got {type(config)}.")
270
+
271
+ profiles = config.get("profiles")
272
+ if profiles is not None and not isinstance(profiles, dict):
273
+ error_and_exit(f"Invalid type for profiles option in config. Expected dictionary but got {type(profiles)}.")
274
+
275
+ history_path = cls.load_history_path(config, project_root)
276
+ remote_path = cls.load_remote_path(config, project_root)
277
+ sync = config.get("sync", True)
278
+ ssh = SSHConfig.load(config)
279
+ docker = DockerConfig.load(config)
280
+ queue = QueueConfig.load(config)
281
+
282
+ return cls(
283
+ history_path=history_path,
284
+ profiles=profiles,
285
+ project_root=project_root,
286
+ remote_path=remote_path,
287
+ sync=sync,
288
+ ssh=ssh,
289
+ docker=docker,
290
+ queue=queue,
291
+ )
292
+
293
+ @classmethod
294
+ def get_project_root(cls) -> Path:
295
+ """Assume that config file exist in the project root and use that to get the project root."""
296
+ root = Path("/")
297
+ current_path = Path.cwd()
298
+ while current_path != root:
299
+ if (current_path / CONFIG_FILENAME).exists():
300
+ return current_path
301
+ current_path = current_path.parent
302
+
303
+ if (root / CONFIG_FILENAME).exists():
304
+ return root
305
+
306
+ error_and_exit(
307
+ f"Could not find project root. Make sure that '{CONFIG_FILENAME}' exists in the root of the project."
308
+ )
309
+
310
+ @classmethod
311
+ def load_history_path(cls, config: dict, project_root: Path) -> Path:
312
+ if "history_path" in config:
313
+ history_path = config["history_path"]
314
+ if not isinstance(history_path, str):
315
+ error_and_exit(
316
+ f"Invalid type for history_path option in config. Expected string but got {type(history_path)}."
317
+ )
318
+ return Path(history_path)
319
+ return project_root / HISTORY_FILENAME
320
+
321
+ @classmethod
322
+ def load_remote_path(cls, config: dict, project_root: Path) -> str:
323
+ if "remote_path" in config:
324
+ return config["remote_path"]
325
+
326
+ name = project_root.name
327
+ hash = sha256(str(project_root).encode()).hexdigest()[:8]
328
+ return f"~/{name}-{hash}"
329
+
330
+ def check_ssh(self, msg: str = "SSH configuration is required for this command."):
331
+ if self.ssh is None:
332
+ error_and_exit(msg)
333
+
334
+ def check_docker(self, msg: str = "Docker configuration is required for this command"):
335
+ if self.docker is None:
336
+ error_and_exit(msg)
337
+
338
+ def load_profile(self, name: str):
339
+ if name not in self.profiles:
340
+ error_and_exit(f"Profile '{name}' not found in config.")
341
+
342
+ profile = self.profiles[name]
343
+
344
+ if "history_path" in profile:
345
+ self.history_path = CLIConfig.load_history_path(profile, self.project_root)
346
+
347
+ if "remote_path" in profile:
348
+ self.remote_path = profile["remote_path"]
349
+
350
+ if "ssh" in profile:
351
+ ssh = SSHConfig.validate(profile["ssh"])
352
+ self.ssh = dataclasses.replace(self.ssh, **ssh)
353
+
354
+
355
+ cli_config = CLIConfig.load()
dockhand/constants.py ADDED
@@ -0,0 +1,2 @@
1
+ CONFIG_FILENAME = ".dockhand.json"
2
+ HISTORY_FILENAME = ".dockhand_history.json"
dockhand/download.py ADDED
@@ -0,0 +1,53 @@
1
+ """Download files from docker volumes."""
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from rich.progress import Progress, SpinnerColumn, TextColumn
6
+
7
+ from dockhand.config import DockerConfig, cli_config
8
+ from dockhand.error import error_and_exit
9
+ from dockhand.volumes import _resolve_to_host
10
+
11
+
12
+ def execute_download(config: DockerConfig, path: str, local_path: str | None = None):
13
+ """Download a file from a docker volume by its workdir-relative path."""
14
+ result = _resolve_to_host(path, config)
15
+ if result is None:
16
+ error_and_exit(f"Path '{path}' does not match any configured docker volume.")
17
+
18
+ host_path, _ = result
19
+ ssh = cli_config.ssh
20
+
21
+ # If no local_path provided, mirror the remote path structure locally.
22
+ # For directory downloads (trailing slash), use the directory itself as the destination
23
+ # so rsync places contents at reports/figures/ rather than reports/.
24
+ # For file downloads, use the parent directory so rsync places the file correctly.
25
+ if local_path is None:
26
+ if path.endswith("/"):
27
+ dest = cli_config.project_root / Path(path.rstrip("/"))
28
+ dest.mkdir(parents=True, exist_ok=True)
29
+ local_path = str(dest)
30
+ else:
31
+ parent_dir = cli_config.project_root / Path(path).parent
32
+ parent_dir.mkdir(parents=True, exist_ok=True)
33
+ local_path = str(parent_dir)
34
+
35
+ with Progress(
36
+ SpinnerColumn(),
37
+ TextColumn("[progress.description]{task.description}"),
38
+ ) as progress:
39
+ task = progress.add_task(description="Downloading", total=None)
40
+ progress.start()
41
+ try:
42
+ command = [
43
+ "rsync",
44
+ "-avz",
45
+ "-e",
46
+ f"ssh -i {ssh.identityfile}",
47
+ f"{ssh.user}@{ssh.hostname}:{host_path}",
48
+ local_path,
49
+ ]
50
+ subprocess.run(command, check=True, capture_output=True)
51
+ except subprocess.CalledProcessError as e:
52
+ error_and_exit(f"Download failed:\n{e.stderr.decode()}")
53
+ progress.update(task, completed=True)
dockhand/error.py ADDED
@@ -0,0 +1,10 @@
1
+ import sys
2
+
3
+ from rich import print as rich_print
4
+ from rich.panel import Panel
5
+
6
+
7
+ def error_and_exit(message: str, code: int = 1):
8
+ panel = Panel(message, border_style="red", title="Error", title_align="left", highlight=True)
9
+ rich_print(panel)
10
+ sys.exit(code)
dockhand/history.py ADDED
@@ -0,0 +1,171 @@
1
+ """Docker container run history management and tracking."""
2
+
3
+ import json
4
+ import time
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import List
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from dockhand.config import DockerConfig
15
+ from dockhand.constants import HISTORY_FILENAME
16
+
17
+ DOCKER_HISTORY_FILE = Path(".dockhand_history.json")
18
+
19
+
20
+ def load_history() -> list[dict]:
21
+ """Load container run history from disk."""
22
+ # path = DOCKER_HISTORY_FILE
23
+ path = Path(HISTORY_FILENAME)
24
+ if not path.exists():
25
+ return []
26
+ return json.loads(path.read_text())
27
+
28
+
29
+ def save_history(history: list[dict]):
30
+ """Save container run history to disk."""
31
+ # path = DOCKER_HISTORY_FILE
32
+ path = Path(HISTORY_FILENAME)
33
+ path.write_text(json.dumps(history))
34
+
35
+
36
+ def _next_local_id(history: list[dict]) -> int:
37
+ if not history:
38
+ return 1
39
+ return max((e.get("local_id", 0) for e in history), default=0) + 1
40
+
41
+
42
+ def reserve_local_id() -> int:
43
+ """Peek the next local job ID without persisting it.
44
+
45
+ Submitting needs the ID before the job runs (e.g. to name the container), so it
46
+ is reserved here and passed to :func:`add_to_history` once the job has started.
47
+ """
48
+ return _next_local_id(load_history())
49
+
50
+
51
+ def add_to_history(
52
+ config: DockerConfig,
53
+ commands: List[str],
54
+ *,
55
+ local_id: int,
56
+ handle: dict,
57
+ image_ref: str | None = None,
58
+ branch: str | None = None,
59
+ ports: list[str] | None = None,
60
+ host: str | None = None,
61
+ ) -> int:
62
+ """Add a started job to the history file. Returns the local job ID.
63
+
64
+ ``handle`` carries the transport-specific job handle (e.g. ``transport`` name and
65
+ a ``ts_job_id`` or container ``handle``) and is merged into the entry. ``image_ref``
66
+ is the exact image that ran (a resolved baked tag, or the base image name for mount
67
+ delivery) so the job can be reproduced verbatim on resubmit.
68
+ """
69
+ history = load_history()
70
+ _d = {
71
+ "gpus": config.gpus,
72
+ "volumes": config.volumes,
73
+ "imagename": config.imagename,
74
+ "commands": commands,
75
+ "ports": ports,
76
+ }
77
+ if image_ref is not None:
78
+ _d["image_ref"] = image_ref
79
+ if branch is not None:
80
+ _d["branch"] = branch
81
+ entry = {
82
+ "local_id": local_id,
83
+ "timestamp": time.time(),
84
+ "config": _d,
85
+ **handle,
86
+ }
87
+ if host is not None:
88
+ entry["host"] = host
89
+ history.append(entry)
90
+ save_history(history)
91
+ return local_id
92
+
93
+
94
+ def get_history_entry(local_id: int) -> dict | None:
95
+ """Look up a history entry by local job ID."""
96
+ history = load_history()
97
+ for entry in reversed(history):
98
+ if entry.get("local_id") == local_id:
99
+ return entry
100
+ return None
101
+
102
+
103
+ def mark_stopped(local_id: int):
104
+ """Flag a history entry as user-stopped, so ``jobs`` can show it distinctly."""
105
+ history = load_history()
106
+ for entry in history:
107
+ if entry.get("local_id") == local_id:
108
+ entry["stopped"] = True
109
+ save_history(history)
110
+
111
+
112
+ def mark_job_time(local_id: int, *, started_at: float | None = None, ended_at: float | None = None):
113
+ """Record start/end timestamps for a job, without overwriting ones already set.
114
+
115
+ Neither the queue nor docker exposes exact start/end timestamps cheaply, so these
116
+ are stamped lazily the first time a command happens to observe the job in that
117
+ state (see ``manage.py``).
118
+ """
119
+ history = load_history()
120
+ changed = False
121
+ for entry in history:
122
+ if entry.get("local_id") != local_id:
123
+ continue
124
+ if started_at is not None and "started_at" not in entry:
125
+ entry["started_at"] = started_at
126
+ changed = True
127
+ if ended_at is not None and "ended_at" not in entry:
128
+ entry["ended_at"] = ended_at
129
+ changed = True
130
+ if changed:
131
+ save_history(history)
132
+
133
+
134
+ def execute_history(config: DockerConfig):
135
+ """Show history of past Docker runs."""
136
+ history_file = Path(HISTORY_FILENAME)
137
+ if not history_file.exists():
138
+ typer.echo(f"No history found in '{history_file}'. You might not have submitted any jobs yet.")
139
+ return
140
+
141
+ history = load_history()
142
+
143
+ table = Table(title="Docker Run History", show_lines=True)
144
+ table.add_column("Job ID", justify="right", style="bold")
145
+ table.add_column("Host")
146
+ table.add_column("Timestamp")
147
+ table.add_column("Branch")
148
+ table.add_column("GPU(s)")
149
+ table.add_column("Volume(s)")
150
+ table.add_column("Imagename")
151
+ table.add_column("Commands")
152
+
153
+ for entry in history:
154
+ local_id = str(entry.get("local_id", "-"))
155
+ host = entry.get("host") or "-"
156
+ timestamp = datetime.fromtimestamp(entry["timestamp"]).strftime("%Y-%m-%d %H:%M:%S")
157
+ _config = entry["config"]
158
+ branch = _config.get("branch") or "-"
159
+ gpus = _config["gpus"] if _config["gpus"] else "-"
160
+ volumes = (
161
+ "\n".join(
162
+ [f"{v['hostpath']}:{v['containerpath']}:{v['permissions']}" for v in (_config.get("volumes") or [])]
163
+ )
164
+ or "-"
165
+ )
166
+ imagename = _config["imagename"]
167
+ cmds = _config.get("commands") or _config.get("arguments") or []
168
+ commands_str = " ".join(cmds)
169
+ table.add_row(local_id, host, timestamp, branch, gpus, volumes, imagename, Text(commands_str))
170
+
171
+ Console().print(table)