From 03fd20a0ac99b1fe5146c418e81217afcb89cbab Mon Sep 17 00:00:00 2001 From: nsz Date: Sat, 11 Apr 2026 10:04:17 +0200 Subject: [PATCH] feat(uda): harmonize delete; add facade & doc --- CHANGELOG.md | 12 ++++++++ docs/examples/advanced.md | 4 +-- docs/examples/basic.md | 6 ++++ docs/llms/llms-udas.md | 39 +++++++++++++++-------- src/taskwarrior/main.py | 41 +++++++++++++++++++++++++ src/taskwarrior/services/uda_service.py | 37 ++++++++++++++++++++-- tests/unit/test_uda_service.py | 3 +- 7 files changed, 123 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f095581..04064f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to pytaskwarrior will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Harmonized UDA deletion and facade methods: UdaService.delete_uda now maps the library's internal `uda_type` field to TaskWarrior's `uda..type` key and removes the correct configuration keys; public facade methods `tw.define_uda`, `tw.update_uda` and `tw.delete_uda` were added to simplify UDA management. +- Documentation review and improvements: updated examples to use the public TaskWarrior façade, added guidance on UDA `uda_type` vs TaskWarrior `type`, and added tag helper examples (`tw.get_tags`, `tw.get_context_tags`) for clarity and consistency. + +### Changed + +- Documentation: swept and updated examples and how-tos to use the public API and clarified terminology around UDAs and tags. + + ## [2.0.3] - 2026-04-10 ### Changed diff --git a/docs/examples/advanced.md b/docs/examples/advanced.md index cd4192d..5943cb1 100644 --- a/docs/examples/advanced.md +++ b/docs/examples/advanced.md @@ -67,7 +67,7 @@ severity = UdaConfig( values=["low", "medium", "high", "critical"], default="medium", ) -tw.uda_service.define_uda(severity) +tw.define_uda(severity) # Numeric UDA for time estimates estimate = UdaConfig( @@ -76,7 +76,7 @@ estimate = UdaConfig( label="Hours", coefficient=1.0, # Affects urgency ) -tw.uda_service.define_uda(estimate) +tw.define_uda(estimate) ``` ### Using UDAs in Tasks diff --git a/docs/examples/basic.md b/docs/examples/basic.md index c7ef5ad..1341b6f 100644 --- a/docs/examples/basic.md +++ b/docs/examples/basic.md @@ -66,6 +66,12 @@ work_tasks = tw.get_tasks("project:work") # By tag urgent = tw.get_tasks("+urgent") +# Tags - helpers +# Get all tags in the system +all_tags = tw.get_tags() # e.g. ['work', 'urgent'] +# Get tags following the context convention (start with '@') +context_tags = tw.get_context_tags() # e.g. ['@home', '@work'] + # Complex filter overdue = tw.get_tasks("due.before:today status:pending") ``` diff --git a/docs/llms/llms-udas.md b/docs/llms/llms-udas.md index 624e9e8..5ed54c3 100644 --- a/docs/llms/llms-udas.md +++ b/docs/llms/llms-udas.md @@ -1,6 +1,8 @@ # UDA Implementation -User Defined Attributes (UDAs) extend TaskWarrior with custom fields to meet specific project needs. +User Defined Attributes (UDAs) extend TaskWarrior with custom fields. + +Note: In this library the DTO uses the field name `uda_type` (to avoid using the Python reserved word `type`). In TaskWarrior configuration files the corresponding key is written as `uda..type`. Examples in this document use the public TaskWarrior facade (for example `tw.define_uda`, `tw.update_uda`, and `tw.delete_uda`). ## UDA Definition Examples @@ -19,7 +21,7 @@ severity = UdaConfig( values=["low", "medium", "high", "critical"], default="medium", ) -tw.uda_service.define_uda(severity) +tw.define_uda(severity) ``` ### Numeric UDA for Time Estimates @@ -32,7 +34,7 @@ estimate = UdaConfig( label="Hours", coefficient=1.0, # Affects urgency ) -tw.uda_service.define_uda(estimate) +tw.define_uda(estimate) ``` ### Date UDA for Milestones @@ -44,7 +46,7 @@ milestone = UdaConfig( uda_type=UdaType.DATE, label="Milestone Date", ) -tw.uda_service.define_uda(milestone) +tw.define_uda(milestone) ``` ## Using UDAs in Tasks @@ -73,6 +75,17 @@ estimate = task.get_uda("estimate", default=0) # 4 milestone = task.get_uda("milestone") # None if not set ``` +### Update and Delete UDAs + +```python +# Update UDA +severity_updated = UdaConfig(name="severity", uda_type=UdaType.STRING, label="Severity", default="low") +tw.update_uda(severity_updated) + +# Delete UDA +tw.delete_uda(severity) +``` + ## Listing and Managing UDAs ### Get All Defined UDA Names @@ -125,20 +138,20 @@ def setup_udas(tw): coefficient=1.0, ) - tw.uda_service.define_uda(severity) - tw.uda_service.define_uda(estimate) + tw.define_uda(severity) + tw.define_uda(estimate) ``` ### Use Descriptive Names ```python # Good descriptive names -tw.uda_service.define_uda(UdaConfig(name="priority", uda_type=UdaType.STRING, label="Priority")) -tw.uda_service.define_uda(UdaConfig(name="risk", uda_type=UdaType.STRING, label="Risk Level")) +tw.define_uda(UdaConfig(name="priority", uda_type=UdaType.STRING, label="Priority")) +tw.define_uda(UdaConfig(name="risk", uda_type=UdaType.STRING, label="Risk Level")) # Less descriptive names -tw.uda_service.define_uda(UdaConfig(name="p", uda_type=UdaType.STRING, label="Priority")) -tw.uda_service.define_uda(UdaConfig(name="r", uda_type=UdaType.STRING, label="Risk")) +tw.define_uda(UdaConfig(name="p", uda_type=UdaType.STRING, label="Priority")) +tw.define_uda(UdaConfig(name="r", uda_type=UdaType.STRING, label="Risk")) ``` ### Set Appropriate Defaults @@ -175,7 +188,7 @@ status_tracking = UdaConfig( values=["planning", "in-progress", "review", "completed"], default="planning", ) -tw.uda_service.define_uda(status_tracking) +tw.define_uda(status_tracking) # Use in tasks task = TaskInputDTO( @@ -195,7 +208,7 @@ resource = UdaConfig( values=["developer", "designer", "manager", "qa"], default="developer", ) -tw.uda_service.define_uda(resource) +tw.define_uda(resource) # Use in tasks task = TaskInputDTO( @@ -214,7 +227,7 @@ budget = UdaConfig( label="Budget (USD)", coefficient=1.0, ) -tw.uda_service.define_uda(budget) +tw.define_uda(budget) # Use in tasks task = TaskInputDTO( diff --git a/src/taskwarrior/main.py b/src/taskwarrior/main.py index 90830f3..44baffd 100644 --- a/src/taskwarrior/main.py +++ b/src/taskwarrior/main.py @@ -554,6 +554,47 @@ def get_uda_config(self, name: str) -> UdaConfig | None: """ return self.uda_service.registry.get_uda(name) + def define_uda(self, uda: UdaConfig) -> None: + """Define a new UDA via the TaskWarrior facade. + + Delegates to UdaService.define_uda which performs the necessary + TaskWarrior config writes and registers the UDA in the local registry. + + Args: + uda: The UdaConfig describing the UDA to create. + + Raises: + TaskOperationError: If creating the UDA via the underlying adapter fails. + """ + self.uda_service.define_uda(uda) + + def update_uda(self, uda: UdaConfig) -> None: + """Update an existing UDA via the TaskWarrior facade. + + Delegates to UdaService.update_uda. + + Args: + uda: The UdaConfig with updated fields. + + Raises: + TaskOperationError: If applying the update fails. + """ + self.uda_service.update_uda(uda) + + def delete_uda(self, uda: UdaConfig) -> None: + """Delete a UDA via the TaskWarrior facade. + + Delegates to UdaService.delete_uda which removes TaskWarrior config + keys and the UDA from the local registry. + + Args: + uda: The UdaConfig identifying the UDA to remove. + + Raises: + TaskOperationError: If deletion fails for reasons other than missing keys. + """ + self.uda_service.delete_uda(uda) + def get_projects(self) -> list[str]: """Get all projects defined in TaskWarrior. diff --git a/src/taskwarrior/services/uda_service.py b/src/taskwarrior/services/uda_service.py index bb87a29..084a3fc 100644 --- a/src/taskwarrior/services/uda_service.py +++ b/src/taskwarrior/services/uda_service.py @@ -59,6 +59,16 @@ def define_uda(self, uda: UdaConfig) -> None: The service executes the required `task config` commands via the adapter and only updates the registry if all commands succeed. + + Args: + uda: The UdaConfig describing the UDA to create. + + Raises: + TaskOperationError: If any underlying TaskWarrior config command fails. + + Example: + >>> uda = UdaConfig(name="sev", uda_type=UdaType.STRING, label="Severity") + >>> service.define_uda(uda) """ # Build commands to define the UDA field_names = uda.__class__.model_fields.keys() - {"name"} @@ -86,6 +96,12 @@ def update_uda(self, uda: UdaConfig) -> None: """Update an existing UDA in TaskWarrior and in the registry. Executes commands via adapter and updates the registry on success. + + Args: + uda: The UdaConfig with updated settings to apply. + + Raises: + TaskOperationError: If applying the updated configuration fails. """ # For now, same as define_uda self.define_uda(uda) @@ -94,9 +110,26 @@ def delete_uda(self, uda: UdaConfig) -> None: """Delete a UDA from TaskWarrior and remove it from the registry. Executes `task config ` without a value to remove each UDA key. + + Args: + uda: The UdaConfig identifying the UDA to remove. + + Raises: + TaskOperationError: If an unexpected TaskWarrior error occurs while + attempting to remove configuration keys (missing keys are tolerated). """ - field_names = uda.__class__.model_fields.keys() - for key in field_names: + # Mirror define_uda: skip 'name' and map internal 'uda_type' -> TaskWarrior 'type' + field_names = set(uda.__class__.model_fields.keys()) - {"name"} + keys_to_delete: list[str] = [] + + if "uda_type" in field_names: + keys_to_delete.append("type") + field_names.remove("uda_type") + + # delete remaining fields deterministically + keys_to_delete.extend(sorted(field_names)) + + for key in keys_to_delete: cmd = ["config", f"uda.{uda.name}.{key}"] result = self.adapter.run_task_command(cmd) if getattr(result, "returncode", 0) != 0: diff --git a/tests/unit/test_uda_service.py b/tests/unit/test_uda_service.py index d626b72..b68157b 100644 --- a/tests/unit/test_uda_service.py +++ b/tests/unit/test_uda_service.py @@ -117,8 +117,7 @@ def test_uda_service_delete_uda(): service.delete_uda(uda) - mock_adapter.run_task_command.assert_any_call(["config", "uda.test_uda.name"]) - mock_adapter.run_task_command.assert_any_call(["config", "uda.test_uda.uda_type"]) + mock_adapter.run_task_command.assert_any_call(["config", "uda.test_uda.type"]) mock_adapter.run_task_command.assert_any_call(["config", "uda.test_uda.label"]) mock_adapter.run_task_command.assert_any_call(["config", "uda.test_uda.values"]) mock_adapter.run_task_command.assert_any_call(["config", "uda.test_uda.default"])