ailogger-client-dev 0.1.51__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.
- ailogger_client/__init__.py +96 -0
- ailogger_client/client.py +220 -0
- ailogger_client/config.py +386 -0
- ailogger_client/errors.py +610 -0
- ailogger_client/file_transfer.py +110 -0
- ailogger_client/invocation.py +550 -0
- ailogger_client/message_methods.py +332 -0
- ailogger_client/observation_methods.py +195 -0
- ailogger_client/py.typed +0 -0
- ailogger_client/queued_methods.py +247 -0
- ailogger_client/read_methods.py +585 -0
- ailogger_client/retrieval_methods.py +314 -0
- ailogger_client/service_methods.py +148 -0
- ailogger_client/session_methods.py +236 -0
- ailogger_client_dev-0.1.51.dist-info/METADATA +108 -0
- ailogger_client_dev-0.1.51.dist-info/RECORD +18 -0
- ailogger_client_dev-0.1.51.dist-info/WHEEL +5 -0
- ailogger_client_dev-0.1.51.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Queued commands: submit a job, observe it, and return its finished result.
|
|
3
|
+
|
|
4
|
+
Some commands of the service are ALWAYS queued: a call is answered with a job
|
|
5
|
+
identity, never with the result, and the result is read through the queue's
|
|
6
|
+
job status. This module is the one place the client knows how to do that. It
|
|
7
|
+
carries two kinds of members:
|
|
8
|
+
|
|
9
|
+
- the PRIVATE machinery every typed queued method is built on - submitting a
|
|
10
|
+
command and taking the job identity out of the answer, and waiting for a job
|
|
11
|
+
to finish and taking its result out of the job-status answer. The caller of
|
|
12
|
+
a typed method never sees a job identity, a poll or an envelope unless it
|
|
13
|
+
asks for the job identity;
|
|
14
|
+
- the public typed ``messages_import_submit``, the no-wait form of the
|
|
15
|
+
``messages_import`` command, which submits the job and returns its identity.
|
|
16
|
+
(The waiting form, ``messages_import``, is declared with the other message
|
|
17
|
+
methods.)
|
|
18
|
+
|
|
19
|
+
POLLING AND ENVELOPES BELONG HERE. Everything about reading a job - the poll
|
|
20
|
+
interval, the deadline, which answers are terminal, where in the job-status
|
|
21
|
+
answer the command's result lives, how a failed job or a refusing result
|
|
22
|
+
becomes a typed error of this client - is this module's, so a consumer of the
|
|
23
|
+
client (the pipeline included) holds none of it.
|
|
24
|
+
|
|
25
|
+
NETWORK LAW. This module imports nothing from the adapter framework and adds
|
|
26
|
+
no transport of its own; it reaches the service only through the invocation
|
|
27
|
+
seam and the job-observation surface of ``ailogger_client.invocation``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import time
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from typing import Any, Final
|
|
36
|
+
|
|
37
|
+
from .errors import (
|
|
38
|
+
CompletionFailedError,
|
|
39
|
+
CompletionTimeoutError,
|
|
40
|
+
QueuedProtocolError,
|
|
41
|
+
ResultUnavailableError,
|
|
42
|
+
refusal_from_answer,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = ["QueuedMixin", "find_job_id"]
|
|
46
|
+
|
|
47
|
+
#: How often a waiting caller asks the queue for the job's status, in seconds.
|
|
48
|
+
_POLL_SECONDS: Final[float] = 1.0
|
|
49
|
+
|
|
50
|
+
#: The longest a waiting caller waits for one job to finish, in seconds.
|
|
51
|
+
_WAIT_SECONDS: Final[float] = 900.0
|
|
52
|
+
|
|
53
|
+
#: Job-status values that end a job without a usable result.
|
|
54
|
+
_FAILED_STATUSES: Final[frozenset[str]] = frozenset(
|
|
55
|
+
{"failed", "error", "timeout", "stopped", "cancelled"}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
#: The key the framework's job-status tracking keeps the last queue answer under.
|
|
59
|
+
_LAST_QUEUE_POLL: Final[str] = "last_queue_poll"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
#: The wrappers a job identity may sit inside in a submit answer.
|
|
63
|
+
_ANSWER_WRAPPERS: Final[tuple[str, ...]] = ("data", "result")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_job_id(answer: object) -> str | None:
|
|
67
|
+
"""
|
|
68
|
+
The job identity a submit answer carries, if it carries one.
|
|
69
|
+
|
|
70
|
+
An always-queued command is answered with a job identity at the top of the
|
|
71
|
+
answer or inside the answer's ``data`` / ``result`` wrapper; a direct
|
|
72
|
+
command's answer carries none. Nothing deeper is looked at, so a field
|
|
73
|
+
that merely happens to be called ``job_id`` inside some result is never
|
|
74
|
+
taken for one.
|
|
75
|
+
"""
|
|
76
|
+
if not isinstance(answer, Mapping):
|
|
77
|
+
return None
|
|
78
|
+
found = answer.get("job_id")
|
|
79
|
+
if isinstance(found, str) and found.strip():
|
|
80
|
+
return found.strip()
|
|
81
|
+
for wrapper in _ANSWER_WRAPPERS:
|
|
82
|
+
nested = answer.get(wrapper)
|
|
83
|
+
if isinstance(nested, Mapping):
|
|
84
|
+
found = nested.get("job_id")
|
|
85
|
+
if isinstance(found, str) and found.strip():
|
|
86
|
+
return found.strip()
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _find_result_array(node: object) -> list[Any] | None:
|
|
91
|
+
"""
|
|
92
|
+
The array a finished job answered with: the ``data`` of the first success
|
|
93
|
+
envelope whose data is an array, searched through the job-status answer.
|
|
94
|
+
"""
|
|
95
|
+
if isinstance(node, Mapping):
|
|
96
|
+
data = node.get("data")
|
|
97
|
+
if node.get("success") is True and isinstance(data, list):
|
|
98
|
+
return data
|
|
99
|
+
for value in node.values():
|
|
100
|
+
nested = _find_result_array(value)
|
|
101
|
+
if nested is not None:
|
|
102
|
+
return nested
|
|
103
|
+
elif isinstance(node, list):
|
|
104
|
+
for value in node:
|
|
105
|
+
nested = _find_result_array(value)
|
|
106
|
+
if nested is not None:
|
|
107
|
+
return nested
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _find_refusal(node: object, command: str) -> Exception | None:
|
|
112
|
+
"""A refusing result envelope inside the job-status answer, if there is one."""
|
|
113
|
+
if isinstance(node, Mapping):
|
|
114
|
+
if node.get("success") is False:
|
|
115
|
+
refusal = refusal_from_answer(dict(node), command)
|
|
116
|
+
if refusal is not None:
|
|
117
|
+
return refusal
|
|
118
|
+
for value in node.values():
|
|
119
|
+
nested = _find_refusal(value, command)
|
|
120
|
+
if nested is not None:
|
|
121
|
+
return nested
|
|
122
|
+
elif isinstance(node, list):
|
|
123
|
+
for value in node:
|
|
124
|
+
nested = _find_refusal(value, command)
|
|
125
|
+
if nested is not None:
|
|
126
|
+
return nested
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class QueuedMixin:
|
|
131
|
+
"""
|
|
132
|
+
Mixin providing the machinery of always-queued commands and the no-wait
|
|
133
|
+
typed method of ``messages_import``.
|
|
134
|
+
|
|
135
|
+
This mixin expects to be composed with InvocationMixin, which provides
|
|
136
|
+
``_invoke`` and the job-observation surface (``get_job_status``).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
async def _invoke(
|
|
140
|
+
self,
|
|
141
|
+
command: str,
|
|
142
|
+
params: dict[str, Any] | None = None,
|
|
143
|
+
*,
|
|
144
|
+
wait_for_result: bool = True,
|
|
145
|
+
) -> Any:
|
|
146
|
+
"""Private invocation entry point, provided by InvocationMixin."""
|
|
147
|
+
raise NotImplementedError # Provided by InvocationMixin
|
|
148
|
+
|
|
149
|
+
async def get_job_status(
|
|
150
|
+
self,
|
|
151
|
+
job_id: str,
|
|
152
|
+
*,
|
|
153
|
+
sync_from_queue: bool = False,
|
|
154
|
+
) -> Any:
|
|
155
|
+
"""Job observation, provided by InvocationMixin."""
|
|
156
|
+
raise NotImplementedError # Provided by InvocationMixin
|
|
157
|
+
|
|
158
|
+
async def _submit_queued(self, command: str, params: dict[str, Any]) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Submit an always-queued command and return its job identity.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
QueuedProtocolError: When the service answered without a job
|
|
164
|
+
identity - an always-queued command must never be answered
|
|
165
|
+
inline.
|
|
166
|
+
ServiceRefusalError: When the whole call is refused (nothing is
|
|
167
|
+
written and no job exists).
|
|
168
|
+
"""
|
|
169
|
+
answer = await self._invoke(command, params, wait_for_result=False)
|
|
170
|
+
job_id = find_job_id(answer)
|
|
171
|
+
if job_id is None:
|
|
172
|
+
raise QueuedProtocolError(
|
|
173
|
+
f"{command} was not queued: the service answered without a "
|
|
174
|
+
"job identity"
|
|
175
|
+
)
|
|
176
|
+
return job_id
|
|
177
|
+
|
|
178
|
+
async def _await_queued_result(
|
|
179
|
+
self,
|
|
180
|
+
job_id: str,
|
|
181
|
+
*,
|
|
182
|
+
command: str,
|
|
183
|
+
timeout: float | None = None,
|
|
184
|
+
) -> list[Any]:
|
|
185
|
+
"""
|
|
186
|
+
Wait for a queued job to finish and return the array it answered with.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
CompletionTimeoutError: When the job does not finish in time.
|
|
190
|
+
CompletionFailedError: When the job ends failed.
|
|
191
|
+
ServiceRefusalError: When the job's own result is a refusal.
|
|
192
|
+
ResultUnavailableError: When the job ended without a result array.
|
|
193
|
+
"""
|
|
194
|
+
limit = _WAIT_SECONDS if timeout is None else timeout
|
|
195
|
+
deadline = time.monotonic() + limit
|
|
196
|
+
while True:
|
|
197
|
+
snapshot = await self.get_job_status(job_id, sync_from_queue=True)
|
|
198
|
+
if snapshot.is_terminal:
|
|
199
|
+
break
|
|
200
|
+
if time.monotonic() >= deadline:
|
|
201
|
+
raise CompletionTimeoutError(
|
|
202
|
+
f"{command} job {job_id} did not finish within {limit} seconds"
|
|
203
|
+
)
|
|
204
|
+
await asyncio.sleep(_POLL_SECONDS)
|
|
205
|
+
summary = snapshot.summary
|
|
206
|
+
raw: object = summary.get(_LAST_QUEUE_POLL, summary)
|
|
207
|
+
status = str(snapshot.execution_status or "").lower()
|
|
208
|
+
refusal = _find_refusal(raw, command)
|
|
209
|
+
if refusal is not None:
|
|
210
|
+
raise refusal
|
|
211
|
+
if status in _FAILED_STATUSES:
|
|
212
|
+
raise CompletionFailedError(
|
|
213
|
+
f"{command} job {job_id} ended {status!r}: {raw!r}"
|
|
214
|
+
)
|
|
215
|
+
result = _find_result_array(raw)
|
|
216
|
+
if result is None:
|
|
217
|
+
raise ResultUnavailableError(
|
|
218
|
+
f"{command} job {job_id} ended {status!r} without a result array"
|
|
219
|
+
)
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
async def messages_import_submit(self, messages: list[dict[str, Any]]) -> str:
|
|
223
|
+
"""Submit a batch import without waiting and return its job identity.
|
|
224
|
+
|
|
225
|
+
``messages_import`` is ALWAYS queued: the service answers with a job
|
|
226
|
+
identity and runs the import in a worker. This is the no-wait form:
|
|
227
|
+
it returns that identity as a plain string. The job's result - one
|
|
228
|
+
``{message_id, success}`` entry per item - is what the waiting form,
|
|
229
|
+
``messages_import``, returns; a caller of this method can read it
|
|
230
|
+
through ``get_job_status``.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
messages: The items to import - exactly the ``append_message``
|
|
234
|
+
parameter set per item, with ``message_id`` and
|
|
235
|
+
``occurred_at`` required. Passed through unchanged; the
|
|
236
|
+
service validates them.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
The job identity.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
ServiceRefusalError: When the whole call is refused (nothing is
|
|
243
|
+
written and no job is created).
|
|
244
|
+
QueuedProtocolError: When the service answered inline instead of
|
|
245
|
+
queueing.
|
|
246
|
+
"""
|
|
247
|
+
return await self._submit_queued("messages_import", {"messages": messages})
|