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 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)