utrain 0.0.4__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.
- utrain/__init__.py +0 -0
- utrain/cli/__init__.py +0 -0
- utrain/cli/attempts.py +116 -0
- utrain/cli/compute.py +172 -0
- utrain/cli/db.py +178 -0
- utrain/cli/debug.py +38 -0
- utrain/cli/exceptions.py +2 -0
- utrain/cli/images.py +121 -0
- utrain/cli/main.py +382 -0
- utrain/cli/orchestrator.py +363 -0
- utrain/cli/output.py +37 -0
- utrain/cli/phases.py +228 -0
- utrain/cli/reconcile.py +144 -0
- utrain/cli/runs.py +705 -0
- utrain/cli/store.py +22 -0
- utrain/config.py +47 -0
- utrain/container/__init__.py +3 -0
- utrain/container/podman.py +74 -0
- utrain/container/run_data.py +107 -0
- utrain/container/schema.py +53 -0
- utrain/container/wandb_shim/wandb/__init__.py +14 -0
- utrain-0.0.4.dist-info/METADATA +15 -0
- utrain-0.0.4.dist-info/RECORD +26 -0
- utrain-0.0.4.dist-info/WHEEL +4 -0
- utrain-0.0.4.dist-info/entry_points.txt +2 -0
- utrain-0.0.4.dist-info/licenses/LICENSE +21 -0
utrain/__init__.py
ADDED
|
File without changes
|
utrain/cli/__init__.py
ADDED
|
File without changes
|
utrain/cli/attempts.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
import sqlalchemy
|
|
4
|
+
import sqlalchemy.orm
|
|
5
|
+
|
|
6
|
+
from . import db as dbmod
|
|
7
|
+
from . import exceptions, output, reconcile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def list_attempts(run_id_prefix: str, session: sqlalchemy.orm.Session) -> None:
|
|
11
|
+
run_id = dbmod.resolve_run_id(run_id_prefix, session)
|
|
12
|
+
|
|
13
|
+
attempt_n = dbmod.latest_attempt(run_id, session)
|
|
14
|
+
if attempt_n is not None:
|
|
15
|
+
reconcile.reconcile_attempt(run_id, attempt_n, session)
|
|
16
|
+
|
|
17
|
+
rows = (
|
|
18
|
+
session.execute(
|
|
19
|
+
sqlalchemy.select(dbmod.run_attempts)
|
|
20
|
+
.where(dbmod.run_attempts.c.run_id == run_id)
|
|
21
|
+
.order_by(dbmod.run_attempts.c.attempt.desc())
|
|
22
|
+
)
|
|
23
|
+
.mappings()
|
|
24
|
+
.fetchall()
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
rid = dbmod.short_run_id(run_id, session)
|
|
28
|
+
|
|
29
|
+
headers = ["ATTEMPT", "FROM_PHASE", "STATUS", "STARTED", "ENDED"]
|
|
30
|
+
table_rows = [
|
|
31
|
+
[
|
|
32
|
+
f"{rid}/{r['attempt']}",
|
|
33
|
+
str(r["from_phase"]) if r["from_phase"] else "--",
|
|
34
|
+
str(r["status"]),
|
|
35
|
+
output.format_time(r["started_at"]),
|
|
36
|
+
output.format_time(r["ended_at"]),
|
|
37
|
+
]
|
|
38
|
+
for r in rows
|
|
39
|
+
]
|
|
40
|
+
print(output.format_table(headers, table_rows))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def list_attempt_ids(run_id_prefix: str, session: sqlalchemy.orm.Session) -> list[str]:
|
|
44
|
+
run_id = dbmod.resolve_run_id(run_id_prefix, session)
|
|
45
|
+
|
|
46
|
+
rows = (
|
|
47
|
+
session.execute(
|
|
48
|
+
sqlalchemy.select(dbmod.run_attempts.c.attempt)
|
|
49
|
+
.where(dbmod.run_attempts.c.run_id == run_id)
|
|
50
|
+
.order_by(dbmod.run_attempts.c.attempt.desc())
|
|
51
|
+
)
|
|
52
|
+
.scalars()
|
|
53
|
+
.fetchall()
|
|
54
|
+
)
|
|
55
|
+
return [f"{run_id}/{attempt}" for attempt in rows]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def show_attempt(run_id_prefix: str, attempt_n: int, session: sqlalchemy.orm.Session) -> None:
|
|
59
|
+
run_id = dbmod.resolve_run_id(run_id_prefix, session)
|
|
60
|
+
reconcile.reconcile_attempt(run_id, attempt_n, session)
|
|
61
|
+
|
|
62
|
+
run_row = dbmod.get_run(run_id, session)
|
|
63
|
+
run_dir = pathlib.Path(str(run_row["run_dir"]))
|
|
64
|
+
|
|
65
|
+
attempt_row = (
|
|
66
|
+
session.execute(
|
|
67
|
+
sqlalchemy.select(dbmod.run_attempts).where(
|
|
68
|
+
(dbmod.run_attempts.c.run_id == run_id)
|
|
69
|
+
& (dbmod.run_attempts.c.attempt == attempt_n)
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
.mappings()
|
|
73
|
+
.fetchone()
|
|
74
|
+
)
|
|
75
|
+
if attempt_row is None:
|
|
76
|
+
raise exceptions.UI(f"abort: attempt {attempt_n} not found for run '{run_id}'")
|
|
77
|
+
|
|
78
|
+
phase_rows = (
|
|
79
|
+
session.execute(
|
|
80
|
+
sqlalchemy.select(dbmod.run_phases)
|
|
81
|
+
.where(
|
|
82
|
+
(dbmod.run_phases.c.run_id == run_id) & (dbmod.run_phases.c.attempt == attempt_n)
|
|
83
|
+
)
|
|
84
|
+
.order_by(dbmod.run_phases.c.phase_order)
|
|
85
|
+
)
|
|
86
|
+
.mappings()
|
|
87
|
+
.fetchall()
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
print(f"run: {run_id} ({run_row['name']})")
|
|
91
|
+
print(f"attempt: {attempt_n}")
|
|
92
|
+
print(f"from_phase: {attempt_row['from_phase'] or '--'}")
|
|
93
|
+
print(f"status: {attempt_row['status']}")
|
|
94
|
+
print(f"started: {output.format_time(attempt_row['started_at'])}")
|
|
95
|
+
print(f"ended: {output.format_time(attempt_row['ended_at'])}")
|
|
96
|
+
|
|
97
|
+
if phase_rows:
|
|
98
|
+
print()
|
|
99
|
+
headers = ["PHASE", "ORDER", "STATUS", "STARTED", "ENDED"]
|
|
100
|
+
table_rows = [
|
|
101
|
+
[
|
|
102
|
+
str(p["phase"]),
|
|
103
|
+
str(p["phase_order"]),
|
|
104
|
+
str(p["status"]),
|
|
105
|
+
output.format_time(p["started_at"]),
|
|
106
|
+
output.format_time(p["ended_at"]),
|
|
107
|
+
]
|
|
108
|
+
for p in phase_rows
|
|
109
|
+
]
|
|
110
|
+
print(output.format_table(headers, table_rows))
|
|
111
|
+
|
|
112
|
+
logs_dir = run_dir / "attempt" / str(attempt_n) / "logs"
|
|
113
|
+
wandb_dir = run_dir / "attempt" / str(attempt_n) / "wandb"
|
|
114
|
+
print()
|
|
115
|
+
print(f"logs: {logs_dir}")
|
|
116
|
+
print(f"data: {wandb_dir}")
|
utrain/cli/compute.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import pathlib
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CpuInfo:
|
|
8
|
+
name: str
|
|
9
|
+
cores: int
|
|
10
|
+
mem_total_gb: float
|
|
11
|
+
mem_available_gb: float
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
name: str,
|
|
16
|
+
cores: int,
|
|
17
|
+
mem_total_gb: float,
|
|
18
|
+
mem_available_gb: float,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.name = name
|
|
21
|
+
self.cores = cores
|
|
22
|
+
self.mem_total_gb = mem_total_gb
|
|
23
|
+
self.mem_available_gb = mem_available_gb
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GpuInfo:
|
|
27
|
+
index: int
|
|
28
|
+
name: str
|
|
29
|
+
power_draw: float
|
|
30
|
+
power_limit: float
|
|
31
|
+
util: int
|
|
32
|
+
mem_used_mb: float
|
|
33
|
+
mem_total_mb: float
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
index: int,
|
|
38
|
+
name: str,
|
|
39
|
+
power_draw: float,
|
|
40
|
+
power_limit: float,
|
|
41
|
+
util: int,
|
|
42
|
+
mem_used_mb: float,
|
|
43
|
+
mem_total_mb: float,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.index = index
|
|
46
|
+
self.name = name
|
|
47
|
+
self.power_draw = power_draw
|
|
48
|
+
self.power_limit = power_limit
|
|
49
|
+
self.util = util
|
|
50
|
+
self.mem_used_mb = mem_used_mb
|
|
51
|
+
self.mem_total_mb = mem_total_mb
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ComputeInfo:
|
|
55
|
+
cpu: CpuInfo
|
|
56
|
+
gpus: list[GpuInfo]
|
|
57
|
+
|
|
58
|
+
def __init__(self, cpu: CpuInfo, gpus: list[GpuInfo]) -> None:
|
|
59
|
+
self.cpu = cpu
|
|
60
|
+
self.gpus = gpus
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_cpu_name() -> str:
|
|
64
|
+
try:
|
|
65
|
+
text = pathlib.Path("/proc/cpuinfo").read_text()
|
|
66
|
+
for line in text.splitlines():
|
|
67
|
+
if line.startswith("model name"):
|
|
68
|
+
return line.split(":", 1)[1].strip()
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
return "Unknown CPU"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_meminfo() -> tuple[float, float]:
|
|
75
|
+
try:
|
|
76
|
+
text = pathlib.Path("/proc/meminfo").read_text()
|
|
77
|
+
total_kb = 0
|
|
78
|
+
available_kb = 0
|
|
79
|
+
for line in text.splitlines():
|
|
80
|
+
if line.startswith("MemTotal:"):
|
|
81
|
+
total_kb = int(line.split()[1])
|
|
82
|
+
elif line.startswith("MemAvailable:"):
|
|
83
|
+
available_kb = int(line.split()[1])
|
|
84
|
+
return total_kb / (1024 * 1024), available_kb / (1024 * 1024)
|
|
85
|
+
except OSError:
|
|
86
|
+
return 0.0, 0.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _read_gpus() -> list[GpuInfo]:
|
|
90
|
+
try:
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
[
|
|
93
|
+
"nvidia-smi",
|
|
94
|
+
"--query-gpu=index,name,power.draw,enforced.power.limit,memory.used,memory.total,utilization.gpu",
|
|
95
|
+
"--format=csv,noheader,nounits",
|
|
96
|
+
],
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=10,
|
|
100
|
+
)
|
|
101
|
+
if result.returncode != 0:
|
|
102
|
+
return []
|
|
103
|
+
gpus: list[GpuInfo] = []
|
|
104
|
+
for line in result.stdout.strip().splitlines():
|
|
105
|
+
parts = [p.strip() for p in line.split(",")]
|
|
106
|
+
if len(parts) < 7:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
def _float(s: str) -> float:
|
|
110
|
+
try:
|
|
111
|
+
return float(s)
|
|
112
|
+
except ValueError:
|
|
113
|
+
return 0.0
|
|
114
|
+
|
|
115
|
+
def _int(s: str) -> int:
|
|
116
|
+
try:
|
|
117
|
+
return int(s)
|
|
118
|
+
except ValueError:
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
gpus.append(
|
|
122
|
+
GpuInfo(
|
|
123
|
+
index=_int(parts[0]),
|
|
124
|
+
name=parts[1],
|
|
125
|
+
power_draw=_float(parts[2]),
|
|
126
|
+
power_limit=_float(parts[3]),
|
|
127
|
+
mem_used_mb=_float(parts[4]),
|
|
128
|
+
mem_total_mb=_float(parts[5]),
|
|
129
|
+
util=_int(parts[6]),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return gpus
|
|
133
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def collect_compute() -> ComputeInfo:
|
|
138
|
+
fixture = os.environ.get("UTRAIN_COMPUTE_FIXTURE")
|
|
139
|
+
if fixture:
|
|
140
|
+
data: dict[str, object] = json.loads(pathlib.Path(fixture).read_text())
|
|
141
|
+
cpu_data = data["cpu"]
|
|
142
|
+
assert isinstance(cpu_data, dict)
|
|
143
|
+
cpu = CpuInfo(
|
|
144
|
+
name=str(cpu_data["name"]),
|
|
145
|
+
cores=int(cpu_data["cores"]), # type: ignore[arg-type]
|
|
146
|
+
mem_total_gb=float(cpu_data["mem_total_gb"]), # type: ignore[arg-type]
|
|
147
|
+
mem_available_gb=float(cpu_data["mem_available_gb"]), # type: ignore[arg-type]
|
|
148
|
+
)
|
|
149
|
+
gpus: list[GpuInfo] = []
|
|
150
|
+
for i, g in enumerate(data.get("gpus", [])): # type: ignore[union-attr]
|
|
151
|
+
assert isinstance(g, dict)
|
|
152
|
+
gpus.append(
|
|
153
|
+
GpuInfo(
|
|
154
|
+
index=int(g.get("index", i)), # type: ignore[arg-type]
|
|
155
|
+
name=str(g["name"]),
|
|
156
|
+
power_draw=float(g["power_draw"]), # type: ignore[arg-type]
|
|
157
|
+
power_limit=float(g["power_limit"]), # type: ignore[arg-type]
|
|
158
|
+
util=int(g["util"]), # type: ignore[arg-type]
|
|
159
|
+
mem_used_mb=float(g["mem_used_mb"]), # type: ignore[arg-type]
|
|
160
|
+
mem_total_mb=float(g["mem_total_mb"]), # type: ignore[arg-type]
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
return ComputeInfo(cpu=cpu, gpus=gpus)
|
|
164
|
+
|
|
165
|
+
mem_total, mem_available = _read_meminfo()
|
|
166
|
+
cpu = CpuInfo(
|
|
167
|
+
name=_read_cpu_name(),
|
|
168
|
+
cores=os.cpu_count() or 1,
|
|
169
|
+
mem_total_gb=mem_total,
|
|
170
|
+
mem_available_gb=mem_available,
|
|
171
|
+
)
|
|
172
|
+
return ComputeInfo(cpu=cpu, gpus=_read_gpus())
|
utrain/cli/db.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import collections.abc
|
|
2
|
+
import contextlib
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
import sqlalchemy
|
|
6
|
+
import sqlalchemy.event
|
|
7
|
+
import sqlalchemy.orm
|
|
8
|
+
|
|
9
|
+
from .. import config
|
|
10
|
+
from . import exceptions
|
|
11
|
+
|
|
12
|
+
metadata = sqlalchemy.MetaData()
|
|
13
|
+
|
|
14
|
+
runs = sqlalchemy.Table(
|
|
15
|
+
"runs",
|
|
16
|
+
metadata,
|
|
17
|
+
sqlalchemy.Column("id", sqlalchemy.Text, primary_key=True),
|
|
18
|
+
sqlalchemy.Column("name", sqlalchemy.Text, nullable=False),
|
|
19
|
+
sqlalchemy.Column("image", sqlalchemy.Text, nullable=False),
|
|
20
|
+
sqlalchemy.Column("compute", sqlalchemy.Text, nullable=False),
|
|
21
|
+
sqlalchemy.Column("run_dir", sqlalchemy.Text, nullable=False),
|
|
22
|
+
sqlalchemy.Column("status", sqlalchemy.Text, nullable=False, default="configuring"),
|
|
23
|
+
sqlalchemy.Column("config_hash", sqlalchemy.Text, nullable=True),
|
|
24
|
+
sqlalchemy.Column("created_at", sqlalchemy.Float, nullable=False),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
run_attempts = sqlalchemy.Table(
|
|
28
|
+
"run_attempts",
|
|
29
|
+
metadata,
|
|
30
|
+
sqlalchemy.Column("run_id", sqlalchemy.Text, nullable=False),
|
|
31
|
+
sqlalchemy.Column("attempt", sqlalchemy.Integer, nullable=False),
|
|
32
|
+
sqlalchemy.Column("from_phase", sqlalchemy.Text, nullable=True),
|
|
33
|
+
sqlalchemy.Column("status", sqlalchemy.Text, nullable=False),
|
|
34
|
+
sqlalchemy.Column("pid", sqlalchemy.Integer, nullable=True),
|
|
35
|
+
sqlalchemy.Column("started_at", sqlalchemy.Float, nullable=False),
|
|
36
|
+
sqlalchemy.Column("ended_at", sqlalchemy.Float, nullable=True),
|
|
37
|
+
sqlalchemy.PrimaryKeyConstraint("run_id", "attempt"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
run_phases = sqlalchemy.Table(
|
|
41
|
+
"run_phases",
|
|
42
|
+
metadata,
|
|
43
|
+
sqlalchemy.Column("run_id", sqlalchemy.Text, nullable=False),
|
|
44
|
+
sqlalchemy.Column("attempt", sqlalchemy.Integer, nullable=False),
|
|
45
|
+
sqlalchemy.Column("phase", sqlalchemy.Text, nullable=False),
|
|
46
|
+
sqlalchemy.Column("phase_order", sqlalchemy.Integer, nullable=False),
|
|
47
|
+
sqlalchemy.Column("status", sqlalchemy.Text, nullable=False),
|
|
48
|
+
sqlalchemy.Column("started_at", sqlalchemy.Float, nullable=True),
|
|
49
|
+
sqlalchemy.Column("ended_at", sqlalchemy.Float, nullable=True),
|
|
50
|
+
sqlalchemy.PrimaryKeyConstraint("run_id", "attempt", "phase"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _set_sqlite_pragmas(dbapi_conn: sqlite3.Connection, _record: object) -> None:
|
|
55
|
+
# A detached orchestrator writes the DB concurrently with foreground read
|
|
56
|
+
# commands (notably `run show --wait`). Wait up to 5s for a held lock instead
|
|
57
|
+
# of failing immediately with "database is locked".
|
|
58
|
+
cursor = dbapi_conn.cursor()
|
|
59
|
+
cursor.execute("PRAGMA busy_timeout=5000")
|
|
60
|
+
cursor.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_engine(settings: config.Settings) -> sqlalchemy.Engine:
|
|
64
|
+
url = f"sqlite:///{settings.db_path}"
|
|
65
|
+
engine = sqlalchemy.create_engine(url, connect_args={"check_same_thread": False})
|
|
66
|
+
sqlalchemy.event.listen(engine, "connect", _set_sqlite_pragmas)
|
|
67
|
+
return engine
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def init_db(engine: sqlalchemy.Engine) -> None:
|
|
71
|
+
metadata.create_all(engine)
|
|
72
|
+
_migrate(engine)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _migrate(engine: sqlalchemy.Engine) -> None:
|
|
76
|
+
with engine.begin() as conn:
|
|
77
|
+
inspector = sqlalchemy.inspect(engine)
|
|
78
|
+
existing = {
|
|
79
|
+
t: {c["name"] for c in inspector.get_columns(t)} for t in inspector.get_table_names()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Rename gpu column to compute if it exists and compute doesn't
|
|
83
|
+
if "runs" in existing and "gpu" in existing["runs"] and "compute" not in existing["runs"]:
|
|
84
|
+
conn.execute(sqlalchemy.text("ALTER TABLE runs RENAME COLUMN gpu TO compute"))
|
|
85
|
+
# Refresh existing set after the rename
|
|
86
|
+
existing["runs"].discard("gpu")
|
|
87
|
+
existing["runs"].add("compute")
|
|
88
|
+
|
|
89
|
+
# Add new columns to legacy 'runs' table if it came from the old schema
|
|
90
|
+
if "runs" in existing:
|
|
91
|
+
for col, ddl in [
|
|
92
|
+
("name", "TEXT NOT NULL DEFAULT ''"),
|
|
93
|
+
("image", "TEXT NOT NULL DEFAULT ''"),
|
|
94
|
+
("compute", "TEXT NOT NULL DEFAULT 'cpu'"),
|
|
95
|
+
("config_hash", "TEXT"),
|
|
96
|
+
("created_at", "REAL NOT NULL DEFAULT 0"),
|
|
97
|
+
]:
|
|
98
|
+
if col not in existing["runs"]:
|
|
99
|
+
conn.execute(sqlalchemy.text(f"ALTER TABLE runs ADD COLUMN {col} {ddl}"))
|
|
100
|
+
|
|
101
|
+
# Drop columns that don't belong in the new schema (SQLite can't DROP columns
|
|
102
|
+
# before 3.35; skip silently — the extra columns are harmless).
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@contextlib.contextmanager
|
|
106
|
+
def with_db(
|
|
107
|
+
settings: config.Settings,
|
|
108
|
+
) -> collections.abc.Generator[sqlalchemy.orm.Session, None, None]:
|
|
109
|
+
engine = create_engine(settings)
|
|
110
|
+
init_db(engine)
|
|
111
|
+
with sqlalchemy.orm.Session(engine) as session:
|
|
112
|
+
try:
|
|
113
|
+
yield session
|
|
114
|
+
session.commit()
|
|
115
|
+
except Exception:
|
|
116
|
+
session.rollback()
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_run_id(prefix: str, session: sqlalchemy.orm.Session) -> str:
|
|
121
|
+
rows = (
|
|
122
|
+
session.execute(sqlalchemy.select(runs.c.id).where(runs.c.id.like(f"{prefix}%")))
|
|
123
|
+
.scalars()
|
|
124
|
+
.fetchall()
|
|
125
|
+
)
|
|
126
|
+
if not rows:
|
|
127
|
+
raise exceptions.UI(f"abort: run '{prefix}' not found")
|
|
128
|
+
if len(rows) > 1:
|
|
129
|
+
matches = ", ".join(str(r) for r in rows[:4])
|
|
130
|
+
raise exceptions.UI(f"abort: id prefix '{prefix}' is ambiguous (matches: {matches})")
|
|
131
|
+
return str(rows[0])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def short_run_id(run_id: str, session: sqlalchemy.orm.Session) -> str:
|
|
135
|
+
"""Shortest prefix of run_id that is unique across the runs table.
|
|
136
|
+
|
|
137
|
+
Uses run_id's lexicographic neighbors (PK-indexed), so it avoids scanning the
|
|
138
|
+
whole table. The result may be one char shorter than the uniform width used by
|
|
139
|
+
'run list', but still resolves unambiguously through resolve_run_id.
|
|
140
|
+
"""
|
|
141
|
+
pred = session.execute(
|
|
142
|
+
sqlalchemy.select(runs.c.id).where(runs.c.id < run_id).order_by(runs.c.id.desc()).limit(1)
|
|
143
|
+
).scalar_one_or_none()
|
|
144
|
+
succ = session.execute(
|
|
145
|
+
sqlalchemy.select(runs.c.id).where(runs.c.id > run_id).order_by(runs.c.id.asc()).limit(1)
|
|
146
|
+
).scalar_one_or_none()
|
|
147
|
+
|
|
148
|
+
def _lcp(a: str, b: str | None) -> int:
|
|
149
|
+
if b is None:
|
|
150
|
+
return 0
|
|
151
|
+
n = 0
|
|
152
|
+
for ca, cb in zip(a, b):
|
|
153
|
+
if ca != cb:
|
|
154
|
+
break
|
|
155
|
+
n += 1
|
|
156
|
+
return n
|
|
157
|
+
|
|
158
|
+
plen = 1 + max(
|
|
159
|
+
_lcp(run_id, str(pred) if pred is not None else None),
|
|
160
|
+
_lcp(run_id, str(succ) if succ is not None else None),
|
|
161
|
+
)
|
|
162
|
+
return run_id[:plen]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def latest_attempt(run_id: str, session: sqlalchemy.orm.Session) -> int | None:
|
|
166
|
+
result = session.execute(
|
|
167
|
+
sqlalchemy.select(sqlalchemy.func.max(run_attempts.c.attempt)).where(
|
|
168
|
+
run_attempts.c.run_id == run_id
|
|
169
|
+
)
|
|
170
|
+
).scalar_one_or_none()
|
|
171
|
+
return int(result) if result is not None else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_run(run_id: str, session: sqlalchemy.orm.Session) -> sqlalchemy.engine.RowMapping:
|
|
175
|
+
row = session.execute(sqlalchemy.select(runs).where(runs.c.id == run_id)).mappings().fetchone()
|
|
176
|
+
if row is None:
|
|
177
|
+
raise exceptions.UI(f"abort: run '{run_id}' not found")
|
|
178
|
+
return row
|
utrain/cli/debug.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
FORMAT = "%(asctime)s:%(levelname)s:%(module)s.%(funcName)s:%(message)s"
|
|
6
|
+
DATEFMT = "%H:%M:%S"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup(debug: int, log_filename: str) -> None:
|
|
10
|
+
log_level = os.getenv("UTRAIN_LOG_LEVEL")
|
|
11
|
+
match log_level:
|
|
12
|
+
case "DEBUG":
|
|
13
|
+
debug = 3
|
|
14
|
+
case "INFO":
|
|
15
|
+
debug = 2
|
|
16
|
+
case "WARNING":
|
|
17
|
+
debug = 1
|
|
18
|
+
case None:
|
|
19
|
+
pass
|
|
20
|
+
case _:
|
|
21
|
+
try:
|
|
22
|
+
debug = int(log_level)
|
|
23
|
+
except ValueError:
|
|
24
|
+
pass
|
|
25
|
+
if debug == 0:
|
|
26
|
+
return
|
|
27
|
+
match debug:
|
|
28
|
+
case 1:
|
|
29
|
+
level = logging.WARNING
|
|
30
|
+
case 2:
|
|
31
|
+
level = logging.INFO
|
|
32
|
+
case _:
|
|
33
|
+
level = logging.DEBUG
|
|
34
|
+
try:
|
|
35
|
+
f = open(log_filename, "a", buffering=1)
|
|
36
|
+
except Exception:
|
|
37
|
+
f = sys.stdout
|
|
38
|
+
logging.basicConfig(stream=f, level=level, format=FORMAT, datefmt=DATEFMT, force=True)
|
utrain/cli/exceptions.py
ADDED
utrain/cli/images.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import sqlalchemy
|
|
4
|
+
import sqlalchemy.orm
|
|
5
|
+
|
|
6
|
+
from .. import container
|
|
7
|
+
from . import db as dbmod
|
|
8
|
+
from . import exceptions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ImageInfo:
|
|
12
|
+
name: str
|
|
13
|
+
size_str: str
|
|
14
|
+
run_count: int
|
|
15
|
+
|
|
16
|
+
def __init__(self, name: str, size_str: str, run_count: int) -> None:
|
|
17
|
+
self.name = name
|
|
18
|
+
self.size_str = size_str
|
|
19
|
+
self.run_count = run_count
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _image_exists(ref: str) -> bool:
|
|
23
|
+
result = subprocess.run(["podman", "image", "exists", ref])
|
|
24
|
+
return result.returncode == 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _format_size(num_bytes: int) -> str:
|
|
28
|
+
size = float(num_bytes)
|
|
29
|
+
for unit in ("B", "K", "M", "G"):
|
|
30
|
+
if size < 1024 or unit == "G":
|
|
31
|
+
return f"{size:.0f}{unit}"
|
|
32
|
+
size /= 1024
|
|
33
|
+
return "?"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _image_size(name: str) -> str:
|
|
37
|
+
try:
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
[
|
|
40
|
+
"podman",
|
|
41
|
+
"image",
|
|
42
|
+
"inspect",
|
|
43
|
+
container.podman.image_ref(name),
|
|
44
|
+
"--format",
|
|
45
|
+
"{{.Size}}",
|
|
46
|
+
],
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
timeout=30,
|
|
50
|
+
)
|
|
51
|
+
if result.returncode == 0:
|
|
52
|
+
return _format_size(int(result.stdout.strip()))
|
|
53
|
+
except (FileNotFoundError, ValueError, subprocess.TimeoutExpired):
|
|
54
|
+
pass
|
|
55
|
+
return "?"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_images(session: sqlalchemy.orm.Session) -> list[ImageInfo]:
|
|
59
|
+
presets = container.podman.list_presets()
|
|
60
|
+
images: list[ImageInfo] = []
|
|
61
|
+
for name in sorted(presets):
|
|
62
|
+
run_count = session.execute(
|
|
63
|
+
sqlalchemy.select(sqlalchemy.func.count()).where(
|
|
64
|
+
(dbmod.runs.c.image == name) & (dbmod.runs.c.status != "deleted")
|
|
65
|
+
)
|
|
66
|
+
).scalar_one()
|
|
67
|
+
images.append(ImageInfo(name=name, size_str=_image_size(name), run_count=int(run_count)))
|
|
68
|
+
return images
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add_image(url: str) -> str:
|
|
72
|
+
# Derive <name> from the last path component of the URL (strip scheme and tag).
|
|
73
|
+
path_part = url.split("://", 1)[-1]
|
|
74
|
+
base = path_part.split("/")[-1].split(":")[0]
|
|
75
|
+
name = container.podman.preset_key(base)
|
|
76
|
+
|
|
77
|
+
# `podman://` is an enroot transport, not a podman one; it means "already in
|
|
78
|
+
# the local store", so there is nothing to pull. Everything else goes to
|
|
79
|
+
# `podman pull` with its scheme intact (docker://, or a bare registry ref).
|
|
80
|
+
if not url.startswith("podman://") and not _image_exists(path_part):
|
|
81
|
+
result = subprocess.run(["podman", "pull", url])
|
|
82
|
+
if result.returncode != 0:
|
|
83
|
+
raise exceptions.UI(f"abort: podman pull failed (exit {result.returncode})")
|
|
84
|
+
|
|
85
|
+
# Tag from the scheme-stripped ref: that is the name a pull stores locally,
|
|
86
|
+
# and `podman tag` rejects a transport prefix.
|
|
87
|
+
result = subprocess.run(
|
|
88
|
+
["podman", "tag", path_part, container.podman.image_ref(name)],
|
|
89
|
+
capture_output=True,
|
|
90
|
+
text=True,
|
|
91
|
+
)
|
|
92
|
+
if result.returncode != 0:
|
|
93
|
+
raise exceptions.UI(f"abort: podman tag failed: {result.stderr.strip()}")
|
|
94
|
+
|
|
95
|
+
return name
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def remove_image(name: str, session: sqlalchemy.orm.Session, force: bool = False) -> None:
|
|
99
|
+
if name not in container.podman.list_presets():
|
|
100
|
+
raise exceptions.UI(f"abort: image '{name}' not found")
|
|
101
|
+
|
|
102
|
+
run_count = session.execute(
|
|
103
|
+
sqlalchemy.select(sqlalchemy.func.count()).where(
|
|
104
|
+
(dbmod.runs.c.image == name) & (dbmod.runs.c.status != "deleted")
|
|
105
|
+
)
|
|
106
|
+
).scalar_one()
|
|
107
|
+
|
|
108
|
+
if int(run_count) > 0 and not force:
|
|
109
|
+
raise exceptions.UI(
|
|
110
|
+
f"abort: image '{name}' is used by {run_count} run(s); use --force to remove anyway"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Quiet: `podman rmi` reports every tag it drops ("Untagged: ..."), which is
|
|
114
|
+
# podman's bookkeeping, not utrain's output. Removal is silent on success.
|
|
115
|
+
result = subprocess.run(
|
|
116
|
+
["podman", "rmi", container.podman.image_ref(name)],
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
)
|
|
120
|
+
if result.returncode != 0:
|
|
121
|
+
raise exceptions.UI(f"abort: podman rmi failed: {result.stderr.strip()}")
|