webfunction 0.1.0__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.
@@ -0,0 +1,6 @@
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ build/
6
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: webfunction
3
+ Version: 0.1.0
4
+ Summary: Reference client library for the Web Function (wfn) protocol
5
+ Project-URL: Homepage, https://webfunction.org
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: httpx>=0.24
10
+ Description-Content-Type: text/markdown
11
+
12
+ # webfunction-python
13
+
14
+ A reference client library for the [Web Function (wfn)](https://webfunction.org) protocol.
15
+
16
+ Part of the same reference-client suite as the Ruby gem
17
+ (`https://github.com/webfunction-protocol/webfunction-ruby`, the spec author's own implementation),
18
+ `webfunction-go`, `webfunction-java`, and `webfunction-csharp`.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install webfunction
24
+ ```
25
+
26
+ The only runtime dependency is [httpx](https://www.python-httpx.org/), which is what
27
+ lets this library offer both a synchronous `Client` and an async `AsyncClient` from a
28
+ single implementation.
29
+
30
+ ## Usage
31
+
32
+ ### Sync
33
+
34
+ ```python
35
+ from webfunction import Client
36
+
37
+ client = Client.from_package_endpoint("https://api.example.com/package")
38
+ result = client.list_items(email="a@example.com") # dynamic dispatch, no generated code
39
+ ```
40
+
41
+ ### Async
42
+
43
+ ```python
44
+ import asyncio
45
+ from webfunction import AsyncClient
46
+
47
+ async def main():
48
+ client = await AsyncClient.from_package_endpoint("https://api.example.com/package")
49
+ result = await client.list_items(email="a@example.com")
50
+
51
+ asyncio.run(main())
52
+ ```
53
+
54
+ ### Pagination
55
+
56
+ Endpoints that declare the `paginated` flag return a `Page` (or `AsyncPage`):
57
+
58
+ ```python
59
+ page = client.list_people()
60
+ for person in page:
61
+ ...
62
+ if page.has_next:
63
+ page = page.next_page()
64
+ ```
65
+
66
+ ### Pipelining
67
+
68
+ If the package declares a `pipeline_url`, pass `pipelined=True` to batch several
69
+ endpoint invocations into a single request. Calls return a `Promise` instead of
70
+ executing immediately; index into a promise (`promise["id"]`) to build a reference to
71
+ one of its fields before it's resolved.
72
+
73
+ ```python
74
+ client = Client.from_package_endpoint(url, pipelined=True)
75
+ item = client.list_items(email="a@example.com")
76
+ detail = client.get_widget(id=item["id"]) # references item's future "id" field
77
+ results = client.pipeline.execute()
78
+ print(detail.value)
79
+ ```
80
+
81
+ ### Errors
82
+
83
+ All errors are subclasses of `WebFunctionError`, carrying `.code`, `.message`, and
84
+ `.details`: `BadRequestError`, `UnexpectedStatusCodeError`, `JsonParseError`,
85
+ `UnresolvedPromiseError`.
86
+
87
+ ## A note on unknown fields
88
+
89
+ This library never validates incoming JSON against a strict schema -- model classes
90
+ only read the keys they know about via plain `dict.get()`, so an API that adds an
91
+ undocumented field to its responses (this has happened for real against
92
+ `api.reservepay.com`) won't break parsing the way it did for the Java client, which
93
+ needed an explicit fix to disable strict unknown-field rejection.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ pip install -e .
99
+ python -m unittest discover tests
100
+ ```
@@ -0,0 +1,89 @@
1
+ # webfunction-python
2
+
3
+ A reference client library for the [Web Function (wfn)](https://webfunction.org) protocol.
4
+
5
+ Part of the same reference-client suite as the Ruby gem
6
+ (`https://github.com/webfunction-protocol/webfunction-ruby`, the spec author's own implementation),
7
+ `webfunction-go`, `webfunction-java`, and `webfunction-csharp`.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install webfunction
13
+ ```
14
+
15
+ The only runtime dependency is [httpx](https://www.python-httpx.org/), which is what
16
+ lets this library offer both a synchronous `Client` and an async `AsyncClient` from a
17
+ single implementation.
18
+
19
+ ## Usage
20
+
21
+ ### Sync
22
+
23
+ ```python
24
+ from webfunction import Client
25
+
26
+ client = Client.from_package_endpoint("https://api.example.com/package")
27
+ result = client.list_items(email="a@example.com") # dynamic dispatch, no generated code
28
+ ```
29
+
30
+ ### Async
31
+
32
+ ```python
33
+ import asyncio
34
+ from webfunction import AsyncClient
35
+
36
+ async def main():
37
+ client = await AsyncClient.from_package_endpoint("https://api.example.com/package")
38
+ result = await client.list_items(email="a@example.com")
39
+
40
+ asyncio.run(main())
41
+ ```
42
+
43
+ ### Pagination
44
+
45
+ Endpoints that declare the `paginated` flag return a `Page` (or `AsyncPage`):
46
+
47
+ ```python
48
+ page = client.list_people()
49
+ for person in page:
50
+ ...
51
+ if page.has_next:
52
+ page = page.next_page()
53
+ ```
54
+
55
+ ### Pipelining
56
+
57
+ If the package declares a `pipeline_url`, pass `pipelined=True` to batch several
58
+ endpoint invocations into a single request. Calls return a `Promise` instead of
59
+ executing immediately; index into a promise (`promise["id"]`) to build a reference to
60
+ one of its fields before it's resolved.
61
+
62
+ ```python
63
+ client = Client.from_package_endpoint(url, pipelined=True)
64
+ item = client.list_items(email="a@example.com")
65
+ detail = client.get_widget(id=item["id"]) # references item's future "id" field
66
+ results = client.pipeline.execute()
67
+ print(detail.value)
68
+ ```
69
+
70
+ ### Errors
71
+
72
+ All errors are subclasses of `WebFunctionError`, carrying `.code`, `.message`, and
73
+ `.details`: `BadRequestError`, `UnexpectedStatusCodeError`, `JsonParseError`,
74
+ `UnresolvedPromiseError`.
75
+
76
+ ## A note on unknown fields
77
+
78
+ This library never validates incoming JSON against a strict schema -- model classes
79
+ only read the keys they know about via plain `dict.get()`, so an API that adds an
80
+ undocumented field to its responses (this has happened for real against
81
+ `api.reservepay.com`) won't break parsing the way it did for the Java client, which
82
+ needed an explicit fix to disable strict unknown-field rejection.
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ pip install -e .
88
+ python -m unittest discover tests
89
+ ```
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+ """Fetches the merchants package from api.reservepay.com and calls its
3
+ "available-api-versions" endpoint.
4
+
5
+ Requires a bearer token in the RESERVEPAY_BEARER_TOKEN environment variable:
6
+
7
+ RESERVEPAY_BEARER_TOKEN=... python examples/available_api_versions.py
8
+ """
9
+
10
+ import os
11
+ import sys
12
+
13
+ from webfunction import Client, WebFunctionError
14
+
15
+ PACKAGE_URL = "https://api.reservepay.com/merchants"
16
+
17
+
18
+ def main() -> int:
19
+ token = os.environ.get("RESERVEPAY_BEARER_TOKEN")
20
+ if not token:
21
+ print("RESERVEPAY_BEARER_TOKEN is not set.", file=sys.stderr)
22
+ print(f"Usage: RESERVEPAY_BEARER_TOKEN=... python {sys.argv[0]}", file=sys.stderr)
23
+ return 1
24
+
25
+ client = Client.from_package_endpoint(PACKAGE_URL, bearer_auth=token)
26
+ try:
27
+ versions = client.available_api_versions()
28
+ print(versions)
29
+ except WebFunctionError as e:
30
+ print(f"{type(e).__name__}: {e.message} (code={e.code}, details={e.details})", file=sys.stderr)
31
+ return 1
32
+ finally:
33
+ client.close()
34
+
35
+ return 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """Async equivalent of available_api_versions.py.
3
+
4
+ RESERVEPAY_BEARER_TOKEN=... python examples/available_api_versions_async.py
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import sys
10
+
11
+ from webfunction import AsyncClient, WebFunctionError
12
+
13
+ PACKAGE_URL = "https://api.reservepay.com/merchants"
14
+
15
+
16
+ async def run() -> int:
17
+ token = os.environ.get("RESERVEPAY_BEARER_TOKEN")
18
+ if not token:
19
+ print("RESERVEPAY_BEARER_TOKEN is not set.", file=sys.stderr)
20
+ print(f"Usage: RESERVEPAY_BEARER_TOKEN=... python {sys.argv[0]}", file=sys.stderr)
21
+ return 1
22
+
23
+ client = await AsyncClient.from_package_endpoint(PACKAGE_URL, bearer_auth=token)
24
+ try:
25
+ versions = await client.available_api_versions()
26
+ print(versions)
27
+ except WebFunctionError as e:
28
+ print(f"{type(e).__name__}: {e.message} (code={e.code}, details={e.details})", file=sys.stderr)
29
+ return 1
30
+ finally:
31
+ await client.aclose()
32
+
33
+ return 0
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(asyncio.run(run()))
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "webfunction"
7
+ version = "0.1.0"
8
+ description = "Reference client library for the Web Function (wfn) protocol"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "httpx>=0.24",
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://webfunction.org"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/webfunction"]
@@ -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"