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,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()
|
haveapi_client/client.py
ADDED
|
@@ -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)
|