tilebox-workflows 0.44.0__tar.gz → 0.45.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 (50) hide show
  1. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/PKG-INFO +1 -1
  2. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/automations/client.py +3 -3
  3. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/data.py +176 -31
  4. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/formatting/job.py +24 -11
  5. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/jobs/client.py +38 -12
  6. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/jobs/service.py +3 -3
  7. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/runner/task_runner.py +38 -9
  8. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/task.py +91 -10
  9. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/automation_pb2.py +22 -22
  10. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/automation_pb2.pyi +2 -2
  11. tilebox_workflows-0.45.0/tilebox/workflows/workflows/v1/core_pb2.py +103 -0
  12. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/core_pb2.pyi +86 -13
  13. tilebox_workflows-0.45.0/tilebox/workflows/workflows/v1/job_pb2.py +104 -0
  14. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/job_pb2.pyi +17 -11
  15. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/task_pb2.py +16 -16
  16. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/task_pb2.pyi +5 -3
  17. tilebox_workflows-0.44.0/tilebox/workflows/workflows/v1/core_pb2.py +0 -79
  18. tilebox_workflows-0.44.0/tilebox/workflows/workflows/v1/job_pb2.py +0 -104
  19. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/.gitignore +0 -0
  20. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/README.md +0 -0
  21. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/pyproject.toml +0 -0
  22. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/__init__.py +0 -0
  23. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/automations/__init__.py +0 -0
  24. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/automations/cron.py +0 -0
  25. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/automations/service.py +0 -0
  26. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/automations/storage_event.py +0 -0
  27. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/cache.py +0 -0
  28. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/client.py +0 -0
  29. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/clusters/__init__.py +0 -0
  30. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/clusters/client.py +0 -0
  31. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/clusters/service.py +0 -0
  32. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/formatting/__init__.py +0 -0
  33. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/interceptors.py +0 -0
  34. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/jobs/__init__.py +0 -0
  35. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/observability/__init__.py +0 -0
  36. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/observability/logging.py +0 -0
  37. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/observability/tracing.py +0 -0
  38. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/runner/__init__.py +0 -0
  39. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/runner/task_service.py +0 -0
  40. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/timeseries.py +0 -0
  41. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/automation_pb2_grpc.py +0 -0
  42. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/core_pb2_grpc.py +0 -0
  43. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/diagram_pb2.py +0 -0
  44. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/diagram_pb2.pyi +0 -0
  45. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/diagram_pb2_grpc.py +0 -0
  46. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/job_pb2_grpc.py +0 -0
  47. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/task_pb2_grpc.py +0 -0
  48. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/workflows_pb2.py +0 -0
  49. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.0}/tilebox/workflows/workflows/v1/workflows_pb2.pyi +0 -0
  50. {tilebox_workflows-0.44.0 → tilebox_workflows-0.45.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.44.0
3
+ Version: 0.45.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
@@ -7,9 +7,9 @@ from tilebox.workflows.clusters.client import ClusterSlugLike, to_cluster_slug
7
7
  from tilebox.workflows.data import (
8
8
  AutomationPrototype,
9
9
  CronTrigger,
10
+ SingleTaskSubmission,
10
11
  StorageEventTrigger,
11
12
  StorageLocation,
12
- TaskSubmission,
13
13
  )
14
14
  from tilebox.workflows.task import _task_meta
15
15
 
@@ -76,7 +76,7 @@ class AutomationClient:
76
76
  automation = AutomationPrototype(
77
77
  id=UUID(int=0),
78
78
  name=name,
79
- prototype=TaskSubmission(
79
+ prototype=SingleTaskSubmission(
80
80
  cluster_slug=to_cluster_slug(cluster or ""),
81
81
  identifier=_task_meta(task).identifier,
82
82
  input=task._serialize_args(), # noqa: SLF001
@@ -118,7 +118,7 @@ class AutomationClient:
118
118
  automation = AutomationPrototype(
119
119
  id=UUID(int=0),
120
120
  name=name,
121
- prototype=TaskSubmission(
121
+ prototype=SingleTaskSubmission(
122
122
  cluster_slug=to_cluster_slug(cluster or ""),
123
123
  identifier=_task_meta(task).identifier,
124
124
  input=task._serialize_args(), # noqa: SLF001
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta
6
6
  from enum import Enum
7
7
  from functools import lru_cache
8
8
  from pathlib import Path
9
- from typing import Any
9
+ from typing import Any, cast
10
10
  from uuid import UUID
11
11
 
12
12
  import boto3
@@ -23,7 +23,7 @@ from tilebox.datasets.query.time_interval import (
23
23
  timedelta_to_duration,
24
24
  timestamp_to_datetime,
25
25
  )
26
- from tilebox.datasets.uuid import uuid_message_to_optional_uuid, uuid_message_to_uuid, uuid_to_uuid_message
26
+ from tilebox.datasets.uuid import must_uuid_to_uuid_message, uuid_message_to_uuid, uuid_to_uuid_message
27
27
 
28
28
  try:
29
29
  # let's not make this a hard dependency, but if it's installed we can use its types
@@ -162,7 +162,7 @@ class Task:
162
162
  return core_pb2.Task(
163
163
  id=uuid_to_uuid_message(self.id),
164
164
  identifier=self.identifier.to_message(),
165
- state=f"TASK_STATE_{self.state.name}",
165
+ state=cast(core_pb2.TaskState, self.state.value),
166
166
  input=self.input,
167
167
  display=self.display,
168
168
  job=self.job.to_message() if self.job else None,
@@ -189,13 +189,66 @@ class Idling:
189
189
 
190
190
  class JobState(Enum):
191
191
  UNSPECIFIED = 0
192
- QUEUED = 1
193
- STARTED = 2
194
- COMPLETED = 3
192
+ SUBMITTED = 1
193
+ RUNNING = 2
194
+ STARTED = 3
195
+ COMPLETED = 4
196
+ FAILED = 5
197
+ CANCELED = 6
195
198
 
196
199
 
197
200
  _JOB_STATES = {state.value: state for state in JobState}
198
201
 
202
+ # JobState.QUEUED is deprecated and has been renamed to SUBMITTED, but we keep it around for backwards compatibility
203
+ JobState.QUEUED = JobState.SUBMITTED # type: ignore[assignment]
204
+
205
+
206
+ @dataclass(order=True, frozen=True)
207
+ class ExecutionStats:
208
+ first_task_started_at: datetime | None
209
+ last_task_stopped_at: datetime | None
210
+ compute_time: timedelta
211
+ elapsed_time: timedelta
212
+ parallelism: float
213
+ total_tasks: int
214
+ tasks_by_state: dict[TaskState, int]
215
+
216
+ @classmethod
217
+ def from_message(cls, execution_stats: core_pb2.ExecutionStats) -> "ExecutionStats":
218
+ """Convert a ExecutionStats protobuf message to a ExecutionStats object."""
219
+ return cls(
220
+ first_task_started_at=timestamp_to_datetime(execution_stats.first_task_started_at)
221
+ if execution_stats.HasField("first_task_started_at")
222
+ else None,
223
+ last_task_stopped_at=timestamp_to_datetime(execution_stats.last_task_stopped_at)
224
+ if execution_stats.HasField("last_task_stopped_at")
225
+ else None,
226
+ compute_time=duration_to_timedelta(execution_stats.compute_time),
227
+ elapsed_time=duration_to_timedelta(execution_stats.elapsed_time),
228
+ parallelism=execution_stats.parallelism,
229
+ total_tasks=execution_stats.total_tasks,
230
+ tasks_by_state={_TASK_STATES[state.state]: state.count for state in execution_stats.tasks_by_state},
231
+ )
232
+
233
+ def to_message(self) -> core_pb2.ExecutionStats:
234
+ """Convert a ExecutionStats object to a ExecutionStats protobuf message."""
235
+ return core_pb2.ExecutionStats(
236
+ first_task_started_at=datetime_to_timestamp(self.first_task_started_at)
237
+ if self.first_task_started_at
238
+ else None,
239
+ last_task_stopped_at=datetime_to_timestamp(self.last_task_stopped_at)
240
+ if self.last_task_stopped_at
241
+ else None,
242
+ compute_time=timedelta_to_duration(self.compute_time),
243
+ elapsed_time=timedelta_to_duration(self.elapsed_time),
244
+ parallelism=self.parallelism,
245
+ total_tasks=self.total_tasks,
246
+ tasks_by_state=[
247
+ core_pb2.TaskStateCount(state=cast(core_pb2.TaskState, state.value), count=count)
248
+ for state, count in self.tasks_by_state.items()
249
+ ],
250
+ )
251
+
199
252
 
200
253
  @dataclass(order=True, frozen=True)
201
254
  class Job:
@@ -204,9 +257,8 @@ class Job:
204
257
  trace_parent: str
205
258
  state: JobState
206
259
  submitted_at: datetime
207
- started_at: datetime | None
208
- canceled: bool
209
260
  progress: list[ProgressIndicator]
261
+ execution_stats: ExecutionStats
210
262
 
211
263
  @classmethod
212
264
  def from_message(
@@ -219,9 +271,8 @@ class Job:
219
271
  trace_parent=job.trace_parent,
220
272
  state=_JOB_STATES[job.state],
221
273
  submitted_at=timestamp_to_datetime(job.submitted_at),
222
- started_at=timestamp_to_datetime(job.started_at) if job.HasField("started_at") else None,
223
- canceled=job.canceled,
224
274
  progress=[ProgressIndicator.from_message(progress) for progress in job.progress],
275
+ execution_stats=ExecutionStats.from_message(job.execution_stats),
225
276
  **extra_kwargs,
226
277
  )
227
278
 
@@ -231,13 +282,33 @@ class Job:
231
282
  id=uuid_to_uuid_message(self.id),
232
283
  name=self.name,
233
284
  trace_parent=self.trace_parent,
234
- state=f"JOB_STATE_{self.state.name}",
285
+ state=cast(core_pb2.JobState, self.state.value),
235
286
  submitted_at=datetime_to_timestamp(self.submitted_at),
236
- started_at=datetime_to_timestamp(self.started_at) if self.started_at else None,
237
- canceled=self.canceled,
238
287
  progress=[progress.to_message() for progress in self.progress],
288
+ execution_stats=self.execution_stats.to_message(),
239
289
  )
240
290
 
291
+ @property
292
+ def canceled(self) -> bool:
293
+ warnings.warn(
294
+ "The canceled property on a job has been deprecated, and will be removed in a future version. "
295
+ "Use job.state == JobState.CANCELED, or job.state == JobState.FAILED instead.",
296
+ DeprecationWarning,
297
+ stacklevel=2,
298
+ )
299
+ # the deprecated canceled property was also true for failed jobs, so we keep that behavior
300
+ return self.state in (JobState.CANCELED, JobState.FAILED)
301
+
302
+ @property
303
+ def started_at(self) -> datetime | None:
304
+ warnings.warn(
305
+ "The started_at property on a job has been deprecated, use `job.execution_stats.first_task_started_at` "
306
+ "instead.",
307
+ DeprecationWarning,
308
+ stacklevel=2,
309
+ )
310
+ return self.execution_stats.first_task_started_at
311
+
241
312
 
242
313
  @dataclass(order=True)
243
314
  class Cluster:
@@ -271,7 +342,69 @@ class NextTaskToRun:
271
342
 
272
343
 
273
344
  @dataclass
274
- class TaskSubmission:
345
+ class TaskSubmissionGroup:
346
+ dependencies_on_other_groups: list[int]
347
+ inputs: list[bytes] = field(default_factory=list)
348
+ identifier_pointers: list[int] = field(default_factory=list)
349
+ cluster_slug_pointers: list[int] = field(default_factory=list)
350
+ display_pointers: list[int] = field(default_factory=list)
351
+ max_retries_values: list[int] = field(default_factory=list)
352
+
353
+ @classmethod
354
+ def from_message(cls, group: core_pb2.TaskSubmissionGroup) -> "TaskSubmissionGroup":
355
+ """Convert a TaskSubmissionGroup protobuf message to a TaskSubmissionGroup object."""
356
+ return cls(
357
+ dependencies_on_other_groups=list(group.dependencies_on_other_groups),
358
+ inputs=list(group.inputs),
359
+ identifier_pointers=list(group.identifier_pointers),
360
+ cluster_slug_pointers=list(group.cluster_slug_pointers),
361
+ display_pointers=list(group.display_pointers),
362
+ max_retries_values=list(group.max_retries_values),
363
+ )
364
+
365
+ def to_message(self) -> core_pb2.TaskSubmissionGroup:
366
+ """Convert a TaskSubmissionGroup object to a TaskSubmissionGroup protobuf message."""
367
+ return core_pb2.TaskSubmissionGroup(
368
+ dependencies_on_other_groups=self.dependencies_on_other_groups,
369
+ inputs=self.inputs,
370
+ identifier_pointers=self.identifier_pointers,
371
+ cluster_slug_pointers=self.cluster_slug_pointers,
372
+ display_pointers=self.display_pointers,
373
+ max_retries_values=self.max_retries_values,
374
+ )
375
+
376
+
377
+ @dataclass
378
+ class TaskSubmissions:
379
+ task_groups: list[TaskSubmissionGroup]
380
+ cluster_slug_lookup: list[str]
381
+ identifier_lookup: list[TaskIdentifier]
382
+ display_lookup: list[str]
383
+
384
+ @classmethod
385
+ def from_message(cls, sub_task: core_pb2.TaskSubmissions) -> "TaskSubmissions":
386
+ """Convert a TaskSubmission protobuf message to a TaskSubmission object."""
387
+ return cls(
388
+ task_groups=[TaskSubmissionGroup.from_message(group) for group in sub_task.task_groups],
389
+ cluster_slug_lookup=list(sub_task.cluster_slug_lookup),
390
+ identifier_lookup=[TaskIdentifier.from_message(identifier) for identifier in sub_task.identifier_lookup],
391
+ display_lookup=list(sub_task.display_lookup),
392
+ )
393
+
394
+ def to_message(self) -> core_pb2.TaskSubmissions:
395
+ """Convert a TaskSubmissions object to a TaskSubmissions protobuf message."""
396
+ return core_pb2.TaskSubmissions(
397
+ task_groups=[group.to_message() for group in self.task_groups],
398
+ cluster_slug_lookup=self.cluster_slug_lookup,
399
+ identifier_lookup=[identifier.to_message() for identifier in self.identifier_lookup],
400
+ display_lookup=self.display_lookup,
401
+ )
402
+
403
+
404
+ @dataclass
405
+ class SingleTaskSubmission:
406
+ """A submission of a single task. Used for automations."""
407
+
275
408
  cluster_slug: str
276
409
  identifier: TaskIdentifier
277
410
  input: bytes
@@ -280,8 +413,8 @@ class TaskSubmission:
280
413
  max_retries: int = 0
281
414
 
282
415
  @classmethod
283
- def from_message(cls, sub_task: core_pb2.TaskSubmission) -> "TaskSubmission":
284
- """Convert a TaskSubmission protobuf message to a TaskSubmission object."""
416
+ def from_message(cls, sub_task: core_pb2.SingleTaskSubmission) -> "SingleTaskSubmission":
417
+ """Convert a TaskSubmission protobuf message to a SingleTaskSubmission object."""
285
418
  return cls(
286
419
  cluster_slug=sub_task.cluster_slug,
287
420
  identifier=TaskIdentifier.from_message(sub_task.identifier),
@@ -291,9 +424,9 @@ class TaskSubmission:
291
424
  max_retries=sub_task.max_retries,
292
425
  )
293
426
 
294
- def to_message(self) -> core_pb2.TaskSubmission:
295
- """Convert a TaskSubmission object to a TaskSubmission protobuf message."""
296
- return core_pb2.TaskSubmission(
427
+ def to_message(self) -> core_pb2.SingleTaskSubmission:
428
+ """Convert a SingleTaskSubmission object to a TaskSubmission protobuf message."""
429
+ return core_pb2.SingleTaskSubmission(
297
430
  cluster_slug=self.cluster_slug,
298
431
  identifier=self.identifier.to_message(),
299
432
  input=self.input,
@@ -307,7 +440,7 @@ class TaskSubmission:
307
440
  class ComputedTask:
308
441
  id: UUID
309
442
  display: str | None
310
- sub_tasks: list[TaskSubmission]
443
+ sub_tasks: TaskSubmissions | None
311
444
  progress_updates: list[ProgressIndicator]
312
445
 
313
446
  @classmethod
@@ -316,7 +449,9 @@ class ComputedTask:
316
449
  return cls(
317
450
  id=uuid_message_to_uuid(computed_task.id),
318
451
  display=computed_task.display,
319
- sub_tasks=[TaskSubmission.from_message(sub_task) for sub_task in computed_task.sub_tasks],
452
+ sub_tasks=TaskSubmissions.from_message(computed_task.sub_tasks)
453
+ if computed_task.HasField("sub_tasks")
454
+ else None,
320
455
  progress_updates=[ProgressIndicator.from_message(progress) for progress in computed_task.progress_updates],
321
456
  )
322
457
 
@@ -325,7 +460,7 @@ class ComputedTask:
325
460
  return task_pb2.ComputedTask(
326
461
  id=uuid_to_uuid_message(self.id),
327
462
  display=self.display,
328
- sub_tasks=[sub_task.to_message() for sub_task in self.sub_tasks],
463
+ sub_tasks=self.sub_tasks.to_message() if self.sub_tasks else None,
329
464
  progress_updates=[progress.to_message() for progress in self.progress_updates],
330
465
  )
331
466
 
@@ -505,7 +640,7 @@ class TriggeredCronEvent:
505
640
  class AutomationPrototype:
506
641
  id: UUID
507
642
  name: str
508
- prototype: TaskSubmission
643
+ prototype: SingleTaskSubmission
509
644
  storage_event_triggers: list[StorageEventTrigger]
510
645
  cron_triggers: list[CronTrigger]
511
646
 
@@ -515,7 +650,7 @@ class AutomationPrototype:
515
650
  return cls(
516
651
  id=uuid_message_to_uuid(task.id),
517
652
  name=task.name,
518
- prototype=TaskSubmission.from_message(task.prototype),
653
+ prototype=SingleTaskSubmission.from_message(task.prototype),
519
654
  storage_event_triggers=[
520
655
  StorageEventTrigger.from_message(trigger) for trigger in task.storage_event_triggers
521
656
  ],
@@ -595,21 +730,31 @@ class QueryJobsResponse:
595
730
 
596
731
  @dataclass(frozen=True)
597
732
  class QueryFilters:
598
- time_interval: TimeInterval | None = None
599
- id_interval: IDInterval | None = None
600
- automation_id: UUID | None = None
733
+ time_interval: TimeInterval | None
734
+ id_interval: IDInterval | None
735
+ automation_ids: list[UUID]
736
+ job_states: list[JobState]
737
+ name: str | None
601
738
 
602
739
  @classmethod
603
740
  def from_message(cls, filters: job_pb2.QueryFilters) -> "QueryFilters":
604
741
  return cls(
605
- time_interval=TimeInterval.from_message(filters.time_interval),
606
- id_interval=IDInterval.from_message(filters.id_interval),
607
- automation_id=uuid_message_to_optional_uuid(filters.automation_id),
742
+ time_interval=TimeInterval.from_message(filters.time_interval)
743
+ if filters.HasField("time_interval")
744
+ else None,
745
+ id_interval=IDInterval.from_message(filters.id_interval) if filters.HasField("id_interval") else None,
746
+ automation_ids=[uuid_message_to_uuid(uuid) for uuid in filters.automation_ids],
747
+ job_states=[_JOB_STATES[state] for state in filters.states],
748
+ name=filters.name or None,
608
749
  )
609
750
 
610
751
  def to_message(self) -> job_pb2.QueryFilters:
611
752
  return job_pb2.QueryFilters(
612
753
  time_interval=self.time_interval.to_message() if self.time_interval else None,
613
754
  id_interval=self.id_interval.to_message() if self.id_interval else None,
614
- automation_id=uuid_to_uuid_message(self.automation_id) if self.automation_id else None,
755
+ automation_ids=[must_uuid_to_uuid_message(uuid) for uuid in self.automation_ids]
756
+ if self.automation_ids
757
+ else None,
758
+ states=[cast(core_pb2.JobState, state.value) for state in self.job_states] if self.job_states else None,
759
+ name=self.name or None, # empty string becomes None
615
760
  )
@@ -35,9 +35,7 @@ class JobWidget:
35
35
  self.widgets.append(HTML(_render_job_details_html(self.job)))
36
36
  self.widgets.append(HTML(_render_job_progress(self.job, False)))
37
37
  self.widgets.extend(
38
- _progress_indicator_bar(
39
- progress.label or self.job.name, progress.done, progress.total, self.job.canceled
40
- )
38
+ _progress_indicator_bar(progress.label or self.job.name, progress.done, progress.total, self.job.state)
41
39
  for progress in self.job.progress
42
40
  )
43
41
  self.layout = VBox(self.widgets)
@@ -63,14 +61,17 @@ class JobWidget:
63
61
  if last_progress is None: # first time, don't add the refresh time
64
62
  self.widgets[1] = HTML(_render_job_progress(progress, False))
65
63
  updated = True
66
- elif progress.state != last_progress.state or progress.started_at != last_progress.started_at:
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
+ ):
67
68
  self.widgets[1] = HTML(_render_job_progress(progress, True))
68
69
  updated = True
69
70
 
70
71
  if last_progress is None or progress.progress != last_progress.progress:
71
72
  self.widgets[2:] = [
72
73
  _progress_indicator_bar(
73
- progress.label or self.job.name, progress.done, progress.total, self.job.canceled
74
+ progress.label or self.job.name, progress.done, progress.total, self.job.state
74
75
  )
75
76
  for progress in progress.progress
76
77
  ]
@@ -193,7 +194,7 @@ body.vscode-dark {
193
194
  padding: 2px 10px;
194
195
  }
195
196
 
196
- .tbx-job-state-queued {
197
+ .tbx-job-state-submitted {
197
198
  background-color: #f1f5f9;
198
199
  color: #0f172a;
199
200
  }
@@ -203,6 +204,11 @@ body.vscode-dark {
203
204
  color: #f8fafc;
204
205
  }
205
206
 
207
+ .tbx-job-state-started {
208
+ background-color: #fd9b11;
209
+ color: #f8fafc;
210
+ }
211
+
206
212
  .tbx-job-state-completed {
207
213
  background-color: #21c45d;
208
214
  color: #f8fafc;
@@ -213,6 +219,11 @@ body.vscode-dark {
213
219
  color: #f8fafc;
214
220
  }
215
221
 
222
+ .tbx-job-state-canceled {
223
+ background-color: #94a2b3;
224
+ color: #f8fafc;
225
+ }
226
+
216
227
  .tbx-job-progress a {
217
228
  text-decoration: underline;
218
229
  }
@@ -285,20 +296,20 @@ def _render_job_progress(job: Job, include_refresh_time: bool) -> str:
285
296
  refresh = f" <span class='tbx-detail-value-muted'>(refreshed at {current_time.strftime('%H:%M:%S')})</span> {_info_icon}"
286
297
 
287
298
  state_name = job.state.name
288
- if job.state == JobState.STARTED:
289
- state_name = "RUNNING" if not job.canceled else "FAILED"
290
299
 
291
300
  no_progress = ""
292
301
  if not job.progress:
293
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>"
294
303
 
304
+ started_at = job.execution_stats.first_task_started_at
305
+
295
306
  """Render a job's progress as HTML, needs to be called after render_job_details_html since that injects the necessary CSS."""
296
307
  return f"""
297
308
  <div class="tbx-wrap">
298
309
  <div class="tbx-obj-type">Progress{refresh}</div>
299
310
  <div class="tbx-job-progress">
300
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>
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>
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>
302
313
  <div><span class="tbx-detail-key tbx-detail-mono">progress:</span> {no_progress}</div>
303
314
  </div>
304
315
  </div>
@@ -312,9 +323,11 @@ _BAR_COLORS = {
312
323
  }
313
324
 
314
325
 
315
- def _progress_indicator_bar(label: str, done: int, total: int, job_cancelled: bool) -> HBox:
326
+ def _progress_indicator_bar(label: str, done: int, total: int, state: JobState) -> HBox:
316
327
  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"]
328
+ non_completed_color = (
329
+ _BAR_COLORS["failed"] if state in (JobState.FAILED, JobState.CANCELED) else _BAR_COLORS["running"]
330
+ )
318
331
  progress = IntProgress(
319
332
  min=0,
320
333
  max=total,
@@ -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 "")] * len(tasks)
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
- FutureTask(i, task, [], slugs[i], max_retries).to_submission() for i, task in enumerate(tasks)
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, task_submissions)
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.
@@ -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(self, temporal_extent: TimeIntervalLike | IDIntervalLike, automation_id: UUID | None = None) -> list[Job]:
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
- automation_id: The automation id to filter jobs by. If specified, only jobs created by the given automation
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
- filters = QueryFilters(time_interval=time_interval, id_interval=id_interval, automation_id=automation_id)
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,7 +8,7 @@ from tilebox.workflows.data import (
8
8
  Job,
9
9
  QueryFilters,
10
10
  QueryJobsResponse,
11
- TaskSubmission,
11
+ TaskSubmissions,
12
12
  uuid_to_uuid_message,
13
13
  )
14
14
  from tilebox.workflows.formatting.job import JobWidget, RichDisplayJob
@@ -39,9 +39,9 @@ class JobService:
39
39
  """
40
40
  self.service = with_pythonic_errors(JobServiceStub(channel))
41
41
 
42
- def submit(self, job_name: str, trace_parent: str, tasks: list[TaskSubmission]) -> Job:
42
+ def submit(self, job_name: str, trace_parent: str, tasks: TaskSubmissions) -> Job:
43
43
  request = SubmitJobRequest(
44
- tasks=[task.to_message() for task in tasks],
44
+ tasks=tasks.to_message(),
45
45
  job_name=job_name,
46
46
  trace_parent=trace_parent,
47
47
  )
@@ -34,7 +34,13 @@ from tilebox.workflows.observability.logging import get_logger
34
34
  from tilebox.workflows.observability.tracing import WorkflowTracer
35
35
  from tilebox.workflows.runner.task_service import TaskService
36
36
  from tilebox.workflows.task import ExecutionContext as ExecutionContextBase
37
- from tilebox.workflows.task import FutureTask, ProgressUpdate, RunnerContext, TaskMeta
37
+ from tilebox.workflows.task import (
38
+ FutureTask,
39
+ ProgressUpdate,
40
+ RunnerContext,
41
+ TaskMeta,
42
+ merge_future_tasks_to_submissions,
43
+ )
38
44
  from tilebox.workflows.task import Task as TaskInstance
39
45
 
40
46
  # The time we give a task to finish it's execution when a runner shutdown is requested before we forcefully stop it
@@ -489,10 +495,12 @@ class TaskRunner:
489
495
  computed_task = ComputedTask(
490
496
  id=task.id,
491
497
  display=task.display,
492
- sub_tasks=[
493
- task.to_submission(self.tasks_to_run.cluster_slug)
494
- for task in context._sub_tasks # noqa: SLF001
495
- ],
498
+ sub_tasks=merge_future_tasks_to_submissions(
499
+ context._sub_tasks, # noqa: SLF001
500
+ # if not otherwise specified, we use the cluster of the runner for all subtasks, which is also
501
+ # the cluster of the parent task
502
+ self.tasks_to_run.cluster_slug,
503
+ ),
496
504
  progress_updates=_finalize_mutable_progress_trackers(context._progress_indicators), # noqa: SLF001
497
505
  )
498
506
 
@@ -518,15 +526,30 @@ class ExecutionContext(ExecutionContextBase):
518
526
  def submit_subtask(
519
527
  self,
520
528
  task: TaskInstance,
521
- depends_on: list[FutureTask] | None = None,
529
+ depends_on: FutureTask | list[FutureTask] | None = None,
522
530
  cluster: str | None = None,
523
531
  max_retries: int = 0,
524
532
  ) -> FutureTask:
533
+ dependencies: list[int] = []
534
+
535
+ if depends_on is None:
536
+ depends_on = []
537
+ elif isinstance(depends_on, FutureTask):
538
+ depends_on = [depends_on]
539
+ elif not isinstance(depends_on, list):
540
+ raise TypeError(f"Invalid dependency. Expected FutureTask or list[FutureTask], got {type(depends_on)}")
541
+
542
+ for dep in depends_on:
543
+ if not isinstance(dep, FutureTask):
544
+ raise TypeError(f"Invalid dependency. Expected FutureTask, got {type(dep)}")
545
+ if dep.index >= len(self._sub_tasks):
546
+ raise ValueError(f"Dependent task {dep.index} does not exist")
547
+ dependencies.append(dep.index)
525
548
  subtask = FutureTask(
526
549
  index=len(self._sub_tasks),
527
550
  task=task,
528
551
  # cyclic dependencies are not allowed, they are detected by the server and will result in an error
529
- depends_on=[d.index for d in depends_on] if depends_on is not None else [],
552
+ depends_on=dependencies,
530
553
  cluster=cluster,
531
554
  max_retries=max_retries,
532
555
  )
@@ -534,9 +557,15 @@ class ExecutionContext(ExecutionContextBase):
534
557
  return subtask
535
558
 
536
559
  def submit_subtasks(
537
- self, tasks: Sequence[TaskInstance], cluster: str | None = None, max_retries: int = 0
560
+ self,
561
+ tasks: Sequence[TaskInstance],
562
+ cluster: str | None = None,
563
+ max_retries: int = 0,
564
+ depends_on: FutureTask | list[FutureTask] | None = None,
538
565
  ) -> list[FutureTask]:
539
- return [self.submit_subtask(task, cluster=cluster, max_retries=max_retries) for task in tasks]
566
+ return [
567
+ self.submit_subtask(task, cluster=cluster, max_retries=max_retries, depends_on=depends_on) for task in tasks
568
+ ]
540
569
 
541
570
  def submit_batch(
542
571
  self, tasks: Sequence[TaskInstance], cluster: str | None = None, max_retries: int = 0