pytaskwarrior 1.2.0__tar.gz → 2.0.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 (36) hide show
  1. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/PKG-INFO +1 -1
  2. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/README.md +7 -6
  3. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/pyproject.toml +1 -1
  4. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/pytaskwarrior.egg-info/PKG-INFO +1 -1
  5. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/pytaskwarrior.egg-info/SOURCES.txt +1 -0
  6. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/__init__.py +0 -1
  7. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/adapters/taskwarrior_adapter.py +4 -13
  8. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/config/config_store.py +26 -4
  9. pytaskwarrior-2.0.0/src/taskwarrior/config/uda_parser.py +65 -0
  10. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/dto/__init__.py +1 -1
  11. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/dto/annotation_dto.py +1 -3
  12. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/dto/task_dto.py +2 -6
  13. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/dto/uda_dto.py +5 -5
  14. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/exceptions.py +1 -0
  15. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/main.py +16 -19
  16. pytaskwarrior-2.0.0/src/taskwarrior/registry/uda_registry.py +70 -0
  17. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/services/context_service.py +26 -27
  18. pytaskwarrior-2.0.0/src/taskwarrior/services/uda_service.py +109 -0
  19. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/utils/conversions.py +0 -1
  20. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/utils/dto_converter.py +12 -1
  21. pytaskwarrior-1.2.0/src/taskwarrior/registry/uda_registry.py +0 -168
  22. pytaskwarrior-1.2.0/src/taskwarrior/services/uda_service.py +0 -92
  23. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/LICENSE +0 -0
  24. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/PYPI_README.md +0 -0
  25. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/setup.cfg +0 -0
  26. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/__init__.py +0 -0
  27. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/pytaskwarrior.egg-info/dependency_links.txt +0 -0
  28. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/pytaskwarrior.egg-info/requires.txt +0 -0
  29. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/pytaskwarrior.egg-info/top_level.txt +0 -0
  30. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/adapters/__init__.py +0 -0
  31. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/dto/context_dto.py +0 -0
  32. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/enums.py +0 -0
  33. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/py.typed +0 -0
  34. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/registry/__init__.py +0 -0
  35. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/services/__init__.py +0 -0
  36. {pytaskwarrior-1.2.0 → pytaskwarrior-2.0.0}/src/taskwarrior/utils/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytaskwarrior
3
- Version: 1.2.0
3
+ Version: 2.0.0
4
4
  Summary: Taskwarrior wrapper python module
5
5
  Author-email: sznicolas <sznicolas@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -9,7 +9,7 @@
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.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.
12
+ **v2.0.0**: Major release with breaking API changes (Context.define now accepts ContextDTO; UdaConfig.type UdaConfig.uda_type). All tests passing and documentation updated.
13
13
 
14
14
  ## Features
15
15
 
@@ -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.2.0
34
+ pip install pytaskwarrior==2.0.0
35
35
  ```
36
36
 
37
37
  Or install from source:
@@ -132,7 +132,7 @@ tw = TaskWarrior(
132
132
 
133
133
  | Method | Description |
134
134
  |--------|-------------|
135
- | `define_context(name, read_filter, write_filter)` | Create a context with read and write filters |
135
+ | `define_context(ctx: ContextDTO)` | Create a context from a ContextDTO (name, read_filter, write_filter) |
136
136
  | `apply_context(name)` | Activate a context |
137
137
  | `unset_context()` | Deactivate current context |
138
138
  | `get_contexts()` | List all contexts |
@@ -144,7 +144,7 @@ tw = TaskWarrior(
144
144
 
145
145
  | Method | Description |
146
146
  |--------|-------------|
147
- | `is_sync_configured()` | Return `True` if any `sync.*` key is present in taskrc. |
147
+ | `is_sync_configured()` | Return `True` if any `sync.*` key is present in configuration (ConfigStore). |
148
148
  | `synchronize()` | Run `task sync`; raises `TaskSyncError` if not configured or sync fails. |
149
149
 
150
150
  ### Exceptions
@@ -244,9 +244,10 @@ RecurrencePeriod.YEARLY
244
244
  ### Working with Contexts
245
245
 
246
246
  ```python
247
+ from taskwarrior import ContextDTO
247
248
  # Define contexts for different workflows
248
- tw.define_context("work", read_filter="project:work or +urgent", write_filter="project:work or +urgent")
249
- tw.define_context("home", read_filter="project:home or project:personal", write_filter="project:home or project:personal")
249
+ tw.define_context(ContextDTO(name="work", read_filter="project:work or +urgent", write_filter="project:work or +urgent"))
250
+ tw.define_context(ContextDTO(name="home", read_filter="project:home or project:personal", write_filter="project:home or project:personal"))
250
251
 
251
252
  # Switch to work context
252
253
  tw.apply_context("work")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytaskwarrior"
3
- version = "1.2.0"
3
+ version = "2.0.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.2.0
3
+ Version: 2.0.0
4
4
  Summary: Taskwarrior wrapper python module
5
5
  Author-email: sznicolas <sznicolas@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -16,6 +16,7 @@ src/taskwarrior/py.typed
16
16
  src/taskwarrior/adapters/__init__.py
17
17
  src/taskwarrior/adapters/taskwarrior_adapter.py
18
18
  src/taskwarrior/config/config_store.py
19
+ src/taskwarrior/config/uda_parser.py
19
20
  src/taskwarrior/dto/__init__.py
