fastspec 0.2.2__tar.gz → 0.2.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastspec
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Dynamic OpenAPI, Discovery, and GraphQL spec client for Python — turn any API spec into a fully-typed async client with attribute chaining, streaming, and file uploads
5
5
  Author-email: Kerem Turgutlu <keremturgutlu@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -13,7 +13,7 @@ Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Requires-Dist: fastcore>=2.2.1
16
- Requires-Dist: fasttransport>=0.0.1
16
+ Requires-Dist: fasttransport>=0.0.2
17
17
  Provides-Extra: dev
18
18
  Requires-Dist: safepyrun>=0.2.3; extra == "dev"
19
19
  Requires-Dist: pyskills>=0.0.17; extra == "dev"
@@ -4,4 +4,4 @@ Modules:
4
4
 
5
5
  - `fastspec.gql`: GraphQL client: distill a schema once, then build schema-checked queries by attribute chaining, batched into single requests"""
6
6
 
7
- __version__ = "0.2.2"
7
+ __version__ = "0.2.4"
@@ -5,23 +5,7 @@ d = { 'settings': { 'branch': 'main',
5
5
  'doc_host': 'https://AnswerDotAI.github.io',
6
6
  'git_url': 'https://github.com/AnswerDotAI/fastspec',
7
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.SpecError': ('errors.html#specerror', 'fastspec/errors.py'),
14
- 'fastspec.errors._parse_sse_error_event': ('errors.html#_parse_sse_error_event', 'fastspec/errors.py'),
15
- 'fastspec.errors._req_id': ('errors.html#_req_id', 'fastspec/errors.py'),
16
- 'fastspec.errors._retryable': ('errors.html#_retryable', 'fastspec/errors.py'),
17
- 'fastspec.errors._to_text': ('errors.html#_to_text', 'fastspec/errors.py'),
18
- 'fastspec.errors.api_error_from_event': ('errors.html#api_error_from_event', 'fastspec/errors.py'),
19
- 'fastspec.errors.httpx2.HTTPStatusError.api_error': ( 'errors.html#httpx2.httpstatuserror.api_error',
20
- 'fastspec/errors.py'),
21
- 'fastspec.errors.httpx2.RequestError.api_error': ( 'errors.html#httpx2.requesterror.api_error',
22
- 'fastspec/errors.py'),
23
- 'fastspec.errors.httpx2.Response.error': ('errors.html#httpx2.response.error', 'fastspec/errors.py')},
24
- 'fastspec.gql': { 'fastspec.gql.GqlClient': ('gql.html#gqlclient', 'fastspec/gql.py'),
8
+ 'syms': { 'fastspec.gql': { 'fastspec.gql.GqlClient': ('gql.html#gqlclient', 'fastspec/gql.py'),
25
9
  'fastspec.gql.GqlClient.__call__': ('gql.html#gqlclient.__call__', 'fastspec/gql.py'),
26
10
  'fastspec.gql.GqlClient.__dir__': ('gql.html#gqlclient.__dir__', 'fastspec/gql.py'),
27
11
  'fastspec.gql.GqlClient.__getattr__': ('gql.html#gqlclient.__getattr__', 'fastspec/gql.py'),
@@ -87,8 +71,10 @@ d = { 'settings': { 'branch': 'main',
87
71
  'fastspec.oapi.OpenAPIClient.full_docs': ('oapi.html#openapiclient.full_docs', 'fastspec/oapi.py'),
88
72
  'fastspec.oapi.SyncOpFunc': ('oapi.html#syncopfunc', 'fastspec/oapi.py'),
89
73
  'fastspec.oapi.SyncOpFunc.__call__': ('oapi.html#syncopfunc.__call__', 'fastspec/oapi.py'),
74
+ 'fastspec.oapi._examples_doc': ('oapi.html#_examples_doc', 'fastspec/oapi.py'),
90
75
  'fastspec.oapi._join_url': ('oapi.html#_join_url', 'fastspec/oapi.py'),
91
- 'fastspec.oapi._path': ('oapi.html#_path', 'fastspec/oapi.py')},
76
+ 'fastspec.oapi._path': ('oapi.html#_path', 'fastspec/oapi.py'),
77
+ 'fastspec.oapi.op_func': ('oapi.html#op_func', 'fastspec/oapi.py')},
92
78
  'fastspec.spec': { 'fastspec.spec.OpSpec': ('spec.html#opspec', 'fastspec/spec.py'),
93
79
  'fastspec.spec.OpSpec.__post_init__': ('spec.html#opspec.__post_init__', 'fastspec/spec.py'),
94
80
  'fastspec.spec.OpSpec._repr_markdown_': ('spec.html#opspec._repr_markdown_', 'fastspec/spec.py'),
@@ -5,7 +5,7 @@ Docs: https://AnswerDotAI.github.io/fastspec/oapi.html.md"""
5
5
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_oapi.ipynb.
6
6
 
