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.
@@ -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
+ ]
@@ -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
@@ -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)