taskferry 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.
Files changed (48) hide show
  1. taskferry/__init__.py +211 -0
  2. taskferry/aio.py +486 -0
  3. taskferry/backends/__init__.py +38 -0
  4. taskferry/backends/inline.py +235 -0
  5. taskferry/backends/process.py +292 -0
  6. taskferry/backends/subprocess.py +390 -0
  7. taskferry/backends/thread.py +351 -0
  8. taskferry/capabilities.py +90 -0
  9. taskferry/cli.py +445 -0
  10. taskferry/config.py +360 -0
  11. taskferry/contract/__init__.py +56 -0
  12. taskferry/contract/base.py +179 -0
  13. taskferry/contract/inline.py +89 -0
  14. taskferry/contract/job.py +91 -0
  15. taskferry/contract/task.py +91 -0
  16. taskferry/core/__init__.py +130 -0
  17. taskferry/core/capabilities.py +89 -0
  18. taskferry/core/config.py +167 -0
  19. taskferry/core/correlation.py +120 -0
  20. taskferry/core/delivery.py +36 -0
  21. taskferry/core/errors.py +55 -0
  22. taskferry/core/ids.py +37 -0
  23. taskferry/core/observability.py +136 -0
  24. taskferry/core/otel.py +83 -0
  25. taskferry/core/provider.py +50 -0
  26. taskferry/core/py.typed +0 -0
  27. taskferry/core/registry.py +92 -0
  28. taskferry/core/serialization.py +79 -0
  29. taskferry/core/typing.py +16 -0
  30. taskferry/envelope.py +197 -0
  31. taskferry/errors.py +144 -0
  32. taskferry/execution.py +239 -0
  33. taskferry/functions.py +290 -0
  34. taskferry/handle.py +186 -0
  35. taskferry/hooks.py +238 -0
  36. taskferry/plugins.py +183 -0
  37. taskferry/ports.py +356 -0
  38. taskferry/py.typed +0 -0
  39. taskferry/retry.py +205 -0
  40. taskferry/router.py +160 -0
  41. taskferry/runtime.py +609 -0
  42. taskferry/specs.py +353 -0
  43. taskferry/tracking.py +129 -0
  44. taskferry-0.2.0.dist-info/METADATA +109 -0
  45. taskferry-0.2.0.dist-info/RECORD +48 -0
  46. taskferry-0.2.0.dist-info/WHEEL +4 -0
  47. taskferry-0.2.0.dist-info/entry_points.txt +2 -0
  48. taskferry-0.2.0.dist-info/licenses/LICENSE +201 -0
