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.
@@ -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
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)