gcube-cli 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.
gcube_cli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__: str = importlib.metadata.version("gcube-cli")
5
+ except importlib.metadata.PackageNotFoundError:
6
+ __version__ = "0.0.0-dev"
gcube_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from gcube_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
gcube_cli/_group.py ADDED
@@ -0,0 +1,8 @@
1
+ import click
2
+
3
+
4
+ class OrderedGroup(click.Group):
5
+ """click.Group that lists commands in insertion order instead of alphabetically."""
6
+
7
+ def list_commands(self, ctx: click.Context) -> list[str]: # noqa: ARG002
8
+ return list(self.commands)
@@ -0,0 +1,16 @@
1
+ """gcube_cli API package — public re-exports."""
2
+
3
+ from gcube_cli.api.client import APIError, AuthError, Client, make_client
4
+ from gcube_cli.api.credential import Credential, CredentialAPI
5
+ from gcube_cli.api.workload import Workload, WorkloadAPI
6
+
7
+ __all__ = [
8
+ "APIError",
9
+ "AuthError",
10
+ "Client",
11
+ "make_client",
12
+ "Credential",
13
+ "CredentialAPI",
14
+ "Workload",
15
+ "WorkloadAPI",
16
+ ]
@@ -0,0 +1,225 @@
1
+ """Base HTTP client for gcube CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import sys
7
+ from typing import Any, cast
8
+
9
+ import click
10
+ import httpx
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Exceptions
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ class AuthError(Exception):
18
+ """Raised on HTTP 401 — token expired or invalid."""
19
+
20
+ MESSAGE = (
21
+ "Token expired or invalid. "
22
+ "Please get a new token from https://gcube.ai"
23
+ )
24
+
25
+ def __init__(self) -> None:
26
+ super().__init__(self.MESSAGE)
27
+
28
+
29
+ class APIError(Exception):
30
+ """Raised when the gcube API returns status != 200 in the response body."""
31
+
32
+ def __init__(self, status_code: int, code: str, message: str) -> None:
33
+ self.status_code = status_code
34
+ self.code = code
35
+ self.message = message
36
+ super().__init__(f"API Error [{code}]: {message}")
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Command-layer decorator
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def handle_api_errors(func: Any) -> Any:
45
+ """Decorator that catches AuthError / APIError and exits with the correct code.
46
+
47
+ Apply to every Click command that calls the API.
48
+ """
49
+
50
+ @functools.wraps(func)
51
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
52
+ try:
53
+ return func(*args, **kwargs)
54
+ except AuthError as e:
55
+ click.echo(str(e), err=True)
56
+ sys.exit(3)
57
+ except APIError as e:
58
+ click.echo(f"API Error [{e.code}]: {e.message}", err=True)
59
+ sys.exit(2)
60
+
61
+ return wrapper
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Client
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def _make_debug_hooks(base_url: str) -> dict[str, list[Any]]:
70
+ """Return httpx event hooks that print request/response to stderr."""
71
+ import json as _json
72
+
73
+ def _pretty(raw: str) -> str:
74
+ try:
75
+ return _json.dumps(_json.loads(raw), ensure_ascii=False, indent=2)
76
+ except (ValueError, TypeError):
77
+ return raw
78
+
79
+ def _log_request(request: httpx.Request) -> None:
80
+ click.echo(f"\n[DEBUG] >>> {request.method} {request.url}", err=True)
81
+ if request.content:
82
+ click.echo("[DEBUG] >>> Body:", err=True)
83
+ click.echo(_pretty(request.content.decode("utf-8", errors="replace")), err=True)
84
+
85
+ def _log_response(response: httpx.Response) -> None:
86
+ response.read()
87
+ click.echo(f"[DEBUG] <<< {response.status_code}", err=True)
88
+ click.echo("[DEBUG] <<< Body:", err=True)
89
+ click.echo(_pretty(response.text), err=True)
90
+
91
+ return {"request": [_log_request], "response": [_log_response]}
92
+
93
+
94
+ class Client:
95
+ """Synchronous HTTP client with Bearer auth and uniform error handling."""
96
+
97
+ def __init__(self, base_url: str, token: str, debug: bool = False) -> None:
98
+ self.base_url = base_url
99
+ self.debug = debug
100
+ init_kwargs: dict[str, Any] = {
101
+ "base_url": base_url,
102
+ "headers": {
103
+ "Authorization": f"Bearer {token}",
104
+ "Content-Type": "application/json",
105
+ },
106
+ "timeout": 30.0,
107
+ }
108
+ if debug:
109
+ init_kwargs["event_hooks"] = _make_debug_hooks(base_url)
110
+ self._client = httpx.Client(**init_kwargs)
111
+
112
+ # ------------------------------------------------------------------
113
+ # Internal request dispatcher
114
+ # ------------------------------------------------------------------
115
+
116
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
117
+ """Execute an HTTP request and return the parsed JSON body.
118
+
119
+ Error handling order:
120
+ 1. httpx.RequestError (network/timeout) → sys.exit(4)
121
+ 2. HTTP 401 → raise AuthError
122
+ 3. raise_for_status() for other 4xx/5xx → wrap as APIError
123
+ 4. body["status"] != 200 → raise APIError
124
+ """
125
+ try:
126
+ response = self._client.request(method, path, **kwargs)
127
+ except httpx.RequestError:
128
+ click.echo(
129
+ f"Cannot connect to {self.base_url}. Check network.",
130
+ err=True,
131
+ )
132
+ sys.exit(4)
133
+
134
+ # 401 — try to parse body first; only raise AuthError for bare token failures
135
+ if response.status_code == 401:
136
+ try:
137
+ err_data = cast(dict[str, Any], response.json())
138
+ if "error" in err_data and "message" in err_data:
139
+ raise APIError(
140
+ err_data.get("status", 401),
141
+ err_data.get("error", "UNAUTHORIZED"),
142
+ err_data.get("message", "Unauthorized"),
143
+ )
144
+ except (ValueError, KeyError):
145
+ pass
146
+ raise AuthError()
147
+
148
+ # Other unexpected HTTP-level errors (e.g. 500 with no JSON body)
149
+ try:
150
+ response.raise_for_status()
151
+ except httpx.HTTPStatusError as exc:
152
+ # Try to extract gcube envelope; fall back to generic message
153
+ try:
154
+ err_data = cast(dict[str, Any], exc.response.json())
155
+ raise APIError(
156
+ err_data.get("status", exc.response.status_code),
157
+ err_data.get("error", str(exc.response.status_code)),
158
+ err_data.get("message", exc.response.text),
159
+ ) from exc
160
+ except (ValueError, KeyError):
161
+ raise APIError(
162
+ exc.response.status_code,
163
+ str(exc.response.status_code),
164
+ exc.response.text,
165
+ ) from exc
166
+
167
+ # Parse gcube response envelope
168
+ data: dict[str, Any] = cast(dict[str, Any], response.json())
169
+ if data.get("status") != 200:
170
+ raise APIError(
171
+ data.get("status", 0),
172
+ data.get("error", ""),
173
+ data.get("message", "Unknown error"),
174
+ )
175
+
176
+ return data
177
+
178
+ # ------------------------------------------------------------------
179
+ # Public HTTP methods
180
+ # ------------------------------------------------------------------
181
+
182
+ def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
183
+ return self._request("GET", path, **kwargs)
184
+
185
+ def post(
186
+ self, path: str, json: dict[str, Any] | None = None, **kwargs: Any
187
+ ) -> dict[str, Any]:
188
+ return self._request("POST", path, json=json, **kwargs)
189
+
190
+ def put(
191
+ self, path: str, json: dict[str, Any] | None = None, **kwargs: Any
192
+ ) -> dict[str, Any]:
193
+ return self._request("PUT", path, json=json, **kwargs)
194
+
195
+ def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
196
+ return self._request("DELETE", path, **kwargs)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Factory
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def make_client(
205
+ config: Any, # gcube_cli.config.config.GcubeConfig — Any to avoid circular import
206
+ debug: bool = False,
207
+ platform_url: str | None = None,
208
+ ) -> Client:
209
+ """Create a Client from a loaded GcubeConfig object.
210
+
211
+ Exits with code 1 if no token is configured.
212
+ """
213
+ token: str | None = None
214
+ if config.auth:
215
+ token = config.auth.access_token or None
216
+
217
+ if not token:
218
+ click.echo(
219
+ "No token configured. Run: gcube configure set --token <token>",
220
+ err=True,
221
+ )
222
+ sys.exit(1)
223
+
224
+ base_url = platform_url or config.platform_url or "https://api.gcube.ai"
225
+ return Client(base_url=base_url, token=token, debug=debug)
@@ -0,0 +1,68 @@
1
+ """Credential management API client for gcube CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from gcube_cli.api.client import Client
9
+
10
+
11
+ @dataclass
12
+ class Credential:
13
+ """Container image registry credential."""
14
+
15
+ owner: str
16
+ category: str # 항상 "repo"
17
+ cloud: str # docker | github | harbor | aws | huggingface | quay
18
+ acc_key: str # JSON: accKey (username)
19
+ sec_key: str # JSON: secKey (token/password)
20
+ region: str | None = None # AWS ECR region
21
+ registry_id: str | None = None # JSON: registryId (AWS ECR only)
22
+
23
+ @classmethod
24
+ def from_dict(cls, data: dict[str, Any]) -> Credential:
25
+ return cls(
26
+ owner=data.get("owner", ""),
27
+ category=data.get("category", ""),
28
+ cloud=data.get("cloud", ""),
29
+ acc_key=data.get("accKey", data.get("acc_key", "")),
30
+ sec_key=data.get("secKey", data.get("sec_key", "")),
31
+ region=data.get("region") or None,
32
+ registry_id=data.get("registryId", data.get("registry_id")) or None,
33
+ )
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ """Serialize to camelCase JSON dict for the API request."""
37
+ return {
38
+ "owner": self.owner,
39
+ "category": self.category,
40
+ "cloud": self.cloud,
41
+ "accKey": self.acc_key,
42
+ "secKey": self.sec_key,
43
+ "region": self.region or "",
44
+ "registryId": self.registry_id or "",
45
+ }
46
+
47
+
48
+ class CredentialAPI:
49
+ """High-level Credential API client."""
50
+
51
+ def __init__(self, client: Client) -> None:
52
+ self._client = client
53
+
54
+ def list(self, owner: str) -> list[Credential]:
55
+ """GET /api/credential/list?owner={email}"""
56
+ data = self._client.get("/api/credential/list", params={"owner": owner})
57
+ items: list[dict[str, Any]] = data.get("credentials", data.get("data", data.get("items", [])))
58
+ return [Credential.from_dict(item) for item in items]
59
+
60
+ def create(self, cred: Credential) -> Credential:
61
+ """POST /api/credential/register"""
62
+ data = self._client.post("/api/credential/register", json=cred.to_dict())
63
+ item: dict[str, Any] = data.get("data", data)
64
+ return Credential.from_dict(item) if isinstance(item, dict) and item else cred
65
+
66
+ def delete(self, cloud: str) -> None:
67
+ """DELETE /api/credential/delete/repo/{cloud}"""
68
+ self._client.delete(f"/api/credential/delete/repo/{cloud}")
gcube_cli/api/logs.py ADDED
@@ -0,0 +1,52 @@
1
+ """WebSocket log streaming for gcube workloads (PBI-016)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import websockets
7
+ import websockets.exceptions
8
+
9
+
10
+ class LogsConnectionError(Exception):
11
+ """Raised when the WebSocket connection to the log stream fails."""
12
+
13
+
14
+ class LogsAuthError(Exception):
15
+ """Raised when the server rejects the connection due to auth failure."""
16
+
17
+
18
+ class LogStreamer:
19
+ def __init__(self, ws_url: str, token: str) -> None:
20
+ self.ws_url = ws_url.rstrip("/")
21
+ self.token = token
22
+
23
+ async def stream(self, ser: str, pod_name: str, ct_index: int = 0) -> None:
24
+ """Open a WebSocket and stream log messages to stdout.
25
+
26
+ The server always requires /container/{ct_index} in the URL path.
27
+ ct_index defaults to 0 for single-container pods.
28
+ """
29
+ url = (
30
+ f"{self.ws_url}/workloads/{ser}/log/{pod_name}"
31
+ f"/container/{ct_index}?token={self.token}"
32
+ )
33
+
34
+ try:
35
+ async with websockets.connect(url, close_timeout=0) as ws:
36
+ async for message in ws:
37
+ click.echo(message, nl=False)
38
+ except websockets.exceptions.ConnectionClosed:
39
+ pass # server closed the stream (clean close or proxy timeout)
40
+ except websockets.exceptions.InvalidStatus as exc:
41
+ status_code = getattr(getattr(exc, "response", None), "status_code", 0)
42
+ if status_code in (401, 403):
43
+ raise LogsAuthError(
44
+ "Token expired or invalid. Please get a new token from https://console.gcube.ai"
45
+ ) from exc
46
+ raise LogsConnectionError(
47
+ f"Cannot connect to log stream (HTTP {status_code}). Check ws-url configuration."
48
+ ) from exc
49
+ except (OSError, websockets.exceptions.WebSocketException) as exc:
50
+ raise LogsConnectionError(
51
+ "Cannot connect to log stream. Check ws-url configuration."
52
+ ) from exc
gcube_cli/api/point.py ADDED
@@ -0,0 +1,173 @@
1
+ """Point balance and usage API client for gcube CLI (PBI-013)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import calendar
6
+ import datetime
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from gcube_cli.api.client import Client
11
+
12
+
13
+ @dataclass
14
+ class PointStatus:
15
+ available_point: int
16
+ charge_point: int
17
+ spend_point: int
18
+ net_point: int
19
+ is_blocked: bool
20
+ is_alarm: bool
21
+
22
+ @classmethod
23
+ def from_dict(cls, data: dict[str, Any]) -> PointStatus:
24
+ points: dict[str, Any] = data.get("points", data)
25
+ return cls(
26
+ available_point=int(points.get("availablePoint", 0)),
27
+ charge_point=int(points.get("chargePoint", 0)),
28
+ spend_point=int(points.get("spendPoint", 0)),
29
+ net_point=int(points.get("netPoint", 0)),
30
+ is_blocked=bool(points.get("isBlocked", False)),
31
+ is_alarm=bool(points.get("isAlarm", False)),
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class PointSpendSummary:
37
+ total_spend: int
38
+ prev_month_spend: int
39
+ this_month_spend: int
40
+
41
+ @classmethod
42
+ def from_dict(cls, data: dict[str, Any]) -> PointSpendSummary:
43
+ points: dict[str, Any] = data.get("points", data)
44
+ total = points.get("totalSpend", {})
45
+ prev = points.get("prevMonthSpend", {})
46
+ this = points.get("thisMonthSpend", {})
47
+ return cls(
48
+ total_spend=int(total.get("point", 0) if isinstance(total, dict) else total),
49
+ prev_month_spend=int(prev.get("point", 0) if isinstance(prev, dict) else prev),
50
+ this_month_spend=int(this.get("point", 0) if isinstance(this, dict) else this),
51
+ )
52
+
53
+
54
+ @dataclass
55
+ class DailySpend:
56
+ date: str
57
+ point: float
58
+ krw_amount: int
59
+ krw_unit_amount: int
60
+ krw_usage_amount: int
61
+ gpu_usage: float
62
+ net_rx: float
63
+ net_tx: float
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: dict[str, Any]) -> DailySpend:
67
+ return cls(
68
+ date=str(data.get("date", "")),
69
+ point=float(data.get("point", 0)),
70
+ krw_amount=int(data.get("krwAmount", 0)),
71
+ krw_unit_amount=int(data.get("krwUnitAmount", 0)),
72
+ krw_usage_amount=int(data.get("krwUsageAmount", 0)),
73
+ gpu_usage=float(data.get("gpuUsage", 0)),
74
+ net_rx=float(data.get("netRx", 0)),
75
+ net_tx=float(data.get("netTx", 0)),
76
+ )
77
+
78
+
79
+ def _month_range(month: str) -> tuple[str, str]:
80
+ """'YYYY-MM' → ('YYYY-MM-01', 'YYYY-MM-DD') 말일 계산."""
81
+ year, mon = int(month[:4]), int(month[5:7])
82
+ last_day = calendar.monthrange(year, mon)[1]
83
+ return f"{year:04d}-{mon:02d}-01", f"{year:04d}-{mon:02d}-{last_day:02d}"
84
+
85
+
86
+ def _current_month_range() -> tuple[str, str]:
87
+ today = datetime.date.today()
88
+ return f"{today.year:04d}-{today.month:02d}-01", today.strftime("%Y-%m-%d")
89
+
90
+
91
+ # status API (/api/point/status) 에서 표시할 필드만 허용
92
+ _STATUS_ALLOWLIST = frozenset({
93
+ "availablePoint", "chargePoint", "spendPoint", "netPoint",
94
+ "isBlocked", "isAlarm",
95
+ })
96
+
97
+ # daily spends 항목에서 제외할 필드명
98
+ _EXCLUDED_SPENDING_EXACT = frozenset({"gpuPoint", "namespace", "krw", "useTime", "owner"})
99
+ # daily spends 항목에서 제외할 패턴 (substring match, 대소문자 구분)
100
+ # - "Gpu": krwGpuAmount 등 GCUBE 내부 단가
101
+ # - "vram","cpu","mem","disk": resource 커맨드 영역
102
+ _EXCLUDED_SPENDING_PATTERN = ("income", "settle", "refund", "Gpu", "vram", "cpu", "mem", "disk")
103
+
104
+
105
+ def _filter_status_json(raw: dict[str, Any]) -> dict[str, Any]:
106
+ """status JSON: 허용 목록 필드만 출력."""
107
+ points = raw.get("points", {})
108
+ return {"points": {k: v for k, v in points.items() if k in _STATUS_ALLOWLIST}}
109
+
110
+
111
+ def _filter_spend_summary_json(raw: dict[str, Any]) -> dict[str, Any]:
112
+ """spend/status JSON: 각 항목의 point 값만 추출하여 flat 구조로 반환."""
113
+ points = raw.get("points", {})
114
+
115
+ def _pt(key: str) -> int:
116
+ val = points.get(key, {})
117
+ return int(val.get("point", 0) if isinstance(val, dict) else val)
118
+
119
+ return {
120
+ "totalSpend": _pt("totalSpend"),
121
+ "prevMonthSpend": _pt("prevMonthSpend"),
122
+ "thisMonthSpend": _pt("thisMonthSpend"),
123
+ }
124
+
125
+
126
+ def _filter_spending_json(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
127
+ """daily spends JSON: gpuPoint/namespace/krw + income/settle/refund/*Gpu* 제외."""
128
+ result = []
129
+ for item in items:
130
+ filtered = {
131
+ k: v for k, v in item.items()
132
+ if k not in _EXCLUDED_SPENDING_EXACT
133
+ and not any(ex in k for ex in _EXCLUDED_SPENDING_PATTERN)
134
+ }
135
+ result.append(filtered)
136
+ return result
137
+
138
+
139
+ class PointAPI:
140
+ def __init__(self, client: Client) -> None:
141
+ self._client = client
142
+
143
+ def status(self, owner: str) -> tuple[PointStatus, dict[str, Any]]:
144
+ """GET /api/point/status?owner={email}. Returns (model, raw_data)."""
145
+ raw = self._client.get("/api/point/status", params={"owner": owner})
146
+ data: dict[str, Any] = raw.get("data", raw)
147
+ return PointStatus.from_dict(data), data
148
+
149
+ def spend_summary(self, owner: str) -> tuple[PointSpendSummary, dict[str, Any]]:
150
+ """GET /api/point/spend/status?owner={email}. Returns (model, raw_data)."""
151
+ raw = self._client.get("/api/point/spend/status", params={"owner": owner})
152
+ data: dict[str, Any] = raw.get("data", raw)
153
+ return PointSpendSummary.from_dict(data), data
154
+
155
+ def daily_spends(
156
+ self,
157
+ owner: str,
158
+ workload: int | None = None,
159
+ start_time: str | None = None,
160
+ end_time: str | None = None,
161
+ ) -> tuple[list[DailySpend], list[dict[str, Any]]]:
162
+ """GET /api/point/daily/spends. Returns (models, raw_items)."""
163
+ params: dict[str, Any] = {"owner": owner}
164
+ if workload is not None:
165
+ params["workload"] = workload
166
+ if start_time:
167
+ params["startTime"] = start_time
168
+ if end_time:
169
+ params["endTime"] = end_time
170
+ raw = self._client.get("/api/point/daily/spends", params=params)
171
+ data: dict[str, Any] = raw.get("data", raw)
172
+ items: list[dict[str, Any]] = data.get("pointSpends", [])
173
+ return [DailySpend.from_dict(i) for i in items], items
@@ -0,0 +1,108 @@
1
+ """Resource monitoring API client for gcube CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from gcube_cli.api.client import APIError, Client
10
+
11
+ _GAI_BASE = "/api/workloads/gai/"
12
+
13
+
14
+ def _parse_time_interval(ti: Any) -> str | None:
15
+ """Convert timeInterval from the API into a human-readable string.
16
+
17
+ Handles two formats the API may return:
18
+ - float/int: Unix timestamp in milliseconds → "YYYY-MM-DD HH:MM"
19
+ - list [year, month, day, hour, minute]: → "YYYY-MM-DD HH:MM"
20
+ - str: pass through as-is
21
+ """
22
+ if ti is None:
23
+ return None
24
+ if isinstance(ti, (int, float)):
25
+ return datetime.fromtimestamp(ti / 1000).strftime("%Y-%m-%d %H:%M")
26
+ if isinstance(ti, list) and len(ti) >= 5:
27
+ y, m, d, h, mi = int(ti[0]), int(ti[1]), int(ti[2]), int(ti[3]), int(ti[4])
28
+ return f"{y:04d}-{m:02d}-{d:02d} {h:02d}:{mi:02d}"
29
+ return str(ti)
30
+
31
+
32
+ @dataclass
33
+ class ResourceUsage:
34
+ """Resource usage snapshot for a pod."""
35
+
36
+ gpu_name: str # JSON: gpuName (시간평균 응답에만 있음)
37
+ gpu_usage: float # JSON: gpuUsage, 단위 %
38
+ vram_usage: float # JSON: vramUsage, 단위 %
39
+ vram_cap: int # JSON: vramCap, 단위 MB
40
+ cpu_usage: float # JSON: cpuUsage, 단위 %
41
+ mem_usage: float # JSON: memUsage, 단위 %
42
+ disk_usage: float # JSON: diskUsage, 단위 %
43
+ disk_occu: float # JSON: diskOccu, 단위 MB
44
+ net_rx: float # JSON: netRx, 단위 kbps
45
+ net_tx: float # JSON: netTx, 단위 kbps
46
+ time_interval: str | None = None # JSON: timeInterval
47
+
48
+ @classmethod
49
+ def from_dict(cls, data: dict[str, Any]) -> ResourceUsage:
50
+ return cls(
51
+ gpu_name=data.get("gpuName", ""),
52
+ gpu_usage=round(float(data.get("gpuUsage", 0.0)), 2),
53
+ vram_usage=round(float(data.get("vramUsage", 0.0)), 2),
54
+ vram_cap=int(data.get("vramCap", 0)),
55
+ cpu_usage=round(float(data.get("cpuUsage", 0.0)), 2),
56
+ mem_usage=round(float(data.get("memUsage", 0.0)), 2),
57
+ disk_usage=round(float(data.get("diskUsage", 0.0)), 2),
58
+ disk_occu=round(float(data.get("diskOccu", 0.0)), 2),
59
+ net_rx=round(float(data.get("netRx", 0.0)), 2),
60
+ net_tx=round(float(data.get("netTx", 0.0)), 2),
61
+ time_interval=_parse_time_interval(data.get("timeInterval")),
62
+ )
63
+
64
+
65
+ class ResourceAPI:
66
+ """Resource usage API client.
67
+
68
+ 2-step lookup: workload name → pod list → per-pod resource usage.
69
+
70
+ Endpoint: GET /api/usage/avg → response key: ``usages`` (list)
71
+ """
72
+
73
+ def __init__(self, client: Client) -> None:
74
+ self._client = client
75
+
76
+ def get_workload_pods(self, workload_name: str) -> list[str]:
77
+ """Resolve workload name to a list of running pod names."""
78
+ data = self._client.get(f"{_GAI_BASE}{workload_name}")
79
+ item: dict[str, Any] = data.get("workload", data.get("data", data))
80
+ pods: list[dict[str, Any]] = item.get("pods") or []
81
+ return [p["name"] for p in pods if p.get("name")]
82
+
83
+ def workload(self, workload_name: str) -> dict[str, list[ResourceUsage]]:
84
+ """Get time-series resource usage grouped by pod name.
85
+
86
+ Returns:
87
+ Mapping of pod_name → list[ResourceUsage].
88
+
89
+ Raises:
90
+ APIError: If workload has no running pods or an API call fails.
91
+ """
92
+ pods = self.get_workload_pods(workload_name)
93
+ if not pods:
94
+ raise APIError(
95
+ status_code=404,
96
+ code="NO_PODS",
97
+ message=f"Workload '{workload_name}' has no running pods. Is it stopped?",
98
+ )
99
+ return {pod: self._get_avg(pod) for pod in pods}
100
+
101
+ def _get_avg(self, pod_name: str) -> list[ResourceUsage]:
102
+ """GET /api/usage/avg?pod={pod}&isPod=true"""
103
+ data = self._client.get(
104
+ "/api/usage/avg",
105
+ params={"pod": pod_name, "isPod": "true"},
106
+ )
107
+ items: list[dict[str, Any]] = data.get("usages", [])
108
+ return [ResourceUsage.from_dict(item) for item in items]