pytaskwarrior 1.1.0__tar.gz → 1.2.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.
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/PKG-INFO +8 -7
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/PYPI_README.md +7 -6
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/README.md +40 -10
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/pyproject.toml +1 -1
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/PKG-INFO +8 -7
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/SOURCES.txt +1 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/__init__.py +14 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/adapters/taskwarrior_adapter.py +81 -105
- pytaskwarrior-1.2.0/src/taskwarrior/config/config_store.py +130 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/exceptions.py +46 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/main.py +99 -24
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/registry/uda_registry.py +2 -2
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/services/context_service.py +17 -31
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/services/uda_service.py +10 -2
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/utils/conversions.py +5 -2
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/LICENSE +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/setup.cfg +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/__init__.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/dependency_links.txt +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/requires.txt +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/top_level.txt +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/adapters/__init__.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/__init__.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/annotation_dto.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/context_dto.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/task_dto.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/uda_dto.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/enums.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/py.typed +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/registry/__init__.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/services/__init__.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/utils/__init__.py +0 -0
- {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/utils/dto_converter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytaskwarrior
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Taskwarrior wrapper python module
|
|
5
5
|
Author-email: sznicolas <sznicolas@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -34,15 +34,16 @@ Dynamic: license-file
|
|
|
34
34
|
|
|
35
35
|
A modern Python wrapper for [TaskWarrior](https://taskwarrior.org/), the command-line task management tool.
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Production-ready with 164 tests (96% coverage), strict type checking, and professional-grade code quality. Zero linting errors, full async-safe subprocess handling, PEP 561 type hints for IDE support, and a consistent exception hierarchy.
|
|
38
38
|
|
|
39
39
|
## Features
|
|
40
40
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
41
|
+
- Full CRUD operations for tasks
|
|
42
|
+
- Type-safe with Pydantic models
|
|
43
|
+
- Context management
|
|
44
|
+
- UDA (User Defined Attributes) support
|
|
45
|
+
- Recurring tasks and annotations
|
|
46
|
+
- Consistent exception hierarchy (`TaskNotFound`, `TaskValidationError`, `TaskOperationError`, `TaskConfigurationError`, …)
|
|
46
47
|
|
|
47
48
|
## Requirements
|
|
48
49
|
|
|
@@ -9,15 +9,16 @@
|
|
|
9
9
|
|
|
10
10
|
A modern Python wrapper for [TaskWarrior](https://taskwarrior.org/), the command-line task management tool.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Production-ready with 164 tests (96% coverage), strict type checking, and professional-grade code quality. Zero linting errors, full async-safe subprocess handling, PEP 561 type hints for IDE support, and a consistent exception hierarchy.
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
16
|
+
- Full CRUD operations for tasks
|
|
17
|
+
- Type-safe with Pydantic models
|
|
18
|
+
- Context management
|
|
19
|
+
- UDA (User Defined Attributes) support
|
|
20
|
+
- Recurring tasks and annotations
|
|
21
|
+
- Consistent exception hierarchy (`TaskNotFound`, `TaskValidationError`, `TaskOperationError`, `TaskConfigurationError`, …)
|
|
21
22
|
|
|
22
23
|
## Requirements
|
|
23
24
|
|
|
@@ -9,17 +9,17 @@
|
|
|
9
9
|
|
|
10
10
|
A modern Python wrapper for [TaskWarrior](https://taskwarrior.org/) v3.4, the command-line task management tool.
|
|
11
11
|
|
|
12
|
-
**v1.
|
|
12
|
+
**v1.2.0**: Production-ready with 164 tests (96% coverage), strict type checking, and professional-grade code quality. Zero linting errors, full async-safe subprocess handling, PEP 561 type hints for IDE support, and a consistent exception hierarchy.
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
16
|
+
- **Full CRUD operations** - Create, read, update, delete tasks
|
|
17
|
+
- **Type-safe** - Pydantic models with full type hints
|
|
18
|
+
- **Context management** - Define, apply, and switch contexts
|
|
19
|
+
- **UDA support** - User Defined Attributes
|
|
20
|
+
- **Recurring tasks** - Full recurrence support
|
|
21
|
+
- **Annotations** - Add notes to tasks
|
|
22
|
+
- **Date calculations** - Use TaskWarrior's date expressions
|
|
23
23
|
|
|
24
24
|
## Requirements
|
|
25
25
|
|
|
@@ -31,7 +31,7 @@ A modern Python wrapper for [TaskWarrior](https://taskwarrior.org/) v3.4, the co
|
|
|
31
31
|
## Installation
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
pip install pytaskwarrior==1.
|
|
34
|
+
pip install pytaskwarrior==1.2.0
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
Or install from source:
|
|
@@ -140,6 +140,37 @@ tw = TaskWarrior(
|
|
|
140
140
|
| `delete_context(name)` | Remove a context |
|
|
141
141
|
| `has_context(name)` | Check if context exists |
|
|
142
142
|
|
|
143
|
+
#### Synchronization Operations
|
|
144
|
+
|
|
145
|
+
| Method | Description |
|
|
146
|
+
|--------|-------------|
|
|
147
|
+
| `is_sync_configured()` | Return `True` if any `sync.*` key is present in taskrc. |
|
|
148
|
+
| `synchronize()` | Run `task sync`; raises `TaskSyncError` if not configured or sync fails. |
|
|
149
|
+
|
|
150
|
+
### Exceptions
|
|
151
|
+
|
|
152
|
+
All exceptions inherit from `TaskWarriorError` and are importable from the top-level package:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from taskwarrior import (
|
|
156
|
+
TaskWarriorError, # Base class — catch all library errors
|
|
157
|
+
TaskNotFound, # Task does not exist
|
|
158
|
+
TaskValidationError, # Invalid input data (empty description, etc.)
|
|
159
|
+
TaskOperationError, # Operation failed on an existing task
|
|
160
|
+
TaskConfigurationError, # Environment issue (binary not found, taskrc missing)
|
|
161
|
+
TaskSyncError, # Synchronization failure
|
|
162
|
+
)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
| Exception | Raised when |
|
|
166
|
+
|-----------|-------------|
|
|
167
|
+
| `TaskWarriorError` | Base class; catch-all for any library error |
|
|
168
|
+
| `TaskNotFound` | The requested task does not exist |
|
|
169
|
+
| `TaskValidationError` | Input data is invalid (e.g., empty description) |
|
|
170
|
+
| `TaskOperationError` | A write operation failed on an existing task (delete, done, start…) |
|
|
171
|
+
| `TaskConfigurationError` | Environment error (binary not in PATH, taskrc missing/unreadable) |
|
|
172
|
+
| `TaskSyncError` | Sync backend not configured or synchronization failed |
|
|
173
|
+
|
|
143
174
|
### Data Models
|
|
144
175
|
|
|
145
176
|
#### TaskInputDTO
|
|
@@ -340,4 +371,3 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
|
|
340
371
|
- [TaskWarrior](https://taskwarrior.org/) - The underlying task management tool
|
|
341
372
|
- [GitHub Repository](https://github.com/sznicolas/pytaskwarrior/)
|
|
342
373
|
- [PyPI Package](https://pypi.org/project/pytaskwarrior/)
|
|
343
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytaskwarrior
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Taskwarrior wrapper python module
|
|
5
5
|
Author-email: sznicolas <sznicolas@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -34,15 +34,16 @@ Dynamic: license-file
|
|
|
34
34
|
|
|
35
35
|
A modern Python wrapper for [TaskWarrior](https://taskwarrior.org/), the command-line task management tool.
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Production-ready with 164 tests (96% coverage), strict type checking, and professional-grade code quality. Zero linting errors, full async-safe subprocess handling, PEP 561 type hints for IDE support, and a consistent exception hierarchy.
|
|
38
38
|
|
|
39
39
|
## Features
|
|
40
40
|
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
41
|
+
- Full CRUD operations for tasks
|
|
42
|
+
- Type-safe with Pydantic models
|
|
43
|
+
- Context management
|
|
44
|
+
- UDA (User Defined Attributes) support
|
|
45
|
+
- Recurring tasks and annotations
|
|
46
|
+
- Consistent exception hierarchy (`TaskNotFound`, `TaskValidationError`, `TaskOperationError`, `TaskConfigurationError`, …)
|
|
46
47
|
|
|
47
48
|
## Requirements
|
|
48
49
|
|
|
@@ -15,6 +15,7 @@ src/taskwarrior/main.py
|
|
|
15
15
|
src/taskwarrior/py.typed
|
|
16
16
|
src/taskwarrior/adapters/__init__.py
|
|
17
17
|
src/taskwarrior/adapters/taskwarrior_adapter.py
|
|
18
|
+
src/taskwarrior/config/config_store.py
|
|
18
19
|
src/taskwarrior/dto/__init__.py
|
|
19
20
|
src/taskwarrior/dto/annotation_dto.py
|
|
20
21
|
src/taskwarrior/dto/context_dto.py
|
|
@@ -39,6 +39,14 @@ from .dto.context_dto import ContextDTO
|
|
|
39
39
|
from .dto.task_dto import TaskInputDTO, TaskOutputDTO
|
|
40
40
|
from .dto.uda_dto import UdaConfig, UdaType
|
|
41
41
|
from .enums import Priority, RecurrencePeriod, TaskStatus
|
|
42
|
+
from .exceptions import (
|
|
43
|
+
TaskConfigurationError,
|
|
44
|
+
TaskNotFound,
|
|
45
|
+
TaskOperationError,
|
|
46
|
+
TaskSyncError,
|
|
47
|
+
TaskValidationError,
|
|
48
|
+
TaskWarriorError,
|
|
49
|
+
)
|
|
42
50
|
from .main import TaskWarrior
|
|
43
51
|
from .registry.uda_registry import UdaRegistry
|
|
44
52
|
from .utils.dto_converter import task_output_to_input
|
|
@@ -54,9 +62,15 @@ __all__ = [
|
|
|
54
62
|
"Priority",
|
|
55
63
|
"RecurrencePeriod",
|
|
56
64
|
"TaskStatus",
|
|
65
|
+
"TaskConfigurationError",
|
|
57
66
|
"TaskInputDTO",
|
|
67
|
+
"TaskNotFound",
|
|
68
|
+
"TaskOperationError",
|
|
58
69
|
"TaskOutputDTO",
|
|
70
|
+
"TaskSyncError",
|
|
71
|
+
"TaskValidationError",
|
|
59
72
|
"TaskWarrior",
|
|
73
|
+
"TaskWarriorError",
|
|
60
74
|
"task_output_to_input",
|
|
61
75
|
"UdaConfig",
|
|
62
76
|
"UdaRegistry",
|
|
@@ -5,37 +5,30 @@ This module provides the low-level interface to TaskWarrior CLI commands.
|
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
|
-
import os
|
|
9
8
|
import re
|
|
10
9
|
import shlex
|
|
11
10
|
import shutil
|
|
12
11
|
import subprocess
|
|
13
12
|
from pathlib import Path
|
|
14
|
-
from typing import TypedDict
|
|
15
13
|
from uuid import UUID
|
|
16
14
|
|
|
15
|
+
from ..config.config_store import ConfigStore
|
|
17
16
|
from ..dto.task_dto import TaskInputDTO, TaskOutputDTO
|
|
18
17
|
from ..enums import TaskStatus
|
|
19
|
-
from ..exceptions import
|
|
18
|
+
from ..exceptions import (
|
|
19
|
+
TaskConfigurationError,
|
|
20
|
+
TaskNotFound,
|
|
21
|
+
TaskOperationError,
|
|
22
|
+
TaskSyncError,
|
|
23
|
+
TaskValidationError,
|
|
24
|
+
TaskWarriorError,
|
|
25
|
+
)
|
|
20
26
|
|
|
21
27
|
logger = logging.getLogger(__name__)
|
|
22
28
|
|
|
23
|
-
DEFAULT_OPTIONS = [
|
|
24
|
-
"rc.confirmation=off",
|
|
25
|
-
"rc.bulk=0",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class TaskWarriorInfo(TypedDict, total=False):
|
|
30
|
-
"""Type definition for TaskWarrior configuration information."""
|
|
31
|
-
|
|
32
|
-
task_cmd: Path
|
|
33
|
-
taskrc_file: Path
|
|
34
|
-
options: list[str]
|
|
35
|
-
version: str
|
|
36
|
-
|
|
37
29
|
|
|
38
30
|
class TaskWarriorAdapter:
|
|
31
|
+
|
|
39
32
|
"""Low-level adapter for TaskWarrior CLI commands.
|
|
40
33
|
|
|
41
34
|
This class handles direct communication with the TaskWarrior binary,
|
|
@@ -44,65 +37,42 @@ class TaskWarriorAdapter:
|
|
|
44
37
|
|
|
45
38
|
Attributes:
|
|
46
39
|
task_cmd: Path to the TaskWarrior binary.
|
|
47
|
-
taskrc_file: Path to the taskrc configuration file.
|
|
48
|
-
data_location: Path to the task data directory.
|
|
49
40
|
"""
|
|
50
41
|
|
|
51
42
|
def __init__(
|
|
52
43
|
self,
|
|
44
|
+
config_store: ConfigStore,
|
|
53
45
|
task_cmd: str = "task",
|
|
54
|
-
taskrc_file: str = "~/.taskrc",
|
|
55
|
-
data_location: str | None = None
|
|
56
46
|
):
|
|
57
47
|
"""Initialize the adapter.
|
|
58
48
|
|
|
59
49
|
Args:
|
|
60
50
|
task_cmd: TaskWarrior binary name or path.
|
|
61
|
-
|
|
62
|
-
data_location: Path to data directory (optional).
|
|
51
|
+
config_store: The configuration store instance (required).
|
|
63
52
|
|
|
64
53
|
Raises:
|
|
65
|
-
|
|
54
|
+
TaskConfigurationError: If TaskWarrior binary not found.
|
|
66
55
|
"""
|
|
56
|
+
|
|
67
57
|
self.task_cmd: Path = self._check_binary_path(task_cmd)
|
|
68
|
-
self.
|
|
69
|
-
self.
|
|
70
|
-
self._options.extend([f"rc:{self.taskrc_file}"])
|
|
71
|
-
if data_location:
|
|
72
|
-
self.data_location: Path | None = Path(os.path.expandvars(data_location)).expanduser()
|
|
73
|
-
self._options.extend([f"rc.data.location={self.data_location}"])
|
|
74
|
-
else:
|
|
75
|
-
self.data_location = None
|
|
76
|
-
self._check_or_create_taskfiles()
|
|
58
|
+
self._cli_options: list[str] = config_store.cli_options
|
|
59
|
+
self._sync_configured: bool = bool(config_store.get_sync_config())
|
|
77
60
|
|
|
78
|
-
|
|
61
|
+
@property
|
|
62
|
+
def cli_options(self) -> list[str]:
|
|
63
|
+
"""Public accessor for CLI options."""
|
|
64
|
+
return self._cli_options
|
|
79
65
|
|
|
80
66
|
def _check_binary_path(self, task_cmd: str) -> Path:
|
|
81
67
|
"""Verify TaskWarrior binary exists in PATH."""
|
|
82
68
|
resolved_path = shutil.which(task_cmd)
|
|
83
69
|
if not resolved_path:
|
|
84
|
-
raise
|
|
85
|
-
f"TaskWarrior command '{task_cmd}' not found in PATH"
|
|
86
|
-
)
|
|
70
|
+
raise TaskConfigurationError(f"TaskWarrior command '{task_cmd}' not found in PATH")
|
|
87
71
|
return Path(resolved_path)
|
|
88
72
|
|
|
89
|
-
def
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
default_content = """# Taskwarrior configuration file
|
|
93
|
-
# This file was automatically created by pytaskwarrior
|
|
94
|
-
# Default data location
|
|
95
|
-
rc.data.location={data_location}
|
|
96
|
-
# Disable confirmation prompts
|
|
97
|
-
rc.confirmation=off
|
|
98
|
-
rc.bulk=0
|
|
99
|
-
""".format(data_location=self.data_location or "~/.task")
|
|
100
|
-
self.taskrc_file.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
-
self.taskrc_file.write_text(default_content)
|
|
102
|
-
logger.info(f"Created Taskrc file '{self.taskrc_file}'")
|
|
103
|
-
if self.data_location and not self.data_location.exists():
|
|
104
|
-
self.data_location.mkdir(parents=True, exist_ok=True)
|
|
105
|
-
logger.info(f"Created Task data direcory '{self.data_location}'")
|
|
73
|
+
def is_sync_configured(self) -> bool:
|
|
74
|
+
"""Return True if sync settings are present in taskrc (any ``sync.*`` key)."""
|
|
75
|
+
return self._sync_configured
|
|
106
76
|
|
|
107
77
|
def run_task_command(
|
|
108
78
|
self, args: list[str], no_opt: bool = False
|
|
@@ -119,7 +89,7 @@ rc.bulk=0
|
|
|
119
89
|
cmd = [str(self.task_cmd)]
|
|
120
90
|
# Options (rc:...) must come before command and filter arguments so they are applied properly.
|
|
121
91
|
if not no_opt:
|
|
122
|
-
cmd.extend(self.
|
|
92
|
+
cmd.extend(self._cli_options)
|
|
123
93
|
cmd.extend(args)
|
|
124
94
|
logger.debug(f"Running command: {' '.join(cmd)}")
|
|
125
95
|
|
|
@@ -144,7 +114,29 @@ rc.bulk=0
|
|
|
144
114
|
|
|
145
115
|
except (OSError, subprocess.SubprocessError) as e:
|
|
146
116
|
logger.error(f"Exception while running '{cmd}': {e}")
|
|
147
|
-
raise
|
|
117
|
+
raise TaskWarriorError(f"Command execution failed: {e}") from e
|
|
118
|
+
|
|
119
|
+
def synchronize(self) -> None:
|
|
120
|
+
"""Synchronize tasks by running ``task sync``.
|
|
121
|
+
|
|
122
|
+
Delegates to the TaskWarrior CLI's built-in sync command, which handles
|
|
123
|
+
both local (``sync.local.server_dir``) and remote (``sync.server.origin``)
|
|
124
|
+
synchronization based on the taskrc configuration.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
TaskSyncError: If no sync settings are configured, or if the sync
|
|
128
|
+
command exits with a non-zero return code.
|
|
129
|
+
"""
|
|
130
|
+
if not self._sync_configured:
|
|
131
|
+
raise TaskSyncError(
|
|
132
|
+
"No sync server is configured. "
|
|
133
|
+
"Add sync.* settings to your taskrc (e.g. sync.local.server_dir)."
|
|
134
|
+
)
|
|
135
|
+
result = self.run_task_command(["sync"])
|
|
136
|
+
if result.returncode != 0:
|
|
137
|
+
raise TaskSyncError(
|
|
138
|
+
f"Synchronization failed: {result.stderr or result.stdout}"
|
|
139
|
+
)
|
|
148
140
|
|
|
149
141
|
@staticmethod
|
|
150
142
|
def _wrap_filter(f: str) -> str:
|
|
@@ -225,7 +217,7 @@ rc.bulk=0
|
|
|
225
217
|
if not tasks:
|
|
226
218
|
error_msg = "Failed to retrieve added task"
|
|
227
219
|
logger.error(error_msg)
|
|
228
|
-
raise
|
|
220
|
+
raise TaskWarriorError(error_msg)
|
|
229
221
|
added_task = tasks[0]
|
|
230
222
|
|
|
231
223
|
if task.annotations:
|
|
@@ -235,9 +227,7 @@ rc.bulk=0
|
|
|
235
227
|
logger.info(f"Successfully added task with UUID: {added_task.uuid}")
|
|
236
228
|
return added_task
|
|
237
229
|
|
|
238
|
-
def modify_task(
|
|
239
|
-
self, task: TaskInputDTO, task_id_or_uuid: str | int | UUID
|
|
240
|
-
) -> TaskOutputDTO:
|
|
230
|
+
def modify_task(self, task: TaskInputDTO, task_id_or_uuid: str | int | UUID) -> TaskOutputDTO:
|
|
241
231
|
"""Modify an existing task. Returns the updated task."""
|
|
242
232
|
logger.info(f"Modifying task with UUID: {task_id_or_uuid}")
|
|
243
233
|
|
|
@@ -247,15 +237,13 @@ rc.bulk=0
|
|
|
247
237
|
if result.returncode != 0:
|
|
248
238
|
error_msg = f"Failed to modify task: {result.stderr}"
|
|
249
239
|
logger.error(error_msg)
|
|
250
|
-
raise
|
|
240
|
+
raise TaskWarriorError(error_msg)
|
|
251
241
|
|
|
252
242
|
updated_task = self.get_task(task_id_or_uuid)
|
|
253
243
|
logger.info(f"Successfully modified task with UUID: {task_id_or_uuid}")
|
|
254
244
|
return updated_task
|
|
255
245
|
|
|
256
|
-
def get_task(
|
|
257
|
-
self, task_id_or_uuid: str | int | UUID, filter_args: str = ""
|
|
258
|
-
) -> TaskOutputDTO:
|
|
246
|
+
def get_task(self, task_id_or_uuid: str | int | UUID, filter_args: str = "") -> TaskOutputDTO:
|
|
259
247
|
"""Retrieve a single task by ID or UUID."""
|
|
260
248
|
task_id_or_uuid = str(task_id_or_uuid)
|
|
261
249
|
logger.debug(f"Retrieving task with ID/UUID: {task_id_or_uuid}")
|
|
@@ -279,12 +267,12 @@ rc.bulk=0
|
|
|
279
267
|
)
|
|
280
268
|
except json.JSONDecodeError as e:
|
|
281
269
|
logger.error(f"Failed to parse JSON response: {e}")
|
|
282
|
-
raise
|
|
270
|
+
raise TaskWarriorError(
|
|
283
271
|
f"Invalid response from TaskWarrior: {result.stdout}"
|
|
284
272
|
) from e
|
|
285
273
|
else:
|
|
286
|
-
raise
|
|
287
|
-
f"
|
|
274
|
+
raise TaskNotFound(
|
|
275
|
+
f"Task ID/UUID {task_id_or_uuid} not found"
|
|
288
276
|
)
|
|
289
277
|
|
|
290
278
|
def get_tasks(
|
|
@@ -342,17 +330,12 @@ rc.bulk=0
|
|
|
342
330
|
|
|
343
331
|
try:
|
|
344
332
|
tasks_data = json.loads(result.stdout)
|
|
345
|
-
tasks = [
|
|
346
|
-
TaskOutputDTO.model_validate(task_data) for task_data in tasks_data
|
|
347
|
-
]
|
|
333
|
+
tasks = [TaskOutputDTO.model_validate(task_data) for task_data in tasks_data]
|
|
348
334
|
logger.debug(f"Retrieved {len(tasks)} tasks")
|
|
349
335
|
return tasks
|
|
350
336
|
except json.JSONDecodeError as e:
|
|
351
337
|
logger.error(f"Failed to parse JSON response: {e}")
|
|
352
|
-
raise
|
|
353
|
-
f"Invalid response from TaskWarrior: {result.stdout}"
|
|
354
|
-
) from e
|
|
355
|
-
|
|
338
|
+
raise TaskWarriorError(f"Invalid response from TaskWarrior: {result.stdout}") from e
|
|
356
339
|
|
|
357
340
|
def get_recurring_task(self, task_id_or_uuid: str | int | UUID) -> TaskOutputDTO:
|
|
358
341
|
"""Get the parent recurring task template."""
|
|
@@ -364,7 +347,13 @@ rc.bulk=0
|
|
|
364
347
|
)
|
|
365
348
|
|
|
366
349
|
if result.returncode == 0:
|
|
367
|
-
|
|
350
|
+
try:
|
|
351
|
+
tasks_data = json.loads(result.stdout)
|
|
352
|
+
except json.JSONDecodeError as e:
|
|
353
|
+
logger.error(f"Failed to parse JSON response: {e}")
|
|
354
|
+
raise TaskWarriorError(
|
|
355
|
+
f"Invalid response from TaskWarrior: {result.stdout}"
|
|
356
|
+
) from e
|
|
368
357
|
if tasks_data:
|
|
369
358
|
task = TaskOutputDTO.model_validate(tasks_data[0])
|
|
370
359
|
logger.debug(f"Successfully retrieved recurring task: {task.uuid}")
|
|
@@ -375,9 +364,7 @@ rc.bulk=0
|
|
|
375
364
|
)
|
|
376
365
|
return self.get_task(task_id_or_uuid)
|
|
377
366
|
|
|
378
|
-
def get_recurring_instances(
|
|
379
|
-
self, task_id_or_uuid: str | int | UUID
|
|
380
|
-
) -> list[TaskOutputDTO]:
|
|
367
|
+
def get_recurring_instances(self, task_id_or_uuid: str | int | UUID) -> list[TaskOutputDTO]:
|
|
381
368
|
"""Get all instances of a recurring task."""
|
|
382
369
|
task_id_or_uuid = str(task_id_or_uuid)
|
|
383
370
|
logger.debug(f"Getting recurring instances for parent UUID: {task_id_or_uuid}")
|
|
@@ -393,7 +380,7 @@ rc.bulk=0
|
|
|
393
380
|
return []
|
|
394
381
|
error_msg = f"Failed to get recurring instances: {result.stderr}"
|
|
395
382
|
logger.error(error_msg)
|
|
396
|
-
raise
|
|
383
|
+
raise TaskWarriorError(error_msg)
|
|
397
384
|
|
|
398
385
|
if not result.stdout.strip():
|
|
399
386
|
logger.debug("No recurring instances returned (empty response)")
|
|
@@ -401,14 +388,12 @@ rc.bulk=0
|
|
|
401
388
|
|
|
402
389
|
try:
|
|
403
390
|
tasks_data = json.loads(result.stdout)
|
|
404
|
-
tasks = [
|
|
405
|
-
TaskOutputDTO.model_validate(task_data) for task_data in tasks_data
|
|
406
|
-
]
|
|
391
|
+
tasks = [TaskOutputDTO.model_validate(task_data) for task_data in tasks_data]
|
|
407
392
|
logger.debug(f"Retrieved {len(tasks)} recurring instances")
|
|
408
393
|
return tasks
|
|
409
394
|
except json.JSONDecodeError as e:
|
|
410
395
|
logger.error(f"Failed to parse JSON response: {e}")
|
|
411
|
-
raise
|
|
396
|
+
raise TaskWarriorError(f"Invalid response from TaskWarrior: {result.stdout}") from e
|
|
412
397
|
|
|
413
398
|
def delete_task(self, task_id_or_uuid: str | int | UUID) -> None:
|
|
414
399
|
"""Mark a task as deleted."""
|
|
@@ -420,7 +405,7 @@ rc.bulk=0
|
|
|
420
405
|
if result.returncode != 0:
|
|
421
406
|
error_msg = f"Failed to delete task: {result.stderr}"
|
|
422
407
|
logger.error(error_msg)
|
|
423
|
-
raise
|
|
408
|
+
raise TaskOperationError(error_msg)
|
|
424
409
|
|
|
425
410
|
logger.info(f"Successfully deleted task: {task_ref}")
|
|
426
411
|
|
|
@@ -434,7 +419,7 @@ rc.bulk=0
|
|
|
434
419
|
if result.returncode != 0:
|
|
435
420
|
error_msg = f"Failed to purge task: {result.stderr}"
|
|
436
421
|
logger.error(error_msg)
|
|
437
|
-
raise
|
|
422
|
+
raise TaskOperationError(error_msg)
|
|
438
423
|
|
|
439
424
|
logger.info(f"Successfully purged task: {task_ref}")
|
|
440
425
|
|
|
@@ -448,7 +433,7 @@ rc.bulk=0
|
|
|
448
433
|
if result.returncode != 0:
|
|
449
434
|
error_msg = f"Failed to mark task as done: {result.stderr}"
|
|
450
435
|
logger.error(error_msg)
|
|
451
|
-
raise
|
|
436
|
+
raise TaskOperationError(error_msg)
|
|
452
437
|
|
|
453
438
|
logger.info(f"Successfully completed task: {task_ref}")
|
|
454
439
|
|
|
@@ -462,7 +447,7 @@ rc.bulk=0
|
|
|
462
447
|
if result.returncode != 0:
|
|
463
448
|
error_msg = f"Failed to start task: {result.stderr}"
|
|
464
449
|
logger.error(error_msg)
|
|
465
|
-
raise
|
|
450
|
+
raise TaskOperationError(error_msg)
|
|
466
451
|
|
|
467
452
|
logger.info(f"Successfully started task: {task_ref}")
|
|
468
453
|
|
|
@@ -476,7 +461,7 @@ rc.bulk=0
|
|
|
476
461
|
if result.returncode != 0:
|
|
477
462
|
error_msg = f"Failed to stop task: {result.stderr}"
|
|
478
463
|
logger.error(error_msg)
|
|
479
|
-
raise
|
|
464
|
+
raise TaskOperationError(error_msg)
|
|
480
465
|
|
|
481
466
|
logger.info(f"Successfully stopped task: {task_ref}")
|
|
482
467
|
|
|
@@ -491,26 +476,10 @@ rc.bulk=0
|
|
|
491
476
|
if result.returncode != 0:
|
|
492
477
|
error_msg = f"Failed to annotate task: {result.stderr}"
|
|
493
478
|
logger.error(error_msg)
|
|
494
|
-
raise
|
|
479
|
+
raise TaskOperationError(error_msg)
|
|
495
480
|
|
|
496
481
|
logger.info(f"Successfully annotated task: {task_ref}")
|
|
497
482
|
|
|
498
|
-
def get_info(self) -> TaskWarriorInfo:
|
|
499
|
-
"""Get TaskWarrior configuration and version info."""
|
|
500
|
-
info: TaskWarriorInfo = {
|
|
501
|
-
"task_cmd": self.task_cmd,
|
|
502
|
-
"taskrc_file": self.taskrc_file,
|
|
503
|
-
"options": self._options,
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
try:
|
|
507
|
-
version_result = self.run_task_command(["--version"], no_opt=True)
|
|
508
|
-
if version_result.returncode == 0 and version_result.stdout:
|
|
509
|
-
info["version"] = version_result.stdout.strip()
|
|
510
|
-
except Exception:
|
|
511
|
-
info["version"] = "unknown"
|
|
512
|
-
return info
|
|
513
|
-
|
|
514
483
|
def task_calc(self, date_str: str) -> str:
|
|
515
484
|
"""Calculate a TaskWarrior date expression."""
|
|
516
485
|
try:
|
|
@@ -535,6 +504,13 @@ rc.bulk=0
|
|
|
535
504
|
except subprocess.SubprocessError:
|
|
536
505
|
return False
|
|
537
506
|
|
|
507
|
+
def get_version(self) -> str:
|
|
508
|
+
"""Return the TaskWarrior CLI version as a string."""
|
|
509
|
+
version_result = self.run_task_command(["--version"], no_opt=True)
|
|
510
|
+
if version_result.returncode == 0 and version_result.stdout:
|
|
511
|
+
return version_result.stdout.strip()
|
|
512
|
+
return "unknown"
|
|
513
|
+
|
|
538
514
|
def get_projects(self) -> list[str]:
|
|
539
515
|
"""Get all projects defined in TaskWarrior.
|
|
540
516
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from ..exceptions import TaskConfigurationError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..dto.context_dto import ContextDTO
|
|
10
|
+
|
|
11
|
+
DEFAULT_OPTIONS = [
|
|
12
|
+
"rc.confirmation=off",
|
|
13
|
+
"rc.bulk=0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
class ConfigStore:
|
|
17
|
+
"""
|
|
18
|
+
Loads and caches Taskwarrior config from taskrc. Provides access methods and refresh capability.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, taskrc_path: str, data_location: str | None = None) -> None:
|
|
22
|
+
self._taskrc_path: Path = Path(os.path.expandvars(taskrc_path)).expanduser()
|
|
23
|
+
self._data_location: Path | None = Path(os.path.expandvars(data_location)).expanduser() if data_location else None
|
|
24
|
+
self._check_or_create_taskfiles()
|
|
25
|
+
self._config: dict[str, str] | None = None
|
|
26
|
+
self._load_config()
|
|
27
|
+
|
|
28
|
+
def _load_config(self) -> None:
|
|
29
|
+
self._config = self._extract_taskrc_config(self._taskrc_path)
|
|
30
|
+
|
|
31
|
+
def _check_or_create_taskfiles(self) -> None:
|
|
32
|
+
"""Create taskrc and data directory if they don't exist."""
|
|
33
|
+
if not self._taskrc_path.exists():
|
|
34
|
+
default_content = f"""# Taskwarrior configuration file
|
|
35
|
+
# This file was automatically created by pytaskwarrior
|
|
36
|
+
# Default data location
|
|
37
|
+
rc.data.location={self._data_location or '~/.task'}
|
|
38
|
+
# Disable confirmation prompts
|
|
39
|
+
rc.confirmation=off
|
|
40
|
+
rc.bulk=0
|
|
41
|
+
"""
|
|
42
|
+
self._taskrc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
self._taskrc_path.write_text(default_content)
|
|
44
|
+
if self._data_location and not self._data_location.exists():
|
|
45
|
+
self._data_location.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
def _extract_taskrc_config(self, path: Path) -> dict[str, str]:
|
|
48
|
+
import configparser
|
|
49
|
+
|
|
50
|
+
config: dict[str, str] = {}
|
|
51
|
+
parser = configparser.ConfigParser()
|
|
52
|
+
# Accept .taskrc files without section headers by adding a dummy section
|
|
53
|
+
try:
|
|
54
|
+
with open(path, encoding="utf-8") as f:
|
|
55
|
+
lines = f.readlines()
|
|
56
|
+
except FileNotFoundError as e:
|
|
57
|
+
raise TaskConfigurationError(f"Taskrc file not found: {path}") from e
|
|
58
|
+
except PermissionError as e:
|
|
59
|
+
raise TaskConfigurationError(f"Cannot read taskrc file (permission denied): {path}") from e
|
|
60
|
+
except OSError as e:
|
|
61
|
+
raise TaskConfigurationError(f"Failed to read taskrc file: {path}: {e}") from e
|
|
62
|
+
# Only keep blank lines, comments, or lines containing '=' (key-value)
|
|
63
|
+
filtered = [line for line in lines if line.strip() == "" or line.strip().startswith("#") or "=" in line]
|
|
64
|
+
content = "[taskrc]\n" + "".join(filtered)
|
|
65
|
+
parser.read_string(content)
|
|
66
|
+
for section in parser.sections():
|
|
67
|
+
for key, value in parser.items(section):
|
|
68
|
+
config[key] = value
|
|
69
|
+
for key in parser.defaults():
|
|
70
|
+
config[key] = parser.defaults()[key]
|
|
71
|
+
return config
|
|
72
|
+
|
|
73
|
+
def refresh(self) -> None:
|
|
74
|
+
"""Reloads the config from disk."""
|
|
75
|
+
self._load_config()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def config(self) -> dict[str, str]:
|
|
79
|
+
if self._config is None:
|
|
80
|
+
self._load_config()
|
|
81
|
+
assert self._config is not None
|
|
82
|
+
return self._config
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def taskrc_path(self) -> Path:
|
|
86
|
+
"""Return the path to the taskrc file."""
|
|
87
|
+
return self._taskrc_path
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def cli_options(self) -> list[str]:
|
|
91
|
+
"""Return CLI options for Taskwarrior commands, including defaults."""
|
|
92
|
+
options = [f"rc:{self._taskrc_path}"]
|
|
93
|
+
if self._data_location:
|
|
94
|
+
options.append(f"rc.data.location={self._data_location}")
|
|
95
|
+
options.extend(DEFAULT_OPTIONS)
|
|
96
|
+
return options
|
|
97
|
+
|
|
98
|
+
def get_sync_config(self) -> dict[str, str]:
|
|
99
|
+
# Extract sync config directly from self.config
|
|
100
|
+
# Accept both 'sync.' and 'taskrc.sync.' keys for compatibility
|
|
101
|
+
return {k: v for k, v in self.config.items() if k.startswith("sync.")}
|
|
102
|
+
|
|
103
|
+
def get_contexts_config(self) -> dict[str, str]:
|
|
104
|
+
# Extract context config directly from self.config
|
|
105
|
+
return {k: v for k, v in self.config.items() if k.startswith("context.")}
|
|
106
|
+
|
|
107
|
+
def get_contexts(self, current_context: str | None = None) -> list["ContextDTO"]:
|
|
108
|
+
"""
|
|
109
|
+
Returns a list of ContextDTO objects representing all defined contexts.
|
|
110
|
+
"""
|
|
111
|
+
from ..dto.context_dto import ContextDTO
|
|
112
|
+
|
|
113
|
+
contexts_config = self.get_contexts_config()
|
|
114
|
+
names: dict[str, dict[str, str]] = {}
|
|
115
|
+
|
|
116
|
+
for k, v in contexts_config.items():
|
|
117
|
+
m = re.match(r"context\.([^\.]+)\.(read|write)", k)
|
|
118
|
+
if m:
|
|
119
|
+
ctx_name = m.group(1)
|
|
120
|
+
kind = m.group(2)
|
|
121
|
+
names.setdefault(ctx_name, {})[kind] = v
|
|
122
|
+
return [
|
|
123
|
+
ContextDTO(
|
|
124
|
+
name=n,
|
|
125
|
+
read_filter=filters.get("read", ""),
|
|
126
|
+
write_filter=filters.get("write", ""),
|
|
127
|
+
active=(n == current_context),
|
|
128
|
+
)
|
|
129
|
+
for n, filters in names.items()
|
|
130
|
+
]
|
|
@@ -21,6 +21,14 @@ class TaskWarriorError(Exception):
|
|
|
21
21
|
pass
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
class TaskSyncError(TaskWarriorError):
|
|
25
|
+
"""Raised when a synchronization error occurs in TaskWarrior.
|
|
26
|
+
|
|
27
|
+
This exception is used to signal errors encountered during sync operations.
|
|
28
|
+
"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
24
32
|
class TaskNotFound(TaskWarriorError): # noqa: N818
|
|
25
33
|
"""Raised when a requested task does not exist.
|
|
26
34
|
|
|
@@ -55,3 +63,41 @@ class TaskValidationError(TaskWarriorError):
|
|
|
55
63
|
"""
|
|
56
64
|
|
|
57
65
|
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TaskConfigurationError(TaskWarriorError):
|
|
69
|
+
"""Raised when a configuration or environment error is detected.
|
|
70
|
+
|
|
71
|
+
This exception is raised when:
|
|
72
|
+
- The TaskWarrior binary is not found in PATH
|
|
73
|
+
- The taskrc configuration file is missing or unreadable
|
|
74
|
+
- Required environment setup is invalid
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
>>> try:
|
|
78
|
+
... tw = TaskWarrior(task_cmd="nonexistent-binary")
|
|
79
|
+
... except TaskConfigurationError as e:
|
|
80
|
+
... print(f"Configuration error: {e}")
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TaskOperationError(TaskWarriorError):
|
|
87
|
+
"""Raised when a task operation fails on an existing task.
|
|
88
|
+
|
|
89
|
+
This exception is raised when a write operation (delete, complete, start,
|
|
90
|
+
stop, annotate, purge, modify) fails, for reasons other than the task not
|
|
91
|
+
being found. Examples:
|
|
92
|
+
- Marking an already-completed task as done
|
|
93
|
+
- Starting a task that is already active
|
|
94
|
+
- Annotating with an empty annotation text
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> try:
|
|
98
|
+
... tw.done_task(uuid)
|
|
99
|
+
... except TaskOperationError as e:
|
|
100
|
+
... print(f"Operation failed: {e}")
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
pass
|
|
@@ -7,9 +7,10 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
|
+
from typing import Any
|
|
10
11
|
from uuid import UUID
|
|
11
12
|
|
|
12
|
-
from .adapters.taskwarrior_adapter import TaskWarriorAdapter
|
|
13
|
+
from .adapters.taskwarrior_adapter import TaskWarriorAdapter
|
|
13
14
|
from .dto.context_dto import ContextDTO
|
|
14
15
|
from .dto.task_dto import TaskInputDTO, TaskOutputDTO
|
|
15
16
|
from .dto.uda_dto import UdaConfig
|
|
@@ -69,22 +70,26 @@ class TaskWarrior:
|
|
|
69
70
|
task_cmd: Path or name of the TaskWarrior binary. Defaults to "task".
|
|
70
71
|
taskrc_file: Path to the taskrc configuration file. If None, uses
|
|
71
72
|
the TASKRC environment variable or defaults to ~/.taskrc.
|
|
72
|
-
data_location:
|
|
73
|
-
|
|
73
|
+
data_location: Optional path to TaskWarrior data directory. If None,
|
|
74
|
+
TASKDATA environment variable or taskrc value will be used.
|
|
74
75
|
|
|
75
76
|
Raises:
|
|
76
|
-
|
|
77
|
+
TaskConfigurationError: If the TaskWarrior binary is not found.
|
|
77
78
|
"""
|
|
78
79
|
if taskrc_file is None:
|
|
79
80
|
taskrc_file = os.environ.get("TASKRC", "$HOME/.taskrc")
|
|
81
|
+
|
|
80
82
|
if data_location is None:
|
|
81
|
-
data_location = os.environ.get("TASKDATA")
|
|
83
|
+
data_location = os.environ.get("TASKDATA", None)
|
|
84
|
+
|
|
85
|
+
from .config.config_store import ConfigStore
|
|
82
86
|
|
|
87
|
+
self.config_store = ConfigStore(taskrc_file, data_location)
|
|
83
88
|
self.adapter: TaskWarriorAdapter = TaskWarriorAdapter(
|
|
84
|
-
task_cmd=task_cmd,
|
|
89
|
+
task_cmd=task_cmd, config_store=self.config_store
|
|
85
90
|
)
|
|
86
|
-
self.context_service: ContextService = ContextService(self.adapter)
|
|
87
|
-
self.uda_service: UdaService = UdaService(self.adapter)
|
|
91
|
+
self.context_service: ContextService = ContextService(self.adapter, self.config_store)
|
|
92
|
+
self.uda_service: UdaService = UdaService(self.adapter, self.config_store)
|
|
88
93
|
|
|
89
94
|
# Auto-load UDA definitions from taskrc
|
|
90
95
|
self.uda_service.load_udas_from_taskrc()
|
|
@@ -108,9 +113,7 @@ class TaskWarrior:
|
|
|
108
113
|
"""
|
|
109
114
|
return self.adapter.add_task(task)
|
|
110
115
|
|
|
111
|
-
def modify_task(
|
|
112
|
-
self, task: TaskInputDTO, task_id_or_uuid: str | int | UUID
|
|
113
|
-
) -> TaskOutputDTO:
|
|
116
|
+
def modify_task(self, task: TaskInputDTO, task_id_or_uuid: str | int | UUID) -> TaskOutputDTO:
|
|
114
117
|
"""Modify an existing task.
|
|
115
118
|
|
|
116
119
|
Args:
|
|
@@ -163,6 +166,9 @@ class TaskWarrior:
|
|
|
163
166
|
Deleted and completed tasks are excluded by default; use
|
|
164
167
|
*include_completed* / *include_deleted* to override.
|
|
165
168
|
|
|
169
|
+
If a context is active, its read_filter is applied in addition to the
|
|
170
|
+
provided filter (combined with AND).
|
|
171
|
+
|
|
166
172
|
Args:
|
|
167
173
|
filter: TaskWarrior filter expression. Examples::
|
|
168
174
|
|
|
@@ -180,8 +186,25 @@ class TaskWarrior:
|
|
|
180
186
|
Raises:
|
|
181
187
|
TaskWarriorError: If the query fails.
|
|
182
188
|
"""
|
|
189
|
+
# Combine the user-provided filter with the active context's read_filter
|
|
190
|
+
combined_filter = filter or ""
|
|
191
|
+
try:
|
|
192
|
+
current_context = self.get_current_context()
|
|
193
|
+
if current_context:
|
|
194
|
+
contexts = self.context_service.get_contexts()
|
|
195
|
+
active = next((c for c in contexts if c.active or c.name == current_context), None)
|
|
196
|
+
if active and active.read_filter:
|
|
197
|
+
ctx_read = active.read_filter.strip()
|
|
198
|
+
if combined_filter.strip():
|
|
199
|
+
combined_filter = f"{ctx_read} and ({combined_filter})"
|
|
200
|
+
else:
|
|
201
|
+
combined_filter = ctx_read
|
|
202
|
+
except Exception as e:
|
|
203
|
+
# Do not fail listing due to context lookup issues — log and proceed
|
|
204
|
+
logger.debug("Failed to apply context read_filter to get_tasks(): %s", e)
|
|
205
|
+
|
|
183
206
|
return self.adapter.get_tasks(
|
|
184
|
-
filter=
|
|
207
|
+
filter=combined_filter,
|
|
185
208
|
include_completed=include_completed,
|
|
186
209
|
include_deleted=include_deleted,
|
|
187
210
|
)
|
|
@@ -200,9 +223,7 @@ class TaskWarrior:
|
|
|
200
223
|
"""
|
|
201
224
|
return self.adapter.get_recurring_task(task_id_or_uuid)
|
|
202
225
|
|
|
203
|
-
def get_recurring_instances(
|
|
204
|
-
self, task_id_or_uuid: str | int | UUID
|
|
205
|
-
) -> list[TaskOutputDTO]:
|
|
226
|
+
def get_recurring_instances(self, task_id_or_uuid: str | int | UUID) -> list[TaskOutputDTO]:
|
|
206
227
|
"""Get all instances of a recurring task.
|
|
207
228
|
|
|
208
229
|
Args:
|
|
@@ -225,7 +246,7 @@ class TaskWarrior:
|
|
|
225
246
|
task_id_or_uuid: The task ID or UUID to delete.
|
|
226
247
|
|
|
227
248
|
Raises:
|
|
228
|
-
|
|
249
|
+
TaskOperationError: If the operation fails (e.g., task already deleted).
|
|
229
250
|
"""
|
|
230
251
|
self.adapter.delete_task(task_id_or_uuid)
|
|
231
252
|
|
|
@@ -238,7 +259,7 @@ class TaskWarrior:
|
|
|
238
259
|
task_id_or_uuid: The task ID or UUID to purge.
|
|
239
260
|
|
|
240
261
|
Raises:
|
|
241
|
-
|
|
262
|
+
TaskOperationError: If the operation fails (e.g., task was not deleted first).
|
|
242
263
|
"""
|
|
243
264
|
self.adapter.purge_task(task_id_or_uuid)
|
|
244
265
|
|
|
@@ -249,7 +270,7 @@ class TaskWarrior:
|
|
|
249
270
|
task_id_or_uuid: The task ID or UUID to complete.
|
|
250
271
|
|
|
251
272
|
Raises:
|
|
252
|
-
|
|
273
|
+
TaskOperationError: If the operation fails (e.g., task is already completed).
|
|
253
274
|
|
|
254
275
|
Example:
|
|
255
276
|
>>> tw.done_task(1)
|
|
@@ -266,7 +287,7 @@ class TaskWarrior:
|
|
|
266
287
|
task_id_or_uuid: The task ID or UUID to start.
|
|
267
288
|
|
|
268
289
|
Raises:
|
|
269
|
-
|
|
290
|
+
TaskOperationError: If the operation fails (e.g., task is already started).
|
|
270
291
|
"""
|
|
271
292
|
self.adapter.start_task(task_id_or_uuid)
|
|
272
293
|
|
|
@@ -279,7 +300,7 @@ class TaskWarrior:
|
|
|
279
300
|
task_id_or_uuid: The task ID or UUID to stop.
|
|
280
301
|
|
|
281
302
|
Raises:
|
|
282
|
-
|
|
303
|
+
TaskOperationError: If the operation fails (e.g., task was not started).
|
|
283
304
|
"""
|
|
284
305
|
self.adapter.stop_task(task_id_or_uuid)
|
|
285
306
|
|
|
@@ -293,7 +314,7 @@ class TaskWarrior:
|
|
|
293
314
|
annotation: The annotation text to add.
|
|
294
315
|
|
|
295
316
|
Raises:
|
|
296
|
-
|
|
317
|
+
TaskOperationError: If the operation fails (e.g., task not found).
|
|
297
318
|
|
|
298
319
|
Example:
|
|
299
320
|
>>> tw.annotate_task(1, "Discussed with team, need more info")
|
|
@@ -390,18 +411,72 @@ class TaskWarrior:
|
|
|
390
411
|
"""
|
|
391
412
|
return self.context_service.has_context(context)
|
|
392
413
|
|
|
393
|
-
def
|
|
414
|
+
def is_sync_configured(self) -> bool:
|
|
415
|
+
"""Return True if synchronization is configured for this TaskWarrior instance."""
|
|
416
|
+
return self.adapter.is_sync_configured()
|
|
417
|
+
|
|
418
|
+
def synchronize(self) -> None:
|
|
419
|
+
"""Run TaskWarrior synchronization via ``task sync``.
|
|
420
|
+
|
|
421
|
+
Delegates to the TaskWarrior CLI's built-in sync command. Synchronization
|
|
422
|
+
settings (server address, credentials, or local path) must be configured
|
|
423
|
+
in the taskrc file before calling this method.
|
|
424
|
+
|
|
425
|
+
Raises:
|
|
426
|
+
TaskSyncError: If no sync backend is configured or synchronization fails.
|
|
427
|
+
|
|
428
|
+
Example:
|
|
429
|
+
>>> tw = TaskWarrior(taskrc_file="/path/to/.taskrc")
|
|
430
|
+
>>> tw.synchronize() # requires sync.* settings in taskrc
|
|
431
|
+
"""
|
|
432
|
+
self.adapter.synchronize()
|
|
433
|
+
|
|
434
|
+
def get_info(self) -> dict[str, Any]:
|
|
394
435
|
"""Get comprehensive TaskWarrior configuration information.
|
|
395
436
|
|
|
396
437
|
Returns:
|
|
397
438
|
Dictionary containing task_cmd path, taskrc_file path,
|
|
398
|
-
options, and
|
|
439
|
+
options, TaskWarrior version, and active context information.
|
|
399
440
|
|
|
400
441
|
Example:
|
|
401
442
|
>>> info = tw.get_info()
|
|
402
443
|
>>> print(info["version"])
|
|
403
444
|
"""
|
|
404
|
-
|
|
445
|
+
# Compose info from TaskWarrior instance, not adapter
|
|
446
|
+
info: dict[str, Any] = {
|
|
447
|
+
"task_cmd": str(self.adapter.task_cmd),
|
|
448
|
+
"taskrc_file": str(self.config_store.taskrc_path),
|
|
449
|
+
"options": self.adapter.cli_options,
|
|
450
|
+
"version": self.adapter.get_version(),
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
# Add current context information (name and details) if available.
|
|
454
|
+
current_context: str | None = None
|
|
455
|
+
current_context_details: dict[str, Any] | None = None
|
|
456
|
+
try:
|
|
457
|
+
current_context = self.get_current_context()
|
|
458
|
+
if current_context:
|
|
459
|
+
contexts = self.context_service.get_contexts()
|
|
460
|
+
active = next((c for c in contexts if c.active or c.name == current_context), None)
|
|
461
|
+
if active:
|
|
462
|
+
current_context_details = {
|
|
463
|
+
"name": active.name,
|
|
464
|
+
"read_filter": active.read_filter,
|
|
465
|
+
"write_filter": active.write_filter,
|
|
466
|
+
"active": active.active,
|
|
467
|
+
}
|
|
468
|
+
except Exception as e:
|
|
469
|
+
# Do not fail get_info() for context lookup issues — log and return None fields
|
|
470
|
+
logger.debug("Failed to retrieve current context for get_info(): %s", e)
|
|
471
|
+
current_context = None
|
|
472
|
+
current_context_details = None
|
|
473
|
+
|
|
474
|
+
info.update({
|
|
475
|
+
"current_context": current_context,
|
|
476
|
+
"current_context_details": current_context_details,
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
return info
|
|
405
480
|
|
|
406
481
|
def task_calc(self, date_str: str) -> str:
|
|
407
482
|
"""Calculate a TaskWarrior date expression.
|
|
@@ -10,7 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
from ..adapters.taskwarrior_adapter import TaskWarriorAdapter
|
|
12
12
|
from ..dto.uda_dto import UdaConfig, UdaType
|
|
13
|
-
from ..exceptions import TaskWarriorError
|
|
13
|
+
from ..exceptions import TaskConfigurationError, TaskWarriorError
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class UdaRegistry:
|
|
@@ -92,7 +92,7 @@ class UdaRegistry:
|
|
|
92
92
|
raise TaskWarriorError(f"Error while parsing {name}: {str(e)}") from e
|
|
93
93
|
|
|
94
94
|
except FileNotFoundError as e:
|
|
95
|
-
raise
|
|
95
|
+
raise TaskConfigurationError(f"Taskrc file not found: {taskrc_file}") from e
|
|
96
96
|
except Exception as e:
|
|
97
97
|
raise TaskWarriorError(f"Error reading taskrc: {str(e)}") from e
|
|
98
98
|
|
|
@@ -4,11 +4,15 @@ This module provides the ContextService class for managing TaskWarrior
|
|
|
4
4
|
contexts (named filters).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
8
9
|
|
|
9
10
|
from ..adapters.taskwarrior_adapter import TaskWarriorAdapter
|
|
10
11
|
from ..dto.context_dto import ContextDTO
|
|
11
|
-
from ..exceptions import TaskWarriorError
|
|
12
|
+
from ..exceptions import TaskValidationError, TaskWarriorError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..config.config_store import ConfigStore
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
class ContextService:
|
|
@@ -29,17 +33,19 @@ class ContextService:
|
|
|
29
33
|
tw.apply_context("work")
|
|
30
34
|
"""
|
|
31
35
|
|
|
32
|
-
def __init__(self, adapter: TaskWarriorAdapter):
|
|
36
|
+
def __init__(self, adapter: TaskWarriorAdapter, config_store: 'ConfigStore') -> None:
|
|
33
37
|
"""Initialize the context service.
|
|
34
38
|
|
|
35
39
|
Args:
|
|
36
40
|
adapter: The TaskWarriorAdapter to use for CLI commands.
|
|
41
|
+
config_store: The configuration store instance (required).
|
|
37
42
|
"""
|
|
38
43
|
self.adapter: TaskWarriorAdapter = adapter
|
|
44
|
+
self.config_store = config_store
|
|
39
45
|
|
|
40
46
|
def _validate_name(self, name: str) -> None:
|
|
41
47
|
if not name or not name.strip():
|
|
42
|
-
raise
|
|
48
|
+
raise TaskValidationError("Context name cannot be empty")
|
|
43
49
|
|
|
44
50
|
def define_context(
|
|
45
51
|
self, name: str, read_filter: str, write_filter: str
|
|
@@ -72,6 +78,7 @@ class ContextService:
|
|
|
72
78
|
raise TaskWarriorError(
|
|
73
79
|
f"Failed to set write filter for context '{name}': {result.stderr}"
|
|
74
80
|
)
|
|
81
|
+
self.config_store.refresh()
|
|
75
82
|
|
|
76
83
|
def apply_context(self, name: str) -> None:
|
|
77
84
|
"""Apply a context, making it the active filter.
|
|
@@ -100,10 +107,11 @@ class ContextService:
|
|
|
100
107
|
raise TaskWarriorError(f"Failed to unset context: {result.stderr}")
|
|
101
108
|
|
|
102
109
|
def get_contexts(self) -> list[ContextDTO]:
|
|
103
|
-
"""
|
|
110
|
+
"""Return list of ContextDTO by delegating to ConfigStore and marking active state.
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
The ConfigStore returns ContextDTO instances; this wrapper simply forwards them.
|
|
113
|
+
"""
|
|
114
|
+
"""List all defined contexts with their read and write filters.
|
|
107
115
|
|
|
108
116
|
Returns:
|
|
109
117
|
List of ContextDTO objects (name, read_filter, write_filter, active).
|
|
@@ -113,30 +121,7 @@ class ContextService:
|
|
|
113
121
|
"""
|
|
114
122
|
try:
|
|
115
123
|
current = self.get_current_context()
|
|
116
|
-
|
|
117
|
-
content = taskrc_path.read_text(encoding="utf-8")
|
|
118
|
-
|
|
119
|
-
# Collect all context.*.read entries as canonical source of truth
|
|
120
|
-
names: dict[str, dict[str, str]] = {}
|
|
121
|
-
for m in re.finditer(
|
|
122
|
-
r"^\s*context\.([^.\s]+)\.(read|write)\s*=\s*(.*)",
|
|
123
|
-
content,
|
|
124
|
-
re.MULTILINE,
|
|
125
|
-
):
|
|
126
|
-
ctx_name = m.group(1)
|
|
127
|
-
kind = m.group(2)
|
|
128
|
-
value = m.group(3).strip()
|
|
129
|
-
names.setdefault(ctx_name, {})[kind] = value
|
|
130
|
-
|
|
131
|
-
return [
|
|
132
|
-
ContextDTO(
|
|
133
|
-
name=n,
|
|
134
|
-
read_filter=filters.get("read", ""),
|
|
135
|
-
write_filter=filters.get("write", ""),
|
|
136
|
-
active=(n == current),
|
|
137
|
-
)
|
|
138
|
-
for n, filters in names.items()
|
|
139
|
-
]
|
|
124
|
+
return self.config_store.get_contexts(current_context=current)
|
|
140
125
|
except Exception as e:
|
|
141
126
|
raise TaskWarriorError(f"Error retrieving contexts: {str(e)}") from e
|
|
142
127
|
|
|
@@ -173,6 +158,7 @@ class ContextService:
|
|
|
173
158
|
raise TaskWarriorError(
|
|
174
159
|
f"Failed to delete context '{name}': {result.stderr}"
|
|
175
160
|
)
|
|
161
|
+
self.config_store.refresh()
|
|
176
162
|
|
|
177
163
|
def has_context(self, name: str) -> bool:
|
|
178
164
|
"""Check if a context with the given name exists.
|
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
This module provides the UdaService class for managing custom task attributes.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
6
8
|
from ..adapters.taskwarrior_adapter import TaskWarriorAdapter
|
|
7
9
|
from ..dto.uda_dto import UdaConfig
|
|
8
10
|
from ..registry.uda_registry import UdaRegistry
|
|
9
11
|
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..config.config_store import ConfigStore
|
|
14
|
+
|
|
10
15
|
|
|
11
16
|
class UdaService:
|
|
12
17
|
"""Service for managing User Defined Attributes (UDAs).
|
|
@@ -27,13 +32,16 @@ class UdaService:
|
|
|
27
32
|
tw.uda_service.define_uda(uda)
|
|
28
33
|
"""
|
|
29
34
|
|
|
30
|
-
def __init__(self, adapter: TaskWarriorAdapter):
|
|
35
|
+
def __init__(self, adapter: TaskWarriorAdapter, config_store: 'ConfigStore') -> None:
|
|
31
36
|
"""Initialize the UDA service.
|
|
32
37
|
|
|
33
38
|
Args:
|
|
34
39
|
adapter: The TaskWarriorAdapter to use for CLI commands.
|
|
40
|
+
config_store: The configuration store instance (required).
|
|
35
41
|
"""
|
|
42
|
+
|
|
36
43
|
self.adapter = adapter
|
|
44
|
+
self.config_store = config_store
|
|
37
45
|
self.registry = UdaRegistry()
|
|
38
46
|
|
|
39
47
|
def load_udas_from_taskrc(self) -> None:
|
|
@@ -42,7 +50,7 @@ class UdaService:
|
|
|
42
50
|
Parses the taskrc file to discover and register any UDAs
|
|
43
51
|
that have been previously defined.
|
|
44
52
|
"""
|
|
45
|
-
self.registry.load_from_taskrc(self.
|
|
53
|
+
self.registry.load_from_taskrc(self.config_store._taskrc_path)
|
|
46
54
|
|
|
47
55
|
def define_uda(self, uda: UdaConfig) -> None:
|
|
48
56
|
"""Define a new UDA in TaskWarrior.
|
|
@@ -41,6 +41,9 @@ def parse_taskwarrior_date(value: str) -> datetime:
|
|
|
41
41
|
# Try standard parsing
|
|
42
42
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
43
43
|
except ValueError:
|
|
44
|
-
# If parsing fails, try
|
|
45
|
-
|
|
44
|
+
# If parsing fails, try without timezone suffix
|
|
45
|
+
try:
|
|
46
|
+
return datetime.fromisoformat(value)
|
|
47
|
+
except ValueError as e:
|
|
48
|
+
raise ValueError(f"Cannot parse TaskWarrior date: {value!r}") from e
|
|
46
49
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|