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,209 @@
1
+ """Asynchronous dynamic HaveAPI client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, Any, Self
7
+
8
+ import httpx
9
+
10
+ from haveapi_client._auth import (
11
+ AsyncAuthenticationChallenge,
12
+ Authenticated,
13
+ AuthProvider,
14
+ TokenAuth,
15
+ )
16
+ from haveapi_client._protocol import (
17
+ CallContext,
18
+ DecodedResponse,
19
+ Description,
20
+ InputParams,
21
+ RequestOptions,
22
+ decode_envelope,
23
+ decode_response,
24
+ parse_description,
25
+ )
26
+ from haveapi_client._resources.state import ClientState
27
+ from haveapi_client._transport import AsyncTransport
28
+ from haveapi_client.exceptions import AuthenticationError
29
+
30
+ if TYPE_CHECKING:
31
+ from haveapi_client.response import Response
32
+
33
+
34
+ class AsyncClient(ClientState):
35
+ def __init__(
36
+ self,
37
+ base_url: str,
38
+ *,
39
+ version: str | None = None,
40
+ auth: AuthProvider | None = None,
41
+ timeout: float = 30.0,
42
+ language: str | None = None,
43
+ http_client: httpx.AsyncClient | None = None,
44
+ ) -> None:
45
+ super().__init__(
46
+ base_url, version=version, auth=auth, language=language, asynchronous=True
47
+ )
48
+ self._transport = AsyncTransport(timeout=timeout, http_client=http_client)
49
+
50
+ async def _discover(
51
+ self,
52
+ description: Description | None = None,
53
+ auth: AuthProvider | None = None,
54
+ ) -> Description:
55
+ wire = await self._transport.send(self._discovery_request(description, auth))
56
+ return parse_description(decode_envelope(wire), api_version=self._version)
57
+
58
+ async def _load(self) -> Description:
59
+ public = await self._discover()
60
+ return (
61
+ await self._discover(public, self._auth)
62
+ if self._auth is not None
63
+ else public
64
+ )
65
+
66
+ async def connect(self) -> Self:
67
+ with self._mutation():
68
+ if self._description is None:
69
+ self._install(await self._load())
70
+ return self
71
+
72
+ async def refresh_description(self) -> Description:
73
+ with self._mutation():
74
+ self._invalidate()
75
+ description = await self._load()
76
+ self._install(description)
77
+ return description
78
+
79
+ async def set_auth(self, auth: AuthProvider | None) -> Self:
80
+ with self._mutation():
81
+ self._invalidate()
82
+ self._auth = auth
83
+ self._install(await self._load())
84
+ return self
85
+
86
+ async def logout(self) -> None:
87
+ await self.set_auth(None)
88
+
89
+ async def _invoke(
90
+ self,
91
+ description: Description,
92
+ context: CallContext,
93
+ *,
94
+ auth: AuthProvider | None,
95
+ params: InputParams | None = None,
96
+ meta: Mapping[str, object] | None = None,
97
+ options: RequestOptions | None = None,
98
+ ) -> DecodedResponse:
99
+ request = self._action_request(
100
+ description, context, auth=auth, params=params, meta=meta
101
+ )
102
+ return decode_response(
103
+ description,
104
+ context.action,
105
+ await self._transport.send(request, options=options),
106
+ )
107
+
108
+ async def execute(
109
+ self,
110
+ context: CallContext,
111
+ *,
112
+ params: InputParams | None = None,
113
+ meta: Mapping[str, object] | None = None,
114
+ options: RequestOptions | None = None,
115
+ ) -> Response[Any]:
116
+ from haveapi_client.response import adapt_async_response
117
+
118
+ with self._request(context) as description:
119
+ decoded = await self._invoke(
120
+ description,
121
+ context,
122
+ auth=self._auth,
123
+ params=params,
124
+ meta=meta,
125
+ options=options,
126
+ )
127
+ return adapt_async_response(decoded, context, self)
128
+
129
+ async def begin_token_login(
130
+ self, params: Mapping[str, object]
131
+ ) -> Authenticated | AsyncAuthenticationChallenge:
132
+ with self._mutation():
133
+ self._invalidate()
134
+ self._auth = None
135
+ description = await self._discover()
136
+ return await self._login_step(description, "request", params)
137
+
138
+ async def _login_step(
139
+ self,
140
+ description: Description,
141
+ name: str,
142
+ params: Mapping[str, object],
143
+ ) -> Authenticated | AsyncAuthenticationChallenge:
144
+ action = self._token_action(description, name)
145
+ context = CallContext(action, (), {}, self.generation)
146
+ decoded = await self._invoke(description, context, auth=None, params=params)
147
+ complete, token, valid_to, next_action = self._login_result(decoded.data)
148
+ self._login = None
149
+ if complete:
150
+ auth = TokenAuth(token)
151
+ authorized = await self._discover(description, auth)
152
+ self._auth = auth
153
+ self._install(authorized)
154
+ return Authenticated(token, valid_to)
155
+ assert next_action is not None
156
+ fields, nonce = self._prepare_challenge(description, next_action)
157
+
158
+ async def submit(
159
+ values: Mapping[str, object],
160
+ ) -> Authenticated | AsyncAuthenticationChallenge:
161
+ with self._mutation():
162
+ params = self._continuation_params(nonce, token, values)
163
+ return await self._login_step(description, next_action, params)
164
+
165
+ return AsyncAuthenticationChallenge(next_action, fields, valid_to, submit)
166
+
167
+ async def renew_token(self) -> Response[Any]:
168
+ with self._mutation():
169
+ return await self._token_operation("renew")
170
+
171
+ async def revoke_token(self) -> Response[Any]:
172
+ with self._mutation():
173
+ response = await self._token_operation("revoke")
174
+ self._auth = None
175
+ self._invalidate()
176
+ self._install(await self._discover())
177
+ return response
178
+
179
+ async def _token_operation(self, name: str) -> Response[Any]:
180
+ from haveapi_client.response import adapt_async_response
181
+
182
+ if not isinstance(self._auth, TokenAuth):
183
+ raise AuthenticationError("A connected token identity is required")
184
+ description = self.description
185
+ context = CallContext(
186
+ self._token_action(description, name), (), {}, self.generation
187
+ )
188
+ return adapt_async_response(
189
+ await self._invoke(description, context, auth=self._auth), context, self
190
+ )
191
+
192
+ async def aclose(self) -> None:
193
+ if self._closed:
194
+ return
195
+ with self._mutation():
196
+ self._invalidate()
197
+ self._auth = None
198
+ self._closed = True
199
+ await self._transport.aclose()
200
+
201
+ async def __aenter__(self) -> Self:
202
+ try:
203
+ return await self.connect()
204
+ except BaseException:
205
+ await self.aclose()
206
+ raise
207
+
208
+ async def __aexit__(self, *args: object) -> None:
209
+ await self.aclose()
@@ -0,0 +1,203 @@
1
+ """Synchronous dynamic HaveAPI client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, Any, Self
7
+
8
+ import httpx
9
+
10
+ from haveapi_client._auth import (
11
+ Authenticated,
12
+ AuthenticationChallenge,
13
+ AuthProvider,
14
+ TokenAuth,
15
+ )
16
+ from haveapi_client._protocol import (
17
+ CallContext,
18
+ DecodedResponse,
19
+ Description,
20
+ InputParams,
21
+ RequestOptions,
22
+ decode_envelope,
23
+ decode_response,
24
+ parse_description,
25
+ )
26
+ from haveapi_client._resources.state import ClientState
27
+ from haveapi_client._transport import SyncTransport
28
+ from haveapi_client.exceptions import AuthenticationError
29
+
30
+ if TYPE_CHECKING:
31
+ from haveapi_client.response import Response
32
+
33
+
34
+ class Client(ClientState):
35
+ def __init__(
36
+ self,
37
+ base_url: str,
38
+ *,
39
+ version: str | None = None,
40
+ auth: AuthProvider | None = None,
41
+ timeout: float = 30.0,
42
+ language: str | None = None,
43
+ http_client: httpx.Client | None = None,
44
+ ) -> None:
45
+ super().__init__(
46
+ base_url, version=version, auth=auth, language=language, asynchronous=False
47
+ )
48
+ self._transport = SyncTransport(timeout=timeout, http_client=http_client)
49
+
50
+ def _discover(
51
+ self,
52
+ description: Description | None = None,
53
+ auth: AuthProvider | None = None,
54
+ ) -> Description:
55
+ wire = self._transport.send(self._discovery_request(description, auth))
56
+ return parse_description(decode_envelope(wire), api_version=self._version)
57
+
58
+ def _load(self) -> Description:
59
+ public = self._discover()
60
+ return self._discover(public, self._auth) if self._auth is not None else public
61
+
62
+ def connect(self) -> Self:
63
+ with self._mutation():
64
+ if self._description is None:
65
+ self._install(self._load())
66
+ return self
67
+
68
+ def refresh_description(self) -> Description:
69
+ with self._mutation():
70
+ self._invalidate()
71
+ description = self._load()
72
+ self._install(description)
73
+ return description
74
+
75
+ def set_auth(self, auth: AuthProvider | None) -> Self:
76
+ with self._mutation():
77
+ self._invalidate()
78
+ self._auth = auth
79
+ self._install(self._load())
80
+ return self
81
+
82
+ def logout(self) -> None:
83
+ self.set_auth(None)
84
+
85
+ def _invoke(
86
+ self,
87
+ description: Description,
88
+ context: CallContext,
89
+ *,
90
+ auth: AuthProvider | None,
91
+ params: InputParams | None = None,
92
+ meta: Mapping[str, object] | None = None,
93
+ options: RequestOptions | None = None,
94
+ ) -> DecodedResponse:
95
+ request = self._action_request(
96
+ description, context, auth=auth, params=params, meta=meta
97
+ )
98
+ return decode_response(
99
+ description, context.action, self._transport.send(request, options=options)
100
+ )
101
+
102
+ def execute(
103
+ self,
104
+ context: CallContext,
105
+ *,
106
+ params: InputParams | None = None,
107
+ meta: Mapping[str, object] | None = None,
108
+ options: RequestOptions | None = None,
109
+ ) -> Response[Any]:
110
+ from haveapi_client.response import adapt_response
111
+
112
+ with self._request(context) as description:
113
+ decoded = self._invoke(
114
+ description,
115
+ context,
116
+ auth=self._auth,
117
+ params=params,
118
+ meta=meta,
119
+ options=options,
120
+ )
121
+ return adapt_response(decoded, context, self)
122
+
123
+ def begin_token_login(
124
+ self, params: Mapping[str, object]
125
+ ) -> Authenticated | AuthenticationChallenge:
126
+ with self._mutation():
127
+ self._invalidate()
128
+ self._auth = None
129
+ description = self._discover()
130
+ return self._login_step(description, "request", params)
131
+
132
+ def _login_step(
133
+ self,
134
+ description: Description,
135
+ name: str,
136
+ params: Mapping[str, object],
137
+ ) -> Authenticated | AuthenticationChallenge:
138
+ action = self._token_action(description, name)
139
+ context = CallContext(action, (), {}, self.generation)
140
+ decoded = self._invoke(description, context, auth=None, params=params)
141
+ complete, token, valid_to, next_action = self._login_result(decoded.data)
142
+ self._login = None
143
+ if complete:
144
+ auth = TokenAuth(token)
145
+ authorized = self._discover(description, auth)
146
+ self._auth = auth
147
+ self._install(authorized)
148
+ return Authenticated(token, valid_to)
149
+ assert next_action is not None
150
+ fields, nonce = self._prepare_challenge(description, next_action)
151
+
152
+ def submit(
153
+ values: Mapping[str, object],
154
+ ) -> Authenticated | AuthenticationChallenge:
155
+ with self._mutation():
156
+ params = self._continuation_params(nonce, token, values)
157
+ return self._login_step(description, next_action, params)
158
+
159
+ return AuthenticationChallenge(next_action, fields, valid_to, submit)
160
+
161
+ def renew_token(self) -> Response[Any]:
162
+ with self._mutation():
163
+ return self._token_operation("renew")
164
+
165
+ def revoke_token(self) -> Response[Any]:
166
+ with self._mutation():
167
+ response = self._token_operation("revoke")
168
+ self._auth = None
169
+ self._invalidate()
170
+ self._install(self._discover())
171
+ return response
172
+
173
+ def _token_operation(self, name: str) -> Response[Any]:
174
+ from haveapi_client.response import adapt_response
175
+
176
+ if not isinstance(self._auth, TokenAuth):
177
+ raise AuthenticationError("A connected token identity is required")
178
+ description = self.description
179
+ context = CallContext(
180
+ self._token_action(description, name), (), {}, self.generation
181
+ )
182
+ return adapt_response(
183
+ self._invoke(description, context, auth=self._auth), context, self
184
+ )
185
+
186
+ def close(self) -> None:
187
+ if self._closed:
188
+ return
189
+ with self._mutation():
190
+ self._invalidate()
191
+ self._auth = None
192
+ self._closed = True
193
+ self._transport.close()
194
+
195
+ def __enter__(self) -> Self:
196
+ try:
197
+ return self.connect()
198
+ except BaseException:
199
+ self.close()
200
+ raise
201
+
202
+ def __exit__(self, *args: object) -> None:
203
+ self.close()
@@ -0,0 +1,88 @@
1
+ """Public error categories with safe, inspectable protocol context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from types import MappingProxyType
7
+
8
+
9
+ def _redact(value: object) -> object:
10
+ if isinstance(value, Mapping):
11
+ return MappingProxyType(
12
+ {
13
+ str(k): (
14
+ "<redacted>"
15
+ if any(s in str(k).lower() for s in ("token", "password", "secret"))
16
+ else _redact(v)
17
+ )
18
+ for k, v in value.items()
19
+ }
20
+ )
21
+ if isinstance(value, (list, tuple)):
22
+ return tuple(_redact(v) for v in value)
23
+ return value
24
+
25
+
26
+ class HaveAPIError(Exception):
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ status_code: int | None = None,
32
+ errors: Mapping[str, object] | None = None,
33
+ envelope: Mapping[str, object] | None = None,
34
+ operation_id: str | int | None = None,
35
+ ) -> None:
36
+ super().__init__(message)
37
+ self.message = message
38
+ self.status_code = status_code
39
+ self.errors = MappingProxyType(dict(errors or {}))
40
+ # Arbitrary protected parameter names cannot be inferred from an envelope.
41
+ # Retain its diagnostic shape, but never expose a failed response payload.
42
+ self.envelope = MappingProxyType(
43
+ {
44
+ k: "<redacted>" if k == "response" else _redact(v)
45
+ for k, v in (envelope or {}).items()
46
+ }
47
+ )
48
+ self.operation_id = operation_id
49
+
50
+
51
+ class ProtocolError(HaveAPIError):
52
+ pass
53
+
54
+
55
+ class TransportError(HaveAPIError):
56
+ pass
57
+
58
+
59
+ class AuthenticationError(HaveAPIError):
60
+ pass
61
+
62
+
63
+ class ValidationError(HaveAPIError):
64
+ pass
65
+
66
+
67
+ class ActionError(HaveAPIError):
68
+ pass
69
+
70
+
71
+ class NotConnectedError(HaveAPIError):
72
+ pass
73
+
74
+
75
+ class StaleDescriptionError(HaveAPIError):
76
+ pass
77
+
78
+
79
+ class PaginationError(HaveAPIError):
80
+ pass
81
+
82
+
83
+ class OperationFailedError(HaveAPIError):
84
+ pass
85
+
86
+
87
+ class OperationTimeoutError(HaveAPIError):
88
+ pass
@@ -0,0 +1,190 @@
1
+ """Explicit monitoring of HaveAPI action_state operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time
7
+ from collections.abc import Mapping
8
+ from typing import Any, cast
9
+
10
+ import anyio
11
+
12
+ from ._protocol import (
13
+ AsyncExecutor,
14
+ CallContext,
15
+ RequestOptions,
16
+ SyncExecutor,
17
+ lookup_action,
18
+ )
19
+ from .exceptions import (
20
+ OperationFailedError,
21
+ OperationTimeoutError,
22
+ ProtocolError,
23
+ StaleDescriptionError,
24
+ TransportError,
25
+ )
26
+ from .response import Response
27
+
28
+
29
+ def _positive(value: float, name: str) -> None:
30
+ if isinstance(value, bool) or not math.isfinite(value) or value <= 0:
31
+ raise ValueError(f"{name} must be positive and finite")
32
+
33
+
34
+ class _Operation:
35
+ def __init__(
36
+ self,
37
+ executor: SyncExecutor | AsyncExecutor,
38
+ generation: int,
39
+ action_state_id: str | int,
40
+ ) -> None:
41
+ if isinstance(action_state_id, bool) or not isinstance(
42
+ action_state_id, (str, int)
43
+ ):
44
+ raise ValueError("operation ID must be a string or integer")
45
+ self._executor = executor
46
+ self._generation = generation
47
+ self.action_state_id = action_state_id
48
+
49
+ def __repr__(self) -> str:
50
+ return f"<{type(self).__name__} id={self.action_state_id!r}>"
51
+
52
+ def _context(self, name: str) -> CallContext:
53
+ if self._generation != self._executor.generation:
54
+ raise StaleDescriptionError(
55
+ "operation belongs to an old description",
56
+ operation_id=self.action_state_id,
57
+ )
58
+ try:
59
+ action = lookup_action(self._executor.description, ("action_state",), name)
60
+ except KeyError as error:
61
+ raise ProtocolError(
62
+ f"operation action is unavailable: {name}",
63
+ operation_id=self.action_state_id,
64
+ ) from error
65
+ if len(action.path_parameters) != 1:
66
+ raise ProtocolError(
67
+ "operation action must have one ID path parameter",
68
+ operation_id=self.action_state_id,
69
+ )
70
+ return CallContext(action, (self.action_state_id,), {}, self._generation)
71
+
72
+ def _terminal(self, response: Response[Any]) -> bool:
73
+ state = response.data
74
+ if not isinstance(state, Mapping) or not isinstance(
75
+ state.get("finished"), bool
76
+ ):
77
+ raise ProtocolError(
78
+ "operation response has no boolean finished state",
79
+ operation_id=self.action_state_id,
80
+ )
81
+ if state["finished"] is False:
82
+ return False
83
+ if state.get("status") is False:
84
+ raise OperationFailedError(
85
+ "operation finished unsuccessfully",
86
+ status_code=response.status_code,
87
+ envelope=response.envelope,
88
+ operation_id=self.action_state_id,
89
+ )
90
+ if state.get("status") is not True:
91
+ raise ProtocolError(
92
+ "finished operation has no boolean result status",
93
+ operation_id=self.action_state_id,
94
+ )
95
+ return True
96
+
97
+ def _remaining(self, deadline: float) -> float:
98
+ remaining = deadline - time.monotonic()
99
+ if remaining <= 0:
100
+ raise OperationTimeoutError(
101
+ "operation wait timed out", operation_id=self.action_state_id
102
+ )
103
+ return remaining
104
+
105
+ def _cancellable(self, response: Response[Any]) -> None:
106
+ if (
107
+ not isinstance(response.data, Mapping)
108
+ or response.data.get("can_cancel") is not True
109
+ ):
110
+ raise OperationFailedError(
111
+ "operation cannot be cancelled", operation_id=self.action_state_id
112
+ )
113
+
114
+
115
+ class Operation(_Operation):
116
+ def show(self) -> Response[Any]:
117
+ return cast(SyncExecutor, self._executor).execute(self._context("show"))
118
+
119
+ def wait(self, *, timeout: float = 300.0, interval: float = 1.0) -> Response[Any]:
120
+ """Wait locally, leaving the server operation running on timeout.
121
+
122
+ HTTPX applies the remaining budget to individual transport phases. A
123
+ blocking request can return control after the overall deadline; classify
124
+ its result or failure against the monotonic deadline once it returns.
125
+ """
126
+ _positive(timeout, "timeout")
127
+ _positive(interval, "interval")
128
+ deadline = time.monotonic() + timeout
129
+ while True:
130
+ remaining = self._remaining(deadline)
131
+ try:
132
+ response = cast(SyncExecutor, self._executor).execute(
133
+ self._context("show"), options=RequestOptions(timeout=remaining)
134
+ )
135
+ except TransportError as error:
136
+ try:
137
+ self._remaining(deadline)
138
+ except OperationTimeoutError as expired:
139
+ raise expired from error
140
+ raise
141
+ self._remaining(deadline)
142
+ if self._terminal(response):
143
+ return response
144
+ time.sleep(min(interval, self._remaining(deadline)))
145
+
146
+ def cancel(self) -> Response[Any]:
147
+ context = self._context("cancel")
148
+ self._cancellable(self.show())
149
+ return cast(SyncExecutor, self._executor).execute(context)
150
+
151
+
152
+ class AsyncOperation(_Operation):
153
+ async def show(self) -> Response[Any]:
154
+ return await cast(AsyncExecutor, self._executor).execute(self._context("show"))
155
+
156
+ async def wait(
157
+ self, *, timeout: float = 300.0, interval: float = 1.0
158
+ ) -> Response[Any]:
159
+ _positive(timeout, "timeout")
160
+ _positive(interval, "interval")
161
+ deadline = time.monotonic() + timeout
162
+ while True:
163
+ remaining = self._remaining(deadline)
164
+ scope = None
165
+ try:
166
+ with anyio.fail_after(remaining) as scope:
167
+ response = await cast(AsyncExecutor, self._executor).execute(
168
+ self._context("show"), options=RequestOptions(timeout=remaining)
169
+ )
170
+ except TimeoutError as error:
171
+ if scope is not None and scope.cancelled_caught:
172
+ raise OperationTimeoutError(
173
+ "operation wait timed out", operation_id=self.action_state_id
174
+ ) from error
175
+ raise
176
+ except TransportError as error:
177
+ try:
178
+ self._remaining(deadline)
179
+ except OperationTimeoutError as expired:
180
+ raise expired from error
181
+ raise
182
+ self._remaining(deadline)
183
+ if self._terminal(response):
184
+ return response
185
+ await anyio.sleep(min(interval, self._remaining(deadline)))
186
+
187
+ async def cancel(self) -> Response[Any]:
188
+ context = self._context("cancel")
189
+ self._cancellable(await self.show())
190
+ return await cast(AsyncExecutor, self._executor).execute(context)