mesh-deck 0.1.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.
- mesh_cli/__init__.py +1 -0
- mesh_cli/__main__.py +7 -0
- mesh_cli/cli.py +333 -0
- mesh_cli/completion.py +192 -0
- mesh_cli/config.py +96 -0
- mesh_cli/gpu.py +88 -0
- mesh_cli/holders.py +310 -0
- mesh_cli/planner.py +113 -0
- mesh_cli/py.typed +0 -0
- mesh_cli/runner.py +388 -0
- mesh_cli/suggest.py +17 -0
- mesh_cli/ui.py +251 -0
- mesh_deck-0.1.0.dist-info/METADATA +238 -0
- mesh_deck-0.1.0.dist-info/RECORD +18 -0
- mesh_deck-0.1.0.dist-info/WHEEL +5 -0
- mesh_deck-0.1.0.dist-info/entry_points.txt +2 -0
- mesh_deck-0.1.0.dist-info/licenses/LICENSE +21 -0
- mesh_deck-0.1.0.dist-info/top_level.txt +1 -0
mesh_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
mesh_cli/__main__.py
ADDED
mesh_cli/cli.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Command line entry point: mesh plan | up | down | status | logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__, completion, holders, runner, ui
|
|
12
|
+
from .config import ConfigError, load_config
|
|
13
|
+
from .gpu import GpuError, parse_gpu_query, read_gpus
|
|
14
|
+
from .planner import GpuBudget, Plan, PlanError, make_plan
|
|
15
|
+
from .suggest import did_you_mean
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Parser(argparse.ArgumentParser):
|
|
19
|
+
"""Adds a 'did you mean' to the unknown-command and invalid-value errors."""
|
|
20
|
+
|
|
21
|
+
def error(self, message: str):
|
|
22
|
+
found = re.search(r"invalid choice: '?(.+?)'? \(choose from (.+)\)", message)
|
|
23
|
+
if found and "maybe you meant" not in message: # newer Pythons add their own suggestion
|
|
24
|
+
word = found.group(1)
|
|
25
|
+
options = [o.strip().strip("'") for o in found.group(2).split(",")]
|
|
26
|
+
options = [o for o in options if not o.startswith("_")]
|
|
27
|
+
what = "command" if message.startswith("argument command") else "value"
|
|
28
|
+
message = f"unknown {what} '{word}'.{did_you_mean(word, options)} Choose from: {', '.join(options)}"
|
|
29
|
+
super().error(message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_external(value: str) -> dict[int, int]:
|
|
33
|
+
result: dict[int, int] = {}
|
|
34
|
+
for part in value.split(","):
|
|
35
|
+
gpu, _, mib = part.partition("=")
|
|
36
|
+
if not gpu.strip().isdigit() or not mib.strip().isdigit():
|
|
37
|
+
raise argparse.ArgumentTypeError(f"expected GPU=MiB pairs like 0=417,1=4, got {value!r}")
|
|
38
|
+
result[int(gpu)] = int(mib)
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _line_count(value: str) -> int | None:
|
|
43
|
+
"""`-n all` means the whole file (None); otherwise a number of lines, 0 or more."""
|
|
44
|
+
if value.lower() == "all":
|
|
45
|
+
return None
|
|
46
|
+
if not value.isdigit():
|
|
47
|
+
raise argparse.ArgumentTypeError(f"expected a number of lines or 'all', got {value!r}")
|
|
48
|
+
return int(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _render(plan: Plan, budgets: list[GpuBudget]) -> str:
|
|
52
|
+
lines = [f"Plan for {len(plan.services)} services on {len(budgets)} GPUs", ""]
|
|
53
|
+
for b in budgets:
|
|
54
|
+
lines.append(f"GPU {b.index} {b.name} total {b.total_mib} MiB, driver-reserved {b.reserved_mib}, external {b.external_mib} MiB")
|
|
55
|
+
lines.append("")
|
|
56
|
+
for number, svc in enumerate(plan.services, 1):
|
|
57
|
+
status = "OK " if svc.ok else "FAIL"
|
|
58
|
+
lines.append(f"{number}. {svc.name:<12} {status}")
|
|
59
|
+
for c in svc.checks:
|
|
60
|
+
if c.needed_mib is None:
|
|
61
|
+
lines.append(f" GPU {c.gpu}: footprint unknown, {c.free_before_mib} MiB free before start")
|
|
62
|
+
elif c.ok:
|
|
63
|
+
lines.append(f" GPU {c.gpu}: needs {c.needed_mib} MiB free, has {c.free_before_mib}; will hold {c.footprint_mib} MiB")
|
|
64
|
+
else:
|
|
65
|
+
lines.append(f" GPU {c.gpu}: needs {c.needed_mib} MiB free but only {c.free_before_mib} MiB is free")
|
|
66
|
+
if svc.suggested_utilization is not None:
|
|
67
|
+
lines.append(f" try --gpu-memory-utilization {svc.suggested_utilization:.2f} or less")
|
|
68
|
+
for note in svc.notes:
|
|
69
|
+
lines.append(f" note: {note}")
|
|
70
|
+
lines.append("")
|
|
71
|
+
lines.append("After all services start:")
|
|
72
|
+
for gi, left in sorted(plan.free_after_mib.items()):
|
|
73
|
+
lines.append(f" GPU {gi}: {left} MiB free")
|
|
74
|
+
for warning in plan.warnings:
|
|
75
|
+
lines.append(f"warning: {warning}")
|
|
76
|
+
return "\n".join(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _budgets(gpus, external: dict[int, int] | None) -> list[GpuBudget]:
|
|
80
|
+
return [
|
|
81
|
+
GpuBudget(
|
|
82
|
+
g.index,
|
|
83
|
+
g.total_mib,
|
|
84
|
+
external.get(g.index, 0) if external is not None else g.used_mib,
|
|
85
|
+
g.name,
|
|
86
|
+
g.reserved_mib,
|
|
87
|
+
)
|
|
88
|
+
for g in gpus
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
MEMORY_ERRORS = ("free memory on device", "out of memory", "outofmemoryerror")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _looks_like_memory_error(text: str) -> bool:
|
|
96
|
+
lowered = text.lower()
|
|
97
|
+
return any(marker in lowered for marker in MEMORY_ERRORS)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _show_holders(log_dir: Path, title: str, only: set[int] | None = None) -> None:
|
|
101
|
+
"""Print who holds GPU memory. Never raises: this is extra help on top of an error that is already shown."""
|
|
102
|
+
try:
|
|
103
|
+
reports = holders.gather(log_dir, only)
|
|
104
|
+
except (GpuError, OSError) as exc:
|
|
105
|
+
print(f"\n(could not read which processes hold GPU memory: {exc})")
|
|
106
|
+
return
|
|
107
|
+
if ui.fancy():
|
|
108
|
+
con = ui.console()
|
|
109
|
+
con.print(f"\n{title}\n", style="bold", markup=False)
|
|
110
|
+
ui.print_holders(reports, con)
|
|
111
|
+
return
|
|
112
|
+
print(f"\n{title}\n\n{holders.render(reports)}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cmd_gpu(args: argparse.Namespace) -> int:
|
|
116
|
+
reports = holders.gather(Path(args.log_dir))
|
|
117
|
+
if ui.fancy():
|
|
118
|
+
ui.print_holders(reports)
|
|
119
|
+
else:
|
|
120
|
+
print(holders.render(reports))
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _show_plan(plan: Plan, budgets: list[GpuBudget]) -> None:
|
|
125
|
+
if ui.fancy():
|
|
126
|
+
ui.print_plan(plan, budgets)
|
|
127
|
+
else:
|
|
128
|
+
print(_render(plan, budgets))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _plan_summary(plan: Plan, budgets: list[GpuBudget]) -> str:
|
|
132
|
+
free = ", ".join(f"GPU {i}: {mib} MiB" for i, mib in sorted(plan.free_after_mib.items()))
|
|
133
|
+
return f"Plan: all {len(plan.services)} services fit on {len(budgets)} GPUs. Free once they are up: {free}."
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _plan_has_news(plan: Plan) -> bool:
|
|
137
|
+
"""True when the plan has something to read: a service that will not fit, a warning, a note, or an unknown size."""
|
|
138
|
+
return bool(
|
|
139
|
+
not plan.ok
|
|
140
|
+
or plan.warnings
|
|
141
|
+
or any(s.notes or any(c.needed_mib is None for c in s.checks) for s in plan.services)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cmd_plan(args: argparse.Namespace) -> int:
|
|
146
|
+
config = load_config(args.config)
|
|
147
|
+
gpus = parse_gpu_query(Path(args.gpus_csv).read_text()) if args.gpus_csv else read_gpus()
|
|
148
|
+
if args.external is None:
|
|
149
|
+
print("note: counting all memory currently in use as external; stop running services or pass --external")
|
|
150
|
+
budgets = _budgets(gpus, args.external)
|
|
151
|
+
plan = make_plan(config, budgets)
|
|
152
|
+
_show_plan(plan, budgets)
|
|
153
|
+
if args.external is None and not args.gpus_csv and any(b.external_mib > 0 for b in budgets):
|
|
154
|
+
_show_holders(Path(args.log_dir), "Memory currently in use, by holder:", {b.index for b in budgets if b.external_mib > 0})
|
|
155
|
+
return 0 if plan.ok else 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cmd_up(args: argparse.Namespace) -> int:
|
|
159
|
+
config = load_config(args.config)
|
|
160
|
+
log_dir = Path(args.log_dir)
|
|
161
|
+
alive = runner.running_services(log_dir)
|
|
162
|
+
if alive:
|
|
163
|
+
print(f"error: already running ({', '.join(alive)}). Stop them first with: mesh down", file=sys.stderr)
|
|
164
|
+
return 1
|
|
165
|
+
try:
|
|
166
|
+
gpus = read_gpus()
|
|
167
|
+
except GpuError:
|
|
168
|
+
gpus = None
|
|
169
|
+
print("note: nvidia-smi not available; skipping the memory plan and memory measurements")
|
|
170
|
+
snapshot = None
|
|
171
|
+
if gpus is not None:
|
|
172
|
+
budgets = _budgets(gpus, None)
|
|
173
|
+
plan = make_plan(config, budgets)
|
|
174
|
+
if args.plan or _plan_has_news(plan):
|
|
175
|
+
_show_plan(plan, budgets)
|
|
176
|
+
else:
|
|
177
|
+
print(_plan_summary(plan, budgets))
|
|
178
|
+
if not plan.ok and not args.force:
|
|
179
|
+
print("\nThe plan does not fit. Fix the config, or start anyway with --force.")
|
|
180
|
+
short = {c.gpu for s in plan.services for c in s.checks if not c.ok}
|
|
181
|
+
_show_holders(log_dir, "Who is holding memory on the GPUs that are short:", short)
|
|
182
|
+
return 1
|
|
183
|
+
print()
|
|
184
|
+
snapshot = lambda: {g.index: g.used_mib for g in read_gpus()} # noqa: E731
|
|
185
|
+
display = ui.StartupDisplay() if ui.fancy() else None
|
|
186
|
+
try:
|
|
187
|
+
if display:
|
|
188
|
+
runner.run_up(config, log_dir, snapshot=snapshot, out=display.out, progress=display.progress)
|
|
189
|
+
else:
|
|
190
|
+
runner.run_up(config, log_dir, snapshot=snapshot)
|
|
191
|
+
except runner.RunError as exc:
|
|
192
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
193
|
+
if isinstance(exc, runner.ServiceExited) and _looks_like_memory_error(str(exc)):
|
|
194
|
+
_show_holders(log_dir, "Who is holding GPU memory now (the services mesh started have been stopped):")
|
|
195
|
+
return 1
|
|
196
|
+
finally:
|
|
197
|
+
if display:
|
|
198
|
+
display.close()
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _cmd_down(args: argparse.Namespace) -> int:
|
|
203
|
+
out = ui.StartupDisplay().out if ui.fancy() else print
|
|
204
|
+
if not runner.stop_from_state(Path(args.log_dir), out=out):
|
|
205
|
+
print(f"nothing running (looked for {Path(args.log_dir) / runner.STATE_FILE})")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
210
|
+
state = runner.read_state(Path(args.log_dir))
|
|
211
|
+
if not state:
|
|
212
|
+
print("nothing running")
|
|
213
|
+
return 0
|
|
214
|
+
rows = []
|
|
215
|
+
for name, info in state["services"].items():
|
|
216
|
+
alive = runner.group_alive(info["pid"])
|
|
217
|
+
code = runner.probe(info["port"], info["health"]) if alive else None
|
|
218
|
+
health = f"HTTP {code}" if code is not None else "not answering"
|
|
219
|
+
rows.append((name, alive, info["pid"], info["port"], health))
|
|
220
|
+
if ui.fancy():
|
|
221
|
+
ui.print_status(rows)
|
|
222
|
+
return 0
|
|
223
|
+
for name, alive, pid, port, health in rows:
|
|
224
|
+
print(f"{name:<12} {'running' if alive else 'stopped':<8} pid {pid:<8} port {port:<6} {health}")
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _cmd_logs(args: argparse.Namespace) -> int:
|
|
229
|
+
path = runner.log_path(Path(args.log_dir), args.service)
|
|
230
|
+
if not path.exists():
|
|
231
|
+
known = runner.known_services(Path(args.log_dir))
|
|
232
|
+
if known:
|
|
233
|
+
print(f"error: no log for service '{args.service}'.{did_you_mean(args.service, known)} "
|
|
234
|
+
f"Known services: {', '.join(known)}", file=sys.stderr)
|
|
235
|
+
else:
|
|
236
|
+
print(f"error: no log file {path}. Start a stack with 'mesh up', or pass --log-dir if it uses another directory.",
|
|
237
|
+
file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
try:
|
|
240
|
+
with open(path, errors="replace") as handle:
|
|
241
|
+
if args.lines is None: # -n all: the whole file, then (with -f) keep reading from where it ended
|
|
242
|
+
for chunk in iter(lambda: handle.read(65536), ""):
|
|
243
|
+
sys.stdout.write(chunk)
|
|
244
|
+
else:
|
|
245
|
+
sys.stdout.write(runner.tail_lines(path, args.lines))
|
|
246
|
+
handle.seek(0, 2)
|
|
247
|
+
sys.stdout.flush()
|
|
248
|
+
if not args.follow:
|
|
249
|
+
return 0
|
|
250
|
+
while True:
|
|
251
|
+
chunk = handle.read()
|
|
252
|
+
if chunk:
|
|
253
|
+
sys.stdout.write(chunk)
|
|
254
|
+
sys.stdout.flush()
|
|
255
|
+
else:
|
|
256
|
+
time.sleep(0.5)
|
|
257
|
+
except KeyboardInterrupt:
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _cmd_completion(args: argparse.Namespace) -> int:
|
|
262
|
+
print(completion.script(args.shell, build_parser()), end="")
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _cmd_complete(args: argparse.Namespace) -> int:
|
|
267
|
+
for name in runner.known_services(Path(args.log_dir)):
|
|
268
|
+
print(name)
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
273
|
+
parser = _Parser(prog="mesh", description="Plan and run multi-service GPU stacks.")
|
|
274
|
+
parser.add_argument("--version", action="version", version=f"mesh {__version__}")
|
|
275
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="command")
|
|
276
|
+
|
|
277
|
+
plan = sub.add_parser("plan", help="check whether the services fit on the GPUs, in order")
|
|
278
|
+
plan.add_argument("config", help="path to models.yaml")
|
|
279
|
+
plan.add_argument("--gpus-csv", help="saved output of: nvidia-smi --query-gpu=index,uuid,name,memory.total,memory.used,memory.free --format=csv")
|
|
280
|
+
plan.add_argument("--external", type=_parse_external, help="memory used by other processes, e.g. 0=417,1=4 (MiB)")
|
|
281
|
+
plan.add_argument("--log-dir", default="logs", help="where `mesh up` keeps its state (used to recognise your services)")
|
|
282
|
+
plan.set_defaults(func=_cmd_plan)
|
|
283
|
+
|
|
284
|
+
gpu = sub.add_parser("gpu", help="show who is holding memory on each GPU")
|
|
285
|
+
gpu.add_argument("--log-dir", default="logs", help="where `mesh up` keeps its state (used to recognise your services)")
|
|
286
|
+
gpu.set_defaults(func=_cmd_gpu)
|
|
287
|
+
|
|
288
|
+
up = sub.add_parser("up", help="plan, then start every service in order with one log file each")
|
|
289
|
+
up.add_argument("config", help="path to models.yaml")
|
|
290
|
+
up.add_argument("--log-dir", default="logs")
|
|
291
|
+
up.add_argument("--force", action="store_true", help="start even if the plan says a service will not fit")
|
|
292
|
+
up.add_argument("--plan", action="store_true", help="always print the full plan (by default only a one-line summary when everything fits)")
|
|
293
|
+
up.set_defaults(func=_cmd_up)
|
|
294
|
+
|
|
295
|
+
for name, help_text, func in (
|
|
296
|
+
("down", "stop a stack started with `mesh up`", _cmd_down),
|
|
297
|
+
("status", "show running services", _cmd_status),
|
|
298
|
+
):
|
|
299
|
+
p = sub.add_parser(name, help=help_text)
|
|
300
|
+
p.add_argument("--log-dir", default="logs")
|
|
301
|
+
p.set_defaults(func=func)
|
|
302
|
+
|
|
303
|
+
logs = sub.add_parser("logs", help="show a service's log")
|
|
304
|
+
logs.add_argument("service")
|
|
305
|
+
logs.add_argument("-n", "--lines", type=_line_count, default=50, metavar="N|all",
|
|
306
|
+
help="how many of the last lines to show (default 50), or 'all' for the whole log")
|
|
307
|
+
logs.add_argument("-f", "--follow", action="store_true", help="keep printing new lines until Ctrl+C")
|
|
308
|
+
logs.add_argument("--log-dir", default="logs")
|
|
309
|
+
logs.set_defaults(func=_cmd_logs)
|
|
310
|
+
|
|
311
|
+
comp = sub.add_parser("completion", help="print a shell completion script (bash, zsh or fish)")
|
|
312
|
+
comp.add_argument("shell", choices=completion.SHELLS)
|
|
313
|
+
comp.set_defaults(func=_cmd_completion)
|
|
314
|
+
|
|
315
|
+
# Used by the completion scripts; hidden from --help.
|
|
316
|
+
hidden = sub.add_parser("_complete")
|
|
317
|
+
hidden.add_argument("what", choices=["services"])
|
|
318
|
+
hidden.add_argument("--log-dir", default="logs")
|
|
319
|
+
hidden.set_defaults(func=_cmd_complete)
|
|
320
|
+
return parser
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def main(argv: list[str] | None = None) -> int:
|
|
324
|
+
args = build_parser().parse_args(argv)
|
|
325
|
+
try:
|
|
326
|
+
return args.func(args)
|
|
327
|
+
except (ConfigError, GpuError, PlanError) as exc:
|
|
328
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
329
|
+
return 2
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
sys.exit(main())
|
mesh_cli/completion.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Shell completion scripts for bash, zsh and fish, generated from the argument parser.
|
|
2
|
+
|
|
3
|
+
`mesh completion bash` (or zsh, fish) prints a script to add to your shell once. It completes commands, options,
|
|
4
|
+
config files, and, for `mesh logs`, the names of your services (asked of `mesh _complete services`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
SHELLS = ("bash", "zsh", "fish")
|
|
13
|
+
DIR_OPTIONS = {"--log-dir"}
|
|
14
|
+
FILE_OPTIONS = {"--gpus-csv"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Option:
|
|
19
|
+
flags: list[str]
|
|
20
|
+
help: str
|
|
21
|
+
takes_value: bool
|
|
22
|
+
kind: str # "dir" | "file" | "none"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Command:
|
|
27
|
+
name: str
|
|
28
|
+
help: str
|
|
29
|
+
options: list[Option] = field(default_factory=list)
|
|
30
|
+
positional: str | None = None # "config" | "service" | "shell" | None
|
|
31
|
+
choices: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clean(text: str | None) -> str:
|
|
35
|
+
return (text or "").replace("'", "").replace("`", "").replace('"', "").replace("\n", " ").replace("%", "%%").strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def introspect(parser: argparse.ArgumentParser) -> tuple[list[Option], list[Command]]:
|
|
39
|
+
"""Top-level options and the visible subcommands (those whose name does not start with an underscore)."""
|
|
40
|
+
top: list[Option] = []
|
|
41
|
+
commands: list[Command] = []
|
|
42
|
+
for action in parser._actions:
|
|
43
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
44
|
+
helps = {choice.dest: choice.help for choice in action._choices_actions}
|
|
45
|
+
for name, sub in action.choices.items():
|
|
46
|
+
if name.startswith("_"):
|
|
47
|
+
continue
|
|
48
|
+
command = Command(name, _clean(helps.get(name)))
|
|
49
|
+
for a in sub._actions:
|
|
50
|
+
if isinstance(a, argparse._HelpAction):
|
|
51
|
+
continue
|
|
52
|
+
if a.option_strings:
|
|
53
|
+
flag = a.option_strings[0]
|
|
54
|
+
kind = "dir" if flag in DIR_OPTIONS else "file" if flag in FILE_OPTIONS else "none"
|
|
55
|
+
takes = a.nargs != 0
|
|
56
|
+
command.options.append(Option(list(a.option_strings), _clean(a.help), takes, kind))
|
|
57
|
+
else:
|
|
58
|
+
command.positional = a.dest
|
|
59
|
+
command.choices = [str(c) for c in (a.choices or [])]
|
|
60
|
+
commands.append(command)
|
|
61
|
+
elif action.option_strings and not isinstance(action, argparse._HelpAction):
|
|
62
|
+
top.append(Option(list(action.option_strings), _clean(action.help), action.nargs != 0, "none"))
|
|
63
|
+
return top, commands
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bash(parser: argparse.ArgumentParser) -> str:
|
|
67
|
+
top, commands = introspect(parser)
|
|
68
|
+
names = " ".join(c.name for c in commands)
|
|
69
|
+
top_flags = " ".join(f for o in top for f in o.flags) + " --help"
|
|
70
|
+
cases = []
|
|
71
|
+
for c in commands:
|
|
72
|
+
flags = " ".join(f for o in c.options for f in o.flags) + " --help"
|
|
73
|
+
cases.append(f' {c.name}) opts="{flags}" ;;')
|
|
74
|
+
value_dirs = "|".join(sorted(DIR_OPTIONS))
|
|
75
|
+
value_files = "|".join(sorted(FILE_OPTIONS))
|
|
76
|
+
shells = " ".join(SHELLS)
|
|
77
|
+
return f"""# bash completion for mesh. Add to ~/.bashrc: eval "$(mesh completion bash)"
|
|
78
|
+
_mesh() {{
|
|
79
|
+
local cur prev cmd opts logdir i
|
|
80
|
+
cur="${{COMP_WORDS[COMP_CWORD]}}"
|
|
81
|
+
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
|
|
82
|
+
cmd="${{COMP_WORDS[1]}}"
|
|
83
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
84
|
+
COMPREPLY=($(compgen -W "{names} {top_flags}" -- "$cur"))
|
|
85
|
+
return
|
|
86
|
+
fi
|
|
87
|
+
case "$prev" in
|
|
88
|
+
{value_dirs}) COMPREPLY=($(compgen -d -- "$cur")); return ;;
|
|
89
|
+
{value_files}) COMPREPLY=($(compgen -f -- "$cur")); return ;;
|
|
90
|
+
esac
|
|
91
|
+
if [[ "$cur" == -* ]]; then
|
|
92
|
+
case "$cmd" in
|
|
93
|
+
{chr(10).join(cases)}
|
|
94
|
+
esac
|
|
95
|
+
COMPREPLY=($(compgen -W "$opts" -- "$cur"))
|
|
96
|
+
return
|
|
97
|
+
fi
|
|
98
|
+
case "$cmd" in
|
|
99
|
+
plan|up) COMPREPLY=($(compgen -f -- "$cur")) ;;
|
|
100
|
+
logs)
|
|
101
|
+
logdir=logs
|
|
102
|
+
for ((i = 1; i < COMP_CWORD; i++)); do
|
|
103
|
+
if [ "${{COMP_WORDS[i]}}" = "--log-dir" ]; then logdir="${{COMP_WORDS[i+1]}}"; fi
|
|
104
|
+
done
|
|
105
|
+
COMPREPLY=($(compgen -W "$(mesh _complete services --log-dir "$logdir" 2>/dev/null)" -- "$cur"))
|
|
106
|
+
;;
|
|
107
|
+
completion) COMPREPLY=($(compgen -W "{shells}" -- "$cur")) ;;
|
|
108
|
+
esac
|
|
109
|
+
}}
|
|
110
|
+
complete -o filenames -F _mesh mesh
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def zsh(parser: argparse.ArgumentParser) -> str:
|
|
115
|
+
top, commands = introspect(parser)
|
|
116
|
+
command_lines = "\n".join(f" '{c.name}:{c.help}'" for c in commands)
|
|
117
|
+
cases = []
|
|
118
|
+
for c in commands:
|
|
119
|
+
specs = []
|
|
120
|
+
for o in c.options:
|
|
121
|
+
where = {"dir": ":directory:_files -/", "file": ":file:_files", "none": ":value: " if o.takes_value else ""}[o.kind]
|
|
122
|
+
for flag in o.flags:
|
|
123
|
+
specs.append(f"'{flag}[{o.help}]{where}'")
|
|
124
|
+
if c.positional == "config":
|
|
125
|
+
specs.append("':config:_files -g \"*.(yaml|yml)\"'")
|
|
126
|
+
elif c.positional == "service":
|
|
127
|
+
specs.append("':service:_mesh_services'")
|
|
128
|
+
elif c.positional == "shell":
|
|
129
|
+
specs.append(f"':shell:({' '.join(c.choices)})'")
|
|
130
|
+
body = " \\\n ".join(specs) if specs else "'*:'"
|
|
131
|
+
cases.append(f" {c.name})\n _arguments \\\n {body}\n ;;")
|
|
132
|
+
top_specs = " ".join(f"'{f}[{o.help}]'" for o in top for f in o.flags)
|
|
133
|
+
return f"""#compdef mesh
|
|
134
|
+
# zsh completion for mesh. Add to ~/.zshrc (after compinit): eval "$(mesh completion zsh)"
|
|
135
|
+
_mesh_services() {{
|
|
136
|
+
local -a names
|
|
137
|
+
names=(${{(f)"$(mesh _complete services --log-dir "${{opt_args[--log-dir]:-logs}}" 2>/dev/null)"}})
|
|
138
|
+
_describe 'service' names
|
|
139
|
+
}}
|
|
140
|
+
|
|
141
|
+
_mesh() {{
|
|
142
|
+
local -a commands
|
|
143
|
+
commands=(
|
|
144
|
+
{command_lines}
|
|
145
|
+
)
|
|
146
|
+
if (( CURRENT == 2 )); then
|
|
147
|
+
_describe 'command' commands
|
|
148
|
+
_arguments {top_specs}
|
|
149
|
+
return
|
|
150
|
+
fi
|
|
151
|
+
case "$words[2]" in
|
|
152
|
+
{chr(10).join(cases)}
|
|
153
|
+
esac
|
|
154
|
+
}}
|
|
155
|
+
|
|
156
|
+
compdef _mesh mesh
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def fish(parser: argparse.ArgumentParser) -> str:
|
|
161
|
+
top, commands = introspect(parser)
|
|
162
|
+
lines = ["# fish completion for mesh. Save to ~/.config/fish/completions/mesh.fish: mesh completion fish > that file",
|
|
163
|
+
"complete -c mesh -f"]
|
|
164
|
+
for o in top:
|
|
165
|
+
long = [f[2:] for f in o.flags if f.startswith("--")]
|
|
166
|
+
lines.append(f"complete -c mesh -n '__fish_use_subcommand' -l {long[0]} -d '{o.help}'")
|
|
167
|
+
for c in commands:
|
|
168
|
+
lines.append(f"complete -c mesh -n '__fish_use_subcommand' -a {c.name} -d '{c.help}'")
|
|
169
|
+
for c in commands:
|
|
170
|
+
for o in c.options:
|
|
171
|
+
long = [f[2:] for f in o.flags if f.startswith("--")]
|
|
172
|
+
short = [f[1:] for f in o.flags if not f.startswith("--") and f.startswith("-")]
|
|
173
|
+
parts = [f"complete -c mesh -n '__fish_seen_subcommand_from {c.name}'"]
|
|
174
|
+
if long:
|
|
175
|
+
parts.append(f"-l {long[0]}")
|
|
176
|
+
if short:
|
|
177
|
+
parts.append(f"-s {short[0]}")
|
|
178
|
+
if o.takes_value:
|
|
179
|
+
parts.append("-r -F" if o.kind in ("dir", "file") else "-r")
|
|
180
|
+
parts.append(f"-d '{o.help}'")
|
|
181
|
+
lines.append(" ".join(parts))
|
|
182
|
+
if c.positional == "config":
|
|
183
|
+
lines.append(f"complete -c mesh -n '__fish_seen_subcommand_from {c.name}' -F")
|
|
184
|
+
elif c.positional == "service":
|
|
185
|
+
lines.append(f"complete -c mesh -n '__fish_seen_subcommand_from {c.name}' -a '(mesh _complete services 2>/dev/null)'")
|
|
186
|
+
elif c.positional == "shell":
|
|
187
|
+
lines.append(f"complete -c mesh -n '__fish_seen_subcommand_from {c.name}' -a '{' '.join(c.choices)}'")
|
|
188
|
+
return "\n".join(lines) + "\n"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def script(shell: str, parser: argparse.ArgumentParser) -> str:
|
|
192
|
+
return {"bash": bash, "zsh": zsh, "fish": fish}[shell](parser)
|
mesh_cli/config.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Load and validate models.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
11
|
+
|
|
12
|
+
from .suggest import did_you_mean
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _duplicates(values: list[str] | list[int]) -> list[str | int]:
|
|
16
|
+
return sorted({v for v in values if values.count(v) > 1}, key=str) # type: ignore[arg-type]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Service(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
25
|
+
|
|
26
|
+
name: str = Field(min_length=1)
|
|
27
|
+
port: int = Field(ge=1, le=65535)
|
|
28
|
+
gpus: list[int] = Field(min_length=1)
|
|
29
|
+
runtime: Literal["command", "docker"] = "command"
|
|
30
|
+
# vLLM-style fraction of each GPU's total memory.
|
|
31
|
+
gpu_memory_utilization: float | None = Field(default=None, gt=0, le=1)
|
|
32
|
+
# Real memory the service held per GPU in a previous run (from nvidia-smi).
|
|
33
|
+
measured_mib: dict[int, int] | None = None
|
|
34
|
+
# Extra memory beyond the requested fraction (CUDA graphs etc.), used when not measured.
|
|
35
|
+
overhead_mib: int = Field(default=0, ge=0)
|
|
36
|
+
health: str = "/v1/models"
|
|
37
|
+
# Shell snippet run with `bash -c`; end it with `exec <program>` so signals reach the program.
|
|
38
|
+
command: str | None = None
|
|
39
|
+
# Extra command run first when stopping (e.g. `docker stop my-stt`).
|
|
40
|
+
stop: str | None = None
|
|
41
|
+
start_timeout_s: int = Field(default=900, gt=0)
|
|
42
|
+
|
|
43
|
+
@model_validator(mode="after")
|
|
44
|
+
def _measured_gpus_are_used(self) -> Service:
|
|
45
|
+
if self.measured_mib:
|
|
46
|
+
extra = set(self.measured_mib) - set(self.gpus)
|
|
47
|
+
if extra:
|
|
48
|
+
raise ValueError(f"measured_mib lists GPUs {sorted(extra)} that are not in gpus")
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Config(BaseModel):
|
|
53
|
+
model_config = ConfigDict(extra="forbid")
|
|
54
|
+
|
|
55
|
+
services: list[Service] = Field(min_length=1)
|
|
56
|
+
# Free memory that should remain on each GPU after everything has started.
|
|
57
|
+
margin_mib: int = Field(default=1024, ge=0)
|
|
58
|
+
|
|
59
|
+
@model_validator(mode="after")
|
|
60
|
+
def _unique_names_and_ports(self) -> Config:
|
|
61
|
+
names = [s.name for s in self.services]
|
|
62
|
+
ports = [s.port for s in self.services]
|
|
63
|
+
for label, dupes in (("name", _duplicates(names)), ("port", _duplicates(ports))):
|
|
64
|
+
if dupes:
|
|
65
|
+
raise ValueError(f"duplicate service {label}: {dupes}")
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_config(path: str | Path) -> Config:
|
|
70
|
+
path = Path(path)
|
|
71
|
+
try:
|
|
72
|
+
raw = yaml.safe_load(path.read_text())
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
hint = ""
|
|
75
|
+
if not path.exists() and path.parent.is_dir():
|
|
76
|
+
hint = did_you_mean(path.name, (p.name for p in path.parent.iterdir() if p.suffix in (".yaml", ".yml")))
|
|
77
|
+
raise ConfigError(f"cannot read {path}: {exc.strerror}.{hint}") from exc
|
|
78
|
+
except yaml.YAMLError as exc:
|
|
79
|
+
raise ConfigError(f"{path} is not valid YAML: {exc}") from exc
|
|
80
|
+
if not isinstance(raw, dict):
|
|
81
|
+
raise ConfigError(f"{path} must contain a mapping with a 'services' list")
|
|
82
|
+
try:
|
|
83
|
+
return Config.model_validate(raw)
|
|
84
|
+
except ValidationError as exc:
|
|
85
|
+
lines = [f" {'.'.join(str(p) for p in e['loc'])}: {e['msg']}{_field_hint(e)}" for e in exc.errors()]
|
|
86
|
+
raise ConfigError(f"invalid config {path}:\n" + "\n".join(lines)) from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _field_hint(error: Mapping[str, Any]) -> str:
|
|
90
|
+
"""For an unknown field, suggest the closest real one (for example gpu_memory_utilisation)."""
|
|
91
|
+
if error.get("type") != "extra_forbidden":
|
|
92
|
+
return ""
|
|
93
|
+
loc = error["loc"]
|
|
94
|
+
model = Service if "services" in loc[:-1] else Config
|
|
95
|
+
hint = did_you_mean(str(loc[-1]), model.model_fields)
|
|
96
|
+
return f". {hint.strip()}" if hint else ""
|