haveapi-client 0.30.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.
- haveapi_client/__init__.py +62 -0
- haveapi_client/_auth/__init__.py +139 -0
- haveapi_client/_protocol/__init__.py +52 -0
- haveapi_client/_protocol/description.py +176 -0
- haveapi_client/_protocol/execution.py +61 -0
- haveapi_client/_protocol/input.py +161 -0
- haveapi_client/_protocol/models.py +166 -0
- haveapi_client/_protocol/request.py +345 -0
- haveapi_client/_protocol/response.py +97 -0
- haveapi_client/_resources/__init__.py +196 -0
- haveapi_client/_resources/state.py +266 -0
- haveapi_client/_transport/__init__.py +155 -0
- haveapi_client/async_client.py +209 -0
- haveapi_client/client.py +203 -0
- haveapi_client/exceptions.py +88 -0
- haveapi_client/operations.py +190 -0
- haveapi_client/pagination.py +202 -0
- haveapi_client/py.typed +0 -0
- haveapi_client/response.py +303 -0
- haveapi_client-0.30.0.dist-info/METADATA +198 -0
- haveapi_client-0.30.0.dist-info/RECORD +23 -0
- haveapi_client-0.30.0.dist-info/WHEEL +4 -0
- haveapi_client-0.30.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Explicit cursor iteration; a normal action response remains one finite page."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from ._protocol import AsyncExecutor, CallContext, RequestOptions, SyncExecutor
|
|
10
|
+
from ._protocol.models import frozen_mapping
|
|
11
|
+
from .exceptions import PaginationError, StaleDescriptionError
|
|
12
|
+
from .response import Response
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CursorStrategy(Protocol):
|
|
16
|
+
@property
|
|
17
|
+
def parameter(self) -> str: ...
|
|
18
|
+
|
|
19
|
+
def next_cursor(
|
|
20
|
+
self, page: Response[Any], previous: object | None
|
|
21
|
+
) -> object | None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _items(page: Response[Any]) -> Sequence[Any]:
|
|
25
|
+
if not isinstance(page.data, (list, tuple)):
|
|
26
|
+
raise PaginationError("pagination requires a finite list response")
|
|
27
|
+
return page.data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class IdCursor:
|
|
32
|
+
parameter: str = "from_id"
|
|
33
|
+
field: str = "id"
|
|
34
|
+
direction: str = "asc"
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if self.direction not in ("asc", "desc"):
|
|
38
|
+
raise ValueError("cursor direction must be asc or desc")
|
|
39
|
+
if not self.parameter or not self.field:
|
|
40
|
+
raise ValueError("cursor parameter and field must be nonempty")
|
|
41
|
+
|
|
42
|
+
def next_cursor(
|
|
43
|
+
self, page: Response[Any], previous: object | None
|
|
44
|
+
) -> object | None:
|
|
45
|
+
items = _items(page)
|
|
46
|
+
if not items:
|
|
47
|
+
return None
|
|
48
|
+
if previous is not None and (
|
|
49
|
+
not isinstance(previous, int) or isinstance(previous, bool)
|
|
50
|
+
):
|
|
51
|
+
raise PaginationError("IdCursor requires integer cursors")
|
|
52
|
+
last = previous
|
|
53
|
+
for item in items:
|
|
54
|
+
value = item.get(self.field) if isinstance(item, Mapping) else None
|
|
55
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
56
|
+
raise PaginationError(
|
|
57
|
+
f"page item has no integer cursor field: {self.field}"
|
|
58
|
+
)
|
|
59
|
+
if last is not None and (
|
|
60
|
+
value <= last if self.direction == "asc" else value >= last
|
|
61
|
+
):
|
|
62
|
+
raise PaginationError("cursor did not progress monotonically")
|
|
63
|
+
last = value
|
|
64
|
+
return last
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _prepare(
|
|
68
|
+
context: CallContext,
|
|
69
|
+
strategy: CursorStrategy,
|
|
70
|
+
page_size: int,
|
|
71
|
+
params: Mapping[str, object] | None,
|
|
72
|
+
) -> dict[str, object]:
|
|
73
|
+
if isinstance(page_size, bool) or not isinstance(page_size, int) or page_size <= 0:
|
|
74
|
+
raise PaginationError("page_size must be a positive integer")
|
|
75
|
+
action = context.action
|
|
76
|
+
if not action.output or action.output.layout not in ("object_list", "hash_list"):
|
|
77
|
+
raise PaginationError("pagination requires object_list or hash_list output")
|
|
78
|
+
if not action.input or action.input.layout not in ("object", "hash"):
|
|
79
|
+
raise PaginationError("pagination requires object-shaped input")
|
|
80
|
+
if not isinstance(strategy.parameter, str) or not strategy.parameter:
|
|
81
|
+
raise PaginationError("strategy must declare a cursor parameter")
|
|
82
|
+
if not {"limit", strategy.parameter} <= action.input.parameters.keys():
|
|
83
|
+
raise PaginationError("action does not advertise limit and cursor parameters")
|
|
84
|
+
result = dict(frozen_mapping(params or {}))
|
|
85
|
+
result["limit"] = page_size
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _next(
|
|
90
|
+
strategy: CursorStrategy,
|
|
91
|
+
page: Response[Any],
|
|
92
|
+
previous: object | None,
|
|
93
|
+
seen: list[object],
|
|
94
|
+
) -> object | None:
|
|
95
|
+
if not _items(page):
|
|
96
|
+
return None
|
|
97
|
+
cursor = strategy.next_cursor(page, previous)
|
|
98
|
+
if cursor is None:
|
|
99
|
+
return None
|
|
100
|
+
if any(cursor == old for old in seen):
|
|
101
|
+
raise PaginationError("cursor repeated without progress")
|
|
102
|
+
seen.append(cursor)
|
|
103
|
+
return cursor
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def iter_pages(
|
|
107
|
+
executor: SyncExecutor,
|
|
108
|
+
context: CallContext,
|
|
109
|
+
*,
|
|
110
|
+
strategy: CursorStrategy,
|
|
111
|
+
page_size: int = 100,
|
|
112
|
+
params: Mapping[str, object] | None = None,
|
|
113
|
+
meta: Mapping[str, object] | None = None,
|
|
114
|
+
options: RequestOptions | None = None,
|
|
115
|
+
) -> Iterator[Response[Any]]:
|
|
116
|
+
inputs = _prepare(context, strategy, page_size, params)
|
|
117
|
+
metadata = frozen_mapping(meta) if meta is not None else None
|
|
118
|
+
cursor = inputs.get(strategy.parameter)
|
|
119
|
+
seen = [cursor] if cursor is not None else []
|
|
120
|
+
while True:
|
|
121
|
+
if executor.generation != context.generation:
|
|
122
|
+
raise StaleDescriptionError("pagination belongs to an old description")
|
|
123
|
+
page = executor.execute(context, params=inputs, meta=metadata, options=options)
|
|
124
|
+
following = _next(strategy, page, cursor, seen)
|
|
125
|
+
yield page
|
|
126
|
+
if following is None:
|
|
127
|
+
return
|
|
128
|
+
cursor = following
|
|
129
|
+
inputs = {**inputs, strategy.parameter: cursor}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def iter_all(
|
|
133
|
+
executor: SyncExecutor,
|
|
134
|
+
context: CallContext,
|
|
135
|
+
*,
|
|
136
|
+
strategy: CursorStrategy,
|
|
137
|
+
page_size: int = 100,
|
|
138
|
+
params: Mapping[str, object] | None = None,
|
|
139
|
+
meta: Mapping[str, object] | None = None,
|
|
140
|
+
options: RequestOptions | None = None,
|
|
141
|
+
) -> Iterator[Any]:
|
|
142
|
+
for page in iter_pages(
|
|
143
|
+
executor,
|
|
144
|
+
context,
|
|
145
|
+
strategy=strategy,
|
|
146
|
+
page_size=page_size,
|
|
147
|
+
params=params,
|
|
148
|
+
meta=meta,
|
|
149
|
+
options=options,
|
|
150
|
+
):
|
|
151
|
+
yield from _items(page)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def aiter_pages(
|
|
155
|
+
executor: AsyncExecutor,
|
|
156
|
+
context: CallContext,
|
|
157
|
+
*,
|
|
158
|
+
strategy: CursorStrategy,
|
|
159
|
+
page_size: int = 100,
|
|
160
|
+
params: Mapping[str, object] | None = None,
|
|
161
|
+
meta: Mapping[str, object] | None = None,
|
|
162
|
+
options: RequestOptions | None = None,
|
|
163
|
+
) -> AsyncIterator[Response[Any]]:
|
|
164
|
+
inputs = _prepare(context, strategy, page_size, params)
|
|
165
|
+
metadata = frozen_mapping(meta) if meta is not None else None
|
|
166
|
+
cursor = inputs.get(strategy.parameter)
|
|
167
|
+
seen = [cursor] if cursor is not None else []
|
|
168
|
+
while True:
|
|
169
|
+
if executor.generation != context.generation:
|
|
170
|
+
raise StaleDescriptionError("pagination belongs to an old description")
|
|
171
|
+
page = await executor.execute(
|
|
172
|
+
context, params=inputs, meta=metadata, options=options
|
|
173
|
+
)
|
|
174
|
+
following = _next(strategy, page, cursor, seen)
|
|
175
|
+
yield page
|
|
176
|
+
if following is None:
|
|
177
|
+
return
|
|
178
|
+
cursor = following
|
|
179
|
+
inputs = {**inputs, strategy.parameter: cursor}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def aiter_all(
|
|
183
|
+
executor: AsyncExecutor,
|
|
184
|
+
context: CallContext,
|
|
185
|
+
*,
|
|
186
|
+
strategy: CursorStrategy,
|
|
187
|
+
page_size: int = 100,
|
|
188
|
+
params: Mapping[str, object] | None = None,
|
|
189
|
+
meta: Mapping[str, object] | None = None,
|
|
190
|
+
options: RequestOptions | None = None,
|
|
191
|
+
) -> AsyncIterator[Any]:
|
|
192
|
+
async for page in aiter_pages(
|
|
193
|
+
executor,
|
|
194
|
+
context,
|
|
195
|
+
strategy=strategy,
|
|
196
|
+
page_size=page_size,
|
|
197
|
+
params=params,
|
|
198
|
+
meta=meta,
|
|
199
|
+
options=options,
|
|
200
|
+
):
|
|
201
|
+
for item in _items(page):
|
|
202
|
+
yield item
|
haveapi_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Finite response values and explicit, side-effect-free object bindings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
from ._protocol import (
|
|
11
|
+
ActionSpec,
|
|
12
|
+
AsyncExecutor,
|
|
13
|
+
CallContext,
|
|
14
|
+
DecodedResponse,
|
|
15
|
+
SyncExecutor,
|
|
16
|
+
lookup_action,
|
|
17
|
+
)
|
|
18
|
+
from ._protocol.models import freeze, frozen_mapping
|
|
19
|
+
from .exceptions import ProtocolError, StaleDescriptionError, ValidationError
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .operations import AsyncOperation, Operation
|
|
23
|
+
|
|
24
|
+
_T = TypeVar("_T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Response(Generic[_T]):
|
|
29
|
+
data: _T = field(repr=False)
|
|
30
|
+
meta: Mapping[str, object] = field(repr=False)
|
|
31
|
+
status_code: int
|
|
32
|
+
headers: Mapping[str, str] = field(repr=False)
|
|
33
|
+
envelope: Mapping[str, object] = field(repr=False)
|
|
34
|
+
operation: Operation | AsyncOperation | None = None
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
object.__setattr__(self, "meta", frozen_mapping(self.meta))
|
|
38
|
+
object.__setattr__(self, "headers", frozen_mapping(self.headers))
|
|
39
|
+
object.__setattr__(self, "envelope", frozen_mapping(self.envelope))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _identity(value: object) -> bool:
|
|
43
|
+
return isinstance(value, (str, int)) and not isinstance(value, bool)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bindings(
|
|
47
|
+
action: ActionSpec,
|
|
48
|
+
context: CallContext,
|
|
49
|
+
values: Mapping[str, object],
|
|
50
|
+
meta: Mapping[str, object],
|
|
51
|
+
*,
|
|
52
|
+
identity_field: str = "id",
|
|
53
|
+
inherit_context: bool = True,
|
|
54
|
+
) -> Mapping[str, str | int]:
|
|
55
|
+
names = action.path_parameters
|
|
56
|
+
supplied = meta.get("path_params")
|
|
57
|
+
if supplied is not None:
|
|
58
|
+
if (
|
|
59
|
+
not isinstance(supplied, (list, tuple))
|
|
60
|
+
or len(supplied) != len(names)
|
|
61
|
+
or not all(_identity(v) for v in supplied)
|
|
62
|
+
):
|
|
63
|
+
raise ProtocolError("object path_params must match the described show path")
|
|
64
|
+
return dict(zip(names, cast(tuple[str | int, ...], supplied), strict=True))
|
|
65
|
+
known: dict[str, str | int] = {}
|
|
66
|
+
if inherit_context:
|
|
67
|
+
known.update(
|
|
68
|
+
zip(context.action.path_parameters, context.bound_args, strict=False)
|
|
69
|
+
)
|
|
70
|
+
known.update(context.path_params)
|
|
71
|
+
result = {name: known[name] for name in names if name in known}
|
|
72
|
+
missing = [name for name in names if name not in result]
|
|
73
|
+
identity = values.get(identity_field)
|
|
74
|
+
if len(missing) == 1 and names and missing[0] == names[-1] and _identity(identity):
|
|
75
|
+
result[missing[0]] = cast(str | int, identity)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ResourceObject(Mapping[str, object]):
|
|
80
|
+
"""Read-only returned fields; navigation and inspection never fetch data."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
values: Mapping[str, object],
|
|
85
|
+
*,
|
|
86
|
+
meta: Mapping[str, object],
|
|
87
|
+
resource_path: tuple[str, ...],
|
|
88
|
+
context: CallContext,
|
|
89
|
+
executor: SyncExecutor | AsyncExecutor,
|
|
90
|
+
identity_field: str = "id",
|
|
91
|
+
inherit_context: bool = True,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._fields = MappingProxyType(
|
|
94
|
+
{
|
|
95
|
+
key: value if isinstance(value, ResourceObject) else freeze(value)
|
|
96
|
+
for key, value in values.items()
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
self._meta = frozen_mapping(meta)
|
|
100
|
+
self._resource_path = resource_path
|
|
101
|
+
self._context = context
|
|
102
|
+
self._executor = executor
|
|
103
|
+
self._identity_field = identity_field
|
|
104
|
+
self._inherit_context = inherit_context
|
|
105
|
+
|
|
106
|
+
def __getitem__(self, key: str) -> object:
|
|
107
|
+
return self._fields[key]
|
|
108
|
+
|
|
109
|
+
def __iter__(self) -> Iterator[str]:
|
|
110
|
+
return iter(self._fields)
|
|
111
|
+
|
|
112
|
+
def __len__(self) -> int:
|
|
113
|
+
return len(self._fields)
|
|
114
|
+
|
|
115
|
+
def __getattr__(self, name: str) -> object:
|
|
116
|
+
if not name.startswith("_") and name in self._fields:
|
|
117
|
+
return self._fields[name]
|
|
118
|
+
raise AttributeError(name)
|
|
119
|
+
|
|
120
|
+
def __repr__(self) -> str:
|
|
121
|
+
return f"<{type(self).__name__} resource={self._resource_path!r}>"
|
|
122
|
+
|
|
123
|
+
def __dir__(self) -> list[str]:
|
|
124
|
+
return sorted(set(super().__dir__()) | set(self._fields))
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def meta(self) -> Mapping[str, object]:
|
|
128
|
+
return self._meta
|
|
129
|
+
|
|
130
|
+
def _check(self) -> None:
|
|
131
|
+
if self._context.generation != self._executor.generation:
|
|
132
|
+
raise StaleDescriptionError("object belongs to an old description")
|
|
133
|
+
|
|
134
|
+
def _show_context(self) -> CallContext:
|
|
135
|
+
self._check()
|
|
136
|
+
try:
|
|
137
|
+
action = lookup_action(
|
|
138
|
+
self._executor.description, self._resource_path, "show"
|
|
139
|
+
)
|
|
140
|
+
except KeyError as error:
|
|
141
|
+
raise ProtocolError(
|
|
142
|
+
"associated resource has no unambiguous show action"
|
|
143
|
+
) from error
|
|
144
|
+
bindings = _bindings(
|
|
145
|
+
action,
|
|
146
|
+
self._context,
|
|
147
|
+
self._fields,
|
|
148
|
+
self._meta,
|
|
149
|
+
identity_field=self._identity_field,
|
|
150
|
+
inherit_context=self._inherit_context,
|
|
151
|
+
)
|
|
152
|
+
if set(bindings) != set(action.path_parameters):
|
|
153
|
+
raise ValidationError(
|
|
154
|
+
"object identity is incomplete; provide explicit path parameters"
|
|
155
|
+
)
|
|
156
|
+
return CallContext(action, (), bindings, self._context.generation)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def resource(self) -> Any:
|
|
160
|
+
self._check()
|
|
161
|
+
try:
|
|
162
|
+
action = lookup_action(
|
|
163
|
+
self._executor.description, self._resource_path, "show"
|
|
164
|
+
)
|
|
165
|
+
except KeyError:
|
|
166
|
+
# A resource may expose only custom actions. Preserve known binding
|
|
167
|
+
# context and let its actual action validate the required parameters.
|
|
168
|
+
if (
|
|
169
|
+
self._inherit_context
|
|
170
|
+
and self._resource_path == self._context.action.resource_path
|
|
171
|
+
):
|
|
172
|
+
return self._executor.resource(
|
|
173
|
+
self._resource_path,
|
|
174
|
+
bound_args=self._context.bound_args,
|
|
175
|
+
path_params=self._context.path_params,
|
|
176
|
+
)
|
|
177
|
+
return self._executor.resource(self._resource_path)
|
|
178
|
+
bindings = _bindings(
|
|
179
|
+
action,
|
|
180
|
+
self._context,
|
|
181
|
+
self._fields,
|
|
182
|
+
self._meta,
|
|
183
|
+
identity_field=self._identity_field,
|
|
184
|
+
inherit_context=self._inherit_context,
|
|
185
|
+
)
|
|
186
|
+
return self._executor.resource(self._resource_path, path_params=bindings)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Association(ResourceObject):
|
|
190
|
+
def fetch(self) -> Response[Any]:
|
|
191
|
+
return cast(SyncExecutor, self._executor).execute(self._show_context())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class AsyncAssociation(ResourceObject):
|
|
195
|
+
async def fetch(self) -> Response[Any]:
|
|
196
|
+
return await cast(AsyncExecutor, self._executor).execute(self._show_context())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _object(
|
|
200
|
+
values: Mapping[str, object],
|
|
201
|
+
context: CallContext,
|
|
202
|
+
executor: SyncExecutor | AsyncExecutor,
|
|
203
|
+
*,
|
|
204
|
+
asynchronous: bool,
|
|
205
|
+
single_meta: Mapping[str, object] | None = None,
|
|
206
|
+
) -> ResourceObject:
|
|
207
|
+
namespace = executor.description.meta_namespace
|
|
208
|
+
raw_meta = values.get(namespace, single_meta or {})
|
|
209
|
+
if not isinstance(raw_meta, Mapping):
|
|
210
|
+
raise ProtocolError("object metadata must be an object")
|
|
211
|
+
fields: dict[str, object] = {k: v for k, v in values.items() if k != namespace}
|
|
212
|
+
if context.action.output:
|
|
213
|
+
for name, parameter in context.action.output.parameters.items():
|
|
214
|
+
if parameter.type != "Resource" or fields.get(name) is None:
|
|
215
|
+
continue
|
|
216
|
+
associated = fields[name]
|
|
217
|
+
path = parameter.raw.get("resource")
|
|
218
|
+
if (
|
|
219
|
+
not isinstance(associated, Mapping)
|
|
220
|
+
or not isinstance(path, (list, tuple))
|
|
221
|
+
or not all(isinstance(v, str) for v in path)
|
|
222
|
+
):
|
|
223
|
+
raise ProtocolError("invalid resource association")
|
|
224
|
+
association_meta = associated.get(namespace, {})
|
|
225
|
+
if not isinstance(association_meta, Mapping):
|
|
226
|
+
raise ProtocolError("invalid association metadata")
|
|
227
|
+
value_id = parameter.raw.get("value_id", "id")
|
|
228
|
+
value_label = parameter.raw.get("value_label", "label")
|
|
229
|
+
if not isinstance(value_id, str) or not isinstance(value_label, str):
|
|
230
|
+
raise ProtocolError("invalid association identity fields")
|
|
231
|
+
association_fields = {k: v for k, v in associated.items() if k != namespace}
|
|
232
|
+
# Expose stable convenience names while retaining exact wire fields.
|
|
233
|
+
association_fields.setdefault("id", associated.get(value_id))
|
|
234
|
+
association_fields.setdefault("label", associated.get(value_label))
|
|
235
|
+
cls = AsyncAssociation if asynchronous else Association
|
|
236
|
+
fields[name] = cls(
|
|
237
|
+
association_fields,
|
|
238
|
+
meta=association_meta,
|
|
239
|
+
resource_path=tuple(path),
|
|
240
|
+
context=context,
|
|
241
|
+
executor=executor,
|
|
242
|
+
identity_field=value_id,
|
|
243
|
+
inherit_context=False,
|
|
244
|
+
)
|
|
245
|
+
return ResourceObject(
|
|
246
|
+
fields,
|
|
247
|
+
meta=raw_meta,
|
|
248
|
+
resource_path=context.action.resource_path,
|
|
249
|
+
context=context,
|
|
250
|
+
executor=executor,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _adapt(
|
|
255
|
+
decoded: DecodedResponse,
|
|
256
|
+
context: CallContext,
|
|
257
|
+
executor: SyncExecutor | AsyncExecutor,
|
|
258
|
+
*,
|
|
259
|
+
asynchronous: bool,
|
|
260
|
+
) -> Response[Any]:
|
|
261
|
+
data = decoded.data
|
|
262
|
+
output = context.action.output
|
|
263
|
+
if output and output.layout == "object":
|
|
264
|
+
if not isinstance(data, Mapping):
|
|
265
|
+
raise ProtocolError("object output must be an object")
|
|
266
|
+
data = _object(
|
|
267
|
+
data, context, executor, asynchronous=asynchronous, single_meta=decoded.meta
|
|
268
|
+
)
|
|
269
|
+
elif output and output.layout == "object_list":
|
|
270
|
+
if not isinstance(data, (list, tuple)):
|
|
271
|
+
raise ProtocolError("object list output must be an array")
|
|
272
|
+
data = tuple(
|
|
273
|
+
_object(item, context, executor, asynchronous=asynchronous) for item in data
|
|
274
|
+
)
|
|
275
|
+
operation = None
|
|
276
|
+
operation_id = decoded.meta.get("action_state_id")
|
|
277
|
+
if context.action.blocking and operation_id is not None:
|
|
278
|
+
if not _identity(operation_id):
|
|
279
|
+
raise ProtocolError("invalid blocking operation identifier")
|
|
280
|
+
from .operations import AsyncOperation, Operation
|
|
281
|
+
|
|
282
|
+
cls = AsyncOperation if asynchronous else Operation
|
|
283
|
+
operation = cls(executor, context.generation, cast(str | int, operation_id))
|
|
284
|
+
return Response(
|
|
285
|
+
data,
|
|
286
|
+
decoded.meta,
|
|
287
|
+
decoded.status_code,
|
|
288
|
+
decoded.headers,
|
|
289
|
+
decoded.envelope,
|
|
290
|
+
operation,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def adapt_response(
|
|
295
|
+
decoded: DecodedResponse, context: CallContext, executor: SyncExecutor
|
|
296
|
+
) -> Response[Any]:
|
|
297
|
+
return _adapt(decoded, context, executor, asynchronous=False)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def adapt_async_response(
|
|
301
|
+
decoded: DecodedResponse, context: CallContext, executor: AsyncExecutor
|
|
302
|
+
) -> Response[Any]:
|
|
303
|
+
return _adapt(decoded, context, executor, asynchronous=True)
|