tilebox-workflows 0.43.0__tar.gz → 0.44.0__tar.gz

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.
Files changed (49) hide show
  1. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/PKG-INFO +3 -1
  2. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/pyproject.toml +2 -0
  3. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/__init__.py +2 -1
  4. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/data.py +25 -16
  5. tilebox_workflows-0.44.0/tilebox/workflows/formatting/job.py +389 -0
  6. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/jobs/client.py +3 -3
  7. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/jobs/service.py +12 -3
  8. tilebox_workflows-0.44.0/tilebox/workflows/runner/__init__.py +0 -0
  9. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/runner/task_runner.py +16 -14
  10. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/runner/task_service.py +4 -2
  11. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/task.py +4 -4
  12. tilebox_workflows-0.44.0/tilebox/workflows/workflows/v1/core_pb2.py +79 -0
  13. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/core_pb2.pyi +5 -5
  14. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/job_pb2.py +27 -29
  15. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/job_pb2.pyi +0 -6
  16. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/job_pb2_grpc.py +43 -0
  17. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/task_pb2.py +14 -14
  18. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/task_pb2.pyi +4 -4
  19. tilebox_workflows-0.43.0/tilebox/workflows/workflows/v1/core_pb2.py +0 -79
  20. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/.gitignore +0 -0
  21. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/README.md +0 -0
  22. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/automations/__init__.py +0 -0
  23. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/automations/client.py +0 -0
  24. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/automations/cron.py +0 -0
  25. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/automations/service.py +0 -0
  26. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/automations/storage_event.py +0 -0
  27. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/cache.py +0 -0
  28. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/client.py +0 -0
  29. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/clusters/__init__.py +0 -0
  30. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/clusters/client.py +0 -0
  31. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/clusters/service.py +0 -0
  32. {tilebox_workflows-0.43.0/tilebox/workflows/jobs → tilebox_workflows-0.44.0/tilebox/workflows/formatting}/__init__.py +0 -0
  33. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/interceptors.py +0 -0
  34. {tilebox_workflows-0.43.0/tilebox/workflows/observability → tilebox_workflows-0.44.0/tilebox/workflows/jobs}/__init__.py +0 -0
  35. {tilebox_workflows-0.43.0/tilebox/workflows/runner → tilebox_workflows-0.44.0/tilebox/workflows/observability}/__init__.py +0 -0
  36. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/observability/logging.py +0 -0
  37. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/observability/tracing.py +0 -0
  38. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/timeseries.py +0 -0
  39. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/automation_pb2.py +0 -0
  40. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/automation_pb2.pyi +0 -0
  41. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/automation_pb2_grpc.py +0 -0
  42. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/core_pb2_grpc.py +0 -0
  43. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/diagram_pb2.py +0 -0
  44. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/diagram_pb2.pyi +0 -0
  45. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/diagram_pb2_grpc.py +0 -0
  46. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/task_pb2_grpc.py +0 -0
  47. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/workflows_pb2.py +0 -0
  48. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/workflows_pb2.pyi +0 -0
  49. {tilebox_workflows-0.43.0 → tilebox_workflows-0.44.0}/tilebox/workflows/workflows/v1/workflows_pb2_grpc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilebox-workflows
3
- Version: 0.43.0
3
+ Version: 0.44.0
4
4
  Summary: Workflow client and task runner for Tilebox
5
5
  Project-URL: Homepage, https://tilebox.com
6
6
  Project-URL: Documentation, https://docs.tilebox.com/workflows/introduction
@@ -22,9 +22,11 @@ Requires-Python: >=3.10
22
22
  Requires-Dist: boto3-stubs[essential]>=1.33
23
23
  Requires-Dist: boto3>=1.33
24
24
  Requires-Dist: google-cloud-storage>=2.10
25
+ Requires-Dist: ipywidgets>=8.1.7
25
26
  Requires-Dist: opentelemetry-api>=1.28
26
27
  Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.28
27
28
  Requires-Dist: opentelemetry-sdk>=1.28
29
+ Requires-Dist: python-dateutil>=2.9.0.post0
28
30
  Requires-Dist: tenacity>=8
29
31
  Requires-Dist: tilebox-datasets
30
32
  Requires-Dist: tilebox-grpc>=0.28.0