7
7
  # %% auto #0
8
- __all__ = ['OpFunc', 'SyncOpFunc', 'OpenAPIClient']
8
+ __all__ = ['OpFunc', 'op_func', 'SyncOpFunc', 'OpenAPIClient']
9
9
 
10
10
  # %% ../nbs/04_oapi.ipynb #632a2010
11
11
  import httpx2,json
@@ -14,38 +14,32 @@ from fastcore.utils import *
14
14
  from fastcore.meta import delegates
15
15
  from fastcore.apisurface import snake, sanitized_params, mk_sig, mk_doc, OpGroup, mk_groups, full_docs as _full_docs
16
16
 
17
- from .errors import APIError
17
+ from fasttransport.errors import APIError
18
18
  from .spec import OpSpec, SpecParser
19
19
  from fasttransport.core import AsyncTransport,SyncTransport
20
20
 
21
21
 
22
22
  # %% ../nbs/04_oapi.ipynb #6b2f1057
23
+ def _examples_doc(exs):
24
+ "Render body examples as an `Examples:` doc section, one fenced JSON block per example"
25
+ if not exs: return ''
26
+ return '\n\nExamples:' + ''.join(f"\n- {v.get('summary') or k}:\n```json\n{json.dumps(v['value'], indent=2)}\n```" for k,v in exs.items())
27
+
23
28
  class OpFunc:
24
29
  def __init__(self, op_spec, client, base_url, form_encoder=None, defaults=None):
25
30
  store_attr()
26
31
  self.defaults = defaults or {}
27
32
  self.sparams = sanitized_params(op_spec.params)
28
- self.__name__ = op_spec.name
29
- self.name = op_spec.name
30
- self.group = op_spec.group
31
- self.path = op_spec.path
32
- self.verb = op_spec.verb
33
- self.route_params = op_spec.route_params
34
- self.query_params = op_spec.query_params
35
- self.body_params = op_spec.body_params
36
- self.request_content_type = op_spec.request_content_type
37
- self.file_params = op_spec.file_params
38
- self.summary = op_spec.summary or f"{op_spec.verb} {op_spec.path}"
39
- self.docs_url = op_spec.docs_url
40
- self.required_params = op_spec.required_params
41
- self.param_docs = op_spec.param_docs
33
+ for o in ('name group path verb route_params query_params body_params request_content_type '
34
+ 'file_params docs_url required_params param_docs').split(): setattr(self, o, getattr(op_spec, o))
35
+ self.__name__ = self.name
36
+ self.summary = op_spec.summary or f"{op_spec.verb} {op_spec.path}"
42
37
  self.__signature__ = mk_sig(op_spec, self.sparams, self.defaults)
43
- self.__doc__ = mk_doc(self, self.__signature__, self.sparams)
38
+ self.__doc__ = mk_doc(self, self.__signature__, self.sparams) + _examples_doc(op_spec.body_examples)
44
39
 
45
40
  def _repr_markdown_(self): return self.__doc__
46
41
  def __repr__(self): return f"{'.'.join(snake(g) for g in listify(self.group))}.{self.name}{self.__signature__}\n{self.docs_url}"
47
42
 
48
-
49
43
  # %% ../nbs/04_oapi.ipynb #6e381df4
50
44
  @patch
51
45
  def _bind(self:OpFunc, args, kwargs):
@@ -147,6 +141,13 @@ async def __call__(self:OpFunc, *args, **kwargs):
147
141
  if stream: return self._stream(url, headers=headers, query=query, route=route, **kw)
148
142
  return await self._request(url, headers=headers, query=query, route=route, **kw)
149
143
 
144
+ # %% ../nbs/04_oapi.ipynb #75d6cd54
145
+ def op_func(spec, name, group=None):
146
+ "A detached `OpFunc` for spec op `name`: a signature and docs donor, e.g. a `delegates` target for wrappers"
147
+ o = first(o for o in spec.ops if o.name == name and (group is None or o.group == group))
148
+ if o is None: raise ValueError(f'no op named {name!r}' + (f' in group {group!r}' if group else ''))
149
+ return OpFunc(o, None, '')
150
+
150
151
  # %% ../nbs/04_oapi.ipynb #b23f43ae
151
152
  class SyncOpFunc(OpFunc):
