functualize-tasks-local 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_local-0.1.0/.gitignore +101 -0
- functualize_tasks_local-0.1.0/PKG-INFO +79 -0
- functualize_tasks_local-0.1.0/README.md +59 -0
- functualize_tasks_local-0.1.0/examples/README.md +11 -0
- functualize_tasks_local-0.1.0/examples/todo_local/test_todo_local.py +13 -0
- functualize_tasks_local-0.1.0/examples/todo_local/todo_local.py +27 -0
- functualize_tasks_local-0.1.0/pyproject.toml +42 -0
- functualize_tasks_local-0.1.0/src/functualize_tasks_local/__init__.py +13 -0
- functualize_tasks_local-0.1.0/src/functualize_tasks_local/_plugin.py +79 -0
- functualize_tasks_local-0.1.0/src/functualize_tasks_local/_provider.py +162 -0
- functualize_tasks_local-0.1.0/src/functualize_tasks_local/py.typed +0 -0
- functualize_tasks_local-0.1.0/tests/__init__.py +0 -0
- functualize_tasks_local-0.1.0/tests/conftest.py +1 -0
- functualize_tasks_local-0.1.0/tests/test_local_provider.py +97 -0
- functualize_tasks_local-0.1.0/tests/test_plugin.py +15 -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,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: functualize-tasks-local
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local state-backed task storage plugin for functualize
|
|
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: functualize-state<1.0.0,>=0.1.0
|
|
15
|
+
Requires-Dist: functualize-tasks<1.0.0,>=0.1.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# functualize-tasks-local
|
|
22
|
+
|
|
23
|
+
> **Status: Published** — Independently installable from PyPI.
|
|
24
|
+
|
|
25
|
+
Local state-backed task storage plugin for functualize. Provides a `TaskProvider`
|
|
26
|
+
implementation that persists tasks as JSON blobs in the active `StateBackend`,
|
|
27
|
+
using keys prefixed with `tasks:`. Zero external dependencies beyond the
|
|
28
|
+
functualize workspace packages — if you have a state backend registered, this
|
|
29
|
+
plugin gives you a fully functional task queue with no additional infrastructure.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install functualize-tasks-local
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from functualize_state import InMemoryState
|
|
41
|
+
from functualize_tasks import TaskStatus
|
|
42
|
+
from functualize_tasks_local import LocalTaskProvider
|
|
43
|
+
|
|
44
|
+
# Create a provider backed by an in-memory state store
|
|
45
|
+
backend = InMemoryState()
|
|
46
|
+
provider = LocalTaskProvider(backend=backend)
|
|
47
|
+
|
|
48
|
+
# Add a task and retrieve it
|
|
49
|
+
task_id = provider.add("Deploy staging server")
|
|
50
|
+
tasks = provider.list(status=TaskStatus.PENDING)
|
|
51
|
+
print(tasks[0].title) # "Deploy staging server"
|
|
52
|
+
|
|
53
|
+
# Update status and clean up
|
|
54
|
+
provider.update(task_id, status=TaskStatus.DONE)
|
|
55
|
+
provider.delete(task_id)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
- **State-backed persistence** — delegates all storage to the active `StateBackend`, so tasks survive restarts when using a durable backend like SQLite
|
|
61
|
+
- **Automatic plugin registration** — registers via the `functualize.tasks_providers` entry point with name `"local"`, no manual wiring required
|
|
62
|
+
- **Full CRUD operations** — create, list, update, delete, and link tasks with filtering by status or title substring
|
|
63
|
+
- **Zero external dependencies** — only depends on `functualize-tasks` and `functualize-state` from the workspace
|
|
64
|
+
- **JSON serialization** — each task stored as a compact JSON blob under `tasks:{task_id}`, easily inspectable for debugging
|
|
65
|
+
|
|
66
|
+
## API Reference
|
|
67
|
+
|
|
68
|
+
Public classes exported by this plugin:
|
|
69
|
+
|
|
70
|
+
- `LocalTaskProvider` — `TaskProvider` implementation that stores tasks in a `StateBackend` with `tasks:` key prefix. Methods: `add()`, `list()`, `update()`, `delete()`, `link()`
|
|
71
|
+
- `LocalTasksPlugin` — Plugin entry point that resolves the active `StateBackend` from DI at boot and registers a `LocalTaskProvider` as the `TaskProvider` implementation
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
Run plugin tests:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv run pytest plugins/functualize-tasks-local/tests/ -v
|
|
79
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# functualize-tasks-local
|
|
2
|
+
|
|
3
|
+
> **Status: Published** — Independently installable from PyPI.
|
|
4
|
+
|
|
5
|
+
Local state-backed task storage plugin for functualize. Provides a `TaskProvider`
|
|
6
|
+
implementation that persists tasks as JSON blobs in the active `StateBackend`,
|
|
7
|
+
using keys prefixed with `tasks:`. Zero external dependencies beyond the
|
|
8
|
+
functualize workspace packages — if you have a state backend registered, this
|
|
9
|
+
plugin gives you a fully functional task queue with no additional infrastructure.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install functualize-tasks-local
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from functualize_state import InMemoryState
|
|
21
|
+
from functualize_tasks import TaskStatus
|
|
22
|
+
from functualize_tasks_local import LocalTaskProvider
|
|
23
|
+
|
|
24
|
+
# Create a provider backed by an in-memory state store
|
|
25
|
+
backend = InMemoryState()
|
|
26
|
+
provider = LocalTaskProvider(backend=backend)
|
|
27
|
+
|
|
28
|
+
# Add a task and retrieve it
|
|
29
|
+
task_id = provider.add("Deploy staging server")
|
|
30
|
+
tasks = provider.list(status=TaskStatus.PENDING)
|
|
31
|
+
print(tasks[0].title) # "Deploy staging server"
|
|
32
|
+
|
|
33
|
+
# Update status and clean up
|
|
34
|
+
provider.update(task_id, status=TaskStatus.DONE)
|
|
35
|
+
provider.delete(task_id)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **State-backed persistence** — delegates all storage to the active `StateBackend`, so tasks survive restarts when using a durable backend like SQLite
|
|
41
|
+
- **Automatic plugin registration** — registers via the `functualize.tasks_providers` entry point with name `"local"`, no manual wiring required
|
|
42
|
+
- **Full CRUD operations** — create, list, update, delete, and link tasks with filtering by status or title substring
|
|
43
|
+
- **Zero external dependencies** — only depends on `functualize-tasks` and `functualize-state` from the workspace
|
|
44
|
+
- **JSON serialization** — each task stored as a compact JSON blob under `tasks:{task_id}`, easily inspectable for debugging
|
|
45
|
+
|
|
46
|
+
## API Reference
|
|
47
|
+
|
|
48
|
+
Public classes exported by this plugin:
|
|
49
|
+
|
|
50
|
+
- `LocalTaskProvider` — `TaskProvider` implementation that stores tasks in a `StateBackend` with `tasks:` key prefix. Methods: `add()`, `list()`, `update()`, `delete()`, `link()`
|
|
51
|
+
- `LocalTasksPlugin` — Plugin entry point that resolves the active `StateBackend` from DI at boot and registers a `LocalTaskProvider` as the `TaskProvider` implementation
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
Run plugin tests:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv run pytest plugins/functualize-tasks-local/tests/ -v
|
|
59
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# functualize-tasks-local Examples
|
|
2
|
+
|
|
3
|
+
The local task provider: stores tasks in any `StateBackend`, so task lists persist wherever your state does.
|
|
4
|
+
|
|
5
|
+
| Directory | Demonstrates |
|
|
6
|
+
|-----------|--------------|
|
|
7
|
+
| [`todo_local/`](todo_local/) | `LocalTaskProvider` wired to a state backend, driven through the `Tasks` capability |
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv run pytest plugins/functualize-tasks-local/examples/ -v
|
|
11
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Tests for the todo_local 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_local import checklist
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_one_item_remains_open():
|
|
13
|
+
assert checklist(MagicMock()) == 1
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Tasks stored in a state backend via LocalTaskProvider.
|
|
2
|
+
|
|
3
|
+
Pairs the tasks domain with the state domain: tasks live wherever your
|
|
4
|
+
StateBackend does (in-memory here; SQLite in production). In a real app
|
|
5
|
+
`LocalTasksPlugin` wires this automatically at boot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from functualize_state.testing import InMemoryState
|
|
9
|
+
from functualize_tasks import Tasks, TaskStatus
|
|
10
|
+
from functualize_tasks_local import LocalTaskProvider
|
|
11
|
+
|
|
12
|
+
from functualize.job import RunContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def checklist(rc: RunContext) -> int:
|
|
16
|
+
"""Create a checklist and report how many items remain open."""
|
|
17
|
+
backend = InMemoryState()
|
|
18
|
+
tasks = Tasks(_provider=LocalTaskProvider(backend))
|
|
19
|
+
|
|
20
|
+
first = tasks.add("Write the report")
|
|
21
|
+
tasks.add("Review the report")
|
|
22
|
+
|
|
23
|
+
tasks.update(first, status=TaskStatus.DONE)
|
|
24
|
+
|
|
25
|
+
open_items = [t for t in tasks.list() if t.status is not TaskStatus.DONE]
|
|
26
|
+
rc.log(f"{len(open_items)} item(s) still open")
|
|
27
|
+
return len(open_items)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "functualize-tasks-local"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Local state-backed task storage plugin for functualize"
|
|
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
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"functualize-tasks>=0.1.0,<1.0.0",
|
|
21
|
+
"functualize-state>=0.1.0,<1.0.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.entry-points."functualize.tasks_providers"]
|
|
25
|
+
local = "functualize_tasks_local:LocalTasksPlugin"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=7.4.0",
|
|
30
|
+
"pytest-cov>=4.1.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.uv.sources]
|
|
38
|
+
functualize-tasks = { workspace = true }
|
|
39
|
+
functualize-state = { workspace = true }
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/functualize_tasks_local"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Functualize Tasks Local Plugin — state-backed task storage.
|
|
2
|
+
|
|
3
|
+
Provides a TaskProvider implementation that stores tasks in the active
|
|
4
|
+
StateBackend using keys prefixed with ``tasks:``. Zero external dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from functualize_tasks_local._plugin import LocalTasksPlugin
|
|
8
|
+
from functualize_tasks_local._provider import LocalTaskProvider
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"LocalTasksPlugin",
|
|
12
|
+
"LocalTaskProvider",
|
|
13
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Local Tasks Plugin — DI registration.
|
|
2
|
+
|
|
3
|
+
Registers LocalTaskProvider as TaskProvider with the DI registry via
|
|
4
|
+
app.provide(). Uses the active StateBackend for task storage with
|
|
5
|
+
keys prefixed ``tasks:``.
|
|
6
|
+
|
|
7
|
+
Registered via entry point ``functualize.tasks_providers`` with name "local".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from functualize_state import StateBackend
|
|
16
|
+
from functualize_tasks import TaskProvider
|
|
17
|
+
|
|
18
|
+
from functualize_tasks_local._provider import LocalTaskProvider
|
|
19
|
+
|
|
20
|
+
__all__ = ["LocalTasksPlugin"]
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LocalTasksPlugin:
|
|
26
|
+
"""Plugin that registers a local StateBackend-backed TaskProvider.
|
|
27
|
+
|
|
28
|
+
At boot time (APP_READY), resolves the active StateBackend from the DI
|
|
29
|
+
registry, creates a LocalTaskProvider wrapping it, and registers the
|
|
30
|
+
provider as the TaskProvider implementation via app.provide().
|
|
31
|
+
|
|
32
|
+
Implements the plugin callable protocol expected by functualize's plugin
|
|
33
|
+
discovery system.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str = "tasks-local"
|
|
37
|
+
version: str = "0.1.0"
|
|
38
|
+
description: str = "Local state-backed TaskProvider using tasks: prefix"
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._provider: LocalTaskProvider | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def provider(self) -> LocalTaskProvider | None:
|
|
45
|
+
"""The LocalTaskProvider instance (available after APP_READY)."""
|
|
46
|
+
return self._provider
|
|
47
|
+
|
|
48
|
+
def __call__(self, app: Any) -> None:
|
|
49
|
+
"""Register the plugin with the application instance.
|
|
50
|
+
|
|
51
|
+
Hooks into APP_READY for initialization and DI registration.
|
|
52
|
+
"""
|
|
53
|
+
from functualize._events.hooks import HookEvent
|
|
54
|
+
|
|
55
|
+
app.hook_registry.register_global(HookEvent.APP_READY, self._on_app_ready)
|
|
56
|
+
|
|
57
|
+
def _on_app_ready(self, app: Any) -> None:
|
|
58
|
+
"""Initialize LocalTaskProvider and register with DI registry.
|
|
59
|
+
|
|
60
|
+
Resolves the StateBackend from the DI registry and creates a
|
|
61
|
+
LocalTaskProvider backed by it. Registers the provider as
|
|
62
|
+
TaskProvider via app.provide().
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
# Resolve the active StateBackend from DI
|
|
66
|
+
backend = app.resolve(StateBackend)
|
|
67
|
+
|
|
68
|
+
# Create local provider backed by the state backend
|
|
69
|
+
self._provider = LocalTaskProvider(backend=backend)
|
|
70
|
+
|
|
71
|
+
# Register as TaskProvider
|
|
72
|
+
app.provide(TaskProvider, self._provider)
|
|
73
|
+
|
|
74
|
+
logger.debug(
|
|
75
|
+
"LocalTasksPlugin: Registered TaskProvider (state-backed, "
|
|
76
|
+
"prefix='tasks:')"
|
|
77
|
+
)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error("LocalTasksPlugin: Failed to initialize: %s", e)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Local TaskProvider implementation backed by StateBackend.
|
|
2
|
+
|
|
3
|
+
Stores tasks as JSON in the active StateBackend using keys prefixed with
|
|
4
|
+
``tasks:``. Each task is stored under ``tasks:{task_id}`` as a JSON-encoded
|
|
5
|
+
dict containing all TaskItem fields.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from functualize_tasks import TaskItem, TaskLink, TaskNotFoundError, TaskStatus
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from functualize_state import StateBackend
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LocalTaskProvider:
|
|
22
|
+
"""TaskProvider implementation using StateBackend with ``tasks:`` prefix.
|
|
23
|
+
|
|
24
|
+
Each task is stored as a JSON blob under the key ``tasks:{task_id}``.
|
|
25
|
+
Listing operations scan all keys with the ``tasks:`` prefix and
|
|
26
|
+
deserialize them for filtering.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
PREFIX = "tasks:"
|
|
30
|
+
|
|
31
|
+
def __init__(self, backend: StateBackend) -> None:
|
|
32
|
+
self._backend = backend
|
|
33
|
+
|
|
34
|
+
def _task_key(self, task_id: str) -> str:
|
|
35
|
+
"""Return the full state key for a task ID."""
|
|
36
|
+
return f"{self.PREFIX}{task_id}"
|
|
37
|
+
|
|
38
|
+
def _serialize_task(self, task: TaskItem) -> str:
|
|
39
|
+
"""Serialize a TaskItem to JSON string."""
|
|
40
|
+
data: dict = {
|
|
41
|
+
"id": task.id,
|
|
42
|
+
"title": task.title,
|
|
43
|
+
"status": task.status.value,
|
|
44
|
+
"linked_to": None,
|
|
45
|
+
"notes": task.notes,
|
|
46
|
+
"creator": task.creator,
|
|
47
|
+
"created_at": task.created_at,
|
|
48
|
+
}
|
|
49
|
+
if task.linked_to is not None:
|
|
50
|
+
data["linked_to"] = {
|
|
51
|
+
"kind": task.linked_to.kind,
|
|
52
|
+
"target": task.linked_to.target,
|
|
53
|
+
}
|
|
54
|
+
return json.dumps(data)
|
|
55
|
+
|
|
56
|
+
def _deserialize_task(self, raw: str) -> TaskItem:
|
|
57
|
+
"""Deserialize a JSON string to a TaskItem."""
|
|
58
|
+
data = json.loads(raw)
|
|
59
|
+
linked_to = None
|
|
60
|
+
if data.get("linked_to") is not None:
|
|
61
|
+
linked_to = TaskLink(
|
|
62
|
+
kind=data["linked_to"]["kind"],
|
|
63
|
+
target=data["linked_to"]["target"],
|
|
64
|
+
)
|
|
65
|
+
return TaskItem(
|
|
66
|
+
id=data["id"],
|
|
67
|
+
title=data["title"],
|
|
68
|
+
status=TaskStatus(data["status"]),
|
|
69
|
+
linked_to=linked_to,
|
|
70
|
+
notes=data.get("notes"),
|
|
71
|
+
creator=data.get("creator"),
|
|
72
|
+
created_at=data.get("created_at"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _get_task(self, task_id: str) -> TaskItem:
|
|
76
|
+
"""Retrieve a task by ID, raising TaskNotFoundError if it doesn't exist."""
|
|
77
|
+
raw = self._backend.get(self._task_key(task_id))
|
|
78
|
+
if raw is None:
|
|
79
|
+
raise TaskNotFoundError(f"Task '{task_id}' not found")
|
|
80
|
+
return self._deserialize_task(raw)
|
|
81
|
+
|
|
82
|
+
def add(self, title: str, linked_to: TaskLink | None = None) -> str:
|
|
83
|
+
"""Create a new task and return its generated unique ID."""
|
|
84
|
+
task_id = uuid.uuid4().hex[:12]
|
|
85
|
+
task = TaskItem(
|
|
86
|
+
id=task_id,
|
|
87
|
+
title=title,
|
|
88
|
+
status=TaskStatus.PENDING,
|
|
89
|
+
linked_to=linked_to,
|
|
90
|
+
notes=None,
|
|
91
|
+
creator=None,
|
|
92
|
+
created_at=time.time(),
|
|
93
|
+
)
|
|
94
|
+
self._backend.set(self._task_key(task_id), self._serialize_task(task))
|
|
95
|
+
return task_id
|
|
96
|
+
|
|
97
|
+
def list(
|
|
98
|
+
self, status: TaskStatus | None = None, filter: str | None = None
|
|
99
|
+
) -> list[TaskItem]:
|
|
100
|
+
"""List tasks, optionally filtered by status or title substring."""
|
|
101
|
+
keys = self._backend.keys(self.PREFIX)
|
|
102
|
+
tasks: list[TaskItem] = []
|
|
103
|
+
for key in keys:
|
|
104
|
+
raw = self._backend.get(key)
|
|
105
|
+
if raw is None:
|
|
106
|
+
continue
|
|
107
|
+
task = self._deserialize_task(raw)
|
|
108
|
+
if status is not None and task.status != status:
|
|
109
|
+
continue
|
|
110
|
+
if filter is not None and filter not in task.title:
|
|
111
|
+
continue
|
|
112
|
+
tasks.append(task)
|
|
113
|
+
return tasks
|
|
114
|
+
|
|
115
|
+
def update(
|
|
116
|
+
self, task_id: str, status: TaskStatus | None = None, notes: str | None = None
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Update a task's status and/or notes.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
TaskNotFoundError: If the task_id does not exist.
|
|
122
|
+
"""
|
|
123
|
+
task = self._get_task(task_id)
|
|
124
|
+
# Build updated task (TaskItem is frozen, so we reconstruct)
|
|
125
|
+
updated = TaskItem(
|
|
126
|
+
id=task.id,
|
|
127
|
+
title=task.title,
|
|
128
|
+
status=status if status is not None else task.status,
|
|
129
|
+
linked_to=task.linked_to,
|
|
130
|
+
notes=notes if notes is not None else task.notes,
|
|
131
|
+
creator=task.creator,
|
|
132
|
+
created_at=task.created_at,
|
|
133
|
+
)
|
|
134
|
+
self._backend.set(self._task_key(task_id), self._serialize_task(updated))
|
|
135
|
+
|
|
136
|
+
def delete(self, task_id: str) -> None:
|
|
137
|
+
"""Delete a task by its ID.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
TaskNotFoundError: If the task_id does not exist.
|
|
141
|
+
"""
|
|
142
|
+
# Verify existence first
|
|
143
|
+
self._get_task(task_id)
|
|
144
|
+
self._backend.delete(self._task_key(task_id))
|
|
145
|
+
|
|
146
|
+
def link(self, task_id: str, linked_to: TaskLink) -> None:
|
|
147
|
+
"""Associate a task with a job, workflow step, or job phase.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
TaskNotFoundError: If the task_id does not exist.
|
|
151
|
+
"""
|
|
152
|
+
task = self._get_task(task_id)
|
|
153
|
+
updated = TaskItem(
|
|
154
|
+
id=task.id,
|
|
155
|
+
title=task.title,
|
|
156
|
+
status=task.status,
|
|
157
|
+
linked_to=linked_to,
|
|
158
|
+
notes=task.notes,
|
|
159
|
+
creator=task.creator,
|
|
160
|
+
created_at=task.created_at,
|
|
161
|
+
)
|
|
162
|
+
self._backend.set(self._task_key(task_id), self._serialize_task(updated))
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared fixtures for functualize-tasks-local plugin tests."""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Functional tests for LocalTaskProvider.
|
|
2
|
+
|
|
3
|
+
Tests local store/retrieve tasks, list filtering, and state persistence
|
|
4
|
+
via InMemoryState fixture.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from functualize_state import InMemoryState
|
|
11
|
+
from functualize_tasks import TaskLink, TaskNotFoundError, TaskStatus
|
|
12
|
+
from functualize_tasks_local import LocalTaskProvider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def backend() -> InMemoryState:
|
|
17
|
+
"""Provide a fresh InMemoryState backend for each test."""
|
|
18
|
+
return InMemoryState()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def provider(backend: InMemoryState) -> LocalTaskProvider:
|
|
23
|
+
"""Provide a LocalTaskProvider backed by InMemoryState."""
|
|
24
|
+
return LocalTaskProvider(backend=backend)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestStoreAndRetrieve:
|
|
28
|
+
"""Tests for adding and listing tasks."""
|
|
29
|
+
|
|
30
|
+
def test_add_task_returns_id_and_persists(
|
|
31
|
+
self, provider: LocalTaskProvider
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Adding a task returns a unique ID and is retrievable via list."""
|
|
34
|
+
task_id = provider.add("Deploy service")
|
|
35
|
+
|
|
36
|
+
assert task_id is not None
|
|
37
|
+
assert len(task_id) == 12 # uuid4 hex[:12]
|
|
38
|
+
|
|
39
|
+
tasks = provider.list()
|
|
40
|
+
assert len(tasks) == 1
|
|
41
|
+
assert tasks[0].id == task_id
|
|
42
|
+
assert tasks[0].title == "Deploy service"
|
|
43
|
+
assert tasks[0].status == TaskStatus.PENDING
|
|
44
|
+
|
|
45
|
+
def test_add_task_with_link(self, provider: LocalTaskProvider) -> None:
|
|
46
|
+
"""Adding a task with a TaskLink persists the link correctly."""
|
|
47
|
+
link = TaskLink(kind="job", target="deploy-job")
|
|
48
|
+
provider.add("Linked task", linked_to=link)
|
|
49
|
+
|
|
50
|
+
tasks = provider.list()
|
|
51
|
+
assert len(tasks) == 1
|
|
52
|
+
assert tasks[0].linked_to is not None
|
|
53
|
+
assert tasks[0].linked_to.kind == "job"
|
|
54
|
+
assert tasks[0].linked_to.target == "deploy-job"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestListFiltering:
|
|
58
|
+
"""Tests for filtering tasks by status and title substring."""
|
|
59
|
+
|
|
60
|
+
def test_filter_by_status(self, provider: LocalTaskProvider) -> None:
|
|
61
|
+
"""Listing with a status filter returns only matching tasks."""
|
|
62
|
+
id1 = provider.add("Task A")
|
|
63
|
+
id2 = provider.add("Task B")
|
|
64
|
+
provider.update(id1, status=TaskStatus.DONE)
|
|
65
|
+
|
|
66
|
+
pending = provider.list(status=TaskStatus.PENDING)
|
|
67
|
+
done = provider.list(status=TaskStatus.DONE)
|
|
68
|
+
|
|
69
|
+
assert len(pending) == 1
|
|
70
|
+
assert pending[0].id == id2
|
|
71
|
+
assert len(done) == 1
|
|
72
|
+
assert done[0].id == id1
|
|
73
|
+
|
|
74
|
+
def test_filter_by_title_substring(self, provider: LocalTaskProvider) -> None:
|
|
75
|
+
"""Listing with a filter string returns only tasks whose title contains it."""
|
|
76
|
+
provider.add("Deploy service")
|
|
77
|
+
provider.add("Run migrations")
|
|
78
|
+
provider.add("Deploy database")
|
|
79
|
+
|
|
80
|
+
results = provider.list(filter="Deploy")
|
|
81
|
+
assert len(results) == 2
|
|
82
|
+
titles = {t.title for t in results}
|
|
83
|
+
assert titles == {"Deploy service", "Deploy database"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestErrorHandling:
|
|
87
|
+
"""Tests for error cases and proper exception raising."""
|
|
88
|
+
|
|
89
|
+
def test_update_nonexistent_task_raises(self, provider: LocalTaskProvider) -> None:
|
|
90
|
+
"""Updating a task that doesn't exist raises TaskNotFoundError."""
|
|
91
|
+
with pytest.raises(TaskNotFoundError):
|
|
92
|
+
provider.update("nonexistent-id", status=TaskStatus.DONE)
|
|
93
|
+
|
|
94
|
+
def test_delete_nonexistent_task_raises(self, provider: LocalTaskProvider) -> None:
|
|
95
|
+
"""Deleting a task that doesn't exist raises TaskNotFoundError."""
|
|
96
|
+
with pytest.raises(TaskNotFoundError):
|
|
97
|
+
provider.delete("nonexistent-id")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Unit tests for functualize-tasks-local plugin.
|
|
2
|
+
|
|
3
|
+
Tests the local task runner implementation.
|
|
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_local
|
|
14
|
+
|
|
15
|
+
assert dir(functualize_tasks_local)
|