onceonly-sdk 1.2.0__py3-none-any.whl → 2.0.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.
onceonly/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from .version import __version__
1
2
  from .client import OnceOnly, create_client
2
3
  from .models import CheckLockResult
3
4
  from .exceptions import (
@@ -9,8 +10,6 @@ from .exceptions import (
9
10
  ApiError,
10
11
  )
11
12
 
12
- __version__ = "1.2.0"
13
-
14
13
  __all__ = [
15
14
  "OnceOnly",
16
15
  "create_client",
@@ -21,4 +20,5 @@ __all__ = [
21
20
  "RateLimitError",
22
21
  "ValidationError",
23
22
  "ApiError",
23
+ "__version__",
24
24
  ]
onceonly/_http.py ADDED
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Any, Dict, Optional, Union, Callable, Awaitable
6
+
7
+ import httpx
8
+
9
+ from .exceptions import ApiError, UnauthorizedError, OverLimitError, RateLimitError, ValidationError
10
+
11
+
12
+ def try_extract_detail(resp: httpx.Response) -> Optional[Union[Dict[str, Any], str, Any]]:
13
+ try:
14
+ j = resp.json()
15
+ if isinstance(j, dict) and "detail" in j:
16
+ return j.get("detail")
17
+ return j
18
+ except Exception:
19
+ return None
20
+
21
+
22
+ def error_text(resp: httpx.Response, default: str) -> str:
23
+ d = try_extract_detail(resp)
24
+ if isinstance(d, dict):
25
+ return d.get("error") or d.get("message") or default
26
+ if isinstance(d, str) and d.strip():
27
+ return d
28
+ return default
29
+
30
+
31
+ def _parse_retry_after(resp: httpx.Response) -> Optional[float]:
32
+ # headers are case-insensitive in httpx
33
+ ra = resp.headers.get("Retry-After")
34
+ if not ra:
35
+ return None
36
+ ra = ra.strip()
37
+ try:
38
+ return float(ra)
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def parse_json_or_raise(resp: httpx.Response) -> Dict[str, Any]:
44
+ # typed errors
45
+ if resp.status_code in (401, 403):
46
+ raise UnauthorizedError(error_text(resp, "Invalid API Key (Unauthorized)."))
47
+
48
+ if resp.status_code == 402:
49
+ d = try_extract_detail(resp)
50
+ raise OverLimitError("Usage limit reached. Please upgrade your plan.", detail=d if isinstance(d, dict) else {})
51
+
52
+ if resp.status_code == 429:
53
+ retry_after = _parse_retry_after(resp)
54
+ raise RateLimitError(error_text(resp, "Rate limit exceeded. Please slow down."), retry_after_sec=retry_after)
55
+
56
+ if resp.status_code == 422:
57
+ raise ValidationError(error_text(resp, f"Validation Error: {resp.text}"))
58
+
59
+ if resp.status_code < 200 or resp.status_code >= 300:
60
+ d = try_extract_detail(resp)
61
+ raise ApiError(
62
+ error_text(resp, f"API Error ({resp.status_code})"),
63
+ status_code=resp.status_code,
64
+ detail=d if isinstance(d, dict) else {},
65
+ )
66
+
67
+ try:
68
+ data = resp.json()
69
+ except Exception:
70
+ data = {}
71
+ return data if isinstance(data, dict) else {"data": data}
72
+
73
+
74
+ def request_with_retries_sync(
75
+ fn: Callable[[], httpx.Response],
76
+ *,
77
+ max_retries: int,
78
+ base_backoff: float,
79
+ max_backoff: float,
80
+ ) -> httpx.Response:
81
+ attempt = 0
82
+ while True:
83
+ resp = fn()
84
+ if resp.status_code != 429 or attempt >= max_retries:
85
+ return resp
86
+
87
+ ra = _parse_retry_after(resp)
88
+ sleep_s = ra if ra is not None else min(max_backoff, base_backoff * (2**attempt))
89
+ time.sleep(max(0.0, float(sleep_s)))
90
+ attempt += 1
91
+
92
+
93
+ async def request_with_retries_async(
94
+ fn: Callable[[], Awaitable[httpx.Response]],
95
+ *,
96
+ max_retries: int,
97
+ base_backoff: float,
98
+ max_backoff: float,
99
+ ) -> httpx.Response:
100
+ attempt = 0
101
+ while True:
102
+ resp = await fn()
103
+ if resp.status_code != 429 or attempt >= max_retries:
104
+ return resp
105
+
106
+ ra = _parse_retry_after(resp)
107
+ sleep_s = ra if ra is not None else min(max_backoff, base_backoff * (2**attempt))
108
+ await asyncio.sleep(max(0.0, float(sleep_s)))
109
+ attempt += 1
onceonly/_util.py ADDED
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import is_dataclass, asdict
4
+ from typing import Any, Dict, Mapping, Optional, Union
5
+
6
+ MetadataLike = Union[Mapping[str, Any], Any] # Mapping | pydantic model | dataclass | any
7
+
8
+
9
+ def to_metadata_dict(metadata: Optional[MetadataLike]) -> Optional[Dict[str, Any]]:
10
+ """
11
+ Accepts:
12
+ - Mapping[str, Any]
13
+ - Pydantic model (duck-typed: has model_dump())
14
+ - dataclass
15
+ - anything else => {"value": str(obj)}
16
+
17
+ Returns plain JSON-ready dict (best effort).
18
+ """
19
+ if metadata is None:
20
+ return None
21
+
22
+ # Pydantic v2
23
+ md = getattr(metadata, "model_dump", None)
24
+ if callable(md):
25
+ try:
26
+ out = md()
27
+ return out if isinstance(out, dict) else {"data": out}
28
+ except Exception:
29
+ return {"value": str(metadata)}
30
+
31
+ # dataclass
32
+ if is_dataclass(metadata):
33
+ try:
34
+ out = asdict(metadata)
35
+ return out if isinstance(out, dict) else {"data": out}
36
+ except Exception:
37
+ return {"value": str(metadata)}
38
+
39
+ # mapping
40
+ if isinstance(metadata, Mapping):
41
+ try:
42
+ return dict(metadata)
43
+ except Exception:
44
+ return {"value": str(metadata)}
45
+
46
+ return {"value": str(metadata)}
onceonly/ai.py ADDED
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ import logging
6
+ from typing import Any, Dict, Optional, Awaitable, Callable
7
+
8
+ import httpx
9
+
10
+ from ._http import (
11
+ parse_json_or_raise,
12
+ request_with_retries_sync,
13
+ request_with_retries_async,
14
+ )
15
+ from ._util import to_metadata_dict, MetadataLike
16
+ from .ai_models import AiRun, AiStatus, AiResult
17
+
18
+ logger = logging.getLogger("onceonly")
19
+
20
+
21
+ class AiClient:
22
+ """
23
+ AI helpers for long-running backend tasks.
24
+
25
+ Typical usage for agents:
26
+ result = client.ai.run_and_wait(key="job:123", metadata={...})
27
+
28
+ Endpoints:
29
+ - POST /ai/run => start/attach to a run (idempotent by key)
30
+ - GET /ai/status => poll status
31
+ - GET /ai/result => fetch final result (completed/failed)
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ sync_client: httpx.Client,
37
+ async_client_getter: Callable[[], Awaitable[httpx.AsyncClient]],
38
+ *,
39
+ max_retries_429: int = 0,
40
+ retry_backoff: float = 0.5,
41
+ retry_max_backoff: float = 5.0,
42
+ ):
43
+ self._c = sync_client
44
+ self._get_ac = async_client_getter
45
+
46
+ self._max_retries_429 = int(max_retries_429)
47
+ self._retry_backoff = float(retry_backoff)
48
+ self._retry_max_backoff = float(retry_max_backoff)
49
+
50
+ # ---- sync ----
51
+
52
+ def run(self, key: str, ttl: Optional[int] = None, metadata: Optional[MetadataLike] = None) -> AiRun:
53
+ payload: Dict[str, Any] = {"key": key}
54
+ if ttl is not None:
55
+ payload["ttl"] = int(ttl)
56
+ md = to_metadata_dict(metadata)
57
+ if md is not None:
58
+ payload["metadata"] = md
59
+
60
+ resp = request_with_retries_sync(
61
+ lambda: self._c.post("/ai/run", json=payload),
62
+ max_retries=self._max_retries_429,
63
+ base_backoff=self._retry_backoff,
64
+ max_backoff=self._retry_max_backoff,
65
+ )
66
+ data = parse_json_or_raise(resp)
67
+
68
+ logger.debug("ai.run key=%s status=%s version=%s", key, data.get("status"), data.get("version"))
69
+ return AiRun.from_dict(data)
70
+
71
+ def status(self, key: str) -> AiStatus:
72
+ resp = request_with_retries_sync(
73
+ lambda: self._c.get("/ai/status", params={"key": key}),
74
+ max_retries=self._max_retries_429,
75
+ base_backoff=self._retry_backoff,
76
+ max_backoff=self._retry_max_backoff,
77
+ )
78
+ data = parse_json_or_raise(resp)
79
+
80
+ logger.debug("ai.status key=%s status=%s version=%s", key, data.get("status"), data.get("version"))
81
+ return AiStatus.from_dict(data)
82
+
83
+ def result(self, key: str) -> AiResult:
84
+ resp = request_with_retries_sync(
85
+ lambda: self._c.get("/ai/result", params={"key": key}),
86
+ max_retries=self._max_retries_429,
87
+ base_backoff=self._retry_backoff,
88
+ max_backoff=self._retry_max_backoff,
89
+ )
90
+ data = parse_json_or_raise(resp)
91
+
92
+ logger.debug("ai.result key=%s status=%s", key, data.get("status"))
93
+ return AiResult.from_dict(data)
94
+
95
+ def wait(self, key: str, timeout: float = 60.0, poll_min: float = 0.5, poll_max: float = 5.0) -> AiResult:
96
+ t0 = time.time()
97
+ while True:
98
+ st = self.status(key)
99
+ if st.status in ("completed", "failed"):
100
+ return self.result(key)
101
+
102
+ if time.time() - t0 >= timeout:
103
+ return AiResult(ok=False, status="timeout", key=key, error_code="timeout")
104
+
105
+ sleep_s = st.retry_after_sec if isinstance(st.retry_after_sec, int) else poll_min
106
+ sleep_s = max(poll_min, min(poll_max, float(sleep_s)))
107
+ time.sleep(sleep_s)
108
+
109
+ def run_and_wait(
110
+ self,
111
+ key: str,
112
+ *,
113
+ ttl: Optional[int] = None,
114
+ metadata: Optional[MetadataLike] = None,
115
+ timeout: float = 60.0,
116
+ poll_min: float = 0.5,
117
+ poll_max: float = 5.0,
118
+ ) -> AiResult:
119
+ self.run(key=key, ttl=ttl, metadata=metadata)
120
+ return self.wait(key=key, timeout=timeout, poll_min=poll_min, poll_max=poll_max)
121
+
122
+ # ---- async ----
123
+
124
+ async def run_async(self, key: str, ttl: Optional[int] = None, metadata: Optional[MetadataLike] = None) -> AiRun:
125
+ payload: Dict[str, Any] = {"key": key}
126
+ if ttl is not None:
127
+ payload["ttl"] = int(ttl)
128
+ md = to_metadata_dict(metadata)
129
+ if md is not None:
130
+ payload["metadata"] = md
131
+
132
+ c = await self._get_ac()
133
+ resp = await request_with_retries_async(
134
+ lambda: c.post("/ai/run", json=payload),
135
+ max_retries=self._max_retries_429,
136
+ base_backoff=self._retry_backoff,
137
+ max_backoff=self._retry_max_backoff,
138
+ )
139
+ data = parse_json_or_raise(resp)
140
+
141
+ logger.debug("ai.run_async key=%s status=%s version=%s", key, data.get("status"), data.get("version"))
142
+ return AiRun.from_dict(data)
143
+
144
+ async def status_async(self, key: str) -> AiStatus:
145
+ c = await self._get_ac()
146
+ resp = await request_with_retries_async(
147
+ lambda: c.get("/ai/status", params={"key": key}),
148
+ max_retries=self._max_retries_429,
149
+ base_backoff=self._retry_backoff,
150
+ max_backoff=self._retry_max_backoff,
151
+ )
152
+ data = parse_json_or_raise(resp)
153
+
154
+ logger.debug("ai.status_async key=%s status=%s version=%s", key, data.get("status"), data.get("version"))
155
+ return AiStatus.from_dict(data)
156
+
157
+ async def result_async(self, key: str) -> AiResult:
158
+ c = await self._get_ac()
159
+ resp = await request_with_retries_async(
160
+ lambda: c.get("/ai/result", params={"key": key}),
161
+ max_retries=self._max_retries_429,
162
+ base_backoff=self._retry_backoff,
163
+ max_backoff=self._retry_max_backoff,
164
+ )
165
+ data = parse_json_or_raise(resp)
166
+
167
+ logger.debug("ai.result_async key=%s status=%s", key, data.get("status"))
168
+ return AiResult.from_dict(data)
169
+
170
+ async def wait_async(self, key: str, timeout: float = 60.0, poll_min: float = 0.5, poll_max: float = 5.0) -> AiResult:
171
+ t0 = time.time()
172
+ while True:
173
+ st = await self.status_async(key)
174
+ if st.status in ("completed", "failed"):
175
+ return await self.result_async(key)
176
+
177
+ if time.time() - t0 >= timeout:
178
+ return AiResult(ok=False, status="timeout", key=key, error_code="timeout")
179
+
180
+ sleep_s = st.retry_after_sec if isinstance(st.retry_after_sec, int) else poll_min
181
+ sleep_s = max(poll_min, min(poll_max, float(sleep_s)))
182
+ await asyncio.sleep(sleep_s)
183
+
184
+ async def run_and_wait_async(
185
+ self,
186
+ key: str,
187
+ *,
188
+ ttl: Optional[int] = None,
189
+ metadata: Optional[MetadataLike] = None,
190
+ timeout: float = 60.0,
191
+ poll_min: float = 0.5,
192
+ poll_max: float = 5.0,
193
+ ) -> AiResult:
194
+ await self.run_async(key=key, ttl=ttl, metadata=metadata)
195
+ return await self.wait_async(key=key, timeout=timeout, poll_min=poll_min, poll_max=poll_max)
onceonly/ai_models.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional
5
+
6
+
7
+ def _dict_or_none(v: Any) -> Optional[Dict[str, Any]]:
8
+ return v if isinstance(v, dict) else None
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class AiRun:
13
+ ok: bool
14
+ status: str
15
+ key: str
16
+ lease_id: Optional[str] = None
17
+ version: int = 0
18
+ charged: Optional[int] = None
19
+ usage: Optional[int] = None
20
+ limit: Optional[int] = None
21
+ retry_after_sec: Optional[int] = None
22
+ done_at: Optional[str] = None
23
+ error_code: Optional[str] = None
24
+ result_hash: Optional[str] = None
25
+ result: Optional[Dict[str, Any]] = None
26
+
27
+ @staticmethod
28
+ def from_dict(d: Dict[str, Any]) -> "AiRun":
29
+ return AiRun(
30
+ ok=bool(d.get("ok", False)),
31
+ status=str(d.get("status") or ""),
32
+ key=str(d.get("key") or ""),
33
+ lease_id=d.get("lease_id"),
34
+ version=int(d.get("version") or 0),
35
+ charged=d.get("charged"),
36
+ usage=d.get("usage"),
37
+ limit=d.get("limit"),
38
+ retry_after_sec=d.get("retry_after_sec"),
39
+ done_at=d.get("done_at"),
40
+ error_code=d.get("error_code"),
41
+ result_hash=d.get("result_hash"),
42
+ result=_dict_or_none(d.get("result")),
43
+ )
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class AiStatus:
48
+ ok: bool
49
+ status: str
50
+ key: str
51
+ lease_id: Optional[str] = None
52
+ version: int = 0
53
+ ttl_left: Optional[int] = None
54
+ first_seen_at: Optional[str] = None
55
+ done_at: Optional[str] = None
56
+ result_hash: Optional[str] = None
57
+ error_code: Optional[str] = None
58
+ retry_after_sec: Optional[int] = None
59
+
60
+ @staticmethod
61
+ def from_dict(d: Dict[str, Any]) -> "AiStatus":
62
+ return AiStatus(
63
+ ok=bool(d.get("ok", False)),
64
+ status=str(d.get("status") or ""),
65
+ key=str(d.get("key") or ""),
66
+ lease_id=d.get("lease_id"),
67
+ version=int(d.get("version") or 0),
68
+ ttl_left=d.get("ttl_left"),
69
+ first_seen_at=d.get("first_seen_at"),
70
+ done_at=d.get("done_at"),
71
+ result_hash=d.get("result_hash"),
72
+ error_code=d.get("error_code"),
73
+ retry_after_sec=d.get("retry_after_sec"),
74
+ )
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class AiResult:
79
+ ok: bool
80
+ status: str
81
+ key: str
82
+ result: Optional[Dict[str, Any]] = None
83
+ result_hash: Optional[str] = None
84
+ error_code: Optional[str] = None
85
+ done_at: Optional[str] = None
86
+
87
+ @staticmethod
88
+ def from_dict(d: Dict[str, Any]) -> "AiResult":
89
+ return AiResult(
90
+ ok=bool(d.get("ok", False)),
91
+ status=str(d.get("status") or ""),
92
+ key=str(d.get("key") or ""),
93
+ result=_dict_or_none(d.get("result")),
94
+ result_hash=d.get("result_hash"),
95
+ error_code=d.get("error_code"),
96
+ done_at=d.get("done_at"),
97
+ )