computenexa 0.1.1__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.
@@ -0,0 +1,60 @@
1
+ """ComputeNexa: private GPUs for your business, from Python.
2
+
3
+ from computenexa import Client
4
+
5
+ cnx = Client() # reads CNX_API_KEY
6
+ with cnx.gpu("rtx-4090", app="datascience", hours=2) as machine:
7
+ print(machine.ssh_command)
8
+
9
+ Guide: https://app.computenexa.com/help/api · Reference: https://app.computenexa.com/help/api/reference
10
+ """
11
+
12
+ __version__ = "0.1.1"
13
+
14
+ from .client import Client
15
+ from .errors import (
16
+ APIConnectionError,
17
+ AuthenticationError,
18
+ ComputeNexaError,
19
+ ConflictError,
20
+ GpuLimitReachedError,
21
+ InsufficientCreditsError,
22
+ InvalidRequestError,
23
+ LaunchFailedError,
24
+ NoCapacityError,
25
+ NotFoundError,
26
+ PermissionDeniedError,
27
+ ProjectInUseError,
28
+ RateLimitError,
29
+ ServerError,
30
+ WaitTimeoutError,
31
+ )
32
+ from .models import Account, App, Catalog, Gpu, Instance, LedgerEntry, Organization, Project, SshKey
33
+
34
+ __all__ = [
35
+ "APIConnectionError",
36
+ "Account",
37
+ "App",
38
+ "AuthenticationError",
39
+ "Catalog",
40
+ "Client",
41
+ "ComputeNexaError",
42
+ "ConflictError",
43
+ "Gpu",
44
+ "GpuLimitReachedError",
45
+ "Instance",
46
+ "InsufficientCreditsError",
47
+ "InvalidRequestError",
48
+ "LaunchFailedError",
49
+ "LedgerEntry",
50
+ "NoCapacityError",
51
+ "NotFoundError",
52
+ "Organization",
53
+ "PermissionDeniedError",
54
+ "Project",
55
+ "ProjectInUseError",
56
+ "RateLimitError",
57
+ "ServerError",
58
+ "SshKey",
59
+ "WaitTimeoutError",
60
+ ]
computenexa/client.py ADDED
@@ -0,0 +1,235 @@
1
+ """The ComputeNexa API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ import time
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from . import __version__
15
+ from .errors import APIConnectionError, ComputeNexaError, NotFoundError, RateLimitError, ServerError, from_response
16
+ from .models import ACTIVE, STARTING, Account, Catalog, Instance, LedgerEntry, Project, SshKey
17
+
18
+ DEFAULT_BASE_URL = "https://api.computenexa.com/v1"
19
+ READS = ("GET", "HEAD")
20
+
21
+
22
+ class Client:
23
+ """A ComputeNexa API client.
24
+
25
+ cnx = Client() # reads CNX_API_KEY (and CNX_API_URL) from the environment
26
+ cnx = Client(api_key="cnxk_...")
27
+
28
+ Create keys in the dashboard under API keys. A key acts as the person who created it and spends their team's
29
+ credits. Look-only keys can read; "launch and stop" keys can also launch, extend and stop.
30
+ """
31
+
32
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None, timeout: float = 30.0,
33
+ max_retries: int = 3, http_client: httpx.Client | None = None):
34
+ key = api_key or os.environ.get("CNX_API_KEY")
35
+ if not key:
36
+ raise ComputeNexaError("No API key: pass api_key=... or set CNX_API_KEY. Create one in the dashboard "
37
+ "under API keys.")
38
+ if not key.startswith("cnxk_"):
39
+ raise ComputeNexaError("That isn't a ComputeNexa API key: they start with cnxk_.")
40
+ self.base_url = (base_url or os.environ.get("CNX_API_URL") or DEFAULT_BASE_URL).rstrip("/")
41
+ self.max_retries = max_retries
42
+ self._http = http_client or httpx.Client(timeout=timeout)
43
+ self._headers = {"Authorization": f"Bearer {key}", "User-Agent": f"computenexa-python/{__version__}"}
44
+
45
+ def __repr__(self) -> str: # never the key
46
+ return f"Client(base_url={self.base_url!r})"
47
+
48
+ def close(self) -> None:
49
+ self._http.close()
50
+
51
+ def __enter__(self) -> Client: # noqa: PYI034 (typing.Self needs Python 3.11; we support 3.9)
52
+ return self
53
+
54
+ def __exit__(self, *exc: object) -> None:
55
+ self.close()
56
+
57
+ # ------------------------------------------------------------------ HTTP
58
+
59
+ def _request(self, method: str, path: str, *, json: Any = None, params: dict | None = None) -> Any:
60
+ """One API call. Reads are retried on 429, 5xx and connection errors; writes only on 429 (refused before
61
+ anything happened), so a launch is never sent twice."""
62
+ attempt = 0
63
+ while True:
64
+ try:
65
+ r = self._http.request(method, f"{self.base_url}{path}", json=json, params=params,
66
+ headers=self._headers)
67
+ except httpx.HTTPError as e:
68
+ if method in READS and attempt < self.max_retries:
69
+ attempt += 1
70
+ self._backoff(attempt)
71
+ continue
72
+ raise APIConnectionError(f"Couldn't reach the ComputeNexa API: {e}") from e
73
+ if r.status_code < 400:
74
+ return r.json() if r.content else None
75
+ try:
76
+ body = r.json()
77
+ except ValueError:
78
+ body = None
79
+ err = from_response(r.status_code, body)
80
+ retryable = isinstance(err, RateLimitError) or (isinstance(err, ServerError) and method in READS)
81
+ if retryable and attempt < self.max_retries:
82
+ attempt += 1
83
+ self._backoff(attempt, r.headers.get("retry-after"))
84
+ continue
85
+ raise err
86
+
87
+ @staticmethod
88
+ def _backoff(attempt: int, retry_after: str | None = None) -> None:
89
+ try:
90
+ wait = float(retry_after) if retry_after else 0.0
91
+ except ValueError:
92
+ wait = 0.0
93
+ time.sleep(max(wait, min(30.0, 0.5 * 2 ** attempt + random.random())))
94
+
95
+ # ------------------------------------------------------------------ account
96
+
97
+ def account(self) -> Account:
98
+ """Who this key acts as, their team, its balance and GPU limit."""
99
+ return Account.from_api(self._request("GET", "/me"))
100
+
101
+ def balance_cents(self) -> int:
102
+ """The team's balance in US cents."""
103
+ return int(self._request("GET", "/balance")["balance_cents"])
104
+
105
+ def ledger(self, limit: int = 50) -> list[LedgerEntry]:
106
+ """Charges, refunds and top-ups, newest first."""
107
+ return [LedgerEntry.from_api(e) for e in self._request("GET", "/ledger", params={"limit": limit})]
108
+
109
+ # ------------------------------------------------------------------ catalog
110
+
111
+ def catalog(self, region: str | None = None) -> Catalog:
112
+ """GPUs with live prices and stock, and the apps and models they run. `region`: "us" or "ca"."""
113
+ return Catalog.from_api(self._request("GET", "/catalog", params={"region": region} if region else None))
114
+
115
+ def image_contents(self, app: str) -> dict:
116
+ """What's installed in an app's images: Ubuntu, CUDA, Python and every package with its version."""
117
+ return self._request("GET", f"/images/{app}")
118
+
119
+ # ------------------------------------------------------------------ instances
120
+
121
+ def launch(self, gpu: str, *, app: str | None = None, image: str | None = None, minutes: int | None = None,
122
+ hours: float | None = None, gpu_count: int = 1, region: str = "any", label: str | None = None,
123
+ model: str | None = None, project: Project | str | None = None) -> Instance:
124
+ """Launch a GPU. It's charged now for the booked time; unused time is refunded when it stops early, all of
125
+ it if it can't start.
126
+
127
+ cnx.launch("rtx-4090", app="datascience", hours=2, project=proj)
128
+
129
+ `app`: "basic", "datascience", "chat", "imagegen", "modelapi" or "voice" (or `image=` an exact image slug).
130
+ `model`: for apps that take one; left out = the recommended model for this GPU.
131
+ """
132
+ if (app is None) == (image is None):
133
+ raise ValueError("Give exactly one of app=... or image=...")
134
+ body: dict[str, Any] = {"gpu": gpu, "duration_minutes": _minutes(minutes, hours), "gpu_count": gpu_count,
135
+ "region": region}
136
+ if app is not None:
137
+ body["image_family"] = app
138
+ if image is not None:
139
+ body["image"] = image
140
+ if label is not None:
141
+ body["label"] = label
142
+ if model is not None:
143
+ body["model"] = model
144
+ if project is not None:
145
+ body["project_id"] = project.id if isinstance(project, Project) else str(project)
146
+ return Instance.from_api(self._request("POST", "/instances", json=body), self)
147
+
148
+ def get_instance(self, instance_id: str) -> Instance:
149
+ return Instance.from_api(self._request("GET", f"/instances/{instance_id}"), self)
150
+
151
+ def list_instances(self, *, active: bool | None = None, everyone: bool = False, limit: int = 50) -> list[Instance]:
152
+ """Your machines, newest first. `active=True` only starting/running ones, `False` only ended ones.
153
+ `everyone=True`: the whole team's (owners and admins)."""
154
+ params: dict[str, Any] = {"limit": limit}
155
+ if active is not None:
156
+ params["status"] = "active" if active else "ended"
157
+ if everyone:
158
+ params["whose"] = "everyone"
159
+ return [Instance.from_api(d, self) for d in self._request("GET", "/instances", params=params)]
160
+
161
+ def extend_instance(self, instance_id: str, *, minutes: int | None = None, hours: float | None = None) -> Instance:
162
+ return Instance.from_api(self._request("POST", f"/instances/{instance_id}/extend",
163
+ json={"minutes": _minutes(minutes, hours)}), self)
164
+
165
+ def stop_instance(self, instance_id: str) -> Instance:
166
+ """End it now: stop if running, cancel if still starting (full refund). An ended one is returned as is."""
167
+ current = self.get_instance(instance_id)
168
+ if current.state == "running":
169
+ return Instance.from_api(self._request("POST", f"/instances/{instance_id}/stop"), self)
170
+ if current.state in STARTING:
171
+ return Instance.from_api(self._request("POST", f"/instances/{instance_id}/cancel"), self)
172
+ return current
173
+
174
+ @contextmanager
175
+ def gpu(self, gpu: str, *, wait: bool = True, timeout: float = 900, **launch: Any) -> Iterator[Instance]:
176
+ """Launch a GPU for the length of a `with` block, then stop it, even if your code fails:
177
+
178
+ with cnx.gpu("rtx-4090", app="datascience", hours=2) as machine:
179
+ print(machine.ssh_command)
180
+ ... # your work
181
+ # stopped here; unused time refunded
182
+
183
+ `wait=True` (default) returns once it's running. Other arguments are those of `launch`.
184
+ """
185
+ machine = self.launch(gpu, **launch)
186
+ try:
187
+ if wait:
188
+ machine.wait_until_running(timeout=timeout)
189
+ yield machine
190
+ finally:
191
+ try:
192
+ if machine.refresh().state in ACTIVE:
193
+ machine.stop()
194
+ except NotFoundError:
195
+ pass
196
+
197
+ # ------------------------------------------------------------------ projects
198
+
199
+ def list_projects(self) -> list[Project]:
200
+ return [Project.from_api(p) for p in self._request("GET", "/projects")]
201
+
202
+ def create_project(self, name: str) -> Project:
203
+ """An empty project; launch with `project=` to restore it into ~/work and save it back automatically."""
204
+ return Project.from_api(self._request("POST", "/projects", json={"name": name}))
205
+
206
+ def get_or_create_project(self, name: str) -> Project:
207
+ for p in self.list_projects():
208
+ if p.name.lower() == name.lower():
209
+ return p
210
+ return self.create_project(name)
211
+
212
+ def delete_project(self, project: Project | str) -> None:
213
+ """Delete a project and all its files (refused while a GPU uses it)."""
214
+ self._request("DELETE", f"/projects/{project.id if isinstance(project, Project) else project}")
215
+
216
+ # ------------------------------------------------------------------ SSH keys
217
+
218
+ def list_ssh_keys(self) -> list[SshKey]:
219
+ return [SshKey.from_api(k) for k in self._request("GET", "/ssh-keys")]
220
+
221
+ def add_ssh_key(self, name: str, public_key: str) -> SshKey:
222
+ """Add a public key (e.g. the contents of ~/.ssh/id_ed25519.pub). Never a private key."""
223
+ return SshKey.from_api(self._request("POST", "/ssh-keys", json={"name": name, "public_key": public_key}))
224
+
225
+ def delete_ssh_key(self, key: SshKey | str) -> None:
226
+ self._request("DELETE", f"/ssh-keys/{key.id if isinstance(key, SshKey) else key}")
227
+
228
+
229
+ def _minutes(minutes: int | None, hours: float | None) -> int:
230
+ if (minutes is None) == (hours is None):
231
+ raise ValueError("Give exactly one of minutes=... or hours=...")
232
+ value = int(minutes) if minutes is not None else round(float(hours) * 60) # type: ignore[arg-type]
233
+ if value < 1:
234
+ raise ValueError("The duration must be at least 1 minute.")
235
+ return value
computenexa/errors.py ADDED
@@ -0,0 +1,102 @@
1
+ """Errors raised by the ComputeNexa client.
2
+
3
+ Every refusal from the API carries a stable `code` (e.g. "insufficient_credits"), a `message` that is safe to show
4
+ people, and a `request_id` to quote to support@computenexa.com. Catch `ComputeNexaError` for all of them, or a
5
+ subclass for one kind.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class ComputeNexaError(Exception):
12
+ """Base class: any error from the client or the API."""
13
+
14
+ def __init__(self, message: str, *, status: int | None = None, code: str | None = None,
15
+ request_id: str | None = None):
16
+ super().__init__(message)
17
+ self.message = message
18
+ self.status = status
19
+ self.code = code
20
+ self.request_id = request_id
21
+
22
+ def __str__(self) -> str:
23
+ extra = ", ".join(f"{k}={v}" for k, v in (("status", self.status), ("code", self.code),
24
+ ("request_id", self.request_id)) if v is not None)
25
+ return f"{self.message} ({extra})" if extra else self.message
26
+
27
+
28
+ class AuthenticationError(ComputeNexaError):
29
+ """401: the API key is missing, mistyped or revoked, or its creator left the team."""
30
+
31
+
32
+ class PermissionDeniedError(ComputeNexaError):
33
+ """403: the key can't do this (a look-only key, or a dashboard-only setting), or the account is suspended."""
34
+
35
+
36
+ class InsufficientCreditsError(ComputeNexaError):
37
+ """402: not enough credits for this launch or extension. Add credits in the dashboard."""
38
+
39
+
40
+ class NotFoundError(ComputeNexaError):
41
+ """404: no such machine, project or key, or not one this key can see."""
42
+
43
+
44
+ class ConflictError(ComputeNexaError):
45
+ """409: the request clashes with the current state (see the subclasses)."""
46
+
47
+
48
+ class NoCapacityError(ConflictError):
49
+ """409 no_capacity: that GPU is fully booked right now. Retry later or pick another GPU."""
50
+
51
+
52
+ class GpuLimitReachedError(ConflictError):
53
+ """409 gpu_limit_reached: your team (or you) already run as many GPUs as allowed."""
54
+
55
+
56
+ class ProjectInUseError(ConflictError):
57
+ """409 project_in_use: another GPU is using this project."""
58
+
59
+
60
+ class InvalidRequestError(ComputeNexaError):
61
+ """422: a field is missing or wrong; the message says which."""
62
+
63
+
64
+ class RateLimitError(ComputeNexaError):
65
+ """429: more than 120 requests a minute with this key. The client retries these by itself first."""
66
+
67
+
68
+ class ServerError(ComputeNexaError):
69
+ """5xx: something went wrong on our side. The client retries these by itself first."""
70
+
71
+
72
+ class APIConnectionError(ComputeNexaError):
73
+ """The API couldn't be reached (network, DNS, timeout)."""
74
+
75
+
76
+ class LaunchFailedError(ComputeNexaError):
77
+ """The machine couldn't start (no capacity after all, timeout…). Its cost was refunded in full."""
78
+
79
+
80
+ class WaitTimeoutError(ComputeNexaError):
81
+ """`wait_until_running` gave up before the machine was ready. The machine may still start: check or stop it."""
82
+
83
+
84
+ _BY_CODE = {
85
+ "no_capacity": NoCapacityError,
86
+ "gpu_limit_reached": GpuLimitReachedError,
87
+ "project_in_use": ProjectInUseError,
88
+ "insufficient_credits": InsufficientCreditsError,
89
+ }
90
+ _BY_STATUS = {401: AuthenticationError, 402: InsufficientCreditsError, 403: PermissionDeniedError,
91
+ 404: NotFoundError, 409: ConflictError, 422: InvalidRequestError, 429: RateLimitError}
92
+
93
+
94
+ def from_response(status: int, body: object) -> ComputeNexaError:
95
+ """The error for an API refusal: by its code first, else by its HTTP status."""
96
+ data = body if isinstance(body, dict) else {}
97
+ code = data.get("error") if isinstance(data.get("error"), str) else None
98
+ message = data.get("message") or data.get("detail") or f"HTTP {status}"
99
+ if not isinstance(message, str):
100
+ message = str(message)
101
+ cls = _BY_CODE.get(code or "") or _BY_STATUS.get(status) or (ServerError if status >= 500 else ComputeNexaError)
102
+ return cls(message, status=status, code=code, request_id=data.get("request_id"))
computenexa/models.py ADDED
@@ -0,0 +1,261 @@
1
+ """What the API returns, as Python objects. Every object keeps the full reply in `.raw` (fields added to the API
2
+ later are there before this library knows them)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from .errors import LaunchFailedError, WaitTimeoutError
12
+
13
+ if TYPE_CHECKING:
14
+ from .client import Client
15
+
16
+ STARTING = ("requested", "provisioning")
17
+ ACTIVE = ("requested", "provisioning", "running")
18
+ ENDED = ("terminated", "failed")
19
+
20
+
21
+ def _time(value: Any) -> datetime | None:
22
+ if not value:
23
+ return None
24
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
25
+
26
+
27
+ @dataclass
28
+ class Organization:
29
+ id: str
30
+ name: str
31
+ role: str # your role in it: owner | admin | member
32
+ members: int
33
+
34
+
35
+ @dataclass
36
+ class Account:
37
+ """Who the API key acts as, and their team."""
38
+
39
+ id: str
40
+ email: str
41
+ balance_cents: int # the team's balance
42
+ max_concurrent_gpus: int # the team's GPU limit
43
+ organization: Organization
44
+ raw: dict = field(repr=False)
45
+
46
+ @property
47
+ def balance(self) -> float:
48
+ """The team's balance in US dollars."""
49
+ return self.balance_cents / 100
50
+
51
+ @classmethod
52
+ def from_api(cls, d: dict) -> Account:
53
+ o = d.get("organization") or {}
54
+ return cls(id=d["id"], email=d["email"], balance_cents=d["balance_cents"],
55
+ max_concurrent_gpus=d["max_concurrent_gpus"],
56
+ organization=Organization(id=o.get("id", ""), name=o.get("name", ""), role=o.get("role", ""),
57
+ members=o.get("members", 1)), raw=d)
58
+
59
+
60
+ @dataclass
61
+ class Gpu:
62
+ """A GPU in the catalog."""
63
+
64
+ slug: str # what you launch with, e.g. "rtx-4090"
65
+ name: str # e.g. "NVIDIA RTX 4090"
66
+ vram_gb: int
67
+ available: bool
68
+ price_per_hour_cents: int | None # None when not for sale right now
69
+ billing_unit: str # "minute", or "hour" (billed per started hour)
70
+ apps: list[str] # apps (image families) it runs: "chat", "datascience", …
71
+ regions: list[str]
72
+ raw: dict = field(repr=False)
73
+
74
+ @classmethod
75
+ def from_api(cls, d: dict) -> Gpu:
76
+ return cls(slug=d["slug"], name=d["display_name"], vram_gb=d["vram_gb"], available=d["available"],
77
+ price_per_hour_cents=d.get("price_per_hour_cents"), billing_unit=d.get("billing_unit", "minute"),
78
+ apps=sorted((d.get("families") or {}).keys()), regions=list(d.get("regions") or []), raw=d)
79
+
80
+
81
+ @dataclass
82
+ class App:
83
+ """Something a GPU can start with (an image family): "basic", "datascience", "chat", "imagegen", "modelapi",
84
+ "voice"."""
85
+
86
+ name: str # the value for `app=` / image_family
87
+ display_name: str # as on the launch page
88
+ summary: str
89
+ needs_ssh_key: bool
90
+ models: list[dict] # models offered (chat, imagegen, modelapi, voice); [] for the others
91
+ model_required: bool
92
+ recommended: list[tuple[int, str]] # [(min GPU memory in GB, model)], first match wins
93
+
94
+ def recommended_model(self, vram_gb: int) -> str | None:
95
+ """Our pick for a GPU with this much memory, or None."""
96
+ return next((m for min_gb, m in self.recommended if vram_gb >= min_gb), None)
97
+
98
+
99
+ @dataclass
100
+ class Catalog:
101
+ region: str
102
+ gpus: list[Gpu]
103
+ apps: dict[str, App]
104
+ raw: dict = field(repr=False)
105
+
106
+ def gpu(self, slug: str) -> Gpu:
107
+ for g in self.gpus:
108
+ if g.slug == slug:
109
+ return g
110
+ raise KeyError(f"no GPU {slug!r} in the catalog; known: {', '.join(g.slug for g in self.gpus)}")
111
+
112
+ def available(self, app: str | None = None) -> list[Gpu]:
113
+ """GPUs for sale right now, cheapest first; only those that run `app` if given."""
114
+ found = [g for g in self.gpus if g.available and g.price_per_hour_cents is not None
115
+ and (app is None or app in g.apps)]
116
+ return sorted(found, key=lambda g: g.price_per_hour_cents or 0)
117
+
118
+ @classmethod
119
+ def from_api(cls, d: dict) -> Catalog:
120
+ models = d.get("launch_models") or {}
121
+ apps = {}
122
+ for f in d.get("families") or []:
123
+ m = models.get(f["family"]) or {}
124
+ apps[f["family"]] = App(
125
+ name=f["family"], display_name=f.get("display_name", f["family"]), summary=f.get("summary", ""),
126
+ needs_ssh_key=f.get("needs_ssh_key", True), models=list(m.get("models") or []),
127
+ model_required=bool(m.get("required")),
128
+ recommended=[(int(a), str(b)) for a, b in m.get("recommended") or []])
129
+ return cls(region=d.get("region", "any"), gpus=[Gpu.from_api(g) for g in d.get("gpus") or []], apps=apps,
130
+ raw=d)
131
+
132
+
133
+ @dataclass
134
+ class Project:
135
+ """Saved storage: restored into ~/work on GPUs launched with it, and saved back automatically."""
136
+
137
+ id: str
138
+ name: str
139
+ bytes_used: int
140
+ files: int
141
+ created_at: datetime | None
142
+ last_used_at: datetime | None
143
+ in_use_by: str | None # the machine using it right now, if any
144
+ raw: dict = field(repr=False)
145
+
146
+ @classmethod
147
+ def from_api(cls, d: dict) -> Project:
148
+ return cls(id=d["id"], name=d["name"], bytes_used=d.get("bytes_used", 0), files=d.get("files", 0),
149
+ created_at=_time(d.get("created_at")), last_used_at=_time(d.get("last_used_at")),
150
+ in_use_by=d.get("in_use_by"), raw=d)
151
+
152
+
153
+ @dataclass
154
+ class SshKey:
155
+ id: str
156
+ name: str
157
+ fingerprint: str
158
+ raw: dict = field(repr=False)
159
+
160
+ @classmethod
161
+ def from_api(cls, d: dict) -> SshKey:
162
+ return cls(id=str(d["id"]), name=d["name"], fingerprint=d.get("fingerprint", ""), raw=d)
163
+
164
+
165
+ @dataclass
166
+ class LedgerEntry:
167
+ created_at: datetime | None
168
+ kind: str # topup | charge | refund | adjustment
169
+ amount_cents: int # + credit, − debit
170
+ description: str
171
+ instance: str | None
172
+ raw: dict = field(repr=False)
173
+
174
+ @classmethod
175
+ def from_api(cls, d: dict) -> LedgerEntry:
176
+ return cls(created_at=_time(d.get("created_at")), kind=d["kind"], amount_cents=d["amount_cents"],
177
+ description=d.get("description", ""), instance=d.get("instance"), raw=d)
178
+
179
+
180
+ @dataclass
181
+ class Instance:
182
+ """A GPU machine. Methods act on it through the client that returned it."""
183
+
184
+ id: str # e.g. "cnx-7f3a9c"
185
+ state: str # requested | provisioning | running | stopping | terminated | failed
186
+ gpu: str
187
+ gpu_count: int
188
+ app: str # its image family
189
+ label: str | None
190
+ price_per_hour_cents: int
191
+ ssh_command: str | None # while running, if you have an SSH key
192
+ expires_at: datetime | None
193
+ time_left_seconds: int | None
194
+ charged_cents: int
195
+ refunded_cents: int
196
+ cost_so_far_cents: int
197
+ end_reason: str | None
198
+ startup_progress: str | None
199
+ raw: dict = field(repr=False)
200
+ _client: Client | None = field(default=None, repr=False, compare=False)
201
+
202
+ @property
203
+ def is_running(self) -> bool:
204
+ return self.state == "running"
205
+
206
+ @property
207
+ def is_active(self) -> bool:
208
+ """Starting or running: it may still cost money."""
209
+ return self.state in ACTIVE
210
+
211
+ @classmethod
212
+ def from_api(cls, d: dict, client: Client | None = None) -> Instance:
213
+ return cls(id=d["id"], state=d["state"], gpu=d["gpu"], gpu_count=d.get("gpu_count", 1),
214
+ app=d.get("image_family", ""), label=d.get("label"),
215
+ price_per_hour_cents=d.get("price_per_hour_cents", 0), ssh_command=d.get("ssh_command"),
216
+ expires_at=_time(d.get("expires_at")), time_left_seconds=d.get("time_left_seconds"),
217
+ charged_cents=d.get("charged_cents", 0), refunded_cents=d.get("refunded_cents", 0),
218
+ cost_so_far_cents=d.get("cost_so_far_cents", 0), end_reason=d.get("end_reason"),
219
+ startup_progress=d.get("startup_progress"), raw=d, _client=client)
220
+
221
+ def _update(self, other: Instance) -> Instance:
222
+ for name in self.__dataclass_fields__:
223
+ if name != "_client":
224
+ setattr(self, name, getattr(other, name))
225
+ return self
226
+
227
+ def _c(self) -> Client:
228
+ if self._client is None:
229
+ raise RuntimeError("this Instance isn't attached to a client")
230
+ return self._client
231
+
232
+ def refresh(self) -> Instance:
233
+ """Fetch its current state (in place; returns itself)."""
234
+ return self._update(self._c().get_instance(self.id))
235
+
236
+ def wait_until_running(self, timeout: float = 900, poll: float = 10,
237
+ on_progress: Any = None) -> Instance:
238
+ """Wait until it's running. Raises LaunchFailedError if it couldn't start (refunded in full), or
239
+ WaitTimeoutError after `timeout` seconds (it may still start: check or stop it). `on_progress(instance)` is
240
+ called after each check, e.g. to print `instance.startup_progress`."""
241
+ deadline = time.monotonic() + timeout
242
+ while True:
243
+ self.refresh()
244
+ if on_progress is not None:
245
+ on_progress(self)
246
+ if self.state == "running":
247
+ return self
248
+ if self.state not in STARTING:
249
+ raise LaunchFailedError(f"{self.id} didn't start: {self.state}"
250
+ + (f" ({self.end_reason})" if self.end_reason else ""))
251
+ if time.monotonic() >= deadline:
252
+ raise WaitTimeoutError(f"{self.id} wasn't running after {timeout:.0f} s (still {self.state})")
253
+ time.sleep(poll)
254
+
255
+ def extend(self, minutes: int | None = None, *, hours: float | None = None) -> Instance:
256
+ """Add time at the price it launched with."""
257
+ return self._update(self._c().extend_instance(self.id, minutes=minutes, hours=hours))
258
+
259
+ def stop(self) -> Instance:
260
+ """End it now: stop if running, cancel if still starting. Unused time is refunded."""
261
+ return self._update(self._c().stop_instance(self.id))
computenexa/py.typed ADDED
File without changes
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.5
2
+ Name: computenexa
3
+ Version: 0.1.1
4
+ Summary: Python client for ComputeNexa: private GPUs for your business, launched and stopped from code.
5
+ Project-URL: Homepage, https://computenexa.com
6
+ Project-URL: Documentation, https://app.computenexa.com/help/api
7
+ Project-URL: API reference, https://app.computenexa.com/help/api/reference
8
+ Project-URL: Support, https://app.computenexa.com/help
9
+ Author-email: ComputeNexa <support@computenexa.com>
10
+ Maintainer-email: ComputeNexa <support@computenexa.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: ai,cloud,computenexa,gpu,inference,llm,private ai,training
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: httpx<1,>=0.27
22
+ Description-Content-Type: text/markdown
23
+
24
+ # ComputeNexa Python SDK
25
+
26
+ Launch, use and stop private GPUs on [ComputeNexa](https://computenexa.com) from Python. Each GPU is reserved for
27
+ your team, runs open AI models or your own code, and is paid by the minute from your team's prepaid credits.
28
+
29
+ ```bash
30
+ pip install computenexa
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ Create an API key in the dashboard under **API keys** ("Launch and stop" to launch), then:
36
+
37
+ ```bash
38
+ export CNX_API_KEY="cnxk_..."
39
+ ```
40
+
41
+ ```python
42
+ from computenexa import Client
43
+
44
+ cnx = Client() # reads CNX_API_KEY
45
+
46
+ # Launch an RTX 4090 with JupyterLab and PyTorch for 2 hours; it's stopped when the block ends,
47
+ # even if your code fails, and unused minutes are refunded.
48
+ with cnx.gpu("rtx-4090", app="datascience", hours=2) as machine:
49
+ print(machine.ssh_command) # ssh cnx-7f3a9c@ssh.computenexa.com
50
+ ... # your work
51
+ ```
52
+
53
+ ## Choose a GPU and an app
54
+
55
+ ```python
56
+ catalog = cnx.catalog() # live prices and stock; catalog.apps describes each app
57
+ for gpu in catalog.available(app="chat"): # for sale now, cheapest first
58
+ print(gpu.slug, gpu.vram_gb, "GB", gpu.price_per_hour_cents / 100, "$/h")
59
+
60
+ chat = catalog.apps["chat"]
61
+ print(chat.recommended_model(vram_gb=48)) # our model pick for a 48 GB GPU
62
+ print(cnx.image_contents("datascience")) # every package and version installed
63
+ ```
64
+
65
+ | `app=` | On the launch page | For |
66
+ |---|---|---|
67
+ | `chat` | Private Chat | chat with open AI models about your documents, in your browser |
68
+ | `imagegen` | Image Generation (ComfyUI) | images with SDXL or FLUX |
69
+ | `modelapi` | Model API (vLLM) | an AI model behind your own API key |
70
+ | `voice` | Voice Agent | speech in, spoken AI reply out |
71
+ | `datascience` | Notebook (Jupyter) | JupyterLab, PyTorch, pandas: analysis, training, fine-tuning |
72
+ | `basic` | Basic (SSH only) | Ubuntu + CUDA for your own code |
73
+
74
+ ## Keep files between sessions with projects
75
+
76
+ A project is saved storage: launched with it, a GPU starts with the project's files in `~/work` and saves
77
+ `~/work` back automatically.
78
+
79
+ ```python
80
+ project = cnx.get_or_create_project("titanic-training")
81
+ machine = cnx.launch("l40s", app="datascience", hours=4, project=project, label="nightly training")
82
+ machine.wait_until_running(on_progress=lambda m: print(m.state, m.startup_progress or ""))
83
+ print(machine.ssh_command)
84
+ # ... later
85
+ machine.stop()
86
+ print("refunded", machine.refunded_cents / 100)
87
+ ```
88
+
89
+ ## Machines
90
+
91
+ ```python
92
+ cnx.list_instances(active=True) # your running and starting machines
93
+ cnx.list_instances(everyone=True) # the whole team's (owners and admins)
94
+ machine = cnx.get_instance("cnx-7f3a9c")
95
+ machine.extend(hours=1) # same price it launched with
96
+ machine.stop() # stops if running, cancels if still starting
97
+ ```
98
+
99
+ ## Account, credits and SSH keys
100
+
101
+ ```python
102
+ me = cnx.account()
103
+ print(me.email, me.organization.name, me.organization.role, me.balance, me.max_concurrent_gpus)
104
+ for entry in cnx.ledger(limit=20):
105
+ print(entry.created_at, entry.description, entry.amount_cents / 100)
106
+
107
+ cnx.add_ssh_key("Work laptop", open("/Users/me/.ssh/id_ed25519.pub").read()) # the PUBLIC key only
108
+ ```
109
+
110
+ ## Errors
111
+
112
+ Every refusal raises a subclass of `computenexa.ComputeNexaError` with a stable `code`, a `message` you can show
113
+ and a `request_id` to quote to support:
114
+
115
+ | Exception | When |
116
+ |---|---|
117
+ | `InsufficientCreditsError` | not enough credits for the launch or extension |
118
+ | `NoCapacityError` | that GPU is fully booked right now: retry or pick another |
119
+ | `GpuLimitReachedError` | your team (or you) already run as many GPUs as allowed |
120
+ | `ProjectInUseError` | another GPU is using the project |
121
+ | `AuthenticationError` | the key is missing, mistyped or revoked |
122
+ | `PermissionDeniedError` | a look-only key tried to launch, or a dashboard-only setting |
123
+ | `NotFoundError` | no such machine or project, or not one you can see |
124
+ | `LaunchFailedError` | the machine couldn't start (refunded in full) |
125
+ | `WaitTimeoutError` | `wait_until_running` gave up; the machine may still start |
126
+
127
+ ```python
128
+ from computenexa import NoCapacityError
129
+
130
+ for gpu in ["rtx-4090", "l40s", "a40"]:
131
+ try:
132
+ machine = cnx.launch(gpu, app="chat", hours=1)
133
+ break
134
+ except NoCapacityError:
135
+ continue
136
+ ```
137
+
138
+ Reads are retried automatically when the API is busy (429) or has a server error. A launch is never retried after
139
+ an uncertain failure, so it can't start two GPUs.
140
+
141
+ ## Configuration
142
+
143
+ | | |
144
+ |---|---|
145
+ | `Client(api_key=...)` or `CNX_API_KEY` | your API key |
146
+ | `Client(base_url=...)` or `CNX_API_URL` | defaults to `https://api.computenexa.com/v1` |
147
+ | `Client(timeout=30, max_retries=3)` | per request |
148
+
149
+ ## Help
150
+
151
+ - Guide: <https://app.computenexa.com/help/api>
152
+ - Every operation: <https://app.computenexa.com/help/api/reference>
153
+ - Support: <support@computenexa.com>
154
+
155
+ MIT licensed. © ComputeNexa.
@@ -0,0 +1,9 @@
1
+ computenexa/__init__.py,sha256=-WAmlRie2He7fbuGG-HL6aujKvR5wq_FR-sY936uYME,1443
2
+ computenexa/client.py,sha256=UHt77B2giIFE6dQt_AHa1gDzRM5RaAzGzB_Kmgbug1Y,11158
3
+ computenexa/errors.py,sha256=R33ILaz0lJSQAiBzTuDrnuaEHjHZfiHUaF_s8-24fbo,3921
4
+ computenexa/models.py,sha256=Ee0JUC27YyTU5Uwj4LBjO_7SiMqxqxvTWPJvLEmkLYw,10148
5
+ computenexa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ computenexa-0.1.1.dist-info/METADATA,sha256=c5dpLekLj1wROqWX4JgIy0wGFVvmfD5FUSQ_OCLJf_A,5992
7
+ computenexa-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ computenexa-0.1.1.dist-info/licenses/LICENSE,sha256=2QFwVb5OF20CkVUdITA4oVlgRJFDfLGpSr7UjCAxV1s,1068
9
+ computenexa-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ComputeNexa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.