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.
- computenexa/__init__.py +60 -0
- computenexa/client.py +235 -0
- computenexa/errors.py +102 -0
- computenexa/models.py +261 -0
- computenexa/py.typed +0 -0
- computenexa-0.1.1.dist-info/METADATA +155 -0
- computenexa-0.1.1.dist-info/RECORD +9 -0
- computenexa-0.1.1.dist-info/WHEEL +4 -0
- computenexa-0.1.1.dist-info/licenses/LICENSE +21 -0
computenexa/__init__.py
ADDED
|
@@ -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,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.
|