@@ -30,6 +30,8 @@ dependencies = [
30
30
  "tenacity>=8",
31
31
  "boto3>=1.33",
32
32
  "boto3-stubs[essential]>=1.33",
33
+ "ipywidgets>=8.1.7",
34
+ "python-dateutil>=2.9.0.post0",
33
35
  ]
34
36
 
35
37
  [dependency-groups]
@@ -4,9 +4,10 @@ import sys
4
4
  from loguru import logger
5
5
 
6
6
  from tilebox.workflows.client import Client
7
+ from tilebox.workflows.data import Job
7
8
  from tilebox.workflows.task import ExecutionContext, Task
8
9
 
9
- __all__ = ["Client", "ExecutionContext", "Task"]
10
+ __all__ = ["Client", "ExecutionContext", "Job", "Task"]
10
11
 
11
12
 
12
13
  def _init_logging(level: str = "INFO") -> None:
@@ -1,10 +1,12 @@
1
1
  import re
2
2
  import warnings
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass, field
4
5
  from datetime import datetime, timedelta
5
6
  from enum import Enum
6
7
  from functools import lru_cache
7
8
  from pathlib import Path
9
+ from typing import Any
8
10
  from uuid import UUID
9
11
 
10
12
  import boto3
@@ -108,19 +110,19 @@ class TaskLease:
108
110
 
109
111
 
110
112
  @dataclass(order=True)
111
- class ProgressBar:
113
+ class ProgressIndicator:
112
114
  label: str | None
113
115
  total: int
114
116
  done: int
115
117
 
116
118
  @classmethod
117
- def from_message(cls, progress_bar: core_pb2.ProgressBar) -> "ProgressBar":
118
- """Convert a ProgressBar protobuf message to a ProgressBar object."""
119
- return cls(label=progress_bar.label or None, total=progress_bar.total, done=progress_bar.done)
119
+ def from_message(cls, progress_indicator: core_pb2.Progress) -> "ProgressIndicator":
120
+ """Convert a ProgressIndicator protobuf message to a ProgressIndicator object."""
121
+ return cls(label=progress_indicator.label or None, total=progress_indicator.total, done=progress_indicator.done)
120
122
 
121
- def to_message(self) -> core_pb2.ProgressBar:
122
- """Convert a ProgressBar object to a ProgressBar protobuf message."""
123
- return core_pb2.ProgressBar(label=self.label, total=self.total, done=self.done)
123
+ def to_message(self) -> core_pb2.Progress:
124
+ """Convert a ProgressIndicator object to a ProgressIndicator protobuf message."""
125
+ return core_pb2.Progress(label=self.label, total=self.total, done=self.done)
124
126
 
125
127
 
126
128
  @dataclass(order=True)
@@ -195,7 +197,7 @@ class JobState(Enum):
195
197
  _JOB_STATES = {state.value: state for state in JobState}
196
198
 
197
199
 
198
- @dataclass(order=True)
200
+ @dataclass(order=True, frozen=True)
199
201
  class Job:
200
202
  id: UUID
201
203
  name: str
@@ -204,10 +206,12 @@ class Job:
204
206
  submitted_at: datetime
205
207
  started_at: datetime | None
206
208
  canceled: bool
207
- progress_bars: list[ProgressBar]
209
+ progress: list[ProgressIndicator]
208
210
 
209
211
  @classmethod
210
- def from_message(cls, job: core_pb2.Job) -> "Job": # lets use typing.Self once we require python >= 3.11
212
+ def from_message(
213
+ cls, job: core_pb2.Job, **extra_kwargs: Any
214
+ ) -> "Job": # lets use typing.Self once we require python >= 3.11
211
215
  """Convert a Job protobuf message to a Job object."""
212
216
  return cls(
213
217
  id=uuid_message_to_uuid(job.id),
@@ -217,7 +221,8 @@ class Job:
217
221
  submitted_at=timestamp_to_datetime(job.submitted_at),
218
222
  started_at=timestamp_to_datetime(job.started_at) if job.HasField("started_at") else None,
219
223
  canceled=job.canceled,
220
- progress_bars=[ProgressBar.from_message(progress_bar) for progress_bar in job.progress_bars],
224
+ progress=[ProgressIndicator.from_message(progress) for progress in job.progress],
225
+ **extra_kwargs,
221
226
  )
222
227
 
223
228
  def to_message(self) -> core_pb2.Job:
@@ -230,7 +235,7 @@ class Job:
230
235
  submitted_at=datetime_to_timestamp(self.submitted_at),
