kainguru-sdk 0.1.4.dev0__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.
kainguru/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """Kainguru Python SDK — run models and fine-tune them on the Kainguru ML platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._config import KainguruConfig
6
+ from ._errors import (
7
+ KainguruAPIError,
8
+ KainguruAuthError,
9
+ KainguruConfigError,
10
+ KainguruConnectionError,
11
+ KainguruError,
12
+ KainguruNotFoundError,
13
+ KainguruRateLimitError,
14
+ KainguruTimeoutError,
15
+ )
16
+ from .async_client import AsyncKainguruClient
17
+ from .client import KainguruClient
18
+ from .models import (
19
+ ApplicationDto,
20
+ ExecutionResultDto,
21
+ ModelExecResponseDto,
22
+ ModelFineTunedDetailDto,
23
+ ModelStatus,
24
+ )
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "KainguruClient",
31
+ "AsyncKainguruClient",
32
+ "KainguruConfig",
33
+ # Errors
34
+ "KainguruError",
35
+ "KainguruConfigError",
36
+ "KainguruAPIError",
37
+ "KainguruAuthError",
38
+ "KainguruNotFoundError",
39
+ "KainguruRateLimitError",
40
+ "KainguruTimeoutError",
41
+ "KainguruConnectionError",
42
+ # Models
43
+ "ModelStatus",
44
+ "ModelExecResponseDto",
45
+ "ModelFineTunedDetailDto",
46
+ "ApplicationDto",
47
+ "ExecutionResultDto",
48
+ ]
kainguru/_config.py ADDED
@@ -0,0 +1,63 @@
1
+ """Client configuration resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ._errors import KainguruConfigError
8
+
9
+ DEFAULT_TIMEOUT = 30.0
10
+ DEFAULT_MAX_RETRIES = 3
11
+ DEFAULT_RETRY_BASE_DELAY = 1.0
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class KainguruConfig:
16
+ api_key: str
17
+ base_url: str
18
+ timeout: float
19
+ max_retries: int
20
+ retry_base_delay: float
21
+
22
+ @classmethod
23
+ def resolve(
24
+ cls,
25
+ *,
26
+ api_key: str | None = None,
27
+ base_url: str | None = None,
28
+ timeout: float = DEFAULT_TIMEOUT,
29
+ max_retries: int = DEFAULT_MAX_RETRIES,
30
+ retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
31
+ ) -> KainguruConfig:
32
+ """Resolve configuration, failing fast when the caller omits a required field.
33
+
34
+ Both ``api_key`` and ``base_url`` are required and must be passed
35
+ explicitly. (A per-request ``api_key=`` kwarg may still override per call.)
36
+ """
37
+ if not api_key or not api_key.strip():
38
+ raise KainguruConfigError(
39
+ "Kainguru API key is required. Pass api_key=... to the client."
40
+ )
41
+ if not base_url or not base_url.strip():
42
+ raise KainguruConfigError(
43
+ "Kainguru base URL is required. Pass base_url=... (the full host "
44
+ "including the /api context path)."
45
+ )
46
+ if timeout <= 0:
47
+ raise KainguruConfigError("timeout must be > 0")
48
+ if max_retries < 0:
49
+ raise KainguruConfigError("max_retries must be >= 0")
50
+
51
+ return cls(
52
+ api_key=api_key.strip(),
53
+ # httpx resolves request paths against base_url using RFC 3986, so the
54
+ # base URL MUST end with "/" or the "/api" context path is dropped.
55
+ base_url=_ensure_trailing_slash(base_url.strip()),
56
+ timeout=timeout,
57
+ max_retries=max_retries,
58
+ retry_base_delay=retry_base_delay,
59
+ )
60
+
61
+
62
+ def _ensure_trailing_slash(url: str) -> str:
63
+ return url if url.endswith("/") else url + "/"
kainguru/_errors.py ADDED
@@ -0,0 +1,81 @@
1
+ """Exception hierarchy for the Kainguru SDK.
2
+
3
+ Every error extends :class:`KainguruError`. HTTP-derived errors expose the status
4
+ code, raw body, optional API code, and the underlying ``httpx.Response`` when
5
+ available. Specific subclasses for common statuses let callers narrow their
6
+ ``except`` clauses.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ class KainguruError(Exception):
15
+ """Base class for all SDK errors."""
16
+
17
+
18
+ class KainguruConfigError(KainguruError):
19
+ """Missing or invalid configuration (e.g. no API key, bad base URL)."""
20
+
21
+
22
+ class KainguruAPIError(KainguruError):
23
+ """Non-2xx HTTP response, or a 2xx body with ``success == False``."""
24
+
25
+ def __init__(
26
+ self,
27
+ message: str,
28
+ *,
29
+ status_code: int,
30
+ body: str = "",
31
+ api_code: int | None = None,
32
+ response: Any | None = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.status_code = status_code
36
+ self.body = body
37
+ self.api_code = api_code
38
+ self.response = response
39
+
40
+
41
+ class KainguruAuthError(KainguruAPIError):
42
+ """401 / 403 — authentication or authorization failure."""
43
+
44
+
45
+ class KainguruNotFoundError(KainguruAPIError):
46
+ """404 — resource not found."""
47
+
48
+
49
+ class KainguruRateLimitError(KainguruAPIError):
50
+ """429 — rate limited. ``retry_after`` (seconds) is set when the header is present."""
51
+
52
+ def __init__(
53
+ self,
54
+ message: str,
55
+ *,
56
+ status_code: int,
57
+ body: str = "",
58
+ api_code: int | None = None,
59
+ response: Any | None = None,
60
+ retry_after: float | None = None,
61
+ ) -> None:
62
+ super().__init__(
63
+ message,
64
+ status_code=status_code,
65
+ body=body,
66
+ api_code=api_code,
67
+ response=response,
68
+ )
69
+ self.retry_after = retry_after
70
+
71
+
72
+ class KainguruTimeoutError(KainguruError):
73
+ """``await_completion`` exceeded its timeout. Carries the last DTO observed."""
74
+
75
+ def __init__(self, message: str, *, last_dto: Any | None = None) -> None:
76
+ super().__init__(message)
77
+ self.last_dto = last_dto
78
+
79
+
80
+ class KainguruConnectionError(KainguruError):
81
+ """Network / transport failure. The underlying error is chained via ``from``."""
kainguru/_polling.py ADDED
@@ -0,0 +1,98 @@
1
+ """Polling engines for ``await_completion`` — sync and async."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import TypeVar
9
+
10
+ from ._errors import KainguruTimeoutError
11
+
12
+ T = TypeVar("T")
13
+
14
+ DEFAULT_POLL_INTERVAL = 2.0
15
+ DEFAULT_TIMEOUT = 300.0
16
+ DEFAULT_BACKOFF = 1.0
17
+ DEFAULT_MAX_INTERVAL = 30.0
18
+
19
+
20
+ def _validate(poll_interval: float, timeout: float, backoff: float, max_interval: float) -> None:
21
+ if poll_interval <= 0:
22
+ raise ValueError("poll_interval must be > 0")
23
+ if timeout <= 0:
24
+ raise ValueError("timeout must be > 0")
25
+ if backoff < 1.0:
26
+ raise ValueError("backoff must be >= 1.0")
27
+ if max_interval <= 0:
28
+ raise ValueError("max_interval must be > 0")
29
+
30
+
31
+ def await_completion(
32
+ get_fn: Callable[[], T],
33
+ *,
34
+ is_terminal: Callable[[T], bool],
35
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
36
+ timeout: float = DEFAULT_TIMEOUT,
37
+ backoff: float = DEFAULT_BACKOFF,
38
+ max_interval: float = DEFAULT_MAX_INTERVAL,
39
+ _clock: Callable[[], float] = time.monotonic,
40
+ _sleep: Callable[[float], None] = time.sleep,
41
+ ) -> T:
42
+ """Poll ``get_fn`` until ``is_terminal`` is true, then return that value.
43
+
44
+ Raises :class:`KainguruTimeoutError` (carrying the last DTO) on timeout. A
45
+ ``FAILED`` status is terminal and therefore *returns* — inspect the result.
46
+ """
47
+ _validate(poll_interval, timeout, backoff, max_interval)
48
+ start = _clock()
49
+ deadline = start + timeout
50
+ interval = poll_interval
51
+ last: T | None = None
52
+
53
+ while True:
54
+ last = get_fn()
55
+ if is_terminal(last):
56
+ return last
57
+ now = _clock()
58
+ if now >= deadline:
59
+ raise KainguruTimeoutError(
60
+ f"Polling timed out after {timeout}s", last_dto=last
61
+ )
62
+ sleep_for = min(interval, max_interval, deadline - now)
63
+ if sleep_for > 0:
64
+ _sleep(sleep_for)
65
+ interval = min(interval * backoff, max_interval)
66
+
67
+
68
+ async def await_completion_async(
69
+ get_fn: Callable[[], Awaitable[T]],
70
+ *,
71
+ is_terminal: Callable[[T], bool],
72
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
73
+ timeout: float = DEFAULT_TIMEOUT,
74
+ backoff: float = DEFAULT_BACKOFF,
75
+ max_interval: float = DEFAULT_MAX_INTERVAL,
76
+ _clock: Callable[[], float] = time.monotonic,
77
+ _sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
78
+ ) -> T:
79
+ """Async mirror of :func:`await_completion` using non-blocking sleeps."""
80
+ _validate(poll_interval, timeout, backoff, max_interval)
81
+ start = _clock()
82
+ deadline = start + timeout
83
+ interval = poll_interval
84
+ last: T | None = None
85
+
86
+ while True:
87
+ last = await get_fn()
88
+ if is_terminal(last):
89
+ return last
90
+ now = _clock()
91
+ if now >= deadline:
92
+ raise KainguruTimeoutError(
93
+ f"Polling timed out after {timeout}s", last_dto=last
94
+ )
95
+ sleep_for = min(interval, max_interval, deadline - now)
96
+ if sleep_for > 0:
97
+ await _sleep(sleep_for)
98
+ interval = min(interval * backoff, max_interval)
kainguru/_transport.py ADDED
@@ -0,0 +1,206 @@
1
+ """HTTP transport: auth header injection, retry, error mapping, envelope unwrap.
2
+
3
+ Two thin wrappers — :class:`Transport` (sync) and :class:`AsyncTransport`
4
+ (async) — share the retry/error helpers below so the two clients behave
5
+ identically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import random
12
+ import time
13
+ from typing import Any, TypeVar
14
+
15
+ import httpx
16
+ from pydantic import BaseModel, ValidationError
17
+
18
+ from ._config import KainguruConfig
19
+ from ._errors import (
20
+ KainguruAPIError,
21
+ KainguruAuthError,
22
+ KainguruConnectionError,
23
+ KainguruNotFoundError,
24
+ KainguruRateLimitError,
25
+ )
26
+
27
+ API_KEY_HEADER = "X-API-Key"
28
+
29
+ ModelT = TypeVar("ModelT", bound=BaseModel)
30
+
31
+
32
+ def _headers(config: KainguruConfig, api_key: str | None) -> dict[str, str]:
33
+ headers = {"Accept": "application/json"}
34
+ key = api_key or config.api_key
35
+ if key:
36
+ headers[API_KEY_HEADER] = key
37
+ return headers
38
+
39
+
40
+ def _is_retriable_status(status_code: int) -> bool:
41
+ return status_code == 429 or 500 <= status_code <= 599
42
+
43
+
44
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
45
+ raw = response.headers.get("retry-after")
46
+ if not raw:
47
+ return None
48
+ try:
49
+ return max(0.0, float(int(raw.strip())))
50
+ except (ValueError, TypeError):
51
+ return None
52
+
53
+
54
+ def _retry_delay(attempt: int, response: httpx.Response | None, base_delay: float) -> float:
55
+ if response is not None:
56
+ retry_after = _retry_after_seconds(response)
57
+ if retry_after is not None:
58
+ return retry_after
59
+ exp = base_delay * (2**attempt)
60
+ jitter = random.uniform(0, max(0.001, exp / 2))
61
+ return float(exp + jitter)
62
+
63
+
64
+ def _map_http_error(response: httpx.Response) -> KainguruAPIError:
65
+ status = response.status_code
66
+ body = response.text
67
+ message = f"Kainguru API error: HTTP {status}"
68
+ if status in (401, 403):
69
+ return KainguruAuthError(message, status_code=status, body=body, response=response)
70
+ if status == 404:
71
+ return KainguruNotFoundError(message, status_code=status, body=body, response=response)
72
+ if status == 429:
73
+ return KainguruRateLimitError(
74
+ message,
75
+ status_code=status,
76
+ body=body,
77
+ response=response,
78
+ retry_after=_retry_after_seconds(response),
79
+ )
80
+ return KainguruAPIError(message, status_code=status, body=body, response=response)
81
+
82
+
83
+ def unwrap(response: httpx.Response, model_cls: type[ModelT]) -> ModelT:
84
+ """Unwrap a ``CommonApiResponse`` envelope into ``model_cls``.
85
+
86
+ Raises :class:`KainguruAPIError` on non-2xx, a malformed body, or
87
+ ``success == False``.
88
+ """
89
+ if not response.is_success:
90
+ raise _map_http_error(response)
91
+ try:
92
+ body = response.json()
93
+ except ValueError as exc:
94
+ raise KainguruAPIError(
95
+ "Malformed JSON in Kainguru API response",
96
+ status_code=response.status_code,
97
+ body=response.text,
98
+ response=response,
99
+ ) from exc
100
+ if not isinstance(body, dict) or "success" not in body:
101
+ raise KainguruAPIError(
102
+ "Unexpected response body (missing CommonApiResponse envelope)",
103
+ status_code=response.status_code,
104
+ body=response.text,
105
+ response=response,
106
+ )
107
+ if not body.get("success"):
108
+ raise KainguruAPIError(
109
+ f"Kainguru API returned success=false (code={body.get('code')})",
110
+ status_code=response.status_code,
111
+ body=response.text,
112
+ api_code=body.get("code"),
113
+ response=response,
114
+ )
115
+ try:
116
+ return model_cls.model_validate(body.get("data"))
117
+ except ValidationError as exc:
118
+ raise KainguruAPIError(
119
+ f"Failed to parse Kainguru API response into {model_cls.__name__}",
120
+ status_code=response.status_code,
121
+ body=response.text,
122
+ response=response,
123
+ ) from exc
124
+
125
+
126
+ class Transport:
127
+ """Synchronous transport wrapping an ``httpx.Client``."""
128
+
129
+ def __init__(self, client: httpx.Client, config: KainguruConfig) -> None:
130
+ self._client = client
131
+ self._config = config
132
+
133
+ def request(
134
+ self,
135
+ method: str,
136
+ url: str,
137
+ *,
138
+ params: dict[str, Any] | None = None,
139
+ json: dict[str, Any] | None = None,
140
+ api_key: str | None = None,
141
+ ) -> httpx.Response:
142
+ headers = _headers(self._config, api_key)
143
+ last_exc: Exception | None = None
144
+ for attempt in range(self._config.max_retries + 1):
145
+ try:
146
+ response = self._client.request(
147
+ method, url, params=params, json=json, headers=headers
148
+ )
149
+ except httpx.RequestError as exc:
150
+ last_exc = exc
151
+ if attempt >= self._config.max_retries:
152
+ raise KainguruConnectionError(
153
+ f"Network error while calling the Kainguru API: {exc}"
154
+ ) from exc
155
+ time.sleep(_retry_delay(attempt, None, self._config.retry_base_delay))
156
+ continue
157
+ if _is_retriable_status(response.status_code) and attempt < self._config.max_retries:
158
+ time.sleep(_retry_delay(attempt, response, self._config.retry_base_delay))
159
+ continue
160
+ return response
161
+ # Unreachable, but keeps type checkers happy.
162
+ raise KainguruConnectionError("Retry loop exhausted") from last_exc
163
+
164
+ def close(self) -> None:
165
+ self._client.close()
166
+
167
+
168
+ class AsyncTransport:
169
+ """Asynchronous transport wrapping an ``httpx.AsyncClient``."""
170
+
171
+ def __init__(self, client: httpx.AsyncClient, config: KainguruConfig) -> None:
172
+ self._client = client
173
+ self._config = config
174
+
175
+ async def request(
176
+ self,
177
+ method: str,
178
+ url: str,
179
+ *,
180
+ params: dict[str, Any] | None = None,
181
+ json: dict[str, Any] | None = None,
182
+ api_key: str | None = None,
183
+ ) -> httpx.Response:
184
+ headers = _headers(self._config, api_key)
185
+ last_exc: Exception | None = None
186
+ for attempt in range(self._config.max_retries + 1):
187
+ try:
188
+ response = await self._client.request(
189
+ method, url, params=params, json=json, headers=headers
190
+ )
191
+ except httpx.RequestError as exc:
192
+ last_exc = exc
193
+ if attempt >= self._config.max_retries:
194
+ raise KainguruConnectionError(
195
+ f"Network error while calling the Kainguru API: {exc}"
196
+ ) from exc
197
+ await asyncio.sleep(_retry_delay(attempt, None, self._config.retry_base_delay))
198
+ continue
199
+ if _is_retriable_status(response.status_code) and attempt < self._config.max_retries:
200
+ await asyncio.sleep(_retry_delay(attempt, response, self._config.retry_base_delay))
201
+ continue
202
+ return response
203
+ raise KainguruConnectionError("Retry loop exhausted") from last_exc
204
+
205
+ async def aclose(self) -> None:
206
+ await self._client.aclose()
@@ -0,0 +1,74 @@
1
+ """Asynchronous client facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+
7
+ import httpx
8
+
9
+ from ._config import (
10
+ DEFAULT_MAX_RETRIES,
11
+ DEFAULT_RETRY_BASE_DELAY,
12
+ DEFAULT_TIMEOUT,
13
+ KainguruConfig,
14
+ )
15
+ from ._transport import AsyncTransport
16
+ from .resources import AsyncExecutionsResource, AsyncFineTuningResource
17
+
18
+
19
+ class AsyncKainguruClient:
20
+ """Entry point for the Kainguru SDK (async).
21
+
22
+ Example::
23
+
24
+ async with AsyncKainguruClient(
25
+ api_key="kg_...", base_url="https://your-host/api"
26
+ ) as client:
27
+ submitted = await client.executions.execute(
28
+ "my-mlflow-id", {"prompt": "hello world"}, "json"
29
+ )
30
+ done = await client.executions.await_completion(submitted.id)
31
+ print(done.status)
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: str,
37
+ *,
38
+ base_url: str,
39
+ timeout: float = DEFAULT_TIMEOUT,
40
+ max_retries: int = DEFAULT_MAX_RETRIES,
41
+ retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
42
+ http_client: httpx.AsyncClient | None = None,
43
+ ) -> None:
44
+ self._config = KainguruConfig.resolve(
45
+ api_key=api_key,
46
+ base_url=base_url,
47
+ timeout=timeout,
48
+ max_retries=max_retries,
49
+ retry_base_delay=retry_base_delay,
50
+ )
51
+ client = http_client or httpx.AsyncClient(
52
+ base_url=self._config.base_url, timeout=self._config.timeout
53
+ )
54
+ self._transport = AsyncTransport(client, self._config)
55
+ self.executions = AsyncExecutionsResource(self._transport)
56
+ self.fine_tuning = AsyncFineTuningResource(self._transport)
57
+
58
+ @property
59
+ def config(self) -> KainguruConfig:
60
+ return self._config
61
+
62
+ async def aclose(self) -> None:
63
+ await self._transport.aclose()
64
+
65
+ async def __aenter__(self) -> AsyncKainguruClient:
66
+ return self
67
+
68
+ async def __aexit__(
69
+ self,
70
+ exc_type: type[BaseException] | None,
71
+ exc: BaseException | None,
72
+ tb: TracebackType | None,
73
+ ) -> None:
74
+ await self.aclose()
kainguru/client.py ADDED
@@ -0,0 +1,74 @@
1
+ """Synchronous client facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+
7
+ import httpx
8
+
9
+ from ._config import (
10
+ DEFAULT_MAX_RETRIES,
11
+ DEFAULT_RETRY_BASE_DELAY,
12
+ DEFAULT_TIMEOUT,
13
+ KainguruConfig,
14
+ )
15
+ from ._transport import Transport
16
+ from .resources import ExecutionsResource, FineTuningResource
17
+
18
+
19
+ class KainguruClient:
20
+ """Entry point for the Kainguru SDK (sync).
21
+
22
+ Example::
23
+
24
+ with KainguruClient(
25
+ api_key="kg_...", base_url="https://your-host/api"
26
+ ) as client:
27
+ submitted = client.executions.execute(
28
+ "my-mlflow-id", {"prompt": "hello world"}, "json"
29
+ )
30
+ done = client.executions.await_completion(submitted.id)
31
+ print(done.status) # ModelStatus.COMPLETED / FAILED
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: str,
37
+ *,
38
+ base_url: str,
39
+ timeout: float = DEFAULT_TIMEOUT,
40
+ max_retries: int = DEFAULT_MAX_RETRIES,
41
+ retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
42
+ http_client: httpx.Client | None = None,
43
+ ) -> None:
44
+ self._config = KainguruConfig.resolve(
45
+ api_key=api_key,
46
+ base_url=base_url,
47
+ timeout=timeout,
48
+ max_retries=max_retries,
49
+ retry_base_delay=retry_base_delay,
50
+ )
51
+ client = http_client or httpx.Client(
52
+ base_url=self._config.base_url, timeout=self._config.timeout
53
+ )
54
+ self._transport = Transport(client, self._config)
55
+ self.executions = ExecutionsResource(self._transport)
56
+ self.fine_tuning = FineTuningResource(self._transport)
57
+
58
+ @property
59
+ def config(self) -> KainguruConfig:
60
+ return self._config
61
+
62
+ def close(self) -> None:
63
+ self._transport.close()
64
+
65
+ def __enter__(self) -> KainguruClient:
66
+ return self
67
+
68
+ def __exit__(
69
+ self,
70
+ exc_type: type[BaseException] | None,
71
+ exc: BaseException | None,
72
+ tb: TracebackType | None,
73
+ ) -> None:
74
+ self.close()