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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ vilvik