231
236
  started_at=datetime_to_timestamp(self.started_at) if self.started_at else None,
232
237
  canceled=self.canceled,
233
- progress_bars=[progress_bar.to_message() for progress_bar in self.progress_bars],
238
+ progress=[progress.to_message() for progress in self.progress],
234
239
  )
235
240
 
236
241
 
@@ -303,7 +308,7 @@ class ComputedTask:
303
308
  id: UUID
304
309
  display: str | None
305
310
  sub_tasks: list[TaskSubmission]
306
- progress_updates: list[ProgressBar]
311
+ progress_updates: list[ProgressIndicator]
307
312
 
308
313
  @classmethod
309
314
  def from_message(cls, computed_task: task_pb2.ComputedTask) -> "ComputedTask":
@@ -312,7 +317,7 @@ class ComputedTask:
312
317
  id=uuid_message_to_uuid(computed_task.id),
313
318
  display=computed_task.display,
314
319
  sub_tasks=[TaskSubmission.from_message(sub_task) for sub_task in computed_task.sub_tasks],
315
- progress_updates=[ProgressBar.from_message(progress) for progress in computed_task.progress_updates],
320
+ progress_updates=[ProgressIndicator.from_message(progress) for progress in computed_task.progress_updates],
316
321
  )
317
322
 
318
323
  def to_message(self) -> task_pb2.ComputedTask:
@@ -571,9 +576,13 @@ class QueryJobsResponse:
571
576
  next_page: Pagination
572
577
 
573
578
  @classmethod
574
- def from_message(cls, page: job_pb2.QueryJobsResponse) -> "QueryJobsResponse":
579
+ def from_message(
580
+ cls,
581
+ page: job_pb2.QueryJobsResponse,
582
+ job_factory: Callable[[core_pb2.Job], Job] = Job.from_message,
583
+ ) -> "QueryJobsResponse":
575
584
  return cls(
576
- jobs=[Job.from_message(job) for job in page.jobs],
585
+ jobs=[job_factory(job) for job in page.jobs],
577
586
  next_page=Pagination.from_message(page.next_page),
578
587
  )
579
588
 
