qbrix 0.1.3__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.
qbrix/__init__.py ADDED
@@ -0,0 +1,91 @@
1
+ from qbrix._client import AsyncQbrix
2
+ from qbrix._client import Qbrix
3
+ from qbrix._config import QbrixConfig
4
+ from qbrix._mod_client import _load_client
5
+ from qbrix._mod_client import _reset_client
6
+ from qbrix._proxies import AgentProxy
7
+ from qbrix._proxies import ExperimentProxy
8
+ from qbrix._proxies import GateProxy
9
+ from qbrix._proxies import PoolProxy
10
+ from qbrix._version import __version__
11
+ from qbrix.resource.agent import AgentResource
12
+ from qbrix.resource.experiment import ExperimentResource
13
+ from qbrix.resource.gate import GateResource
14
+ from qbrix.resource.pool import PoolResource
15
+ from qbrix.exception import AuthenticationError
16
+ from qbrix.exception import BadRequestError
17
+ from qbrix.exception import ConflictError
18
+ from qbrix.exception import ForbiddenError
19
+ from qbrix.exception import InternalServerError
20
+ from qbrix.exception import NotFoundError
21
+ from qbrix.exception import QbrixAPIError
22
+ from qbrix.exception import QbrixConnectionError
23
+ from qbrix.exception import QbrixError
24
+ from qbrix.exception import QbrixTimeoutError
25
+ from qbrix.exception import RateLimitedError
26
+ from qbrix.exception import ServiceUnavailableError
27
+ from qbrix.model import Arm
28
+ from qbrix.model import ArmCreate
29
+ from qbrix.model import Context
30
+ from qbrix.model import Experiment
31
+ from qbrix.model import ExperimentCreate
32
+ from qbrix.model import ExperimentUpdate
33
+ from qbrix.model import GateConfig
34
+ from qbrix.model import GateCreate
35
+ from qbrix.model import GateRule
36
+ from qbrix.model import PaginatedResponse
37
+ from qbrix.model import Pool
38
+ from qbrix.model import PoolCreate
39
+ from qbrix.model import PoolUpdate
40
+ from qbrix.model import SelectedArm
41
+ from qbrix.model import SelectResponse
42
+
43
+ # module-level resource proxies — no explicit client instantiation required.
44
+ # reads QBRIX_API_KEY and QBRIX_BASE_URL from environment on first use.
45
+ pool: PoolResource = PoolProxy().__as_proxied__() # type: ignore[assignment]
46
+ experiment: ExperimentResource = ExperimentProxy().__as_proxied__() # type: ignore[assignment]
47
+ gate: GateResource = GateProxy().__as_proxied__() # type: ignore[assignment]
48
+ agent: AgentResource = AgentProxy().__as_proxied__() # type: ignore[assignment]
49
+
50
+ __all__ = [
51
+ "__version__",
52
+ "AsyncQbrix",
53
+ "Qbrix",
54
+ "QbrixConfig",
55
+ # module-level proxies
56
+ "pool",
57
+ "experiment",
58
+ "gate",
59
+ "agent",
60
+ "_load_client",
61
+ "_reset_client",
62
+ # exceptions
63
+ "AuthenticationError",
64
+ "BadRequestError",
65
+ "ConflictError",
66
+ "ForbiddenError",
67
+ "InternalServerError",
68
+ "NotFoundError",
69
+ "QbrixAPIError",
70
+ "QbrixConnectionError",
71
+ "QbrixError",
72
+ "QbrixTimeoutError",
73
+ "RateLimitedError",
74
+ "ServiceUnavailableError",
75
+ # models
76
+ "Arm",
77
+ "ArmCreate",
78
+ "Context",
79
+ "Experiment",
80
+ "ExperimentCreate",
81
+ "ExperimentUpdate",
82
+ "GateConfig",
83
+ "GateCreate",
84
+ "GateRule",
85
+ "PaginatedResponse",
86
+ "Pool",
87
+ "PoolCreate",
88
+ "PoolUpdate",
89
+ "SelectedArm",
90
+ "SelectResponse",
91
+ ]
qbrix/_base_client.py ADDED
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+ from typing import Any
7
+ from typing import TypeVar
8
+
9
+ import httpx
10
+ from pydantic import BaseModel
11
+
12
+ from qbrix._config import QbrixConfig
13
+ from qbrix.exception import QbrixAPIError
14
+ from qbrix.exception import QbrixConnectionError
15
+ from qbrix.exception import QbrixTimeoutError
16
+ from qbrix.exception import RateLimitedError
17
+ from qbrix.exception import STATUS_CODE_TO_EXCEPTION
18
+
19
+ _T = TypeVar("_T", bound=BaseModel)
20
+
21
+
22
+ class BaseClient:
23
+ _config: QbrixConfig
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ api_key: str | None = None,
29
+ base_url: str | None = None,
30
+ timeout: float | None = None,
31
+ max_retries: int | None = None,
32
+ **kwargs: Any,
33
+ ) -> None:
34
+ overrides: dict[str, Any] = {}
35
+ if api_key is not None:
36
+ overrides["api_key"] = api_key
37
+ if base_url is not None:
38
+ overrides["base_url"] = base_url
39
+ if timeout is not None:
40
+ overrides["timeout"] = timeout
41
+ if max_retries is not None:
42
+ overrides["max_retries"] = max_retries
43
+ overrides.update(kwargs)
44
+ self._config = QbrixConfig(**overrides)
45
+
46
+ def _build_headers(self) -> dict[str, str]:
47
+ headers: dict[str, str] = {"Accept": "application/json"}
48
+ if self._config.api_key:
49
+ headers["X-API-Key"] = self._config.api_key
50
+ return headers
51
+
52
+ @staticmethod
53
+ def _make_status_error(response: httpx.Response) -> QbrixAPIError:
54
+ detail = ""
55
+ context = None
56
+ try:
57
+ body = response.json()
58
+ detail = body.get("detail", response.text)
59
+ context = body.get("context")
60
+ except Exception: # noqa
61
+ detail = response.text
62
+
63
+ status = response.status_code
64
+ exc_cls = STATUS_CODE_TO_EXCEPTION.get(status, QbrixAPIError)
65
+
66
+ if exc_cls is RateLimitedError:
67
+ retry_after_raw = response.headers.get("Retry-After")
68
+ retry_after = float(retry_after_raw) if retry_after_raw else None
69
+ return RateLimitedError(status, detail, context, retry_after)
70
+
71
+ return exc_cls(status, detail, context)
72
+
73
+ def _should_retry(self, response: httpx.Response) -> bool:
74
+ return response.status_code in self._config.retry_on
75
+
76
+ def _calculate_retry_delay(self, attempt: int) -> float:
77
+ delay = self._config.retry_base_delay * (2**attempt)
78
+ delay = min(delay, self._config.retry_max_delay)
79
+ return delay + random.uniform(0, delay * 0.1)
80
+
81
+
82
+ class SyncAPIClient(BaseClient):
83
+ _client: httpx.Client
84
+
85
+ def __init__(self, **kwargs: Any) -> None:
86
+ super().__init__(**kwargs)
87
+ self._client = httpx.Client(
88
+ base_url=self._config.base_url,
89
+ headers=self._build_headers(),
90
+ timeout=self._config.timeout,
91
+ )
92
+
93
+ def request(
94
+ self,
95
+ method: str,
96
+ path: str,
97
+ *,
98
+ body: dict[str, Any] | None = None,
99
+ params: dict[str, Any] | None = None,
100
+ cast_to: type[_T] | None = None,
101
+ ) -> _T | dict[str, Any]:
102
+ last_exc: Exception | None = None
103
+
104
+ for attempt in range(self._config.max_retries + 1):
105
+ try:
106
+ response = self._client.request(
107
+ method, path, json=body, params=params
108
+ )
109
+ except httpx.ConnectError as exc:
110
+ raise QbrixConnectionError(str(exc)) from exc
111
+ except httpx.TimeoutException as exc:
112
+ raise QbrixTimeoutError(str(exc)) from exc
113
+
114
+ if response.is_success:
115
+ if response.status_code == 204 or not response.content:
116
+ return cast_to.model_validate({}) if cast_to else {}
117
+ data = response.json()
118
+ return cast_to.model_validate(data) if cast_to else data
119
+
120
+ if not self._should_retry(response):
121
+ raise self._make_status_error(response)
122
+
123
+ last_exc = self._make_status_error(response)
124
+
125
+ if attempt < self._config.max_retries:
126
+ time.sleep(self._calculate_retry_delay(attempt))
127
+
128
+ if last_exc:
129
+ raise last_exc
130
+
131
+ return {}
132
+
133
+ def get(
134
+ self,
135
+ path: str,
136
+ *,
137
+ cast_to: type[_T] | None = None,
138
+ params: dict[str, Any] | None = None,
139
+ ) -> _T | dict[str, Any]:
140
+ return self.request("GET", path, cast_to=cast_to, params=params)
141
+
142
+ def post(
143
+ self,
144
+ path: str,
145
+ *,
146
+ body: dict[str, Any] | None = None,
147
+ cast_to: type[_T] | None = None,
148
+ ) -> _T | dict[str, Any]:
149
+ return self.request("POST", path, body=body, cast_to=cast_to)
150
+
151
+ def put(
152
+ self,
153
+ path: str,
154
+ *,
155
+ body: dict[str, Any] | None = None,
156
+ cast_to: type[_T] | None = None,
157
+ ) -> _T | dict[str, Any]:
158
+ return self.request("PUT", path, body=body, cast_to=cast_to)
159
+
160
+ def patch(
161
+ self,
162
+ path: str,
163
+ *,
164
+ body: dict[str, Any] | None = None,
165
+ cast_to: type[_T] | None = None,
166
+ ) -> _T | dict[str, Any]:
167
+ return self.request("PATCH", path, body=body, cast_to=cast_to)
168
+
169
+ def delete(self, path: str) -> None:
170
+ self.request("DELETE", path)
171
+
172
+ def close(self) -> None:
173
+ self._client.close()
174
+
175
+ def __enter__(self) -> SyncAPIClient:
176
+ return self
177
+
178
+ def __exit__(self, *args: Any) -> None:
179
+ self.close()
180
+
181
+
182
+ class AsyncAPIClient(BaseClient):
183
+ _client: httpx.AsyncClient
184
+
185
+ def __init__(self, **kwargs: Any) -> None:
186
+ super().__init__(**kwargs)
187
+ self._client = httpx.AsyncClient(
188
+ base_url=self._config.base_url,
189
+ headers=self._build_headers(),
190
+ timeout=self._config.timeout,
191
+ )
192
+
193
+ async def request(
194
+ self,
195
+ method: str,
196
+ path: str,
197
+ *,
198
+ body: dict[str, Any] | None = None,
199
+ params: dict[str, Any] | None = None,
200
+ cast_to: type[_T] | None = None,
201
+ ) -> _T | dict[str, Any]:
202
+ last_exc: Exception | None = None
203
+
204
+ for attempt in range(self._config.max_retries + 1):
205
+ try:
206
+ response = await self._client.request(
207
+ method, path, json=body, params=params
208
+ )
209
+ except httpx.ConnectError as exc:
210
+ raise QbrixConnectionError(str(exc)) from exc
211
+ except httpx.TimeoutException as exc:
212
+ raise QbrixTimeoutError(str(exc)) from exc
213
+
214
+ if response.is_success:
215
+ if response.status_code == 204 or not response.content:
216
+ return cast_to.model_validate({}) if cast_to else {}
217
+ data = response.json()
218
+ return cast_to.model_validate(data) if cast_to else data
219
+
220
+ if not self._should_retry(response):
221
+ raise self._make_status_error(response)
222
+
223
+ last_exc = self._make_status_error(response)
224
+
225
+ if attempt < self._config.max_retries:
226
+ await asyncio.sleep(self._calculate_retry_delay(attempt))
227
+
228
+ if last_exc:
229
+ raise last_exc
230
+
231
+ return {}
232
+
233
+ async def get(
234
+ self,
235
+ path: str,
236
+ *,
237
+ cast_to: type[_T] | None = None,
238
+ params: dict[str, Any] | None = None,
239
+ ) -> _T | dict[str, Any]:
240
+ return await self.request("GET", path, cast_to=cast_to, params=params)
241
+
242
+ async def post(
243
+ self,
244
+ path: str,
245
+ *,
246
+ body: dict[str, Any] | None = None,
247
+ cast_to: type[_T] | None = None,
248
+ ) -> _T | dict[str, Any]:
249
+ return await self.request("POST", path, body=body, cast_to=cast_to)
250
+
251
+ async def put(
252
+ self,
253
+ path: str,
254
+ *,
255
+ body: dict[str, Any] | None = None,
256
+ cast_to: type[_T] | None = None,
257
+ ) -> _T | dict[str, Any]:
258
+ return await self.request("PUT", path, body=body, cast_to=cast_to)
259
+
260
+ async def patch(
261
+ self,
262
+ path: str,
263
+ *,
264
+ body: dict[str, Any] | None = None,
265
+ cast_to: type[_T] | None = None,
266
+ ) -> _T | dict[str, Any]:
267
+ return await self.request("PATCH", path, body=body, cast_to=cast_to)
268
+
269
+ async def delete(self, path: str) -> None:
270
+ await self.request("DELETE", path)
271
+
272
+ async def close(self) -> None:
273
+ await self._client.aclose()
274
+
275
+ async def __aenter__(self) -> AsyncAPIClient:
276
+ return self
277
+
278
+ async def __aexit__(self, *args: Any) -> None:
279
+ await self.close()
qbrix/_client.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+
5
+ from qbrix._base_client import AsyncAPIClient
6
+ from qbrix._base_client import SyncAPIClient
7
+ from qbrix.resource.agent import AgentResource
8
+ from qbrix.resource.agent import AsyncAgentResource
9
+ from qbrix.resource.experiment import AsyncExperimentResource
10
+ from qbrix.resource.experiment import ExperimentResource
11
+ from qbrix.resource.gate import AsyncGateResource
12
+ from qbrix.resource.gate import GateResource
13
+ from qbrix.resource.pool import AsyncPoolResource
14
+ from qbrix.resource.pool import PoolResource
15
+
16
+
17
+ class Qbrix(SyncAPIClient):
18
+ """synchronous qbrix SDK client.
19
+
20
+ Usage::
21
+
22
+ with Qbrix(api_key="optiq_xxx") as client:
23
+ result = client.agent.select("exp-id", context={"id": "user-1"})
24
+ client.agent.feedback(result.request_id, reward=1.0)
25
+ """
26
+
27
+ @cached_property
28
+ def pool(self) -> PoolResource:
29
+ return PoolResource(self)
30
+
31
+ @cached_property
32
+ def experiment(self) -> ExperimentResource:
33
+ return ExperimentResource(self)
34
+
35
+ @cached_property
36
+ def gate(self) -> GateResource:
37
+ return GateResource(self)
38
+
39
+ @cached_property
40
+ def agent(self) -> AgentResource:
41
+ return AgentResource(self)
42
+
43
+
44
+ class AsyncQbrix(AsyncAPIClient):
45
+ """asynchronous qbrix SDK client.
46
+
47
+ Usage::
48
+
49
+ async with AsyncQbrix(api_key="optiq_xxx") as client:
50
+ result = await client.agent.select("exp-id", context={"id": "user-1"})
51
+ await client.agent.feedback(result.request_id, reward=1.0)
52
+ """
53
+
54
+ @cached_property
55
+ def pool(self) -> AsyncPoolResource:
56
+ return AsyncPoolResource(self)
57
+
58
+ @cached_property
59
+ def experiment(self) -> AsyncExperimentResource:
60
+ return AsyncExperimentResource(self)
61
+
62
+ @cached_property
63
+ def gate(self) -> AsyncGateResource:
64
+ return AsyncGateResource(self)
65
+
66
+ @cached_property
67
+ def agent(self) -> AsyncAgentResource:
68
+ return AsyncAgentResource(self)
69
+
70
+
71
+ Client = Qbrix
72
+ AsyncClient = AsyncQbrix
qbrix/_config.py ADDED
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic_settings import BaseSettings
4
+ from pydantic_settings import SettingsConfigDict
5
+
6
+
7
+ class QbrixConfig(BaseSettings):
8
+ model_config = SettingsConfigDict(env_prefix="QBRIX_")
9
+
10
+ base_url: str = "http://localhost:8080"
11
+ api_key: str | None = None
12
+ timeout: float = 5.0
13
+ max_retries: int = 0
14
+ retry_on: tuple[int, ...] = (429, 502, 503, 504)
15
+ retry_base_delay: float = 0.5
16
+ retry_max_delay: float = 30.0
qbrix/_mod_client.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from qbrix._client import Qbrix
7
+
8
+ _mod_client: "Qbrix | None" = None
9
+
10
+
11
+ def _load_client() -> "Qbrix":
12
+ global _mod_client
13
+ if _mod_client is None:
14
+ from qbrix._client import Qbrix
15
+ _mod_client = Qbrix()
16
+ return _mod_client
17
+
18
+
19
+ def _reset_client() -> None:
20
+ """Reset the module-level default client. Useful in tests."""
21
+ global _mod_client
22
+ _mod_client = None
qbrix/_proxies.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from qbrix._util import LazyProxy
4
+ from qbrix.resource.agent import AgentResource
5
+ from qbrix.resource.experiment import ExperimentResource
6
+ from qbrix.resource.gate import GateResource
7
+ from qbrix.resource.pool import PoolResource
8
+
9
+
10
+ def _load_client() -> "Qbrix": # type: ignore[name-defined] # noqa: F821
11
+ from qbrix._mod_client import _load_client as _lc
12
+
13
+ return _lc()
14
+
15
+
16
+ class PoolProxy(LazyProxy[PoolResource]):
17
+ def __load__(self) -> PoolResource:
18
+ return _load_client().pool
19
+
20
+
21
+ class ExperimentProxy(LazyProxy[ExperimentResource]):
22
+ def __load__(self) -> ExperimentResource:
23
+ return _load_client().experiment
24
+
25
+
26
+ class GateProxy(LazyProxy[GateResource]):
27
+ def __load__(self) -> GateResource:
28
+ return _load_client().gate
29
+
30
+
31
+ class AgentProxy(LazyProxy[AgentResource]):
32
+ def __load__(self) -> AgentResource:
33
+ return _load_client().agent
qbrix/_util.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Generic, TypeVar
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class LazyProxy(Generic[T]):
9
+ """Defers instantiation of a resource until first attribute access."""
10
+
11
+ def __init__(self) -> None:
12
+ object.__setattr__(self, "_proxied", None)
13
+
14
+ def __load__(self) -> T:
15
+ raise NotImplementedError
16
+
17
+ def __getattr__(self, name: str) -> object:
18
+ proxied = object.__getattribute__(self, "_proxied")
19
+ if proxied is None:
20
+ proxied = self.__load__()
21
+ object.__setattr__(self, "_proxied", proxied)
22
+ return getattr(proxied, name)
23
+
24
+ def __as_proxied__(self) -> "LazyProxy[T]":
25
+ return self
qbrix/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3" # x-release-please-version
qbrix/exception.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class QbrixError(Exception):
7
+ """base exception for all qbrix SDK errors."""
8
+
9
+
10
+ class QbrixAPIError(QbrixError):
11
+ """error returned by the qbrix API."""
12
+
13
+ status_code: int
14
+ detail: str
15
+ context: dict[str, Any] | None
16
+
17
+ def __init__(
18
+ self,
19
+ status_code: int,
20
+ detail: str,
21
+ context: dict[str, Any] | None = None,
22
+ ) -> None:
23
+ self.status_code = status_code
24
+ self.detail = detail
25
+ self.context = context
26
+ super().__init__(f"[{status_code}] {detail}")
27
+
28
+
29
+ class BadRequestError(QbrixAPIError):
30
+ """400 — malformed request."""
31
+
32
+
33
+ class AuthenticationError(QbrixAPIError):
34
+ """401 — invalid or missing credentials."""
35
+
36
+
37
+ class ForbiddenError(QbrixAPIError):
38
+ """403 — insufficient permissions."""
39
+
40
+
41
+ class NotFoundError(QbrixAPIError):
42
+ """404 — resource not found."""
43
+
44
+
45
+ class ConflictError(QbrixAPIError):
46
+ """409 — resource conflict."""
47
+
48
+
49
+ class RateLimitedError(QbrixAPIError):
50
+ """429 — rate limit exceeded."""
51
+
52
+ retry_after: float | None
53
+
54
+ def __init__(
55
+ self,
56
+ status_code: int,
57
+ detail: str,
58
+ context: dict[str, Any] | None = None,
59
+ retry_after: float | None = None,
60
+ ) -> None:
61
+ super().__init__(status_code, detail, context)
62
+ self.retry_after = retry_after
63
+
64
+
65
+ class InternalServerError(QbrixAPIError):
66
+ """500 — server error."""
67
+
68
+
69
+ class ServiceUnavailableError(QbrixAPIError):
70
+ """503 — downstream service failure."""
71
+
72
+
73
+ class QbrixConnectionError(QbrixError):
74
+ """failed to connect to the qbrix API."""
75
+
76
+
77
+ class QbrixTimeoutError(QbrixError):
78
+ """request to the qbrix API timed out."""
79
+
80
+
81
+ STATUS_CODE_TO_EXCEPTION: dict[int, type[QbrixAPIError]] = {
82
+ 400: BadRequestError,
83
+ 401: AuthenticationError,
84
+ 403: ForbiddenError,
85
+ 404: NotFoundError,
86
+ 409: ConflictError,
87
+ 429: RateLimitedError,
88
+ 500: InternalServerError,
89
+ 503: ServiceUnavailableError,
90
+ }
@@ -0,0 +1,41 @@
1
+ from qbrix.model.agent import FeedbackRequest
2
+ from qbrix.model.agent import FeedbackResponse
3
+ from qbrix.model.agent import SelectedArm
4
+ from qbrix.model.agent import SelectRequest
5
+ from qbrix.model.agent import SelectResponse
6
+ from qbrix.model.auth import APIKeyInfo
7
+ from qbrix.model.common import Context
8
+ from qbrix.model.common import PaginatedResponse
9
+ from qbrix.model.experiment import Experiment
10
+ from qbrix.model.experiment import ExperimentCreate
11
+ from qbrix.model.experiment import ExperimentUpdate
12
+ from qbrix.model.gate import GateConfig
13
+ from qbrix.model.gate import GateCreate
14
+ from qbrix.model.gate import GateRule
15
+ from qbrix.model.pool import Arm
16
+ from qbrix.model.pool import ArmCreate
17
+ from qbrix.model.pool import Pool
18
+ from qbrix.model.pool import PoolCreate
19
+ from qbrix.model.pool import PoolUpdate
20
+
21
+ __all__ = [
22
+ "Arm",
23
+ "ArmCreate",
24
+ "APIKeyInfo",
25
+ "Context",
26
+ "Experiment",
27
+ "ExperimentCreate",
28
+ "ExperimentUpdate",
29
+ "FeedbackRequest",
30
+ "FeedbackResponse",
31
+ "GateConfig",
32
+ "GateCreate",
33
+ "GateRule",
34
+ "PaginatedResponse",
35
+ "Pool",
36
+ "PoolCreate",
37
+ "PoolUpdate",
38
+ "SelectedArm",
39
+ "SelectRequest",
40
+ "SelectResponse",
41
+ ]