Bump version to 0.2.0 and enhance documentation

- Updated version number in pyproject.toml and __init__.py to 0.2.0.
- Revised README.md to reflect the current state of the project, including usage instructions and setup steps.
- Improved CLI documentation in cli.md, adding details about new commands and their functionalities.
- Enhanced the quick start section in README.md for better clarity on initial setup.
- Updated local folder documentation to clarify file handling and commands.
- Added a new command for listing GPU flavors and improved error handling in the CLI.
- Implemented a watchdog feature in the tunnel to manage server states effectively.
This commit is contained in:
Leonid Pershin
2026-08-21 03:38:01 +03:00
parent 343f741baa
commit a563ae06c4
30 changed files with 1750 additions and 132 deletions
+111
View File
@@ -0,0 +1,111 @@
"""Tests for hold parsing and idle-killer busy classification."""
from __future__ import annotations
import importlib.util
from datetime import datetime, timezone
from pathlib import Path
import pytest
from gpu_rent.errors import GpuRentError
from gpu_rent.hold import _parse_until
ROOT = Path(__file__).resolve().parents[1]
REMOTE_KILLER = ROOT / "src" / "gpu_rent" / "remote" / "idle_killer.py"
def _load_remote():
spec = importlib.util.spec_from_file_location("idle_killer_remote", REMOTE_KILLER)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_parse_until_iso():
ts = _parse_until("2030-01-01T00:00:00+00:00")
assert ts == int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp())
def test_parse_until_unix():
assert _parse_until("1700000000") == 1700000000
def test_parse_until_bad():
with pytest.raises(GpuRentError):
_parse_until("not-a-date")
def test_classify_busy_from_status(monkeypatch):
mod = _load_remote()
class FakeResp:
def __init__(self, payload):
self._payload = payload
def read(self):
import json
return json.dumps(self._payload).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
calls = {"n": 0}
def fake_urlopen(req, timeout=0, context=None):
calls["n"] += 1
url = getattr(req, "full_url", None) or req.get_full_url()
if "GetNewSession" in url:
return FakeResp({"session_id": "abc"})
return FakeResp(
{
"status": {"waiting_gens": 0, "live_gens": 0, "loading_models": 0},
"backend_status": {"status": "idle"},
}
)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
busy, detail = mod.swarm_busy("http://127.0.0.1:7801")
assert busy is False
assert "idle" in detail
def test_classify_busy_queue(monkeypatch):
mod = _load_remote()
class FakeResp:
def __init__(self, payload):
self._payload = payload
def read(self):
import json
return json.dumps(self._payload).encode()
def __enter__(self):
return self
def __exit__(self, *args):
return False
def fake_urlopen(req, timeout=0, context=None):
url = getattr(req, "full_url", None) or req.get_full_url()
if "GetNewSession" in url:
return FakeResp({"session_id": "abc"})
return FakeResp(
{
"status": {"waiting_gens": 2, "live_gens": 0, "loading_models": 0},
"backend_status": {"status": "idle"},
}
)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
busy, detail = mod.swarm_busy("http://127.0.0.1:7801")
assert busy is True
assert "waiting=2" in detail
+75
View File
@@ -0,0 +1,75 @@
from gpu_rent.notify import print_mcp_snippet
from gpu_rent.snapshot import ensure_boot_snapshot
class _Cfg:
boot_snapshot_name = "gpu-rent-boot-ok"
swarmui_local_port = 17801
notify_ready = True
def test_ensure_boot_snapshot_skips_existing():
class Snap:
id = "snap1"
name = "gpu-rent-boot-ok"
class Conn:
class block_storage:
@staticmethod
def create_snapshot(**kwargs):
raise AssertionError("should not create")
logs = []
import gpu_rent.snapshot as snap_mod
# monkey via attribute
orig = snap_mod.find_snapshot_by_name
snap_mod.find_snapshot_by_name = lambda conn, name: Snap()
try:
got = ensure_boot_snapshot(Conn(), boot_volume_id="v1", cfg=_Cfg(), log=logs.append)
assert got is not None
assert any("уже есть" in m for m in logs)
finally:
snap_mod.find_snapshot_by_name = orig
def test_ensure_boot_snapshot_creates(monkeypatch):
created = {}
class Snap:
def __init__(self):
self.id = "new"
self.status = "creating"
class Conn:
class block_storage:
@staticmethod
def create_snapshot(**kwargs):
created.update(kwargs)
return Snap()
@staticmethod
def get_snapshot(sid):
s = Snap()
s.status = "available"
return s
import gpu_rent.snapshot as snap_mod
monkeypatch.setattr(snap_mod, "find_snapshot_by_name", lambda conn, name: None)
monkeypatch.setattr(snap_mod.time, "sleep", lambda s: None)
logs = []
got = ensure_boot_snapshot(Conn(), boot_volume_id="vol-1", cfg=_Cfg(), log=logs.append)
assert created["volume_id"] == "vol-1"
assert created["force"] is True
assert created["name"] == "gpu-rent-boot-ok"
assert got.status == "available"
def test_mcp_snippet(capsys):
logs = []
print_mcp_snippet(_Cfg(), logs.append)
text = "\n".join(logs)
assert "17801/mcp" in text
assert "gpu-rent tunnel" in text
assert "mcp.json" in text
+32
View File
@@ -0,0 +1,32 @@
import pytest
from gpu_rent.errors import GpuRentError
from gpu_rent.resize import resize_data_volume
from gpu_rent.state import SessionState, save_state
class _Cfg:
pass
def test_resize_rejects_shrink(monkeypatch):
save_state(SessionState(data_volume_id="d1", floating_ip=None))
class Vol:
id = "d1"
size = 200
status = "in-use"
class Conn:
class block_storage:
@staticmethod
def get_volume(vid):
return Vol()
@staticmethod
def extend_volume(*a, **k):
raise AssertionError("no extend")
monkeypatch.setattr("gpu_rent.resize.connect", lambda cfg: Conn())
with pytest.raises(GpuRentError, match="вниз нельзя"):
resize_data_volume(_Cfg(), 100, log=lambda m: None)
+16 -2
View File
@@ -48,7 +48,14 @@ def test_cmd_up_does_not_create_second_gpu(monkeypatch):
)
monkeypatch.setattr("gpu_rent.session.wait_ssh", lambda cfg, host, timeout=420.0: None)
monkeypatch.setattr("gpu_rent.session.run_bootstrap", lambda cfg, host, log: None)
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log: None)
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log, **kw: None)
monkeypatch.setattr("gpu_rent.session.wait_backend_idle", lambda cfg, host, log, **kw: None)
monkeypatch.setattr(
"gpu_rent.session.ensure_boot_snapshot",
lambda conn, boot_volume_id, cfg, log: None,
)
monkeypatch.setattr("gpu_rent.session.notify_ready", lambda cfg, log: None)
monkeypatch.setattr("gpu_rent.session.print_mcp_snippet", lambda cfg, log: None)
monkeypatch.setattr(
"gpu_rent.session.create_gpu_server",
lambda *a, **k: created.append("created") or Server(),
@@ -79,7 +86,14 @@ def test_cmd_up_unshelves_expired(monkeypatch):
)
monkeypatch.setattr("gpu_rent.session.wait_ssh", lambda cfg, host, timeout=420.0: None)
monkeypatch.setattr("gpu_rent.session.run_bootstrap", lambda cfg, host, log: None)
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log: None)
monkeypatch.setattr("gpu_rent.session.provision_vm", lambda cfg, host, log, **kw: None)
monkeypatch.setattr("gpu_rent.session.wait_backend_idle", lambda cfg, host, log, **kw: None)
monkeypatch.setattr(
"gpu_rent.session.ensure_boot_snapshot",
lambda conn, boot_volume_id, cfg, log: None,
)
monkeypatch.setattr("gpu_rent.session.notify_ready", lambda cfg, log: None)
monkeypatch.setattr("gpu_rent.session.print_mcp_snippet", lambda cfg, log: None)
monkeypatch.setattr(
"gpu_rent.session.create_gpu_server",
lambda *a, **k: created.append("created"),
+27
View File
@@ -0,0 +1,27 @@
from gpu_rent.tunnel import decide_watch
def test_decide_ok_active():
d = decide_watch("ACTIVE", tunnel_alive=True)
assert d.kind == "ok"
def test_decide_reconnect_when_tunnel_dead():
d = decide_watch("ACTIVE", tunnel_alive=False)
assert d.kind == "reconnect"
def test_decide_unshelve_expired():
for st in ("EXPIRED", "SHELVED", "SHELVED_OFFLOADED"):
d = decide_watch(st, tunnel_alive=True)
assert d.kind == "unshelve", st
def test_decide_exit_error():
d = decide_watch("ERROR", tunnel_alive=True)
assert d.kind == "exit"
def test_decide_exit_missing():
d = decide_watch(None, tunnel_alive=False)
assert d.kind == "exit"
+36
View File
@@ -0,0 +1,36 @@
from gpu_rent.config import load_config
from gpu_rent.inventory import FlavorInfo
from gpu_rent.ux import cost_and_risk_lines, format_flavor_lines
def test_format_flavor_lines_marks_pick():
ranked = [
FlavorInfo("a", "RTX 4090 24GB", 8, 32768, False, {}, "4090-24"),
FlavorInfo("b", "A5000", 8, 32768, False, {}, "a5000"),
]
picked = ranked[0]
text = "\n".join(format_flavor_lines(ranked, picked))
assert "выберем" in text
assert "4090-24" in text
def test_cost_lines_mention_disk_and_killer(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.chdir(tmp_path)
for key in (
"OS_AUTH_URL",
"OS_USER_DOMAIN_NAME",
"OS_USERNAME",
"OS_PASSWORD",
"OS_PROJECT_ID",
"OS_REGION_NAME",
"GPU_RENT_AZ",
):
monkeypatch.delenv(key, raising=False)
cfg = load_config(require_auth=False)
lines = cost_and_risk_lines(cfg, spot=True, flavor_name="GPU 4090")
blob = "\n".join(lines)
assert "24/7" in blob
assert "idle-killer" in blob
assert "панель" in blob.lower() or "Selectel" in blob