@@ -0,0 +1,389 @@
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(
39
+ progress.label or self.job.name, progress.done, progress.total, self.job.canceled
40
+ )
41
+ for progress in self.job.progress
42
+ )
43
+ self.layout = VBox(self.widgets)
44
+ self.refresh_thread = Thread(target=self._refresh_worker)
45
+ self.refresh_thread.start()
46
+
47
+ return self.layout._repr_mimebundle_(*args, **kwargs) if self.layout is not None else None
48
+
49
+ def stop(self) -> None:
50
+ self._stop_refresh.set()
51
+
52
+ def _refresh_worker(self) -> None:
53
+ """Refresh the job's progress display, intended to be run in a background thread."""
54
+
55
+ if self.job is None or self.refresh_callback is None or self.layout is None:
56
+ return
57
+
58
+ last_progress: Job | None = None
59
+
60
+ while True:
61
+ progress = self.refresh_callback(self.job.id)
62
+ updated = False
63
+ if last_progress is None: # first time, don't add the refresh time
64
+ self.widgets[1] = HTML(_render_job_progress(progress, False))
65
+ updated = True
66
+ elif progress.state != last_progress.state or progress.started_at != last_progress.started_at:
67
+ self.widgets[1] = HTML(_render_job_progress(progress, True))
68
+ updated = True
69
+
70
+ if last_progress is None or progress.progress != last_progress.progress:
71
+ self.widgets[2:] = [
72
+ _progress_indicator_bar(
73
+ progress.label or self.job.name, progress.done, progress.total, self.job.canceled
74
+ )
75
+ for progress in progress.progress
76
+ ]
77
+ updated = True
78
+
79
+ if updated:
80
+ self.layout.children = self.widgets # trigger a rerender of the ipywidgets
81
+
82
+ last_progress = progress
83
+
84
+ if last_progress.state == JobState.COMPLETED:
85
+ self.stop()
86
+ return # no more refreshing needed
87
+
88
+ refresh_wait = 5 + random.uniform(0, 2) # noqa: S311 # wait between 5 and 7 seconds to refresh progress
89
+ if self._stop_refresh.wait(refresh_wait): # check if the event to stop is set, if so exit the thread
90
+ return
91
+
92
+
93
+ @dataclass(order=True, frozen=True)
94
+ class RichDisplayJob(Job):
95
+ _widget: JobWidget = field(compare=False, repr=False)
96
+
97
+ def __repr__(self) -> str:
98
+ return super().__repr__().replace(RichDisplayJob.__name__, Job.__name__)
99
+
100
+ def _repr_mimebundle_(self, *args: Any, **kwargs: Any) -> dict[str, str] | None:
101
+ """Called by the IPython MimeBundleFormatter for interactive display of ipywidgets.
102
+
103
+ By overriding this and forwarding to an ipywidget, we can utilize the interactive display capabilities of
104
+ the widget, without having to inherit from a widget as base class of the Job.
105
+ """
106
+ self._widget.job = self # initialize the widget the first time we want to interactively display it
107
+ return self._widget._repr_mimebundle_(*args, **kwargs)
108
+
109
+
110
+ _CSS = """
111
+ :root {
112
+ --tbx-font-color0: var(
113
+ --jp-content-font-color0,
114
+ var(--pst-color-text-base rgba(0, 0, 0, 1))
115
+ );
116
+ --tbx-font-color2: var(
117
+ --jp-content-font-color2,
118
+ var(--pst-color-text-base, rgba(0, 0, 0, 0.54))
119
+ );
120
+ --tbx-border-color: var(
121
+ --jp-border-color2,
122
+ hsl(from var(--pst-color-on-background, white) h s calc(l - 10))
123
+ );
124
+ }
125
+
126
+ html[theme="dark"],
127
+ html[data-theme="dark"],
128
+ body[data-theme="dark"],
129
+ body.vscode-dark {
130
+ --tbx-font-color0: var(
131
+ --jp-content-font-color0,
132
+ var(--pst-color-text-base, rgba(255, 255, 255, 1))
133
+ );
134
+ --tbx-font-color2: var(
135
+ --jp-content-font-color2,
136
+ var(--pst-color-text-base, rgba(255, 255, 255, 0.54))
137
+ );
138
+ --tbx-border-color: var(
139
+ --jp-border-color2,
140
+ hsl(from var(--pst-color-on-background, #111111) h s calc(l + 10))
141
+ );
142
+ }
143
+
144
+ .tbx-wrap {
145
+ display: block !important;
146
+ min-width: 300px;
147
+ max-width: 540px;
148
+ }
149
+
150
+ .tbx-text-repr-fallback {
151
+ /* fallback to plain text repr when CSS is not injected (untrusted notebook) */
152
+ display: none;
153
+ }
154
+
155
+ .tbx-header {
156
+ display: flex;
157
+ flex-direction: row;
158
+ justify-content: space-between;
159
+ align-items: center;
160
+ padding-bottom: 2px;
161
+ }
162
+
163
+ .tbx-header > .tbx-obj-type {
164
+ padding: 3px 5px;
165
+ line-height: 1;
166
+ border-bottom: solid 1px var(--tbx-border-color);
167
+ }
168
+
169
+
170
+ .tbx-obj-type {
171
+ display: flex;
172
+ flex-direction: row;
173
+ align-items: center;
174
+ gap: 4px;
175
+ color: var(--tbx-font-color2);
176
+ }
177
+
178
+ .tbx-detail-key {
179
+ color: var(--tbx-font-color2);
180
+ }
181
+ .tbx-detail-mono {
182
+ font-family: monospace;
183
+ }
184
+ .tbx-detail-value {
185
+ color: var(--tbx-font-color0);
186
+ }
187
+ .tbx-detail-value-muted {
188
+ color: var(--tbx-font-color2);
189
+ }
190
+
191
+ .tbx-job-state {
192
+ border-radius: 10px;
193
+ padding: 2px 10px;
194
+ }
195
+
196
+ .tbx-job-state-queued {
197
+ background-color: #f1f5f9;
198
+ color: #0f172a;
199
+ }
200
+
201
+ .tbx-job-state-running {
202
+ background-color: #0066ff;
203
+ color: #f8fafc;
204
+ }
205
+
206
+ .tbx-job-state-completed {
207
+ background-color: #21c45d;
208
+ color: #f8fafc;
209
+ }
210
+
211
+ .tbx-job-state-failed {
212
+ background-color: #f43e5d;
213
+ color: #f8fafc;
214
+ }
215
+
216
+ .tbx-job-progress a {
217
+ text-decoration: underline;
218
+ }
219
+
220
+ .tbx-detail-button a{
221
+ display: flex;
222
+ align-items: center;
223
+ flex-direction: row;
224
+ gap: 8px;
225
+ font-size: 13px;
226
+ background-color: #f43f5e;
227
+ padding: 2px 10px;
228
+ color: white;
229
+ }
230
+
231
+ .tbx-detail-button a svg {
232
+ fill: white;
233
+ stroke: white;
234
+ stroke-width: 2px;
235
+ }
236
+
237
+ .tbx-detail-button a span {
238
+ display: inline;
239
+ vertical-align: middle;
240
+ margin: 0;
241
+ padding: 0;
242
+ line-height: 1.8;
243
+ }
244
+
245
+ .tbx-detail-button a:hover {
246
+ background-color: #cc4e63;
247
+ color: white;
248
+ }
249
+ """
250
+
251
+
252
+ def _render_job_details_html(job: Job) -> str:
253
+ """Render a job as HTML."""
254
+ return f"""
255
+ <style>
256
+ {_CSS}
257
+ </style>
258
+ <div class="tbx-text-repr-fallback"></div>
259
+ <div class="tbx-wrap">
260
+ <div class="tbx-header">
261
+ <div class="tbx-obj-type">tilebox.workflows.Job</div>
262
+ <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>
263
+ </div>
264
+ <div class="tbx-job-details">
265
+ <div><span class="tbx-detail-key tbx-detail-mono">id:</span> <span class="tbx-detail-value tbx-detail-mono">{job.id!s}</span><div>
266
+ <div><span class="tbx-detail-key tbx-detail-mono">name:</span> <span class="tbx-detail-value">{job.name}</span><div>
267
+ <div><span class="tbx-detail-key tbx-detail-mono">submitted_at:</span> {_render_datetime(job.submitted_at)}<div>
268
+ </div>
269
+ </div>
270
+ """
271
+
272
+
273
+ def _render_datetime(dt: datetime) -> str:
274
+ local = dt.astimezone(tzlocal())
275
+ time_part = local.strftime("%Y-%m-%d %H:%M:%S")
276
+ tz_part = local.strftime("%z")
277
+ tz_part = "(UTC)" if tz_part == "+0000" else f"(UTC{tz_part})"
278
+ return f"<span class='tbx-detail-value'>{time_part}</span> <span class='tbx-detail-value-muted'>{tz_part}</span>"
279
+
280
+
281
+ def _render_job_progress(job: Job, include_refresh_time: bool) -> str:
282
+ refresh = ""
283
+ if include_refresh_time:
284
+ current_time = datetime.now(tzlocal())
285
+ refresh = f" <span class='tbx-detail-value-muted'>(refreshed at {current_time.strftime('%H:%M:%S')})</span> {_info_icon}"
286
+
287
+ state_name = job.state.name
288
+ if job.state == JobState.STARTED:
289
+ state_name = "RUNNING" if not job.canceled else "FAILED"
290
+
291
+ no_progress = ""
292
+ if not job.progress:
293
+ 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>"
294
+
295
+ """Render a job's progress as HTML, needs to be called after render_job_details_html since that injects the necessary CSS."""
296
+ return f"""
297
+ <div class="tbx-wrap">
298
+ <div class="tbx-obj-type">Progress{refresh}</div>
299
+ <div class="tbx-job-progress">
300
+ <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>
301
+ <div><span class="tbx-detail-key tbx-detail-mono">started_at:</span> {_render_datetime(job.started_at) if job.started_at else "<span class='tbx-detail-value-muted tbx-detail-mono'>None</span>"}<div>
302
+ <div><span class="tbx-detail-key tbx-detail-mono">progress:</span> {no_progress}</div>
303
+ </div>
304
+ </div>
305
+ """.strip()
306
+
307
+
308
+ _BAR_COLORS = {
309
+ "running": "#0066ff",
310
+ "failed": "#f43e5d",
311
+ "completed": "#21c45d",
312
+ }
313
+
314
+
315
+ def _progress_indicator_bar(label: str, done: int, total: int, job_cancelled: bool) -> HBox:
316
+ percentage = done / total if total > 0 else 0 if done <= total else 1
317
+ non_completed_color = _BAR_COLORS["running"] if not job_cancelled else _BAR_COLORS["failed"]
318
+ progress = IntProgress(
319
+ min=0,
320
+ max=total,
321
+ value=done,
322
+ description=label,
323
+ tooltip=label,
324
+ style={"bar_color": non_completed_color if percentage < 1 else _BAR_COLORS["completed"]},
325
+ layout={"width": "400px"},
326
+ )
327
+ label_html = (
328
+ f"<span class='tbx-detail-mono'><span class='tbx-detail-value'>{percentage:.0%}</span> "
329
+ f"<span class='tbx-detail-value-muted'>({done} / {total})</span></span>"
330
+ )
331
+ label = HTML(label_html)
332
+ return HBox([progress, label])
333
+
334
+
335
+ _eye_icon = """
336
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px"
337
+ height="16px" viewBox="0 0 72 72" enable-background="new 0 0 72 72" xml:space="preserve">
338
+ <g>
339
+ <g>
340
+ <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
341
+ 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
342
+ c11.113,0,29.5-8.18,29.5-17.5C65.501,32.929,47.114,24.75,36.001,24.75z"/>
343
+ </g>
344
+ <g>
345
+ <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
346
+ 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
347
+ c4.686,0,8.5-3.813,8.5-8.5C44.501,37.731,40.687,33.917,36.001,33.917z"/>
348
+ </g>
349
+ <g>
350
+ <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
351
+ 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
352
+ c-0.158,0.129-0.307,0.27-0.444,0.418C32.612,39.7,32.342,39.809,32.073,39.809z"/>
353
+ </g>
354
+ <g>
355
+ <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
356
+ 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"/>
357
+ </g>
358
+ <g>
359
+ <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
360
+ C37.876,17.354,36.979,18.25,35.876,18.25z"/>
361
+ </g>
362
+ <g>
363
+ <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
364
+ 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"
365
+ />
366
+ </g>
367
+ <g>
368
+ <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
369
+ 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"/>
370
+ </g>
371
+ <g>
372
+ <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
373
+ 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
374
+ "/>
375
+ </g>
376
+ <g>
377
+ <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
378
+ 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"/>
379
+ </g>
380
+ </g>
381
+ </svg>
382
+ """.strip()
383
+
384
+ _info_icon = """
385
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="24px" height="24px">
386
+ <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>
387
+ <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"/>
388
+ </svg>
389
+ """.strip()
@@ -125,19 +125,19 @@ class JobClient:
125
125
  """
126
126
  return self._service.get_by_id(_to_uuid(job_id))
127
127
 
128
- def display(self, job: JobIDLike, direction: str = "down", layout: str = "dagre", sketchy: bool = True) -> None:
128
+ def display(self, job_id: JobIDLike, direction: str = "down", layout: str = "dagre", sketchy: bool = True) -> None:
129
129
  """Create a visualization of the job as a diagram and display it in an interactive environment.
