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/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
|
+
)
|
webfunction/pipeline.py
ADDED
|
@@ -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
|