functualize-tasks 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- functualize_tasks-0.1.0/.gitignore +101 -0
- functualize_tasks-0.1.0/PKG-INFO +109 -0
- functualize_tasks-0.1.0/README.md +90 -0
- functualize_tasks-0.1.0/examples/README.md +13 -0
- functualize_tasks-0.1.0/examples/todo/test_todo.py +14 -0
- functualize_tasks-0.1.0/examples/todo/todo.py +27 -0
- functualize_tasks-0.1.0/pyproject.toml +37 -0
- functualize_tasks-0.1.0/src/functualize_tasks/__init__.py +41 -0
- functualize_tasks-0.1.0/src/functualize_tasks/_errors.py +7 -0
- functualize_tasks-0.1.0/src/functualize_tasks/_events.py +9 -0
- functualize_tasks-0.1.0/src/functualize_tasks/_metadata.py +37 -0
- functualize_tasks-0.1.0/src/functualize_tasks/_protocols.py +89 -0
- functualize_tasks-0.1.0/src/functualize_tasks/_tasks.py +250 -0
- functualize_tasks-0.1.0/src/functualize_tasks/_types.py +52 -0
- functualize_tasks-0.1.0/src/functualize_tasks/py.typed +0 -0
- functualize_tasks-0.1.0/src/functualize_tasks/testing/__init__.py +13 -0
- functualize_tasks-0.1.0/src/functualize_tasks/testing/_mock_tasks.py +168 -0
- functualize_tasks-0.1.0/tests/__init__.py +0 -0
- functualize_tasks-0.1.0/tests/conftest.py +1 -0
- functualize_tasks-0.1.0/tests/test_plugin.py +15 -0
- functualize_tasks-0.1.0/tests/test_tasks_capability.py +180 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg-info/
|
|
7
|
+
*.egg
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.whl
|
|
11
|
+
|
|
12
|
+
# Agents
|
|
13
|
+
.spec/archive/
|
|
14
|
+
.spec/features/
|
|
15
|
+
.spec/scrutiny-reports/
|
|
16
|
+
.spec/.agentic-coding
|
|
17
|
+
.spec/STATE.md
|
|
18
|
+
.spec/PROJECT.md
|
|
19
|
+
.spec/REQUIREMENTS.md
|
|
20
|
+
.spec/ROADMAP.md
|
|
21
|
+
.opencode/
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Virtual environments
|
|
25
|
+
.venv/
|
|
26
|
+
venv/
|
|
27
|
+
ENV/
|
|
28
|
+
|
|
29
|
+
# Testing
|
|
30
|
+
.coverage
|
|
31
|
+
.pytest_cache/
|
|
32
|
+
htmlcov/
|
|
33
|
+
.hypothesis/
|
|
34
|
+
snapshot_report.html
|
|
35
|
+
_*_result*.txt
|
|
36
|
+
_debug.txt
|
|
37
|
+
_tui_debug.txt
|
|
38
|
+
_tui_eval_debug.txt
|
|
39
|
+
|
|
40
|
+
# IDE
|
|
41
|
+
.idea/
|
|
42
|
+
*.swp
|
|
43
|
+
*.swo
|
|
44
|
+
*~
|
|
45
|
+
*.code-workspace
|
|
46
|
+
|
|
47
|
+
# Coding-agent tooling state (guards — these dirs are not part of the repo)
|
|
48
|
+
.kiro/
|
|
49
|
+
.moai/
|
|
50
|
+
|
|
51
|
+
# OS
|
|
52
|
+
.DS_Store
|
|
53
|
+
Thumbs.db
|
|
54
|
+
|
|
55
|
+
# Environment / secrets
|
|
56
|
+
.env
|
|
57
|
+
.env.*
|
|
58
|
+
!.env.example
|
|
59
|
+
|
|
60
|
+
# Agent scratch space (test output, temp scripts)
|
|
61
|
+
tmp/
|
|
62
|
+
|
|
63
|
+
# Local-only files (not for the repo)
|
|
64
|
+
*.local.md
|
|
65
|
+
*.local.*
|
|
66
|
+
|
|
67
|
+
# Personal notes
|
|
68
|
+
HUMAN_NOTE.md
|
|
69
|
+
|
|
70
|
+
# Distribution
|
|
71
|
+
dist/
|
|
72
|
+
|
|
73
|
+
# Documentation site build output
|
|
74
|
+
site/
|
|
75
|
+
|
|
76
|
+
# uv
|
|
77
|
+
.python-version
|
|
78
|
+
.functualize/cache.json
|
|
79
|
+
.functualize_cache.json
|
|
80
|
+
.todos/
|
|
81
|
+
.sidecar/
|
|
82
|
+
.sidecar-agent
|
|
83
|
+
.sidecar-task
|
|
84
|
+
.sidecar-pr
|
|
85
|
+
.sidecar-start.sh
|
|
86
|
+
.sidecar-base
|
|
87
|
+
.td-root
|
|
88
|
+
.functualize/
|
|
89
|
+
.import_linter_cache/
|
|
90
|
+
.mypy_cache/
|
|
91
|
+
.pytest_cache/
|
|
92
|
+
.ruff_cache/
|
|
93
|
+
|
|
94
|
+
# OmO / OpenCode agent run-continuation scratch state
|
|
95
|
+
.omo/
|
|
96
|
+
.mcp.json
|
|
97
|
+
.agentsroom/handoff-transcript-*.txt
|
|
98
|
+
.agentsroom/handoff-summary-*.md
|
|
99
|
+
|
|
100
|
+
# Internal pre-release audit reports (contain session IDs / local infra notes)
|
|
101
|
+
.release/
|
|
@@ -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,90 @@
|
|
|
1
|
+
# functualize-tasks
|
|
2
|
+
|
|
3
|
+
> **Status: Published** — Independently installable from PyPI.
|
|
4
|
+
|
|
5
|
+
Tasks Domain SDK for the functualize framework. Provides a task management capability
|
|
6
|
+
that acts as a mutable planning scratchpad within job execution — create, list, update,
|
|
7
|
+
delete, and link tasks with structured event emission on every mutation. Storage is
|
|
8
|
+
delegated to pluggable `TaskProvider` implementations, making the capability
|
|
9
|
+
backend-agnostic.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install functualize-tasks
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from functualize_tasks import Tasks, TaskStatus, TaskLink
|
|
21
|
+
|
|
22
|
+
tasks = Tasks()
|
|
23
|
+
|
|
24
|
+
# Create tasks
|
|
25
|
+
task_id = tasks.add("Run database migration")
|
|
26
|
+
tasks.add("Deploy service", linked_to=TaskLink(kind="job", target="deploy"))
|
|
27
|
+
|
|
28
|
+
# Update status
|
|
29
|
+
tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
|
|
30
|
+
tasks.update(task_id, status=TaskStatus.DONE, notes="Migration complete")
|
|
31
|
+
|
|
32
|
+
# List and filter
|
|
33
|
+
pending = tasks.list(status=TaskStatus.PENDING)
|
|
34
|
+
all_tasks = tasks.list(filter="service")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **CRUD task management** — add, list, update, delete, and link tasks with a clean method-call API
|
|
40
|
+
- **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
|
|
41
|
+
- **Pluggable storage** — implement the `TaskProvider` protocol to back tasks with any persistence layer (in-memory, SQLite, remote service)
|
|
42
|
+
- **Rich data model** — `TaskItem` with status enum (`PENDING`, `IN_PROGRESS`, `DONE`, `SKIPPED`, `BLOCKED`), optional `TaskLink` to associate tasks with jobs or workflow steps
|
|
43
|
+
- **Testing double included** — `MockTasks` captures all operations for assertion while executing against a real in-memory provider
|
|
44
|
+
- **Type-safe** — fully typed with PEP 561 `py.typed` marker for mypy/pyright support
|
|
45
|
+
|
|
46
|
+
## API Reference
|
|
47
|
+
|
|
48
|
+
Public classes, types, and constants exported by this plugin:
|
|
49
|
+
|
|
50
|
+
### Capability
|
|
51
|
+
|
|
52
|
+
- `Tasks` — Main capability class providing `add()`, `list()`, `update()`, `delete()`, and `link()` methods
|
|
53
|
+
|
|
54
|
+
### Protocols
|
|
55
|
+
|
|
56
|
+
- `TaskProvider` — Runtime-checkable protocol that storage backends must implement
|
|
57
|
+
|
|
58
|
+
### Types
|
|
59
|
+
|
|
60
|
+
- `TaskItem` — Frozen dataclass representing a task (id, title, status, linked_to, notes, creator, created_at)
|
|
61
|
+
- `TaskLink` — Frozen dataclass specifying a link kind and target
|
|
62
|
+
- `TaskStatus` — String enum with values: `PENDING`, `IN_PROGRESS`, `DONE`, `SKIPPED`, `BLOCKED`
|
|
63
|
+
|
|
64
|
+
### Errors
|
|
65
|
+
|
|
66
|
+
- `TaskNotFound` — Raised when an operation targets a non-existent task ID
|
|
67
|
+
|
|
68
|
+
### Event Constants
|
|
69
|
+
|
|
70
|
+
- `TASKS_CREATED` — `"tasks.task.created"`
|
|
71
|
+
- `TASKS_UPDATED` — `"tasks.task.updated"`
|
|
72
|
+
- `TASKS_COMPLETED` — `"tasks.task.completed"`
|
|
73
|
+
- `TASKS_DELETED` — `"tasks.task.deleted"`
|
|
74
|
+
|
|
75
|
+
### Testing
|
|
76
|
+
|
|
77
|
+
- `MockTasks` — Operation-capturing testing double with `operations`, `adds`, `updates`, `deletes`, `links` properties
|
|
78
|
+
- `MockTaskOperation` — Frozen dataclass recording a single captured method call (method, args, kwargs, result)
|
|
79
|
+
|
|
80
|
+
### Metadata
|
|
81
|
+
|
|
82
|
+
- `domain_metadata` — `DomainMetadata` instance describing the tasks domain SDK
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
Run plugin tests:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv run pytest plugins/functualize-tasks/tests/ -v
|
|
90
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# functualize-tasks Examples
|
|
2
|
+
|
|
3
|
+
The task-management domain SDK: `Tasks` capability, `TaskStatus`, and the `MockTasks` testing double (a working in-memory provider that also records operations).
|
|
4
|
+
|
|
5
|
+
| Directory | Demonstrates |
|
|
6
|
+
|-----------|--------------|
|
|
7
|
+
| [`todo/`](todo/) | A job creating and completing tasks through the `Tasks` capability |
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv run pytest plugins/functualize-tasks/examples/ -v
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For a persistent provider, see [`functualize-tasks-local/examples/`](../../functualize-tasks-local/examples/).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Tests for the todo example."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
8
|
+
|
|
9
|
+
from todo import plan_release
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_completed_task_leaves_the_list():
|
|
13
|
+
remaining = plan_release(MagicMock())
|
|
14
|
+
assert remaining == ["Run smoke tests", "Tag release"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Track work items through the Tasks capability.
|
|
2
|
+
|
|
3
|
+
Uses MockTasks (a real in-memory provider that also records operations)
|
|
4
|
+
so the example is self-contained; in a real app the framework injects a
|
|
5
|
+
`Tasks` capability backed by the installed provider.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from functualize_tasks import TaskStatus
|
|
9
|
+
from functualize_tasks.testing import MockTasks
|
|
10
|
+
|
|
11
|
+
from functualize.job import RunContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plan_release(rc: RunContext) -> list[str]:
|
|
15
|
+
"""Create a small release checklist and complete the first item."""
|
|
16
|
+
tasks = MockTasks()
|
|
17
|
+
|
|
18
|
+
build_id = tasks.add("Build artifacts")
|
|
19
|
+
tasks.add("Run smoke tests")
|
|
20
|
+
tasks.add("Tag release")
|
|
21
|
+
|
|
22
|
+
tasks.update(build_id, status=TaskStatus.DONE)
|
|
23
|
+
rc.log("Build artifacts: done")
|
|
24
|
+
|
|
25
|
+
remaining = [t.title for t in tasks.list() if t.status is not TaskStatus.DONE]
|
|
26
|
+
rc.log(f"Remaining: {', '.join(remaining)}")
|
|
27
|
+
return remaining
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "functualize-tasks"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tasks Domain SDK for functualize — task management capabilities"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Mohammad Hakim Adiprasetya", email = "viltohmyst@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pydantic>=2.0.0",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.entry-points."functualize.domains"]
|
|
24
|
+
tasks = "functualize_tasks._metadata:domain_metadata"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=7.4.0",
|
|
29
|
+
"pytest-cov>=4.1.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/functualize_tasks"]
|
|
@@ -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
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared fixtures for functualize-tasks plugin tests."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Unit tests for functualize-tasks domain SDK.
|
|
2
|
+
|
|
3
|
+
Tests the task capability protocols and domain metadata.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestImports:
|
|
10
|
+
"""Verify the plugin is importable."""
|
|
11
|
+
|
|
12
|
+
def test_import_package(self):
|
|
13
|
+
import functualize_tasks
|
|
14
|
+
|
|
15
|
+
assert dir(functualize_tasks)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Functional tests for functualize-tasks domain SDK.
|
|
2
|
+
|
|
3
|
+
Tests the Tasks capability class, status transitions, MockTasks
|
|
4
|
+
operation recording, and event emission on state changes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from functualize_tasks import (
|
|
13
|
+
TASKS_COMPLETED,
|
|
14
|
+
TASKS_CREATED,
|
|
15
|
+
TASKS_DELETED,
|
|
16
|
+
TASKS_UPDATED,
|
|
17
|
+
MockTasks,
|
|
18
|
+
TaskLink,
|
|
19
|
+
TaskNotFoundError,
|
|
20
|
+
Tasks,
|
|
21
|
+
TaskStatus,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _CapturingEventBus:
|
|
26
|
+
"""Minimal event bus that records all emitted events for assertion."""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self.events: list[tuple[str, dict[str, Any]]] = []
|
|
30
|
+
|
|
31
|
+
def emit(self, event_name: str, **payload: Any) -> None:
|
|
32
|
+
self.events.append((event_name, payload))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestTaskCreation:
|
|
36
|
+
"""Tests for creating tasks via the Tasks capability."""
|
|
37
|
+
|
|
38
|
+
def test_add_returns_unique_id(self):
|
|
39
|
+
"""Happy path: adding a task returns a non-empty string ID."""
|
|
40
|
+
tasks = Tasks()
|
|
41
|
+
task_id = tasks.add("Write tests")
|
|
42
|
+
assert isinstance(task_id, str)
|
|
43
|
+
assert len(task_id) > 0
|
|
44
|
+
|
|
45
|
+
def test_add_creates_task_with_pending_status(self):
|
|
46
|
+
"""Happy path: a newly added task has PENDING status."""
|
|
47
|
+
tasks = Tasks()
|
|
48
|
+
task_id = tasks.add("Deploy service")
|
|
49
|
+
items = tasks.list()
|
|
50
|
+
assert len(items) == 1
|
|
51
|
+
assert items[0].id == task_id
|
|
52
|
+
assert items[0].title == "Deploy service"
|
|
53
|
+
assert items[0].status == TaskStatus.PENDING
|
|
54
|
+
|
|
55
|
+
def test_add_with_link(self):
|
|
56
|
+
"""Happy path: task can be created with a link."""
|
|
57
|
+
tasks = Tasks()
|
|
58
|
+
link = TaskLink(kind="job", target="deploy")
|
|
59
|
+
tasks.add("Run migrations", linked_to=link)
|
|
60
|
+
items = tasks.list()
|
|
61
|
+
assert items[0].linked_to == link
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestStatusTransitions:
|
|
65
|
+
"""Tests for updating task status through transitions."""
|
|
66
|
+
|
|
67
|
+
def test_transition_pending_to_in_progress(self):
|
|
68
|
+
"""Happy path: task status can transition from PENDING to IN_PROGRESS."""
|
|
69
|
+
tasks = Tasks()
|
|
70
|
+
task_id = tasks.add("Build")
|
|
71
|
+
tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
|
|
72
|
+
items = tasks.list()
|
|
73
|
+
assert items[0].status == TaskStatus.IN_PROGRESS
|
|
74
|
+
|
|
75
|
+
def test_transition_to_done(self):
|
|
76
|
+
"""Happy path: task status can transition to DONE."""
|
|
77
|
+
tasks = Tasks()
|
|
78
|
+
task_id = tasks.add("Test")
|
|
79
|
+
tasks.update(task_id, status=TaskStatus.DONE)
|
|
80
|
+
items = tasks.list()
|
|
81
|
+
assert items[0].status == TaskStatus.DONE
|
|
82
|
+
|
|
83
|
+
def test_update_nonexistent_task_raises_task_not_found(self):
|
|
84
|
+
"""Error case: updating a nonexistent task raises TaskNotFoundError."""
|
|
85
|
+
tasks = Tasks()
|
|
86
|
+
with pytest.raises(TaskNotFoundError):
|
|
87
|
+
tasks.update("nonexistent-id", status=TaskStatus.DONE)
|
|
88
|
+
|
|
89
|
+
def test_delete_nonexistent_task_raises_task_not_found(self):
|
|
90
|
+
"""Error case: deleting a nonexistent task raises TaskNotFoundError."""
|
|
91
|
+
tasks = Tasks()
|
|
92
|
+
with pytest.raises(TaskNotFoundError):
|
|
93
|
+
tasks.delete("nonexistent-id")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestMockTasksBehavior:
|
|
97
|
+
"""Tests for the MockTasks testing double — operation recording."""
|
|
98
|
+
|
|
99
|
+
def test_mock_records_add_operations(self):
|
|
100
|
+
"""Happy path: MockTasks records add operations with result."""
|
|
101
|
+
mock = MockTasks()
|
|
102
|
+
task_id = mock.add("First task")
|
|
103
|
+
assert len(mock.operations) == 1
|
|
104
|
+
assert mock.operations[0].method == "add"
|
|
105
|
+
assert mock.operations[0].args == ("First task",)
|
|
106
|
+
assert mock.operations[0].result == task_id
|
|
107
|
+
|
|
108
|
+
def test_mock_records_multiple_operation_types(self):
|
|
109
|
+
"""Happy path: MockTasks records mixed operations in order."""
|
|
110
|
+
mock = MockTasks()
|
|
111
|
+
task_id = mock.add("Task A")
|
|
112
|
+
mock.list()
|
|
113
|
+
mock.update(task_id, status=TaskStatus.IN_PROGRESS)
|
|
114
|
+
assert len(mock.operations) == 3
|
|
115
|
+
assert mock.adds[0].method == "add"
|
|
116
|
+
assert mock.lists[0].method == "list"
|
|
117
|
+
assert mock.updates[0].method == "update"
|
|
118
|
+
|
|
119
|
+
def test_mock_reset_clears_operations(self):
|
|
120
|
+
"""Happy path: reset clears all recorded operations."""
|
|
121
|
+
mock = MockTasks()
|
|
122
|
+
mock.add("Task B")
|
|
123
|
+
assert len(mock.operations) == 1
|
|
124
|
+
mock.reset()
|
|
125
|
+
assert len(mock.operations) == 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestEventEmission:
|
|
129
|
+
"""Tests for event emission on state changes."""
|
|
130
|
+
|
|
131
|
+
def test_add_emits_created_event(self):
|
|
132
|
+
"""Happy path: adding a task emits tasks.task.created."""
|
|
133
|
+
bus = _CapturingEventBus()
|
|
134
|
+
tasks = Tasks(_event_bus=bus)
|
|
135
|
+
task_id = tasks.add("Event test")
|
|
136
|
+
assert len(bus.events) == 1
|
|
137
|
+
event_name, payload = bus.events[0]
|
|
138
|
+
assert event_name == TASKS_CREATED
|
|
139
|
+
assert payload["task_id"] == task_id
|
|
140
|
+
assert payload["title"] == "Event test"
|
|
141
|
+
|
|
142
|
+
def test_update_status_emits_updated_event(self):
|
|
143
|
+
"""Happy path: changing status emits tasks.task.updated."""
|
|
144
|
+
bus = _CapturingEventBus()
|
|
145
|
+
tasks = Tasks(_event_bus=bus)
|
|
146
|
+
task_id = tasks.add("Status event")
|
|
147
|
+
bus.events.clear() # Clear the created event
|
|
148
|
+
|
|
149
|
+
tasks.update(task_id, status=TaskStatus.IN_PROGRESS)
|
|
150
|
+
assert len(bus.events) == 1
|
|
151
|
+
event_name, payload = bus.events[0]
|
|
152
|
+
assert event_name == TASKS_UPDATED
|
|
153
|
+
assert payload["task_id"] == task_id
|
|
154
|
+
assert payload["old_status"] == TaskStatus.PENDING.value
|
|
155
|
+
assert payload["new_status"] == TaskStatus.IN_PROGRESS.value
|
|
156
|
+
|
|
157
|
+
def test_transition_to_done_emits_completed_event(self):
|
|
158
|
+
"""Happy path: transitioning to DONE emits both updated and completed."""
|
|
159
|
+
bus = _CapturingEventBus()
|
|
160
|
+
tasks = Tasks(_event_bus=bus)
|
|
161
|
+
task_id = tasks.add("Complete me")
|
|
162
|
+
bus.events.clear()
|
|
163
|
+
|
|
164
|
+
tasks.update(task_id, status=TaskStatus.DONE)
|
|
165
|
+
event_names = [name for name, _ in bus.events]
|
|
166
|
+
assert TASKS_UPDATED in event_names
|
|
167
|
+
assert TASKS_COMPLETED in event_names
|
|
168
|
+
|
|
169
|
+
def test_delete_emits_deleted_event(self):
|
|
170
|
+
"""Happy path: deleting a task emits tasks.task.deleted."""
|
|
171
|
+
bus = _CapturingEventBus()
|
|
172
|
+
tasks = Tasks(_event_bus=bus)
|
|
173
|
+
task_id = tasks.add("Delete me")
|
|
174
|
+
bus.events.clear()
|
|
175
|
+
|
|
176
|
+
tasks.delete(task_id)
|
|
177
|
+
assert len(bus.events) == 1
|
|
178
|
+
event_name, payload = bus.events[0]
|
|
179
|
+
assert event_name == TASKS_DELETED
|
|
180
|
+
assert payload["task_id"] == task_id
|