taskferry/runtime.py ADDED
@@ -0,0 +1,609 @@
1
+ """The Taskferry runtime — one object an application holds and passes around.
2
+
3
+ ```mermaid
4
+ flowchart TD
5
+ APP["Application"]
6
+ RT["Taskferry runtime"]
7
+ ROUTER["Router"]
8
+ REG["Backend registry<br/>(lazy)"]
9
+
10
+ INLINE["inline backend"]
11
+ TASK["task backend"]
12
+ JOB["job backend"]
13
+
14
+ APP -->|"runtime.tasks.submit(...)"| RT
15
+ RT --> ROUTER
16
+ ROUTER -->|"backend name"| REG
17
+ REG --> INLINE
18
+ REG --> TASK
19
+ REG --> JOB
20
+ ```
21
+
22
+ The runtime coordinates and does nothing else. It routes, it builds backends
23
+ lazily, it validates capabilities, it wraps results in handles, it fires hooks —
24
+ and then it gets out of the way. It never queues, never polls a broker, never
25
+ runs a worker loop. Those belong to the engines.
26
+
27
+ Three facades read the way the three primitives are actually used::
28
+
29
+ runtime.inline.submit(add, 20, 22) # a callable, now
30
+ runtime.tasks.submit("myapp.tasks:refresh", 42) # a name, on an engine
31
+ runtime.jobs.submit("build-cog", image="gdal:latest") # a container, somewhere
32
+
33
+ Backends are built on first use and cached. Configuring a Cloud Run job backend
34
+ therefore costs a web process nothing until a job is actually routed to it, and
35
+ ``import taskferry`` never reaches a provider SDK.
36
+
37
+ Thread safety
38
+ -------------
39
+
40
+ A runtime is safe to share across threads: backend construction is guarded by a
41
+ lock (so a backend is built exactly once), the router and config are immutable,
42
+ and the execution index is lock-protected. Whether a *backend* is thread-safe is
43
+ that backend's own promise; all built-ins are.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import threading
49
+ from collections import OrderedDict
50
+ from collections.abc import Callable, Iterable, Mapping, Sequence
51
+ from datetime import datetime, timedelta
52
+ from types import TracebackType
53
+ from typing import Any, Self
54
+
55
+ from .capabilities import CapabilitySet
56
+ from .config import BackendConfig, TaskferryConfig
57
+ from .core.correlation import Correlation, ensure_correlation
58
+ from .core.errors import ConfigurationError
59
+ from .core.serialization import JsonSerializer, Serializer
60
+ from .core.typing import JSONValue
61
+ from .errors import ExecutionNotFound, RoutingError
62
+ from .execution import Execution, ExecutionId, ExecutionKind, ExecutionResult
63
+ from .functions import FunctionRef, FunctionRegistry
64
+ from .handle import ExecutionHandle
65
+ from .hooks import Hook, HookChain
66
+ from .plugins import build_backend
67
+ from .ports import BaseBackend, ExecutionBackend
68
+ from .retry import NO_RETRY, NO_TIMEOUT, RetryPolicy, TimeoutPolicy
69
+ from .router import Router
70
+ from .specs import AnySpec, BackendOptions, InlineSpec, JobSpec, Resources, TaskSpec
71
+
72
+
73
+ class Taskferry:
74
+ """The portable execution layer.
75
+
76
+ Args:
77
+ config: Where backends, routes and defaults come from. Defaults to
78
+ :meth:`TaskferryConfig.from_env`, so a Twelve-Factor deployment needs
79
+ no code at all.
80
+ backends: Pre-built backend instances, by name. Takes precedence over
81
+ ``config.backends`` — this is how tests inject fakes and how an
82
+ application that builds its own clients hands them over.
83
+ router: Overrides the router derived from ``config``.
84
+ serializer: Payload serializer handed to backends that need one.
85
+ registry: Function registry used to resolve task names.
86
+ hooks: Lifecycle observers. See :mod:`taskferry.hooks`.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ *,
92
+ config: TaskferryConfig | None = None,
93
+ backends: Mapping[str, ExecutionBackend] | None = None,
94
+ router: Router | None = None,
95
+ serializer: Serializer | None = None,
96
+ registry: FunctionRegistry | None = None,
97
+ hooks: Iterable[Hook] = (),
98
+ ) -> None:
99
+ self._config = config if config is not None else TaskferryConfig.from_env()
100
+ self._router = router if router is not None else self._config.router()
101
+ self._serializer = serializer if serializer is not None else JsonSerializer()
102
+ self._registry = (
103
+ registry
104
+ if registry is not None
105
+ else FunctionRegistry(
106
+ allow_import=self._config.allow_import,
107
+ allowed_modules=self._config.allowed_modules,
108
+ )
109
+ )
110
+ self._hooks = HookChain(hooks)
111
+ self._instances: dict[str, ExecutionBackend] = dict(backends or {})
112
+ self._explicit: frozenset[str] = frozenset(self._instances)
113
+ self._lock = threading.RLock()
114
+ # Bounded id -> backend index so get()/cancel() can find the owner
115
+ # without the caller having to remember it.
116
+ self._index: OrderedDict[str, str] = OrderedDict()
117
+
118
+ for name in self._instances:
119
+ self._attach_hooks(self._instances[name])
120
+
121
+ self.inline = InlineFacade(self)
122
+ self.tasks = TaskFacade(self)
123
+ self.jobs = JobFacade(self)
124
+
125
+ # -- constructors ------------------------------------------------------- #
126
+ @classmethod
127
+ def local(cls, **kwargs: Any) -> Taskferry:
128
+ """A runtime that needs no infrastructure whatsoever.
129
+
130
+ Inline runs here, tasks run on a thread pool, jobs run as subprocesses::
131
+
132
+ runtime = Taskferry.local()
133
+ assert runtime.inline.submit(add, 20, 22).result().value == 42
134
+
135
+ No Django, no PostgreSQL, no Redis, no cloud, no worker process.
136
+ """
137
+ kwargs.setdefault("config", TaskferryConfig.local())
138
+ return cls(**kwargs)
139
+
140
+ @classmethod
141
+ def from_env(cls, environ: Mapping[str, str] | None = None, **kwargs: Any) -> Taskferry:
142
+ """Build from ``TASKFERRY_*`` env vars. See :meth:`TaskferryConfig.from_env`."""
143
+ kwargs.setdefault("config", TaskferryConfig.from_env(environ))
144
+ return cls(**kwargs)
145
+
146
+ @classmethod
147
+ def from_mapping(cls, data: Mapping[str, Any], **kwargs: Any) -> Taskferry:
148
+ """Build from a plain mapping (TOML, YAML, Django settings, a literal dict)."""
149
+ kwargs.setdefault("config", TaskferryConfig.from_mapping(data))
150
+ return cls(**kwargs)
151
+
152
+ # -- introspection ------------------------------------------------------ #
153
+ @property
154
+ def config(self) -> TaskferryConfig:
155
+ return self._config
156
+
157
+ @property
158
+ def router(self) -> Router:
159
+ return self._router
160
+
161
+ @property
162
+ def registry(self) -> FunctionRegistry:
163
+ """Where task names are resolved. Register callables here for local runs."""
164
+ return self._registry
165
+
166
+ @property
167
+ def serializer(self) -> Serializer:
168
+ return self._serializer
169
+
170
+ @property
171
+ def hooks(self) -> HookChain:
172
+ return self._hooks
173
+
174
+ def backend_names(self) -> tuple[str, ...]:
175
+ """Every configured backend name, built or not."""
176
+ return tuple(sorted({*self._config.backends, *self._instances}))
177
+
178
+ def backend(self, name: str) -> ExecutionBackend:
179
+ """Return the named backend, building it on first use.
180
+
181
+ Raises:
182
+ ConfigurationError: if no backend is configured under ``name``.
183
+ """
184
+ with self._lock:
185
+ existing = self._instances.get(name)
186
+ if existing is not None:
187
+ return existing
188
+ definition: BackendConfig | None = self._config.backends.get(name)
189
+ if definition is None:
190
+ known = ", ".join(self.backend_names()) or "<none>"
191
+ raise ConfigurationError(
192
+ f"no backend configured under {name!r} (configured: {known})"
193
+ )
194
+ instance = build_backend(definition.factory, definition.options)
195
+ self._attach_hooks(instance)
196
+ self._instances[name] = instance
197
+ return instance
198
+
199
+ def capabilities(self, name: str) -> CapabilitySet:
200
+ """What the named backend can do. Builds it if necessary."""
201
+ return self.backend(name).capabilities
202
+
203
+ def describe(self) -> dict[str, Any]:
204
+ """A JSON-shaped summary of the runtime, used by the CLI and by ``doctor``.
205
+
206
+ Backends are *not* built to produce this — the point is to be able to
207
+ inspect a configuration in an environment where a provider SDK may be
208
+ missing.
209
+ """
210
+ return {
211
+ "backends": {
212
+ name: {
213
+ "factory": definition.factory,
214
+ "options": sorted(definition.options),
215
+ "built": name in self._instances,
216
+ }
217
+ for name, definition in sorted(self._config.backends.items())
218
+ }
219
+ | {
220
+ name: {"factory": "<injected>", "options": [], "built": True}
221
+ for name in sorted(self._explicit)
222
+ },
223
+ "routes": [route.describe() for route in self._router.routes],
224
+ "defaults": {
225
+ kind.value: backend for kind, backend in sorted(self._router.defaults.items())
226
+ },
227
+ }
228
+
229
+ # -- submission ---------------------------------------------------------- #
230
+ def submit(
231
+ self,
232
+ spec: AnySpec,
233
+ *,
234
+ backend: str | None = None,
235
+ idempotency_key: str | None = None,
236
+ ) -> ExecutionHandle:
237
+ """Route ``spec`` to a backend, submit it, and return a live handle.
238
+
239
+ Args:
240
+ spec: What to run.
241
+ backend: Bypass routing and use this backend by name. For a CLI flag
242
+ or a deliberate one-off; application code should route.
243
+ idempotency_key: Convenience for ``spec.evolve(idempotency_key=...)``.
244
+ Requires the target backend to advertise ``DEDUPLICATION``, and
245
+ is **not** an exactly-once promise — see ADR-0010.
246
+
247
+ Raises:
248
+ RoutingError: no backend matched and the kind has no default.
249
+ UnsupportedCapability: the chosen backend cannot honour the spec.
250
+ """
251
+ target, name, prepared = self._prepare(
252
+ spec, backend=backend, idempotency_key=idempotency_key
253
+ )
254
+ execution = target.submit(prepared)
255
+ self._track(execution.id, name)
256
+ return ExecutionHandle(execution, target)
257
+
258
+ def _prepare(
259
+ self,
260
+ spec: AnySpec,
261
+ *,
262
+ backend: str | None,
263
+ idempotency_key: str | None,
264
+ ) -> tuple[ExecutionBackend, str, AnySpec]:
265
+ """Route and validate, without submitting.
266
+
267
+ Shared verbatim by the sync runtime and by
268
+ :class:`~taskferry.aio.AsyncTaskferry`, so the two surfaces cannot drift on
269
+ routing, correlation or the kind check — the parts where a divergence
270
+ would be silent and expensive.
271
+ """
272
+ if idempotency_key is not None:
273
+ spec = spec.evolve(idempotency_key=idempotency_key)
274
+ if spec.correlation is None:
275
+ spec = spec.evolve(correlation=ensure_correlation())
276
+
277
+ name = backend if backend is not None else self._router.resolve(spec)
278
+ target = self.backend(name)
279
+ if target.kind is not spec.kind:
280
+ raise RoutingError(
281
+ f"{name!r} is a {target.kind.value} backend but was asked to run a "
282
+ f"{spec.kind.value} spec ({spec.name!r}); check the routes for "
283
+ f"queue={spec.queue!r} profile={spec.profile!r}"
284
+ )
285
+ return target, name, spec
286
+
287
+ # -- lookup ------------------------------------------------------------- #
288
+ def get(
289
+ self, execution_id: ExecutionId | str, *, backend: str | None = None
290
+ ) -> ExecutionHandle:
291
+ """Return a handle for a previously submitted execution.
292
+
293
+ The owning backend is remembered from submission. Across a process
294
+ boundary that memory is gone, so pass ``backend=`` — Taskferry does not
295
+ broadcast a lookup to every configured engine, because polling a cloud
296
+ API for an id it has never seen is slow, costly, and misleading.
297
+
298
+ Raises:
299
+ ExecutionNotFound: when the owner is unknown or the backend has no
300
+ record of the id.
301
+ """
302
+ target = self._owner_backend(execution_id, backend)
303
+ execution = target.get(ExecutionId(str(execution_id)))
304
+ return ExecutionHandle(execution, target)
305
+
306
+ def _owner_backend(
307
+ self, execution_id: ExecutionId | str, backend: str | None
308
+ ) -> ExecutionBackend:
309
+ """The backend that owns ``execution_id``. Shared with the async surface."""
310
+ key = str(execution_id)
311
+ name = backend if backend is not None else self._owner_of(key)
312
+ if name is None:
313
+ raise ExecutionNotFound(
314
+ f"{key!r} was not submitted by this runtime; pass backend='<name>' to say "
315
+ f"which engine owns it (configured: {', '.join(self.backend_names()) or '<none>'})"
316
+ )
317
+ return self.backend(name)
318
+
319
+ def cancel(self, execution_id: ExecutionId | str, *, backend: str | None = None) -> Execution:
320
+ """Cancel an execution. Requires the owning backend to advertise ``CANCEL``."""
321
+ return self.get(execution_id, backend=backend).cancel()
322
+
323
+ def wait(
324
+ self,
325
+ execution_id: ExecutionId | str,
326
+ *,
327
+ timeout: float | None = None,
328
+ backend: str | None = None,
329
+ ) -> Execution:
330
+ """Block until an execution is terminal, then return the final snapshot."""
331
+ return self.get(execution_id, backend=backend).wait(timeout)
332
+
333
+ def result(
334
+ self,
335
+ execution_id: ExecutionId | str,
336
+ *,
337
+ timeout: float | None = None,
338
+ backend: str | None = None,
339
+ ) -> ExecutionResult:
340
+ """Return an execution's outcome, waiting up to ``timeout``."""
341
+ return self.get(execution_id, backend=backend).result(timeout)
342
+
343
+ # -- lifecycle ----------------------------------------------------------- #
344
+ def close(self) -> None:
345
+ """Release every backend's resources and forget them. Idempotent.
346
+
347
+ Injected backends are closed too: passing one in hands the runtime
348
+ ownership of it. Keep your own reference and skip ``close()`` if you need
349
+ a different lifetime. A closed runtime rebuilds configured backends on
350
+ the next submit; injected ones are gone, so it is not reusable.
351
+ """
352
+ with self._lock:
353
+ instances = list(self._instances.values())
354
+ self._instances.clear()
355
+ self._index.clear()
356
+ for instance in instances:
357
+ closer = getattr(instance, "close", None)
358
+ if callable(closer):
359
+ closer()
360
+
361
+ def __enter__(self) -> Self:
362
+ return self
363
+
364
+ def __exit__(
365
+ self,
366
+ exc_type: type[BaseException] | None,
367
+ exc: BaseException | None,
368
+ tb: TracebackType | None,
369
+ ) -> None:
370
+ self.close()
371
+
372
+ def __repr__(self) -> str:
373
+ return f"<Taskferry backends={list(self.backend_names())}>"
374
+
375
+ # -- internals ----------------------------------------------------------- #
376
+ def _attach_hooks(self, backend: ExecutionBackend) -> None:
377
+ """Give a backend the runtime's hook chain, if it can hold one."""
378
+ if isinstance(backend, BaseBackend):
379
+ backend.hooks = self._hooks
380
+
381
+ def _track(self, execution_id: ExecutionId, backend: str) -> None:
382
+ limit = self._config.max_tracked_executions
383
+ if limit == 0:
384
+ return
385
+ with self._lock:
386
+ self._index[str(execution_id)] = backend
387
+ self._index.move_to_end(str(execution_id))
388
+ while len(self._index) > limit:
389
+ self._index.popitem(last=False)
390
+
391
+ def _owner_of(self, execution_id: str) -> str | None:
392
+ with self._lock:
393
+ return self._index.get(execution_id)
394
+
395
+
396
+ class _Facade:
397
+ """Shared plumbing for the three primitive facades."""
398
+
399
+ __slots__ = ("_runtime",)
400
+
401
+ def __init__(self, runtime: Taskferry) -> None:
402
+ self._runtime = runtime
403
+
404
+ @property
405
+ def backend_name(self) -> str | None:
406
+ """The default backend for this kind, or ``None`` when unset."""
407
+ return self._runtime.router.defaults.get(self._kind)
408
+
409
+ @property
410
+ def _kind(self) -> ExecutionKind: # pragma: no cover - overridden
411
+ raise NotImplementedError
412
+
413
+ def submit_spec(self, spec: AnySpec, *, backend: str | None = None) -> ExecutionHandle:
414
+ """Submit an already-built spec. The escape hatch from the shorthand."""
415
+ return self._runtime.submit(spec, backend=backend)
416
+
417
+
418
+ class InlineFacade(_Facade):
419
+ """``runtime.inline`` — run a callable right now, in this process."""
420
+
421
+ __slots__ = ()
422
+
423
+ @property
424
+ def _kind(self) -> ExecutionKind:
425
+ return ExecutionKind.INLINE
426
+
427
+ def submit(
428
+ self,
429
+ func: Callable[..., Any],
430
+ /,
431
+ *args: Any,
432
+ backend: str | None = None,
433
+ name: str = "",
434
+ queue: str = "default",
435
+ retry: RetryPolicy = NO_RETRY,
436
+ labels: Mapping[str, str] | None = None,
437
+ correlation: Correlation | None = None,
438
+ **kwargs: Any,
439
+ ) -> ExecutionHandle:
440
+ """Run ``func(*args, **kwargs)`` immediately and return a finished handle.
441
+
442
+ The one primitive that takes a live callable: nothing crosses a process
443
+ boundary, so there is nothing to serialize and no importable name to
444
+ demand. Lambdas, closures and notebook functions all work.
445
+
446
+ ``async def`` callables are awaited. Arguments are passed through
447
+ untouched — no JSON validation — because they are not going anywhere.
448
+ """
449
+ spec = InlineSpec(
450
+ func=func,
451
+ args=args,
452
+ kwargs=kwargs,
453
+ name=name,
454
+ queue=queue,
455
+ retry=retry,
456
+ labels=labels or {},
457
+ correlation=correlation,
458
+ )
459
+ return self._runtime.submit(spec, backend=backend)
460
+
461
+ def run(self, func: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
462
+ """Run ``func`` and return its value directly, raising on failure.
463
+
464
+ Sugar over ``submit(...).result().value`` for scripts and tests.
465
+ """
466
+ return self.submit(func, *args, **kwargs).result().value
467
+
468
+
469
+ class TaskFacade(_Facade):
470
+ """``runtime.tasks`` — hand a named function to a task engine."""
471
+
472
+ __slots__ = ()
473
+
474
+ @property
475
+ def _kind(self) -> ExecutionKind:
476
+ return ExecutionKind.TASK
477
+
478
+ def submit(
479
+ self,
480
+ task: str | Callable[..., Any] | FunctionRef,
481
+ /,
482
+ *args: JSONValue,
483
+ backend: str | None = None,
484
+ queue: str = "default",
485
+ priority: int = 0,
486
+ delay: timedelta | float | None = None,
487
+ run_at: datetime | None = None,
488
+ retry: RetryPolicy = NO_RETRY,
489
+ timeout: TimeoutPolicy = NO_TIMEOUT,
490
+ idempotency_key: str | None = None,
491
+ labels: Mapping[str, str] | None = None,
492
+ backend_options: BackendOptions | Mapping[str, Any] | None = None,
493
+ correlation: Correlation | None = None,
494
+ **kwargs: JSONValue,
495
+ ) -> ExecutionHandle:
496
+ """Enqueue ``task`` for background execution.
497
+
498
+ ``task`` may be the portable ``"package.module:function"`` string, or the
499
+ callable itself — in which case its importable name is derived and it is
500
+ registered locally, so the same code works whether the engine runs it
501
+ here or on a worker three availability zones away. Lambdas and closures
502
+ are rejected: a worker could never find them.
503
+
504
+ Arguments must be JSON-shaped; they cross a process boundary. Pass an id,
505
+ not an ORM instance.
506
+ """
507
+ ref = self._runtime.registry.reference(task)
508
+ spec = TaskSpec(
509
+ task=ref.path,
510
+ args=args,
511
+ kwargs=kwargs,
512
+ queue=queue,
513
+ priority=priority,
514
+ delay=_as_timedelta(delay),
515
+ run_at=run_at,
516
+ retry=retry,
517
+ timeout=timeout,
518
+ idempotency_key=idempotency_key,
519
+ labels=labels or {},
520
+ backend_options=_as_backend_options(backend_options),
521
+ correlation=correlation,
522
+ )
523
+ return self._runtime.submit(spec, backend=backend)
524
+
525
+
526
+ class JobFacade(_Facade):
527
+ """``runtime.jobs`` — run an isolated workload to completion."""
528
+
529
+ __slots__ = ()
530
+
531
+ @property
532
+ def _kind(self) -> ExecutionKind:
533
+ return ExecutionKind.JOB
534
+
535
+ def submit(
536
+ self,
537
+ job: str,
538
+ /,
539
+ *,
540
+ backend: str | None = None,
541
+ image: str | None = None,
542
+ command: Sequence[str] = (),
543
+ args: Sequence[str] = (),
544
+ env: Mapping[str, str] | None = None,
545
+ resources: Resources | None = None,
546
+ profile: str = "default",
547
+ parallelism: int = 1,
548
+ working_dir: str | None = None,
549
+ retry: RetryPolicy = NO_RETRY,
550
+ timeout: TimeoutPolicy | float | None = None,
551
+ idempotency_key: str | None = None,
552
+ labels: Mapping[str, str] | None = None,
553
+ backend_options: BackendOptions | Mapping[str, Any] | None = None,
554
+ correlation: Correlation | None = None,
555
+ ) -> ExecutionHandle:
556
+ """Submit an isolated workload — a container or a process.
557
+
558
+ ``profile`` is the routing key for jobs the way ``queue`` is for tasks:
559
+ ``profile="gpu"`` says what the work needs, and the deployment's routes
560
+ decide whether that means a Kubernetes GPU node pool or a local process.
561
+ """
562
+ spec = JobSpec(
563
+ job=job,
564
+ image=image,
565
+ command=command,
566
+ args=args,
567
+ env=env or {},
568
+ resources=resources if resources is not None else Resources(),
569
+ profile=profile,
570
+ parallelism=parallelism,
571
+ working_dir=working_dir,
572
+ retry=retry,
573
+ timeout=_as_timeout(timeout),
574
+ idempotency_key=idempotency_key,
575
+ labels=labels or {},
576
+ backend_options=_as_backend_options(backend_options),
577
+ correlation=correlation,
578
+ )
579
+ return self._runtime.submit(spec, backend=backend)
580
+
581
+
582
+ def _as_timedelta(value: timedelta | float | None) -> timedelta | None:
583
+ if value is None or isinstance(value, timedelta):
584
+ return value
585
+ return timedelta(seconds=float(value))
586
+
587
+
588
+ def _as_timeout(value: TimeoutPolicy | float | None) -> TimeoutPolicy:
589
+ if value is None:
590
+ return NO_TIMEOUT
591
+ if isinstance(value, TimeoutPolicy):
592
+ return value
593
+ return TimeoutPolicy(seconds=float(value))
594
+
595
+
596
+ def _as_backend_options(value: BackendOptions | Mapping[str, Any] | None) -> BackendOptions:
597
+ if value is None:
598
+ return BackendOptions()
599
+ if isinstance(value, BackendOptions):
600
+ return value
601
+ return BackendOptions(dict(value))
602
+
603
+
604
+ __all__ = [
605
+ "InlineFacade",
606
+ "JobFacade",
607
+ "TaskFacade",
608
+ "Taskferry",
609
+ ]