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.
Files changed (33) hide show
  1. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/PKG-INFO +8 -7
  2. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/PYPI_README.md +7 -6
  3. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/README.md +40 -10
  4. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/pyproject.toml +1 -1
  5. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/PKG-INFO +8 -7
  6. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/SOURCES.txt +1 -0
  7. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/__init__.py +14 -0
  8. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/adapters/taskwarrior_adapter.py +81 -105
  9. pytaskwarrior-1.2.0/src/taskwarrior/config/config_store.py +130 -0
  10. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/exceptions.py +46 -0
  11. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/main.py +99 -24
  12. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/registry/uda_registry.py +2 -2
  13. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/services/context_service.py +17 -31
  14. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/services/uda_service.py +10 -2
  15. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/utils/conversions.py +5 -2
  16. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/LICENSE +0 -0
  17. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/setup.cfg +0 -0
  18. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/__init__.py +0 -0
  19. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/dependency_links.txt +0 -0
  20. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/requires.txt +0 -0
  21. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/pytaskwarrior.egg-info/top_level.txt +0 -0
  22. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/adapters/__init__.py +0 -0
  23. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/__init__.py +0 -0
  24. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/annotation_dto.py +0 -0
  25. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/context_dto.py +0 -0
  26. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/task_dto.py +0 -0
  27. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/dto/uda_dto.py +0 -0
  28. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/enums.py +0 -0
  29. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/py.typed +0 -0
  30. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/registry/__init__.py +0 -0
  31. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/services/__init__.py +0 -0
  32. {pytaskwarrior-1.1.0 → pytaskwarrior-1.2.0}/src/taskwarrior/utils/__init__.py +0 -0
  33. {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.1.0
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
- **v1.0.0**: Production-ready with 132 tests (96% coverage), strict type checking, and professional-grade code quality. Zero linting errors, full async-safe subprocess handling, and PEP 561 type hints for IDE support.
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
- -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
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
- **v1.0.0**: Production-ready with 132 tests (96% coverage), strict type checking, and professional-grade code quality. Zero linting errors, full async-safe subprocess handling, and PEP 561 type hints for IDE support.
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
- -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
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.0.0**: Production-ready with 132 tests (96% coverage), strict type checking, and professional-grade code quality. Zero linting errors, full async-safe subprocess handling, and PEP 561 type hints for IDE support.
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
- - **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
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.0.0
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
  [project]
2
2
  name = "pytaskwarrior"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "Taskwarrior wrapper python module"
5
5
  readme = "PYPI_README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytaskwarrior
3
- Version: 1.1.0
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
- **v1.0.0**: Production-ready with 132 tests (96% coverage), strict type checking, and professional-grade code quality. Zero linting errors, full async-safe subprocess handling, and PEP 561 type hints for IDE support.
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
- -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
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 TaskNotFound, TaskValidationError, TaskWarriorError
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
- taskrc_file: Path to taskrc file.
62
- data_location: Path to data directory (optional).
51
+ config_store: The configuration store instance (required).
63
52
 
64
53
  Raises:
65
- TaskValidationError: If TaskWarrior binary not found.
54
+ TaskConfigurationError: If TaskWarrior binary not found.
66
55
  """
56
+
67
57
  self.task_cmd: Path = self._check_binary_path(task_cmd)
68
- self._options: list[str] = []
69
- self.taskrc_file = Path(os.path.expandvars(taskrc_file)).expanduser()
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
- self._options.extend(DEFAULT_OPTIONS)
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 TaskValidationError(
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 _check_or_create_taskfiles(self) -> None:
90
- """Create taskrc and data directory if they don't exist."""
91
- if not self.taskrc_file.exists():
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._options)
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 RuntimeError(error_msg)
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 TaskValidationError(error_msg)
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 TaskValidationError(
270
+ raise TaskWarriorError(
283
271
  f"Invalid response from TaskWarrior: {result.stdout}"
284
272
  ) from e
285
273
  else:
286
- raise TaskWarriorError(
287
- f"Error while retrieving task ID/UUID {task_id_or_uuid} not found"
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 TaskValidationError(
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
- tasks_data = json.loads(result.stdout)
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 TaskNotFound(error_msg)
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 TaskNotFound(f"Invalid response from TaskWarrior: {result.stdout}") from e
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 TaskNotFound(error_msg)
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 TaskNotFound(error_msg)
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 TaskNotFound(error_msg)
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 TaskNotFound(error_msg)
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 TaskNotFound(error_msg)
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 TaskNotFound(error_msg)
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, TaskWarriorInfo
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: Path to the task data directory. If None, uses
73
- the TASKDATA environment variable or the value in taskrc.
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
- TaskValidationError: If the TaskWarrior binary is not found.
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, taskrc_file=taskrc_file, data_location=data_location
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=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
- TaskNotFound: If the task doesn't exist.
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
- TaskNotFound: If the task doesn't exist.
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
- TaskNotFound: If the task doesn't exist.
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
- TaskNotFound: If the task doesn't exist.
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
- TaskNotFound: If the task doesn't exist.
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
- TaskNotFound: If the task doesn't exist.
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 get_info(self) -> TaskWarriorInfo:
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 TaskWarrior version.
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
- return self.adapter.get_info()
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 TaskWarriorError(f"Taskrc file not found: {taskrc_file}") from e
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
- import re
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 TaskWarriorError("Context name cannot be empty")
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
- """List all defined contexts with their read and write filters.
110
+ """Return list of ContextDTO by delegating to ConfigStore and marking active state.
104
111
 
105
- Reads context.*.read and context.*.write entries directly from
106
- .taskrc to guarantee correctness regardless of CLI output format.
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
- taskrc_path = self.adapter.taskrc_file
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.adapter.taskrc_file)
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 to parse as ISO format
45
- return datetime.fromisoformat(value)
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