20
21
  src/taskwarrior/dto/annotation_dto.py
21
22
  src/taskwarrior/dto/context_dto.py
@@ -76,4 +76,3 @@ __all__ = [
76
76
  "UdaRegistry",
77
77
  "UdaType",
78
78
  ]
79
-
@@ -28,7 +28,6 @@ logger = logging.getLogger(__name__)
28
28
 
29
29
 
30
30
  class TaskWarriorAdapter:
31
-
32
31
  """Low-level adapter for TaskWarrior CLI commands.
33
32
 
34
33
  This class handles direct communication with the TaskWarrior binary,
@@ -134,9 +133,7 @@ class TaskWarriorAdapter:
134
133
  )
135
134
  result = self.run_task_command(["sync"])
136
135
  if result.returncode != 0:
137
- raise TaskSyncError(
138
- f"Synchronization failed: {result.stderr or result.stdout}"
139
- )
136
+ raise TaskSyncError(f"Synchronization failed: {result.stderr or result.stdout}")
140
137
 
141
138
  @staticmethod
142
139
  def _wrap_filter(f: str) -> str:
@@ -267,13 +264,9 @@ class TaskWarriorAdapter:
267
264
  )
268
265
  except json.JSONDecodeError as e:
269
266
  logger.error(f"Failed to parse JSON response: {e}")
270
- raise TaskWarriorError(
271
- f"Invalid response from TaskWarrior: {result.stdout}"
272
- ) from e
267
+ raise TaskWarriorError(f"Invalid response from TaskWarrior: {result.stdout}") from e
273
268
  else:
274
- raise TaskNotFound(
275
- f"Task ID/UUID {task_id_or_uuid} not found"
276
- )
269
+ raise TaskNotFound(f"Task ID/UUID {task_id_or_uuid} not found")
277
270
 
278
271
  def get_tasks(
279
272
  self,
@@ -351,9 +344,7 @@ class TaskWarriorAdapter:
351
344
  tasks_data = json.loads(result.stdout)
352
345
  except json.JSONDecodeError as e:
353
346
  logger.error(f"Failed to parse JSON response: {e}")
354
- raise TaskWarriorError(
355
- f"Invalid response from TaskWarrior: {result.stdout}"
356
- ) from e
347
+ raise TaskWarriorError(f"Invalid response from TaskWarrior: {result.stdout}") from e
357
348
  if tasks_data:
358
349
  task = TaskOutputDTO.model_validate(tasks_data[0])
359
350
  logger.debug(f"Successfully retrieved recurring task: {task.uuid}")
@@ -7,12 +7,14 @@ from ..exceptions import TaskConfigurationError
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from ..dto.context_dto import ContextDTO
10
+ from ..dto.uda_dto import UdaConfig
10
11
 
11
12
  DEFAULT_OPTIONS = [
12
13
  "rc.confirmation=off",
13
14
  "rc.bulk=0",
14
15
  ]
15
16
 
17
+
16
18
  class ConfigStore:
17
19
  """
18
20
  Loads and caches Taskwarrior config from taskrc. Provides access methods and refresh capability.
@@ -20,7 +22,9 @@ class ConfigStore:
20
22
 
21
23
  def __init__(self, taskrc_path: str, data_location: str | None = None) -> None:
22
24
  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
25
+ self._data_location: Path | None = (
26
+ Path(os.path.expandvars(data_location)).expanduser() if data_location else None
27
+ )
24
28
  self._check_or_create_taskfiles()
25
29
  self._config: dict[str, str] | None = None
26
30
  self._load_config()
