Refactor CLI and LLM runtime handling for improved user experience
- Removed deprecated console usage in favor of structured logging functions for error handling and user prompts. - Enhanced CLI prompts for LLM runtime and preset selection, utilizing menu helpers for better user interaction. - Updated GPU pool scanning output with improved formatting and error indication for clarity. - Refactored setup wizard to streamline LLM runtime and preset configuration, ensuring a more intuitive setup process. - Improved documentation and user feedback in CLI outputs to enhance overall usability.
This commit is contained in:
+88
-71
@@ -10,7 +10,6 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from gpu_rent import __version__
|
from gpu_rent import __version__
|
||||||
@@ -23,22 +22,15 @@ from gpu_rent.ssh_ops import interactive_ssh, run_ssh
|
|||||||
from gpu_rent.state import load_state, preempt_window_end
|
from gpu_rent.state import load_state, preempt_window_end
|
||||||
from gpu_rent.provision import ensure_swarmui_running, seed_civitai, seed_extensions
|
from gpu_rent.provision import ensure_swarmui_running, seed_civitai, seed_extensions
|
||||||
from gpu_rent.sync_files import pull_tree, push_tree
|
from gpu_rent.sync_files import pull_tree, push_tree
|
||||||
|
from gpu_rent.term import console, err, log, ok, warn
|
||||||
from gpu_rent.tunnel import run_tunnel
|
from gpu_rent.tunnel import run_tunnel
|
||||||
|
|
||||||
if sys.platform == "win32":
|
|
||||||
for _stream in (sys.stdout, sys.stderr):
|
|
||||||
try:
|
|
||||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
||||||
except (AttributeError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
pretty_exceptions_enable=False,
|
pretty_exceptions_enable=False,
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent setup / doctor. Ключи: docs/setup.md",
|
help="Прерываемый GPU Selectel + SwarmUI на localhost:17801. Сначала: gpu-rent setup / doctor. Ключи: docs/setup.md",
|
||||||
)
|
)
|
||||||
console = Console(highlight=False, legacy_windows=False)
|
|
||||||
|
|
||||||
_DEBUG = False
|
_DEBUG = False
|
||||||
|
|
||||||
@@ -54,7 +46,7 @@ def _root(
|
|||||||
def _die(exc: BaseException) -> None:
|
def _die(exc: BaseException) -> None:
|
||||||
if _DEBUG:
|
if _DEBUG:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
console.print(f"[red]{exc}[/red]")
|
err(str(exc))
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -368,7 +360,7 @@ def setup(
|
|||||||
install_watchdog=watchdog if watchdog is not None else (False if yes else None),
|
install_watchdog=watchdog if watchdog is not None else (False if yes else None),
|
||||||
confirm=None if yes and watchdog is None else confirm,
|
confirm=None if yes and watchdog is None else confirm,
|
||||||
ask=None if yes and llm is not None else ask,
|
ask=None if yes and llm is not None else ask,
|
||||||
log=lambda m: console.print(m),
|
log=log,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
@@ -412,7 +404,6 @@ def up(
|
|||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
|
||||||
from gpu_rent.llm_runtime import (
|
from gpu_rent.llm_runtime import (
|
||||||
PRESET_HELP,
|
|
||||||
append_vars_llm_runtime,
|
append_vars_llm_runtime,
|
||||||
decide_runtime,
|
decide_runtime,
|
||||||
ensure_ollama_manifest_from_example,
|
ensure_ollama_manifest_from_example,
|
||||||
@@ -438,11 +429,24 @@ def up(
|
|||||||
|
|
||||||
asked_model_preset = False
|
asked_model_preset = False
|
||||||
if not yes and runtime == "none" and not llm and not ollama and not llamacpp:
|
if not yes and runtime == "none" and not llm and not ollama and not llamacpp:
|
||||||
choice = typer.prompt(
|
from gpu_rent.llm_runtime import (
|
||||||
"Поднять LLM рядом со SwarmUI? [none/ollama/llamacpp]",
|
llamacpp_preset_menu,
|
||||||
default="none",
|
llm_runtime_menu,
|
||||||
|
ollama_preset_menu,
|
||||||
)
|
)
|
||||||
|
from gpu_rent.prompts import prompt_menu
|
||||||
|
|
||||||
|
def _ask(msg: str, default: str = "") -> str:
|
||||||
|
return typer.prompt(msg, default=default)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
choice = prompt_menu(
|
||||||
|
"LLM рядом со SwarmUI",
|
||||||
|
llm_runtime_menu(),
|
||||||
|
default="none",
|
||||||
|
ask=_ask,
|
||||||
|
show=log,
|
||||||
|
)
|
||||||
runtime = decide_runtime(
|
runtime = decide_runtime(
|
||||||
flag=choice, ollama_flag=False, llamacpp_flag=False, from_config="none"
|
flag=choice, ollama_flag=False, llamacpp_flag=False, from_config="none"
|
||||||
)
|
)
|
||||||
@@ -452,63 +456,82 @@ def up(
|
|||||||
append_vars_llm_runtime(vars_path(), runtime)
|
append_vars_llm_runtime(vars_path(), runtime)
|
||||||
if runtime == "ollama":
|
if runtime == "ollama":
|
||||||
ensure_ollama_manifest_from_example()
|
ensure_ollama_manifest_from_example()
|
||||||
console.print(PRESET_HELP)
|
try:
|
||||||
preset = typer.prompt("Ollama preset", default="recommended")
|
preset = prompt_menu(
|
||||||
if preset.strip().lower() not in {"keep", "example"}:
|
"Ollama preset",
|
||||||
write_ollama_models_preset(
|
ollama_preset_menu(include_keep=False),
|
||||||
cfg.ollama_models_manifest, preset.strip().lower()
|
default="recommended",
|
||||||
|
ask=_ask,
|
||||||
|
show=log,
|
||||||
)
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise GpuRentError(str(exc)) from exc
|
||||||
|
if preset not in {"keep", "example"}:
|
||||||
|
write_ollama_models_preset(cfg.ollama_models_manifest, preset)
|
||||||
asked_model_preset = True
|
asked_model_preset = True
|
||||||
elif runtime == "llamacpp":
|
elif runtime == "llamacpp":
|
||||||
from gpu_rent.llm_runtime import (
|
from gpu_rent.llm_runtime import (
|
||||||
LLAMACPP_PRESET_HELP,
|
|
||||||
ensure_llamacpp_manifest_from_example,
|
ensure_llamacpp_manifest_from_example,
|
||||||
write_llamacpp_models_preset,
|
write_llamacpp_models_preset,
|
||||||
)
|
)
|
||||||
|
|
||||||
ensure_llamacpp_manifest_from_example()
|
ensure_llamacpp_manifest_from_example()
|
||||||
console.print(LLAMACPP_PRESET_HELP)
|
try:
|
||||||
preset = typer.prompt(
|
preset = prompt_menu(
|
||||||
"llama.cpp GGUF preset [recommended/light/stock/empty]",
|
"llama.cpp GGUF",
|
||||||
|
llamacpp_preset_menu(include_keep=False),
|
||||||
default="recommended",
|
default="recommended",
|
||||||
|
ask=_ask,
|
||||||
|
show=log,
|
||||||
)
|
)
|
||||||
if preset.strip().lower() not in {"keep", "example"}:
|
except ValueError as exc:
|
||||||
write_llamacpp_models_preset(
|
raise GpuRentError(str(exc)) from exc
|
||||||
cfg.llamacpp_models_manifest, preset.strip().lower()
|
if preset not in {"keep", "example"}:
|
||||||
)
|
write_llamacpp_models_preset(cfg.llamacpp_models_manifest, preset)
|
||||||
asked_model_preset = True
|
asked_model_preset = True
|
||||||
|
|
||||||
# Runtime уже в vars (напр. llamacpp) — всё равно спросить модель, default=keep.
|
# Runtime уже в vars — спросить пресет, default=keep.
|
||||||
if not yes and not asked_model_preset and runtime == "llamacpp":
|
if not yes and not asked_model_preset and runtime in {"llamacpp", "ollama"}:
|
||||||
from gpu_rent.llm_runtime import (
|
from gpu_rent.llm_runtime import (
|
||||||
LLAMACPP_PRESET_HELP,
|
|
||||||
ensure_llamacpp_manifest_from_example,
|
ensure_llamacpp_manifest_from_example,
|
||||||
|
llamacpp_preset_menu,
|
||||||
|
ollama_preset_menu,
|
||||||
write_llamacpp_models_preset,
|
write_llamacpp_models_preset,
|
||||||
)
|
)
|
||||||
|
from gpu_rent.prompts import prompt_menu
|
||||||
|
|
||||||
|
def _ask2(msg: str, default: str = "") -> str:
|
||||||
|
return typer.prompt(msg, default=default)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if runtime == "llamacpp":
|
||||||
ensure_llamacpp_manifest_from_example()
|
ensure_llamacpp_manifest_from_example()
|
||||||
console.print(LLAMACPP_PRESET_HELP)
|
key = prompt_menu(
|
||||||
preset = typer.prompt(
|
"llama.cpp GGUF",
|
||||||
"llama.cpp GGUF preset [recommended/light/stock/empty/keep]",
|
llamacpp_preset_menu(include_keep=True),
|
||||||
default="keep",
|
default="keep",
|
||||||
|
ask=_ask2,
|
||||||
|
show=log,
|
||||||
)
|
)
|
||||||
key = preset.strip().lower()
|
|
||||||
if key not in {"keep", "example", ""}:
|
if key not in {"keep", "example", ""}:
|
||||||
write_llamacpp_models_preset(cfg.llamacpp_models_manifest, key)
|
write_llamacpp_models_preset(cfg.llamacpp_models_manifest, key)
|
||||||
elif not yes and not asked_model_preset and runtime == "ollama":
|
else:
|
||||||
ensure_ollama_manifest_from_example()
|
ensure_ollama_manifest_from_example()
|
||||||
console.print(PRESET_HELP)
|
key = prompt_menu(
|
||||||
preset = typer.prompt(
|
"Ollama preset",
|
||||||
"Ollama preset [recommended/light/stock/alt/empty/keep]",
|
ollama_preset_menu(include_keep=True),
|
||||||
default="keep",
|
default="keep",
|
||||||
|
ask=_ask2,
|
||||||
|
show=log,
|
||||||
)
|
)
|
||||||
key = preset.strip().lower()
|
|
||||||
if key not in {"keep", "example", ""}:
|
if key not in {"keep", "example", ""}:
|
||||||
write_ollama_models_preset(cfg.ollama_models_manifest, key)
|
write_ollama_models_preset(cfg.ollama_models_manifest, key)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise GpuRentError(str(exc)) from exc
|
||||||
|
|
||||||
cfg = replace(cfg, llm_runtime=runtime)
|
cfg = replace(cfg, llm_runtime=runtime)
|
||||||
if runtime != "none":
|
if runtime != "none":
|
||||||
console.print(f"LLM runtime: {runtime}")
|
ok(f"LLM runtime: {runtime}")
|
||||||
|
|
||||||
def confirm(msg: str) -> bool:
|
def confirm(msg: str) -> bool:
|
||||||
return typer.confirm(msg)
|
return typer.confirm(msg)
|
||||||
@@ -525,7 +548,7 @@ def up(
|
|||||||
update=False if no_update else None,
|
update=False if no_update else None,
|
||||||
confirm=confirm,
|
confirm=confirm,
|
||||||
ask=None if yes else ask,
|
ask=None if yes else ask,
|
||||||
log=lambda m: console.print(m),
|
log=log,
|
||||||
)
|
)
|
||||||
if no_tunnel:
|
if no_tunnel:
|
||||||
from gpu_rent.access_card import print_access_card
|
from gpu_rent.access_card import print_access_card
|
||||||
@@ -550,7 +573,7 @@ def up(
|
|||||||
cfg,
|
cfg,
|
||||||
state.floating_ip,
|
state.floating_ip,
|
||||||
open_browser=open_browser,
|
open_browser=open_browser,
|
||||||
log=lambda m: console.print(m),
|
log=log,
|
||||||
)
|
)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
@@ -563,7 +586,7 @@ def stop(
|
|||||||
"""Удалить compute и FIP, диски оставить."""
|
"""Удалить compute и FIP, диски оставить."""
|
||||||
try:
|
try:
|
||||||
cfg = load_config(require_auth=True)
|
cfg = load_config(require_auth=True)
|
||||||
cmd_stop(cfg, no_pull=no_pull, log=lambda m: console.print(m))
|
cmd_stop(cfg, no_pull=no_pull, log=log)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
|
|
||||||
@@ -579,7 +602,7 @@ def destroy(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
try:
|
try:
|
||||||
cfg = load_config(require_auth=True)
|
cfg = load_config(require_auth=True)
|
||||||
cmd_stop(cfg, destroy_disks=True, no_pull=no_pull, log=lambda m: console.print(m))
|
cmd_stop(cfg, destroy_disks=True, no_pull=no_pull, log=log)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
|
|
||||||
@@ -636,7 +659,7 @@ def tunnel(
|
|||||||
cfg,
|
cfg,
|
||||||
state.floating_ip,
|
state.floating_ip,
|
||||||
open_browser=open_browser,
|
open_browser=open_browser,
|
||||||
log=lambda m: console.print(m),
|
log=log,
|
||||||
)
|
)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
@@ -654,9 +677,9 @@ def hold(
|
|||||||
|
|
||||||
cfg, host = _live()
|
cfg, host = _live()
|
||||||
if clear:
|
if clear:
|
||||||
clear_hold(cfg, host, log=lambda m: console.print(m))
|
clear_hold(cfg, host, log=log)
|
||||||
return
|
return
|
||||||
set_hold(cfg, host, minutes=minutes, until=until, log=lambda m: console.print(m))
|
set_hold(cfg, host, minutes=minutes, until=until, log=log)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
|
|
||||||
@@ -666,7 +689,7 @@ def seed_models() -> None:
|
|||||||
"""Докачать Civitai-манифест на живой диск."""
|
"""Докачать Civitai-манифест на живой диск."""
|
||||||
try:
|
try:
|
||||||
cfg, host = _live()
|
cfg, host = _live()
|
||||||
seed_civitai(cfg, host, log=lambda m: console.print(m))
|
seed_civitai(cfg, host, log=log)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
|
|
||||||
@@ -677,9 +700,6 @@ def push_all() -> None:
|
|||||||
try:
|
try:
|
||||||
cfg, host = _live()
|
cfg, host = _live()
|
||||||
|
|
||||||
def log(msg: str) -> None:
|
|
||||||
console.print(msg)
|
|
||||||
|
|
||||||
push_tree(cfg, host, cfg.local_models_dir, "/mnt/swarm_data/Models", log, models=True)
|
push_tree(cfg, host, cfg.local_models_dir, "/mnt/swarm_data/Models", log, models=True)
|
||||||
push_tree(cfg, host, cfg.local_wildcards_dir, "/mnt/swarm_data/Data/Wildcards", log, models=False)
|
push_tree(cfg, host, cfg.local_wildcards_dir, "/mnt/swarm_data/Data/Wildcards", log, models=False)
|
||||||
push_tree(cfg, host, cfg.local_workflows_dir, "/mnt/swarm_data/CustomWorkflows", log, models=False)
|
push_tree(cfg, host, cfg.local_workflows_dir, "/mnt/swarm_data/CustomWorkflows", log, models=False)
|
||||||
@@ -697,7 +717,7 @@ def push_models() -> None:
|
|||||||
host,
|
host,
|
||||||
cfg.local_models_dir,
|
cfg.local_models_dir,
|
||||||
"/mnt/swarm_data/Models",
|
"/mnt/swarm_data/Models",
|
||||||
lambda m: console.print(m),
|
log,
|
||||||
models=True,
|
models=True,
|
||||||
)
|
)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
@@ -714,7 +734,7 @@ def pull_output_cmd() -> None:
|
|||||||
host,
|
host,
|
||||||
"/mnt/swarm_data/Output",
|
"/mnt/swarm_data/Output",
|
||||||
cfg.local_output_dir,
|
cfg.local_output_dir,
|
||||||
lambda m: console.print(m),
|
log,
|
||||||
)
|
)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
@@ -726,9 +746,6 @@ def seed_extensions_cmd() -> None:
|
|||||||
try:
|
try:
|
||||||
cfg, host = _live()
|
cfg, host = _live()
|
||||||
|
|
||||||
def log(msg: str) -> None:
|
|
||||||
console.print(msg)
|
|
||||||
|
|
||||||
seed_extensions(cfg, host, log)
|
seed_extensions(cfg, host, log)
|
||||||
ensure_swarmui_running(cfg, host, log, restart=True)
|
ensure_swarmui_running(cfg, host, log, restart=True)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
@@ -768,9 +785,9 @@ def capture_root(
|
|||||||
host,
|
host,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
kind_filter=kind,
|
kind_filter=kind,
|
||||||
log=lambda m: console.print(m),
|
log=log,
|
||||||
)
|
)
|
||||||
print_report(report, lambda m: console.print(m), dry_run=dry_run)
|
print_report(report, log, dry_run=dry_run)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
|
|
||||||
@@ -796,11 +813,11 @@ def capture_models_cmd(
|
|||||||
None,
|
None,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
kind_filter=kind,
|
kind_filter=kind,
|
||||||
log=lambda m: console.print(m),
|
log=log,
|
||||||
)
|
)
|
||||||
print_report(
|
print_report(
|
||||||
report,
|
report,
|
||||||
lambda m: console.print(m),
|
log,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
show_models=True,
|
show_models=True,
|
||||||
show_extensions=False,
|
show_extensions=False,
|
||||||
@@ -823,11 +840,11 @@ def capture_extensions_cmd(
|
|||||||
host,
|
host,
|
||||||
None,
|
None,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
log=lambda m: console.print(m),
|
log=log,
|
||||||
)
|
)
|
||||||
print_report(
|
print_report(
|
||||||
report,
|
report,
|
||||||
lambda m: console.print(m),
|
log,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
show_models=False,
|
show_models=False,
|
||||||
show_extensions=True,
|
show_extensions=True,
|
||||||
@@ -854,9 +871,9 @@ def capture_all_cmd(
|
|||||||
host,
|
host,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
kind_filter=kind,
|
kind_filter=kind,
|
||||||
log=lambda m: console.print(m),
|
log=log,
|
||||||
)
|
)
|
||||||
print_report(report, lambda m: console.print(m), dry_run=dry_run)
|
print_report(report, log, dry_run=dry_run)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
|
|
||||||
@@ -868,7 +885,7 @@ def resize_data(gb: int = typer.Option(..., "--gb", help="Новый разме
|
|||||||
from gpu_rent.resize import resize_data_volume
|
from gpu_rent.resize import resize_data_volume
|
||||||
|
|
||||||
cfg = load_config(require_auth=True)
|
cfg = load_config(require_auth=True)
|
||||||
resize_data_volume(cfg, gb, log=lambda m: console.print(m))
|
resize_data_volume(cfg, gb, log=log)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
_die(exc)
|
_die(exc)
|
||||||
|
|
||||||
@@ -905,9 +922,9 @@ def watchdog_install(
|
|||||||
try:
|
try:
|
||||||
from gpu_rent.local_watchdog import install_watchdog
|
from gpu_rent.local_watchdog import install_watchdog
|
||||||
|
|
||||||
install_watchdog(interval_minutes=interval, log=lambda m: console.print(m))
|
install_watchdog(interval_minutes=interval, log=log)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
console.print(f"[red]install fail:[/red] {exc}")
|
err(f"install fail: {exc}")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -917,9 +934,9 @@ def watchdog_uninstall() -> None:
|
|||||||
try:
|
try:
|
||||||
from gpu_rent.local_watchdog import uninstall_watchdog
|
from gpu_rent.local_watchdog import uninstall_watchdog
|
||||||
|
|
||||||
uninstall_watchdog(log=lambda m: console.print(m))
|
uninstall_watchdog(log=log)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
console.print(f"[red]uninstall fail:[/red] {exc}")
|
err(f"uninstall fail: {exc}")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -946,7 +963,7 @@ def watchdog_tick(
|
|||||||
try:
|
try:
|
||||||
from gpu_rent.local_watchdog import run_tick
|
from gpu_rent.local_watchdog import run_tick
|
||||||
|
|
||||||
decision = run_tick(dry_run=dry_run, log=lambda m: console.print(m))
|
decision = run_tick(dry_run=dry_run, log=log)
|
||||||
if decision.kind == "stop" and not dry_run:
|
if decision.kind == "stop" and not dry_run:
|
||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
except GpuRentError as exc:
|
except GpuRentError as exc:
|
||||||
|
|||||||
+52
-11
@@ -26,12 +26,18 @@ OLLAMA_PRESETS: dict[str, list[str]] = {
|
|||||||
"empty": [],
|
"empty": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
PRESET_HELP = (
|
OLLAMA_PRESET_LABELS: dict[str, str] = {
|
||||||
"recommended — Qwen2.5 7B abliterate (RU/EN, мало отказов, ~5GB)\n"
|
"recommended": "Qwen2.5 7B abliterate (RU/EN, мало отказов, ~5GB)",
|
||||||
"light — qwen2.5:3b (быстрее, слабее)\n"
|
"light": "qwen2.5:3b (быстрее, слабее)",
|
||||||
"stock — официальный qwen2.5:7b (больше цензуры)\n"
|
"stock": "официальный qwen2.5:7b (больше цензуры)",
|
||||||
"alt — другой abliterate-пак 7B\n"
|
"alt": "другой abliterate-пак 7B",
|
||||||
"empty — только runtime, без pull"
|
"empty": "только runtime, без pull",
|
||||||
|
"keep": "не менять ollama-models.yaml",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deprecated text blob — prefer menu helpers below.
|
||||||
|
PRESET_HELP = "\n".join(
|
||||||
|
f"{k} — {v}" for k, v in OLLAMA_PRESET_LABELS.items() if k != "keep"
|
||||||
)
|
)
|
||||||
|
|
||||||
LLAMACPP_PRESETS: dict[str, list[str]] = {
|
LLAMACPP_PRESETS: dict[str, list[str]] = {
|
||||||
@@ -47,13 +53,48 @@ LLAMACPP_PRESETS: dict[str, list[str]] = {
|
|||||||
"empty": [],
|
"empty": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
LLAMACPP_PRESET_HELP = (
|
LLAMACPP_PRESET_LABELS: dict[str, str] = {
|
||||||
"recommended — Qwen2.5 7B abliterate GGUF Q4_K_M (~4.7GB, мало отказов)\n"
|
"recommended": "Qwen2.5 7B abliterate GGUF Q4_K_M (~4.7GB, мало отказов)",
|
||||||
"light — Qwen2.5 3B Instruct Q4_K_M (~2GB)\n"
|
"light": "Qwen2.5 3B Instruct Q4_K_M (~2GB)",
|
||||||
"stock — официальный Qwen2.5 7B Instruct Q4_K_M\n"
|
"stock": "официальный Qwen2.5 7B Instruct Q4_K_M",
|
||||||
"empty — только llama-server, GGUF положи вручную / правь llamacpp-models.yaml"
|
"empty": "только llama-server, GGUF вручную",
|
||||||
|
"keep": "не менять llamacpp-models.yaml",
|
||||||
|
}
|
||||||
|
|
||||||
|
LLAMACPP_PRESET_HELP = "\n".join(
|
||||||
|
f"{k} — {v}" for k, v in LLAMACPP_PRESET_LABELS.items() if k != "keep"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LLM_RUNTIME_LABELS: dict[str, str] = {
|
||||||
|
"none": "только SwarmUI",
|
||||||
|
"ollama": "Ollama (+ pull моделей)",
|
||||||
|
"llamacpp": "llama.cpp server (+ GGUF)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def llm_runtime_menu() -> list:
|
||||||
|
from gpu_rent.prompts import MenuItem
|
||||||
|
|
||||||
|
return [MenuItem(k, f"{k} — {LLM_RUNTIME_LABELS[k]}") for k in ("none", "ollama", "llamacpp")]
|
||||||
|
|
||||||
|
|
||||||
|
def ollama_preset_menu(*, include_keep: bool = False) -> list:
|
||||||
|
from gpu_rent.prompts import MenuItem
|
||||||
|
|
||||||
|
keys = list(OLLAMA_PRESETS.keys())
|
||||||
|
if include_keep:
|
||||||
|
keys.append("keep")
|
||||||
|
return [MenuItem(k, OLLAMA_PRESET_LABELS.get(k, k)) for k in keys]
|
||||||
|
|
||||||
|
|
||||||
|
def llamacpp_preset_menu(*, include_keep: bool = False) -> list:
|
||||||
|
from gpu_rent.prompts import MenuItem
|
||||||
|
|
||||||
|
keys = list(LLAMACPP_PRESETS.keys())
|
||||||
|
if include_keep:
|
||||||
|
keys.append("keep")
|
||||||
|
return [MenuItem(k, LLAMACPP_PRESET_LABELS.get(k, k)) for k in keys]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OllamaModelEntry:
|
class OllamaModelEntry:
|
||||||
|
|||||||
+12
-8
@@ -186,31 +186,35 @@ def scan_pools(cfg: Config, pools: tuple[str, ...] | None = None) -> list[PoolGp
|
|||||||
|
|
||||||
def format_pool_scan(offers: list[PoolGpuOffer], preference: tuple[str, ...]) -> list[str]:
|
def format_pool_scan(offers: list[PoolGpuOffer], preference: tuple[str, ...]) -> list[str]:
|
||||||
lines = [
|
lines = [
|
||||||
"скан пулов (мультизональные + кандидаты) × характеристики GPU:",
|
"[bold cyan]скан пулов[/bold cyan] (мультизональные + кандидаты) × характеристики GPU:",
|
||||||
" (серые кнопки панели ≠ Nova; available_count бывает только у части flavors)",
|
" [dim](серые кнопки панели ≠ Nova; available_count бывает только у части flavors)[/dim]",
|
||||||
]
|
]
|
||||||
for offer in offers:
|
for offer in offers:
|
||||||
tag = "multizone" if offer.multizone else "pool"
|
tag = "multizone" if offer.multizone else "pool"
|
||||||
prefix = f" {offer.region} ({tag}):"
|
prefix = f" [bold]{offer.region}[/bold] ({tag}):"
|
||||||
if offer.error:
|
if offer.error:
|
||||||
lines.append(f"{prefix} ОШИБКА — {offer.error}")
|
lines.append(f"{prefix} [red]ОШИБКА[/red] — {offer.error}")
|
||||||
continue
|
continue
|
||||||
types_s = ", ".join(offer.gpu_types) if offer.gpu_types else "—"
|
types_s = ", ".join(offer.gpu_types) if offer.gpu_types else "—"
|
||||||
lines.append(f"{prefix} типы: {types_s}")
|
lines.append(f"{prefix} типы: {types_s}")
|
||||||
if offer.gpu_labels_found:
|
if offer.gpu_labels_found:
|
||||||
best = offer.matched[0] if offer.matched else None
|
best = offer.matched[0] if offer.matched else None
|
||||||
best_s = f" → лучший {best.name} ({best.label})" if best else ""
|
best_s = (
|
||||||
|
f" → [green]лучший {best.name} ({best.label})[/green]" if best else ""
|
||||||
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
f" preference: {', '.join(offer.gpu_labels_found)}{best_s}"
|
f" preference: {', '.join(offer.gpu_labels_found)}{best_s}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lines.append(f" preference: нет совпадений с {','.join(preference)}")
|
lines.append(
|
||||||
|
f" [yellow]preference: нет совпадений с {','.join(preference)}[/yellow]"
|
||||||
|
)
|
||||||
for offer in offers:
|
for offer in offers:
|
||||||
if offer.matched and not offer.error:
|
if offer.matched and not offer.error:
|
||||||
lines.append(
|
lines.append(
|
||||||
f"рекомендация: OS_REGION_NAME={offer.region} "
|
f"[green]рекомендация:[/green] OS_REGION_NAME={offer.region} "
|
||||||
f"GPU_RENT_AZ={offer.region}a "
|
f"GPU_RENT_AZ={offer.region}a "
|
||||||
f"(сегмент a/b/c — в панели; диски и VM в одном AZ)"
|
f"[dim](сегмент a/b/c — в панели; диски и VM в одном AZ)[/dim]"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
return lines
|
return lines
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Numbered interactive menus (Enter = default index)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from rich.markup import escape
|
||||||
|
|
||||||
|
Ask = Callable[[str, str], str]
|
||||||
|
Show = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MenuItem:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_menu_choice(
|
||||||
|
raw: str,
|
||||||
|
items: Sequence[MenuItem],
|
||||||
|
*,
|
||||||
|
default_key: str,
|
||||||
|
) -> str:
|
||||||
|
"""Accept 1-based index or exact key (case-insensitive). Empty → default."""
|
||||||
|
text = (raw or "").strip().lower()
|
||||||
|
if not text:
|
||||||
|
return default_key
|
||||||
|
if text.isdigit():
|
||||||
|
idx = int(text)
|
||||||
|
if 1 <= idx <= len(items):
|
||||||
|
return items[idx - 1].key
|
||||||
|
raise ValueError(f"выбор: жду 1…{len(items)}, получили {idx}")
|
||||||
|
for item in items:
|
||||||
|
if item.key.lower() == text:
|
||||||
|
return item.key
|
||||||
|
keys = ", ".join(f"{i}:{it.key}" for i, it in enumerate(items, 1))
|
||||||
|
raise ValueError(f"выбор: жду номер или ключ ({keys}), получили {raw!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_menu(
|
||||||
|
title: str,
|
||||||
|
items: Sequence[MenuItem],
|
||||||
|
*,
|
||||||
|
default: str,
|
||||||
|
ask: Ask,
|
||||||
|
show: Show | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Print numbered list, ask for index (default = current recommendation)."""
|
||||||
|
if not items:
|
||||||
|
raise ValueError(f"{title}: пустое меню")
|
||||||
|
default_idx = 1
|
||||||
|
for i, item in enumerate(items, 1):
|
||||||
|
if item.key == default:
|
||||||
|
default_idx = i
|
||||||
|
break
|
||||||
|
safe_title = escape(title)
|
||||||
|
lines = [f"[bold cyan]{safe_title}[/bold cyan]"]
|
||||||
|
for i, item in enumerate(items, 1):
|
||||||
|
label = escape(item.label)
|
||||||
|
if i == default_idx:
|
||||||
|
lines.append(f" [cyan]{i}.[/cyan] {label} [green]←[/green]")
|
||||||
|
else:
|
||||||
|
lines.append(f" [dim]{i}.[/dim] {label}")
|
||||||
|
emit = show
|
||||||
|
if emit is None:
|
||||||
|
from gpu_rent.term import log as emit # type: ignore[assignment]
|
||||||
|
for line in lines:
|
||||||
|
emit(line)
|
||||||
|
raw = ask(f"{title} [1-{len(items)}]", str(default_idx))
|
||||||
|
return resolve_menu_choice(raw, items, default_key=items[default_idx - 1].key)
|
||||||
@@ -7,12 +7,13 @@ from collections.abc import Callable
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from gpu_rent.llm_runtime import (
|
from gpu_rent.llm_runtime import (
|
||||||
LLAMACPP_PRESET_HELP,
|
|
||||||
PRESET_HELP,
|
|
||||||
append_vars_llm_runtime,
|
append_vars_llm_runtime,
|
||||||
ensure_llamacpp_manifest_from_example,
|
ensure_llamacpp_manifest_from_example,
|
||||||
ensure_ollama_manifest_from_example,
|
ensure_ollama_manifest_from_example,
|
||||||
|
llamacpp_preset_menu,
|
||||||
|
llm_runtime_menu,
|
||||||
normalize_runtime,
|
normalize_runtime,
|
||||||
|
ollama_preset_menu,
|
||||||
write_llamacpp_models_preset,
|
write_llamacpp_models_preset,
|
||||||
write_ollama_models_preset,
|
write_ollama_models_preset,
|
||||||
)
|
)
|
||||||
@@ -28,6 +29,7 @@ from gpu_rent.paths import (
|
|||||||
vars_example_path,
|
vars_example_path,
|
||||||
vars_path,
|
vars_path,
|
||||||
)
|
)
|
||||||
|
from gpu_rent.prompts import prompt_menu
|
||||||
|
|
||||||
Log = Callable[[str], None]
|
Log = Callable[[str], None]
|
||||||
|
|
||||||
@@ -74,9 +76,12 @@ def run_setup(
|
|||||||
runtime = llm
|
runtime = llm
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
if ask:
|
if ask:
|
||||||
runtime = ask(
|
runtime = prompt_menu(
|
||||||
"LLM runtime [none/ollama/llamacpp]",
|
"LLM runtime",
|
||||||
"none",
|
llm_runtime_menu(),
|
||||||
|
default="none",
|
||||||
|
ask=ask,
|
||||||
|
show=log,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
runtime = "none"
|
runtime = "none"
|
||||||
@@ -87,8 +92,13 @@ def run_setup(
|
|||||||
if runtime == "ollama":
|
if runtime == "ollama":
|
||||||
preset = ollama_preset
|
preset = ollama_preset
|
||||||
if preset is None and ask:
|
if preset is None and ask:
|
||||||
log(PRESET_HELP)
|
preset = prompt_menu(
|
||||||
preset = ask("Ollama preset [recommended/light/stock/alt/empty]", "recommended")
|
"Ollama preset",
|
||||||
|
ollama_preset_menu(include_keep=False),
|
||||||
|
default="recommended",
|
||||||
|
ask=ask,
|
||||||
|
show=log,
|
||||||
|
)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
preset = "recommended"
|
preset = "recommended"
|
||||||
if preset.strip().lower() in {"keep", "example", ""}:
|
if preset.strip().lower() in {"keep", "example", ""}:
|
||||||
@@ -98,12 +108,14 @@ def run_setup(
|
|||||||
write_ollama_models_preset(ollama_models_manifest_path(), preset)
|
write_ollama_models_preset(ollama_models_manifest_path(), preset)
|
||||||
log(f"ollama-models.yaml пресет={preset}")
|
log(f"ollama-models.yaml пресет={preset}")
|
||||||
elif runtime == "llamacpp":
|
elif runtime == "llamacpp":
|
||||||
preset = ollama_preset # reuse --ollama-preset flag as generic LLM preset in setup
|
preset = ollama_preset
|
||||||
if preset is None and ask:
|
if preset is None and ask:
|
||||||
log(LLAMACPP_PRESET_HELP)
|
preset = prompt_menu(
|
||||||
preset = ask(
|
"llama.cpp GGUF",
|
||||||
"llama.cpp GGUF preset [recommended/light/stock/empty]",
|
llamacpp_preset_menu(include_keep=False),
|
||||||
"recommended",
|
default="recommended",
|
||||||
|
ask=ask,
|
||||||
|
show=log,
|
||||||
)
|
)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
preset = "recommended"
|
preset = "recommended"
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Colored terminal output (Rich). Plain log lines get severity colors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.markup import escape
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
for _stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
console = Console(highlight=False, legacy_windows=False)
|
||||||
|
|
||||||
|
# Our own markup — leave as-is.
|
||||||
|
_MARKUP = re.compile(
|
||||||
|
r"\[(/?)(bold|dim|red|green|yellow|cyan|magenta|blue|white|"
|
||||||
|
r"bold [a-z]+|dim [a-z]+)\]"
|
||||||
|
)
|
||||||
|
|
||||||
|
_ERR = re.compile(
|
||||||
|
r"(?i)\b("
|
||||||
|
r"fail|failed|error|exception|traceback|отмен|ошиб|не\s+удал|"
|
||||||
|
r"блокирующ|refuse|denied|abort"
|
||||||
|
r")\b"
|
||||||
|
)
|
||||||
|
_WARN = re.compile(
|
||||||
|
r"(?i)("
|
||||||
|
r"^!\s|"
|
||||||
|
r"\bwarn|\bwarning|предупрежд|внимание|"
|
||||||
|
r"тарифицир|idle-killer|preemptible:|"
|
||||||
|
r"₽:|диск data|серые кнопки|не\s+гасит|"
|
||||||
|
r"сейчас OS_REGION|лучше закрывается"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_OK = re.compile(
|
||||||
|
r"(?i)("
|
||||||
|
r"^ok\b|готово|создал|создан|doctor ok|SSH ок|"
|
||||||
|
r"туннель 127|← выберем|рекомендация:|"
|
||||||
|
r"выбрано:|armed|bootstrap уже|слушает 127|"
|
||||||
|
r"pull Output:|push .+: \d|wrote "
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_markup(text: str) -> bool:
|
||||||
|
return bool(_MARKUP.search(text))
|
||||||
|
|
||||||
|
|
||||||
|
def paint(msg: str) -> str:
|
||||||
|
"""Wrap a plain log line in Rich markup by severity heuristics."""
|
||||||
|
if msg == "" or msg.isspace():
|
||||||
|
return msg
|
||||||
|
if has_markup(msg):
|
||||||
|
return msg
|
||||||
|
|
||||||
|
stripped = msg.lstrip()
|
||||||
|
body = escape(msg)
|
||||||
|
|
||||||
|
if stripped.startswith("!"):
|
||||||
|
return f"[yellow]{body}[/yellow]"
|
||||||
|
if _ERR.search(stripped):
|
||||||
|
return f"[red]{body}[/red]"
|
||||||
|
if _WARN.search(stripped):
|
||||||
|
return f"[yellow]{body}[/yellow]"
|
||||||
|
if _OK.search(stripped):
|
||||||
|
return f"[green]{body}[/green]"
|
||||||
|
|
||||||
|
# Numbered menus / lists
|
||||||
|
if re.match(r"^\s*\d+\.\s", msg):
|
||||||
|
return f"[white]{body}[/white]"
|
||||||
|
if stripped.endswith(":") and len(stripped) < 80 and not stripped.startswith("http"):
|
||||||
|
return f"[bold cyan]{body}[/bold cyan]"
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str = "") -> None:
|
||||||
|
"""CLI log callback — colored print."""
|
||||||
|
console.print(paint(msg))
|
||||||
|
|
||||||
|
|
||||||
|
def ok(msg: str) -> None:
|
||||||
|
console.print(f"[green]{escape(msg)}[/green]")
|
||||||
|
|
||||||
|
|
||||||
|
def warn(msg: str) -> None:
|
||||||
|
console.print(f"[yellow]{escape(msg)}[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
|
def err(msg: str) -> None:
|
||||||
|
console.print(f"[red]{escape(msg)}[/red]")
|
||||||
|
|
||||||
|
|
||||||
|
def info(msg: str) -> None:
|
||||||
|
console.print(f"[cyan]{escape(msg)}[/cyan]")
|
||||||
|
|
||||||
|
|
||||||
|
def dim(msg: str) -> None:
|
||||||
|
console.print(f"[dim]{escape(msg)}[/dim]")
|
||||||
+23
-19
@@ -6,6 +6,8 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from rich.markup import escape
|
||||||
|
|
||||||
from gpu_rent.config import Config
|
from gpu_rent.config import Config
|
||||||
from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor
|
from gpu_rent.inventory import FlavorInfo, looks_like_gpu, rank_flavors, resolve_flavor
|
||||||
|
|
||||||
@@ -21,14 +23,19 @@ def list_ranked_flavors(flavors: list[Any], cfg: Config) -> list[FlavorInfo]:
|
|||||||
|
|
||||||
def format_flavor_lines(ranked: list[FlavorInfo], picked: FlavorInfo | None = None) -> list[str]:
|
def format_flavor_lines(ranked: list[FlavorInfo], picked: FlavorInfo | None = None) -> list[str]:
|
||||||
if not ranked:
|
if not ranked:
|
||||||
return ["flavors: пусто (проверь регион / FLAVOR_PREFERENCE)"]
|
return ["[yellow]flavors: пусто (проверь регион / FLAVOR_PREFERENCE)[/yellow]"]
|
||||||
lines = ["flavors (по FLAVOR_PREFERENCE):"]
|
lines = ["[bold cyan]flavors (по FLAVOR_PREFERENCE):[/bold cyan]"]
|
||||||
for i, info in enumerate(ranked, 1):
|
for i, info in enumerate(ranked, 1):
|
||||||
mark = " ← выберем" if picked and info.id == picked.id else ""
|
mark = " [green]← выберем[/green]" if picked and info.id == picked.id else ""
|
||||||
ram = f"{info.ram_mb // 1024}GB" if info.ram_mb else "?"
|
ram = f"{info.ram_mb // 1024}GB" if info.ram_mb else "?"
|
||||||
vcpu = str(info.vcpus) if info.vcpus is not None else "?"
|
vcpu = str(info.vcpus) if info.vcpus is not None else "?"
|
||||||
label = info.label or "—"
|
label = escape(info.label or "—")
|
||||||
lines.append(f" {i}. [{label}] {info.name} vCPU={vcpu} RAM={ram} id={info.id}{mark}")
|
name = escape(info.name)
|
||||||
|
fid = escape(info.id)
|
||||||
|
lines.append(
|
||||||
|
f" [cyan]{i}.[/cyan] [bold]{label}[/bold] {name} "
|
||||||
|
f"vCPU={vcpu} RAM={ram} id={fid}{mark}"
|
||||||
|
)
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
@@ -114,21 +121,18 @@ def prompt_server_plan(
|
|||||||
if data_gb < 20:
|
if data_gb < 20:
|
||||||
raise ValueError("Data disk GB: минимум 20")
|
raise ValueError("Data disk GB: минимум 20")
|
||||||
|
|
||||||
spot_default = "Y" if spot else "n"
|
from gpu_rent.prompts import MenuItem, prompt_menu
|
||||||
raw_spot = (
|
|
||||||
ask(
|
spot_key = prompt_menu(
|
||||||
"Preemptible GPU (дешевле, могут усыпить ~24ч)? [Y/n]",
|
"Тариф GPU",
|
||||||
spot_default,
|
[
|
||||||
|
MenuItem("yes", "preemptible (дешевле, могут усыпить ~24ч)"),
|
||||||
|
MenuItem("no", "обычный on-demand"),
|
||||||
|
],
|
||||||
|
default="yes" if spot else "no",
|
||||||
|
ask=ask,
|
||||||
)
|
)
|
||||||
.strip()
|
use_spot = spot_key == "yes"
|
||||||
.lower()
|
|
||||||
)
|
|
||||||
if raw_spot in {"", "y", "yes", "1", "true", "on"}:
|
|
||||||
use_spot = True
|
|
||||||
elif raw_spot in {"n", "no", "0", "false", "off"}:
|
|
||||||
use_spot = False
|
|
||||||
else:
|
|
||||||
raise ValueError(f"preemptible: жду Y/n, получили {raw_spot!r}")
|
|
||||||
|
|
||||||
changed = (
|
changed = (
|
||||||
chosen.id != picked.id
|
chosen.id != picked.id
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from gpu_rent.prompts import MenuItem, prompt_menu, resolve_menu_choice
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_by_number():
|
||||||
|
items = [MenuItem("a", "A"), MenuItem("b", "B"), MenuItem("keep", "K")]
|
||||||
|
assert resolve_menu_choice("2", items, default_key="keep") == "b"
|
||||||
|
assert resolve_menu_choice("", items, default_key="keep") == "keep"
|
||||||
|
assert resolve_menu_choice("keep", items, default_key="a") == "keep"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_menu_prints_and_picks():
|
||||||
|
items = [
|
||||||
|
MenuItem("recommended", "rec"),
|
||||||
|
MenuItem("light", "lit"),
|
||||||
|
MenuItem("keep", "keep yaml"),
|
||||||
|
]
|
||||||
|
shown: list[str] = []
|
||||||
|
|
||||||
|
def ask(msg: str, default: str = "") -> str:
|
||||||
|
assert default == "3" # keep is default
|
||||||
|
return "1"
|
||||||
|
|
||||||
|
key = prompt_menu(
|
||||||
|
"GGUF",
|
||||||
|
items,
|
||||||
|
default="keep",
|
||||||
|
ask=ask,
|
||||||
|
show=shown.append,
|
||||||
|
)
|
||||||
|
assert key == "recommended"
|
||||||
|
assert any("1. rec" in line for line in shown)
|
||||||
|
assert any("←" in line for line in shown)
|
||||||
@@ -31,7 +31,7 @@ def test_prompt_server_plan_with_ranked(monkeypatch, tmp_path):
|
|||||||
"gpu_rent.ux.list_ranked_flavors",
|
"gpu_rent.ux.list_ranked_flavors",
|
||||||
lambda flavors, cfg: [f1, f2],
|
lambda flavors, cfg: [f1, f2],
|
||||||
)
|
)
|
||||||
answers = iter(["2", "200", "n"])
|
answers = iter(["2", "200", "2"])
|
||||||
|
|
||||||
def ask(msg: str, default: str = "") -> str:
|
def ask(msg: str, default: str = "") -> str:
|
||||||
return next(answers)
|
return next(answers)
|
||||||
|
|||||||
Reference in New Issue
Block a user