152
153
  "`OpFunc` over a `SyncTransport`: calls return results directly, with no `await`."
@@ -32,12 +32,13 @@ class OpSpec:
32
32
  param_types: Dict = field(default_factory=dict)
33
33
  param_defaults: Dict = field(default_factory=dict)
34
34
  param_docs: Dict = field(default_factory=dict)
35
+ body_examples: Dict = field(default_factory=dict)
35
36
  docs_url: str = ""
36
37
 
37
38
  def mk_doc(self):
38
39
  rows = []
39
40
  for f,v in vars(self).items():
40
- if f not in ('param_types','param_defaults','param_docs'): rows.append(f'| `{f}` | {v} |')
41
+ if f not in ('param_types','param_defaults','param_docs','body_examples'): rows.append(f'| `{f}` | {v} |')
41
42
  md = f'| Field | Value |\n|---|---|\n' + '\n'.join(rows)
42
43
  all_params = self.route_params + self.query_params + self.body_params
43
44
  if all_params:
@@ -197,10 +198,13 @@ def _body_params(op, spec):
197
198
  rb = _resolve_obj(op.get("requestBody", {}), spec)
198
199
  content = rb.get("content", {})
199
200
  ct = first((ct for ct in ctypes if ct in content), None)
200
- schema = content.get(ct, {}).get("schema") if ct else None
201
+ mt = content.get(ct) or {}
202
+ schema = mt.get("schema")
203
+ exs = mt.get("examples") or ({"default": {"value": mt["example"]}} if "example" in mt else {})
204
+ bexs = {k: {"summary": r.get("summary",""), "value": r["value"]} for k,v in exs.items() if "value" in (r := _resolve_obj(v, spec))}
201
205
  if not schema:
202
206
  return AttrDict(body_params=[], file_params=[], required_params=set(), param_types={},
203
- param_docs={}, param_defaults={}, request_content_type=ct)
207
+ param_docs={}, param_defaults={}, request_content_type=ct, body_examples=bexs)
204
208
  props, req = _schema_props_required(schema, spec)
205
209
  fparams = [k for k,v in props.items() if _resolve_obj(v, spec).get("format") == "binary"]
206
210
  bparams = [k for k in props if k not in fparams]
@@ -210,7 +214,7 @@ def _body_params(op, spec):
210
214
  # Params without a default or nullable type are required
211
215
  # req |= {k for k in props if k not in defaults} # too aggressive doesn't match when spec is incomplete
212
216
  return AttrDict(body_params=bparams, file_params=fparams, required_params=req, param_types=ptypes,
213
- param_docs=pdocs, param_defaults=defaults, request_content_type=ct)
217
+ param_docs=pdocs, param_defaults=defaults, request_content_type=ct, body_examples=bexs)
214
218
 
215
219
  # %% ../nbs/03_spec.ipynb #9ab326f7
216
220
  _pat_md_url = re.compile(r"\[[^\]]+\]\((https?://[^)\s]+)\)")
@@ -232,7 +236,7 @@ def _op_docs_url(op):
232
236
  if desc and (durl := _first_url(desc)): return durl
233
237
 
234
238
  # %% ../nbs/03_spec.ipynb #f74f3391
235
- _py_types = {t.__name__:t for t in (str,int,float,bool,list,dict)}
239
+ _py_types = {t.__name__:t for t in (str,int,float,bool,list,dict,bytes,type(None))}
236
240
 
237
241
  def _plain(v):
238
242
  "Normalize `v` to plain data: sets sorted, `L`/tuples to `list`, dicts recursed."
@@ -268,7 +272,7 @@ def openapi_to_ops(spec, group_func=None):
268
272
  required_params=sorted(pdict.required_params | bpdict.required_params),
269
273
  param_types={k:v for k,v in merge(pdict.param_types, bpdict.param_types).items() if v},
270
274
  param_defaults=_plain(merge(pdict.param_defaults, bpdict.param_defaults)), param_docs=merge(pdict.param_docs, bpdict.param_docs),
271
- docs_url=_op_docs_url(op) or ""))
275
+ docs_url=_op_docs_url(op) or "", body_examples=_plain(bpdict.body_examples)))
272
276
  return res
273
277
 
274
278
  # %% ../nbs/03_spec.ipynb #78ea6617
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastspec
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Dynamic OpenAPI, Discovery, and GraphQL spec client for Python — turn any API spec into a fully-typed async client with attribute chaining, streaming, and file uploads
5
5
  Author-email: Kerem Turgutlu <keremturgutlu@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -13,7 +13,7 @@ Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Requires-Dist: fastcore>=2.2.1