@@ -34,7 +38,7 @@ class ConfigStore:
34
38
  default_content = f"""# Taskwarrior configuration file
35
39
  # This file was automatically created by pytaskwarrior
36
40
  # Default data location
37
- rc.data.location={self._data_location or '~/.task'}
41
+ rc.data.location={self._data_location or "~/.task"}
38
42
  # Disable confirmation prompts
39
43
  rc.confirmation=off
40
44
  rc.bulk=0
@@ -56,11 +60,17 @@ rc.bulk=0
56
60
  except FileNotFoundError as e:
57
61
  raise TaskConfigurationError(f"Taskrc file not found: {path}") from e
58
62
  except PermissionError as e:
59
- raise TaskConfigurationError(f"Cannot read taskrc file (permission denied): {path}") from e
63
+ raise TaskConfigurationError(
64
+ f"Cannot read taskrc file (permission denied): {path}"
65
+ ) from e
60
66
  except OSError as e:
61
67
  raise TaskConfigurationError(f"Failed to read taskrc file: {path}: {e}") from e
62
68
  # 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]
69
+ filtered = [
70
+ line
71
+ for line in lines
72
+ if line.strip() == "" or line.strip().startswith("#") or "=" in line
73
+ ]
64
74
  content = "[taskrc]\n" + "".join(filtered)
65
75
  parser.read_string(content)
66
76
  for section in parser.sections():
@@ -128,3 +138,15 @@ rc.bulk=0
128
138
  )
129
139
  for n, filters in names.items()
130
140
  ]
141
+
142
+ def get_udas(self) -> list["UdaConfig"]:
143
+ """
144
+ Parse and return UDAs from the cached config mapping.
145
+
146
+ Returns a list of UdaConfig objects representing UDAs defined in the
147
+ TaskWarrior configuration. Uses the shared uda_parser to perform parsing.
148
+ """
149
+ # Local import to avoid module import cycles
150
+ from .uda_parser import parse_udas_from_mapping
151
+
152
+ return parse_udas_from_mapping(self.config)
@@ -0,0 +1,65 @@
1
+ """Parser utilities to convert TaskWarrior config mappings into UdaConfig DTOs.
2
+
3
+ This module centralizes the logic that converts a mapping of configuration
4
+ keys (as produced by ConfigStore.config) into UdaConfig DTOs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..dto.uda_dto import UdaConfig, UdaType
10
+ from ..exceptions import TaskWarriorError
11
+
12
+
13
+ def parse_udas_from_mapping(config: dict[str, str]) -> list[UdaConfig]:
14
+ """Parse UDA definitions from a config mapping.
15
+
16
+ Accepts keys like 'uda.<name>.<attr>' or 'taskrc.uda.<name>.<attr>'.
17
+ Returns a list of UdaConfig objects.
18
+
19
+ Raises TaskWarriorError on parsing errors.
20
+ """
21
+ uda_groups: dict[str, dict[str, str]] = {}
22
+ for key, value in config.items():
23
+ if not key:
24
+ continue
25
+ k = key.strip()
26
+ # normalize 'taskrc.' prefix if present
27
+ if k.startswith("taskrc."):
28
+ k = k[len("taskrc.") :]
29
+ if not k.startswith("uda."):
30
+ continue
31
+ parts = k.split(".")
32
+ if len(parts) < 3:
33
+ continue
34
+ name, attr = parts[1], parts[2]
35
+ uda_groups.setdefault(name, {})[attr] = value
36
+
37
+ udas: list[UdaConfig] = []
38
+ for name, attrs in uda_groups.items():
39
+ try:
40
+ converted_attrs: dict[str, object] = {}
41
+ for attr, val in attrs.items():
42
+ if attr == "type":
43
+ try:
44
+ converted_attrs["uda_type"] = UdaType(val)
45
+ except ValueError:
46
+ converted_attrs["uda_type"] = UdaType(val.lower())
47
+ elif attr == "values":
48
+ converted_attrs["values"] = [v.strip() for v in val.split(",")] if val else []
49
+ elif attr == "coefficient":
50
+ try:
51
+ converted_attrs["coefficient"] = float(val)
52
+ except (TypeError, ValueError):
53
+ converted_attrs["coefficient"] = None
54
+ elif attr == "label":
55
+ converted_attrs["label"] = val
56
+ elif attr == "default":
57
+ converted_attrs["default"] = val
58
+ else:
59
+ converted_attrs[attr] = val
60
+
61
+ uda = UdaConfig(name=name, **converted_attrs) # type: ignore[arg-type]
62
+ udas.append(uda)
63
+ except Exception as e:
64
+ raise TaskWarriorError(f"Error while parsing UDA '{name}': {e}") from e
65
+ return udas
@@ -2,4 +2,4 @@ from .annotation_dto import AnnotationDTO
2
2
  from .context_dto import ContextDTO
3
3
  from .task_dto import TaskInputDTO, TaskOutputDTO
4
4
 
5
- __all__ = ['AnnotationDTO', 'ContextDTO', 'TaskInputDTO', 'TaskOutputDTO']
5
+ __all__ = ["AnnotationDTO", "ContextDTO", "TaskInputDTO", "TaskOutputDTO"]
@@ -30,9 +30,7 @@ class AnnotationDTO(BaseModel):
30
30
  print(f"{annotation.entry}: {annotation.description}")
31
31
  """
32
32
 
33
- entry: datetime = Field(
34
- description="Annotation creation date and time (ISO format)"
35
- )
33
+ entry: datetime = Field(description="Annotation creation date and time (ISO format)")
36
34
  description: str = Field(description="Annotation description")
37
35
 
38
36
  model_config = {"populate_by_name": True, "extra": "forbid"}
@@ -69,15 +69,11 @@ class TaskInputDTO(BaseModel):
69
69
  )
70
70
  """
71
71
 
72
- description: str | None = Field(
73
- default=None, description="Task description (optional)."
74
- )
72
+ description: str | None = Field(default=None, description="Task description (optional).")
75
73
  priority: Priority | None = Field(
76
74
  default=None, description="Priority of the task (H, M, L, or empty)"
77
75
  )
78
- due: str | None = Field(
79
- default=None, description="Due date and time for the task (ISO format)"
80
- )
76
+ due: str | None = Field(default=None, description="Due date and time for the task (ISO format)")
81
77
  project: str | None = Field(default=None, description="Project the task belongs to")
