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 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)
@@ -0,0 +1,2 @@
1
+ class UI(Exception):
2
+ pass
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()}")