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/wftype.py ADDED
@@ -0,0 +1,337 @@
1
+ """The Web Function type grammar, ported from the Ruby reference gem's ``type.rb``.
2
+
3
+ Every type node supports ``.format(fmt)``, ``.valid(value)``, ``.objects``, and
4
+ ``.without_refinements()``, mirroring the Ruby/Go/Java/C# clients' Type classes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import re
11
+ from typing import Any, List, Optional, Union as _TypingUnion
12
+ from urllib.parse import urlparse
13
+
14
+ ALLOWED_REFINEMENTS = {
15
+ "number": {"u32", "u64", "i32", "i64", "f32", "f64", "timestamp"},
16
+ "string": {
17
+ "date", "time", "datetime", "uuid", "base64", "email", "phone",
18
+ "url", "uri", "ipv4", "ipv6", "hostname",
19
+ },
20
+ }
21
+
22
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
23
+ _TIME_RE = re.compile(r"^\d{2}:\d{2}:\d{2}(\.\d+)?$")
24
+ _DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$")
25
+ _UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
26
+ _BASE64_RE = re.compile(r"^[A-Za-z0-9+/]*={0,2}$")
27
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
28
+ _PHONE_RE = re.compile(r"^\+[1-9]\d{1,14}$")
29
+ _HOSTNAME_RE = re.compile(
30
+ r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)"
31
+ r"(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
32
+ )
33
+
34
+
35
+ def _is_number(v: Any) -> bool:
36
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
37
+
38
+
39
+ def _is_int(v: Any) -> bool:
40
+ return isinstance(v, int) and not isinstance(v, bool)
41
+
42
+
43
+ def _v_u32(v): return _is_int(v) and 0 <= v <= 0xFFFFFFFF
44
+ def _v_u64(v): return _is_int(v) and 0 <= v <= 0xFFFFFFFFFFFFFFFF
45
+ def _v_i32(v): return _is_int(v) and -0x80000000 <= v <= 0x7FFFFFFF
46
+ def _v_i64(v): return _is_int(v) and -0x8000000000000000 <= v <= 0x7FFFFFFFFFFFFFFF
47
+
48
+
49
+ def _v_f32(v):
50
+ if not _is_number(v):
51
+ return False
52
+ f = float(v)
53
+ return f == f and f not in (float("inf"), float("-inf")) and abs(f) <= 3.4028235e38
54
+
55
+
56
+ def _v_f64(v):
57
+ if not _is_number(v):
58
+ return False
59
+ f = float(v)
60
+ return f == f and f not in (float("inf"), float("-inf"))
61
+
62
+
63
+ def _v_timestamp(v): return _is_int(v) and v >= 0
64
+ def _v_date(v): return isinstance(v, str) and bool(_DATE_RE.match(v))
65
+ def _v_time(v): return isinstance(v, str) and bool(_TIME_RE.match(v))
66
+ def _v_datetime(v): return isinstance(v, str) and bool(_DATETIME_RE.match(v))
67
+ def _v_uuid(v): return isinstance(v, str) and bool(_UUID_RE.match(v))
68
+ def _v_base64(v): return isinstance(v, str) and len(v) % 4 == 0 and bool(_BASE64_RE.match(v))
69
+ def _v_email(v): return isinstance(v, str) and bool(_EMAIL_RE.match(v))
70
+ def _v_phone(v): return isinstance(v, str) and bool(_PHONE_RE.match(v))
71
+
72
+
73
+ def _v_url(v):
74
+ if not isinstance(v, str):
75
+ return False
76
+ try:
77
+ parsed = urlparse(v)
78
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
79
+ except ValueError:
80
+ return False
81
+
82
+
83
+ def _v_uri(v):
84
+ if not isinstance(v, str):
85
+ return False
86
+ try:
87
+ return bool(urlparse(v).scheme)
88
+ except ValueError:
89
+ return False
90
+
91
+
92
+ def _v_ipv4(v):
93
+ if not isinstance(v, str):
94
+ return False
95
+ try:
96
+ ipaddress.IPv4Address(v)
97
+ return True
98
+ except ValueError:
99
+ return False
100
+
101
+
102
+ def _v_ipv6(v):
103
+ if not isinstance(v, str):
104
+ return False
105
+ try:
106
+ ipaddress.IPv6Address(v)
107
+ return True
108
+ except ValueError:
109
+ return False
110
+
111
+
112
+ def _v_hostname(v): return isinstance(v, str) and bool(_HOSTNAME_RE.match(v))
113
+
114
+
115
+ REFINEMENT_VALIDATORS = {
116
+ "u32": _v_u32, "u64": _v_u64, "i32": _v_i32, "i64": _v_i64,
117
+ "f32": _v_f32, "f64": _v_f64, "timestamp": _v_timestamp,
118
+ "date": _v_date, "time": _v_time, "datetime": _v_datetime,
119
+ "uuid": _v_uuid, "base64": _v_base64, "email": _v_email, "phone": _v_phone,
120
+ "url": _v_url, "uri": _v_uri, "ipv4": _v_ipv4, "ipv6": _v_ipv6, "hostname": _v_hostname,
121
+ }
122
+
123
+
124
+ class Type:
125
+ """Base class for all type nodes. Not instantiated directly."""
126
+
127
+ base_type: Optional[str] = None
128
+ refinement: Optional[str] = None
129
+
130
+ def format(self, fmt: str = "default") -> str:
131
+ raise NotImplementedError
132
+
133
+ def valid(self, value: Any) -> bool:
134
+ raise NotImplementedError
135
+
136
+ @property
137
+ def objects(self) -> List[str]:
138
+ return []
139
+
140
+ def without_refinements(self) -> "Type":
141
+ return self
142
+
143
+ def __str__(self) -> str:
144
+ return self.format()
145
+
146
+
147
+ class BaseTypeNode(Type):
148
+ def __init__(self, base_type: str, refinement: Optional[str] = None):
149
+ self.base_type = base_type
150
+ self.refinement = refinement
151
+
152
+ def format(self, fmt: str = "default") -> str:
153
+ if fmt == "compact":
154
+ return self.refinement or self.base_type
155
+ if fmt == "base":
156
+ return self.base_type
157
+ return f"{self.base_type}.{self.refinement}" if self.refinement else self.base_type
158
+
159
+ def __eq__(self, other):
160
+ return isinstance(other, BaseTypeNode) and other.base_type == self.base_type and other.refinement == self.refinement
161
+
162
+ def __hash__(self):
163
+ return hash((BaseTypeNode, self.base_type, self.refinement))
164
+
165
+ def __repr__(self):
166
+ return f"<{self.base_type} {self.refinement}>" if self.refinement else f"<{self.base_type}>"
167
+
168
+ @property
169
+ def objects(self) -> List[str]:
170
+ return [self.refinement] if self.base_type == "object" and self.refinement else []
171
+
172
+ def without_refinements(self) -> "Type":
173
+ return self if self.refinement is None else BaseTypeNode(self.base_type)
174
+
175
+ def valid(self, value: Any) -> bool:
176
+ if self.base_type == "string":
177
+ return isinstance(value, str) and self._refinement_valid(value)
178
+ if self.base_type == "number":
179
+ return _is_number(value) and self._refinement_valid(value)
180
+ if self.base_type == "object":
181
+ return isinstance(value, dict)
182
+ if self.base_type == "boolean":
183
+ return isinstance(value, bool)
184
+ if self.base_type == "null":
185
+ return value is None
186
+ return False
187
+
188
+ def _refinement_valid(self, value: Any) -> bool:
189
+ if self.refinement is None:
190
+ return True
191
+ return REFINEMENT_VALIDATORS[self.refinement](value)
192
+
193
+
194
+ class ArrayType(Type):
195
+ def __init__(self, of: Type):
196
+ self.base_type = "array"
197
+ self.of = of
198
+
199
+ def format(self, fmt: str = "default") -> str:
200
+ if fmt == "base":
201
+ return "array"
202
+ return f"array<{self.of.format(fmt)}>"
203
+
204
+ def __eq__(self, other):
205
+ return isinstance(other, ArrayType) and other.of == self.of
206
+
207
+ def __hash__(self):
208
+ return hash((ArrayType, self.of))
209
+
210
+ def __repr__(self):
211
+ return f"<ArrayOf {self.of!r}>"
212
+
213
+ @property
214
+ def objects(self) -> List[str]:
215
+ return self.of.objects
216
+
217
+ def without_refinements(self) -> "Type":
218
+ return ArrayType(self.of.without_refinements())
219
+
220
+ def valid(self, value: Any) -> bool:
221
+ return isinstance(value, list) and all(self.of.valid(v) for v in value)
222
+
223
+
224
+ class UnionType(Type):
225
+ def __init__(self, members: List[Type]):
226
+ self.members = members
227
+
228
+ def format(self, fmt: str = "default") -> str:
229
+ return " | ".join(m.format(fmt) for m in self.members)
230
+
231
+ def __eq__(self, other):
232
+ return isinstance(other, UnionType) and other.members == self.members
233
+
234
+ def __hash__(self):
235
+ return hash((UnionType, tuple(self.members)))
236
+
237
+ def __repr__(self):
238
+ return f"<Union {' | '.join(repr(m) for m in self.members)}>"
239
+
240
+ @property
241
+ def objects(self) -> List[str]:
242
+ seen, result = set(), []
243
+ for m in self.members:
244
+ for o in m.objects:
245
+ if o not in seen:
246
+ seen.add(o)
247
+ result.append(o)
248
+ return result
249
+
250
+ def without_refinements(self) -> "Type":
251
+ return union_of([m.without_refinements() for m in self.members])
252
+
253
+ def valid(self, value: Any) -> bool:
254
+ return any(m.valid(value) for m in self.members)
255
+
256
+
257
+ class AnyType(Type):
258
+ base_type = "any"
259
+
260
+ def format(self, fmt: str = "default") -> str:
261
+ return "any"
262
+
263
+ def __eq__(self, other):
264
+ return isinstance(other, AnyType)
265
+
266
+ def __hash__(self):
267
+ return hash(AnyType)
268
+
269
+ def __repr__(self):
270
+ return "<any>"
271
+
272
+ def valid(self, value: Any) -> bool:
273
+ return True
274
+
275
+
276
+ def any_type() -> Type: return AnyType()
277
+ def string_type(refinement: Optional[str] = None) -> Type: return BaseTypeNode("string", refinement)
278
+ def number_type(refinement: Optional[str] = None) -> Type: return BaseTypeNode("number", refinement)
279
+ def object_type(refinement: Optional[str] = None) -> Type: return BaseTypeNode("object", refinement)
280
+ def array_type(of: Optional[Type] = None) -> Type: return ArrayType(of if of is not None else any_type())
281
+ def boolean_type() -> Type: return BaseTypeNode("boolean")
282
+ def null_type() -> Type: return BaseTypeNode("null")
283
+
284
+
285
+ def union_of(types: List[Type]) -> Type:
286
+ uniq: List[Type] = []
287
+ for t in types:
288
+ if t not in uniq:
289
+ uniq.append(t)
290
+ if len(uniq) > 1:
291
+ return UnionType(uniq)
292
+ return uniq[0]
293
+
294
+
295
+ def _base(type_str: str) -> Optional[Type]:
296
+ base_type, _, refinement = type_str.partition(".")
297
+ refinement = refinement or None
298
+
299
+ if base_type == "string":
300
+ if refinement and refinement not in ALLOWED_REFINEMENTS["string"]:
301
+ return string_type()
302
+ return string_type(refinement)
303
+ if base_type == "number":
304
+ if refinement and refinement not in ALLOWED_REFINEMENTS["number"]:
305
+ return number_type()
306
+ return number_type(refinement)
307
+ if base_type == "object":
308
+ return object_type(refinement)
309
+ if base_type == "array":
310
+ return array_type()
311
+ if base_type == "boolean":
312
+ return boolean_type()
313
+ if base_type == "null":
314
+ return null_type()
315
+ if base_type == "any":
316
+ return any_type()
317
+ return None
318
+
319
+
320
+ def _detect(raw: Any) -> Optional[Type]:
321
+ if isinstance(raw, str):
322
+ return _base(raw)
323
+ if isinstance(raw, list):
324
+ types = [t for t in (_detect(r) for r in raw) if t is not None]
325
+ if not types:
326
+ return array_type(any_type())
327
+ return array_type(union_of(types))
328
+ return None
329
+
330
+
331
+ def parse(raw: _TypingUnion[str, List[Any], None]) -> Type:
332
+ """Parses a wfn ``type`` value (a string, or an array of strings/arrays) into a Type node."""
333
+ items = raw if isinstance(raw, list) else ([raw] if raw is not None else [])
334
+ types = [t for t in (_detect(r) for r in items) if t is not None]
335
+ if not types:
336
+ return any_type()
337
+ return union_of(types)
@@ -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,14 @@
1
+ webfunction/__init__.py,sha256=5I_9MKIvpTRaM3Kfgmm8CHK6CbRRdkfS6SD2lB7s2OY,1002
2
+ webfunction/_request.py,sha256=Or4EJrbt9PHPrELz3Vu6X-Op8CDElw6raVqw_U1GeVc,4530
3
+ webfunction/_version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
4
+ webfunction/client.py,sha256=9KB-7mlS9KcGYQcH0gNsX6wBTj5ZbHtzMhR-iOd50PY,13164
5
+ webfunction/errors.py,sha256=5Ny5lfTO2in0hkRJv_lQBpBYSw30xG1LU-A2dNIBVxg,1860
6
+ webfunction/models.py,sha256=Rkdnzy0D17d_x89hbLPsIlfc0z4cw-c9xttqDf20FWE,10803
7
+ webfunction/page.py,sha256=_djvZAH2YDLv8r7xcrVOKOCJcWi2L8XSO_82j10SijI,5007
8
+ webfunction/pipeline.py,sha256=qS29Y9nDhVqFY_FeTPnNTvGxmUWRVJaEHej1iUmcnXs,5441
9
+ webfunction/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ webfunction/wftype.py,sha256=RtuKHb4fUQnPtaP2O5z3LycLXGVgkMbsWBI6D36Umj0,10571
11
+ webfunction-0.1.0.dist-info/METADATA,sha256=OG7SlTwhDs-ec4GYHeprrc5MSU4tLpIrr5cV37_MVU0,2843
12
+ webfunction-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
+ webfunction-0.1.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
14
+ webfunction-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.