tilebox-workflows 0.43.0__py3-none-any.whl → 0.45.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.
- tilebox/workflows/__init__.py +2 -1
- tilebox/workflows/automations/client.py +3 -3
- tilebox/workflows/data.py +200 -46
- tilebox/workflows/formatting/__init__.py +0 -0
- tilebox/workflows/formatting/job.py +402 -0
- tilebox/workflows/jobs/client.py +41 -15
- tilebox/workflows/jobs/service.py +15 -6
- tilebox/workflows/runner/task_runner.py +54 -23
- tilebox/workflows/runner/task_service.py +4 -2
- tilebox/workflows/task.py +95 -14
- tilebox/workflows/workflows/v1/automation_pb2.py +22 -22
- tilebox/workflows/workflows/v1/automation_pb2.pyi +2 -2
- tilebox/workflows/workflows/v1/core_pb2.py +54 -30
- tilebox/workflows/workflows/v1/core_pb2.pyi +89 -16
- tilebox/workflows/workflows/v1/job_pb2.py +36 -38
- tilebox/workflows/workflows/v1/job_pb2.pyi +17 -17
- tilebox/workflows/workflows/v1/job_pb2_grpc.py +43 -0
- tilebox/workflows/workflows/v1/task_pb2.py +16 -16
- tilebox/workflows/workflows/v1/task_pb2.pyi +8 -6
- {tilebox_workflows-0.43.0.dist-info → tilebox_workflows-0.45.0.dist-info}/METADATA +3 -1
- {tilebox_workflows-0.43.0.dist-info → tilebox_workflows-0.45.0.dist-info}/RECORD +22 -20
- {tilebox_workflows-0.43.0.dist-info → tilebox_workflows-0.45.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""HTML formatting and ipywidgets for interactive display of Tilebox Workflow jobs."""
|
|
2
|
+
|
|
3
|
+
# some CSS helpers for our Jupyter HTML snippets - inspired by xarray's interactive display
|
|
4
|
+
import random
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from threading import Event, Thread
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from dateutil.tz import tzlocal
|
|
13
|
+
from ipywidgets import HTML, HBox, IntProgress, VBox
|
|
14
|
+
|
|
15
|
+
from tilebox.workflows.data import Job, JobState
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JobWidget:
|
|
19
|
+
def __init__(self, refresh_callback: Callable[[UUID], Job] | None = None) -> None:
|
|
20
|
+
self.job: Job | None = None
|
|
21
|
+
self.refresh_callback = refresh_callback
|
|
22
|
+
self.layout: VBox | None = None
|
|
23
|
+
self.widgets = []
|
|
24
|
+
self.refresh_thread: Thread | None = None
|
|
25
|
+
self._stop_refresh = Event()
|
|
26
|
+
|
|
27
|
+
def __del__(self) -> None:
|
|
28
|
+
self.stop()
|
|
29
|
+
|
|
30
|
+
def _repr_mimebundle_(self, *args: Any, **kwargs: Any) -> dict[str, str] | None:
|
|
31
|
+
if self.job is None: # no job to display
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
if self.layout is None: # initialize the widget the first time we want to interactively display it
|
|
35
|
+
self.widgets.append(HTML(_render_job_details_html(self.job)))
|
|
36
|
+
self.widgets.append(HTML(_render_job_progress(self.job, False)))
|
|
37
|
+
self.widgets.extend(
|
|
38
|
+
_progress_indicator_bar(progress.label or self.job.name, progress.done, progress.total, self.job.state)
|
|
39
|
+
for progress in self.job.progress
|
|
40
|
+
)
|
|
41
|
+
self.layout = VBox(self.widgets)
|
|
42
|
+
self.refresh_thread = Thread(target=self._refresh_worker)
|
|
43
|
+
self.refresh_thread.start()
|
|
44
|
+
|
|
45
|
+
return self.layout._repr_mimebundle_(*args, **kwargs) if self.layout is not None else None
|
|
46
|
+
|
|
47
|
+
def stop(self) -> None:
|
|
48
|
+
self._stop_refresh.set()
|
|
49
|
+
|
|
50
|
+
def _refresh_worker(self) -> None:
|
|
51
|
+
"""Refresh the job's progress display, intended to be run in a background thread."""
|
|
52
|
+
|
|
53
|
+
if self.job is None or self.refresh_callback is None or self.layout is None:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
last_progress: Job | None = None
|
|
57
|
+
|
|
58
|
+
while True:
|
|
59
|
+
progress = self.refresh_callback(self.job.id)
|
|
60
|
+
updated = False
|
|
61
|
+
if last_progress is None: # first time, don't add the refresh time
|
|
62
|
+
self.widgets[1] = HTML(_render_job_progress(progress, False))
|
|
63
|
+
updated = True
|
|
64
|
+
elif (
|
|
65
|
+
progress.state != last_progress.state
|
|
66
|
+
or progress.execution_stats.first_task_started_at != last_progress.execution_stats.first_task_started_at
|
|
67
|
+
):
|
|
68
|
+
self.widgets[1] = HTML(_render_job_progress(progress, True))
|
|
69
|
+
updated = True
|
|
70
|
+
|
|
71
|
+
if last_progress is None or progress.progress != last_progress.progress:
|
|
72
|
+
self.widgets[2:] = [
|
|
73
|
+
_progress_indicator_bar(
|
|
74
|
+
progress.label or self.job.name, progress.done, progress.total, self.job.state
|
|
75
|
+
)
|
|
76
|
+
for progress in progress.progress
|
|
77
|
+
]
|
|
78
|
+
updated = True
|
|
79
|
+
|
|
80
|
+
if updated:
|
|
81
|
+
self.layout.children = self.widgets # trigger a rerender of the ipywidgets
|
|
82
|
+
|
|
83
|
+
last_progress = progress
|
|
84
|
+
|
|
85
|
+
if last_progress.state == JobState.COMPLETED:
|
|
86
|
+
self.stop()
|
|
87
|
+
return # no more refreshing needed
|
|
88
|
+
|
|
89
|
+
refresh_wait = 5 + random.uniform(0, 2) # noqa: S311 # wait between 5 and 7 seconds to refresh progress
|
|
90
|
+
if self._stop_refresh.wait(refresh_wait): # check if the event to stop is set, if so exit the thread
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(order=True, frozen=True)
|
|
95
|
+
class RichDisplayJob(Job):
|
|
96
|
+
_widget: JobWidget = field(compare=False, repr=False)
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
return super().__repr__().replace(RichDisplayJob.__name__, Job.__name__)
|
|
100
|
+
|
|
101
|
+
def _repr_mimebundle_(self, *args: Any, **kwargs: Any) -> dict[str, str] | None:
|
|
102
|
+
"""Called by the IPython MimeBundleFormatter for interactive display of ipywidgets.
|
|
103
|
+
|
|
104
|
+
By overriding this and forwarding to an ipywidget, we can utilize the interactive display capabilities of
|
|
105
|
+
the widget, without having to inherit from a widget as base class of the Job.
|
|
106
|
+
"""
|
|
107
|
+
self._widget.job = self # initialize the widget the first time we want to interactively display it
|
|
108
|
+
return self._widget._repr_mimebundle_(*args, **kwargs)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_CSS = """
|
|
112
|
+
:root {
|
|
113
|
+
--tbx-font-color0: var(
|
|
114
|
+
--jp-content-font-color0,
|
|
115
|
+
var(--pst-color-text-base rgba(0, 0, 0, 1))
|
|
116
|
+
);
|
|
117
|
+
--tbx-font-color2: var(
|
|
118
|
+
--jp-content-font-color2,
|
|
119
|
+
var(--pst-color-text-base, rgba(0, 0, 0, 0.54))
|
|
120
|
+
);
|
|
121
|
+
--tbx-border-color: var(
|
|
122
|
+
--jp-border-color2,
|
|
123
|
+
hsl(from var(--pst-color-on-background, white) h s calc(l - 10))
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
html[theme="dark"],
|
|
128
|
+
html[data-theme="dark"],
|
|
129
|
+
body[data-theme="dark"],
|
|
130
|
+
body.vscode-dark {
|
|
131
|
+
--tbx-font-color0: var(
|
|
132
|
+
--jp-content-font-color0,
|
|
133
|
+
var(--pst-color-text-base, rgba(255, 255, 255, 1))
|
|
134
|
+
);
|
|
135
|
+
--tbx-font-color2: var(
|
|
136
|
+
--jp-content-font-color2,
|
|
137
|
+
var(--pst-color-text-base, rgba(255, 255, 255, 0.54))
|
|
138
|
+
);
|
|
139
|
+
--tbx-border-color: var(
|
|
140
|
+
--jp-border-color2,
|
|
141
|
+
hsl(from var(--pst-color-on-background, #111111) h s calc(l + 10))
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.tbx-wrap {
|
|
146
|
+
display: block !important;
|
|
147
|
+
min-width: 300px;
|
|
148
|
+
max-width: 540px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.tbx-text-repr-fallback {
|
|
152
|
+
/* fallback to plain text repr when CSS is not injected (untrusted notebook) */
|
|
153
|
+
display: none;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.tbx-header {
|
|
157
|
+
display: flex;
|
|
158
|
+
flex-direction: row;
|
|
159
|
+
justify-content: space-between;
|
|
160
|
+
align-items: center;
|
|
161
|
+
padding-bottom: 2px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.tbx-header > .tbx-obj-type {
|
|
165
|
+
padding: 3px 5px;
|
|
166
|
+
line-height: 1;
|
|
167
|
+
border-bottom: solid 1px var(--tbx-border-color);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
.tbx-obj-type {
|
|
172
|
+
display: flex;
|
|
173
|
+
flex-direction: row;
|
|
174
|
+
align-items: center;
|
|
175
|
+
gap: 4px;
|
|
176
|
+
color: var(--tbx-font-color2);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.tbx-detail-key {
|
|
180
|
+
color: var(--tbx-font-color2);
|
|
181
|
+
}
|
|
182
|
+
.tbx-detail-mono {
|
|
183
|
+
font-family: monospace;
|
|
184
|
+
}
|
|
185
|
+
.tbx-detail-value {
|
|
186
|
+
color: var(--tbx-font-color0);
|
|
187
|
+
}
|
|
188
|
+
.tbx-detail-value-muted {
|
|
189
|
+
color: var(--tbx-font-color2);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.tbx-job-state {
|
|
193
|
+
border-radius: 10px;
|
|
194
|
+
padding: 2px 10px;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.tbx-job-state-submitted {
|
|
198
|
+
background-color: #f1f5f9;
|
|
199
|
+
color: #0f172a;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.tbx-job-state-running {
|
|
203
|
+
background-color: #0066ff;
|
|
204
|
+
color: #f8fafc;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.tbx-job-state-started {
|
|
208
|
+
background-color: #fd9b11;
|
|
209
|
+
color: #f8fafc;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.tbx-job-state-completed {
|
|
213
|
+
background-color: #21c45d;
|
|
214
|
+
color: #f8fafc;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.tbx-job-state-failed {
|
|
218
|
+
background-color: #f43e5d;
|
|
219
|
+
color: #f8fafc;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.tbx-job-state-canceled {
|
|
223
|
+
background-color: #94a2b3;
|
|
224
|
+
color: #f8fafc;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.tbx-job-progress a {
|
|
228
|
+
text-decoration: underline;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.tbx-detail-button a{
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
flex-direction: row;
|
|
235
|
+
gap: 8px;
|
|
236
|
+
font-size: 13px;
|
|
237
|
+
background-color: #f43f5e;
|
|
238
|
+
padding: 2px 10px;
|
|
239
|
+
color: white;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.tbx-detail-button a svg {
|
|
243
|
+
fill: white;
|
|
244
|
+
stroke: white;
|
|
245
|
+
stroke-width: 2px;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.tbx-detail-button a span {
|
|
249
|
+
display: inline;
|
|
250
|
+
vertical-align: middle;
|
|
251
|
+
margin: 0;
|
|
252
|
+
padding: 0;
|
|
253
|
+
line-height: 1.8;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.tbx-detail-button a:hover {
|
|
257
|
+
background-color: #cc4e63;
|
|
258
|
+
color: white;
|
|
259
|
+
}
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _render_job_details_html(job: Job) -> str:
|
|
264
|
+
"""Render a job as HTML."""
|
|
265
|
+
return f"""
|
|
266
|
+
<style>
|
|
267
|
+
{_CSS}
|
|
268
|
+
</style>
|
|
269
|
+
<div class="tbx-text-repr-fallback"></div>
|
|
270
|
+
<div class="tbx-wrap">
|
|
271
|
+
<div class="tbx-header">
|
|
272
|
+
<div class="tbx-obj-type">tilebox.workflows.Job</div>
|
|
273
|
+
<div class="tbx-detail-button"><a href="https://console.tilebox.com/workflows/jobs/{job.id!s}" target="_blank">{_eye_icon} <span>Tilebox Console</span></a></div>
|
|
274
|
+
</div>
|
|
275
|
+
<div class="tbx-job-details">
|
|
276
|
+
<div><span class="tbx-detail-key tbx-detail-mono">id:</span> <span class="tbx-detail-value tbx-detail-mono">{job.id!s}</span><div>
|
|
277
|
+
<div><span class="tbx-detail-key tbx-detail-mono">name:</span> <span class="tbx-detail-value">{job.name}</span><div>
|
|
278
|
+
<div><span class="tbx-detail-key tbx-detail-mono">submitted_at:</span> {_render_datetime(job.submitted_at)}<div>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _render_datetime(dt: datetime) -> str:
|
|
285
|
+
local = dt.astimezone(tzlocal())
|
|
286
|
+
time_part = local.strftime("%Y-%m-%d %H:%M:%S")
|
|
287
|
+
tz_part = local.strftime("%z")
|
|
288
|
+
tz_part = "(UTC)" if tz_part == "+0000" else f"(UTC{tz_part})"
|
|
289
|
+
return f"<span class='tbx-detail-value'>{time_part}</span> <span class='tbx-detail-value-muted'>{tz_part}</span>"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _render_job_progress(job: Job, include_refresh_time: bool) -> str:
|
|
293
|
+
refresh = ""
|
|
294
|
+
if include_refresh_time:
|
|
295
|
+
current_time = datetime.now(tzlocal())
|
|
296
|
+
refresh = f" <span class='tbx-detail-value-muted'>(refreshed at {current_time.strftime('%H:%M:%S')})</span> {_info_icon}"
|
|
297
|
+
|
|
298
|
+
state_name = job.state.name
|
|
299
|
+
|
|
300
|
+
no_progress = ""
|
|
301
|
+
if not job.progress:
|
|
302
|
+
no_progress = "<span class='tbx-detail-value-muted'>No user defined progress indicators. <a href='https://docs.tilebox.com/workflows/progress' target='_blank'>Learn more</a></span>"
|
|
303
|
+
|
|
304
|
+
started_at = job.execution_stats.first_task_started_at
|
|
305
|
+
|
|
306
|
+
"""Render a job's progress as HTML, needs to be called after render_job_details_html since that injects the necessary CSS."""
|
|
307
|
+
return f"""
|
|
308
|
+
<div class="tbx-wrap">
|
|
309
|
+
<div class="tbx-obj-type">Progress{refresh}</div>
|
|
310
|
+
<div class="tbx-job-progress">
|
|
311
|
+
<div><span class="tbx-detail-key tbx-detail-mono">state:</span> <span class="tbx-job-state tbx-job-state-{state_name.lower()}">{state_name}</span><div>
|
|
312
|
+
<div><span class="tbx-detail-key tbx-detail-mono">started_at:</span> {_render_datetime(started_at) if started_at else "<span class='tbx-detail-value-muted tbx-detail-mono'>None</span>"}<div>
|
|
313
|
+
<div><span class="tbx-detail-key tbx-detail-mono">progress:</span> {no_progress}</div>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
""".strip()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
_BAR_COLORS = {
|
|
320
|
+
"running": "#0066ff",
|
|
321
|
+
"failed": "#f43e5d",
|
|
322
|
+
"completed": "#21c45d",
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _progress_indicator_bar(label: str, done: int, total: int, state: JobState) -> HBox:
|
|
327
|
+
percentage = done / total if total > 0 else 0 if done <= total else 1
|
|
328
|
+
non_completed_color = (
|
|
329
|
+
_BAR_COLORS["failed"] if state in (JobState.FAILED, JobState.CANCELED) else _BAR_COLORS["running"]
|
|
330
|
+
)
|
|
331
|
+
progress = IntProgress(
|
|
332
|
+
min=0,
|
|
333
|
+
max=total,
|
|
334
|
+
value=done,
|
|
335
|
+
description=label,
|
|
336
|
+
tooltip=label,
|
|
337
|
+
style={"bar_color": non_completed_color if percentage < 1 else _BAR_COLORS["completed"]},
|
|
338
|
+
layout={"width": "400px"},
|
|
339
|
+
)
|
|
340
|
+
label_html = (
|
|
341
|
+
f"<span class='tbx-detail-mono'><span class='tbx-detail-value'>{percentage:.0%}</span> "
|
|
342
|
+
f"<span class='tbx-detail-value-muted'>({done} / {total})</span></span>"
|
|
343
|
+
)
|
|
344
|
+
label = HTML(label_html)
|
|
345
|
+
return HBox([progress, label])
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
_eye_icon = """
|
|
349
|
+
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px"
|
|
350
|
+
height="16px" viewBox="0 0 72 72" enable-background="new 0 0 72 72" xml:space="preserve">
|
|
351
|
+
<g>
|
|
352
|
+
<g>
|
|
353
|
+
<path d="M36.001,63.75C24.233,63.75,2.5,54.883,2.5,42.25c0-12.634,21.733-21.5,33.501-21.5c11.766,0,33.5,8.866,33.5,21.5
|
|
354
|
+
C69.501,54.883,47.767,63.75,36.001,63.75z M36.001,24.75C24.886,24.75,6.5,32.929,6.5,42.25c0,9.32,18.387,17.5,29.501,17.5
|
|
355
|
+
c11.113,0,29.5-8.18,29.5-17.5C65.501,32.929,47.114,24.75,36.001,24.75z"/>
|
|
356
|
+
</g>
|
|
357
|
+
<g>
|
|
358
|
+
<path d="M36.001,52.917c-5.791,0-10.501-4.709-10.501-10.5c0-5.79,4.711-10.5,10.501-10.5c5.789,0,10.5,4.71,10.5,10.5
|
|
359
|
+
C46.501,48.208,41.79,52.917,36.001,52.917z M36.001,33.917c-4.688,0-8.501,3.814-8.501,8.5c0,4.688,3.813,8.5,8.501,8.5
|
|
360
|
+
c4.686,0,8.5-3.813,8.5-8.5C44.501,37.731,40.687,33.917,36.001,33.917z"/>
|
|
361
|
+
</g>
|
|
362
|
+
<g>
|
|
363
|
+
<path d="M32.073,39.809c-0.242,0-0.484-0.088-0.677-0.264c-0.406-0.375-0.433-1.008-0.059-1.414
|
|
364
|
+
c0.2-0.217,0.415-0.422,0.644-0.609c0.428-0.352,1.058-0.291,1.408,0.137c0.352,0.426,0.29,1.057-0.136,1.408
|
|
365
|
+
c-0.158,0.129-0.307,0.27-0.444,0.418C32.612,39.7,32.342,39.809,32.073,39.809z"/>
|
|
366
|
+
</g>
|
|
367
|
+
<g>
|
|
368
|
+
<path d="M36.001,48.75c-3.494,0-6.335-2.842-6.335-6.334c0-0.553,0.448-1,1-1c0.553,0,1,0.447,1,1
|
|
369
|
+
c0,2.391,1.945,4.334,4.335,4.334c0.553,0,1,0.447,1,1S36.554,48.75,36.001,48.75z"/>
|
|
370
|
+
</g>
|
|
371
|
+
<g>
|
|
372
|
+
<path d="M35.876,18.25c-1.105,0-2-0.896-2-2v-6c0-1.104,0.895-2,2-2c1.104,0,2,0.896,2,2v6
|
|
373
|
+
C37.876,17.354,36.979,18.25,35.876,18.25z"/>
|
|
374
|
+
</g>
|
|
375
|
+
<g>
|
|
376
|
+
<path d="M24.353,18.93c-0.732,0-1.437-0.402-1.788-1.101l-1.852-3.68c-0.497-0.987-0.1-2.189,0.888-2.686
|
|
377
|
+
c0.985-0.498,2.188-0.101,2.686,0.887l1.852,3.68c0.496,0.987,0.099,2.189-0.888,2.686C24.962,18.861,24.655,18.93,24.353,18.93z"
|
|
378
|
+
/>
|
|
379
|
+
</g>
|
|
380
|
+
<g>
|
|
381
|
+
<path d="M12.684,23.567c-0.548,0-1.094-0.224-1.488-0.663l-2.6-2.894c-0.738-0.822-0.671-2.087,0.151-2.824
|
|
382
|
+
c0.82-0.74,2.085-0.672,2.824,0.15l2.6,2.894c0.738,0.822,0.67,2.087-0.151,2.824C13.638,23.398,13.16,23.567,12.684,23.567z"/>
|
|
383
|
+
</g>
|
|
384
|
+
<g>
|
|
385
|
+
<path d="M46.581,18.93c-0.303,0-0.609-0.068-0.898-0.214c-0.986-0.496-1.383-1.698-0.887-2.686l1.852-3.68
|
|
386
|
+
c0.494-0.985,1.695-1.386,2.686-0.887c0.986,0.496,1.385,1.698,0.887,2.686l-1.852,3.68C48.019,18.527,47.313,18.93,46.581,18.93z
|
|
387
|
+
"/>
|
|
388
|
+
</g>
|
|
389
|
+
<g>
|
|
390
|
+
<path d="M58.249,23.567c-0.475,0-0.953-0.169-1.336-0.513c-0.82-0.737-0.889-2.002-0.15-2.824l2.6-2.894
|
|
391
|
+
c0.738-0.82,2.002-0.89,2.824-0.15c0.822,0.737,0.889,2.002,0.15,2.824l-2.6,2.894C59.343,23.344,58.798,23.567,58.249,23.567z"/>
|
|
392
|
+
</g>
|
|
393
|
+
</g>
|
|
394
|
+
</svg>
|
|
395
|
+
""".strip()
|
|
396
|
+
|
|
397
|
+
_info_icon = """
|
|
398
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="24px" height="24px">
|
|
399
|
+
<title>The progress information below is a live update and will refresh automatically. These live updates are not reflected in the underlying job object variables.</title>
|
|
400
|
+
<path d="M 32 10 C 19.85 10 10 19.85 10 32 C 10 44.15 19.85 54 32 54 C 44.15 54 54 44.15 54 32 C 54 19.85 44.15 10 32 10 z M 32 14 C 41.941 14 50 22.059 50 32 C 50 41.941 41.941 50 32 50 C 22.059 50 14 41.941 14 32 C 14 22.059 22.059 14 32 14 z M 32 21 C 30.343 21 29 22.343 29 24 C 29 25.657 30.343 27 32 27 C 33.657 27 35 25.657 35 24 C 35 22.343 33.657 21 32 21 z M 32 30 C 30.895 30 30 30.896 30 32 L 30 42 C 30 43.104 30.895 44 32 44 C 33.105 44 34 43.104 34 42 L 34 32 C 34 30.896 33.105 30 32 30 z"/>
|
|
401
|
+
</svg>
|
|
402
|
+
""".strip()
|
tilebox/workflows/jobs/client.py
CHANGED
|
@@ -9,12 +9,13 @@ from tilebox.datasets.query.time_interval import TimeInterval, TimeIntervalLike
|
|
|
9
9
|
from tilebox.workflows.clusters.client import ClusterSlugLike, to_cluster_slug
|
|
10
10
|
from tilebox.workflows.data import (
|
|
11
11
|
Job,
|
|
12
|
+
JobState,
|
|
12
13
|
QueryFilters,
|
|
13
14
|
QueryJobsResponse,
|
|
14
15
|
)
|
|
15
16
|
from tilebox.workflows.jobs.service import JobService
|
|
16
17
|
from tilebox.workflows.observability.tracing import WorkflowTracer, get_trace_parent_of_current_span
|
|
17
|
-
from tilebox.workflows.task import FutureTask
|
|
18
|
+
from tilebox.workflows.task import FutureTask, merge_future_tasks_to_submissions
|
|
18
19
|
from tilebox.workflows.task import Task as TaskInstance
|
|
19
20
|
|
|
20
21
|
try:
|
|
@@ -64,8 +65,9 @@ class JobClient:
|
|
|
64
65
|
"""
|
|
65
66
|
tasks = root_task_or_tasks if isinstance(root_task_or_tasks, list) else [root_task_or_tasks]
|
|
66
67
|
|
|
68
|
+
default_cluster = ""
|
|
67
69
|
if isinstance(cluster, ClusterSlugLike | None):
|
|
68
|
-
slugs = [to_cluster_slug(cluster or
|
|
70
|
+
slugs = [to_cluster_slug(cluster or default_cluster)] * len(tasks)
|
|
69
71
|
else:
|
|
70
72
|
slugs = [to_cluster_slug(c) for c in cluster]
|
|
71
73
|
|
|
@@ -75,13 +77,14 @@ class JobClient:
|
|
|
75
77
|
f"or exactly one cluster per task. But got {len(tasks)} tasks and {len(slugs)} clusters."
|
|
76
78
|
)
|
|
77
79
|
|
|
78
|
-
task_submissions = [
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
task_submissions = [FutureTask(i, task, [], slugs[i], max_retries) for i, task in enumerate(tasks)]
|
|
81
|
+
submissions_merged = merge_future_tasks_to_submissions(task_submissions, default_cluster)
|
|
82
|
+
if submissions_merged is None:
|
|
83
|
+
raise ValueError("At least one task must be submitted.")
|
|
81
84
|
|
|
82
85
|
with self._tracer.start_as_current_span(f"job/{job_name}"):
|
|
83
86
|
trace_parent = get_trace_parent_of_current_span()
|
|
84
|
-
return self._service.submit(job_name, trace_parent,
|
|
87
|
+
return self._service.submit(job_name, trace_parent, submissions_merged)
|
|
85
88
|
|
|
86
89
|
def retry(self, job_or_id: JobIDLike) -> int:
|
|
87
90
|
"""Retry a job.
|
|
@@ -125,19 +128,19 @@ class JobClient:
|
|
|
125
128
|
"""
|
|
126
129
|
return self._service.get_by_id(_to_uuid(job_id))
|
|
127
130
|
|
|
128
|
-
def display(self,
|
|
131
|
+
def display(self, job_id: JobIDLike, direction: str = "down", layout: str = "dagre", sketchy: bool = True) -> None:
|
|
129
132
|
"""Create a visualization of the job as a diagram and display it in an interactive environment.
|
|
130
133
|
|
|
131
134
|
Requires an IPython environment such as a Jupyter notebook.
|
|
132
135
|
|
|
133
136
|
Args:
|
|
134
|
-
|
|
137
|
+
job_id: The job or job id to visualize.
|
|
135
138
|
direction: The direction of the diagram. Defaults to "down". See https://d2lang.com/tour/layouts/#direction
|
|
136
139
|
layout: The layout to use for the diagram. Defaults to "dagre". Currently supported layouts are
|
|
137
140
|
"dagre" and "elk". See https://d2lang.com/tour/layouts/
|
|
138
141
|
sketchy: Whether to render the diagram in a sketchy hand drawn style. Defaults to True.
|
|
139
142
|
"""
|
|
140
|
-
display(HTML(self.visualize(
|
|
143
|
+
display(HTML(self.visualize(job_id, direction, layout, sketchy)))
|
|
141
144
|
|
|
142
145
|
def visualize(self, job: JobIDLike, direction: str = "down", layout: str = "dagre", sketchy: bool = True) -> str:
|
|
143
146
|
"""Create a visualization of the job as a diagram.
|
|
@@ -154,7 +157,13 @@ class JobClient:
|
|
|
154
157
|
"""
|
|
155
158
|
return self._service.visualize(_to_uuid(job), direction, layout, sketchy)
|
|
156
159
|
|
|
157
|
-
def query(
|
|
160
|
+
def query(
|
|
161
|
+
self,
|
|
162
|
+
temporal_extent: TimeIntervalLike | IDIntervalLike,
|
|
163
|
+
automation_ids: UUID | list[UUID] | None = None,
|
|
164
|
+
job_states: JobState | list[JobState] | None = None,
|
|
165
|
+
name: str | None = None,
|
|
166
|
+
) -> list[Job]:
|
|
158
167
|
"""List jobs in the given temporal extent.
|
|
159
168
|
|
|
160
169
|
Args:
|
|
@@ -170,11 +179,14 @@ class JobClient:
|
|
|
170
179
|
- tuple of two UUIDs: [start, end) -> Construct an IDInterval with the given start and end id
|
|
171
180
|
- tuple of two strings: [start, end) -> Construct an IDInterval with the given start and end id
|
|
172
181
|
parsed from the strings
|
|
173
|
-
|
|
174
|
-
are returned.
|
|
175
|
-
|
|
182
|
+
automation_ids: An automation id or list of automation ids to filter jobs by.
|
|
183
|
+
If specified, only jobs created by one of the selected automations are returned.
|
|
184
|
+
job_states: A job state or list of job states to filter jobs by. If specified, only jobs in one of the
|
|
185
|
+
selected states are returned.
|
|
186
|
+
name: A name to filter jobs by. If specified, only jobs with a matching name are returned. The match is
|
|
187
|
+
case-insensitive and uses a fuzzy matching scheme.
|
|
176
188
|
Returns:
|
|
177
|
-
A list of jobs.
|
|
189
|
+
A list of jobs matching the given filters.
|
|
178
190
|
"""
|
|
179
191
|
time_interval: TimeInterval | None = None
|
|
180
192
|
id_interval: IDInterval | None = None
|
|
@@ -202,7 +214,21 @@ class JobClient:
|
|
|
202
214
|
end_inclusive=dataset_time_interval.end_inclusive,
|
|
203
215
|
)
|
|
204
216
|
|
|
205
|
-
|
|
217
|
+
automation_ids = automation_ids or []
|
|
218
|
+
if not isinstance(automation_ids, list):
|
|
219
|
+
automation_ids = [automation_ids]
|
|
220
|
+
|
|
221
|
+
job_states = job_states or []
|
|
222
|
+
if not isinstance(job_states, list):
|
|
223
|
+
job_states = [job_states]
|
|
224
|
+
|
|
225
|
+
filters = QueryFilters(
|
|
226
|
+
time_interval=time_interval,
|
|
227
|
+
id_interval=id_interval,
|
|
228
|
+
automation_ids=automation_ids,
|
|
229
|
+
job_states=job_states,
|
|
230
|
+
name=name,
|
|
231
|
+
)
|
|
206
232
|
|
|
207
233
|
def request(page: PaginationProtocol) -> QueryJobsResponse:
|
|
208
234
|
query_page = Pagination(page.limit, page.starting_after)
|
|
@@ -8,13 +8,15 @@ from tilebox.workflows.data import (
|
|
|
8
8
|
Job,
|
|
9
9
|
QueryFilters,
|
|
10
10
|
QueryJobsResponse,
|
|
11
|
-
|
|
11
|
+
TaskSubmissions,
|
|
12
12
|
uuid_to_uuid_message,
|
|
13
13
|
)
|
|
14
|
+
from tilebox.workflows.formatting.job import JobWidget, RichDisplayJob
|
|
14
15
|
from tilebox.workflows.workflows.v1.core_pb2 import Job as JobMessage
|
|
15
16
|
from tilebox.workflows.workflows.v1.diagram_pb2 import Diagram, RenderOptions
|
|
16
17
|
from tilebox.workflows.workflows.v1.job_pb2 import (
|
|
17
18
|
CancelJobRequest,
|
|
19
|
+
GetJobProgressRequest,
|
|
18
20
|
GetJobRequest,
|
|
19
21
|
QueryJobsRequest,
|
|
20
22
|
RetryJobRequest,
|
|
@@ -37,18 +39,22 @@ class JobService:
|
|
|
37
39
|
"""
|
|
38
40
|
self.service = with_pythonic_errors(JobServiceStub(channel))
|
|
39
41
|
|
|
40
|
-
def submit(self, job_name: str, trace_parent: str, tasks:
|
|
42
|
+
def submit(self, job_name: str, trace_parent: str, tasks: TaskSubmissions) -> Job:
|
|
41
43
|
request = SubmitJobRequest(
|
|
42
|
-
tasks=
|
|
44
|
+
tasks=tasks.to_message(),
|
|
43
45
|
job_name=job_name,
|
|
44
46
|
trace_parent=trace_parent,
|
|
45
47
|
)
|
|
46
|
-
return
|
|
48
|
+
return RichDisplayJob.from_message(self.service.SubmitJob(request), _widget=JobWidget(self.get_progress))
|
|
47
49
|
|
|
48
50
|
def get_by_id(self, job_id: UUID) -> Job:
|
|
49
51
|
request = GetJobRequest(job_id=uuid_to_uuid_message(job_id))
|
|
50
52
|
response: JobMessage = self.service.GetJob(request)
|
|
51
|
-
return
|
|
53
|
+
return RichDisplayJob.from_message(response, _widget=JobWidget(self.get_progress))
|
|
54
|
+
|
|
55
|
+
def get_progress(self, job_id: UUID) -> Job:
|
|
56
|
+
request = GetJobProgressRequest(job_id=uuid_to_uuid_message(job_id))
|
|
57
|
+
return Job.from_message(self.service.GetJobProgress(request))
|
|
52
58
|
|
|
53
59
|
def retry(self, job_id: UUID) -> int:
|
|
54
60
|
request = RetryJobRequest(job_id=uuid_to_uuid_message(job_id))
|
|
@@ -73,4 +79,7 @@ class JobService:
|
|
|
73
79
|
page=page.to_message() if page is not None else None,
|
|
74
80
|
)
|
|
75
81
|
response: QueryJobsResponseMessage = self.service.QueryJobs(request)
|
|
76
|
-
|
|
82
|
+
|
|
83
|
+
return QueryJobsResponse.from_message(
|
|
84
|
+
response, job_factory=lambda job: RichDisplayJob.from_message(job, _widget=JobWidget(self.get_progress))
|
|
85
|
+
)
|