16
- Requires-Dist: fasttransport>=0.0.1
16
+ Requires-Dist: fasttransport>=0.0.2
17
17
  Provides-Extra: dev
18
18
  Requires-Dist: safepyrun>=0.2.3; extra == "dev"
19
19
  Requires-Dist: pyskills>=0.0.17; extra == "dev"
@@ -4,7 +4,6 @@ README.md
4
4
  pyproject.toml
5
5
  fastspec/__init__.py
6
6
  fastspec/_modidx.py
7
- fastspec/errors.py
8
7
  fastspec/gql.py
9
8
  fastspec/oapi.py
10
9
  fastspec/spec.py
@@ -1,5 +1,5 @@
1
1
  fastcore>=2.2.1
2
- fasttransport>=0.0.1
2
+ fasttransport>=0.0.2
3
3
 
4
4
  [dev]
5
5
  safepyrun>=0.2.3
@@ -15,7 +15,7 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3",
16
16
  "Programming Language :: Python :: 3 :: Only",
17
17
  ]
18
- dependencies = ['fastcore>=2.2.1', 'fasttransport>=0.0.1']
18
+ dependencies = ['fastcore>=2.2.1', 'fasttransport>=0.0.2']
19
19
 
20
20
  [project.optional-dependencies]
21
21
  dev = ['safepyrun>=0.2.3', 'pyskills>=0.0.17']