130
130
 
131
131
  Requires an IPython environment such as a Jupyter notebook.
132
132
 
133
133
  Args:
134
- job: The job or job id to visualize.
134
+ job_id: The job or job id to visualize.
135
135
  direction: The direction of the diagram. Defaults to "down". See https://d2lang.com/tour/layouts/#direction
136
136
  layout: The layout to use for the diagram. Defaults to "dagre". Currently supported layouts are
137
137
  "dagre" and "elk". See https://d2lang.com/tour/layouts/
138
138
  sketchy: Whether to render the diagram in a sketchy hand drawn style. Defaults to True.
139
139
  """
140
- display(HTML(self.visualize(job, direction, layout, sketchy)))
140
+ display(HTML(self.visualize(job_id, direction, layout, sketchy)))
141
141
 
142
142
  def visualize(self, job: JobIDLike, direction: str = "down", layout: str = "dagre", sketchy: bool = True) -> str:
143
143
  """Create a visualization of the job as a diagram.
@@ -11,10 +11,12 @@ from tilebox.workflows.data import (
11
11
  TaskSubmission,
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,
@@ -43,12 +45,16 @@ class JobService:
43
45
  job_name=job_name,
44
46
  trace_parent=trace_parent,
45
47
  )
46
- return Job.from_message(self.service.SubmitJob(request))
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 Job.from_message(response)
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
- return QueryJobsResponse.from_message(response)
82
+
83
+ return QueryJobsResponse.from_message(
84
+ response, job_factory=lambda job: RichDisplayJob.from_message(job, _widget=JobWidget(self.get_progress))
85
+ )