vilvik 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.
- vilvik/__init__.py +56 -0
- vilvik/_http.py +151 -0
- vilvik/client.py +294 -0
- vilvik/exceptions.py +81 -0
- vilvik/models.py +155 -0
- vilvik/run.py +66 -0
- vilvik-0.1.0.dist-info/METADATA +154 -0
- vilvik-0.1.0.dist-info/RECORD +10 -0
- vilvik-0.1.0.dist-info/WHEEL +5 -0
- vilvik-0.1.0.dist-info/top_level.txt +1 -0
vilvik/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Vilvik — Python SDK for the Vilvik genetic-algorithm cloud API.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
import vilvik
|
|
6
|
+
|
|
7
|
+
client = vilvik.Client(api_key="vlk_live_…")
|
|
8
|
+
sub = client.submissions.create(
|
|
9
|
+
fitness_func="def fitness_func(ga_instance, solution, idx):\\n return -sum(s*s for s in solution)",
|
|
10
|
+
num_genes=5,
|
|
11
|
+
num_generations=50,
|
|
12
|
+
sol_per_pop=30,
|
|
13
|
+
)
|
|
14
|
+
print(sub.id, sub.status_url)
|
|
15
|
+
|
|
16
|
+
# Block until the run finishes and inspect the best fitness:
|
|
17
|
+
result = client.results.wait_for(sub.id, timeout=600)
|
|
18
|
+
print(result.best_fitness, result.best_solution)
|
|
19
|
+
|
|
20
|
+
For one-shot scripts, the context-managed `vilvik.run(...)` helper
|
|
21
|
+
combines submit + wait:
|
|
22
|
+
|
|
23
|
+
with vilvik.run(api_key="vlk_live_…", fitness_func=fn, num_genes=5) as r:
|
|
24
|
+
print(r.best_fitness)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from vilvik.client import Client
|
|
28
|
+
from vilvik.exceptions import (
|
|
29
|
+
APIError,
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
TimeoutError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
VilvikError,
|
|
36
|
+
)
|
|
37
|
+
from vilvik.models import CodeUpload, Result, Submission, Webhook
|
|
38
|
+
from vilvik.run import run
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"APIError",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"Client",
|
|
44
|
+
"CodeUpload",
|
|
45
|
+
"NotFoundError",
|
|
46
|
+
"RateLimitError",
|
|
47
|
+
"Result",
|
|
48
|
+
"Submission",
|
|
49
|
+
"TimeoutError",
|
|
50
|
+
"ValidationError",
|
|
51
|
+
"VilvikError",
|
|
52
|
+
"Webhook",
|
|
53
|
+
"run",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
__version__ = "0.1.0"
|
vilvik/_http.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Shared HTTP plumbing for `vilvik.Client`.
|
|
2
|
+
|
|
3
|
+
Kept separate from `client.py` so the resource sub-clients (Submissions,
|
|
4
|
+
Results, CodeUploads, Webhooks) can each consume a thin transport object
|
|
5
|
+
without circular imports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from vilvik.exceptions import (
|
|
19
|
+
APIError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ValidationError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://vilvik.com/api/v1"
|
|
27
|
+
DEFAULT_TIMEOUT_SECONDS = 60.0
|
|
28
|
+
DEFAULT_USER_AGENT = "vilvik-python/0.1.0"
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("vilvik")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Transport:
|
|
34
|
+
"""Wraps `requests.Session` with auth, retry, and error translation."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
api_key: str,
|
|
39
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
40
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
41
|
+
session: Optional[requests.Session] = None,
|
|
42
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
43
|
+
max_retries: int = 2,
|
|
44
|
+
):
|
|
45
|
+
if not api_key:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"api_key is required. Pass it explicitly or set VILVIK_API_KEY.",
|
|
48
|
+
)
|
|
49
|
+
self.api_key = api_key
|
|
50
|
+
self.base_url = base_url.rstrip("/")
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
self.user_agent = user_agent
|
|
53
|
+
self.max_retries = max(0, int(max_retries))
|
|
54
|
+
self.session = session or requests.Session()
|
|
55
|
+
|
|
56
|
+
def request(
|
|
57
|
+
self,
|
|
58
|
+
method: str,
|
|
59
|
+
path: str,
|
|
60
|
+
*,
|
|
61
|
+
params: Optional[Dict[str, Any]] = None,
|
|
62
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
63
|
+
idempotency_key: Optional[str] = None,
|
|
64
|
+
timeout: Optional[float] = None,
|
|
65
|
+
) -> Dict[str, Any]:
|
|
66
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
67
|
+
headers = {
|
|
68
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
69
|
+
"Accept": "application/json",
|
|
70
|
+
"User-Agent": self.user_agent,
|
|
71
|
+
}
|
|
72
|
+
if json_body is not None:
|
|
73
|
+
headers["Content-Type"] = "application/json"
|
|
74
|
+
if idempotency_key:
|
|
75
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
76
|
+
|
|
77
|
+
attempt = 0
|
|
78
|
+
while True:
|
|
79
|
+
try:
|
|
80
|
+
resp = self.session.request(
|
|
81
|
+
method=method,
|
|
82
|
+
url=url,
|
|
83
|
+
params=params,
|
|
84
|
+
data=json.dumps(json_body) if json_body is not None else None,
|
|
85
|
+
headers=headers,
|
|
86
|
+
timeout=timeout or self.timeout,
|
|
87
|
+
)
|
|
88
|
+
except requests.RequestException as exc:
|
|
89
|
+
if attempt < self.max_retries and method.upper() in {"GET", "HEAD"}:
|
|
90
|
+
attempt += 1
|
|
91
|
+
sleep_for = 0.5 * (2 ** (attempt - 1))
|
|
92
|
+
logger.warning(
|
|
93
|
+
"vilvik: network error on %s %s, retrying in %.1fs (%s)",
|
|
94
|
+
method,
|
|
95
|
+
path,
|
|
96
|
+
sleep_for,
|
|
97
|
+
exc,
|
|
98
|
+
)
|
|
99
|
+
time.sleep(sleep_for)
|
|
100
|
+
continue
|
|
101
|
+
raise APIError(
|
|
102
|
+
status_code=0,
|
|
103
|
+
code="network_error",
|
|
104
|
+
message=str(exc),
|
|
105
|
+
) from exc
|
|
106
|
+
|
|
107
|
+
# The server may return non-JSON on infrastructure errors (e.g.
|
|
108
|
+
# 502 from a load balancer). Treat those as APIError without
|
|
109
|
+
# trying to decode the body.
|
|
110
|
+
try:
|
|
111
|
+
payload = resp.json() if resp.content else {}
|
|
112
|
+
except ValueError:
|
|
113
|
+
payload = {"raw_body": resp.text[:1000]}
|
|
114
|
+
|
|
115
|
+
if 200 <= resp.status_code < 300:
|
|
116
|
+
return payload
|
|
117
|
+
|
|
118
|
+
self._raise_for_status(resp, payload)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _raise_for_status(resp: requests.Response, payload: Dict[str, Any]) -> None:
|
|
122
|
+
status = resp.status_code
|
|
123
|
+
# Vilvik's API errors come back as {"error": {"code": "...", "message": "...", "request_id": "..."}}.
|
|
124
|
+
# Fall back to top-level keys if a proxy returned a different envelope.
|
|
125
|
+
err = payload.get("error") if isinstance(payload, dict) else None
|
|
126
|
+
if not isinstance(err, dict):
|
|
127
|
+
err = payload if isinstance(payload, dict) else {}
|
|
128
|
+
code = str(err.get("code") or "")
|
|
129
|
+
message = str(err.get("message") or err.get("detail") or "")
|
|
130
|
+
request_id = str(err.get("request_id") or resp.headers.get("X-Request-Id") or "")
|
|
131
|
+
|
|
132
|
+
if status in (401, 403):
|
|
133
|
+
raise AuthenticationError(status, code, message, request_id, payload)
|
|
134
|
+
if status == 404:
|
|
135
|
+
raise NotFoundError(status, code, message, request_id, payload)
|
|
136
|
+
if status in (400, 422):
|
|
137
|
+
raise ValidationError(status, code, message, request_id, payload)
|
|
138
|
+
if status == 429:
|
|
139
|
+
retry_after_raw = resp.headers.get("Retry-After")
|
|
140
|
+
try:
|
|
141
|
+
retry_after = int(retry_after_raw) if retry_after_raw else None
|
|
142
|
+
except (TypeError, ValueError):
|
|
143
|
+
retry_after = None
|
|
144
|
+
raise RateLimitError(
|
|
145
|
+
status, code, message, request_id, payload, retry_after=retry_after,
|
|
146
|
+
)
|
|
147
|
+
raise APIError(status, code, message, request_id, payload)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def make_idempotency_key() -> str:
|
|
151
|
+
return str(uuid.uuid4())
|
vilvik/client.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""High-level entrypoint: `vilvik.Client`.
|
|
2
|
+
|
|
3
|
+
Modelled after `stripe.StripeClient` — one top-level object that
|
|
4
|
+
namespaces resources (`client.submissions`, `client.results`, …). Each
|
|
5
|
+
resource class is a thin wrapper over `Transport` that converts JSON
|
|
6
|
+
into the dataclasses in `vilvik.models`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
|
14
|
+
|
|
15
|
+
from vilvik._http import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, Transport
|
|
16
|
+
from vilvik.exceptions import TimeoutError as VilvikTimeout
|
|
17
|
+
from vilvik.models import CodeUpload, Page, Result, Submission, Webhook
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Resource:
|
|
21
|
+
"""Base for resource sub-clients — holds a reference to the transport."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, transport: Transport):
|
|
24
|
+
self._t = transport
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Submissions(_Resource):
|
|
28
|
+
"""Endpoints under `/api/v1/submissions`."""
|
|
29
|
+
|
|
30
|
+
def create(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
fitness_func: Optional[str] = None,
|
|
34
|
+
num_genes: Optional[int] = None,
|
|
35
|
+
num_generations: int = 100,
|
|
36
|
+
sol_per_pop: int = 50,
|
|
37
|
+
name: Optional[str] = None,
|
|
38
|
+
description: Optional[str] = None,
|
|
39
|
+
submission_type: str = "new",
|
|
40
|
+
webhook_url: Optional[str] = None,
|
|
41
|
+
notification_email: Optional[str] = None,
|
|
42
|
+
idempotency_key: Optional[str] = None,
|
|
43
|
+
**ga_params: Any,
|
|
44
|
+
) -> Submission:
|
|
45
|
+
"""POST /submissions — enqueue a new GA run.
|
|
46
|
+
|
|
47
|
+
Extra `ga_params` are forwarded as-is so callers can pass any of
|
|
48
|
+
the PyGAD knobs (`mutation_probability`, `parent_selection_type`,
|
|
49
|
+
`gene_space`, …) without the SDK having to enumerate them.
|
|
50
|
+
"""
|
|
51
|
+
body: Dict[str, Any] = {
|
|
52
|
+
"num_generations": num_generations,
|
|
53
|
+
"sol_per_pop": sol_per_pop,
|
|
54
|
+
"submission_type": submission_type,
|
|
55
|
+
}
|
|
56
|
+
if fitness_func is not None:
|
|
57
|
+
body["fitness_func"] = fitness_func
|
|
58
|
+
if num_genes is not None:
|
|
59
|
+
body["num_genes"] = num_genes
|
|
60
|
+
if name is not None:
|
|
61
|
+
body["name"] = name
|
|
62
|
+
if description is not None:
|
|
63
|
+
body["description"] = description
|
|
64
|
+
if webhook_url is not None:
|
|
65
|
+
body["webhook_url"] = webhook_url
|
|
66
|
+
if notification_email is not None:
|
|
67
|
+
body["notification_email"] = notification_email
|
|
68
|
+
body.update({k: v for k, v in ga_params.items() if v is not None})
|
|
69
|
+
|
|
70
|
+
payload = self._t.request(
|
|
71
|
+
"POST",
|
|
72
|
+
"/submissions",
|
|
73
|
+
json_body=body,
|
|
74
|
+
idempotency_key=idempotency_key or Transport.make_idempotency_key(),
|
|
75
|
+
)
|
|
76
|
+
return Submission.from_api(payload)
|
|
77
|
+
|
|
78
|
+
def get(self, submission_id: str) -> Submission:
|
|
79
|
+
payload = self._t.request("GET", f"/submissions/{submission_id}")
|
|
80
|
+
return Submission.from_api(payload)
|
|
81
|
+
|
|
82
|
+
def list(self, *, cursor: Optional[str] = None, limit: int = 25) -> Page:
|
|
83
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
84
|
+
if cursor:
|
|
85
|
+
params["cursor"] = cursor
|
|
86
|
+
payload = self._t.request("GET", "/submissions", params=params)
|
|
87
|
+
items = [Submission.from_api(row) for row in payload.get("data", [])]
|
|
88
|
+
return Page(items=items, next_cursor=payload.get("next_cursor"), raw=payload)
|
|
89
|
+
|
|
90
|
+
def iter_all(self, *, limit: int = 25) -> Iterator[Submission]:
|
|
91
|
+
"""Generator that follows `next_cursor` until exhausted."""
|
|
92
|
+
cursor: Optional[str] = None
|
|
93
|
+
while True:
|
|
94
|
+
page = self.list(cursor=cursor, limit=limit)
|
|
95
|
+
for item in page:
|
|
96
|
+
yield item
|
|
97
|
+
if not page.next_cursor:
|
|
98
|
+
return
|
|
99
|
+
cursor = page.next_cursor
|
|
100
|
+
|
|
101
|
+
def delete(self, submission_id: str) -> None:
|
|
102
|
+
self._t.request("DELETE", f"/submissions/{submission_id}")
|
|
103
|
+
|
|
104
|
+
def reexecute(
|
|
105
|
+
self,
|
|
106
|
+
submission_id: str,
|
|
107
|
+
*,
|
|
108
|
+
idempotency_key: Optional[str] = None,
|
|
109
|
+
**overrides: Any,
|
|
110
|
+
) -> Submission:
|
|
111
|
+
"""POST /submissions/{id}/reexecute — re-run with optional overrides."""
|
|
112
|
+
payload = self._t.request(
|
|
113
|
+
"POST",
|
|
114
|
+
f"/submissions/{submission_id}/reexecute",
|
|
115
|
+
json_body=overrides or None,
|
|
116
|
+
idempotency_key=idempotency_key or Transport.make_idempotency_key(),
|
|
117
|
+
)
|
|
118
|
+
return Submission.from_api(payload)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Results(_Resource):
|
|
122
|
+
"""Endpoints under `/api/v1/results`."""
|
|
123
|
+
|
|
124
|
+
def get(self, result_id: str) -> Result:
|
|
125
|
+
payload = self._t.request("GET", f"/results/{result_id}")
|
|
126
|
+
return Result.from_api(payload)
|
|
127
|
+
|
|
128
|
+
def list(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
submission_id: Optional[str] = None,
|
|
132
|
+
cursor: Optional[str] = None,
|
|
133
|
+
limit: int = 25,
|
|
134
|
+
) -> Page:
|
|
135
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
136
|
+
if submission_id:
|
|
137
|
+
params["submission_id"] = submission_id
|
|
138
|
+
if cursor:
|
|
139
|
+
params["cursor"] = cursor
|
|
140
|
+
payload = self._t.request("GET", "/results", params=params)
|
|
141
|
+
items = [Result.from_api(row) for row in payload.get("data", [])]
|
|
142
|
+
return Page(items=items, next_cursor=payload.get("next_cursor"), raw=payload)
|
|
143
|
+
|
|
144
|
+
def continue_run(
|
|
145
|
+
self,
|
|
146
|
+
result_id: str,
|
|
147
|
+
*,
|
|
148
|
+
idempotency_key: Optional[str] = None,
|
|
149
|
+
**overrides: Any,
|
|
150
|
+
) -> Submission:
|
|
151
|
+
"""POST /results/{id}/continue — branch a fresh run from a finished result."""
|
|
152
|
+
payload = self._t.request(
|
|
153
|
+
"POST",
|
|
154
|
+
f"/results/{result_id}/continue",
|
|
155
|
+
json_body=overrides or None,
|
|
156
|
+
idempotency_key=idempotency_key or Transport.make_idempotency_key(),
|
|
157
|
+
)
|
|
158
|
+
return Submission.from_api(payload)
|
|
159
|
+
|
|
160
|
+
def wait_for(
|
|
161
|
+
self,
|
|
162
|
+
submission_id: str,
|
|
163
|
+
*,
|
|
164
|
+
timeout: float = 600.0,
|
|
165
|
+
poll_interval: float = 3.0,
|
|
166
|
+
) -> Result:
|
|
167
|
+
"""Block until the submission terminates, then return its first result.
|
|
168
|
+
|
|
169
|
+
Polls `GET /submissions/{id}` every `poll_interval` seconds.
|
|
170
|
+
Raises `vilvik.TimeoutError` if `timeout` is exceeded; raises
|
|
171
|
+
`vilvik.APIError` (subclass `vilvik.exceptions.APIError`) wrapping
|
|
172
|
+
the API failure if the submission ended in `failed`.
|
|
173
|
+
"""
|
|
174
|
+
from vilvik.exceptions import APIError as _APIError
|
|
175
|
+
|
|
176
|
+
deadline = time.monotonic() + timeout
|
|
177
|
+
while True:
|
|
178
|
+
sub = Submissions(self._t).get(submission_id)
|
|
179
|
+
if sub.is_terminal:
|
|
180
|
+
if sub.status == "failed":
|
|
181
|
+
raise _APIError(
|
|
182
|
+
status_code=0,
|
|
183
|
+
code="submission_failed",
|
|
184
|
+
message=f"Submission {submission_id} ended with status 'failed'.",
|
|
185
|
+
)
|
|
186
|
+
if sub.status == "cancelled":
|
|
187
|
+
raise _APIError(
|
|
188
|
+
status_code=0,
|
|
189
|
+
code="submission_cancelled",
|
|
190
|
+
message=f"Submission {submission_id} was cancelled.",
|
|
191
|
+
)
|
|
192
|
+
page = self.list(submission_id=submission_id, limit=1)
|
|
193
|
+
if not page.items:
|
|
194
|
+
raise _APIError(
|
|
195
|
+
status_code=0,
|
|
196
|
+
code="result_not_found",
|
|
197
|
+
message=(
|
|
198
|
+
f"Submission {submission_id} reported '{sub.status}' "
|
|
199
|
+
"but no result row was returned."
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
return page.items[0]
|
|
203
|
+
|
|
204
|
+
if time.monotonic() >= deadline:
|
|
205
|
+
raise VilvikTimeout(
|
|
206
|
+
f"Timed out after {timeout:.0f}s waiting for submission "
|
|
207
|
+
f"{submission_id} (last status: {sub.status!r}).",
|
|
208
|
+
)
|
|
209
|
+
time.sleep(poll_interval)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class CodeUploads(_Resource):
|
|
213
|
+
"""Endpoints under `/api/v1/code-uploads`."""
|
|
214
|
+
|
|
215
|
+
def create(self, *, field: str, code: str) -> CodeUpload:
|
|
216
|
+
payload = self._t.request(
|
|
217
|
+
"POST",
|
|
218
|
+
"/code-uploads",
|
|
219
|
+
json_body={"field": field, "code": code},
|
|
220
|
+
)
|
|
221
|
+
return CodeUpload.from_api(payload)
|
|
222
|
+
|
|
223
|
+
def get(self, code_id: str) -> CodeUpload:
|
|
224
|
+
return CodeUpload.from_api(self._t.request("GET", f"/code-uploads/{code_id}"))
|
|
225
|
+
|
|
226
|
+
def list(self, *, cursor: Optional[str] = None, limit: int = 25) -> Page:
|
|
227
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
228
|
+
if cursor:
|
|
229
|
+
params["cursor"] = cursor
|
|
230
|
+
payload = self._t.request("GET", "/code-uploads", params=params)
|
|
231
|
+
items = [CodeUpload.from_api(row) for row in payload.get("data", [])]
|
|
232
|
+
return Page(items=items, next_cursor=payload.get("next_cursor"), raw=payload)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class Webhooks(_Resource):
|
|
236
|
+
"""Read-only listing of webhook subscriptions for this account.
|
|
237
|
+
|
|
238
|
+
Mutations (create / update / delete / test / replay) currently live
|
|
239
|
+
behind the dashboard's CSRF-protected endpoints rather than the
|
|
240
|
+
public REST surface. They will land here once exposed under
|
|
241
|
+
`/api/v1/webhooks`.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def list(self) -> List[Webhook]:
|
|
245
|
+
payload = self._t.request("GET", "/webhooks")
|
|
246
|
+
items = payload.get("data") if isinstance(payload, dict) else payload
|
|
247
|
+
return [Webhook.from_api(row) for row in (items or [])]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Client:
|
|
251
|
+
"""Top-level Vilvik client.
|
|
252
|
+
|
|
253
|
+
Parameters
|
|
254
|
+
----------
|
|
255
|
+
api_key:
|
|
256
|
+
Required. Pass explicitly or set the `VILVIK_API_KEY` env var.
|
|
257
|
+
base_url:
|
|
258
|
+
Override for self-hosted or staging deployments.
|
|
259
|
+
timeout:
|
|
260
|
+
Per-request timeout in seconds.
|
|
261
|
+
session:
|
|
262
|
+
Pre-built `requests.Session` (useful for connection pooling or
|
|
263
|
+
custom adapters / mounts during testing).
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def __init__(
|
|
267
|
+
self,
|
|
268
|
+
api_key: Optional[str] = None,
|
|
269
|
+
*,
|
|
270
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
271
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
272
|
+
session: Any = None,
|
|
273
|
+
max_retries: int = 2,
|
|
274
|
+
):
|
|
275
|
+
resolved_key = api_key or os.environ.get("VILVIK_API_KEY", "")
|
|
276
|
+
self._transport = Transport(
|
|
277
|
+
api_key=resolved_key,
|
|
278
|
+
base_url=base_url,
|
|
279
|
+
timeout=timeout,
|
|
280
|
+
session=session,
|
|
281
|
+
max_retries=max_retries,
|
|
282
|
+
)
|
|
283
|
+
self.submissions = Submissions(self._transport)
|
|
284
|
+
self.results = Results(self._transport)
|
|
285
|
+
self.code_uploads = CodeUploads(self._transport)
|
|
286
|
+
self.webhooks = Webhooks(self._transport)
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def base_url(self) -> str:
|
|
290
|
+
return self._transport.base_url
|
|
291
|
+
|
|
292
|
+
def health(self) -> Dict[str, Any]:
|
|
293
|
+
"""GET /health — liveness probe; returns the raw envelope."""
|
|
294
|
+
return self._transport.request("GET", "/health")
|
vilvik/exceptions.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Typed exception hierarchy for the Vilvik SDK.
|
|
2
|
+
|
|
3
|
+
All SDK errors derive from `VilvikError` so applications can `except
|
|
4
|
+
VilvikError` to catch every SDK failure in one place. Specific subclasses
|
|
5
|
+
let callers branch on the kind of failure (auth, validation, rate limit,
|
|
6
|
+
etc.) without inspecting status codes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VilvikError(Exception):
|
|
15
|
+
"""Base class for every SDK-raised error."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TimeoutError(VilvikError):
|
|
19
|
+
"""A polling helper (e.g. `Results.wait_for`) exceeded its deadline."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIError(VilvikError):
|
|
23
|
+
"""A non-2xx HTTP response from the Vilvik API.
|
|
24
|
+
|
|
25
|
+
Attributes
|
|
26
|
+
----------
|
|
27
|
+
status_code:
|
|
28
|
+
HTTP status returned by the server.
|
|
29
|
+
code:
|
|
30
|
+
Stable machine-readable error code (e.g. "validation_failed").
|
|
31
|
+
Empty string if the server did not return one.
|
|
32
|
+
message:
|
|
33
|
+
Human-readable summary.
|
|
34
|
+
request_id:
|
|
35
|
+
The `X-Request-Id` echoed back by the API (or empty when missing).
|
|
36
|
+
Useful to quote in support requests.
|
|
37
|
+
payload:
|
|
38
|
+
The full decoded JSON body, when available, for debugging.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
status_code: int,
|
|
44
|
+
code: str = "",
|
|
45
|
+
message: str = "",
|
|
46
|
+
request_id: str = "",
|
|
47
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
48
|
+
):
|
|
49
|
+
self.status_code = status_code
|
|
50
|
+
self.code = code
|
|
51
|
+
self.message = message
|
|
52
|
+
self.request_id = request_id
|
|
53
|
+
self.payload = payload or {}
|
|
54
|
+
detail = message or code or f"HTTP {status_code}"
|
|
55
|
+
if request_id:
|
|
56
|
+
detail = f"{detail} (request_id={request_id})"
|
|
57
|
+
super().__init__(detail)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthenticationError(APIError):
|
|
61
|
+
"""401 / 403 — invalid, revoked, expired, or under-scoped API key."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotFoundError(APIError):
|
|
65
|
+
"""404 — the resource does not exist or is not visible to this key."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ValidationError(APIError):
|
|
69
|
+
"""400 / 422 — the request body failed server-side validation."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RateLimitError(APIError):
|
|
73
|
+
"""429 — too many requests for this key's rate limit window.
|
|
74
|
+
|
|
75
|
+
`retry_after` is seconds reported by the server's Retry-After header,
|
|
76
|
+
or `None` when the server didn't advise one.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, *args, retry_after: Optional[int] = None, **kwargs):
|
|
80
|
+
self.retry_after = retry_after
|
|
81
|
+
super().__init__(*args, **kwargs)
|
vilvik/models.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Typed dataclasses for the public surface of the Vilvik API.
|
|
2
|
+
|
|
3
|
+
Each `from_api()` classmethod is lenient on missing keys so a server-side
|
|
4
|
+
addition does not break older SDK builds. New keys not yet modelled here
|
|
5
|
+
are preserved on `raw` for forward compatibility.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_dt(value: Any) -> Optional[datetime]:
|
|
16
|
+
"""Parse an ISO-8601 timestamp; return None when missing or malformed."""
|
|
17
|
+
if not value:
|
|
18
|
+
return None
|
|
19
|
+
if isinstance(value, datetime):
|
|
20
|
+
return value
|
|
21
|
+
try:
|
|
22
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Submission:
|
|
29
|
+
"""A queued, running, or completed GA run."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
status: str
|
|
33
|
+
status_url: str = ""
|
|
34
|
+
result_url: str = ""
|
|
35
|
+
name: Optional[str] = None
|
|
36
|
+
description: Optional[str] = None
|
|
37
|
+
submission_type: Optional[str] = None
|
|
38
|
+
created_at: Optional[datetime] = None
|
|
39
|
+
request_id: Optional[str] = None
|
|
40
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_terminal(self) -> bool:
|
|
44
|
+
"""True once the server-side run has reached a final state.
|
|
45
|
+
|
|
46
|
+
Mirrors the public status mapping in `apiapp.v1.status_mapping`:
|
|
47
|
+
`succeeded`, `failed`, and `cancelled` are terminal; `queued` and
|
|
48
|
+
`running` are not.
|
|
49
|
+
"""
|
|
50
|
+
return self.status in {"succeeded", "failed", "cancelled"}
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_api(cls, data: Dict[str, Any]) -> "Submission":
|
|
54
|
+
return cls(
|
|
55
|
+
id=str(data.get("id", "")),
|
|
56
|
+
status=str(data.get("status", "")),
|
|
57
|
+
status_url=str(data.get("status_url", "") or ""),
|
|
58
|
+
result_url=str(data.get("result_url", "") or ""),
|
|
59
|
+
name=data.get("name"),
|
|
60
|
+
description=data.get("description"),
|
|
61
|
+
submission_type=data.get("submission_type"),
|
|
62
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
63
|
+
request_id=data.get("request_id"),
|
|
64
|
+
raw=dict(data),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Result:
|
|
70
|
+
"""The outcome row associated with a finished submission."""
|
|
71
|
+
|
|
72
|
+
id: str
|
|
73
|
+
submission_id: str
|
|
74
|
+
best_fitness: Optional[float] = None
|
|
75
|
+
best_solution: Optional[List[Any]] = None
|
|
76
|
+
num_generations_ran: Optional[int] = None
|
|
77
|
+
stopped_reason: Optional[str] = None
|
|
78
|
+
created_at: Optional[datetime] = None
|
|
79
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_api(cls, data: Dict[str, Any]) -> "Result":
|
|
83
|
+
return cls(
|
|
84
|
+
id=str(data.get("id", "")),
|
|
85
|
+
submission_id=str(data.get("submission_id", "")),
|
|
86
|
+
best_fitness=data.get("best_fitness"),
|
|
87
|
+
best_solution=data.get("best_solution"),
|
|
88
|
+
num_generations_ran=data.get("num_generations_ran"),
|
|
89
|
+
stopped_reason=data.get("stopped_reason"),
|
|
90
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
91
|
+
raw=dict(data),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class CodeUpload:
|
|
97
|
+
"""A reusable code blob that submissions can reference by id.
|
|
98
|
+
|
|
99
|
+
The model attribute is named `field_name` rather than `field` to
|
|
100
|
+
avoid shadowing `dataclasses.field` inside the class body.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
id: str
|
|
104
|
+
field_name: str = ""
|
|
105
|
+
size_bytes: Optional[int] = None
|
|
106
|
+
created_at: Optional[datetime] = None
|
|
107
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_api(cls, data: Dict[str, Any]) -> "CodeUpload":
|
|
111
|
+
return cls(
|
|
112
|
+
id=str(data.get("id", "")),
|
|
113
|
+
field_name=str(data.get("field", "") or ""),
|
|
114
|
+
size_bytes=data.get("size_bytes"),
|
|
115
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
116
|
+
raw=dict(data),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class Webhook:
|
|
122
|
+
"""A user-configured webhook endpoint."""
|
|
123
|
+
|
|
124
|
+
id: str
|
|
125
|
+
url: str = ""
|
|
126
|
+
event_types: List[str] = field(default_factory=list)
|
|
127
|
+
is_active: bool = True
|
|
128
|
+
created_at: Optional[datetime] = None
|
|
129
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_api(cls, data: Dict[str, Any]) -> "Webhook":
|
|
133
|
+
return cls(
|
|
134
|
+
id=str(data.get("id", "")),
|
|
135
|
+
url=str(data.get("url", "") or ""),
|
|
136
|
+
event_types=list(data.get("event_types") or []),
|
|
137
|
+
is_active=bool(data.get("is_active", True)),
|
|
138
|
+
created_at=_parse_dt(data.get("created_at")),
|
|
139
|
+
raw=dict(data),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Page:
|
|
145
|
+
"""One page of a cursor-paginated list response."""
|
|
146
|
+
|
|
147
|
+
items: List[Any]
|
|
148
|
+
next_cursor: Optional[str] = None
|
|
149
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
150
|
+
|
|
151
|
+
def __iter__(self):
|
|
152
|
+
return iter(self.items)
|
|
153
|
+
|
|
154
|
+
def __len__(self):
|
|
155
|
+
return len(self.items)
|
vilvik/run.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Context-managed one-shot helper: `vilvik.run(...)`.
|
|
2
|
+
|
|
3
|
+
Lets a script submit a run and block until it finishes in a single
|
|
4
|
+
expression — the workflow most users actually want in a Jupyter cell:
|
|
5
|
+
|
|
6
|
+
with vilvik.run(fitness_func=fn, num_genes=5) as result:
|
|
7
|
+
print(result.best_fitness)
|
|
8
|
+
|
|
9
|
+
The context manager handles client construction, submission, and the
|
|
10
|
+
polling loop. On exit (success or exception) it tries to cancel the run
|
|
11
|
+
if it has not yet reached a terminal state, so a Ctrl-C in a notebook
|
|
12
|
+
does not leave a hot run on the server.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from typing import Any, Iterator, Optional
|
|
19
|
+
|
|
20
|
+
from vilvik.client import Client
|
|
21
|
+
from vilvik.exceptions import APIError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def run(
|
|
26
|
+
*,
|
|
27
|
+
api_key: Optional[str] = None,
|
|
28
|
+
base_url: Optional[str] = None,
|
|
29
|
+
timeout: float = 600.0,
|
|
30
|
+
poll_interval: float = 3.0,
|
|
31
|
+
**submission_kwargs: Any,
|
|
32
|
+
) -> Iterator[Any]:
|
|
33
|
+
"""Submit a run, block on completion, and yield the resulting `Result`.
|
|
34
|
+
|
|
35
|
+
Any keyword not consumed by the context manager itself is forwarded
|
|
36
|
+
to `Client.submissions.create(...)` — so `fitness_func`, `num_genes`,
|
|
37
|
+
`num_generations`, `sol_per_pop`, plus any extra PyGAD params all
|
|
38
|
+
work here directly.
|
|
39
|
+
"""
|
|
40
|
+
client_kwargs: dict = {}
|
|
41
|
+
if api_key is not None:
|
|
42
|
+
client_kwargs["api_key"] = api_key
|
|
43
|
+
if base_url is not None:
|
|
44
|
+
client_kwargs["base_url"] = base_url
|
|
45
|
+
client = Client(**client_kwargs)
|
|
46
|
+
|
|
47
|
+
submission = client.submissions.create(**submission_kwargs)
|
|
48
|
+
try:
|
|
49
|
+
result = client.results.wait_for(
|
|
50
|
+
submission.id,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
poll_interval=poll_interval,
|
|
53
|
+
)
|
|
54
|
+
yield result
|
|
55
|
+
finally:
|
|
56
|
+
# Best-effort cancel if the user exited the block early (Ctrl-C,
|
|
57
|
+
# exception, etc.) before the run had a chance to finish. The
|
|
58
|
+
# API does not currently expose a public cancel endpoint, so we
|
|
59
|
+
# try DELETE — failures are silently swallowed because the
|
|
60
|
+
# submission may already be terminal.
|
|
61
|
+
try:
|
|
62
|
+
latest = client.submissions.get(submission.id)
|
|
63
|
+
if not latest.is_terminal:
|
|
64
|
+
client.submissions.delete(submission.id)
|
|
65
|
+
except APIError:
|
|
66
|
+
pass
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vilvik
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Vilvik genetic-algorithm cloud API.
|
|
5
|
+
Author-email: Vilvik <ahmed.f.gad@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://vilvik.com
|
|
8
|
+
Project-URL: Documentation, https://vilvik.com/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/ahmedfgad/vilvik
|
|
10
|
+
Project-URL: Issues, https://github.com/ahmedfgad/vilvik/issues
|
|
11
|
+
Keywords: genetic-algorithm,optimization,pygad,vilvik
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: requests>=2.28
|
|
26
|
+
Provides-Extra: async
|
|
27
|
+
Requires-Dist: httpx>=0.24; extra == "async"
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
31
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
32
|
+
|
|
33
|
+
# vilvik
|
|
34
|
+
|
|
35
|
+
Official Python SDK for [Vilvik](https://vilvik.com) — a cloud platform that
|
|
36
|
+
runs Genetic Algorithm (PyGAD) workloads with a REST API, scoped keys, and
|
|
37
|
+
webhook delivery.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install vilvik
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import vilvik
|
|
47
|
+
|
|
48
|
+
client = vilvik.Client(api_key="vlk_live_…")
|
|
49
|
+
|
|
50
|
+
submission = client.submissions.create(
|
|
51
|
+
fitness_func="""
|
|
52
|
+
def fitness_func(ga_instance, solution, idx):
|
|
53
|
+
return -sum(s * s for s in solution)
|
|
54
|
+
""",
|
|
55
|
+
num_genes=5,
|
|
56
|
+
num_generations=100,
|
|
57
|
+
sol_per_pop=50,
|
|
58
|
+
name="quadratic-minimisation",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
print(submission.id, submission.status) # "abc123…", "queued"
|
|
62
|
+
|
|
63
|
+
result = client.results.wait_for(submission.id, timeout=300)
|
|
64
|
+
print(result.best_fitness, result.best_solution)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## The `run()` one-liner
|
|
68
|
+
|
|
69
|
+
For scripts and notebook cells, `vilvik.run(...)` packages create-and-wait
|
|
70
|
+
into a context manager that also cancels the run if you exit the block
|
|
71
|
+
early:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import vilvik
|
|
75
|
+
|
|
76
|
+
fn = """
|
|
77
|
+
def fitness_func(ga_instance, solution, idx):
|
|
78
|
+
return -sum(s * s for s in solution)
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
with vilvik.run(fitness_func=fn, num_genes=5, num_generations=50) as result:
|
|
82
|
+
print(result.best_fitness)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The API key is read from `VILVIK_API_KEY` if you do not pass it
|
|
86
|
+
explicitly.
|
|
87
|
+
|
|
88
|
+
## Listing and pagination
|
|
89
|
+
|
|
90
|
+
The list endpoints return a `Page` whose items are typed dataclasses; for
|
|
91
|
+
walking everything use `iter_all`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
page = client.submissions.list(limit=25)
|
|
95
|
+
for sub in page:
|
|
96
|
+
print(sub.id, sub.status, sub.name)
|
|
97
|
+
|
|
98
|
+
# All submissions, transparently following the cursor:
|
|
99
|
+
for sub in client.submissions.iter_all():
|
|
100
|
+
...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Branching a finished run
|
|
104
|
+
|
|
105
|
+
`B7 — branching run-graph` is exposed via `Results.continue_run`. Each
|
|
106
|
+
call creates a child submission whose `parent_submission` is the result
|
|
107
|
+
you forked from. Pass any GA parameter you want to override:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
parent = client.results.get(result_id)
|
|
111
|
+
variant_a = client.results.continue_run(parent.id, mutation_probability=0.10)
|
|
112
|
+
variant_b = client.results.continue_run(parent.id, sol_per_pop=100)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The Vilvik dashboard renders the resulting lineage as an interactive tree
|
|
116
|
+
on the result page.
|
|
117
|
+
|
|
118
|
+
## Errors
|
|
119
|
+
|
|
120
|
+
Every SDK error inherits from `vilvik.VilvikError`. Subclasses let you
|
|
121
|
+
catch specific failure modes:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
try:
|
|
125
|
+
client.submissions.get("does-not-exist")
|
|
126
|
+
except vilvik.NotFoundError as e:
|
|
127
|
+
print("Not found:", e.request_id)
|
|
128
|
+
except vilvik.RateLimitError as e:
|
|
129
|
+
print("Slow down, retry after", e.retry_after, "seconds")
|
|
130
|
+
except vilvik.AuthenticationError:
|
|
131
|
+
print("Check your API key and scopes")
|
|
132
|
+
except vilvik.VilvikError:
|
|
133
|
+
print("Something else went wrong")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
| Argument | Default | Notes |
|
|
139
|
+
| --------------- | ---------------------------------- | -------------------------------------------------- |
|
|
140
|
+
| `api_key` | `os.environ["VILVIK_API_KEY"]` | Required. Bearer token created in the dashboard. |
|
|
141
|
+
| `base_url` | `https://vilvik.com/api/v1` | Override for staging or self-hosted instances. |
|
|
142
|
+
| `timeout` | `60.0` seconds | Per-HTTP-request timeout. |
|
|
143
|
+
| `max_retries` | `2` | Idempotent (GET / HEAD) retries on network errors. |
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pip install -e ".[dev]"
|
|
149
|
+
pytest
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
vilvik/__init__.py,sha256=RgCJZ9zGzpPNeHuTzl2FC33QUSYh4z-8LkWvJ5PmJYA,1370
|
|
2
|
+
vilvik/_http.py,sha256=ljLx2PcHQYZfqQndXoUnlov9zuaygFiysomnMDrGaoc,5340
|
|
3
|
+
vilvik/client.py,sha256=N8LypnqZAG4subfA4SeXE21tpdgCX92HkYZHnPAC5b8,10608
|
|
4
|
+
vilvik/exceptions.py,sha256=MEC8vtD_0Dgwn6jDMiUi5pOCWuPT8X9UiUyuqzFc7gE,2419
|
|
5
|
+
vilvik/models.py,sha256=yZ4k2ohVIKfiaClhZuVTaAqB2OdmOioDOvWtbvqXWIU,4804
|
|
6
|
+
vilvik/run.py,sha256=diWXW6SuWhqi6m3QVMsmysEnZRiQwIKL6t9Le8BNpOQ,2289
|
|
7
|
+
vilvik-0.1.0.dist-info/METADATA,sha256=sVPRVyc6ot4uyTxgFJxD3twbEx15wuF9LhTBdci6Bis,4708
|
|
8
|
+
vilvik-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
vilvik-0.1.0.dist-info/top_level.txt,sha256=W9FSlc5ZA_rc_jGCl6R4dV8S4-lyFs_VNCdWTJMzSdw,7
|
|
10
|
+
vilvik-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vilvik
|