@@ -1,178 +0,0 @@
1
- """fastspec errors.
2
-
3
- Docs: https://AnswerDotAI.github.io/fastspec/errors.html.md"""
4
-
5
- # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_errors.ipynb.
6
-
7
- # %% auto #0
8
- __all__ = ['FastSpecError', 'SpecError', 'APIError', 'api_error_from_event']
9
-
10
- # %% ../nbs/00_errors.ipynb #b4e7c7ed
11
- from fastcore.utils import *
12
- import json, httpx2
13
-
14
- # %% ../nbs/00_errors.ipynb #87c600ab
15
- class FastSpecError(Exception):
16
- "Base fastspec error."
17
-
18
- # %% ../nbs/00_errors.ipynb #3832e5fa
19
- class SpecError(FastSpecError):
20
- "Raised when an OpenAPI spec cannot be parsed as expected."
21
-
22
- # %% ../nbs/00_errors.ipynb #73881e99
23
- def _to_text(v):
24
- "Best-effort stringify helper."
25
- if v is None: return ""
26
- if isinstance(v, str): return v
27
- try: return json.dumps(v, ensure_ascii=False)
28
- except Exception: return str(v)
29
-
30
- # %% ../nbs/00_errors.ipynb #2d51e006
31
- def _retryable(status_code, error_type, code, message):
32
- "Classify transient/retryable API failures."
33
- if isinstance(status_code, int) and (status_code >= 500 or status_code in (408, 409, 425, 429)): return True
34
- t = str(error_type or "").lower()
35
- c = str(code or "").lower()
36
- m = (message or "").lower()
37
- hints = ("server_error", "internal", "overload", "rate_limit", "timeout", "unavailable", "temporar")
38
- if any(h in t for h in hints): return True
39
- if any(h in c for h in hints): return True
40
- if any(h in m for h in ("try again", "server error", "temporar", "timeout", "overloaded", "unavailable", "rate limit")): return True
41
- return False
42
-
43
- # %% ../nbs/00_errors.ipynb #9c1cdf65
44
- def _req_id(headers):
45
- "Extract provider request id header when available."
46
- if headers is None: return ""
47
- for k in ("x-request-id", "request-id", "anthropic-request-id", "x-goog-request-id"):
48
- v = headers.get(k)
49
- if v: return str(v)
50
- return ""
51
-
52
- # %% ../nbs/00_errors.ipynb #fdf25eba
53
- @patch
54
- def error(self: httpx2.Response):
55
- "Parse common HTTP API error shapes to (message, error_type, code, raw)."
56
- try: raw = self.json()
57
- except Exception: raw = self.text
58
-
59
- msg, et, code, status = "", "", None, None
60
- if isinstance(raw, dict):
61
- err = raw.get("error")
62
- if isinstance(err, dict):
63
- msg = str(err.get("message") or raw.get("message") or raw.get("detail") or "")
64
- status = err.get("status", raw.get("status"))
65
- et = str(err.get("type") or status or raw.get("type") or "")
66
- code = err.get("code", raw.get("code"))
67
- elif err is not None:
68
- msg = _to_text(err)
69
- status = raw.get("status")
70
- et = str(raw.get("type") or status or "")
71
- code = raw.get("code")
72
- else:
73
- msg = str(raw.get("message") or raw.get("detail") or raw.get("error_description") or "")
74
- status = raw.get("status")
75
- et = str(raw.get("type") or status or "")
76
- code = raw.get("code")
77
- if not msg: msg = _to_text(raw)
78
- else:
79
- msg = _to_text(raw)
80
- # Prefer semantic provider codes (e.g. Gemini `status`) over numeric HTTP-like codes.
81
- if (code is None or isinstance(code, int)) and isinstance(status, str) and status:
82
- code = status
83
- if code in (None, "") and isinstance(et, str) and et:
84
- code = et
85
- return AttrDict(message=msg, type=et, code=code, raw=raw)
86
-
87
- # %% ../nbs/00_errors.ipynb #24ed43ac
88
- def _parse_sse_error_event(event):
89
- "Parse common SSE `error` event shapes to (message, error_type, code, raw)."
90
- raw = event
91
- e = event.get("error") if isinstance(event, dict) and isinstance(event.get("error"), dict) else (
92
- event if isinstance(event, dict) else {"message": _to_text(event)})
93
- msg = str(e.get("message") or e.get("detail") or _to_text(e))
94
- et = str(e.get("type") or e.get("status") or "")
95
- code = e.get("code")
96
- if (code is None or isinstance(code, int)) and isinstance(et, str) and et: code = et
97
- return AttrDict(message=msg, type=et, code=code, raw=raw)
98
-
99
- # %% ../nbs/00_errors.ipynb #0de145da
100
- class APIError(FastSpecError):
101
- "Structured API error with optional context."
102
- def __init__(self, message: str, *, provider: str = "", model: str = "", endpoint: str = "",
103
- status_code= None, error_type: str = "", code = None, request_id: str = "",
104
- retryable = None, raw = None, response = None):
105
- self.message = message or "API request failed"
106
- self.provider = provider or ""
107
- self.model = model or ""
108
- self.endpoint = endpoint or ""
109
- self.status_code = status_code
110
- self.error_type = error_type or ""
111
- self.code = code
112
- self.request_id = request_id or ""
113
- self.retryable = _retryable(status_code, self.error_type, self.code, self.message) if retryable is None else bool(retryable)
114
- self.raw = raw
115
- self.response = response
116
- super().__init__(self.__str__())
117
-
118
- def with_context(self, *, provider: str = "", model: str = "", endpoint: str = "") -> "APIError":
119
- "Return a copy with missing context fields filled."
120
- return APIError(
121
- self.message,
122
- provider=self.provider or provider,
123
- model=self.model or model,
124
- endpoint=self.endpoint or endpoint,
125
- status_code=self.status_code,
126
- error_type=self.error_type,
127
- code=self.code,
128
- request_id=self.request_id,
129
- retryable=self.retryable,
130
- raw=self.raw,
131
- response=self.response,
132
- )
133
-
134
- def __str__(self):
135
- fields = ['message', 'provider', 'model', 'endpoint', 'status_code', 'error_type', 'code', 'request_id', 'retryable', 'raw']
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:httpx2.HTTPStatusError, *, provider: str = "", model: str = "", endpoint: str = ""):
144
- "Build APIError from httpx2 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
- response=resp,
160
- )
161
-
162
- # %% ../nbs/00_errors.ipynb #859197d5
163
- @patch
164
- def api_error(self:httpx2.RequestError, *, provider:str="", model:str="", endpoint:str=""):
165
- "Build APIError from httpx2 RequestError (transport-level failure)."
166
- req = getattr(self, '_request', None)
167
- if not endpoint and req is not None: endpoint = f"{req.method.upper()} {req.url.path}"
168
- et = type(self).__name__
169
- retry = isinstance(self, (httpx2.TimeoutException, httpx2.NetworkError, httpx2.ProtocolError, httpx2.ProxyError))
170
- return APIError(str(self) or et, provider=provider, model=model, endpoint=endpoint,
171
- error_type=et, code=et, retryable=retry, raw=self)
172
-
173
- # %% ../nbs/00_errors.ipynb #e6e2cc84
174
- def api_error_from_event(event, *, provider: str = "", model: str = "", endpoint: str = ""):
175
- "Build APIError from provider SSE/event-level error payload."
176
- parsed = _parse_sse_error_event(event)
177
- return APIError(parsed.message, provider=provider, model=model, endpoint=endpoint, error_type=parsed.type, code=parsed.code, raw=parsed.raw)
178
-
File without changes
File without changes
File without changes
File without changes
File without changes