Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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
Expand Down
4 changes: 2 additions & 2 deletions docs/examples/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/examples/basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```
Expand Down
39 changes: 26 additions & 13 deletions docs/llms/llms-udas.md
Original file line number Diff line number Diff line change
@@ -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.<name>.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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
41 changes: 41 additions & 0 deletions src/taskwarrior/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
37 changes: 35 additions & 2 deletions src/taskwarrior/services/uda_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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)
Expand All @@ -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 <key>` 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:
Expand Down
3 changes: 1 addition & 2 deletions tests/unit/test_uda_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Loading