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/models.py ADDED
@@ -0,0 +1,325 @@
1
+ """Package/Endpoint/Argument/Attribute/ObjectSchema/DocumentedError, ported from the
2
+ Ruby reference gem's package.rb/endpoint.rb/argument.rb/attribute.rb/object_schema.rb/
3
+ documented_error.rb/flaggable.rb.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from . import wftype
12
+
13
+
14
+ def _normalize_array(items: Any, fn=None) -> list:
15
+ if not isinstance(items, list):
16
+ return []
17
+ result = []
18
+ for item in items:
19
+ value = fn(item) if fn else item
20
+ if value is not None:
21
+ result.append(value)
22
+ return result
23
+
24
+
25
+ def _normalize_strings(items: Any) -> List[str]:
26
+ return _normalize_array(items, lambda i: str(i))
27
+
28
+
29
+ def _normalize_choices(raw: Any) -> list:
30
+ if isinstance(raw, list):
31
+ return list(raw)
32
+ return [raw] if raw is not None else []
33
+
34
+
35
+ class Flaggable:
36
+ """Mixin providing ``has_flag()`` for objects with a ``flags`` list, mirroring the
37
+ Ruby gem's ``Flaggable`` module."""
38
+
39
+ flags: List[str]
40
+
41
+ def has_flag(self, flag: str) -> bool:
42
+ return flag in self.flags
43
+
44
+
45
+ @dataclass
46
+ class DocumentedError:
47
+ code: str
48
+ docs: str = ""
49
+
50
+ @classmethod
51
+ def from_dict(cls, d: Any) -> Optional["DocumentedError"]:
52
+ if not isinstance(d, dict) or not d.get("code"):
53
+ return None
54
+ return cls(code=d["code"], docs=str(d.get("docs") or ""))
55
+
56
+ @classmethod
57
+ def from_list(cls, items: Any) -> List["DocumentedError"]:
58
+ return _normalize_array(items, cls.from_dict)
59
+
60
+
61
+ @dataclass
62
+ class Argument(Flaggable):
63
+ name: str
64
+ type: wftype.Type
65
+ group: Optional[str] = None
66
+ choices: list = field(default_factory=list)
67
+ flags: List[str] = field(default_factory=list)
68
+ docs: str = ""
69
+
70
+ @property
71
+ def required(self) -> bool:
72
+ return self.has_flag("required")
73
+
74
+ @property
75
+ def optional(self) -> bool:
76
+ return not self.required
77
+
78
+ @classmethod
79
+ def from_dict(cls, d: Any) -> Optional["Argument"]:
80
+ if not isinstance(d, dict) or not d.get("name") or not d.get("type"):
81
+ return None
82
+ return cls(
83
+ name=d["name"],
84
+ type=wftype.parse(d["type"]),
85
+ group=d.get("group"),
86
+ choices=_normalize_choices(d.get("choices")),
87
+ flags=_normalize_strings(d.get("flags")),
88
+ docs=str(d.get("docs") or ""),
89
+ )
90
+
91
+ @classmethod
92
+ def from_list(cls, items: Any) -> List["Argument"]:
93
+ return _normalize_array(items, cls.from_dict)
94
+
95
+
96
+ @dataclass
97
+ class Attribute(Flaggable):
98
+ name: str
99
+ type: wftype.Type
100
+ values: list = field(default_factory=list)
101
+ flags: List[str] = field(default_factory=list)
102
+ docs: str = ""
103
+
104
+ @property
105
+ def nullable(self) -> bool:
106
+ return self.has_flag("nullable")
107
+
108
+ @classmethod
109
+ def from_dict(cls, d: Any) -> Optional["Attribute"]:
110
+ if not isinstance(d, dict) or not d.get("name") or not d.get("type"):
111
+ return None
112
+ return cls(
113
+ name=d["name"],
114
+ type=wftype.parse(d["type"]),
115
+ values=_normalize_choices(d.get("values")),
116
+ flags=_normalize_strings(d.get("flags")),
117
+ docs=str(d.get("docs") or ""),
118
+ )
119
+
120
+ @classmethod
121
+ def from_list(cls, items: Any) -> List["Attribute"]:
122
+ return _normalize_array(items, cls.from_dict)
123
+
124
+
125
+ class ObjectSchema:
126
+ """Named object definition. An ``object.<name>`` reference resolves against either
127
+ its ``arguments`` (argument context) or ``attributes`` (attribute context)."""
128
+
129
+ CONTEXTS = ("arguments", "attributes")
130
+
131
+ def __init__(self, name: str, arguments: Optional[List[Argument]] = None,
132
+ attributes: Optional[List[Attribute]] = None):
133
+ self.name = name
134
+ self._arguments: Dict[str, Argument] = {a.name: a for a in (arguments or [])}
135
+ self._attributes: Dict[str, Attribute] = {a.name: a for a in (attributes or [])}
136
+
137
+ @property
138
+ def arguments(self) -> List[Argument]:
139
+ return list(self._arguments.values())
140
+
141
+ def argument(self, name: str) -> Optional[Argument]:
142
+ return self._arguments.get(str(name))
143
+
144
+ @property
145
+ def attributes(self) -> List[Attribute]:
146
+ return list(self._attributes.values())
147
+
148
+ def attribute(self, name: str) -> Optional[Attribute]:
149
+ return self._attributes.get(str(name))
150
+
151
+ def properties(self, context: str) -> list:
152
+ if context == "arguments":
153
+ return self.arguments
154
+ if context == "attributes":
155
+ return self.attributes
156
+ raise ValueError(f"context must be one of {self.CONTEXTS!r}, got {context!r}")
157
+
158
+ def __repr__(self):
159
+ return f"ObjectSchema(name={self.name!r})"
160
+
161
+ @classmethod
162
+ def from_dict(cls, d: Any) -> Optional["ObjectSchema"]:
163
+ if not isinstance(d, dict) or not d.get("name"):
164
+ return None
165
+ return cls(
166
+ name=d["name"],
167
+ arguments=Argument.from_list(d.get("arguments")),
168
+ attributes=Attribute.from_list(d.get("attributes")),
169
+ )
170
+
171
+ @classmethod
172
+ def from_list(cls, items: Any) -> List["ObjectSchema"]:
173
+ return _normalize_array(items, cls.from_dict)
174
+
175
+
176
+ class Endpoint(Flaggable):
177
+ def __init__(self, *, name: str, returns: Any, flags: Optional[List[str]] = None,
178
+ group: Optional[str] = None, docs: Optional[str] = None,
179
+ arguments: Optional[List[Argument]] = None,
180
+ attributes: Optional[List[Attribute]] = None,
181
+ errors: Optional[List[DocumentedError]] = None):
182
+ self.name = name
183
+ self.returns = wftype.parse(returns)
184
+ self.flags = flags or []
185
+ self.group = group
186
+ self.docs = docs or ""
187
+ self._arguments: Dict[str, Argument] = {a.name: a for a in (arguments or [])}
188
+ self._attributes: Dict[str, Attribute] = {a.name: a for a in (attributes or [])}
189
+ self._errors: Dict[str, DocumentedError] = {e.code: e for e in (errors or [])}
190
+ # Assigned when the endpoint is loaded from a package into a Client; required by .call().
191
+ self.client = None
192
+
193
+ @property
194
+ def arguments(self) -> List[Argument]:
195
+ return list(self._arguments.values())
196
+
197
+ def argument(self, name: str) -> Optional[Argument]:
198
+ return self._arguments.get(str(name))
199
+
200
+ @property
201
+ def attributes(self) -> List[Attribute]:
202
+ return list(self._attributes.values())
203
+
204
+ def attribute(self, name: str) -> Optional[Attribute]:
205
+ return self._attributes.get(str(name))
206
+
207
+ @property
208
+ def errors(self) -> List[DocumentedError]:
209
+ return list(self._errors.values())
210
+
211
+ def error(self, code: str) -> Optional[DocumentedError]:
212
+ return self._errors.get(str(code))
213
+
214
+ @property
215
+ def bearer_auth(self) -> bool:
216
+ return self.has_flag("bearer_auth")
217
+
218
+ @property
219
+ def capture_bearer(self) -> bool:
220
+ return self.has_flag("capture_bearer")
221
+
222
+ @property
223
+ def paginated(self) -> bool:
224
+ return self.has_flag("paginated")
225
+
226
+ @property
227
+ def private(self) -> bool:
228
+ return self.has_flag("private")
229
+
230
+ def call(self, **args) -> Any:
231
+ if self.client is None:
232
+ raise RuntimeError("client must be set to invoke an endpoint")
233
+ return self.client.call(self.name, args)
234
+
235
+ def __repr__(self):
236
+ return f"Endpoint(name={self.name!r})"
237
+
238
+ @classmethod
239
+ def from_dict(cls, d: Any) -> Optional["Endpoint"]:
240
+ if not isinstance(d, dict) or not d.get("name") or not d.get("returns"):
241
+ return None
242
+ return cls(
243
+ name=d["name"],
244
+ returns=d["returns"],
245
+ flags=_normalize_strings(d.get("flags")),
246
+ group=d.get("group"),
247
+ docs=str(d.get("docs") or ""),
248
+ arguments=Argument.from_list(d.get("arguments")),
249
+ attributes=Attribute.from_list(d.get("attributes")),
250
+ errors=DocumentedError.from_list(d.get("errors")),
251
+ )
252
+
253
+ @classmethod
254
+ def from_list(cls, items: Any) -> List["Endpoint"]:
255
+ return _normalize_array(items, cls.from_dict)
256
+
257
+
258
+ class Package(Flaggable):
259
+ def __init__(self, *, base_url: str, pipeline_url: Optional[str] = None,
260
+ name: Optional[str] = None, version: Optional[str] = None,
261
+ docs: Optional[str] = None, flags: Optional[List[str]] = None,
262
+ versions: Optional[List[str]] = None,
263
+ endpoints: Optional[List[Endpoint]] = None,
264
+ errors: Optional[List[DocumentedError]] = None,
265
+ objects: Optional[List[ObjectSchema]] = None):
266
+ self.base_url = base_url
267
+ self.pipeline_url = pipeline_url
268
+ self.name = name
269
+ self.version = version
270
+ self.docs = docs or ""
271
+ self.flags = flags or []
272
+ self.versions = versions or []
273
+ self._endpoints: Dict[str, Endpoint] = {e.name: e for e in (endpoints or [])}
274
+ self._errors: Dict[str, DocumentedError] = {e.code: e for e in (errors or [])}
275
+ self._objects: Dict[str, ObjectSchema] = {o.name: o for o in (objects or [])}
276
+
277
+ @property
278
+ def endpoints(self) -> List[Endpoint]:
279
+ return list(self._endpoints.values())
280
+
281
+ def endpoint(self, name: str) -> Optional[Endpoint]:
282
+ # Underscores map to hyphens so python_style_names match hyphenated endpoint names.
283
+ return self._endpoints.get(str(name).replace("_", "-"))
284
+
285
+ @property
286
+ def errors(self) -> List[DocumentedError]:
287
+ return list(self._errors.values())
288
+
289
+ def error(self, code: str) -> Optional[DocumentedError]:
290
+ return self._errors.get(str(code))
291
+
292
+ @property
293
+ def objects(self) -> List[ObjectSchema]:
294
+ return list(self._objects.values())
295
+
296
+ def object(self, name: str, *, context: str) -> Optional[ObjectSchema]:
297
+ obj = self._objects.get(str(name))
298
+ if obj is None:
299
+ return None
300
+ if not obj.properties(context):
301
+ return None
302
+ return obj
303
+
304
+ @property
305
+ def versioned(self) -> bool:
306
+ return self.has_flag("versioned")
307
+
308
+ def __repr__(self):
309
+ return f"Package(name={self.name!r}, base_url={self.base_url!r})"
310
+
311
+ @classmethod
312
+ def from_dict(cls, d: Any) -> "Package":
313
+ d = d or {}
314
+ return cls(
315
+ base_url=d.get("base_url"),
316
+ pipeline_url=d.get("pipeline_url"),
317
+ name=d.get("name"),
318
+ version=d.get("version"),
319
+ docs=str(d.get("docs") or ""),
320
+ flags=_normalize_strings(d.get("flags")),
321
+ versions=_normalize_strings(d.get("versions")),
322
+ endpoints=Endpoint.from_list(d.get("endpoints")),
323
+ errors=DocumentedError.from_list(d.get("errors")),
324
+ objects=ObjectSchema.from_list(d.get("objects")),
325
+ )
webfunction/page.py ADDED
@@ -0,0 +1,134 @@
1
+ """Pagination wrapper, ported from the Ruby reference gem's page.rb.
2
+
3
+ Deliberate, ecosystem-wide deviation from the Ruby gem: pagination is detected via
4
+ the endpoint's ``paginated`` flag, not by sniffing the response shape (the shape-sniffing
5
+ approach is also the root cause of a real zero-item-pagination bug hit later on the Ruby
6
+ *codegen* side -- see ``/areas/wfn-ruby-codegen.md``). Go/Java/C# all made this same
7
+ deviation deliberately; Python does too, for consistency across the suite.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, Iterator, List, Optional
13
+
14
+
15
+ class Page:
16
+ """A page of results from a paginated (sync) endpoint. Iterable over its items."""
17
+
18
+ def __init__(self, *, items: list, next_body: Optional[dict], previous_body: Optional[dict],
19
+ fetch: Callable[[dict], Any]):
20
+ self.items = items
21
+ self._next_body = next_body
22
+ self._previous_body = previous_body
23
+ self._fetch = fetch
24
+
25
+ @property
26
+ def has_next(self) -> bool:
27
+ return self._next_body is not None
28
+
29
+ @property
30
+ def has_previous(self) -> bool:
31
+ return self._previous_body is not None
32
+
33
+ def next_page(self) -> Optional[Any]:
34
+ """Fetches the next page by posting the opaque ``next`` body to the same endpoint."""
35
+ if self._next_body is None:
36
+ return None
37
+ return self._fetch(self._next_body)
38
+
39
+ def previous_page(self) -> Optional[Any]:
40
+ """Fetches the previous page by posting the opaque ``previous`` body to the same endpoint."""
41
+ if self._previous_body is None:
42
+ return None
43
+ return self._fetch(self._previous_body)
44
+
45
+ def __iter__(self) -> Iterator[Any]:
46
+ return iter(self.items)
47
+
48
+ def __len__(self) -> int:
49
+ return len(self.items)
50
+
51
+ def __getitem__(self, index):
52
+ return self.items[index]
53
+
54
+ def __repr__(self) -> str:
55
+ return f"Page(items={self.items!r}, has_next={self.has_next}, has_previous={self.has_previous})"
56
+
57
+ @staticmethod
58
+ def is_paginated_shape(response: Any) -> bool:
59
+ """Whether ``response`` matches the pagination contract's shape. Not used for the
60
+ wrap decision itself (see module docstring) but exposed for callers who want to
61
+ double-check a server's response against the spec."""
62
+ return (
63
+ isinstance(response, dict)
64
+ and "page" in response and "next" in response and "previous" in response
65
+ and isinstance(response["page"], list)
66
+ and (response["next"] is None or isinstance(response["next"], dict))
67
+ and (response["previous"] is None or isinstance(response["previous"], dict))
68
+ )
69
+
70
+ @classmethod
71
+ def wrap(cls, response: Any, *, paginated: bool, fetch: Callable[[dict], Any]) -> Any:
72
+ """Wraps ``response`` in a Page when the endpoint declares the ``paginated`` flag,
73
+ else returns it unchanged. ``fetch`` is called with the opaque ``next``/``previous``
74
+ body and should return an already re-wrapped Page (or bare value) in turn."""
75
+ if not paginated or not isinstance(response, dict):
76
+ return response
77
+ return cls(
78
+ items=response.get("page") or [],
79
+ next_body=response.get("next"),
80
+ previous_body=response.get("previous"),
81
+ fetch=fetch,
82
+ )
83
+
84
+
85
+ class AsyncPage:
86
+ """Async equivalent of :class:`Page`. ``next_page()``/``previous_page()`` must be awaited."""
87
+
88
+ def __init__(self, *, items: list, next_body: Optional[dict], previous_body: Optional[dict],
89
+ fetch: Callable[[dict], Any]):
90
+ self.items = items
91
+ self._next_body = next_body
92
+ self._previous_body = previous_body
93
+ self._fetch = fetch
94
+
95
+ @property
96
+ def has_next(self) -> bool:
97
+ return self._next_body is not None
98
+
99
+ @property
100
+ def has_previous(self) -> bool:
101
+ return self._previous_body is not None
102
+
103
+ async def next_page(self) -> Optional[Any]:
104
+ if self._next_body is None:
105
+ return None
106
+ return await self._fetch(self._next_body)
107
+
108
+ async def previous_page(self) -> Optional[Any]:
109
+ if self._previous_body is None:
110
+ return None
111
+ return await self._fetch(self._previous_body)
112
+
113
+ def __iter__(self) -> Iterator[Any]:
114
+ return iter(self.items)
115
+
116
+ def __len__(self) -> int:
117
+ return len(self.items)
118
+
119
+ def __getitem__(self, index):
120
+ return self.items[index]
121
+
122
+ def __repr__(self) -> str:
123
+ return f"AsyncPage(items={self.items!r}, has_next={self.has_next}, has_previous={self.has_previous})"
124
+
125
+ @classmethod
126
+ def wrap(cls, response: Any, *, paginated: bool, fetch: Callable[[dict], Any]) -> Any:
127
+ if not paginated or not isinstance(response, dict):
128
+ return response
129
+ return cls(
130
+ items=response.get("page") or [],
131
+ next_body=response.get("next"),
132
+ previous_body=response.get("previous"),
133
+ fetch=fetch,
134
+ )
@@ -0,0 +1,163 @@
1
+ """Call pipelining: Path/Promise/Pipeline, ported from the Ruby reference gem's
2
+ pipeline.rb/promise.rb.
3
+
4
+ Unlike the Ruby gem -- where an unresolved ``Promise#[]`` returns a bare ``Path``
5
+ rather than another ``Promise`` (arguably a quirk of the reference implementation) --
6
+ Python's version always returns a new ``Promise`` wrapping the deeper path, so
7
+ chained indexing (``promise["a"]["b"]``) keeps working and stays consistent with
8
+ what the Go/Java/C# clients do with their equivalent ``.Field()``/``.get()`` methods.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Callable, List, Union
14
+
15
+ from .errors import UnresolvedPromiseError
16
+
17
+ _UNSET = object()
18
+
19
+
20
+ class Path:
21
+ """A JSONPath expression (e.g. ``"$[0].id"``) built up via indexing."""
22
+
23
+ __slots__ = ("_path",)
24
+
25
+ def __init__(self, path: str):
26
+ self._path = path
27
+
28
+ def __getitem__(self, key: Union[str, int]) -> "Path":
29
+ if isinstance(key, int):
30
+ return Path(f"{self._path}[{key}]")
31
+ return Path(f"{self._path}.{key}")
32
+
33
+ def __str__(self) -> str:
34
+ return self._path
35
+
36
+ def __repr__(self) -> str:
37
+ return f"Path({self._path!r})"
38
+
39
+ def __eq__(self, other):
40
+ return isinstance(other, Path) and other._path == self._path
41
+
42
+
43
+ class Promise:
44
+ """A placeholder for a value that will be resolved once its pipeline executes.
45
+
46
+ A literal argument string starting with ``"$"`` must be escaped as ``"\\$"``
47
+ per the pipelining spec -- that escaping is the caller's responsibility when
48
+ building step bodies, same as every other client in the suite.
49
+ """
50
+
51
+ def __init__(self, pipeline: "Pipeline", path: Union[str, Path]):
52
+ self._pipeline = pipeline
53
+ self._path = path if isinstance(path, Path) else Path(path)
54
+ self._value: Any = _UNSET
55
+
56
+ @property
57
+ def resolved(self) -> bool:
58
+ return self._value is not _UNSET
59
+
60
+ def __getitem__(self, key: Union[str, int]) -> Any:
61
+ if self.resolved:
62
+ return self._value[key]
63
+ return Promise(self._pipeline, self._path[key])
64
+
65
+ def field(self, key: Union[str, int]) -> "Promise":
66
+ """Explicit equivalent of ``promise[key]``, for readability or non-literal keys."""
67
+ return self[key]
68
+
69
+ def __str__(self) -> str:
70
+ return str(self._value) if self.resolved else str(self._path)
71
+
72
+ def __repr__(self) -> str:
73
+ return f"Promise({self._value!r})" if self.resolved else f"Promise({self._path!r})"
74
+
75
+ def to_json_value(self) -> Any:
76
+ """Used by the request layer's JSON encoder to serialize an embedded, unresolved
77
+ Promise as its JSONPath string, or a resolved Promise as its real value."""
78
+ return self._value if self.resolved else str(self._path)
79
+
80
+ @property
81
+ def value(self) -> Any:
82
+ if not self.resolved:
83
+ raise UnresolvedPromiseError()
84
+ return self._value
85
+
86
+ def _set_value(self, value: Any) -> None:
87
+ self._value = value
88
+
89
+ def resolve(self) -> Any:
90
+ """Resolves the promise, executing its pipeline first if necessary."""
91
+ if self.resolved:
92
+ return self._value
93
+ self._pipeline.execute()
94
+ return self.value
95
+
96
+
97
+ class _BasePipeline:
98
+ def __init__(self, url: str):
99
+ self._url = url
100
+ self._steps: List[dict] = []
101
+ self._promises: List[Promise] = []
102
+
103
+ def add_step(self, step: dict) -> Promise:
104
+ n = len(self._promises)
105
+ promise = Promise(self, f"$[{n}]")
106
+ self._steps.append(step)
107
+ self._promises.append(promise)
108
+ return promise
109
+
110
+ def _reset(self) -> None:
111
+ self._steps = []
112
+ self._promises = []
113
+
114
+ def _args_for(self, returns: str) -> dict:
115
+ if returns == "all":
116
+ return {"steps": self._steps, "returns": "$"}
117
+ if returns == "last":
118
+ return {"steps": self._steps, "returns": "$[-1:]"}
119
+ return {"steps": self._steps, "returns": returns}
120
+
121
+
122
+ class Pipeline(_BasePipeline):
123
+ """A sequence of steps executed together as a single request, via a synchronous
124
+ execute function injected by the owning (sync) Client."""
125
+
126
+ def __init__(self, url: str, execute_fn: Callable[[str, dict], Any]):
127
+ super().__init__(url)
128
+ self._execute_fn = execute_fn
129
+
130
+ def execute(self, returns: str = "all") -> Any:
131
+ args = self._args_for(returns)
132
+ response = self._execute_fn(self._url, args)
133
+
134
+ if returns == "all":
135
+ for promise, value in zip(self._promises, response):
136
+ promise._set_value(value)
137
+ elif returns == "last" and self._promises:
138
+ self._promises[-1]._set_value(response)
139
+
140
+ self._reset()
141
+ return response
142
+
143
+
144
+ class AsyncPipeline(_BasePipeline):
145
+ """Async equivalent of :class:`Pipeline`, via an async execute function injected by
146
+ the owning (async) AsyncClient."""
147
+
148
+ def __init__(self, url: str, execute_fn: Callable[[str, dict], Any]):
149
+ super().__init__(url)
150
+ self._execute_fn = execute_fn
151
+
152
+ async def execute(self, returns: str = "all") -> Any:
153
+ args = self._args_for(returns)
154
+ response = await self._execute_fn(self._url, args)
155
+
156
+ if returns == "all":
157
+ for promise, value in zip(self._promises, response):
158
+ promise._set_value(value)
159
+ elif returns == "last" and self._promises:
160
+ self._promises[-1]._set_value(response)
161
+
162
+ self._reset()
163
+ return response
webfunction/py.typed ADDED
File without changes