webfunction 0.1.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.
- webfunction/__init__.py +44 -0
- webfunction/_request.py +106 -0
- webfunction/_version.py +1 -0
- webfunction/client.py +296 -0
- webfunction/errors.py +54 -0
- webfunction/models.py +325 -0
- webfunction/page.py +134 -0
- webfunction/pipeline.py +163 -0
- webfunction/py.typed +0 -0
- webfunction/wftype.py +337 -0
- webfunction-0.1.0.dist-info/METADATA +100 -0
- webfunction-0.1.0.dist-info/RECORD +14 -0
- webfunction-0.1.0.dist-info/WHEEL +4 -0
- webfunction-0.1.0.dist-info/licenses/LICENSE +21 -0
webfunction/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""webfunction -- a reference client library for the Web Function (wfn) protocol.
|
|
2
|
+
|
|
3
|
+
See https://webfunction.org for the protocol specification.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ._version import VERSION
|
|
7
|
+
from .client import AsyncClient, Client
|
|
8
|
+
from .errors import (
|
|
9
|
+
BadRequestError,
|
|
10
|
+
JsonParseError,
|
|
11
|
+
UnexpectedStatusCodeError,
|
|
12
|
+
UnresolvedPromiseError,
|
|
13
|
+
WebFunctionError,
|
|
14
|
+
)
|
|
15
|
+
from .models import Argument, Attribute, DocumentedError, Endpoint, ObjectSchema, Package
|
|
16
|
+
from .page import AsyncPage, Page
|
|
17
|
+
from .pipeline import AsyncPipeline, Path, Pipeline, Promise
|
|
18
|
+
from . import wftype
|
|
19
|
+
|
|
20
|
+
__version__ = VERSION
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Client",
|
|
24
|
+
"AsyncClient",
|
|
25
|
+
"Package",
|
|
26
|
+
"Endpoint",
|
|
27
|
+
"Argument",
|
|
28
|
+
"Attribute",
|
|
29
|
+
"DocumentedError",
|
|
30
|
+
"ObjectSchema",
|
|
31
|
+
"Page",
|
|
32
|
+
"AsyncPage",
|
|
33
|
+
"Path",
|
|
34
|
+
"Promise",
|
|
35
|
+
"Pipeline",
|
|
36
|
+
"AsyncPipeline",
|
|
37
|
+
"WebFunctionError",
|
|
38
|
+
"BadRequestError",
|
|
39
|
+
"UnexpectedStatusCodeError",
|
|
40
|
+
"JsonParseError",
|
|
41
|
+
"UnresolvedPromiseError",
|
|
42
|
+
"wftype",
|
|
43
|
+
"VERSION",
|
|
44
|
+
]
|
webfunction/_request.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Low-level HTTP request/response handling shared by Client and AsyncClient, ported
|
|
2
|
+
from the Ruby reference gem's request.rb.
|
|
3
|
+
|
|
4
|
+
Gzip note: the wfn protocol always sends ``Accept-Encoding: gzip`` explicitly. This has
|
|
5
|
+
bitten *every* prior client in the suite -- webfunction-go and webfunction-java both
|
|
6
|
+
independently broke on gzip response bodies because their HTTP libraries disable their
|
|
7
|
+
own automatic decompression once the caller sets ``Accept-Encoding`` itself.
|
|
8
|
+
httpx does NOT have this problem: it decides whether to decompress based on the
|
|
9
|
+
response's ``Content-Encoding`` header, regardless of who set ``Accept-Encoding`` on the
|
|
10
|
+
request (verified directly against a mock gzip-compressing server -- see
|
|
11
|
+
tests/test_client.py::test_gzip_response). So no manual gzip handling is needed here,
|
|
12
|
+
but it's still covered by a real test rather than just assumed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ._version import VERSION
|
|
23
|
+
from .errors import BadRequestError, JsonParseError, UnexpectedStatusCodeError
|
|
24
|
+
from .pipeline import Promise
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _json_default(obj: Any) -> Any:
|
|
28
|
+
if isinstance(obj, Promise):
|
|
29
|
+
return obj.to_json_value()
|
|
30
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def dumps(obj: Any) -> bytes:
|
|
34
|
+
return json.dumps(obj, default=_json_default).encode("utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_headers(bearer_auth: Optional[str] = None, version: Optional[str] = None) -> Dict[str, str]:
|
|
38
|
+
headers = {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"Accept": "application/json",
|
|
41
|
+
"User-Agent": f"webfunction/{VERSION}",
|
|
42
|
+
"Accept-Encoding": "gzip",
|
|
43
|
+
}
|
|
44
|
+
if bearer_auth:
|
|
45
|
+
headers["Authorization"] = f"Bearer {bearer_auth}"
|
|
46
|
+
if version:
|
|
47
|
+
headers["Api-Version"] = version
|
|
48
|
+
return headers
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_response(status: int, body: bytes) -> Any:
|
|
52
|
+
"""Parses a raw HTTP response per the wfn protocol, raising typed errors as needed.
|
|
53
|
+
Does not do pagination wrapping -- that needs the endpoint's ``paginated`` flag, which
|
|
54
|
+
only the caller (Client/AsyncClient) knows about."""
|
|
55
|
+
if status not in (200, 400):
|
|
56
|
+
raise UnexpectedStatusCodeError(
|
|
57
|
+
f"Unexpected status code ({status})",
|
|
58
|
+
details={"status_code": status, "raw_body": body.decode("utf-8", "replace")},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
result = json.loads(body)
|
|
63
|
+
except json.JSONDecodeError as e:
|
|
64
|
+
raise JsonParseError(
|
|
65
|
+
str(e),
|
|
66
|
+
details={"status_code": status, "raw_body": body.decode("utf-8", "replace"), "original_exception": e},
|
|
67
|
+
) from e
|
|
68
|
+
|
|
69
|
+
if status == 400:
|
|
70
|
+
code, message, details = "WFN_BAD_REQUEST_ERROR", "Bad request", {"body": result}
|
|
71
|
+
if (
|
|
72
|
+
isinstance(result, list) and len(result) == 3
|
|
73
|
+
and isinstance(result[0], str) and isinstance(result[1], str)
|
|
74
|
+
):
|
|
75
|
+
code, message, details = result
|
|
76
|
+
raise BadRequestError(message, code=code, details=details)
|
|
77
|
+
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def execute_sync(http: httpx.Client, url: str, *, bearer_auth: Optional[str] = None,
|
|
82
|
+
version: Optional[str] = None, args: Any = None) -> Any:
|
|
83
|
+
headers = build_headers(bearer_auth, version)
|
|
84
|
+
response = http.post(url, headers=headers, content=dumps(args if args is not None else {}))
|
|
85
|
+
return parse_response(response.status_code, response.content)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def execute_async(http: httpx.AsyncClient, url: str, *, bearer_auth: Optional[str] = None,
|
|
89
|
+
version: Optional[str] = None, args: Any = None) -> Any:
|
|
90
|
+
headers = build_headers(bearer_auth, version)
|
|
91
|
+
response = await http.post(url, headers=headers, content=dumps(args if args is not None else {}))
|
|
92
|
+
return parse_response(response.status_code, response.content)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_body_sync(http: httpx.Client, url: str, *, extra_query_params: Optional[dict] = None) -> bytes:
|
|
96
|
+
params = {k: v for k, v in (extra_query_params or {}).items() if v is not None}
|
|
97
|
+
headers = {"User-Agent": f"webfunction/{VERSION}", "Accept-Encoding": "gzip"}
|
|
98
|
+
response = http.get(url, headers=headers, params=params)
|
|
99
|
+
return response.content
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def get_body_async(http: httpx.AsyncClient, url: str, *, extra_query_params: Optional[dict] = None) -> bytes:
|
|
103
|
+
params = {k: v for k, v in (extra_query_params or {}).items() if v is not None}
|
|
104
|
+
headers = {"User-Agent": f"webfunction/{VERSION}", "Accept-Encoding": "gzip"}
|
|
105
|
+
response = await http.get(url, headers=headers, params=params)
|
|
106
|
+
return response.content
|
webfunction/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.1.0"
|
webfunction/client.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Client and AsyncClient, ported from the Ruby reference gem's client.rb.
|
|
2
|
+
|
|
3
|
+
Dynamic dispatch (``client.list_items(a="b")`` working without any generated code) is
|
|
4
|
+
implemented via ``__getattr__``, the same trick Ruby/JS/PHP use via method_missing /
|
|
5
|
+
Proxy / __call -- confirmed as the right default for Python too, matching the majority
|
|
6
|
+
of the reference-client suite.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import _request
|
|
17
|
+
from .models import Package
|
|
18
|
+
from .page import AsyncPage, Page
|
|
19
|
+
from .pipeline import AsyncPipeline, Pipeline
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _join_url(base_url: str, endpoint_name: str) -> str:
|
|
23
|
+
# Plain string-level normalization per
|
|
24
|
+
# https://webfunction.org/package#url-composition: append endpoint_name
|
|
25
|
+
# directly if base_url ends in "/", otherwise insert a single "/".
|
|
26
|
+
#
|
|
27
|
+
# This deliberately does NOT use urljoin/RFC 3986 relative-reference
|
|
28
|
+
# resolution, despite the comment that used to be here claiming urljoin
|
|
29
|
+
# "already does the right thing to match Ruby's URI.join semantics".
|
|
30
|
+
# That was wrong: RFC 3986 resolution treats a base_url path segment as
|
|
31
|
+
# replaceable when base_url doesn't end in "/" -- e.g. resolving
|
|
32
|
+
# "list-people" against "https://api.example.com/v1" (no trailing
|
|
33
|
+
# slash) silently drops "v1" and produces
|
|
34
|
+
# "https://api.example.com/list-people" instead of
|
|
35
|
+
# "https://api.example.com/v1/list-people". The spec's own composition
|
|
36
|
+
# rule has no such failure mode: it works correctly for a base_url with
|
|
37
|
+
# or without a trailing slash. (Ruby's URI.join has the identical bug,
|
|
38
|
+
# confirmed directly -- so "matching Ruby" was never actually a safe
|
|
39
|
+
# bar to aim for here; webfunction-go had the same bug for the same
|
|
40
|
+
# reason and has since been fixed the same way.)
|
|
41
|
+
if base_url.endswith("/"):
|
|
42
|
+
return base_url + endpoint_name
|
|
43
|
+
return base_url + "/" + endpoint_name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Client:
|
|
47
|
+
"""A synchronous wrapper around a Web Function Package that provides a convenient
|
|
48
|
+
interface for invoking endpoints.
|
|
49
|
+
|
|
50
|
+
Example::
|
|
51
|
+
|
|
52
|
+
client = Client.from_package_endpoint("https://api.example.com/package")
|
|
53
|
+
client.list_items(a="b") # => {"c": "d"}
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, *, base_url: str, endpoints: Optional[List[str]] = None,
|
|
57
|
+
package: Optional[Package] = None, bearer_auth: Optional[str] = None,
|
|
58
|
+
version: Optional[str] = None, pipeline: Optional[Pipeline] = None,
|
|
59
|
+
http_client: Optional[httpx.Client] = None):
|
|
60
|
+
self._package = package
|
|
61
|
+
self._base_url = base_url
|
|
62
|
+
self._endpoints: Dict[str, str] = {e.replace("-", "_"): e for e in (endpoints or [])}
|
|
63
|
+
self.bearer_auth = bearer_auth
|
|
64
|
+
self.version = version
|
|
65
|
+
self.pipeline = pipeline
|
|
66
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
67
|
+
self._owns_http = http_client is None
|
|
68
|
+
|
|
69
|
+
# -- construction -----------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_package_endpoint(cls, url: str, *, bearer_auth: Optional[str] = None,
|
|
73
|
+
version: Optional[str] = None, pipelined: bool = False,
|
|
74
|
+
http_client: Optional[httpx.Client] = None) -> "Client":
|
|
75
|
+
http = http_client if http_client is not None else httpx.Client()
|
|
76
|
+
response = _request.execute_sync(http, url, bearer_auth=bearer_auth, version=version, args={})
|
|
77
|
+
package = Package.from_dict(response)
|
|
78
|
+
return cls.from_package(package, bearer_auth=bearer_auth, version=version,
|
|
79
|
+
pipelined=pipelined, http_client=http)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_url(cls, url: str, *, bearer_auth: Optional[str] = None, version: Optional[str] = None,
|
|
83
|
+
pipelined: bool = False, http_client: Optional[httpx.Client] = None) -> "Client":
|
|
84
|
+
http = http_client if http_client is not None else httpx.Client()
|
|
85
|
+
body = _request.get_body_sync(http, url, extra_query_params={"api_version": version})
|
|
86
|
+
package = Package.from_dict(json.loads(body))
|
|
87
|
+
return cls.from_package(package, bearer_auth=bearer_auth, version=version,
|
|
88
|
+
pipelined=pipelined, http_client=http)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_package(cls, package: Package, *, bearer_auth: Optional[str] = None,
|
|
92
|
+
version: Optional[str] = None, pipelined: bool = False,
|
|
93
|
+
http_client: Optional[httpx.Client] = None) -> "Client":
|
|
94
|
+
http = http_client if http_client is not None else httpx.Client()
|
|
95
|
+
pipeline = None
|
|
96
|
+
if pipelined and package.pipeline_url:
|
|
97
|
+
pipeline = Pipeline(
|
|
98
|
+
package.pipeline_url,
|
|
99
|
+
lambda url, args: _request.execute_sync(http, url, bearer_auth=bearer_auth, version=version, args=args),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
client = cls(
|
|
103
|
+
package=package,
|
|
104
|
+
base_url=package.base_url,
|
|
105
|
+
endpoints=[e.name for e in package.endpoints],
|
|
106
|
+
bearer_auth=bearer_auth,
|
|
107
|
+
version=version,
|
|
108
|
+
pipeline=pipeline,
|
|
109
|
+
http_client=http,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
for endpoint in package.endpoints:
|
|
113
|
+
endpoint.client = client
|
|
114
|
+
|
|
115
|
+
return client
|
|
116
|
+
|
|
117
|
+
# -- invocation ---------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def call(self, endpoint_name: str, args: Optional[dict] = None) -> Any:
|
|
120
|
+
"""Calls an endpoint by name with the given arguments. Paginated responses are
|
|
121
|
+
wrapped in a :class:`Page`. When the client is pipelined, returns a
|
|
122
|
+
:class:`~webfunction.pipeline.Promise` instead of executing immediately."""
|
|
123
|
+
args = args or {}
|
|
124
|
+
url = _join_url(self._base_url, endpoint_name)
|
|
125
|
+
|
|
126
|
+
if self.pipeline is not None:
|
|
127
|
+
step = {"url": url, "headers": _request.build_headers(self.bearer_auth, self.version), "body": args}
|
|
128
|
+
return self.pipeline.add_step(step)
|
|
129
|
+
|
|
130
|
+
return self._execute_and_wrap(url, args, endpoint_name)
|
|
131
|
+
|
|
132
|
+
def _execute_and_wrap(self, url: str, args: dict, endpoint_name: str) -> Any:
|
|
133
|
+
response = _request.execute_sync(self._http, url, bearer_auth=self.bearer_auth, version=self.version, args=args)
|
|
134
|
+
endpoint = self._package.endpoint(endpoint_name) if self._package else None
|
|
135
|
+
paginated = bool(endpoint and endpoint.paginated)
|
|
136
|
+
return Page.wrap(response, paginated=paginated, fetch=lambda body: self._execute_and_wrap(url, body, endpoint_name))
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def package(self) -> Optional[Package]:
|
|
140
|
+
return self._package
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
if self._owns_http:
|
|
144
|
+
self._http.close()
|
|
145
|
+
|
|
146
|
+
def __enter__(self) -> "Client":
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def __exit__(self, *exc) -> None:
|
|
150
|
+
self.close()
|
|
151
|
+
|
|
152
|
+
# -- dynamic dispatch -----------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def __getattr__(self, name: str):
|
|
155
|
+
# Only reached when normal attribute lookup fails.
|
|
156
|
+
endpoints = self.__dict__.get("_endpoints", {})
|
|
157
|
+
endpoint_name = endpoints.get(name)
|
|
158
|
+
if endpoint_name is None:
|
|
159
|
+
raise AttributeError(f"{type(self).__name__!r} object has no attribute or endpoint {name!r}")
|
|
160
|
+
|
|
161
|
+
def _invoke(**kwargs):
|
|
162
|
+
return self.call(endpoint_name, kwargs)
|
|
163
|
+
|
|
164
|
+
return _invoke
|
|
165
|
+
|
|
166
|
+
def __dir__(self):
|
|
167
|
+
return list(super().__dir__()) + list(self.__dict__.get("_endpoints", {}).keys())
|
|
168
|
+
|
|
169
|
+
def __repr__(self) -> str:
|
|
170
|
+
return f"Client(base_url={self._base_url!r})"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class AsyncClient:
|
|
174
|
+
"""Async equivalent of :class:`Client`. All I/O methods (including dynamic-dispatch
|
|
175
|
+
endpoint calls) return awaitables.
|
|
176
|
+
|
|
177
|
+
Example::
|
|
178
|
+
|
|
179
|
+
client = await AsyncClient.from_package_endpoint("https://api.example.com/package")
|
|
180
|
+
await client.list_items(a="b") # => {"c": "d"}
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, *, base_url: str, endpoints: Optional[List[str]] = None,
|
|
184
|
+
package: Optional[Package] = None, bearer_auth: Optional[str] = None,
|
|
185
|
+
version: Optional[str] = None, pipeline: Optional[AsyncPipeline] = None,
|
|
186
|
+
http_client: Optional[httpx.AsyncClient] = None):
|
|
187
|
+
self._package = package
|
|
188
|
+
self._base_url = base_url
|
|
189
|
+
self._endpoints: Dict[str, str] = {e.replace("-", "_"): e for e in (endpoints or [])}
|
|
190
|
+
self.bearer_auth = bearer_auth
|
|
191
|
+
self.version = version
|
|
192
|
+
self.pipeline = pipeline
|
|
193
|
+
self._http = http_client if http_client is not None else httpx.AsyncClient()
|
|
194
|
+
self._owns_http = http_client is None
|
|
195
|
+
|
|
196
|
+
# -- construction -----------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
async def from_package_endpoint(cls, url: str, *, bearer_auth: Optional[str] = None,
|
|
200
|
+
version: Optional[str] = None, pipelined: bool = False,
|
|
201
|
+
http_client: Optional[httpx.AsyncClient] = None) -> "AsyncClient":
|
|
202
|
+
http = http_client if http_client is not None else httpx.AsyncClient()
|
|
203
|
+
response = await _request.execute_async(http, url, bearer_auth=bearer_auth, version=version, args={})
|
|
204
|
+
package = Package.from_dict(response)
|
|
205
|
+
return await cls.from_package(package, bearer_auth=bearer_auth, version=version,
|
|
206
|
+
pipelined=pipelined, http_client=http)
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
async def from_url(cls, url: str, *, bearer_auth: Optional[str] = None, version: Optional[str] = None,
|
|
210
|
+
pipelined: bool = False, http_client: Optional[httpx.AsyncClient] = None) -> "AsyncClient":
|
|
211
|
+
http = http_client if http_client is not None else httpx.AsyncClient()
|
|
212
|
+
body = await _request.get_body_async(http, url, extra_query_params={"api_version": version})
|
|
213
|
+
package = Package.from_dict(json.loads(body))
|
|
214
|
+
return await cls.from_package(package, bearer_auth=bearer_auth, version=version,
|
|
215
|
+
pipelined=pipelined, http_client=http)
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
async def from_package(cls, package: Package, *, bearer_auth: Optional[str] = None,
|
|
219
|
+
version: Optional[str] = None, pipelined: bool = False,
|
|
220
|
+
http_client: Optional[httpx.AsyncClient] = None) -> "AsyncClient":
|
|
221
|
+
http = http_client if http_client is not None else httpx.AsyncClient()
|
|
222
|
+
pipeline = None
|
|
223
|
+
if pipelined and package.pipeline_url:
|
|
224
|
+
async def _exec(url, args):
|
|
225
|
+
return await _request.execute_async(http, url, bearer_auth=bearer_auth, version=version, args=args)
|
|
226
|
+
pipeline = AsyncPipeline(package.pipeline_url, _exec)
|
|
227
|
+
|
|
228
|
+
client = cls(
|
|
229
|
+
package=package,
|
|
230
|
+
base_url=package.base_url,
|
|
231
|
+
endpoints=[e.name for e in package.endpoints],
|
|
232
|
+
bearer_auth=bearer_auth,
|
|
233
|
+
version=version,
|
|
234
|
+
pipeline=pipeline,
|
|
235
|
+
http_client=http,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
for endpoint in package.endpoints:
|
|
239
|
+
endpoint.client = client
|
|
240
|
+
|
|
241
|
+
return client
|
|
242
|
+
|
|
243
|
+
# -- invocation ---------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
async def call(self, endpoint_name: str, args: Optional[dict] = None) -> Any:
|
|
246
|
+
args = args or {}
|
|
247
|
+
url = _join_url(self._base_url, endpoint_name)
|
|
248
|
+
|
|
249
|
+
if self.pipeline is not None:
|
|
250
|
+
step = {"url": url, "headers": _request.build_headers(self.bearer_auth, self.version), "body": args}
|
|
251
|
+
return self.pipeline.add_step(step)
|
|
252
|
+
|
|
253
|
+
return await self._execute_and_wrap(url, args, endpoint_name)
|
|
254
|
+
|
|
255
|
+
async def _execute_and_wrap(self, url: str, args: dict, endpoint_name: str) -> Any:
|
|
256
|
+
response = await _request.execute_async(self._http, url, bearer_auth=self.bearer_auth, version=self.version, args=args)
|
|
257
|
+
endpoint = self._package.endpoint(endpoint_name) if self._package else None
|
|
258
|
+
paginated = bool(endpoint and endpoint.paginated)
|
|
259
|
+
|
|
260
|
+
async def _fetch(body):
|
|
261
|
+
return await self._execute_and_wrap(url, body, endpoint_name)
|
|
262
|
+
|
|
263
|
+
return AsyncPage.wrap(response, paginated=paginated, fetch=_fetch)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def package(self) -> Optional[Package]:
|
|
267
|
+
return self._package
|
|
268
|
+
|
|
269
|
+
async def aclose(self) -> None:
|
|
270
|
+
if self._owns_http:
|
|
271
|
+
await self._http.aclose()
|
|
272
|
+
|
|
273
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
async def __aexit__(self, *exc) -> None:
|
|
277
|
+
await self.aclose()
|
|
278
|
+
|
|
279
|
+
# -- dynamic dispatch -----------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def __getattr__(self, name: str):
|
|
282
|
+
endpoints = self.__dict__.get("_endpoints", {})
|
|
283
|
+
endpoint_name = endpoints.get(name)
|
|
284
|
+
if endpoint_name is None:
|
|
285
|
+
raise AttributeError(f"{type(self).__name__!r} object has no attribute or endpoint {name!r}")
|
|
286
|
+
|
|
287
|
+
async def _invoke(**kwargs):
|
|
288
|
+
return await self.call(endpoint_name, kwargs)
|
|
289
|
+
|
|
290
|
+
return _invoke
|
|
291
|
+
|
|
292
|
+
def __dir__(self):
|
|
293
|
+
return list(super().__dir__()) + list(self.__dict__.get("_endpoints", {}).keys())
|
|
294
|
+
|
|
295
|
+
def __repr__(self) -> str:
|
|
296
|
+
return f"AsyncClient(base_url={self._base_url!r})"
|
webfunction/errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Exception hierarchy for the Web Function client.
|
|
2
|
+
|
|
3
|
+
A single base exception carries ``code``/``message``/``details``, with four
|
|
4
|
+
named subtypes -- matching the same four errors used by every other client
|
|
5
|
+
in the reference-client suite (Ruby, JS, PHP, Go, Java, C#).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
_CAMEL_RE = re.compile(r"(?<!^)(?=[A-Z])")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WebFunctionError(Exception):
|
|
17
|
+
"""Base exception for all Web Function client errors.
|
|
18
|
+
|
|
19
|
+
``code`` defaults to a mechanically-derived string from the class name
|
|
20
|
+
(e.g. ``BadRequestError`` -> ``WFN_BAD_REQUEST_ERROR``) when none is given
|
|
21
|
+
explicitly, matching the Ruby reference gem's behavior.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, *, code: Optional[str] = None, details: Any = None):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.message = message
|
|
27
|
+
self.code = code or self._default_code()
|
|
28
|
+
self.details = details
|
|
29
|
+
|
|
30
|
+
def _default_code(self) -> str:
|
|
31
|
+
name = _CAMEL_RE.sub("_", type(self).__name__).upper()
|
|
32
|
+
return f"WFN_{name}"
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return f"{type(self).__name__}({self.message!r}, code={self.code!r}, details={self.details!r})"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BadRequestError(WebFunctionError):
|
|
39
|
+
"""Raised when the server returns a 400 response."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UnexpectedStatusCodeError(WebFunctionError):
|
|
43
|
+
"""Raised when the server returns a status code other than 200 or 400."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JsonParseError(WebFunctionError):
|
|
47
|
+
"""Raised when the response body is not valid JSON."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UnresolvedPromiseError(WebFunctionError):
|
|
51
|
+
"""Raised when a Promise's value is accessed before it has been resolved."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, message: str = "Promise is not yet resolved", *, code: Optional[str] = None, details: Any = None):
|
|
54
|
+
super().__init__(message, code=code, details=details)
|