functualize-tasks 0.1.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.
- functualize_tasks/__init__.py +41 -0
- functualize_tasks/_errors.py +7 -0
- functualize_tasks/_events.py +9 -0
- functualize_tasks/_metadata.py +37 -0
- functualize_tasks/_protocols.py +89 -0
- functualize_tasks/_tasks.py +250 -0
- functualize_tasks/_types.py +52 -0
- functualize_tasks/py.typed +0 -0
- functualize_tasks/testing/__init__.py +13 -0
- functualize_tasks/testing/_mock_tasks.py +168 -0
- functualize_tasks-0.1.0.dist-info/METADATA +109 -0
- functualize_tasks-0.1.0.dist-info/RECORD +14 -0
- functualize_tasks-0.1.0.dist-info/WHEEL +4 -0
- functualize_tasks-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""functualize-tasks — Tasks Domain SDK.
|
|
2
|
+
|
|
3
|
+
Provides the Tasks capability class, TaskProvider protocol, shared types,
|
|
4
|
+
errors, event constants, and testing doubles for task management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from functualize_tasks._errors import TaskNotFoundError
|
|
8
|
+
from functualize_tasks._events import (
|
|
9
|
+
TASKS_COMPLETED,
|
|
10
|
+
TASKS_CREATED,
|
|
11
|
+
TASKS_DELETED,
|
|
12
|
+
TASKS_UPDATED,
|
|
13
|
+
)
|
|
14
|
+
from functualize_tasks._metadata import domain_metadata
|
|
15
|
+
from functualize_tasks._protocols import TaskProvider
|
|
16
|
+
from functualize_tasks._tasks import Tasks
|
|
17
|
+
from functualize_tasks._types import TaskItem, TaskLink, TaskStatus
|
|
18
|
+
from functualize_tasks.testing._mock_tasks import MockTaskOperation, MockTasks
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
# Capability Class
|
|
22
|
+
"Tasks",
|
|
23
|
+
# Protocols
|
|
24
|
+
"TaskProvider",
|
|
25
|
+
# Types
|
|
26
|
+
"TaskItem",
|
|
27
|
+
"TaskLink",
|
|
28
|
+
"TaskStatus",
|
|
29
|
+
# Errors
|
|
30
|
+
"TaskNotFoundError",
|
|
31
|
+
# Event Constants
|
|
32
|
+
"TASKS_CREATED",
|
|
33
|
+
"TASKS_UPDATED",
|
|
34
|
+
"TASKS_COMPLETED",
|
|
35
|
+
"TASKS_DELETED",
|
|
36
|
+
# Testing Doubles
|
|
37
|
+
"MockTaskOperation",
|
|
38
|
+
"MockTasks",
|
|
39
|
+
# Metadata
|
|
40
|
+
"domain_metadata",
|
|
41
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Event name constants for the Tasks Domain SDK.
|
|
2
|
+
|
|
3
|
+
Events follow the {domain}.{resource}.{action} grammar.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
TASKS_CREATED: str = "tasks.task.created"
|
|
7
|
+
TASKS_UPDATED: str = "tasks.task.updated"
|
|
8
|
+
TASKS_COMPLETED: str = "tasks.task.completed"
|
|
9
|
+
TASKS_DELETED: str = "tasks.task.deleted"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Domain metadata for the Tasks SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class DomainMetadata:
|
|
10
|
+
"""Self-describing metadata for a domain SDK."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
display_name: str
|
|
14
|
+
description: str
|
|
15
|
+
capability_class: str
|
|
16
|
+
provider_protocol: str
|
|
17
|
+
config_section: str
|
|
18
|
+
entry_point_group: str
|
|
19
|
+
events_prefix: str
|
|
20
|
+
scaffold_template: str | None = None
|
|
21
|
+
documentation_url: str | None = None
|
|
22
|
+
mock_factory: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
domain_metadata = DomainMetadata(
|
|
26
|
+
name="tasks",
|
|
27
|
+
display_name="Tasks",
|
|
28
|
+
description="Task management and planning scratchpad",
|
|
29
|
+
capability_class="functualize_tasks.Tasks",
|
|
30
|
+
provider_protocol="functualize_tasks.TaskProvider",
|
|
31
|
+
config_section="tasks",
|
|
32
|
+
entry_point_group="functualize.tasks_providers",
|
|
33
|
+
events_prefix="tasks.",
|
|
34
|
+
scaffold_template=None,
|
|
35
|
+
documentation_url=None,
|
|
36
|
+
mock_factory="functualize_tasks.testing:MockTasks",
|
|
37
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Tasks domain protocol — TaskProvider.
|
|
2
|
+
|
|
3
|
+
Defines the protocol interface that task implementation plugins must satisfy.
|
|
4
|
+
The Tasks capability delegates all storage and retrieval operations to a
|
|
5
|
+
TaskProvider implementation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from functualize_tasks._types import TaskItem, TaskLink, TaskStatus
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class TaskProvider(Protocol):
|
|
18
|
+
"""Protocol for task storage implementation plugins.
|
|
19
|
+
|
|
20
|
+
Implementation plugins (e.g., local state-backed, remote service) must
|
|
21
|
+
satisfy this protocol. The Tasks capability delegates all CRUD operations
|
|
22
|
+
to the active TaskProvider.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def add(self, title: str, linked_to: TaskLink | None = None) -> str:
|
|
26
|
+
"""Create a new task and return its generated unique ID.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
title: Human-readable title for the task.
|
|
30
|
+
linked_to: Optional link associating the task with a job,
|
|
31
|
+
workflow step, or job phase.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The unique identifier of the newly created task.
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def list(
|
|
39
|
+
self, status: TaskStatus | None = None, filter: str | None = None
|
|
40
|
+
) -> list[TaskItem]:
|
|
41
|
+
"""List tasks, optionally filtered by status or title substring.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
status: If provided, return only tasks matching this status.
|
|
45
|
+
filter: If provided, return only tasks whose title contains
|
|
46
|
+
this substring.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A list of matching TaskItem instances.
|
|
50
|
+
"""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def update(
|
|
54
|
+
self, task_id: str, status: TaskStatus | None = None, notes: str | None = None
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Update a task's status and/or notes.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
task_id: The unique identifier of the task to update.
|
|
60
|
+
status: If provided, the new status to set.
|
|
61
|
+
notes: If provided, the new notes to set.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
TaskNotFoundError: If the task_id does not exist.
|
|
65
|
+
"""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def delete(self, task_id: str) -> None:
|
|
69
|
+
"""Delete a task by its ID.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
task_id: The unique identifier of the task to delete.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
TaskNotFoundError: If the task_id does not exist.
|
|
76
|
+
"""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def link(self, task_id: str, linked_to: TaskLink) -> None:
|
|
80
|
+
"""Associate a task with a job, workflow step, or job phase.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
task_id: The unique identifier of the task to link.
|
|
84
|
+
linked_to: The link specifying the kind and target.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
TaskNotFoundError: If the task_id does not exist.
|
|
88
|
+
"""
|
|
89
|
+
...
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Tasks capability class — mutable planning scratchpad.
|
|
2
|
+
|
|
3
|
+
The Tasks class provides methods to add, list, update, delete, and link
|
|
4
|
+
tasks. It delegates all storage operations to a TaskProvider and emits
|
|
5
|
+
structured events via a duck-typed EventBus on every mutation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
12
|
+
|
|
13
|
+
from functualize_tasks._errors import TaskNotFoundError
|
|
14
|
+
from functualize_tasks._events import (
|
|
15
|
+
TASKS_COMPLETED,
|
|
16
|
+
TASKS_CREATED,
|
|
17
|
+
TASKS_DELETED,
|
|
18
|
+
TASKS_UPDATED,
|
|
19
|
+
)
|
|
20
|
+
from functualize_tasks._types import TaskItem, TaskLink, TaskStatus
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from functualize_tasks._protocols import TaskProvider
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _EventBus(Protocol):
|
|
27
|
+
"""Duck-typed EventBus — only requires an emit method."""
|
|
28
|
+
|
|
29
|
+
def emit(self, event_name: str, **payload: Any) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _InMemoryTaskProvider:
|
|
33
|
+
"""Simple in-memory TaskProvider used when no external provider is configured.
|
|
34
|
+
|
|
35
|
+
Stores tasks in a dict keyed by task ID. Suitable for ephemeral use
|
|
36
|
+
when no persistent TaskProvider plugin is installed.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._tasks: dict[str, TaskItem] = {}
|
|
41
|
+
|
|
42
|
+
def add(self, title: str, linked_to: TaskLink | None = None) -> str:
|
|
43
|
+
task_id = uuid.uuid4().hex
|
|
44
|
+
task = TaskItem(
|
|
45
|
+
id=task_id,
|
|
46
|
+
title=title,
|
|
47
|
+
status=TaskStatus.PENDING,
|
|
48
|
+
linked_to=linked_to,
|
|
49
|
+
)
|
|
50
|
+
self._tasks[task_id] = task
|
|
51
|
+
return task_id
|
|
52
|
+
|
|
53
|
+
def list(
|
|
54
|
+
self, status: TaskStatus | None = None, filter: str | None = None
|
|
55
|
+
) -> list[TaskItem]:
|
|
56
|
+
results = list(self._tasks.values())
|
|
57
|
+
if status is not None:
|
|
58
|
+
results = [t for t in results if t.status == status]
|
|
59
|
+
if filter is not None:
|
|
60
|
+
results = [t for t in results if filter in t.title]
|
|
61
|
+
return results
|
|
62
|
+
|
|
63
|
+
def get(self, task_id: str) -> TaskItem | None:
|
|
64
|
+
return self._tasks.get(task_id)
|
|
65
|
+
|
|
66
|
+
def update(
|
|
67
|
+
self, task_id: str, status: TaskStatus | None = None, notes: str | None = None
|
|
68
|
+
) -> None:
|
|
69
|
+
task = self._tasks.get(task_id)
|
|
70
|
+
if task is None:
|
|
71
|
+
raise TaskNotFoundError(f"Task '{task_id}' not found.")
|
|
72
|
+
# Build updated task (frozen dataclass — must reconstruct)
|
|
73
|
+
updates: dict[str, Any] = {}
|
|
74
|
+
if status is not None:
|
|
75
|
+
updates["status"] = status
|
|
76
|
+
if notes is not None:
|
|
77
|
+
updates["notes"] = notes
|
|
78
|
+
if updates:
|
|
79
|
+
from dataclasses import asdict
|
|
80
|
+
|
|
81
|
+
data = asdict(task)
|
|
82
|
+
data.update(updates)
|
|
83
|
+
self._tasks[task_id] = TaskItem(**data)
|
|
84
|
+
|
|
85
|
+
def delete(self, task_id: str) -> None:
|
|
86
|
+
if task_id not in self._tasks:
|
|
87
|
+
raise TaskNotFoundError(f"Task '{task_id}' not found.")
|
|
88
|
+
del self._tasks[task_id]
|
|
89
|
+
|
|
90
|
+
def link(self, task_id: str, linked_to: TaskLink) -> None:
|
|
91
|
+
task = self._tasks.get(task_id)
|
|
92
|
+
if task is None:
|
|
93
|
+
raise TaskNotFoundError(f"Task '{task_id}' not found.")
|
|
94
|
+
from dataclasses import asdict
|
|
95
|
+
|
|
96
|
+
data = asdict(task)
|
|
97
|
+
data["linked_to"] = linked_to
|
|
98
|
+
self._tasks[task_id] = TaskItem(**data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Tasks:
|
|
102
|
+
"""Task management capability — mutable planning scratchpad.
|
|
103
|
+
|
|
104
|
+
Provides methods to create, list, update, delete, and link tasks.
|
|
105
|
+
Delegates all storage to a TaskProvider implementation and emits
|
|
106
|
+
structured events on every mutation via a duck-typed EventBus.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
_provider: The TaskProvider implementation for persistence.
|
|
110
|
+
If None, an in-memory provider is used.
|
|
111
|
+
_event_bus: Optional duck-typed EventBus with an emit(event_name, **payload)
|
|
112
|
+
method. If None, events are silently discarded.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
_provider: TaskProvider | None = None,
|
|
119
|
+
_event_bus: _EventBus | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
self._provider: TaskProvider = (
|
|
122
|
+
_provider if _provider is not None else _InMemoryTaskProvider()
|
|
123
|
+
) # type: ignore[assignment]
|
|
124
|
+
self._event_bus = _event_bus
|
|
125
|
+
|
|
126
|
+
def _emit(self, event_name: str, **payload: Any) -> None:
|
|
127
|
+
"""Emit an event if an event bus is available."""
|
|
128
|
+
if self._event_bus is not None:
|
|
129
|
+
self._event_bus.emit(event_name, **payload)
|
|
130
|
+
|
|
131
|
+
def add(self, title: str, *, linked_to: TaskLink | None = None) -> str:
|
|
132
|
+
"""Create a new task and return its generated unique ID.
|
|
133
|
+
|
|
134
|
+
Emits a ``tasks.task.created`` event with payload containing the
|
|
135
|
+
task id, title, and linked_to.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
title: Human-readable title for the task.
|
|
139
|
+
linked_to: Optional link associating the task with a job,
|
|
140
|
+
workflow step, or job phase.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The unique identifier of the newly created task.
|
|
144
|
+
"""
|
|
145
|
+
task_id = self._provider.add(title, linked_to)
|
|
146
|
+
self._emit(
|
|
147
|
+
TASKS_CREATED,
|
|
148
|
+
task_id=task_id,
|
|
149
|
+
title=title,
|
|
150
|
+
linked_to=linked_to,
|
|
151
|
+
)
|
|
152
|
+
return task_id
|
|
153
|
+
|
|
154
|
+
def list(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
status: TaskStatus | None = None,
|
|
158
|
+
filter: str | None = None,
|
|
159
|
+
) -> list[TaskItem]:
|
|
160
|
+
"""List tasks, optionally filtered by status or title substring.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
status: If provided, return only tasks matching this status.
|
|
164
|
+
filter: If provided, return only tasks whose title contains
|
|
165
|
+
this substring.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
A list of matching TaskItem instances.
|
|
169
|
+
"""
|
|
170
|
+
return self._provider.list(status, filter)
|
|
171
|
+
|
|
172
|
+
def update(
|
|
173
|
+
self,
|
|
174
|
+
task_id: str,
|
|
175
|
+
*,
|
|
176
|
+
status: TaskStatus | None = None,
|
|
177
|
+
notes: str | None = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Update a task's status and/or notes.
|
|
180
|
+
|
|
181
|
+
Emits ``tasks.task.updated`` when the status changes, and
|
|
182
|
+
``tasks.task.completed`` when the new status is DONE.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
task_id: The unique identifier of the task to update.
|
|
186
|
+
status: If provided, the new status to set.
|
|
187
|
+
notes: If provided, the new notes to set.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
TaskNotFoundError: If the task_id does not exist.
|
|
191
|
+
"""
|
|
192
|
+
# Retrieve old status before updating (for event payload)
|
|
193
|
+
old_tasks = self._provider.list()
|
|
194
|
+
old_task = next((t for t in old_tasks if t.id == task_id), None)
|
|
195
|
+
if old_task is None:
|
|
196
|
+
raise TaskNotFoundError(f"Task '{task_id}' not found.")
|
|
197
|
+
|
|
198
|
+
old_status = old_task.status
|
|
199
|
+
|
|
200
|
+
self._provider.update(task_id, status, notes)
|
|
201
|
+
|
|
202
|
+
# Emit updated event when status changes
|
|
203
|
+
if status is not None and status != old_status:
|
|
204
|
+
self._emit(
|
|
205
|
+
TASKS_UPDATED,
|
|
206
|
+
task_id=task_id,
|
|
207
|
+
old_status=old_status.value,
|
|
208
|
+
new_status=status.value,
|
|
209
|
+
)
|
|
210
|
+
# Emit completed event when transitioning to DONE
|
|
211
|
+
if status == TaskStatus.DONE:
|
|
212
|
+
self._emit(TASKS_COMPLETED, task_id=task_id)
|
|
213
|
+
|
|
214
|
+
def delete(self, task_id: str) -> None:
|
|
215
|
+
"""Delete a task by its ID.
|
|
216
|
+
|
|
217
|
+
Emits a ``tasks.task.deleted`` event with the task id.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
task_id: The unique identifier of the task to delete.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
TaskNotFoundError: If the task_id does not exist.
|
|
224
|
+
"""
|
|
225
|
+
# Verify existence before delegating (requirement: raise immediately)
|
|
226
|
+
old_tasks = self._provider.list()
|
|
227
|
+
old_task = next((t for t in old_tasks if t.id == task_id), None)
|
|
228
|
+
if old_task is None:
|
|
229
|
+
raise TaskNotFoundError(f"Task '{task_id}' not found.")
|
|
230
|
+
|
|
231
|
+
self._provider.delete(task_id)
|
|
232
|
+
self._emit(TASKS_DELETED, task_id=task_id)
|
|
233
|
+
|
|
234
|
+
def link(self, task_id: str, linked_to: TaskLink) -> None:
|
|
235
|
+
"""Associate a task with a job, workflow step, or job phase.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
task_id: The unique identifier of the task to link.
|
|
239
|
+
linked_to: The link specifying the kind and target.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
TaskNotFoundError: If the task_id does not exist.
|
|
243
|
+
"""
|
|
244
|
+
# Verify existence before delegating
|
|
245
|
+
old_tasks = self._provider.list()
|
|
246
|
+
old_task = next((t for t in old_tasks if t.id == task_id), None)
|
|
247
|
+
if old_task is None:
|
|
248
|
+
raise TaskNotFoundError(f"Task '{task_id}' not found.")
|
|
249
|
+
|
|
250
|
+
self._provider.link(task_id, linked_to)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared types for the Tasks Domain SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TaskStatus(StrEnum):
|
|
10
|
+
"""Status of a task item."""
|
|
11
|
+
|
|
12
|
+
PENDING = "pending"
|
|
13
|
+
IN_PROGRESS = "in_progress"
|
|
14
|
+
DONE = "done"
|
|
15
|
+
SKIPPED = "skipped"
|
|
16
|
+
BLOCKED = "blocked"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class TaskLink:
|
|
21
|
+
"""Specifies what a task is optionally linked to.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
kind: The type of link — "job", "workflow_step", or "job_phase".
|
|
25
|
+
target: The identifier of the linked entity.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
kind: str # "job" | "workflow_step" | "job_phase"
|
|
29
|
+
target: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class TaskItem:
|
|
34
|
+
"""A single task with id, title, status, and optional metadata.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
id: Unique identifier of the task.
|
|
38
|
+
title: Human-readable title of the task.
|
|
39
|
+
status: Current status of the task.
|
|
40
|
+
linked_to: Optional link to a job, workflow step, or job phase.
|
|
41
|
+
notes: Optional free-form notes.
|
|
42
|
+
creator: Optional identifier of who created the task.
|
|
43
|
+
created_at: Optional UNIX timestamp of creation time.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
title: str
|
|
48
|
+
status: TaskStatus
|
|
49
|
+
linked_to: TaskLink | None = None
|
|
50
|
+
notes: str | None = None
|
|
51
|
+
creator: str | None = None
|
|
52
|
+
created_at: float | None = None
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Tasks testing doubles — MockTasks.
|
|
2
|
+
|
|
3
|
+
Provides a deterministic, operation-capturing testing double for the Tasks
|
|
4
|
+
capability, suitable for unit and integration testing of jobs that use
|
|
5
|
+
task management features.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from functualize_tasks.testing._mock_tasks import MockTaskOperation, MockTasks
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MockTaskOperation",
|
|
12
|
+
"MockTasks",
|
|
13
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""MockTasks — operation-capturing testing double.
|
|
2
|
+
|
|
3
|
+
Provides a Tasks implementation for testing that captures all operations
|
|
4
|
+
(add, list, update, delete, link) for assertion. Backed by an in-memory
|
|
5
|
+
TaskProvider so operations actually execute, and all calls are recorded
|
|
6
|
+
in a queryable log.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from functualize_tasks._tasks import Tasks, _InMemoryTaskProvider
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from functualize_tasks._types import TaskItem, TaskLink, TaskStatus
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class MockTaskOperation:
|
|
22
|
+
"""A recorded operation on the MockTasks instance.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
method: The method name that was called (e.g., "add", "list").
|
|
26
|
+
args: Positional arguments as a tuple.
|
|
27
|
+
kwargs: Keyword arguments as a dict.
|
|
28
|
+
result: The return value of the operation (None for void methods).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
method: str
|
|
32
|
+
args: tuple[Any, ...] = ()
|
|
33
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
result: Any = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MockTasks(Tasks):
|
|
38
|
+
"""Testing double for Tasks that captures all operations for assertion.
|
|
39
|
+
|
|
40
|
+
Extends the Tasks capability with a real in-memory provider so that
|
|
41
|
+
operations actually execute (add creates tasks, list returns them, etc.),
|
|
42
|
+
while also recording every call in an operations log that tests can
|
|
43
|
+
query and assert against.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
>>> tasks = MockTasks()
|
|
47
|
+
>>> task_id = tasks.add("Write tests")
|
|
48
|
+
>>> tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
|
|
49
|
+
>>> assert len(tasks.operations) == 2
|
|
50
|
+
>>> assert tasks.operations[0].method == "add"
|
|
51
|
+
>>> assert tasks.operations[1].method == "update"
|
|
52
|
+
>>> assert tasks.adds == [tasks.operations[0]]
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
None — uses an internal in-memory provider automatically.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
provider = _InMemoryTaskProvider()
|
|
60
|
+
super().__init__(_provider=provider)
|
|
61
|
+
self._operations: list[MockTaskOperation] = []
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def operations(self) -> list[MockTaskOperation]:
|
|
65
|
+
"""All recorded operations in call order."""
|
|
66
|
+
return list(self._operations)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def adds(self) -> list[MockTaskOperation]:
|
|
70
|
+
"""All recorded 'add' operations."""
|
|
71
|
+
return [op for op in self._operations if op.method == "add"]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def lists(self) -> list[MockTaskOperation]:
|
|
75
|
+
"""All recorded 'list' operations."""
|
|
76
|
+
return [op for op in self._operations if op.method == "list"]
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def updates(self) -> list[MockTaskOperation]:
|
|
80
|
+
"""All recorded 'update' operations."""
|
|
81
|
+
return [op for op in self._operations if op.method == "update"]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def deletes(self) -> list[MockTaskOperation]:
|
|
85
|
+
"""All recorded 'delete' operations."""
|
|
86
|
+
return [op for op in self._operations if op.method == "delete"]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def links(self) -> list[MockTaskOperation]:
|
|
90
|
+
"""All recorded 'link' operations."""
|
|
91
|
+
return [op for op in self._operations if op.method == "link"]
|
|
92
|
+
|
|
93
|
+
def reset(self) -> None:
|
|
94
|
+
"""Clear all recorded operations."""
|
|
95
|
+
self._operations.clear()
|
|
96
|
+
|
|
97
|
+
def add(self, title: str, *, linked_to: TaskLink | None = None) -> str:
|
|
98
|
+
"""Create a task and record the operation."""
|
|
99
|
+
result = super().add(title, linked_to=linked_to)
|
|
100
|
+
self._operations.append(
|
|
101
|
+
MockTaskOperation(
|
|
102
|
+
method="add",
|
|
103
|
+
args=(title,),
|
|
104
|
+
kwargs={"linked_to": linked_to},
|
|
105
|
+
result=result,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
def list(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
status: TaskStatus | None = None,
|
|
114
|
+
filter: str | None = None,
|
|
115
|
+
) -> list[TaskItem]:
|
|
116
|
+
"""List tasks and record the operation."""
|
|
117
|
+
result = super().list(status=status, filter=filter)
|
|
118
|
+
self._operations.append(
|
|
119
|
+
MockTaskOperation(
|
|
120
|
+
method="list",
|
|
121
|
+
args=(),
|
|
122
|
+
kwargs={"status": status, "filter": filter},
|
|
123
|
+
result=result,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
def update(
|
|
129
|
+
self,
|
|
130
|
+
task_id: str,
|
|
131
|
+
*,
|
|
132
|
+
status: TaskStatus | None = None,
|
|
133
|
+
notes: str | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Update a task and record the operation."""
|
|
136
|
+
super().update(task_id, status=status, notes=notes)
|
|
137
|
+
self._operations.append(
|
|
138
|
+
MockTaskOperation(
|
|
139
|
+
method="update",
|
|
140
|
+
args=(task_id,),
|
|
141
|
+
kwargs={"status": status, "notes": notes},
|
|
142
|
+
result=None,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def delete(self, task_id: str) -> None:
|
|
147
|
+
"""Delete a task and record the operation."""
|
|
148
|
+
super().delete(task_id)
|
|
149
|
+
self._operations.append(
|
|
150
|
+
MockTaskOperation(
|
|
151
|
+
method="delete",
|
|
152
|
+
args=(task_id,),
|
|
153
|
+
kwargs={},
|
|
154
|
+
result=None,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def link(self, task_id: str, linked_to: TaskLink) -> None:
|
|
159
|
+
"""Link a task and record the operation."""
|
|
160
|
+
super().link(task_id, linked_to=linked_to)
|
|
161
|
+
self._operations.append(
|
|
162
|
+
MockTaskOperation(
|
|
163
|
+
method="link",
|
|
164
|
+
args=(task_id,),
|
|
165
|
+
kwargs={"linked_to": linked_to},
|
|
166
|
+
result=None,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: functualize-tasks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tasks Domain SDK for functualize — task management capabilities
|
|
5
|
+
Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: pydantic>=2.0.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# functualize-tasks
|
|
21
|
+
|
|
22
|
+
> **Status: Published** — Independently installable from PyPI.
|
|
23
|
+
|
|
24
|
+
Tasks Domain SDK for the functualize framework. Provides a task management capability
|
|
25
|
+
that acts as a mutable planning scratchpad within job execution — create, list, update,
|
|
26
|
+
delete, and link tasks with structured event emission on every mutation. Storage is
|
|
27
|
+
delegated to pluggable `TaskProvider` implementations, making the capability
|
|
28
|
+
backend-agnostic.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install functualize-tasks
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from functualize_tasks import Tasks, TaskStatus, TaskLink
|
|
40
|
+
|
|
41
|
+
tasks = Tasks()
|
|
42
|
+
|
|
43
|
+
# Create tasks
|
|
44
|
+
task_id = tasks.add("Run database migration")
|
|
45
|
+
tasks.add("Deploy service", linked_to=TaskLink(kind="job", target="deploy"))
|
|
46
|
+
|
|
47
|
+
# Update status
|
|
48
|
+
tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
|
|
49
|
+
tasks.update(task_id, status=TaskStatus.DONE, notes="Migration complete")
|
|
50
|
+
|
|
51
|
+
# List and filter
|
|
52
|
+
pending = tasks.list(status=TaskStatus.PENDING)
|
|
53
|
+
all_tasks = tasks.list(filter="service")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Features
|
|
57
|
+
|
|
58
|
+
- **CRUD task management** — add, list, update, delete, and link tasks with a clean method-call API
|
|
59
|
+
- **Event-driven mutations** — every state change emits structured events (`tasks.task.created`, `tasks.task.updated`, `tasks.task.completed`, `tasks.task.deleted`) via a duck-typed EventBus
|
|
60
|
+
- **Pluggable storage** — implement the `TaskProvider` protocol to back tasks with any persistence layer (in-memory, SQLite, remote service)
|
|
61
|
+
- **Rich data model** — `TaskItem` with status enum (`PENDING`, `IN_PROGRESS`, `DONE`, `SKIPPED`, `BLOCKED`), optional `TaskLink` to associate tasks with jobs or workflow steps
|
|
62
|
+
- **Testing double included** — `MockTasks` captures all operations for assertion while executing against a real in-memory provider
|
|
63
|
+
- **Type-safe** — fully typed with PEP 561 `py.typed` marker for mypy/pyright support
|
|
64
|
+
|
|
65
|
+
## API Reference
|
|
66
|
+
|
|
67
|
+
Public classes, types, and constants exported by this plugin:
|
|
68
|
+
|
|
69
|
+
### Capability
|
|
70
|
+
|
|
71
|
+
- `Tasks` — Main capability class providing `add()`, `list()`, `update()`, `delete()`, and `link()` methods
|
|
72
|
+
|
|
73
|
+
### Protocols
|
|
74
|
+
|
|
75
|
+
- `TaskProvider` — Runtime-checkable protocol that storage backends must implement
|
|
76
|
+
|
|
77
|
+
### Types
|
|
78
|
+
|
|
79
|
+
- `TaskItem` — Frozen dataclass representing a task (id, title, status, linked_to, notes, creator, created_at)
|
|
80
|
+
- `TaskLink` — Frozen dataclass specifying a link kind and target
|
|
81
|
+
- `TaskStatus` — String enum with values: `PENDING`, `IN_PROGRESS`, `DONE`, `SKIPPED`, `BLOCKED`
|
|
82
|
+
|
|
83
|
+
### Errors
|
|
84
|
+
|
|
85
|
+
- `TaskNotFound` — Raised when an operation targets a non-existent task ID
|
|
86
|
+
|
|
87
|
+
### Event Constants
|
|
88
|
+
|
|
89
|
+
- `TASKS_CREATED` — `"tasks.task.created"`
|
|
90
|
+
- `TASKS_UPDATED` — `"tasks.task.updated"`
|
|
91
|
+
- `TASKS_COMPLETED` — `"tasks.task.completed"`
|
|
92
|
+
- `TASKS_DELETED` — `"tasks.task.deleted"`
|
|
93
|
+
|
|
94
|
+
### Testing
|
|
95
|
+
|
|
96
|
+
- `MockTasks` — Operation-capturing testing double with `operations`, `adds`, `updates`, `deletes`, `links` properties
|
|
97
|
+
- `MockTaskOperation` — Frozen dataclass recording a single captured method call (method, args, kwargs, result)
|
|
98
|
+
|
|
99
|
+
### Metadata
|
|
100
|
+
|
|
101
|
+
- `domain_metadata` — `DomainMetadata` instance describing the tasks domain SDK
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
Run plugin tests:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv run pytest plugins/functualize-tasks/tests/ -v
|
|
109
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
functualize_tasks/__init__.py,sha256=eM-ngF-vtVP16O9fiZbLXTnTCRrzL-97LZTH88cby7A,1062
|
|
2
|
+
functualize_tasks/_errors.py,sha256=HesVnN5wyNSallDcEeGQt2hVNdpmMzkfLr3FUVnKTrk,172
|
|
3
|
+
functualize_tasks/_events.py,sha256=LJqbnfwCBUmiCfUa5yC2ieEP479t3zDfhGgYUOscgf0,284
|
|
4
|
+
functualize_tasks/_metadata.py,sha256=_lGrQZ2zr5Qj_lygmaRYdNDXflSSxHmCZ-JA1YZVRPI,982
|
|
5
|
+
functualize_tasks/_protocols.py,sha256=-odBf9i2g5EEJQo9RIYQ3h8c4bEAn0SVcAFPxWlXnqo,2734
|
|
6
|
+
functualize_tasks/_tasks.py,sha256=XzZTIwBCDM3imb6fds0i29eB3_dqbV2d2NKkJfOJAmQ,8446
|
|
7
|
+
functualize_tasks/_types.py,sha256=U8KF1_tlk2-GzThRy9HpF5hxaA5xYP4JOvtP2HtGo1M,1340
|
|
8
|
+
functualize_tasks/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
functualize_tasks/testing/__init__.py,sha256=74ZpwLS0x3Ka9If8n6Say2QvVKCFy8rb0GKlZ2dVhPI,354
|
|
10
|
+
functualize_tasks/testing/_mock_tasks.py,sha256=sjq_R4b9mx7114RB9wPcg_FIMOzXOkyzM09Dp5iAW2U,5426
|
|
11
|
+
functualize_tasks-0.1.0.dist-info/METADATA,sha256=gFQV4xcOQwb6lUUAyzl-EFFV3mC50OGSBCv41AkK9LA,3900
|
|
12
|
+
functualize_tasks-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
functualize_tasks-0.1.0.dist-info/entry_points.txt,sha256=cbWHKEshIClHGASgLoLdtL64Etibr2-_QcUjqCXuZwc,74
|
|
14
|
+
functualize_tasks-0.1.0.dist-info/RECORD,,
|