velophex 0.2.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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""What a running automation can ask the platform for.
|
|
2
|
+
|
|
3
|
+
Two things live here:
|
|
4
|
+
|
|
5
|
+
* the **ambient run** - :func:`stop_requested`, :func:`stop_reason`, :func:`context` - which
|
|
6
|
+
the worker installs around the handler, and
|
|
7
|
+
* the **Runtime API** - :func:`client` and the typed helpers below - which reaches the
|
|
8
|
+
Orchestrator for assets, secrets, credentials, queues, storage buckets, logs and child jobs.
|
|
9
|
+
|
|
10
|
+
Both need a real run: outside one (a unit test, an import) :func:`context` returns ``None``
|
|
11
|
+
and :func:`client` raises. Nothing here reads the environment for credentials; the execution
|
|
12
|
+
token arrives in the ``start`` context and stays in memory (ADR-P005).
|
|
13
|
+
|
|
14
|
+
>>> from velophex import runtime
|
|
15
|
+
>>>
|
|
16
|
+
>>> api_key = runtime.secret("ErpApiKey")
|
|
17
|
+
>>> while not runtime.stop_requested():
|
|
18
|
+
... item = runtime.claim_queue_item("Invoices")
|
|
19
|
+
... if item is None:
|
|
20
|
+
... break
|
|
21
|
+
>>>
|
|
22
|
+
>>> for file in runtime.list_files("Invoices", "inbox", pattern="*.pdf"):
|
|
23
|
+
... runtime.download_file("Invoices", file.path, f"C:/work/{file.name}")
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import sys
|
|
29
|
+
from collections import Counter
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from os import PathLike
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
from uuid import UUID
|
|
35
|
+
|
|
36
|
+
from velophex.contracts.executor import ExecutionContextInfo, SubResultMessage
|
|
37
|
+
from velophex.errors import VelophexError
|
|
38
|
+
from velophex.runtime._session import RunSession, clear_session, current_session, set_session
|
|
39
|
+
from velophex.runtime.client import (
|
|
40
|
+
ActionAppNotFoundError,
|
|
41
|
+
ActionHubDisabledError,
|
|
42
|
+
ActionHubError,
|
|
43
|
+
ActionHubRuntimeUnsupportedError,
|
|
44
|
+
ActionTaskCorrelationConflictError,
|
|
45
|
+
ActionTaskInputInvalidError,
|
|
46
|
+
ActionTaskNotFoundError,
|
|
47
|
+
ActionTaskNotOwnedError,
|
|
48
|
+
ActionTaskTimeoutError,
|
|
49
|
+
AgentsNotEnabledError,
|
|
50
|
+
AiBudgetExhaustedError,
|
|
51
|
+
AiError,
|
|
52
|
+
AiPolicyDeniedError,
|
|
53
|
+
AiProviderUnavailableError,
|
|
54
|
+
AiRequestInvalidError,
|
|
55
|
+
RuntimeApiError,
|
|
56
|
+
RuntimeClient,
|
|
57
|
+
StorageBucketsUnsupportedError,
|
|
58
|
+
runtime_base_url,
|
|
59
|
+
)
|
|
60
|
+
from velophex.runtime.models import (
|
|
61
|
+
ActionAppSchema,
|
|
62
|
+
ActionTaskCompleter,
|
|
63
|
+
ActionTaskResult,
|
|
64
|
+
AddQueueItemRequest,
|
|
65
|
+
CancellationState,
|
|
66
|
+
QueueItem,
|
|
67
|
+
ResolvedSecret,
|
|
68
|
+
RuntimeActionTask,
|
|
69
|
+
RuntimeAsset,
|
|
70
|
+
RuntimeContext,
|
|
71
|
+
RuntimeCredential,
|
|
72
|
+
RuntimeJob,
|
|
73
|
+
RuntimeQueue,
|
|
74
|
+
StartedJob,
|
|
75
|
+
StorageFile,
|
|
76
|
+
StorageFileList,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"ActionAppNotFoundError",
|
|
81
|
+
"ActionAppSchema",
|
|
82
|
+
"ActionHubDisabledError",
|
|
83
|
+
"ActionHubError",
|
|
84
|
+
"ActionHubRuntimeUnsupportedError",
|
|
85
|
+
"ActionTaskCompleter",
|
|
86
|
+
"ActionTaskCorrelationConflictError",
|
|
87
|
+
"ActionTaskInputInvalidError",
|
|
88
|
+
"ActionTaskNotFoundError",
|
|
89
|
+
"ActionTaskNotOwnedError",
|
|
90
|
+
"ActionTaskResult",
|
|
91
|
+
"ActionTaskTimeoutError",
|
|
92
|
+
"AddQueueItemRequest",
|
|
93
|
+
"AgentsNotEnabledError",
|
|
94
|
+
"AiBudgetExhaustedError",
|
|
95
|
+
"AiError",
|
|
96
|
+
"AiPolicyDeniedError",
|
|
97
|
+
"AiProviderUnavailableError",
|
|
98
|
+
"AiRequestInvalidError",
|
|
99
|
+
"CancellationState",
|
|
100
|
+
"QueueItem",
|
|
101
|
+
"ResolvedSecret",
|
|
102
|
+
"RunSession",
|
|
103
|
+
"RuntimeActionTask",
|
|
104
|
+
"RuntimeApiError",
|
|
105
|
+
"RuntimeAsset",
|
|
106
|
+
"RuntimeClient",
|
|
107
|
+
"RuntimeContext",
|
|
108
|
+
"RuntimeCredential",
|
|
109
|
+
"RuntimeJob",
|
|
110
|
+
"RuntimeQueue",
|
|
111
|
+
"StartedJob",
|
|
112
|
+
"StorageBucketsUnsupportedError",
|
|
113
|
+
"StorageFile",
|
|
114
|
+
"StorageFileList",
|
|
115
|
+
"add_queue_item",
|
|
116
|
+
"asset",
|
|
117
|
+
"claim_queue_item",
|
|
118
|
+
"clear_session",
|
|
119
|
+
"client",
|
|
120
|
+
"complete_queue_item",
|
|
121
|
+
"context",
|
|
122
|
+
"create_action_task",
|
|
123
|
+
"credential",
|
|
124
|
+
"current_session",
|
|
125
|
+
"delete_file",
|
|
126
|
+
"download_file",
|
|
127
|
+
"fail_queue_item",
|
|
128
|
+
"get_action_task",
|
|
129
|
+
"invoke_automation",
|
|
130
|
+
"list_files",
|
|
131
|
+
"list_jobs",
|
|
132
|
+
"queue",
|
|
133
|
+
"raise_alert",
|
|
134
|
+
"read_text",
|
|
135
|
+
"runtime_base_url",
|
|
136
|
+
"secret",
|
|
137
|
+
"set_asset",
|
|
138
|
+
"set_session",
|
|
139
|
+
"should_stop",
|
|
140
|
+
"start_job",
|
|
141
|
+
"stop_job",
|
|
142
|
+
"stop_reason",
|
|
143
|
+
"stop_requested",
|
|
144
|
+
"upload_file",
|
|
145
|
+
"wait_for_action_task",
|
|
146
|
+
"write_text",
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def context() -> ExecutionContextInfo | None:
|
|
151
|
+
"""The ``start`` context of this run, without the execution token, or ``None`` outside a run."""
|
|
152
|
+
session = current_session()
|
|
153
|
+
return session.context.redacted() if session is not None else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def stop_requested() -> bool:
|
|
157
|
+
"""True once the parent asked this run to end.
|
|
158
|
+
|
|
159
|
+
A long-running automation is expected to poll this between units of work: the platform
|
|
160
|
+
never interrupts Python code on its own, it asks, waits out the grace period and then
|
|
161
|
+
kills the process tree.
|
|
162
|
+
"""
|
|
163
|
+
session = current_session()
|
|
164
|
+
return session.stop_requested if session is not None else False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def should_stop() -> bool:
|
|
168
|
+
"""True once the parent asked this run to finish its current work and end.
|
|
169
|
+
|
|
170
|
+
An operator's Stop arrives first as ``request-stop``, which only raises this flag, and only
|
|
171
|
+
later, if the run has not ended, as ``stop``; this is true after either. Unlike
|
|
172
|
+
:func:`stop_requested`, a run that ends normally after a ``request-stop`` still succeeds.
|
|
173
|
+
The Python twin of the workflow Should Stop activity (engine ADR 0037 section 6).
|
|
174
|
+
"""
|
|
175
|
+
session = current_session()
|
|
176
|
+
return session.should_stop if session is not None else False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def stop_reason() -> str | None:
|
|
180
|
+
"""``User`` or ``Timeout`` once a stop was asked for, else ``None``."""
|
|
181
|
+
session = current_session()
|
|
182
|
+
return session.stop_reason if session is not None else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def client() -> RuntimeClient:
|
|
186
|
+
"""The Runtime API client for this attempt."""
|
|
187
|
+
session = current_session()
|
|
188
|
+
|
|
189
|
+
if session is None:
|
|
190
|
+
raise VelophexError("There is no VeloPhex run in this process; the Runtime API is only available to a handler.")
|
|
191
|
+
|
|
192
|
+
return session.client()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def invoke_automation(
|
|
196
|
+
package_id: str,
|
|
197
|
+
inputs: dict[str, Any] | None = None,
|
|
198
|
+
*,
|
|
199
|
+
entry_point: str | None = None,
|
|
200
|
+
timeout_seconds: int | None = None,
|
|
201
|
+
) -> SubResultMessage:
|
|
202
|
+
"""Run another published automation as a step of this job and return its outcome.
|
|
203
|
+
|
|
204
|
+
The Robot runs it on this machine under this job's lease, token and deadline (a hybrid step,
|
|
205
|
+
ADR-P007): it takes no robot slot of its own, so it cannot wait behind the job that asked
|
|
206
|
+
for it. The package must be pinned for this job by the Orchestrator. The result carries
|
|
207
|
+
``outcome`` (``Succeeded``, ``Failed`` or ``Canceled``), ``outputs`` and the step's ``fault``.
|
|
208
|
+
"""
|
|
209
|
+
session = current_session()
|
|
210
|
+
|
|
211
|
+
if session is None:
|
|
212
|
+
raise VelophexError("There is no VeloPhex run in this process; steps are only available to a handler.")
|
|
213
|
+
|
|
214
|
+
return session.invoke(package_id, inputs, entry_point, timeout_seconds)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def asset(name: str) -> RuntimeAsset:
|
|
218
|
+
"""Read an asset of the job's workspace."""
|
|
219
|
+
return client().asset(name)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def set_asset(name: str, **value: Any) -> None:
|
|
223
|
+
"""Set an asset the job's workspace owns, e.g. ``set_asset("LastRun", text="ok")``."""
|
|
224
|
+
client().set_asset(name, **value)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def secret(name: str) -> str:
|
|
228
|
+
"""Resolve a secret asset, or ``"<credential>.password"`` for a credential's password.
|
|
229
|
+
|
|
230
|
+
Returns the plain text, because that is what a caller passes on to the system it logs in
|
|
231
|
+
to. :meth:`RuntimeClient.resolve_secret` returns the :class:`ResolvedSecret` instead, whose
|
|
232
|
+
``value`` is a ``SecretStr`` that prints as ``**********``.
|
|
233
|
+
"""
|
|
234
|
+
return client().resolve_secret(name).get_secret_value()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def credential(name: str) -> RuntimeCredential:
|
|
238
|
+
"""The account name of a credential asset; the password comes from :func:`secret`."""
|
|
239
|
+
return client().credential(name)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def queue(name: str) -> RuntimeQueue:
|
|
243
|
+
"""Queue metadata by name."""
|
|
244
|
+
return client().queue(name)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def add_queue_item(
|
|
248
|
+
queue_name: str,
|
|
249
|
+
payload: Any | None = None,
|
|
250
|
+
*,
|
|
251
|
+
reference: str | None = None,
|
|
252
|
+
priority: int | None = None,
|
|
253
|
+
deadline: datetime | None = None,
|
|
254
|
+
defer_until: datetime | None = None,
|
|
255
|
+
) -> QueueItem:
|
|
256
|
+
"""Add an item to a queue of the job's workspace."""
|
|
257
|
+
request = AddQueueItemRequest(
|
|
258
|
+
reference=reference,
|
|
259
|
+
priority=priority,
|
|
260
|
+
payload=payload,
|
|
261
|
+
deadline=deadline,
|
|
262
|
+
deferUntil=defer_until,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return client().add_queue_item(queue_name, request)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def claim_queue_item(queue_name: str) -> QueueItem | None:
|
|
269
|
+
"""Claim the next item of a queue, or ``None`` when nothing is due."""
|
|
270
|
+
return client().claim_queue_item(queue_name)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def complete_queue_item(item_id: UUID | str, output: Any | None = None) -> QueueItem:
|
|
274
|
+
"""Mark a claimed item successful."""
|
|
275
|
+
return client().complete_queue_item(item_id, output)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def fail_queue_item(
|
|
279
|
+
item_id: UUID | str,
|
|
280
|
+
*,
|
|
281
|
+
kind: str,
|
|
282
|
+
reason: str | None = None,
|
|
283
|
+
output: Any | None = None,
|
|
284
|
+
) -> QueueItem:
|
|
285
|
+
"""Mark a claimed item failed: ``Business`` is final, ``Application`` is retried."""
|
|
286
|
+
return client().fail_queue_item(item_id, kind=kind, reason=reason, output=output)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def start_job(
|
|
290
|
+
*,
|
|
291
|
+
automation_id: UUID | str | None = None,
|
|
292
|
+
automation_name: str | None = None,
|
|
293
|
+
inputs: Any | None = None,
|
|
294
|
+
priority: int | None = None,
|
|
295
|
+
) -> StartedJob:
|
|
296
|
+
"""Start a child automation in this job's workspace."""
|
|
297
|
+
return client().start_job(
|
|
298
|
+
automation_id=automation_id,
|
|
299
|
+
automation_name=automation_name,
|
|
300
|
+
inputs=inputs,
|
|
301
|
+
priority=priority,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def list_jobs(
|
|
306
|
+
*,
|
|
307
|
+
automation_name: str | None = None,
|
|
308
|
+
statuses: list[str] | None = None,
|
|
309
|
+
top: int = 50,
|
|
310
|
+
) -> list[RuntimeJob]:
|
|
311
|
+
"""The newest jobs of this job's workspace (at most 100)."""
|
|
312
|
+
return client().list_jobs(automation_name=automation_name, statuses=statuses, top=top)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def stop_job(job_id: UUID | str, *, kill: bool = False) -> None:
|
|
316
|
+
"""Ask a job of this workspace to stop, or with ``kill`` end it at once."""
|
|
317
|
+
client().stop_job(job_id, kill=kill)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def raise_alert(message: str, *, severity: str = "Info") -> None:
|
|
321
|
+
"""Raise an alert (``Info``, ``Warning`` or ``Error``) tied to this job."""
|
|
322
|
+
client().raise_alert(message, severity=severity)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
#: Calls per call site in this process, so a loop raises one task per iteration while a retried
|
|
326
|
+
#: attempt, which runs the same code again from the start, replays the same keys.
|
|
327
|
+
_call_sites: Counter[str] = Counter()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def create_action_task(
|
|
331
|
+
app_key: str,
|
|
332
|
+
version: int,
|
|
333
|
+
input: dict[str, Any] | None = None,
|
|
334
|
+
*,
|
|
335
|
+
correlation_key: str | None = None,
|
|
336
|
+
title: str | None = None,
|
|
337
|
+
assignee_user_id: UUID | str | None = None,
|
|
338
|
+
assignee_group: str | None = None,
|
|
339
|
+
due_at: datetime | None = None,
|
|
340
|
+
priority: str | None = None,
|
|
341
|
+
) -> UUID:
|
|
342
|
+
"""Raise an Action Hub task from a published app version and return its id.
|
|
343
|
+
|
|
344
|
+
>>> task_id = runtime.create_action_task("approval", 1, {"title": "Laptop", "amount": 1200})
|
|
345
|
+
|
|
346
|
+
``correlation_key`` defaults to the call site and how many times it ran in this process, so a
|
|
347
|
+
retried attempt gets the same task back instead of raising a second one (see
|
|
348
|
+
``docs/action-hub.md``, "Correlation keys").
|
|
349
|
+
"""
|
|
350
|
+
if correlation_key is None:
|
|
351
|
+
caller = sys._getframe(1)
|
|
352
|
+
site = f"{caller.f_code.co_filename}:{caller.f_lineno}"
|
|
353
|
+
_call_sites[site] += 1
|
|
354
|
+
correlation_key = f"py:{site.rsplit('/', 1)[-1].rsplit(chr(92), 1)[-1]}#{_call_sites[site]}"
|
|
355
|
+
|
|
356
|
+
return client().create_action_task(
|
|
357
|
+
app_key,
|
|
358
|
+
version,
|
|
359
|
+
input,
|
|
360
|
+
correlation_key=correlation_key,
|
|
361
|
+
title=title,
|
|
362
|
+
assignee_user_id=assignee_user_id,
|
|
363
|
+
assignee_group=assignee_group,
|
|
364
|
+
due_at=due_at,
|
|
365
|
+
priority=priority,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def get_action_task(task_id: UUID | str) -> RuntimeActionTask:
|
|
370
|
+
"""Read an action task this job created: status, and outcome and output once completed."""
|
|
371
|
+
return client().get_action_task(task_id)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def wait_for_action_task(
|
|
375
|
+
task_id: UUID | str,
|
|
376
|
+
*,
|
|
377
|
+
timeout: float = 900.0,
|
|
378
|
+
raise_on_timeout: bool = False,
|
|
379
|
+
) -> ActionTaskResult:
|
|
380
|
+
"""Wait in the job for the task's decision; ``timedOut`` (task left open) after ``timeout`` seconds.
|
|
381
|
+
|
|
382
|
+
The default is 15 minutes, and the wait never runs past the job's deadline or a stop request.
|
|
383
|
+
This always waits in the job and holds the Robot: a Python job cannot be checkpointed, so it
|
|
384
|
+
cannot suspend the way the XAML Wait for Action Task does past 60 minutes. For waits of hours
|
|
385
|
+
or days give the app a continuation automation.
|
|
386
|
+
"""
|
|
387
|
+
session = current_session()
|
|
388
|
+
deadline = session.context.deadline if session is not None else None
|
|
389
|
+
|
|
390
|
+
return client().wait_for_action_task(
|
|
391
|
+
task_id,
|
|
392
|
+
timeout=timeout,
|
|
393
|
+
raise_on_timeout=raise_on_timeout,
|
|
394
|
+
deadline=deadline,
|
|
395
|
+
stop_requested=stop_requested,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------------------------
|
|
400
|
+
# Storage buckets: the Python twins of the Upload File, Download File, List Files, Delete File,
|
|
401
|
+
# Read Text and Write Text activities (velophex.orchestrator.storage.*). A bucket is named; it is
|
|
402
|
+
# the job's workspace's own or one shared into it, and "default" always exists. Paths use "/".
|
|
403
|
+
# ---------------------------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def upload_file(
|
|
407
|
+
bucket: str,
|
|
408
|
+
local_path: str | PathLike[str],
|
|
409
|
+
path: str | None = None,
|
|
410
|
+
*,
|
|
411
|
+
content_type: str | None = None,
|
|
412
|
+
) -> StorageFile:
|
|
413
|
+
"""Upload a local file to ``path`` in ``bucket`` (its own name at the root when ``path`` is ``None``).
|
|
414
|
+
|
|
415
|
+
An existing path gets a new version in an internal bucket; the bucket's size limit (at most
|
|
416
|
+
100 MiB) and quotas apply.
|
|
417
|
+
"""
|
|
418
|
+
source = Path(local_path)
|
|
419
|
+
|
|
420
|
+
with source.open("rb") as content:
|
|
421
|
+
return client().upload_file(bucket, path or source.name, content, content_type=content_type)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def download_file(bucket: str, path: str, local_path: str | PathLike[str]) -> Path:
|
|
425
|
+
"""Download ``path`` from ``bucket`` to ``local_path`` (folders are created); returns the local path.
|
|
426
|
+
|
|
427
|
+
A failed download leaves no partial file behind.
|
|
428
|
+
"""
|
|
429
|
+
target = Path(local_path)
|
|
430
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
431
|
+
partial = target.with_name(target.name + ".partial")
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
with partial.open("wb") as destination:
|
|
435
|
+
client().download_file(bucket, path, destination)
|
|
436
|
+
|
|
437
|
+
partial.replace(target)
|
|
438
|
+
finally:
|
|
439
|
+
partial.unlink(missing_ok=True)
|
|
440
|
+
|
|
441
|
+
return target
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def list_files(
|
|
445
|
+
bucket: str,
|
|
446
|
+
prefix: str | None = None,
|
|
447
|
+
*,
|
|
448
|
+
pattern: str | None = None,
|
|
449
|
+
recursive: bool = False,
|
|
450
|
+
) -> list[StorageFile]:
|
|
451
|
+
"""Every file under ``prefix`` (the root when ``None``), across all pages.
|
|
452
|
+
|
|
453
|
+
``pattern`` is ``*``/``?`` wildcards on the file name, several separated by ``;``
|
|
454
|
+
(``"*.pdf;*.xlsx"``). ``recursive`` includes every folder below ``prefix``.
|
|
455
|
+
"""
|
|
456
|
+
return list(client().iter_files(bucket, prefix, pattern=pattern, recursive=recursive))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def delete_file(bucket: str, path: str) -> None:
|
|
460
|
+
"""Delete ``path`` from ``bucket``; :class:`RuntimeApiError` ``storage_file_not_found`` when absent."""
|
|
461
|
+
client().delete_file(bucket, path)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def read_text(bucket: str, path: str, *, encoding: str = "utf-8") -> str:
|
|
465
|
+
"""The text of ``path`` in ``bucket``."""
|
|
466
|
+
return client().read_file(bucket, path).decode(encoding)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def write_text(bucket: str, path: str, text: str, *, encoding: str = "utf-8") -> StorageFile:
|
|
470
|
+
"""Store ``text`` at ``path`` in ``bucket`` as a ``text/plain`` file in ``encoding``."""
|
|
471
|
+
return client().upload_file(bucket, path, text.encode(encoding), content_type=f"text/plain; charset={encoding}")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""The ambient run an automation can ask about: stop requests, context and the API client.
|
|
2
|
+
|
|
3
|
+
The worker installs exactly one :class:`RunSession` for the life of the process and tears it
|
|
4
|
+
down afterwards, so :func:`velophex.runtime.stop_requested` and friends work without the
|
|
5
|
+
handler threading anything through its own call stack. A module-level object rather than a
|
|
6
|
+
:class:`contextvars.ContextVar` is deliberate: one process runs one automation (ADR-P005), and
|
|
7
|
+
a worker thread the handler starts must see the same stop flag as the handler itself.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from velophex.contracts.executor import ExecutionContextInfo, SubInvokeMessage, SubResultMessage
|
|
19
|
+
from velophex.entrypoints import EntryPointDefinition
|
|
20
|
+
from velophex.errors import VelophexError
|
|
21
|
+
from velophex.runtime.client import RuntimeClient
|
|
22
|
+
|
|
23
|
+
__all__ = ["RunSession", "clear_session", "current_session", "set_session"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RunSession:
|
|
28
|
+
"""One automation run, as the handler may observe it."""
|
|
29
|
+
|
|
30
|
+
context: ExecutionContextInfo
|
|
31
|
+
entry_point: EntryPointDefinition | None
|
|
32
|
+
package_root: str
|
|
33
|
+
_stop: threading.Event
|
|
34
|
+
_stop_reason: str | None = None
|
|
35
|
+
_client: RuntimeClient | None = None
|
|
36
|
+
_should_stop: bool = False
|
|
37
|
+
_send_sub_invoke: Callable[[SubInvokeMessage], None] | None = None
|
|
38
|
+
_pending: dict[str, list[Any]] = field(default_factory=dict)
|
|
39
|
+
_pending_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def create(
|
|
43
|
+
cls,
|
|
44
|
+
context: ExecutionContextInfo,
|
|
45
|
+
entry_point: EntryPointDefinition | None,
|
|
46
|
+
package_root: str,
|
|
47
|
+
) -> RunSession:
|
|
48
|
+
"""Start a session for one ``start`` message."""
|
|
49
|
+
return cls(context=context, entry_point=entry_point, package_root=package_root, _stop=threading.Event())
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def stop_requested(self) -> bool:
|
|
53
|
+
"""True once the parent asked the run to end."""
|
|
54
|
+
return self._stop.is_set()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def stop_reason(self) -> str | None:
|
|
58
|
+
"""``User`` or ``Timeout`` once a stop was asked for."""
|
|
59
|
+
return self._stop_reason
|
|
60
|
+
|
|
61
|
+
def request_stop(self, reason: str) -> None:
|
|
62
|
+
"""Record a stop request. Called by the worker when a ``stop`` frame arrives."""
|
|
63
|
+
self._stop_reason = reason
|
|
64
|
+
self._stop.set()
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def should_stop(self) -> bool:
|
|
68
|
+
"""True once the parent asked the run to finish its work and end, or to end now."""
|
|
69
|
+
return self._should_stop or self._stop.is_set()
|
|
70
|
+
|
|
71
|
+
def request_soft_stop(self) -> None:
|
|
72
|
+
"""Record a ``request-stop``: the automation should end on its own.
|
|
73
|
+
|
|
74
|
+
Nothing is canceled, and a run that then returns normally still succeeds (engine ADR
|
|
75
|
+
0037 section 6).
|
|
76
|
+
"""
|
|
77
|
+
self._should_stop = True
|
|
78
|
+
|
|
79
|
+
def wait_for_stop(self, timeout: float | None = None) -> bool:
|
|
80
|
+
"""Block until a stop is requested or *timeout* seconds elapse."""
|
|
81
|
+
return self._stop.wait(timeout)
|
|
82
|
+
|
|
83
|
+
def enable_sub_invoke(self, send: Callable[[SubInvokeMessage], None]) -> None:
|
|
84
|
+
"""Let the run ask the parent for steps; the worker calls it when the parent serves them."""
|
|
85
|
+
self._send_sub_invoke = send
|
|
86
|
+
|
|
87
|
+
def invoke(
|
|
88
|
+
self,
|
|
89
|
+
package_id: str,
|
|
90
|
+
inputs: Any | None,
|
|
91
|
+
entry_point_id: str | None,
|
|
92
|
+
timeout_seconds: int | None,
|
|
93
|
+
) -> SubResultMessage:
|
|
94
|
+
"""Run a published automation as a step of this job and block until its ``sub-result``.
|
|
95
|
+
|
|
96
|
+
The parent (the Robot) runs it on this machine, under this job's lease, token and
|
|
97
|
+
deadline, without a slot of its own. It always answers, also when the job is stopped
|
|
98
|
+
(``Canceled``), so the wait is not bounded here.
|
|
99
|
+
"""
|
|
100
|
+
if self._send_sub_invoke is None:
|
|
101
|
+
raise VelophexError(
|
|
102
|
+
"This run cannot invoke another automation: the parent does not serve sub-invoke "
|
|
103
|
+
"(a local velophex run, or a Robot that predates it)."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
request_id = uuid.uuid4().hex
|
|
107
|
+
slot: list[Any] = [threading.Event(), None]
|
|
108
|
+
|
|
109
|
+
with self._pending_lock:
|
|
110
|
+
self._pending[request_id] = slot
|
|
111
|
+
|
|
112
|
+
self._send_sub_invoke(SubInvokeMessage(request_id, package_id, entry_point_id, inputs, timeout_seconds))
|
|
113
|
+
slot[0].wait()
|
|
114
|
+
|
|
115
|
+
with self._pending_lock:
|
|
116
|
+
self._pending.pop(request_id, None)
|
|
117
|
+
|
|
118
|
+
if isinstance(slot[1], BaseException):
|
|
119
|
+
raise slot[1]
|
|
120
|
+
|
|
121
|
+
return slot[1]
|
|
122
|
+
|
|
123
|
+
def deliver(self, result: SubResultMessage) -> bool:
|
|
124
|
+
"""Hand a ``sub-result`` to the call waiting for it; ``False`` for an id nobody is waiting for."""
|
|
125
|
+
with self._pending_lock:
|
|
126
|
+
slot = self._pending.get(result.request_id)
|
|
127
|
+
|
|
128
|
+
if slot is None or slot[0].is_set():
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
slot[1] = result
|
|
132
|
+
slot[0].set()
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def fail_pending(self, reason: str) -> None:
|
|
136
|
+
"""End every waiting step call with an error: the parent is gone or broke the protocol."""
|
|
137
|
+
with self._pending_lock:
|
|
138
|
+
for slot in self._pending.values():
|
|
139
|
+
if not slot[0].is_set():
|
|
140
|
+
slot[1] = VelophexError(reason)
|
|
141
|
+
slot[0].set()
|
|
142
|
+
|
|
143
|
+
def client(self) -> RuntimeClient:
|
|
144
|
+
"""The Runtime API client for this attempt, created on first use.
|
|
145
|
+
|
|
146
|
+
Raises :class:`VelophexError` when the run has no Orchestrator behind it, which is
|
|
147
|
+
the case for ``velophex run`` and for Studio's local Run.
|
|
148
|
+
"""
|
|
149
|
+
if self._client is None:
|
|
150
|
+
if not self.context.orchestrator_url or not self.context.runtime_token:
|
|
151
|
+
raise VelophexError(
|
|
152
|
+
"This run has no Orchestrator: start context carries no orchestratorUrl or runtimeToken. "
|
|
153
|
+
"Assets, queues and child jobs are only available for jobs dispatched by the Orchestrator."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
self._client = RuntimeClient(
|
|
157
|
+
self.context.orchestrator_url,
|
|
158
|
+
self.context.runtime_token,
|
|
159
|
+
trace_parent=self.context.trace_parent,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return self._client
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
"""Release the Runtime API connection pool."""
|
|
166
|
+
if self._client is not None:
|
|
167
|
+
self._client.close()
|
|
168
|
+
self._client = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_current: RunSession | None = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def set_session(session: RunSession) -> None:
|
|
175
|
+
"""Install the ambient session. The worker calls this once per ``start``."""
|
|
176
|
+
global _current
|
|
177
|
+
_current = session
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def clear_session() -> None:
|
|
181
|
+
"""Remove the ambient session and close its client."""
|
|
182
|
+
global _current
|
|
183
|
+
|
|
184
|
+
if _current is not None:
|
|
185
|
+
_current.close()
|
|
186
|
+
|
|
187
|
+
_current = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def current_session() -> RunSession | None:
|
|
191
|
+
"""The ambient session, or ``None`` outside a run."""
|
|
192
|
+
return _current
|