ttasks 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.
ttasks/_store.py ADDED
@@ -0,0 +1,174 @@
1
+ """Unified task and graph storage.
2
+
3
+ A :class:`Store` exposes two collections, :attr:`tasks` and :attr:`graphs`,
4
+ each a ``MutableMapping[str, T]`` keyed by the object's own immutable ID.
5
+ The in-memory implementation lives here; SQLite-backed storage lives in
6
+ ``ttasks._sqlite`` and is re-exported as :class:`ttasks.SQLiteStore`.
7
+
8
+ The store is the single seam between the runtime objects (``Task``,
9
+ ``TaskGraph``) and any durable backend. ``TaskExecutor`` writes to
10
+ ``store.tasks`` automatically on every lifecycle transition, so callers
11
+ do not have to wire event-bus subscribers for normal persistence.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterator, MutableMapping
17
+ from typing import Protocol, runtime_checkable
18
+
19
+ from ._graph import TaskGraph
20
+ from ._task import Task
21
+
22
+
23
+ class TaskCollection(Protocol):
24
+ """Mapping of task ID to :class:`Task` with a ``save(task)`` shortcut.
25
+
26
+ Implementations are real :class:`collections.abc.MutableMapping` subclasses;
27
+ this Protocol describes the structural surface callers should rely on.
28
+ """
29
+
30
+ def save(self, task: Task) -> None:
31
+ """Persist ``task`` under its own ID."""
32
+
33
+ def __getitem__(self, task_id: str) -> Task: ...
34
+ def __setitem__(self, task_id: str, task: Task) -> None: ...
35
+ def __delitem__(self, task_id: str) -> None: ...
36
+ def __iter__(self) -> Iterator[str]: ...
37
+ def __len__(self) -> int: ...
38
+ def __contains__(self, key: object) -> bool: ...
39
+
40
+
41
+ class GraphCollection(Protocol):
42
+ """Mapping of graph ID to :class:`TaskGraph` with a ``save(graph)`` shortcut."""
43
+
44
+ def save(self, graph: TaskGraph) -> None:
45
+ """Persist ``graph`` under its own ID."""
46
+
47
+ def __getitem__(self, graph_id: str) -> TaskGraph: ...
48
+ def __setitem__(self, graph_id: str, graph: TaskGraph) -> None: ...
49
+ def __delitem__(self, graph_id: str) -> None: ...
50
+ def __iter__(self) -> Iterator[str]: ...
51
+ def __len__(self) -> int: ...
52
+ def __contains__(self, key: object) -> bool: ...
53
+
54
+
55
+ @runtime_checkable
56
+ class Store(Protocol):
57
+ """A unified store exposing :attr:`tasks` and :attr:`graphs` collections."""
58
+
59
+ @property
60
+ def tasks(self) -> TaskCollection:
61
+ """Return the task collection."""
62
+
63
+ @property
64
+ def graphs(self) -> GraphCollection:
65
+ """Return the graph collection."""
66
+
67
+
68
+ class InMemoryTaskCollection(MutableMapping[str, Task]):
69
+ """Dict-backed :class:`TaskCollection` that holds live task references."""
70
+
71
+ def __init__(self) -> None:
72
+ """Create an empty task collection."""
73
+ self._tasks: dict[str, Task] = {}
74
+
75
+ def save(self, task: Task) -> None:
76
+ """Persist ``task`` under its own ID."""
77
+ self[task.id] = task
78
+
79
+ def __setitem__(self, task_id: str, task: Task) -> None:
80
+ """Store ``task`` under its own ID."""
81
+ if not isinstance(task, Task):
82
+ raise TypeError(f"Expected Task, got {type(task).__name__}")
83
+ if task_id != task.id:
84
+ raise ValueError("task_id must match task.id")
85
+ self._tasks[task_id] = task
86
+
87
+ def __getitem__(self, task_id: str) -> Task:
88
+ """Return the task for ``task_id`` or raise ``KeyError``."""
89
+ return self._tasks[task_id]
90
+
91
+ def __delitem__(self, task_id: str) -> None:
92
+ """Remove the task identified by ``task_id``."""
93
+ del self._tasks[task_id]
94
+
95
+ def __iter__(self) -> Iterator[str]:
96
+ """Iterate over task IDs in insertion order."""
97
+ return iter(self._tasks)
98
+
99
+ def __len__(self) -> int:
100
+ """Return the number of stored tasks."""
101
+ return len(self._tasks)
102
+
103
+ def __contains__(self, key: object) -> bool:
104
+ """Return whether ``key`` (task or task id) is present."""
105
+ if isinstance(key, Task):
106
+ return key.id in self._tasks
107
+ return key in self._tasks
108
+
109
+ def __repr__(self) -> str:
110
+ """Return a concise representation with the stored-task count."""
111
+ return f"InMemoryTaskCollection({len(self._tasks)} tasks)"
112
+
113
+ def cancel(self, task_id: str) -> None:
114
+ """Cancel a task in place, keeping it in the collection."""
115
+ self._tasks[task_id].cancel()
116
+
117
+
118
+ class InMemoryGraphCollection(MutableMapping[str, "TaskGraph"]):
119
+ """Dict-backed :class:`GraphCollection` that holds live graph references."""
120
+
121
+ def __init__(self) -> None:
122
+ """Create an empty graph collection."""
123
+ self._graphs: dict[str, TaskGraph] = {}
124
+
125
+ def save(self, graph: TaskGraph) -> None:
126
+ """Persist ``graph`` under its own ID."""
127
+ self[graph.id] = graph
128
+
129
+ def __setitem__(self, graph_id: str, graph: TaskGraph) -> None:
130
+ """Store ``graph`` under its own ID."""
131
+ if not isinstance(graph, TaskGraph):
132
+ raise TypeError(f"Expected TaskGraph, got {type(graph).__name__}")
133
+ if graph_id != graph.id:
134
+ raise ValueError("graph_id must match graph.id")
135
+ self._graphs[graph_id] = graph
136
+
137
+ def __getitem__(self, graph_id: str) -> TaskGraph:
138
+ """Return the graph for ``graph_id`` or raise ``KeyError``."""
139
+ return self._graphs[graph_id]
140
+
141
+ def __delitem__(self, graph_id: str) -> None:
142
+ """Remove the graph identified by ``graph_id``."""
143
+ del self._graphs[graph_id]
144
+
145
+ def __iter__(self) -> Iterator[str]:
146
+ """Iterate over graph IDs in insertion order."""
147
+ return iter(self._graphs)
148
+
149
+ def __len__(self) -> int:
150
+ """Return the number of stored graphs."""
151
+ return len(self._graphs)
152
+
153
+ def __contains__(self, key: object) -> bool:
154
+ """Return whether ``key`` (graph or graph id) is present."""
155
+ if isinstance(key, TaskGraph):
156
+ return key.id in self._graphs
157
+ return key in self._graphs
158
+
159
+ def __repr__(self) -> str:
160
+ """Return a concise representation with the stored-graph count."""
161
+ return f"InMemoryGraphCollection({len(self._graphs)} graphs)"
162
+
163
+
164
+ class InMemoryStore:
165
+ """Live-reference :class:`Store` backed by in-memory dictionaries."""
166
+
167
+ def __init__(self) -> None:
168
+ """Create empty task and graph collections."""
169
+ self.tasks = InMemoryTaskCollection()
170
+ self.graphs = InMemoryGraphCollection()
171
+
172
+ def __repr__(self) -> str:
173
+ """Return a concise representation with task and graph counts."""
174
+ return f"InMemoryStore({len(self.tasks)} tasks, {len(self.graphs)} graphs)"
ttasks/_task.py ADDED
@@ -0,0 +1,398 @@
1
+ """Task domain model and state-machine rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from enum import Enum
10
+ from typing import Any, Literal, cast
11
+
12
+
13
+ class TaskStatus(Enum):
14
+ """Lifecycle states a task can move through."""
15
+
16
+ PENDING = "pending"
17
+ RUNNING = "running"
18
+ SUCCEEDED = "succeeded"
19
+ FAILED = "failed"
20
+ CANCELLED = "cancelled"
21
+ BLOCKED = "blocked"
22
+
23
+ @property
24
+ def is_sink(self) -> bool:
25
+ """No outgoing transitions in the SM (SUCCEEDED, CANCELLED).
26
+
27
+ Drift-guarded by a test that compares this set to
28
+ ``_ALLOWED_TRANSITIONS``. The scheduler uses this to identify
29
+ statuses it must never retry; ``executor.cancel`` uses it as
30
+ the boundary past which cancellation is a no-op.
31
+ """
32
+ return self in {TaskStatus.SUCCEEDED, TaskStatus.CANCELLED}
33
+
34
+ @property
35
+ def is_bad(self) -> bool:
36
+ """An upstream parent in this state blocks ready descendants."""
37
+ return self in {TaskStatus.FAILED, TaskStatus.CANCELLED, TaskStatus.BLOCKED}
38
+
39
+ @property
40
+ def is_active(self) -> bool:
41
+ """Task may still progress within the current run without intervention."""
42
+ return self in {TaskStatus.PENDING, TaskStatus.RUNNING}
43
+
44
+
45
+ class TaskType(Enum):
46
+ """Kinds of work the executor can dispatch to handlers."""
47
+
48
+ BASH = "bash"
49
+ POWERSHELL = "powershell"
50
+ PROMPT = "prompt"
51
+ AGENT = "agent"
52
+
53
+
54
+ # Centralized state-machine definition. All task status changes should flow
55
+ # through Task.transition_to() so these rules are enforced consistently.
56
+ _ALLOWED_TRANSITIONS = {
57
+ TaskStatus.PENDING: {
58
+ TaskStatus.RUNNING,
59
+ TaskStatus.CANCELLED,
60
+ TaskStatus.BLOCKED,
61
+ TaskStatus.FAILED,
62
+ },
63
+ TaskStatus.RUNNING: {TaskStatus.SUCCEEDED, TaskStatus.FAILED, TaskStatus.CANCELLED},
64
+ TaskStatus.FAILED: {TaskStatus.RUNNING, TaskStatus.CANCELLED},
65
+ TaskStatus.BLOCKED: {TaskStatus.RUNNING, TaskStatus.CANCELLED},
66
+ TaskStatus.SUCCEEDED: set(),
67
+ TaskStatus.CANCELLED: set(),
68
+ }
69
+
70
+
71
+ @dataclass(eq=False)
72
+ class Task:
73
+ """A unit of work tracked by the ledger and executed by TaskExecutor.
74
+
75
+ status is intentionally exposed as a read-only property. Use transition_to()
76
+ or cancel() to mutate it so invalid state transitions cannot be bypassed.
77
+ Once a task reaches SUCCEEDED, normal public attribute assignment is rejected so
78
+ completed upstream tasks can be safely shared by reference.
79
+
80
+ timeout=None is intentional and means no automatic timeout is applied;
81
+ callers should set a positive timeout for bounded subprocess execution.
82
+ """
83
+
84
+ title: str
85
+ payload: str
86
+ type: TaskType
87
+ description: str = ""
88
+ error: str | None = None
89
+ timeout: float | None = None
90
+ _id: str = field(default_factory=lambda: str(uuid.uuid4()), repr=False)
91
+ created_at: datetime = field(default_factory=datetime.now)
92
+ _status: TaskStatus = field(default=TaskStatus.PENDING, init=False, repr=False)
93
+ # The most recent TaskResult attached to this task, set by TaskExecutor on
94
+ # every non-BLOCKED terminal path (SUCCEEDED, FAILED, CANCELLED). None until
95
+ # first run. BLOCKED tasks leave this as None — no handler ran.
96
+ _result: TaskResult | None = field(default=None, init=False, repr=False)
97
+ # The id of the direct upstream parent whose state (FAILED/CANCELLED/
98
+ # BLOCKED) caused this task to be marked BLOCKED. None unless blocked.
99
+ _blocked_by: str | None = field(default=None, init=False, repr=False)
100
+
101
+ def __setattr__(self, name: str, value: object) -> None:
102
+ """Enforce id immutability and the private-setter discipline.
103
+
104
+ ``_id`` is immutable once set so the identity used by
105
+ ``__hash__`` / ``__eq__`` is stable. ``result`` and
106
+ ``blocked_by`` are read-only properties backed by private
107
+ fields — callers must use the executor-internal
108
+ ``_set_result`` / ``_set_blocked_by`` helpers. SUCCEEDED tasks
109
+ reject all remaining public writes so completed upstream tasks
110
+ can be safely shared by reference.
111
+ """
112
+ if name == "_id" and "_id" in self.__dict__:
113
+ raise AttributeError("Task._id is immutable")
114
+ if name in {"result", "blocked_by"}:
115
+ raise AttributeError(
116
+ f"Task.{name} is read-only; use _set_{name}() to mutate"
117
+ )
118
+ if (
119
+ not name.startswith("_")
120
+ and getattr(self, "_status", None) == TaskStatus.SUCCEEDED
121
+ ):
122
+ raise AttributeError("SUCCEEDED tasks are immutable")
123
+ super().__setattr__(name, value)
124
+
125
+ def __eq__(self, other: object) -> bool:
126
+ """Tasks are equal iff they share an id (identity-by-id)."""
127
+ if not isinstance(other, Task):
128
+ return NotImplemented
129
+ return self._id == other._id
130
+
131
+ def __hash__(self) -> int:
132
+ """Hash by id so tasks work as set / dict keys."""
133
+ return hash(self._id)
134
+
135
+ def __post_init__(self) -> None:
136
+ """Validate task configuration after dataclass initialization."""
137
+ if not isinstance(self.type, TaskType):
138
+ raise TypeError("type must be a TaskType")
139
+ if self.timeout is not None and self.timeout <= 0:
140
+ raise ValueError("timeout must be greater than 0")
141
+
142
+ @property
143
+ def id(self) -> str:
144
+ """Return the immutable task identity."""
145
+ return self._id
146
+
147
+ @property
148
+ def status(self) -> TaskStatus:
149
+ """Return the current lifecycle state without allowing direct writes."""
150
+ return self._status
151
+
152
+ @property
153
+ def is_pending(self) -> bool:
154
+ """Return whether the task is in the PENDING state."""
155
+ return self._status == TaskStatus.PENDING
156
+
157
+ @property
158
+ def is_running(self) -> bool:
159
+ """Return whether the task is in the RUNNING state."""
160
+ return self._status == TaskStatus.RUNNING
161
+
162
+ @property
163
+ def is_succeeded(self) -> bool:
164
+ """Return whether the task has completed successfully."""
165
+ return self._status == TaskStatus.SUCCEEDED
166
+
167
+ @property
168
+ def is_failed(self) -> bool:
169
+ """Return whether the task is in the FAILED state."""
170
+ return self._status == TaskStatus.FAILED
171
+
172
+ @property
173
+ def is_cancelled(self) -> bool:
174
+ """Return whether the task is in the CANCELLED state."""
175
+ return self._status == TaskStatus.CANCELLED
176
+
177
+ @property
178
+ def is_terminal(self) -> bool:
179
+ """Return whether the task is in a terminal state.
180
+
181
+ Terminal states: SUCCEEDED, FAILED, CANCELLED, BLOCKED.
182
+ """
183
+ return self._status in {
184
+ TaskStatus.SUCCEEDED,
185
+ TaskStatus.FAILED,
186
+ TaskStatus.CANCELLED,
187
+ TaskStatus.BLOCKED,
188
+ }
189
+
190
+ @property
191
+ def is_blocked(self) -> bool:
192
+ """Return whether the task is in the BLOCKED state."""
193
+ return self._status == TaskStatus.BLOCKED
194
+
195
+ @property
196
+ def result(self) -> TaskResult | None:
197
+ """Return the latest run's TaskResult, if any."""
198
+ return self._result
199
+
200
+ def _set_result(self, result: TaskResult | None) -> None:
201
+ """Attach ``result`` (executor-internal seam)."""
202
+ object.__setattr__(self, "_result", result)
203
+
204
+ @property
205
+ def blocked_by(self) -> str | None:
206
+ """ID of the direct upstream parent that triggered the block, if any."""
207
+ return self._blocked_by
208
+
209
+ def _set_blocked_by(self, parent_id: str | None) -> None:
210
+ """Attach the blocking parent id (executor-internal seam)."""
211
+ object.__setattr__(self, "_blocked_by", parent_id)
212
+
213
+ def can_transition_to(self, status: TaskStatus) -> bool:
214
+ """Return whether the task may move from its current state to status."""
215
+ if not isinstance(status, TaskStatus):
216
+ raise TypeError("status must be a TaskStatus")
217
+ return status in _ALLOWED_TRANSITIONS[self._status]
218
+
219
+ def transition_to(self, status: TaskStatus, error: str | None = None) -> None:
220
+ """Move the task to a new state if the state-machine allows it.
221
+
222
+ error is stored for failed transitions and cleared by successful ones
223
+ because the default value is None. Entering RUNNING also clears any
224
+ prior run's ``result`` and ``blocked_by`` so a retry starts clean.
225
+ """
226
+ if not isinstance(status, TaskStatus):
227
+ raise TypeError("status must be a TaskStatus")
228
+ if not self.can_transition_to(status):
229
+ message = (
230
+ f"Cannot transition task from {self._status.value!r} "
231
+ f"to {status.value!r}"
232
+ )
233
+ raise ValueError(message)
234
+
235
+ self.error = error
236
+ self._status = status
237
+ if status == TaskStatus.RUNNING:
238
+ object.__setattr__(self, "_result", None)
239
+ object.__setattr__(self, "_blocked_by", None)
240
+
241
+ def cancel(self) -> None:
242
+ """Cancel the task without discarding any existing error detail.
243
+
244
+ Cancellation is intentionally idempotent so duplicate user/API requests
245
+ are harmless, while transition_to(CANCELLED) remains strict.
246
+ """
247
+ if self.status == TaskStatus.CANCELLED:
248
+ return
249
+
250
+ self.transition_to(TaskStatus.CANCELLED, error=self.error)
251
+
252
+ def __repr__(self):
253
+ """Return a concise representation focused on identity and status."""
254
+ return f"Task(id={self.id!r}, title={self.title!r}, status={self.status.value})"
255
+
256
+ @classmethod
257
+ def _make(
258
+ cls,
259
+ task_type: TaskType,
260
+ payload: str,
261
+ *,
262
+ title: str,
263
+ description: str,
264
+ timeout: float | None,
265
+ ) -> Task:
266
+ """Construct a task of ``task_type`` from the shared factory kwargs."""
267
+ return cls(
268
+ title=title,
269
+ payload=payload,
270
+ type=task_type,
271
+ description=description,
272
+ timeout=timeout,
273
+ )
274
+
275
+ @classmethod
276
+ def bash(
277
+ cls,
278
+ payload: str,
279
+ *,
280
+ title: str = "",
281
+ description: str = "",
282
+ timeout: float | None = None,
283
+ ) -> Task:
284
+ """Construct a BASH task without requiring callers to import TaskType."""
285
+ return cls._make(
286
+ TaskType.BASH, payload,
287
+ title=title, description=description, timeout=timeout,
288
+ )
289
+
290
+ @classmethod
291
+ def powershell(
292
+ cls,
293
+ payload: str,
294
+ *,
295
+ title: str = "",
296
+ description: str = "",
297
+ timeout: float | None = None,
298
+ ) -> Task:
299
+ """Construct a POWERSHELL task without requiring callers to import TaskType."""
300
+ return cls._make(
301
+ TaskType.POWERSHELL, payload,
302
+ title=title, description=description, timeout=timeout,
303
+ )
304
+
305
+ @classmethod
306
+ def prompt(
307
+ cls,
308
+ payload: str,
309
+ *,
310
+ title: str = "",
311
+ description: str = "",
312
+ timeout: float | None = None,
313
+ ) -> Task:
314
+ """Construct a PROMPT task without requiring callers to import TaskType."""
315
+ return cls._make(
316
+ TaskType.PROMPT, payload,
317
+ title=title, description=description, timeout=timeout,
318
+ )
319
+
320
+ @classmethod
321
+ def agent(
322
+ cls,
323
+ payload: str,
324
+ *,
325
+ title: str = "",
326
+ description: str = "",
327
+ timeout: float | None = None,
328
+ ) -> Task:
329
+ """Construct an AGENT task without requiring callers to import TaskType."""
330
+ return cls._make(
331
+ TaskType.AGENT, payload,
332
+ title=title, description=description, timeout=timeout,
333
+ )
334
+
335
+
336
+ TerminationReason = Literal["exit_code", "timeout", "cancelled", "handler"]
337
+
338
+
339
+ @dataclass(frozen=True)
340
+ class TaskResult:
341
+ """Normalized record of a single task execution.
342
+
343
+ Attached to Task.result by TaskExecutor on every terminal path so the
344
+ Task itself is the canonical post-run view. Frozen so a completed run
345
+ record cannot be mutated after the fact.
346
+
347
+ ``termination_reason`` distinguishes the cause of every terminal
348
+ transition: ``None`` means SUCCEEDED; ``"exit_code"`` means a
349
+ subprocess exited non-zero; ``"timeout"`` means the wall-clock budget
350
+ was exceeded and SIGTERM/SIGKILL fired; ``"cancelled"`` means a
351
+ cooperative cancel signal was honored; ``"handler"`` means the
352
+ handler raised an unstructured exception.
353
+ """
354
+
355
+ task_id: str
356
+ status: TaskStatus
357
+ started_at: datetime
358
+ finished_at: datetime
359
+ duration: float
360
+ output: str = ""
361
+ error: str | None = None
362
+ returncode: int | None = None
363
+ raw: object | None = None
364
+ termination_reason: TerminationReason | None = None
365
+
366
+ @classmethod
367
+ def from_raw(
368
+ cls,
369
+ task: Task,
370
+ raw: object,
371
+ *,
372
+ status: TaskStatus,
373
+ started_at: datetime,
374
+ finished_at: datetime,
375
+ duration: float,
376
+ ) -> TaskResult:
377
+ """Normalize a handler return value into a timed TaskResult."""
378
+ base: dict[str, Any] = dict(
379
+ task_id=task.id,
380
+ status=status,
381
+ started_at=started_at,
382
+ finished_at=finished_at,
383
+ duration=duration,
384
+ )
385
+ if isinstance(raw, subprocess.CompletedProcess):
386
+ completed = cast("subprocess.CompletedProcess[str]", raw)
387
+ return cls(
388
+ **base,
389
+ output=completed.stdout or "",
390
+ error=completed.stderr or None,
391
+ returncode=completed.returncode,
392
+ raw=completed,
393
+ )
394
+
395
+ if isinstance(raw, str):
396
+ return cls(**base, output=raw, raw=raw)
397
+
398
+ return cls(**base, raw=raw)
ttasks/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = None
ttasks/py.typed ADDED
File without changes