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
@@ -0,0 +1,390 @@
1
+ """A job backend that runs workloads as local child processes.
2
+
3
+ The zero-infrastructure `JobBackend`: what Cloud Run Jobs and Kubernetes Jobs do
4
+ remotely, this does with :mod:`subprocess`. Same `JobSpec`, same `Execution`,
5
+ same state machine — so the development loop and the production deployment
6
+ differ by one line of routing configuration.
7
+
8
+ ```mermaid
9
+ flowchart LR
10
+ SPEC["JobSpec<br/>command · env · timeout"]
11
+ B["SubprocessJobBackend"]
12
+ P["child process"]
13
+ LOG["log file"]
14
+
15
+ SPEC --> B --> P
16
+ P -->|"stdout + stderr"| LOG
17
+ B -->|"exit code"| SPEC
18
+ ```
19
+
20
+ Security and honesty
21
+ --------------------
22
+
23
+ The command is executed **without a shell** (``shell=False``, argv list), so a
24
+ job name or argument can never turn into shell metacharacters.
25
+
26
+ ``image`` is ignored — there is no container here — and the capability set says
27
+ so by omitting ``CPU``, ``MEMORY``, ``GPU`` and ``PARALLELISM``. A spec asking
28
+ for two GPUs is rejected at submit time instead of quietly running on the CPU and
29
+ producing results nobody can explain three days later.
30
+
31
+ Handles are process-local: the backend tracks children in memory, so an execution
32
+ id is only meaningful inside the process that created it.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import subprocess
39
+ import tempfile
40
+ import threading
41
+ import time
42
+ from collections.abc import Iterator
43
+ from dataclasses import dataclass
44
+ from datetime import UTC, datetime
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ from ..capabilities import Capability, CapabilitySet
49
+ from ..core.provider import ProviderMetadata
50
+ from ..errors import ExecutionNotFound, SubmissionError, TaskferryTimeoutError
51
+ from ..execution import (
52
+ Execution,
53
+ ExecutionId,
54
+ ExecutionKind,
55
+ ExecutionResult,
56
+ ExecutionState,
57
+ new_execution_id,
58
+ )
59
+ from ..ports import BaseBackend
60
+ from ..specs import ExecutionSpec, JobSpec
61
+
62
+ SUBPROCESS_CAPABILITIES = frozenset(
63
+ {
64
+ Capability.SUBMIT,
65
+ Capability.STATE,
66
+ Capability.RESULT,
67
+ Capability.CANCEL,
68
+ Capability.LOGS,
69
+ Capability.TIMEOUT,
70
+ Capability.ENVIRONMENT,
71
+ }
72
+ )
73
+
74
+ TERMINATE_GRACE_SECONDS = 5.0
75
+ """How long a terminated child gets to exit cleanly before it is killed."""
76
+
77
+
78
+ @dataclass
79
+ class _Record:
80
+ """Live bookkeeping for one child process."""
81
+
82
+ process: subprocess.Popen[bytes]
83
+ spec: JobSpec
84
+ log_path: Path
85
+ execution: Execution
86
+ cancelled: bool = False
87
+ timed_out: bool = False
88
+
89
+
90
+ class SubprocessJobBackend(BaseBackend):
91
+ """Runs :class:`~taskferry.specs.JobSpec` workloads as child processes.
92
+
93
+ Args:
94
+ log_dir: Where combined stdout/stderr is written. Defaults to the system
95
+ temporary directory.
96
+ inherit_env: Whether the child inherits the parent's environment before
97
+ ``spec.env`` is applied. Turn it off for a clean, reproducible
98
+ environment closer to what a container gets.
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ log_dir: str | os.PathLike[str] | None = None,
105
+ inherit_env: bool = True,
106
+ ) -> None:
107
+ self._log_dir = Path(log_dir) if log_dir is not None else Path(tempfile.gettempdir())
108
+ self._log_dir.mkdir(parents=True, exist_ok=True)
109
+ self._inherit_env = inherit_env
110
+ self._records: dict[str, _Record] = {}
111
+ self._lock = threading.Lock()
112
+
113
+ @property
114
+ def name(self) -> str:
115
+ return "subprocess"
116
+
117
+ @property
118
+ def kind(self) -> ExecutionKind:
119
+ return ExecutionKind.JOB
120
+
121
+ @property
122
+ def capabilities(self) -> CapabilitySet:
123
+ return CapabilitySet(SUBPROCESS_CAPABILITIES, provider=self.name)
124
+
125
+ @property
126
+ def log_dir(self) -> Path:
127
+ return self._log_dir
128
+
129
+ # -- submission ------------------------------------------------------------ #
130
+ def _submit(self, spec: ExecutionSpec) -> Execution:
131
+ assert isinstance(spec, JobSpec)
132
+ argv = list(spec.argv)
133
+ if not argv:
134
+ raise SubmissionError(
135
+ f"job {spec.job!r} has no command; the subprocess backend needs an argv "
136
+ "(image-only specs need a container runtime)",
137
+ backend=self.name,
138
+ )
139
+
140
+ execution_id = new_execution_id(ExecutionKind.JOB)
141
+ log_path = self._log_dir / f"{execution_id}.log"
142
+ env = {**os.environ, **spec.env} if self._inherit_env else dict(spec.env)
143
+ started = datetime.now(UTC)
144
+
145
+ log_file = log_path.open("wb")
146
+ try:
147
+ process = subprocess.Popen(
148
+ argv,
149
+ env=env,
150
+ cwd=spec.working_dir,
151
+ stdout=log_file,
152
+ stderr=subprocess.STDOUT,
153
+ close_fds=True,
154
+ )
155
+ except OSError as exc:
156
+ log_file.close()
157
+ raise SubmissionError(
158
+ f"could not start job {spec.job!r} ({argv[0]!r}): {exc}", backend=self.name
159
+ ) from exc
160
+ finally:
161
+ # The child holds its own descriptor; ours would only keep the file
162
+ # open past the child's exit.
163
+ if not log_file.closed:
164
+ log_file.close()
165
+
166
+ execution = Execution(
167
+ id=execution_id,
168
+ kind=ExecutionKind.JOB,
169
+ backend=self.name,
170
+ state=ExecutionState.RUNNING,
171
+ name=spec.name,
172
+ created_at=started,
173
+ started_at=started,
174
+ external_id=str(process.pid),
175
+ correlation=spec.correlation,
176
+ provider_metadata=ProviderMetadata(
177
+ provider="local",
178
+ provider_id=str(process.pid),
179
+ resource=str(log_path),
180
+ labels=dict(spec.labels),
181
+ ),
182
+ metadata={"log_path": str(log_path), "profile": spec.profile},
183
+ )
184
+ with self._lock:
185
+ self._records[str(execution_id)] = _Record(
186
+ process=process, spec=spec, log_path=log_path, execution=execution
187
+ )
188
+ return execution
189
+
190
+ # -- observation ------------------------------------------------------------ #
191
+ def _record(self, execution_id: ExecutionId) -> _Record:
192
+ with self._lock:
193
+ record = self._records.get(str(execution_id))
194
+ if record is None:
195
+ raise ExecutionNotFound(
196
+ f"job {execution_id!r} is unknown to this process", backend=self.name
197
+ )
198
+ return record
199
+
200
+ def _get(self, execution_id: ExecutionId) -> Execution:
201
+ record = self._record(execution_id)
202
+ with self._lock:
203
+ return self._refresh_locked(record)
204
+
205
+ def _refresh_locked(self, record: _Record) -> Execution:
206
+ """Recompute the execution from the child's real state. Caller holds the lock."""
207
+ if record.execution.is_terminal:
208
+ return record.execution
209
+
210
+ returncode = record.process.poll()
211
+ if returncode is None:
212
+ if self._exceeded_timeout(record):
213
+ self._stop(record, timed_out=True)
214
+ returncode = record.process.returncode
215
+ else:
216
+ return record.execution
217
+
218
+ record.execution = record.execution.evolve(
219
+ state=self._state_for(record, returncode),
220
+ finished_at=record.execution.finished_at or datetime.now(UTC),
221
+ result=self._result_for(record, returncode),
222
+ )
223
+ return record.execution
224
+
225
+ def _state_for(self, record: _Record, returncode: int | None) -> ExecutionState:
226
+ if record.timed_out:
227
+ return ExecutionState.TIMED_OUT
228
+ if record.cancelled:
229
+ return ExecutionState.CANCELLED
230
+ return ExecutionState.SUCCEEDED if returncode == 0 else ExecutionState.FAILED
231
+
232
+ def _result_for(self, record: _Record, returncode: int | None) -> ExecutionResult:
233
+ error: str | None = None
234
+ if record.timed_out:
235
+ error = f"timed out after {record.spec.timeout.seconds}s"
236
+ elif record.cancelled:
237
+ error = "cancelled"
238
+ elif returncode != 0:
239
+ error = f"exited with code {returncode}"
240
+ return ExecutionResult(
241
+ value=returncode,
242
+ error=error,
243
+ error_type="JobFailed" if error else None,
244
+ exit_code=returncode,
245
+ logs_uri=str(record.log_path),
246
+ )
247
+
248
+ def _exceeded_timeout(self, record: _Record) -> bool:
249
+ budget = record.spec.timeout.seconds
250
+ if budget is None:
251
+ return False
252
+ started = record.execution.started_at
253
+ if started is None: # pragma: no cover - always set at submit
254
+ return False
255
+ return (datetime.now(UTC) - started).total_seconds() > budget
256
+
257
+ def _wait(self, execution_id: ExecutionId, *, timeout: float | None) -> Execution:
258
+ record = self._record(execution_id)
259
+ deadline = None if timeout is None else time.monotonic() + timeout
260
+ while True:
261
+ with self._lock:
262
+ execution = self._refresh_locked(record)
263
+ if execution.is_terminal:
264
+ return execution
265
+ if deadline is not None and time.monotonic() >= deadline:
266
+ raise TaskferryTimeoutError(
267
+ f"job {execution_id!r} did not finish within {timeout}s"
268
+ )
269
+ time.sleep(0.02)
270
+
271
+ def _result(self, execution_id: ExecutionId, *, timeout: float | None) -> ExecutionResult:
272
+ execution = self._wait(execution_id, timeout=timeout)
273
+ assert execution.result is not None # every terminal state sets one
274
+ return execution.result
275
+
276
+ def _cancel(self, execution_id: ExecutionId) -> Execution:
277
+ record = self._record(execution_id)
278
+ with self._lock:
279
+ if record.execution.is_terminal:
280
+ return record.execution
281
+ if record.process.poll() is None:
282
+ self._stop(record, timed_out=False)
283
+ record.cancelled = True
284
+ return self._refresh_locked(record)
285
+
286
+ def _stop(self, record: _Record, *, timed_out: bool) -> None:
287
+ """Ask the child to exit, then insist. Caller holds the lock."""
288
+ record.timed_out = timed_out
289
+ record.process.terminate()
290
+ try:
291
+ record.process.wait(timeout=TERMINATE_GRACE_SECONDS)
292
+ except subprocess.TimeoutExpired:
293
+ record.process.kill()
294
+ record.process.wait()
295
+ record.execution = record.execution.evolve(finished_at=datetime.now(UTC))
296
+
297
+ def logs(self, execution_id: ExecutionId) -> str:
298
+ """Read the captured output of a job. Requires ``Capability.LOGS``."""
299
+ self.capabilities.require(Capability.LOGS)
300
+ record = self._record(execution_id)
301
+ try:
302
+ return record.log_path.read_text(encoding="utf-8", errors="replace")
303
+ except OSError:
304
+ return ""
305
+
306
+ def stream_logs(
307
+ self,
308
+ execution_id: ExecutionId,
309
+ *,
310
+ poll_interval: float = 0.1,
311
+ timeout: float | None = None,
312
+ ) -> Iterator[str]:
313
+ """Yield the job's output line by line *as it is produced*.
314
+
315
+ Where :meth:`logs` returns the output once, this follows the growing log
316
+ file — the way ``tail -f`` does — so a caller can watch a long-running job
317
+ live::
318
+
319
+ for line in backend.stream_logs(job_id):
320
+ print(line)
321
+
322
+ The iterator ends when the child reaches a terminal state and the last
323
+ buffered output has been drained, or after ``timeout`` seconds if given.
324
+ Lines are yielded without their trailing newline. Requires
325
+ ``Capability.LOGS``.
326
+
327
+ This is a genuine stream, not a poll-and-diff: a job backend that could
328
+ only report a log *location* (Cloud Run, AWS Batch) does not advertise it,
329
+ and callers reach for the provider's own log stream there.
330
+ """
331
+ self.capabilities.require(Capability.LOGS)
332
+ record = self._record(execution_id)
333
+ deadline = None if timeout is None else time.monotonic() + timeout
334
+ try:
335
+ reader = record.log_path.open("r", encoding="utf-8", errors="replace")
336
+ except OSError: # pragma: no cover - the file is created before the record
337
+ return
338
+ pending = ""
339
+ with reader:
340
+ while True:
341
+ chunk = reader.read()
342
+ if chunk:
343
+ pending += chunk
344
+ lines = pending.split("\n")
345
+ pending = lines.pop() # keep the trailing partial line
346
+ yield from lines
347
+ continue
348
+ with self._lock:
349
+ terminal = self._refresh_locked(record).is_terminal
350
+ if terminal:
351
+ pending += reader.read()
352
+ remaining = pending.split("\n")
353
+ if remaining and remaining[-1] == "":
354
+ remaining.pop() # no spurious empty line from a final newline
355
+ yield from remaining
356
+ return
357
+ if deadline is not None and time.monotonic() > deadline:
358
+ if pending:
359
+ yield pending
360
+ return
361
+ time.sleep(poll_interval)
362
+
363
+ def close(self) -> None:
364
+ """Terminate every child this backend still owns, then forget them."""
365
+ with self._lock:
366
+ records = list(self._records.values())
367
+ self._records.clear()
368
+ for record in records:
369
+ if record.process.poll() is None:
370
+ record.process.terminate()
371
+ try:
372
+ record.process.wait(timeout=TERMINATE_GRACE_SECONDS)
373
+ except subprocess.TimeoutExpired: # pragma: no cover - stubborn child
374
+ record.process.kill()
375
+
376
+
377
+ def make_subprocess_backend(**options: Any) -> SubprocessJobBackend:
378
+ """Factory for configuration (``{"factory": "subprocess", "log_dir": ...}``)."""
379
+ return SubprocessJobBackend(
380
+ log_dir=options.get("log_dir"),
381
+ inherit_env=bool(options.get("inherit_env", True)),
382
+ )
383
+
384
+
385
+ __all__ = [
386
+ "SUBPROCESS_CAPABILITIES",
387
+ "TERMINATE_GRACE_SECONDS",
388
+ "SubprocessJobBackend",
389
+ "make_subprocess_backend",
390
+ ]