nerdit 0.2.0__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.
- nerdit/__init__.py +3 -0
- nerdit/__main__.py +3 -0
- nerdit/cli/__init__.py +0 -0
- nerdit/cli/app.py +61 -0
- nerdit/cli/client.py +208 -0
- nerdit/cli/commands/__init__.py +0 -0
- nerdit/cli/commands/check_deps.py +363 -0
- nerdit/cli/commands/cleanup.py +30 -0
- nerdit/cli/commands/connect.py +65 -0
- nerdit/cli/commands/init.py +57 -0
- nerdit/cli/commands/logs.py +69 -0
- nerdit/cli/commands/run.py +157 -0
- nerdit/cli/commands/status.py +39 -0
- nerdit/cli/commands/stop.py +31 -0
- nerdit/cli/commands/token.py +20 -0
- nerdit/cli/display.py +101 -0
- nerdit/cli/upload.py +70 -0
- nerdit/config/__init__.py +0 -0
- nerdit/config/defaults.py +31 -0
- nerdit/config/project.py +167 -0
- nerdit/config/settings.py +140 -0
- nerdit/utils/__init__.py +0 -0
- nerdit/utils/ids.py +12 -0
- nerdit/utils/logging.py +15 -0
- nerdit-0.2.0.dist-info/METADATA +105 -0
- nerdit-0.2.0.dist-info/RECORD +29 -0
- nerdit-0.2.0.dist-info/WHEEL +4 -0
- nerdit-0.2.0.dist-info/entry_points.txt +2 -0
- nerdit-0.2.0.dist-info/licenses/LICENSE +191 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""nerdit connect — Connect CLI to a remote nerditd daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from nerdit.cli.display import console
|
|
11
|
+
from nerdit.config.defaults import DEFAULT_PORT
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def connect(
|
|
15
|
+
host: str = typer.Argument(..., help="Server IP address or hostname"),
|
|
16
|
+
port: int = typer.Option(DEFAULT_PORT, "--port", "-p", help="Daemon port"),
|
|
17
|
+
token: str = typer.Option(..., "--token", "-t", help="Authentication token"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Connect the CLI to a remote daemon."""
|
|
20
|
+
asyncio.run(_connect_async(host, port, token))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _connect_async(host: str, port: int, token: str) -> None:
|
|
24
|
+
config_path = Path("~/.nerdit/config.toml").expanduser()
|
|
25
|
+
|
|
26
|
+
# Read existing config or start fresh
|
|
27
|
+
if config_path.exists():
|
|
28
|
+
import tomllib
|
|
29
|
+
|
|
30
|
+
with open(config_path, "rb") as f:
|
|
31
|
+
data = tomllib.load(f)
|
|
32
|
+
else:
|
|
33
|
+
data = {}
|
|
34
|
+
|
|
35
|
+
# Update [client] section
|
|
36
|
+
data["client"] = {
|
|
37
|
+
"remote_host": host,
|
|
38
|
+
"remote_port": port,
|
|
39
|
+
"auth_token": token,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Write back
|
|
43
|
+
import tomli_w
|
|
44
|
+
|
|
45
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
with open(config_path, "wb") as f:
|
|
47
|
+
tomli_w.dump(data, f)
|
|
48
|
+
|
|
49
|
+
console.print("[green]Client configuration saved[/green]")
|
|
50
|
+
console.print(f" Server: {host}:{port}")
|
|
51
|
+
|
|
52
|
+
# Test connection
|
|
53
|
+
from nerdit.cli.client import NerditClient
|
|
54
|
+
|
|
55
|
+
client = NerditClient(host=host, port=port, token=token)
|
|
56
|
+
try:
|
|
57
|
+
health = await client.health()
|
|
58
|
+
console.print(
|
|
59
|
+
f"[green]Connection successful[/green] — version {health['version']}, "
|
|
60
|
+
f"{health['gpu_count']} GPU(s)"
|
|
61
|
+
)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
console.print(f"[red]Connection failed:[/red] {exc}")
|
|
64
|
+
console.print("[dim]Check that the daemon is running and the token is correct.[/dim]")
|
|
65
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""nerdit init — Generate project configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from nerdit.cli.display import console
|
|
10
|
+
|
|
11
|
+
NERDIT_TOML_TEMPLATE = """\
|
|
12
|
+
# nerdit.toml — Project configuration for Nerdit
|
|
13
|
+
# Place this file at the root of your project.
|
|
14
|
+
# `nerdit run` with no arguments will use these defaults.
|
|
15
|
+
|
|
16
|
+
[run]
|
|
17
|
+
# script = "train.py"
|
|
18
|
+
# gpus = 1
|
|
19
|
+
# priority = 5
|
|
20
|
+
# image = "nerdit-runtime:0.1"
|
|
21
|
+
# tags = ["render", "nightly"]
|
|
22
|
+
|
|
23
|
+
[scheduling]
|
|
24
|
+
# time_window = "22:00-06:00" # job starts only within this window
|
|
25
|
+
# max_runtime = "12h" # auto-kill after this duration
|
|
26
|
+
# preemptible = false # mark job as interruptible (future use)
|
|
27
|
+
|
|
28
|
+
[data]
|
|
29
|
+
# Data mounts: name = "host_path:container_path"
|
|
30
|
+
# dataset = "/data/imagenet:/data"
|
|
31
|
+
# output = "/results/{{job_id}}:/output"
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def init(
|
|
36
|
+
project: bool = typer.Option(
|
|
37
|
+
False, "--project", help="Generate a nerdit.toml in the current directory"
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initialize a nerdit.toml project configuration."""
|
|
41
|
+
if project:
|
|
42
|
+
_generate_project_config()
|
|
43
|
+
else:
|
|
44
|
+
console.print(
|
|
45
|
+
"Use [cyan]nerdit init --project[/cyan] to generate a nerdit.toml template.\n"
|
|
46
|
+
"To start a daemon, install the full nerdit engine on your GPU server."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _generate_project_config() -> None:
|
|
51
|
+
"""Write a nerdit.toml template to cwd."""
|
|
52
|
+
target = Path.cwd() / "nerdit.toml"
|
|
53
|
+
if target.exists():
|
|
54
|
+
console.print(f"[yellow]nerdit.toml already exists:[/yellow] {target}")
|
|
55
|
+
return
|
|
56
|
+
target.write_text(NERDIT_TOML_TEMPLATE)
|
|
57
|
+
console.print(f"[green]nerdit.toml created:[/green] {target}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""nerdit logs — Stream job logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from nerdit.cli.display import console, display_logs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def logs(
|
|
13
|
+
job_id: str = typer.Argument(..., help="Job ID"),
|
|
14
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Follow logs continuously"),
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Display logs for a job."""
|
|
17
|
+
asyncio.run(_logs_async(job_id, follow))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _logs_async(job_id: str, follow: bool) -> None:
|
|
21
|
+
from nerdit.cli.client import get_configured_client
|
|
22
|
+
|
|
23
|
+
client = get_configured_client()
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
if not follow:
|
|
27
|
+
entries = await client.get_logs(job_id, since_id=0)
|
|
28
|
+
if entries:
|
|
29
|
+
display_logs(entries)
|
|
30
|
+
else:
|
|
31
|
+
console.print("[dim]No logs available[/dim]")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# Follow mode: use SSE stream
|
|
35
|
+
try:
|
|
36
|
+
async for event in client.stream_logs(job_id):
|
|
37
|
+
if event["event"] == "log":
|
|
38
|
+
display_logs([event["data"]])
|
|
39
|
+
elif event["event"] == "done":
|
|
40
|
+
status = event["data"]["status"]
|
|
41
|
+
console.print(f"\n[bold]Job finished: {status}[/bold]")
|
|
42
|
+
break
|
|
43
|
+
except ImportError:
|
|
44
|
+
# Fallback to polling if httpx-sse not installed
|
|
45
|
+
await _follow_polling(client, job_id)
|
|
46
|
+
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _follow_polling(client, job_id: str) -> None:
|
|
53
|
+
"""Fallback follow mode using polling (v0.1 behavior)."""
|
|
54
|
+
since_id = 0
|
|
55
|
+
while True:
|
|
56
|
+
entries = await client.get_logs(job_id, since_id=since_id)
|
|
57
|
+
if entries:
|
|
58
|
+
display_logs(entries)
|
|
59
|
+
since_id = entries[-1]["id"]
|
|
60
|
+
|
|
61
|
+
job = await client.get_job(job_id)
|
|
62
|
+
if job["status"] in ("completed", "failed", "cancelled"):
|
|
63
|
+
entries = await client.get_logs(job_id, since_id=since_id)
|
|
64
|
+
if entries:
|
|
65
|
+
display_logs(entries)
|
|
66
|
+
console.print(f"\n[bold]Job finished: {job['status']}[/bold]")
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
await asyncio.sleep(1.0)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""nerdit run — Submit a job to the scheduler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from nerdit.cli.display import console, display_job_submitted
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(
|
|
15
|
+
script: Optional[str] = typer.Argument(None, help="Python script to run"),
|
|
16
|
+
gpus: Optional[int] = typer.Option(None, "--gpus", "-g", help="Number of GPUs required"),
|
|
17
|
+
priority: Optional[int] = typer.Option(None, "--priority", "-p", help="Priority (1-10)"),
|
|
18
|
+
image: Optional[str] = typer.Option(None, "--image", "-i", help="Docker image to use"),
|
|
19
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name"),
|
|
20
|
+
data: Optional[list[str]] = typer.Option(
|
|
21
|
+
None, "--data", "-d", help="Data mount host:container"
|
|
22
|
+
),
|
|
23
|
+
tag: Optional[list[str]] = typer.Option(None, "--tag", "-t", help="Job tag (repeatable)"),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Submit a job to the scheduler."""
|
|
26
|
+
asyncio.run(_run_async(script, gpus, priority, image, name, data, tag))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _run_async(
|
|
30
|
+
script: str | None,
|
|
31
|
+
gpus: int | None,
|
|
32
|
+
priority: int | None,
|
|
33
|
+
image: str | None,
|
|
34
|
+
name: str | None,
|
|
35
|
+
data: list[str] | None,
|
|
36
|
+
tag: list[str] | None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Build effective job parameters by merging CLI flags over nerdit.toml defaults."""
|
|
39
|
+
from nerdit.cli.client import get_configured_client
|
|
40
|
+
from nerdit.config.project import (
|
|
41
|
+
load_project_config,
|
|
42
|
+
parse_data_flags,
|
|
43
|
+
parse_data_mounts,
|
|
44
|
+
)
|
|
45
|
+
from nerdit.config.settings import load_settings
|
|
46
|
+
|
|
47
|
+
# Load nerdit.toml defaults
|
|
48
|
+
project = load_project_config()
|
|
49
|
+
if project:
|
|
50
|
+
console.print("[dim]nerdit.toml found[/dim]")
|
|
51
|
+
|
|
52
|
+
# Merge: CLI flags override nerdit.toml [run] defaults
|
|
53
|
+
run_cfg = project.run if project and project.run else None
|
|
54
|
+
sched_cfg = project.scheduling if project and project.scheduling else None
|
|
55
|
+
|
|
56
|
+
effective_script = script or (run_cfg.script if run_cfg else None)
|
|
57
|
+
if not effective_script:
|
|
58
|
+
console.print("[red]No script specified (argument or nerdit.toml [run].script)[/red]")
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
|
|
61
|
+
effective_gpus = gpus if gpus is not None else (run_cfg.gpus if run_cfg else None) or 1
|
|
62
|
+
effective_priority = (
|
|
63
|
+
priority if priority is not None else (run_cfg.priority if run_cfg else None) or 5
|
|
64
|
+
)
|
|
65
|
+
effective_image = image or (run_cfg.image if run_cfg else None)
|
|
66
|
+
|
|
67
|
+
# Build tags: toml [run].tags + --tag CLI flags
|
|
68
|
+
effective_tags: list[str] = []
|
|
69
|
+
if run_cfg and run_cfg.tags:
|
|
70
|
+
effective_tags.extend(run_cfg.tags)
|
|
71
|
+
if tag:
|
|
72
|
+
for t in tag:
|
|
73
|
+
if t not in effective_tags:
|
|
74
|
+
effective_tags.append(t)
|
|
75
|
+
|
|
76
|
+
# Scheduling fields from toml [scheduling]
|
|
77
|
+
effective_time_window = sched_cfg.time_window if sched_cfg else None
|
|
78
|
+
effective_max_runtime = sched_cfg.max_runtime if sched_cfg else None
|
|
79
|
+
effective_preemptible = sched_cfg.preemptible if sched_cfg else False
|
|
80
|
+
|
|
81
|
+
# Build volumes: start with toml [data] mounts, overlay --data flags
|
|
82
|
+
volumes: dict[str, str] = {}
|
|
83
|
+
if project and project.data:
|
|
84
|
+
volumes.update(parse_data_mounts(project.data))
|
|
85
|
+
if data:
|
|
86
|
+
volumes.update(parse_data_flags(data))
|
|
87
|
+
|
|
88
|
+
# Resolve script to absolute path
|
|
89
|
+
script_path = str(Path(effective_script).resolve())
|
|
90
|
+
|
|
91
|
+
if not Path(script_path).exists():
|
|
92
|
+
console.print(f"[red]File not found:[/red] {script_path}")
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
|
|
95
|
+
settings = load_settings()
|
|
96
|
+
client = get_configured_client()
|
|
97
|
+
|
|
98
|
+
# Warn about remote mount paths
|
|
99
|
+
if settings.client.remote_host and volumes:
|
|
100
|
+
console.print("[yellow]Remote mode: mount paths reference the remote server.[/yellow]")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
if settings.client.remote_host:
|
|
104
|
+
# Remote mode: create ZIP archive and upload
|
|
105
|
+
from nerdit.cli.upload import (
|
|
106
|
+
check_upload_size,
|
|
107
|
+
create_script_zip,
|
|
108
|
+
warn_large_upload,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
console.print("[dim]Remote mode — creating archive...[/dim]")
|
|
112
|
+
zip_bytes, script_name = create_script_zip(script_path)
|
|
113
|
+
|
|
114
|
+
check_upload_size(zip_bytes)
|
|
115
|
+
|
|
116
|
+
warning = warn_large_upload(zip_bytes)
|
|
117
|
+
if warning:
|
|
118
|
+
console.print(f"[yellow]{warning}[/yellow]")
|
|
119
|
+
|
|
120
|
+
size_mb = len(zip_bytes) / (1024 * 1024)
|
|
121
|
+
console.print(f"[dim]Uploading archive ({size_mb:.1f} MB)...[/dim]")
|
|
122
|
+
|
|
123
|
+
job = await client.upload_job(
|
|
124
|
+
zip_bytes,
|
|
125
|
+
script_name,
|
|
126
|
+
name=name,
|
|
127
|
+
gpu_count=effective_gpus,
|
|
128
|
+
priority=effective_priority,
|
|
129
|
+
image=effective_image,
|
|
130
|
+
volumes=volumes or None,
|
|
131
|
+
tags=effective_tags or None,
|
|
132
|
+
preemptible=effective_preemptible,
|
|
133
|
+
max_runtime=effective_max_runtime,
|
|
134
|
+
time_window=effective_time_window,
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
# Local mode: existing path-based job creation
|
|
138
|
+
job = await client.create_job(
|
|
139
|
+
script_path=script_path,
|
|
140
|
+
name=name,
|
|
141
|
+
gpu_count=effective_gpus,
|
|
142
|
+
priority=effective_priority,
|
|
143
|
+
image=effective_image,
|
|
144
|
+
volumes=volumes or None,
|
|
145
|
+
tags=effective_tags or None,
|
|
146
|
+
preemptible=effective_preemptible,
|
|
147
|
+
max_runtime=effective_max_runtime,
|
|
148
|
+
time_window=effective_time_window,
|
|
149
|
+
)
|
|
150
|
+
display_job_submitted(job)
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
153
|
+
raise typer.Exit(1)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
156
|
+
console.print("[dim]Is the daemon running? (nerdit init)[/dim]")
|
|
157
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""nerdit status — Display GPU and job status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from nerdit.cli.display import console, display_gpu_table, display_job_table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def status() -> None:
|
|
13
|
+
"""Display GPU and job status."""
|
|
14
|
+
asyncio.run(_status_async())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _status_async() -> None:
|
|
18
|
+
from nerdit.cli.client import get_configured_client
|
|
19
|
+
|
|
20
|
+
client = get_configured_client()
|
|
21
|
+
try:
|
|
22
|
+
gpus = await client.list_gpus()
|
|
23
|
+
jobs = await client.list_jobs()
|
|
24
|
+
|
|
25
|
+
if gpus:
|
|
26
|
+
display_gpu_table(gpus)
|
|
27
|
+
else:
|
|
28
|
+
console.print(
|
|
29
|
+
"[yellow]No GPU detected. Jobs requiring GPUs will stay pending.[/yellow]"
|
|
30
|
+
)
|
|
31
|
+
console.print()
|
|
32
|
+
if jobs:
|
|
33
|
+
display_job_table(jobs)
|
|
34
|
+
else:
|
|
35
|
+
console.print("[dim]No jobs[/dim]")
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
38
|
+
console.print("[dim]Is the daemon running? (nerdit init)[/dim]")
|
|
39
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""nerdit stop — Stop a running job."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from nerdit.cli.display import console
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def stop(
|
|
13
|
+
job_id: str = typer.Argument(..., help="Job ID to stop"),
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Stop a running job."""
|
|
16
|
+
asyncio.run(_stop_async(job_id))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _stop_async(job_id: str) -> None:
|
|
20
|
+
from nerdit.cli.client import get_configured_client
|
|
21
|
+
|
|
22
|
+
client = get_configured_client()
|
|
23
|
+
try:
|
|
24
|
+
result = await client.stop_job(job_id)
|
|
25
|
+
if result.get("stopped"):
|
|
26
|
+
console.print(f"[green]Job {job_id} stopped[/green]")
|
|
27
|
+
else:
|
|
28
|
+
console.print(f"[yellow]Job {job_id} could not be stopped[/yellow]")
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
31
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""nerdit token — Display the current authentication token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nerdit.cli.display import console
|
|
6
|
+
from nerdit.config.settings import load_settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def token() -> None:
|
|
10
|
+
"""Display the current authentication token."""
|
|
11
|
+
settings = load_settings()
|
|
12
|
+
|
|
13
|
+
auth_token = settings.daemon.auth_token
|
|
14
|
+
if settings.client.remote_host:
|
|
15
|
+
auth_token = settings.client.auth_token
|
|
16
|
+
|
|
17
|
+
if auth_token:
|
|
18
|
+
console.print(auth_token)
|
|
19
|
+
else:
|
|
20
|
+
console.print("[yellow]No authentication token configured.[/yellow]")
|
nerdit/cli/display.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Rich display formatters for CLI output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def display_gpu_table(gpus: list[dict]) -> None:
|
|
12
|
+
"""Display a Rich table of GPU information."""
|
|
13
|
+
table = Table(title="GPUs")
|
|
14
|
+
table.add_column("ID", style="dim", max_width=16)
|
|
15
|
+
table.add_column("Name", style="cyan")
|
|
16
|
+
table.add_column("Memory", justify="right")
|
|
17
|
+
table.add_column("Status", style="bold")
|
|
18
|
+
table.add_column("Util %", justify="right")
|
|
19
|
+
table.add_column("Temp °C", justify="right")
|
|
20
|
+
|
|
21
|
+
for gpu in gpus:
|
|
22
|
+
status_style = {
|
|
23
|
+
"idle": "green",
|
|
24
|
+
"busy": "yellow",
|
|
25
|
+
"error": "red",
|
|
26
|
+
"offline": "dim",
|
|
27
|
+
}.get(gpu["status"], "")
|
|
28
|
+
|
|
29
|
+
raw_util = gpu.get("utilization_percent")
|
|
30
|
+
util = str(raw_util) if raw_util is not None else "-"
|
|
31
|
+
raw_temp = gpu.get("temperature_c")
|
|
32
|
+
temp = str(raw_temp) if raw_temp is not None else "-"
|
|
33
|
+
|
|
34
|
+
table.add_row(
|
|
35
|
+
gpu["id"][:12] + "...",
|
|
36
|
+
gpu["name"],
|
|
37
|
+
f"{gpu['memory_mb']} MB",
|
|
38
|
+
f"[{status_style}]{gpu['status']}[/{status_style}]",
|
|
39
|
+
util,
|
|
40
|
+
temp,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
console.print(table)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def display_job_table(jobs: list[dict]) -> None:
|
|
47
|
+
"""Display a Rich table of jobs."""
|
|
48
|
+
table = Table(title="Jobs")
|
|
49
|
+
table.add_column("ID", style="dim")
|
|
50
|
+
table.add_column("Name")
|
|
51
|
+
table.add_column("Status", style="bold")
|
|
52
|
+
table.add_column("GPUs", justify="right")
|
|
53
|
+
table.add_column("Priority", justify="right")
|
|
54
|
+
table.add_column("Created")
|
|
55
|
+
|
|
56
|
+
for job in jobs:
|
|
57
|
+
status_style = {
|
|
58
|
+
"pending": "yellow",
|
|
59
|
+
"scheduled": "blue",
|
|
60
|
+
"running": "green",
|
|
61
|
+
"completed": "green bold",
|
|
62
|
+
"failed": "red",
|
|
63
|
+
"cancelled": "dim",
|
|
64
|
+
"retrying": "magenta",
|
|
65
|
+
}.get(job["status"], "")
|
|
66
|
+
|
|
67
|
+
name = job.get("name") or job.get("script_path", "").split("/")[-1]
|
|
68
|
+
|
|
69
|
+
table.add_row(
|
|
70
|
+
job["id"][:12],
|
|
71
|
+
name,
|
|
72
|
+
f"[{status_style}]{job['status']}[/{status_style}]",
|
|
73
|
+
str(job["gpu_count"]),
|
|
74
|
+
str(job["priority"]),
|
|
75
|
+
job.get("created_at", "")[:19],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
console.print(table)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def display_job_submitted(job: dict) -> None:
|
|
82
|
+
"""Display a submitted job confirmation."""
|
|
83
|
+
console.print(f"[green]Job submitted:[/green] {job['id']}")
|
|
84
|
+
console.print(f" Script: {job['script_path']}")
|
|
85
|
+
console.print(f" GPUs: {job['gpu_count']}")
|
|
86
|
+
console.print(f" Status: {job['status']}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def display_logs(logs: list[dict]) -> None:
|
|
90
|
+
"""Display log entries."""
|
|
91
|
+
for log in logs:
|
|
92
|
+
stream = log.get("stream", "stdout")
|
|
93
|
+
message = log.get("message", "")
|
|
94
|
+
ts = log.get("timestamp", "")[:19]
|
|
95
|
+
|
|
96
|
+
if stream == "stderr":
|
|
97
|
+
console.print(f"[dim]{ts}[/dim] [red]{message}[/red]")
|
|
98
|
+
elif stream == "system":
|
|
99
|
+
console.print(f"[dim]{ts}[/dim] [blue]{message}[/blue]")
|
|
100
|
+
else:
|
|
101
|
+
console.print(f"[dim]{ts}[/dim] {message}")
|
nerdit/cli/upload.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""ZIP creation and upload helpers for remote job submission."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import io
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from nerdit.config.defaults import (
|
|
11
|
+
DEFAULT_MAX_UPLOAD_BYTES,
|
|
12
|
+
UPLOAD_WARNING_BYTES,
|
|
13
|
+
ZIP_EXCLUDE_PATTERNS,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def should_exclude(rel_path: str) -> bool:
|
|
18
|
+
"""Check if a relative path matches any exclusion pattern."""
|
|
19
|
+
parts = Path(rel_path).parts
|
|
20
|
+
for pattern in ZIP_EXCLUDE_PATTERNS:
|
|
21
|
+
# Directory pattern (ends with /)
|
|
22
|
+
if pattern.endswith("/"):
|
|
23
|
+
dir_name = pattern.rstrip("/")
|
|
24
|
+
if dir_name in parts:
|
|
25
|
+
return True
|
|
26
|
+
# Glob pattern
|
|
27
|
+
elif any(fnmatch.fnmatch(part, pattern) for part in parts):
|
|
28
|
+
return True
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_script_zip(script_path: str) -> tuple[bytes, str]:
|
|
33
|
+
"""Create a ZIP archive of the script's parent directory.
|
|
34
|
+
|
|
35
|
+
Returns (zip_bytes, script_name) where script_name is the relative
|
|
36
|
+
path of the script inside the archive.
|
|
37
|
+
"""
|
|
38
|
+
script = Path(script_path).resolve()
|
|
39
|
+
base_dir = script.parent
|
|
40
|
+
|
|
41
|
+
buf = io.BytesIO()
|
|
42
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
43
|
+
for file in sorted(base_dir.rglob("*")):
|
|
44
|
+
if not file.is_file():
|
|
45
|
+
continue
|
|
46
|
+
rel = str(file.relative_to(base_dir))
|
|
47
|
+
if should_exclude(rel):
|
|
48
|
+
continue
|
|
49
|
+
zf.write(file, rel)
|
|
50
|
+
|
|
51
|
+
return buf.getvalue(), script.name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_upload_size(
|
|
55
|
+
zip_bytes: bytes,
|
|
56
|
+
max_bytes: int = DEFAULT_MAX_UPLOAD_BYTES,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Raise ValueError if zip_bytes exceed the maximum upload size."""
|
|
59
|
+
if len(zip_bytes) > max_bytes:
|
|
60
|
+
size_mb = len(zip_bytes) / (1024 * 1024)
|
|
61
|
+
limit_mb = max_bytes / (1024 * 1024)
|
|
62
|
+
raise ValueError(f"Archive is {size_mb:.0f} MB, limit is {limit_mb:.0f} MB.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def warn_large_upload(zip_bytes: bytes) -> str | None:
|
|
66
|
+
"""Return a warning message if zip_bytes exceed the warning threshold."""
|
|
67
|
+
if len(zip_bytes) > UPLOAD_WARNING_BYTES:
|
|
68
|
+
size_mb = len(zip_bytes) / (1024 * 1024)
|
|
69
|
+
return f"Archive is {size_mb:.0f} MB — upload may take a while."
|
|
70
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Default constants for Nerdit configuration."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
6
|
+
DEFAULT_PORT = 9321
|
|
7
|
+
DEFAULT_DATA_DIR = Path("~/.nerdit")
|
|
8
|
+
DEFAULT_DB_NAME = "nerdit.db"
|
|
9
|
+
DEFAULT_PID_FILE = "nerditd.pid"
|
|
10
|
+
DEFAULT_LOG_LEVEL = "info"
|
|
11
|
+
|
|
12
|
+
DEFAULT_IMAGE = "nerdit-runtime:0.1"
|
|
13
|
+
DEFAULT_RUNTIME = "docker"
|
|
14
|
+
DEFAULT_CACHE_DIR = "~/.nerdit/cache"
|
|
15
|
+
|
|
16
|
+
DEFAULT_PRIORITY = 5
|
|
17
|
+
DEFAULT_MAX_CONCURRENT_JOBS = 0 # 0 = auto (number of GPUs)
|
|
18
|
+
DEFAULT_TIMEOUT = 0 # 0 = no timeout
|
|
19
|
+
DEFAULT_MAX_RETRIES = 3
|
|
20
|
+
|
|
21
|
+
DEFAULT_MONITOR_INTERVAL = 5 # seconds
|
|
22
|
+
DEFAULT_GPU_TEMP_WARNING = 80 # °C
|
|
23
|
+
DEFAULT_GPU_TEMP_CRITICAL = 90 # °C
|
|
24
|
+
|
|
25
|
+
DAEMON_URL = f"http://{DEFAULT_HOST}:{DEFAULT_PORT}"
|
|
26
|
+
|
|
27
|
+
# Upload / ZIP constants (v0.2)
|
|
28
|
+
DEFAULT_UPLOAD_DIR = Path("~/.nerdit/uploads")
|
|
29
|
+
DEFAULT_MAX_UPLOAD_BYTES = 500 * 1024 * 1024 # 500 MB
|
|
30
|
+
UPLOAD_WARNING_BYTES = 100 * 1024 * 1024 # 100 MB
|
|
31
|
+
ZIP_EXCLUDE_PATTERNS = {".git", "data/", "*.pyc", "__pycache__", ".venv", "node_modules"}
|