82
78
  tags: list[str] = Field(
83
79
  default_factory=list, description="List of tags associated with the task"
@@ -26,7 +26,7 @@ class UdaType(str, Enum):
26
26
 
27
27
  Example:
28
28
  >>> from taskwarrior.dto.uda_dto import UdaConfig, UdaType
29
- >>> uda = UdaConfig(name="severity", type=UdaType.STRING)
29
+ >>> uda = UdaConfig(name="severity", uda_type=UdaType.STRING)
30
30
  """
31
31
 
32
32
  STRING = "string"
@@ -40,11 +40,11 @@ class UdaConfig(BaseModel):
40
40
  """Data Transfer Object for User Defined Attributes (UDAs).
41
41
 
42
42
  UDAs extend TaskWarrior with custom fields. Each UDA has a name,
43
- type, and optional configuration like allowed values or defaults.
43
+ uda_type, and optional configuration like allowed values or defaults.
44
44
 
45
45
  Attributes:
46
46
  name: Unique name for the UDA (used as the field name).
47
- type: Data type of the UDA value.
47
+ uda_type: Data type of the UDA value.
48
48
  label: Human-readable label for display in reports.
49
49
  values: List of allowed values (for string type with enumeration).
50
50
  default: Default value when not specified.
@@ -56,7 +56,7 @@ class UdaConfig(BaseModel):
56
56
 
57
57
  uda = UdaConfig(
58
58
  name="severity",
59
- type=UdaType.STRING,
59
+ uda_type=UdaType.STRING,
60
60
  label="Severity",
61
61
  values=["low", "medium", "high", "critical"],
62
62
  default="medium",
@@ -66,7 +66,7 @@ class UdaConfig(BaseModel):
66
66
  """
67
67
 
68
68
  name: str = Field(..., description="Name of the UDA")
69
- type: UdaType = Field(..., description="Data type of the UDA")
69
+ uda_type: UdaType = Field(..., description="Data type of the UDA")
70
70
  label: str | None = Field(default=None, description="Display label for the UDA")
71
71
  values: list[str] | None = Field(
72
72
  default=None, description="Allowed values for the UDA (for string types)"
@@ -26,6 +26,7 @@ class TaskSyncError(TaskWarriorError):
26
26
 
27
27
  This exception is used to signal errors encountered during sync operations.
28
28
  """
29
+
29
30
  pass
30
31
 
31
32
 
@@ -91,8 +91,9 @@ class TaskWarrior:
91
91
  self.context_service: ContextService = ContextService(self.adapter, self.config_store)
92
92
  self.uda_service: UdaService = UdaService(self.adapter, self.config_store)
93
93
 
94
- # Auto-load UDA definitions from taskrc
95
- self.uda_service.load_udas_from_taskrc()
94
+ # Auto-load UDA definitions from the configured store
95
+ # Use the service to orchestrate loading and registry population
96
+ self.uda_service.load_udas_from_store()
96
97
 
97
98
  def add_task(self, task: TaskInputDTO) -> TaskOutputDTO:
98
99
  """Add a new task to TaskWarrior.
@@ -321,25 +322,19 @@ class TaskWarrior:
321
322
  """
322
323
  self.adapter.annotate_task(task_id_or_uuid, annotation)
323
324
 
324
- def define_context(self, context: str, read_filter: str, write_filter: str) -> None:
325
- """Define a new context with explicit read and write filters.
325
+ def define_context(self, context) -> None:
326
+ """Define a new context from a ContextDTO.
326
327
 
327
- Both filters are required. Use an empty string for write_filter
328
- if you want a read-only context (new tasks won't inherit a project).
328
+ The context argument must be a ContextDTO instance containing
329
+ name, read_filter and write_filter.
329
330
 
330
331
  Args:
331
- context: Name of the context to create.
332
- read_filter: Filter applied when listing/querying tasks.
333
- write_filter: Filter applied when creating or modifying tasks.
332
+ context: ContextDTO instance with the context definition.
334
333
 
335
334
  Raises:
336
335
  TaskWarriorError: If context creation fails.
337
-
338
- Example:
339
- >>> tw.define_context("work", read_filter="project:work", write_filter="project:work")
340
- >>> tw.define_context("review", read_filter="+urgent or priority:H", write_filter="")
341
336
  """
342
- self.context_service.define_context(context, read_filter, write_filter)
337
+ self.context_service.define_context(context)
343
338
 
344
339
  def apply_context(self, context: str) -> None:
345
340
  """Activate a context.
@@ -471,10 +466,12 @@ class TaskWarrior:
471
466
  current_context = None
472
467
  current_context_details = None
473
468
 
474
- info.update({
475
- "current_context": current_context,
476
- "current_context_details": current_context_details,
477
- })
469
+ info.update(
470
+ {
471
+ "current_context": current_context,
472
+ "current_context_details": current_context_details,
473
+ }
474
+ )
478
475
 
479
476
  return info
480
477
 
@@ -526,7 +523,7 @@ class TaskWarrior:
526
523
  >>> tw.reload_udas()
527
524
  >>> names = tw.get_uda_names()
528
525
  """
529
- self.uda_service.load_udas_from_taskrc()
526
+ self.uda_service.load_udas_from_store()
530
527
 
531
528
  def get_uda_names(self) -> set[str]:
532
529
  """Get all defined UDA names.
@@ -0,0 +1,70 @@
1
+ """Registry for User Defined Attributes (UDAs).
2
+
3
+ This module provides the UdaRegistry class for tracking and managing UDA definitions.
4
+
5
+ The registry no longer performs direct file I/O. UDA discovery is performed
6
+ via ConfigStore (ConfigStore.get_udas()) or by passing an in-memory config
7
+ mapping to `load_from_config`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ..dto.uda_dto import UdaConfig
13
+
14
+
15
+ class UdaRegistry:
16
+ """Registry for User Defined Attributes (UDAs).
17
+
18
+ This class maintains a registry of UDA definitions loaded from in-memory
19
+ configuration mappings or provided by a ConfigStore instance. It intentionally
20
+ avoids performing direct file I/O to keep concerns separated.
21
+
22
+ Example:
23
+ >>> registry = UdaRegistry()
24
+ >>> registry.load_from_config({"uda.example.type": "string"})
25
+ >>> names = registry.get_uda_names()
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ self._udas: dict[str, UdaConfig] = {}
30
+
31
+ def register_udas(self, udas: list[UdaConfig]) -> None:
32
+ """Register a list of UdaConfig objects into the registry."""
33
+ for uda in udas:
34
+ self._udas[uda.name] = uda
35
+
36
+ def load_from_config(self, config: dict[str, str]) -> None:
37
+ """Load UDA definitions from an in-memory config mapping.
38
+
39
+ The config mapping should contain keys like 'uda.<name>.<attr>'.
40
+ This avoids direct file I/O and allows using ConfigStore.config.
41
+ """
42
+ # Local import to avoid module import cycles
43
+ from ..config.uda_parser import parse_udas_from_mapping
44
+
45
+ udas = parse_udas_from_mapping(config)
46
+ self.register_udas(udas)
47
+
48
+ def add_uda(self, uda: UdaConfig) -> None:
49
+ """Add a UDA definition to the in-memory registry (no side effects)."""
50
+ self._udas[uda.name] = uda
51
+
52
+ def update_uda(self, uda: UdaConfig) -> None:
53
+ """Update an existing UDA definition in the registry (no side effects)."""
54
+ self._udas[uda.name] = uda
55
+
56
+ def remove_uda(self, name: str) -> None:
57
+ """Remove a UDA definition from the registry by name (no side effects)."""
58
+ self._udas.pop(name, None)
59
+
60
+ def get_uda(self, name: str) -> UdaConfig | None:
61
+ """Get a UDA definition by name."""
62
+ return self._udas.get(name)
63
+
64
+ def get_uda_names(self) -> set[str]:
65
+ """Get all registered UDA names."""
66
+ return set(self._udas.keys())
67
+
68
+ def is_uda_field(self, field_name: str) -> bool:
69
+ """Check if a field name corresponds to a registered UDA."""
70
+ return field_name in self._udas
@@ -4,7 +4,6 @@ This module provides the ContextService class for managing TaskWarrior
4
4
  contexts (named filters).
5
5
  """
6
6
 
7
-
8
7
  from typing import TYPE_CHECKING
9
8
 
10
9
  from ..adapters.taskwarrior_adapter import TaskWarriorAdapter
@@ -29,11 +28,11 @@ class ContextService:
29
28
  This service is typically accessed via TaskWarrior::
30
29
 
31
30
  tw = TaskWarrior()
32
- tw.define_context("work", read_filter="project:work", write_filter="project:work")
31
+ tw.define_context(ContextDTO(name="work", read_filter="project:work", write_filter="project:work"))
33
32
  tw.apply_context("work")
34
33
  """
35
34
 
36
- def __init__(self, adapter: TaskWarriorAdapter, config_store: 'ConfigStore') -> None:
35
+ def __init__(self, adapter: TaskWarriorAdapter, config_store: "ConfigStore") -> None:
37
36
  """Initialize the context service.
38
37
 
39
38
  Args:
@@ -47,37 +46,38 @@ class ContextService:
47
46
  if not name or not name.strip():
48
47
  raise TaskValidationError("Context name cannot be empty")
49
48
 
50
- def define_context(
51
- self, name: str, read_filter: str, write_filter: str
52
- ) -> None:
53
- """Create or update a context with explicit read and write filters.
49
+ def define_context(self, ctx: ContextDTO) -> None:
50
+ """Create or update a context from a ContextDTO.
54
51
 
55
52
  TaskWarrior stores read and write filters separately in .taskrc.
56
- Both must be provided there is no implicit default.
53
+ The ContextDTO supplies both read_filter and write_filter explicitly.
57
54
 
58
55
  Args:
59
- name: Unique context name.
60
- read_filter: Filter applied when listing/querying tasks.
61
- write_filter: Filter applied when creating or modifying tasks.
56
+ ctx: ContextDTO containing name, read_filter, and write_filter.
62
57
 
63
58
  Raises:
64
59
  TaskWarriorError: If the name is empty or creation fails.
65
60
 
66
61
  Example:
67
- >>> service.define_context("work", read_filter="project:work", write_filter="project:work")
68
- >>> service.define_context("urgent", read_filter="+urgent", write_filter="") # read-only filter
62
+ >>> svc.define_context(ContextDTO(name="work", read_filter="project:work", write_filter="project:work"))
69
63
  """
70
- self._validate_name(name)
71
- result = self.adapter.run_task_command(["context", "define", name, read_filter])
72
- if result.returncode != 0:
73
- raise TaskWarriorError(f"Failed to define context '{name}': {result.stderr}")
74
- result = self.adapter.run_task_command(
75
- ["config", f"context.{name}.write", write_filter]
76
- )
77
- if result.returncode != 0:
78
- raise TaskWarriorError(
79
- f"Failed to set write filter for context '{name}': {result.stderr}"
80
- )
64
+ self._validate_name(ctx.name)
65
+ # Collect commands to be executed
66
+ commands = [
67
+ ["context", "define", ctx.name, ctx.read_filter],
68
+ ["config", f"context.{ctx.name}.write", ctx.write_filter],
69
+ ]
70
+
71
+ # Execute all commands through the adapter
72
+ for cmd in commands:
73
+ result = self.adapter.run_task_command(cmd)
74
+ if result.returncode != 0:
75
+ if cmd[0] == "context" and cmd[1] == "define":
76
+ raise TaskWarriorError(f"Failed to define context '{ctx.name}': {result.stderr}")
77
+ elif cmd[0] == "config":
78
+ raise TaskWarriorError(
79
+ f"Failed to set write filter for context '{ctx.name}': {result.stderr}"
80
+ )
81
81
  self.config_store.refresh()
82
82
 
83
83
  def apply_context(self, name: str) -> None:
@@ -153,11 +153,10 @@ class ContextService:
153
153
  TaskWarriorError: If the name is empty or deletion fails.
154
154
  """
155
155
  self._validate_name(name)
156
+ # Execute command through the adapter
156
157
  result = self.adapter.run_task_command(["context", "delete", name])
157
158
  if result.returncode != 0:
158
- raise TaskWarriorError(
159
- f"Failed to delete context '{name}': {result.stderr}"
160
- )
159
+ raise TaskWarriorError(f"Failed to delete context '{name}': {result.stderr}")
161
160
  self.config_store.refresh()
162
161
 
163
162
  def has_context(self, name: str) -> bool:
@@ -0,0 +1,109 @@
1
+ """User Defined Attributes (UDA) service for TaskWarrior.
2
+
3
+ This module provides the UdaService class for managing custom task attributes.
4
+ """
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from ..config.config_store import ConfigStore
10
+
11
+ from ..adapters.taskwarrior_adapter import TaskWarriorAdapter
12
+ from ..dto.uda_dto import UdaConfig
13
+ from ..exceptions import TaskOperationError
14
+ from ..registry.uda_registry import UdaRegistry
15
+
16
+
17
+ class UdaService:
18
+ """Service for managing User Defined Attributes (UDAs).
19
+
20
+ UDAs allow extending TaskWarrior with custom fields. This service
21
+ provides methods to define, update, and delete UDAs, delegating
22
+ the actual work to UdaRegistry.
23
+
24
+ Attributes:
25
+ adapter: The TaskWarriorAdapter instance for CLI communication.
26
+ registry: The UdaRegistry for tracking defined UDAs.
27
+
28
+ Example:
29
+ This service is typically accessed via TaskWarrior::
30
+
31
+ tw = TaskWarrior()
32
+ uda = UdaConfig(name="severity", uda_type=UdaType.STRING)
33
+ tw.uda_service.define_uda(uda)
34
+ """
35
+
36
+ def __init__(self, adapter: TaskWarriorAdapter, config_store: "ConfigStore") -> None:
37
+ """Initialize the UDA service.
38
+
39
+ Args:
40
+ adapter: The TaskWarriorAdapter to use for CLI commands.
41
+ config_store: The configuration store instance (required).
42
+ """
43
+
44
+ self.adapter = adapter
45
+ self.config_store = config_store
46
+ self.registry = UdaRegistry()
47
+
48
+ def load_udas_from_store(self) -> None:
49
+ """Load existing UDA definitions from the configured ConfigStore.
50
+
51
+ This method delegates parsing to ConfigStore.get_udas() and registers
52
+ the resulting UdaConfig objects in the registry (in-memory only).
53
+ """
54
+ udas = self.config_store.get_udas()
55
+ self.registry.register_udas(udas)
56
+
57
+ def define_uda(self, uda: UdaConfig) -> None:
58
+ """Define a new UDA in TaskWarrior and register it locally.
59
+
60
+ The service executes the required `task config` commands via the adapter
61
+ and only updates the registry if all commands succeed.
62
+ """
63
+ # Build commands to define the UDA
64
+ field_names = uda.__class__.model_fields.keys() - {"name"}
65
+ # uda_type is handled first
66
+ commands: list[list[str]] = [["config", f"uda.{uda.name}.type", uda.uda_type.value]]
67
+ field_names -= {"uda_type"}
68
+
69
+ for field_name in field_names:
70
+ value = getattr(uda, field_name)
71
+ if value is not None and value != "":
72
+ commands.append(["config", f"uda.{uda.name}.{field_name}", str(value)])
73
+
74
+ # Execute commands via adapter; if any fail, raise and do not modify registry
75
+ for cmd in commands:
76
+ result = self.adapter.run_task_command(cmd)
77
+ if getattr(result, "returncode", 0) != 0:
78
+ stderr = str(getattr(result, "stderr", ""))
79
+ raise TaskOperationError(f"Failed to run task command: {cmd} -> {stderr}")
80
+
81
+ # On success, update registry
82
+ self.registry.add_uda(uda)
83
+
84
+ def update_uda(self, uda: UdaConfig) -> None:
85
+ """Update an existing UDA in TaskWarrior and in the registry.
86
+
87
+ Executes commands via adapter and updates the registry on success.
88
+ """
89
+ # For now, same as define_uda
90
+ self.define_uda(uda)
91
+
92
+ def delete_uda(self, uda: UdaConfig) -> None:
93
+ """Delete a UDA from TaskWarrior and remove it from the registry.
94
+
95
+ Executes `task config <key>` without a value to remove each UDA key.
96
+ """
97
+ field_names = uda.__class__.model_fields.keys()
98
+ for key in field_names:
99
+ cmd = ["config", f"uda.{uda.name}.{key}"]
100
+ result = self.adapter.run_task_command(cmd)
101
+ if getattr(result, "returncode", 0) != 0:
102
+ stderr = str(getattr(result, "stderr", ""))
103
+ # tolerate missing keys (idempotent deletion)
104
+ if "no entry named" in stderr.lower():
105
+ continue
106
+ raise TaskOperationError(f"Failed to run task command: {cmd} -> {stderr}")
107
+
108
+ # On success, remove from registry
109
+ self.registry.remove_uda(uda.name)
@@ -46,4 +46,3 @@ def parse_taskwarrior_date(value: str) -> datetime:
46
46
  return datetime.fromisoformat(value)
47
47
  except ValueError as e:
48
48
  raise ValueError(f"Cannot parse TaskWarrior date: {value!r}") from e
49
-
@@ -29,7 +29,18 @@ def task_output_to_input(task_output: TaskOutputDTO) -> TaskInputDTO:
29
29
  >>> tw.modify_task(input_dto, uuid)
30
30
  """
31
31
  data = task_output.model_dump(
32
- exclude={"uuid", "entry", "start", "end", "modified", "index", "status", "urgency", "imask", "rtype"}
32
+ exclude={
33
+ "uuid",
34
+ "entry",
35
+ "start",
36
+ "end",
37
+ "modified",
38
+ "index",
39
+ "status",
40
+ "urgency",
41
+ "imask",
42
+ "rtype",
43
+ }
33
44
  )
34
45
  # Convert datetime fields to strings as required by TaskInputDTO
35
46
  datetime_fields = ["due", "scheduled", "wait", "until"]
@@ -1,168 +0,0 @@
1
- """Registry for User Defined Attributes (UDAs).
2
-
3
- This module provides the UdaRegistry singleton class for tracking
4
- and managing UDA definitions.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from pathlib import Path
10
-
11
- from ..adapters.taskwarrior_adapter import TaskWarriorAdapter
12
- from ..dto.uda_dto import UdaConfig, UdaType
13
- from ..exceptions import TaskConfigurationError, TaskWarriorError
14
-
15
-
16
- class UdaRegistry:
17
- """Registry for User Defined Attributes (UDAs).
18
-
19
- This class maintains a registry of UDA definitions, loaded from
20
- the taskrc file or defined programmatically. Each instance has its
21
- own isolated state, making it safe to use with multiple TaskWarrior
22
- instances.
23
-
24
- Attributes:
25
- _udas: Dictionary mapping UDA names to their definitions.
26
-
27
- Example:
28
- >>> registry = UdaRegistry()
29
- >>> registry.load_from_taskrc("~/.taskrc")
30
- >>> names = registry.get_uda_names()
31
- """
32
-
33
- def __init__(self) -> None:
34
- self._udas: dict[str, UdaConfig] = {}
35
-
36
- def load_from_taskrc(self, taskrc_file: str | Path) -> None:
37
- """Load UDA definitions from a taskrc file.
38
-
39
- Parses the taskrc file to find all `uda.*` configuration lines
40
- and creates UdaConfig objects for each discovered UDA.
41
-
42
- Args:
43
- taskrc_file: Path to the taskrc configuration file.
44
-
45
- Raises:
46
- TaskWarriorError: If the file doesn't exist or parsing fails.
47
-
48
- Example:
49
- >>> registry.load_from_taskrc("/path/to/.taskrc")
50
- """
51
- self._udas = {}
52
- try:
53
- with open(taskrc_file) as f:
54
- content = f.read()
55
- # Find all uda.* lines
56
- uda_lines = [
57
- line.strip()
58
- for line in content.splitlines()
59
- if line.strip().startswith("uda.")
60
- ]
61
- # Group by UDA name
62
- uda_groups: dict[str, dict[str, str]] = {}
63
- for line in uda_lines:
64
- if "=" not in line:
65
- continue
66
- key, value = line.split("=", 1)
67
- parts = key.split(".")
68
- if len(parts) < 3:
69
- continue
70
- name, attr = parts[1], parts[2]
71
- if name not in uda_groups:
72
- uda_groups[name] = {}
73
- uda_groups[name][attr] = value.strip()
74
-
75
- # Convert to UdaConfig objects
76
- for name, attrs in uda_groups.items():
77
- try:
78
- # Convert type string to UdaType enum
79
- converted_attrs: dict[str, object] = {}
80
- for key, value in attrs.items():
81
- if key == "type":
82
- converted_attrs[key] = UdaType(value)
83
- elif key == "values":
84
- converted_attrs[key] = value.split(",") if value else []
85
- elif key == "default":
86
- converted_attrs[key] = value
87
- else:
88
- converted_attrs[key] = value
89
-
90
- self._udas[name] = UdaConfig(name=name, **converted_attrs) # type: ignore[arg-type]
91
- except Exception as e:
92
- raise TaskWarriorError(f"Error while parsing {name}: {str(e)}") from e
93
-
94
- except FileNotFoundError as e:
95
- raise TaskConfigurationError(f"Taskrc file not found: {taskrc_file}") from e
96
- except Exception as e:
97
- raise TaskWarriorError(f"Error reading taskrc: {str(e)}") from e
98
-
99
- def define_update_uda(self, uda: UdaConfig, adapter: TaskWarriorAdapter) -> None:
100
- """Define or update a UDA in TaskWarrior configuration.
101
-
102
- Uses `task config` commands to set UDA properties and
103
- updates the local registry.
104
-
105
- Args:
106
- uda: The UDA definition to create or update.
107
- adapter: The TaskWarriorAdapter for executing commands.
108
- """
109
- # Get all field names from UdaConfig
110
- field_names = uda.__class__.model_fields.keys() - {"name"}
111
- # Process the type
112
- field_names -= {"type"}
113
- adapter.run_task_command(
114
- ["config", f"uda.{uda.name}.type", uda.type.value]
115
- )
116
-
117
- # Process each field that has a value
118
- for field_name in field_names:
119
- value = getattr(uda, field_name)
120
- if value is not None and value != "":
121
- config_key = f"uda.{uda.name}.{field_name}"
122
- adapter.run_task_command(["config", config_key, str(value)])
123
- self._udas.update({uda.name: uda})
124
-
125
- def delete_uda(self, uda: UdaConfig, adapter: TaskWarriorAdapter) -> None:
126
- """Delete a UDA from TaskWarrior configuration.
127
-
128
- Clears all UDA configuration entries and removes it from the registry.
129
-
130
- Args:
131
- uda: The UDA to delete.
132
- adapter: The TaskWarriorAdapter for executing commands.
133
- """
134
- # Clear all UDA configuration entries by setting them to empty strings
135
- field_names = uda.__class__.model_fields.keys()
136
- for key in field_names:
137
- adapter.run_task_command(["config", f"uda.{uda.name}.{key}"])
138
- self._udas.pop(uda.name)
139
-
140
- def get_uda(self, name: str) -> UdaConfig | None:
141
- """Get a UDA definition by name.
142
-
143
- Args:
144
- name: The name of the UDA to retrieve.
145
-
146
- Returns:
147
- The UdaConfig if found, None otherwise.
148
- """
149
- return self._udas.get(name)
150
-
151
- def get_uda_names(self) -> set[str]:
152
- """Get all registered UDA names.
153
-
154
- Returns:
155
- Set of UDA names currently in the registry.
156
- """
157
- return set(self._udas.keys())
158
-
159
- def is_uda_field(self, field_name: str) -> bool:
160
- """Check if a field name corresponds to a registered UDA.
161
-
162
- Args:
163
- field_name: The field name to check.
164
-
165
- Returns:
166
- True if the field is a registered UDA, False otherwise.
167
- """
168
- return field_name in self._udas
@@ -1,92 +0,0 @@
1
- """User Defined Attributes (UDA) service for TaskWarrior.
2
-
3
- This module provides the UdaService class for managing custom task attributes.
4
- """
5
-
6
- from typing import TYPE_CHECKING
7
-
8
- from ..adapters.taskwarrior_adapter import TaskWarriorAdapter
9
- from ..dto.uda_dto import UdaConfig
10
- from ..registry.uda_registry import UdaRegistry
11
-
12
- if TYPE_CHECKING:
13
- from ..config.config_store import ConfigStore
14
-
15
-
16
- class UdaService:
17
- """Service for managing User Defined Attributes (UDAs).
18
-
19
- UDAs allow extending TaskWarrior with custom fields. This service
20
- provides methods to define, update, and delete UDAs, delegating
21
- the actual work to UdaRegistry.
22
-
23
- Attributes:
24
- adapter: The TaskWarriorAdapter instance for CLI communication.
25
- registry: The UdaRegistry for tracking defined UDAs.
26
-
27
- Example:
28
- This service is typically accessed via TaskWarrior::
29
-
30
- tw = TaskWarrior()
31
- uda = UdaConfig(name="severity", type=UdaType.STRING)
32
- tw.uda_service.define_uda(uda)
33
- """
34
-
35
- def __init__(self, adapter: TaskWarriorAdapter, config_store: 'ConfigStore') -> None:
36
- """Initialize the UDA service.
37
-
38
- Args:
39
- adapter: The TaskWarriorAdapter to use for CLI commands.
40
- config_store: The configuration store instance (required).
41
- """
42
-
43
- self.adapter = adapter
44
- self.config_store = config_store
45
- self.registry = UdaRegistry()
46
-
47
- def load_udas_from_taskrc(self) -> None:
48
- """Load existing UDA definitions from the taskrc file.
49
-
50
- Parses the taskrc file to discover and register any UDAs
51
- that have been previously defined.
52
- """
53
- self.registry.load_from_taskrc(self.config_store._taskrc_path)
54
-
55
- def define_uda(self, uda: UdaConfig) -> None:
56
- """Define a new UDA in TaskWarrior.
57
-
58
- Creates the UDA configuration in TaskWarrior and registers
59
- it in the local registry.
60
-
61
- Args:
62
- uda: The UDA definition to create.
63
-
64
- Example:
65
- >>> uda = UdaConfig(
66
- ... name="severity",
67
- ... type=UdaType.STRING,
68
- ... values=["low", "medium", "high"]
69
- ... )
70
- >>> service.define_uda(uda)
71
- """
72
- self.registry.define_update_uda(uda, self.adapter)
73
-
74
- def update_uda(self, uda: UdaConfig) -> None:
75
- """Update an existing UDA definition.
76
-
77
- Modifies the UDA configuration in TaskWarrior.
78
-
79
- Args:
80
- uda: The updated UDA definition.
81
- """
82
- self.registry.define_update_uda(uda, self.adapter)
83
-
84
- def delete_uda(self, uda: UdaConfig) -> None:
85
- """Delete a UDA from TaskWarrior.
86
-
87
- Removes the UDA configuration and unregisters it.
88
-
89
- Args:
90
- uda: The UDA to delete.
91
- """
92
- self.registry.delete_uda(uda, self.adapter)
File without changes
File without changes