fastspec 0.0.1__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.
- fastspec/__init__.py +1 -0
- fastspec/_modidx.py +97 -0
- fastspec/errors.py +166 -0
- fastspec/oapi.py +225 -0
- fastspec/spec.py +334 -0
- fastspec/sse.py +43 -0
- fastspec/transport.py +72 -0
- fastspec/types.py +83 -0
- fastspec-0.0.1.dist-info/METADATA +509 -0
- fastspec-0.0.1.dist-info/RECORD +14 -0
- fastspec-0.0.1.dist-info/WHEEL +5 -0
- fastspec-0.0.1.dist-info/entry_points.txt +2 -0
- fastspec-0.0.1.dist-info/licenses/LICENSE +201 -0
- fastspec-0.0.1.dist-info/top_level.txt +1 -0
fastspec/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
fastspec/_modidx.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Autogenerated by nbdev
|
|
2
|
+
|
|
3
|
+
d = { 'settings': { 'branch': 'main',
|
|
4
|
+
'doc_baseurl': '/fastspec',
|
|
5
|
+
'doc_host': 'https://AnswerDotAI.github.io',
|
|
6
|
+
'git_url': 'https://github.com/AnswerDotAI/fastspec',
|
|
7
|
+
'lib_path': 'fastspec'},
|
|
8
|
+
'syms': { 'fastspec.errors': { 'fastspec.errors.APIError': ('errors.html#apierror', 'fastspec/errors.py'),
|
|
9
|
+
'fastspec.errors.APIError.__init__': ('errors.html#apierror.__init__', 'fastspec/errors.py'),
|
|
10
|
+
'fastspec.errors.APIError.__str__': ('errors.html#apierror.__str__', 'fastspec/errors.py'),
|
|
11
|
+
'fastspec.errors.APIError.with_context': ('errors.html#apierror.with_context', 'fastspec/errors.py'),
|
|
12
|
+
'fastspec.errors.FastSpecError': ('errors.html#fastspecerror', 'fastspec/errors.py'),
|
|
13
|
+
'fastspec.errors.ProtocolError': ('errors.html#protocolerror', 'fastspec/errors.py'),
|
|
14
|
+
'fastspec.errors.SpecError': ('errors.html#specerror', 'fastspec/errors.py'),
|
|
15
|
+
'fastspec.errors._parse_sse_error_event': ('errors.html#_parse_sse_error_event', 'fastspec/errors.py'),
|
|
16
|
+
'fastspec.errors._req_id': ('errors.html#_req_id', 'fastspec/errors.py'),
|
|
17
|
+
'fastspec.errors._retryable': ('errors.html#_retryable', 'fastspec/errors.py'),
|
|
18
|
+
'fastspec.errors._to_text': ('errors.html#_to_text', 'fastspec/errors.py'),
|
|
19
|
+
'fastspec.errors.api_error_from_event': ('errors.html#api_error_from_event', 'fastspec/errors.py'),
|
|
20
|
+
'fastspec.errors.httpx.HTTPStatusError.api_error': ( 'errors.html#httpx.httpstatuserror.api_error',
|
|
21
|
+
'fastspec/errors.py'),
|
|
22
|
+
'fastspec.errors.httpx.Response.error': ('errors.html#httpx.response.error', 'fastspec/errors.py')},
|
|
23
|
+
'fastspec.oapi': { 'fastspec.oapi.OpFunc': ('oapi.html#opfunc', 'fastspec/oapi.py'),
|
|
24
|
+
'fastspec.oapi.OpFunc.__call__': ('oapi.html#opfunc.__call__', 'fastspec/oapi.py'),
|
|
25
|
+
'fastspec.oapi.OpFunc.__init__': ('oapi.html#opfunc.__init__', 'fastspec/oapi.py'),
|
|
26
|
+
'fastspec.oapi.OpFunc._bind': ('oapi.html#opfunc._bind', 'fastspec/oapi.py'),
|
|
27
|
+
'fastspec.oapi.OpFunc._raise_with_context': ('oapi.html#opfunc._raise_with_context', 'fastspec/oapi.py'),
|
|
28
|
+
'fastspec.oapi.OpFunc._repr_markdown_': ('oapi.html#opfunc._repr_markdown_', 'fastspec/oapi.py'),
|
|
29
|
+
'fastspec.oapi.OpFunc._request': ('oapi.html#opfunc._request', 'fastspec/oapi.py'),
|
|
30
|
+
'fastspec.oapi.OpFunc._split': ('oapi.html#opfunc._split', 'fastspec/oapi.py'),
|
|
31
|
+
'fastspec.oapi.OpFunc._stream': ('oapi.html#opfunc._stream', 'fastspec/oapi.py'),
|
|
32
|
+
'fastspec.oapi.OpGroup': ('oapi.html#opgroup', 'fastspec/oapi.py'),
|
|
33
|
+
'fastspec.oapi.OpGroup.__init__': ('oapi.html#opgroup.__init__', 'fastspec/oapi.py'),
|
|
34
|
+
'fastspec.oapi.OpGroup._repr_markdown_': ('oapi.html#opgroup._repr_markdown_', 'fastspec/oapi.py'),
|
|
35
|
+
'fastspec.oapi.OpenAPIClient': ('oapi.html#openapiclient', 'fastspec/oapi.py'),
|
|
36
|
+
'fastspec.oapi.OpenAPIClient.__init__': ('oapi.html#openapiclient.__init__', 'fastspec/oapi.py'),
|
|
37
|
+
'fastspec.oapi._build_groups': ('oapi.html#_build_groups', 'fastspec/oapi.py'),
|
|
38
|
+
'fastspec.oapi._join_url': ('oapi.html#_join_url', 'fastspec/oapi.py'),
|
|
39
|
+
'fastspec.oapi._mk_param': ('oapi.html#_mk_param', 'fastspec/oapi.py'),
|
|
40
|
+
'fastspec.oapi._op_line': ('oapi.html#_op_line', 'fastspec/oapi.py'),
|
|
41
|
+
'fastspec.oapi._op_summary': ('oapi.html#_op_summary', 'fastspec/oapi.py'),
|
|
42
|
+
'fastspec.oapi._path': ('oapi.html#_path', 'fastspec/oapi.py'),
|
|
43
|
+
'fastspec.oapi._sort_key': ('oapi.html#_sort_key', 'fastspec/oapi.py'),
|
|
44
|
+
'fastspec.oapi.mk_doc': ('oapi.html#mk_doc', 'fastspec/oapi.py'),
|
|
45
|
+
'fastspec.oapi.mk_sig': ('oapi.html#mk_sig', 'fastspec/oapi.py'),
|
|
46
|
+
'fastspec.oapi.sanitized_params': ('oapi.html#sanitized_params', 'fastspec/oapi.py')},
|
|
47
|
+
'fastspec.spec': { 'fastspec.spec.OpSpec': ('spec.html#opspec', 'fastspec/spec.py'),
|
|
48
|
+
'fastspec.spec.OpSpec.__post_init__': ('spec.html#opspec.__post_init__', 'fastspec/spec.py'),
|
|
49
|
+
'fastspec.spec.OpSpec._repr_markdown_': ('spec.html#opspec._repr_markdown_', 'fastspec/spec.py'),
|
|
50
|
+
'fastspec.spec.OpSpec.mk_doc': ('spec.html#opspec.mk_doc', 'fastspec/spec.py'),
|
|
51
|
+
'fastspec.spec.SpecParser': ('spec.html#specparser', 'fastspec/spec.py'),
|
|
52
|
+
'fastspec.spec.SpecParser.__init__': ('spec.html#specparser.__init__', 'fastspec/spec.py'),
|
|
53
|
+
'fastspec.spec.SpecParser.__repr__': ('spec.html#specparser.__repr__', 'fastspec/spec.py'),
|
|
54
|
+
'fastspec.spec.SpecParser.from_discovery': ('spec.html#specparser.from_discovery', 'fastspec/spec.py'),
|
|
55
|
+
'fastspec.spec.SpecParser.from_openapi': ('spec.html#specparser.from_openapi', 'fastspec/spec.py'),
|
|
56
|
+
'fastspec.spec._body_params': ('spec.html#_body_params', 'fastspec/spec.py'),
|
|
57
|
+
'fastspec.spec._clean_desc': ('spec.html#_clean_desc', 'fastspec/spec.py'),
|
|
58
|
+
'fastspec.spec._collect_params': ('spec.html#_collect_params', 'fastspec/spec.py'),
|
|
59
|
+
'fastspec.spec._discovery_body_params': ('spec.html#_discovery_body_params', 'fastspec/spec.py'),
|
|
60
|
+
'fastspec.spec._discovery_collect_params': ('spec.html#_discovery_collect_params', 'fastspec/spec.py'),
|
|
61
|
+
'fastspec.spec._first_url': ('spec.html#_first_url', 'fastspec/spec.py'),
|
|
62
|
+
'fastspec.spec._group_name': ('spec.html#_group_name', 'fastspec/spec.py'),
|
|
63
|
+
'fastspec.spec._op_docs_url': ('spec.html#_op_docs_url', 'fastspec/spec.py'),
|
|
64
|
+
'fastspec.spec._path_params': ('spec.html#_path_params', 'fastspec/spec.py'),
|
|
65
|
+
'fastspec.spec._prop_default': ('spec.html#_prop_default', 'fastspec/spec.py'),
|
|
66
|
+
'fastspec.spec._prop_desc': ('spec.html#_prop_desc', 'fastspec/spec.py'),
|
|
67
|
+
'fastspec.spec._resolve_obj': ('spec.html#_resolve_obj', 'fastspec/spec.py'),
|
|
68
|
+
'fastspec.spec._resolve_ref': ('spec.html#_resolve_ref', 'fastspec/spec.py'),
|
|
69
|
+
'fastspec.spec._schema_props_required': ('spec.html#_schema_props_required', 'fastspec/spec.py'),
|
|
70
|
+
'fastspec.spec._schema_py_type': ('spec.html#_schema_py_type', 'fastspec/spec.py'),
|
|
71
|
+
'fastspec.spec.discovery_to_ops': ('spec.html#discovery_to_ops', 'fastspec/spec.py'),
|
|
72
|
+
'fastspec.spec.openapi_to_ops': ('spec.html#openapi_to_ops', 'fastspec/spec.py'),
|
|
73
|
+
'fastspec.spec.snake': ('spec.html#snake', 'fastspec/spec.py')},
|
|
74
|
+
'fastspec.sse': { 'fastspec.sse.SSEvent': ('sse.html#ssevent', 'fastspec/sse.py'),
|
|
75
|
+
'fastspec.sse.aiter_sse': ('sse.html#aiter_sse', 'fastspec/sse.py')},
|
|
76
|
+
'fastspec.transport': { 'fastspec.transport.AsyncTransport': ('transport.html#asynctransport', 'fastspec/transport.py'),
|
|
77
|
+
'fastspec.transport.AsyncTransport.__init__': ( 'transport.html#asynctransport.__init__',
|
|
78
|
+
'fastspec/transport.py'),
|
|
79
|
+
'fastspec.transport.AsyncTransport._decode': ( 'transport.html#asynctransport._decode',
|
|
80
|
+
'fastspec/transport.py'),
|
|
81
|
+
'fastspec.transport.AsyncTransport._headers': ( 'transport.html#asynctransport._headers',
|
|
82
|
+
'fastspec/transport.py'),
|
|
83
|
+
'fastspec.transport.AsyncTransport._request_headers': ( 'transport.html#asynctransport._request_headers',
|
|
84
|
+
'fastspec/transport.py'),
|
|
85
|
+
'fastspec.transport.AsyncTransport.aclose': ( 'transport.html#asynctransport.aclose',
|
|
86
|
+
'fastspec/transport.py'),
|
|
87
|
+
'fastspec.transport.AsyncTransport.request': ( 'transport.html#asynctransport.request',
|
|
88
|
+
'fastspec/transport.py'),
|
|
89
|
+
'fastspec.transport.AsyncTransport.stream': ( 'transport.html#asynctransport.stream',
|
|
90
|
+
'fastspec/transport.py')},
|
|
91
|
+
'fastspec.types': { 'fastspec.types.Completion': ('types.html#completion', 'fastspec/types.py'),
|
|
92
|
+
'fastspec.types.Delta': ('types.html#delta', 'fastspec/types.py'),
|
|
93
|
+
'fastspec.types.Msg': ('types.html#msg', 'fastspec/types.py'),
|
|
94
|
+
'fastspec.types.Part': ('types.html#part', 'fastspec/types.py'),
|
|
95
|
+
'fastspec.types.RequestOptions': ('types.html#requestoptions', 'fastspec/types.py'),
|
|
96
|
+
'fastspec.types.ToolCall': ('types.html#toolcall', 'fastspec/types.py'),
|
|
97
|
+
'fastspec.types.Usage': ('types.html#usage', 'fastspec/types.py')}}}
|
fastspec/errors.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""fastspec errors."""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_errors.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% auto #0
|
|
6
|
+
__all__ = ['FastSpecError', 'SpecError', 'ProtocolError', 'APIError', 'api_error_from_event']
|
|
7
|
+
|
|
8
|
+
# %% ../nbs/00_errors.ipynb #b4e7c7ed
|
|
9
|
+
from fastcore.utils import *
|
|
10
|
+
import json, httpx
|
|
11
|
+
|
|
12
|
+
# %% ../nbs/00_errors.ipynb #87c600ab
|
|
13
|
+
class FastSpecError(Exception):
|
|
14
|
+
"Base fastspec error."
|
|
15
|
+
|
|
16
|
+
# %% ../nbs/00_errors.ipynb #3832e5fa
|
|
17
|
+
class SpecError(FastSpecError):
|
|
18
|
+
"Raised when an OpenAPI spec cannot be parsed as expected."
|
|
19
|
+
|
|
20
|
+
# %% ../nbs/00_errors.ipynb #be534d1b
|
|
21
|
+
class ProtocolError(FastSpecError):
|
|
22
|
+
"Raised when provider payloads do not match expected protocol shape."
|
|
23
|
+
|
|
24
|
+
# %% ../nbs/00_errors.ipynb #73881e99
|
|
25
|
+
def _to_text(v):
|
|
26
|
+
"Best-effort stringify helper."
|
|
27
|
+
if v is None: return ""
|
|
28
|
+
if isinstance(v, str): return v
|
|
29
|
+
try: return json.dumps(v, ensure_ascii=False)
|
|
30
|
+
except Exception: return str(v)
|
|
31
|
+
|
|
32
|
+
# %% ../nbs/00_errors.ipynb #2d51e006
|
|
33
|
+
def _retryable(status_code, error_type, code, message):
|
|
34
|
+
"Classify transient/retryable API failures."
|
|
35
|
+
if isinstance(status_code, int) and (status_code >= 500 or status_code in (408, 409, 425, 429)): return True
|
|
36
|
+
t = str(error_type or "").lower()
|
|
37
|
+
c = str(code or "").lower()
|
|
38
|
+
m = (message or "").lower()
|
|
39
|
+
hints = ("server_error", "internal", "overload", "rate_limit", "timeout", "unavailable", "temporar")
|
|
40
|
+
if any(h in t for h in hints): return True
|
|
41
|
+
if any(h in c for h in hints): return True
|
|
42
|
+
if any(h in m for h in ("try again", "server error", "temporar", "timeout", "overloaded", "unavailable")): return True
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
# %% ../nbs/00_errors.ipynb #9c1cdf65
|
|
46
|
+
def _req_id(headers):
|
|
47
|
+
"Extract provider request id header when available."
|
|
48
|
+
if headers is None: return ""
|
|
49
|
+
for k in ("x-request-id", "request-id", "anthropic-request-id", "x-goog-request-id"):
|
|
50
|
+
v = headers.get(k)
|
|
51
|
+
if v: return str(v)
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
# %% ../nbs/00_errors.ipynb #fdf25eba
|
|
55
|
+
@patch
|
|
56
|
+
def error(self: httpx.Response):
|
|
57
|
+
"Parse common HTTP API error shapes to (message, error_type, code, raw)."
|
|
58
|
+
try: raw = self.json()
|
|
59
|
+
except Exception: raw = self.text
|
|
60
|
+
|
|
61
|
+
msg, et, code, status = "", "", None, None
|
|
62
|
+
if isinstance(raw, dict):
|
|
63
|
+
err = raw.get("error")
|
|
64
|
+
if isinstance(err, dict):
|
|
65
|
+
msg = str(err.get("message") or raw.get("message") or raw.get("detail") or "")
|
|
66
|
+
status = err.get("status", raw.get("status"))
|
|
67
|
+
et = str(err.get("type") or status or raw.get("type") or "")
|
|
68
|
+
code = err.get("code", raw.get("code"))
|
|
69
|
+
elif err is not None:
|
|
70
|
+
msg = _to_text(err)
|
|
71
|
+
status = raw.get("status")
|
|
72
|
+
et = str(raw.get("type") or status or "")
|
|
73
|
+
code = raw.get("code")
|
|
74
|
+
else:
|
|
75
|
+
msg = str(raw.get("message") or raw.get("detail") or raw.get("error_description") or "")
|
|
76
|
+
status = raw.get("status")
|
|
77
|
+
et = str(raw.get("type") or status or "")
|
|
78
|
+
code = raw.get("code")
|
|
79
|
+
if not msg: msg = _to_text(raw)
|
|
80
|
+
else:
|
|
81
|
+
msg = _to_text(raw)
|
|
82
|
+
# Prefer semantic provider codes (e.g. Gemini `status`) over numeric HTTP-like codes.
|
|
83
|
+
if (code is None or isinstance(code, int)) and isinstance(status, str) and status:
|
|
84
|
+
code = status
|
|
85
|
+
if code in (None, "") and isinstance(et, str) and et:
|
|
86
|
+
code = et
|
|
87
|
+
return AttrDict(message=msg, type=et, code=code, raw=raw)
|
|
88
|
+
|
|
89
|
+
# %% ../nbs/00_errors.ipynb #24ed43ac
|
|
90
|
+
def _parse_sse_error_event(event):
|
|
91
|
+
"Parse common SSE `error` event shapes to (message, error_type, code, raw)."
|
|
92
|
+
raw = event
|
|
93
|
+
e = event.get("error") if isinstance(event, dict) and isinstance(event.get("error"), dict) else (
|
|
94
|
+
event if isinstance(event, dict) else {"message": _to_text(event)})
|
|
95
|
+
msg = str(e.get("message") or e.get("detail") or _to_text(e))
|
|
96
|
+
et = str(e.get("type") or e.get("status") or "")
|
|
97
|
+
code = e.get("code")
|
|
98
|
+
if (code is None or isinstance(code, int)) and isinstance(et, str) and et: code = et
|
|
99
|
+
return AttrDict(message=msg, type=et, code=code, raw=raw)
|
|
100
|
+
|
|
101
|
+
# %% ../nbs/00_errors.ipynb #0de145da
|
|
102
|
+
class APIError(FastSpecError):
|
|
103
|
+
"Structured API error with optional context."
|
|
104
|
+
def __init__(self, message: str, *, provider: str = "", model: str = "", endpoint: str = "",
|
|
105
|
+
status_code= None, error_type: str = "", code = None, request_id: str = "",
|
|
106
|
+
retryable = None, raw = None):
|
|
107
|
+
self.message = message or "API request failed"
|
|
108
|
+
self.provider = provider or ""
|
|
109
|
+
self.model = model or ""
|
|
110
|
+
self.endpoint = endpoint or ""
|
|
111
|
+
self.status_code = status_code
|
|
112
|
+
self.error_type = error_type or ""
|
|
113
|
+
self.code = code
|
|
114
|
+
self.request_id = request_id or ""
|
|
115
|
+
self.retryable = _retryable(status_code, self.error_type, self.code, self.message) if retryable is None else bool(retryable)
|
|
116
|
+
self.raw = raw
|
|
117
|
+
super().__init__(self.__str__())
|
|
118
|
+
|
|
119
|
+
def with_context(self, *, provider: str = "", model: str = "", endpoint: str = "") -> "APIError":
|
|
120
|
+
"Return a copy with missing context fields filled."
|
|
121
|
+
return APIError(
|
|
122
|
+
self.message,
|
|
123
|
+
provider=self.provider or provider,
|
|
124
|
+
model=self.model or model,
|
|
125
|
+
endpoint=self.endpoint or endpoint,
|
|
126
|
+
status_code=self.status_code,
|
|
127
|
+
error_type=self.error_type,
|
|
128
|
+
code=self.code,
|
|
129
|
+
request_id=self.request_id,
|
|
130
|
+
retryable=self.retryable,
|
|
131
|
+
raw=self.raw,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def __str__(self):
|
|
135
|
+
fields = ['message', 'provider', 'model', 'endpoint', 'status_code', 'error_type', 'code', 'request_id', 'retryable']
|
|
136
|
+
parts = [f"{f}={getattr(self, f)!r}" for f in fields if getattr(self, f) not in (None, "", False)]
|
|
137
|
+
return f"APIError({', '.join(parts)})"
|
|
138
|
+
|
|
139
|
+
__repr__ = __str__
|
|
140
|
+
|
|
141
|
+
# %% ../nbs/00_errors.ipynb #83d2dc0c
|
|
142
|
+
@patch
|
|
143
|
+
def api_error(self:httpx.HTTPStatusError, *, provider: str = "", model: str = "", endpoint: str = ""):
|
|
144
|
+
"Build APIError from httpx HTTPStatusError."
|
|
145
|
+
resp = self.response
|
|
146
|
+
err = resp.error()
|
|
147
|
+
if not endpoint and resp.request is not None:
|
|
148
|
+
endpoint = f"{resp.request.method.upper()} {resp.request.url.path}"
|
|
149
|
+
return APIError(
|
|
150
|
+
err.message,
|
|
151
|
+
provider=provider,
|
|
152
|
+
model=model,
|
|
153
|
+
endpoint=endpoint,
|
|
154
|
+
status_code=resp.status_code,
|
|
155
|
+
error_type=err.type,
|
|
156
|
+
code=err.code,
|
|
157
|
+
request_id=_req_id(resp.headers),
|
|
158
|
+
raw=err.raw,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# %% ../nbs/00_errors.ipynb #e6e2cc84
|
|
162
|
+
def api_error_from_event(event, *, provider: str = "", model: str = "", endpoint: str = ""):
|
|
163
|
+
"Build APIError from provider SSE/event-level error payload."
|
|
164
|
+
parsed = _parse_sse_error_event(event)
|
|
165
|
+
return APIError(parsed.message, provider=provider, model=model, endpoint=endpoint, error_type=parsed.type, code=parsed.code, raw=parsed.raw)
|
|
166
|
+
|
fastspec/oapi.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Dynamic OpenAPI operation client (async)."""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_oapi.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% auto #0
|
|
6
|
+
__all__ = ['sanitized_params', 'mk_sig', 'mk_doc', 'OpFunc', 'OpGroup', 'OpenAPIClient']
|
|
7
|
+
|
|
8
|
+
# %% ../nbs/04_oapi.ipynb #632a2010
|
|
9
|
+
import httpx,json,keyword
|
|
10
|
+
from inspect import Parameter, Signature
|
|
11
|
+
from urllib.parse import urlparse, urljoin, quote
|
|
12
|
+
from fastcore.utils import *
|
|
13
|
+
from fastcore.meta import delegates
|
|
14
|
+
|
|
15
|
+
from .errors import APIError
|
|
16
|
+
from .spec import OpSpec, SpecParser, snake
|
|
17
|
+
from .transport import AsyncTransport
|
|
18
|
+
|
|
19
|
+
# %% ../nbs/04_oapi.ipynb #68bf64d1
|
|
20
|
+
def _mk_param(name, required, anno=None, default=None):
|
|
21
|
+
"Create a function signature parameter."
|
|
22
|
+
anno = Parameter.empty if anno is None else anno
|
|
23
|
+
if default is None: default = Parameter.empty if required else UNSET
|
|
24
|
+
return Parameter(name, kind=Parameter.POSITIONAL_OR_KEYWORD, default=default, annotation=anno)
|
|
25
|
+
|
|
26
|
+
# %% ../nbs/04_oapi.ipynb #6d0f4f6c
|
|
27
|
+
def _sort_key(o):
|
|
28
|
+
if o.default is Parameter.empty: return 0
|
|
29
|
+
if o.default is UNSET: return 1
|
|
30
|
+
return 2
|
|
31
|
+
|
|
32
|
+
def sanitized_params(op):
|
|
33
|
+
"Mapping from original param names to valid Python identifiers."
|
|
34
|
+
res = {}
|
|
35
|
+
for p in op.route_params + op.query_params + op.body_params + op.file_params:
|
|
36
|
+
name = snake(re.sub(r'\W', '_', p).strip('_'))
|
|
37
|
+
if keyword.iskeyword(name): name += '_'
|
|
38
|
+
res[p] = name
|
|
39
|
+
return res
|
|
40
|
+
|
|
41
|
+
def mk_sig(op, sparams):
|
|
42
|
+
"Create a compact operation signature from an Opop."
|
|
43
|
+
params = []
|
|
44
|
+
for pname, sname in sparams.items():
|
|
45
|
+
params.append(_mk_param(sname, pname in op.required_params, op.param_types.get(pname), op.param_defaults.get(pname)))
|
|
46
|
+
return Signature(sorted(params, key=_sort_key))
|
|
47
|
+
|
|
48
|
+
# %% ../nbs/04_oapi.ipynb #4d489284
|
|
49
|
+
def _op_summary(op):
|
|
50
|
+
'Single line op summary with fallback and link rewriting'
|
|
51
|
+
s = re.sub(r"\s+", " ", str(op.summary or f"{op.verb} {op.path}")).strip() or f"{op.verb} {op.path}"
|
|
52
|
+
if not op.docs_url: return s
|
|
53
|
+
p = urlparse(op.docs_url)
|
|
54
|
+
base = f"{p.scheme}://{p.netloc}"
|
|
55
|
+
return re.sub(r"\]\((/[^)]+)\)", lambda m: f"]({urljoin(base, m[1].strip())})", s)
|
|
56
|
+
|
|
57
|
+
# %% ../nbs/04_oapi.ipynb #48cf5a69
|
|
58
|
+
def _op_line(op, sig):
|
|
59
|
+
head = f"{'.'.join(snake(g) for g in listify(op.group))}.{op.name}"
|
|
60
|
+
if op.docs_url: head = f"[{head}]({op.docs_url})"
|
|
61
|
+
s = f"({', '.join(sig.parameters)})"
|
|
62
|
+
summ = _op_summary(op)
|
|
63
|
+
return f"{head}{s}: *{summ}*"
|
|
64
|
+
|
|
65
|
+
# %% ../nbs/04_oapi.ipynb #3fb52bb7
|
|
66
|
+
def mk_doc(op, sig, sparams):
|
|
67
|
+
"Render operation docstring with summary, docs URL, and parameter hints."
|
|
68
|
+
lines = [_op_summary(op)]
|
|
69
|
+
if op.docs_url: lines.append(f"\nDocs: {op.docs_url}")
|
|
70
|
+
if sig.parameters:
|
|
71
|
+
lines.append("\nParameters:")
|
|
72
|
+
req = set(op.required_params or [])
|
|
73
|
+
# Reverse map: sparamsitized → original, for looking up docs/required
|
|
74
|
+
rsparams = {v:k for k,v in sparams.items()}
|
|
75
|
+
for nm,p in sig.parameters.items():
|
|
76
|
+
orig = rsparams.get(nm, nm)
|
|
77
|
+
r = "required" if orig in req else "optional"
|
|
78
|
+
ann = '' if p.annotation is Parameter.empty else p.annotation.__name__
|
|
79
|
+
desc = (op.param_docs or {}).get(orig, "")
|
|
80
|
+
lines.append(f"- {nm} ({ann}, {r}){': ' + desc if desc else ''}")
|
|
81
|
+
return "\n".join(lines)
|
|
82
|
+
|
|
83
|
+
# %% ../nbs/04_oapi.ipynb #6b2f1057
|
|
84
|
+
class OpFunc:
|
|
85
|
+
def __init__(self, op_spec, client, base_url):
|
|
86
|
+
store_attr()
|
|
87
|
+
self.sparams = sanitized_params(op_spec)
|
|
88
|
+
self.__signature__ = mk_sig(op_spec, self.sparams)
|
|
89
|
+
self.__doc__ = mk_doc(op_spec, self.__signature__, self.sparams)
|
|
90
|
+
self.__name__ = op_spec.name
|
|
91
|
+
self.name = op_spec.name
|
|
92
|
+
self.group = op_spec.group
|
|
93
|
+
self.path = op_spec.path
|
|
94
|
+
self.verb = op_spec.verb
|
|
95
|
+
self.route_params = op_spec.route_params
|
|
96
|
+
self.query_params = op_spec.query_params
|
|
97
|
+
self.body_params = op_spec.body_params
|
|
98
|
+
self.file_params = op_spec.file_params
|
|
99
|
+
self.summary = op_spec.summary
|
|
100
|
+
self.docs_url = op_spec.docs_url
|
|
101
|
+
|
|
102
|
+
def _repr_markdown_(self): return self.__doc__
|
|
103
|
+
__repr__ = basic_repr()
|
|
104
|
+
|
|
105
|
+
# %% ../nbs/04_oapi.ipynb #6e381df4
|
|
106
|
+
@patch
|
|
107
|
+
def _bind(self:OpFunc, args, kwargs):
|
|
108
|
+
'Prepare kwargs from args and kwargs'
|
|
109
|
+
flds = [o for o in self.__signature__.parameters if o not in kwargs]
|
|
110
|
+
for a,b in zip(args, flds): kwargs[b] = a
|
|
111
|
+
return kwargs
|
|
112
|
+
|
|
113
|
+
# %% ../nbs/04_oapi.ipynb #9327d89c
|
|
114
|
+
@patch
|
|
115
|
+
def _split(self:OpFunc, kwargs):
|
|
116
|
+
"Split kwargs into route/query/body/files + control kwargs."
|
|
117
|
+
stream = kwargs.get("stream", False)
|
|
118
|
+
headers = kwargs.pop("_headers", {})
|
|
119
|
+
# Map sanitized names back to originals
|
|
120
|
+
rsparams = {v:k for k,v in self.sparams.items()}
|
|
121
|
+
|
|
122
|
+
route, query, body, files = {}, {}, {}, {}
|
|
123
|
+
for k,v in kwargs.items():
|
|
124
|
+
if v is UNSET: continue
|
|
125
|
+
orig = rsparams.get(k, k)
|
|
126
|
+
if orig in self.route_params: route[orig] = v
|
|
127
|
+
elif orig in self.file_params: files[orig] = v
|
|
128
|
+
elif orig in self.query_params: query[orig] = v
|
|
129
|
+
elif orig in self.body_params: body[orig] = v
|
|
130
|
+
|
|
131
|
+
query.update(kwargs.pop("_query", {}))
|
|
132
|
+
body.update(kwargs.pop("_body", {}))
|
|
133
|
+
if self.verb in ("GET", "DELETE", "HEAD", "OPTIONS") and not body: body = None
|
|
134
|
+
return stream, headers, route, query, body, files
|
|
135
|
+
|
|
136
|
+
# %% ../nbs/04_oapi.ipynb #7b604f4e
|
|
137
|
+
def _path(path, route_params={}):
|
|
138
|
+
"Apply route params to path template."
|
|
139
|
+
if not route_params: return path
|
|
140
|
+
for k,v in route_params.items():
|
|
141
|
+
s = str(v)
|
|
142
|
+
safe = "/" if "/" in s else ""
|
|
143
|
+
path = path.replace("{" + k + "}", quote(s, safe=safe))
|
|
144
|
+
path = path.replace("{+" + k + "}", quote(str(v), safe="/"))
|
|
145
|
+
return re.sub(r"\{\+([^}]+)\}", lambda m: "{" + m.group(1) + "}", path)
|
|
146
|
+
|
|
147
|
+
# %% ../nbs/04_oapi.ipynb #5f8425a6
|
|
148
|
+
def _join_url(base, path):
|
|
149
|
+
"Join base URL and path, ensuring correct slash handling."
|
|
150
|
+
return urljoin(base.rstrip("/") + "/", path.lstrip("/"))
|
|
151
|
+
|
|
152
|
+
# %% ../nbs/04_oapi.ipynb #ca48c44b
|
|
153
|
+
@patch
|
|
154
|
+
def _raise_with_context(self:OpFunc, exc: Exception, *, endpoint: str, route: Optional[dict], query: Optional[dict], body: Optional[dict]):
|
|
155
|
+
"Raise APIError with operation context for dynamic op calls."
|
|
156
|
+
provider,model,ep = '','',''
|
|
157
|
+
# TODO: Make APIError generic, users can modify/subclass it include additional info like model,provider etc..
|
|
158
|
+
if isinstance(exc, httpx.HTTPStatusError): raise exc.api_error(provider=provider, model=model) from exc
|
|
159
|
+
raise exc
|
|
160
|
+
|
|
161
|
+
# %% ../nbs/04_oapi.ipynb #c7c96f87
|
|
162
|
+
@patch
|
|
163
|
+
@delegates(AsyncTransport.request) # files, raw
|
|
164
|
+
async def _request(self:OpFunc, url, *, headers=None, query=None, body=None, route=None, **kwargs):
|
|
165
|
+
"Execute an HTTP request and return decoded response."
|
|
166
|
+
try: return await self.client.request(self.verb, url, headers=headers, params=query, json_data=body, **kwargs)
|
|
167
|
+
except Exception as e: self._raise_with_context(e, endpoint='', route=route, query=query, body=body)
|
|
168
|
+
|
|
169
|
+
@patch
|
|
170
|
+
@delegates(AsyncTransport.stream) # files, raw
|
|
171
|
+
async def _stream(self:OpFunc, url, *, headers=None, query=None, body=None, route=None, **kwargs):
|
|
172
|
+
"Execute an SSE request yielding parsed JSON events."
|
|
173
|
+
try:
|
|
174
|
+
async for ev in self.client.stream(self.verb, url, headers=headers, params=query, json_data=body, **kwargs): yield ev
|
|
175
|
+
except Exception as e: self._raise_with_context(e, endpoint='', route=route, query=query, body=body)
|
|
176
|
+
|
|
177
|
+
# %% ../nbs/04_oapi.ipynb #3b0399ad
|
|
178
|
+
@patch
|
|
179
|
+
async def __call__(self:OpFunc, *args, **kwargs):
|
|
180
|
+
stream, headers, route, query, body, files = self._split(self._bind(args, kwargs))
|
|
181
|
+
url = _join_url(self.base_url, _path(self.path, route_params=route))
|
|
182
|
+
if files: kw = dict(body=None, files=files, data=body or None)
|
|
183
|
+
else: kw = dict(body=body)
|
|
184
|
+
if stream: return self._stream(url, headers=headers, query=query, route=route, **kw)
|
|
185
|
+
return await self._request(url, headers=headers, query=query, route=route, **kw)
|
|
186
|
+
|
|
187
|
+
# %% ../nbs/04_oapi.ipynb #99464917
|
|
188
|
+
class OpGroup:
|
|
189
|
+
"Simple namespace for grouped operations."
|
|
190
|
+
def __init__(self, name: str, ops: Iterable[OpFunc]):
|
|
191
|
+
self.name,self.ops = name,list(ops)
|
|
192
|
+
repr_md = []
|
|
193
|
+
for op in self.ops:
|
|
194
|
+
setattr(self, op.name, op)
|
|
195
|
+
if hasattr(op, '__signature__'): repr_md.append(f"- {_op_line(op, op.__signature__)}")
|
|
196
|
+
self.__doc__ = "\n".join(repr_md)
|
|
197
|
+
|
|
198
|
+
def _repr_markdown_(self): return self.__doc__
|
|
199
|
+
|
|
200
|
+
# %% ../nbs/04_oapi.ipynb #51975775
|
|
201
|
+
def _build_groups(ops:List[OpFunc]):
|
|
202
|
+
"Build nested op group tree from ops."
|
|
203
|
+
root = {}
|
|
204
|
+
for op in ops:
|
|
205
|
+
g = [snake(p) for p in listify(op.group)]
|
|
206
|
+
node = root
|
|
207
|
+
for part in g[:-1]: node = node.setdefault(part, {})
|
|
208
|
+
node.setdefault(g[-1], {}).setdefault('_ops', []).append(op)
|
|
209
|
+
|
|
210
|
+
def _mk(name, d):
|
|
211
|
+
grp = OpGroup(name, d.pop('_ops', []))
|
|
212
|
+
for k,v in d.items(): setattr(grp, k, _mk(k, v))
|
|
213
|
+
return grp
|
|
214
|
+
|
|
215
|
+
return {k: _mk(k, v) for k,v in root.items()}
|
|
216
|
+
|
|
217
|
+
# %% ../nbs/04_oapi.ipynb #d4ff9dd9
|
|
218
|
+
class OpenAPIClient:
|
|
219
|
+
"Async client built from OpenAPI operation metadata."
|
|
220
|
+
def __init__(self, spec, *, headers=None, timeout=60.0):
|
|
221
|
+
self.transport = AsyncTransport(timeout=timeout, base_headers=headers)
|
|
222
|
+
self.ops = [OpFunc(o, self.transport, spec.base_url) for o in spec.ops]
|
|
223
|
+
self.func_dict = {f"{o.path}:{o.verb.upper()}": o for o in self.ops}
|
|
224
|
+
self.groups = _build_groups(self.ops)
|
|
225
|
+
for k,v in self.groups.items(): setattr(self, k, v)
|