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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ 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).

## [2.0.3] - 2026-04-10

### Changed

- add get_tags()
- add tw.get_context_tags(); tags beginning with `@`

## [2.0.2] - 2026-04-06

### Changed

- Bumped package version to 2.0.2 in pyproject.toml.

## [2.0.1] - 2026-04-07

### Fixed
Expand Down
9 changes: 9 additions & 0 deletions PYPI_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Production-ready with 164 tests (96% coverage), strict type checking, and profes
- Type-safe with Pydantic models
- Context management
- UDA (User Defined Attributes) support
- Tag discovery and `@` context tags
- Recurring tasks and annotations
- Consistent exception hierarchy (`TaskNotFound`, `TaskValidationError`, `TaskOperationError`, `TaskConfigurationError`, …)

Expand Down Expand Up @@ -55,6 +56,14 @@ for t in tw.get_tasks():
tw.done_task(added.uuid)
```

## Tags

```python
tags = tw.get_tags() # virtual tags excluded
all_tags = tw.get_tags(include_virtual_tags=True)
context_tags = tw.get_context_tags() # tags starting with "@"
```

## Documentation

Full documentation: [GitHub Repository](https://github.com/sznicolas/pytaskwarrior/)
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ A modern Python wrapper for [TaskWarrior](https://taskwarrior.org/) v3.4, the co
- **Type-safe** - Pydantic models with full type hints
- **Context management** - Define, apply, and switch contexts
- **UDA support** - User Defined Attributes
- **Tag discovery** - List real tags, virtual tags, and `@` context tags
- **Recurring tasks** - Full recurrence support
- **Annotations** - Add notes to tasks
- **Date calculations** - Use TaskWarrior's date expressions
Expand Down Expand Up @@ -128,6 +129,13 @@ tw = TaskWarrior(
| `stop_task(uuid)` | Stop working on task |
| `annotate_task(uuid, annotation)` | Add annotation to task |

#### Tags Operations

| Method | Description |
|--------|-------------|
| `get_tags(include_virtual_tags=False)` | List tags, excluding virtual tags by default |
| `get_context_tags()` | List tags that start with `@` |

#### Context Operations

| Method | Description |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pytaskwarrior"
version = "2.0.2"
version = "2.0.3"
description = "Taskwarrior wrapper python module"
readme = "PYPI_README.md"
requires-python = ">=3.12"
Expand Down
54 changes: 54 additions & 0 deletions src/taskwarrior/adapters/taskwarrior_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,40 @@

logger = logging.getLogger(__name__)

TASKWARRIOR_VIRTUAL_TAGS: tuple[str, ...] = (
"BLOCKED",
"UNBLOCKED",
"BLOCKING",
"DUE",
"DUETODAY",
"TODAY",
"OVERDUE",
"WEEK",
"MONTH",
"QUARTER",
"YEAR",
"ACTIVE",
"SCHEDULED",
"PARENT",
"CHILD",
"UNTIL",
"WAITING",
"ANNOTATED",
"READY",
"YESTERDAY",
"TOMORROW",
"TAGGED",
"PENDING",
"COMPLETED",
"DELETED",
"UDA",
"ORPHAN",
"PRIORITY",
"PROJECT",
"LATEST",
)
TASKWARRIOR_VIRTUAL_TAG_SET = frozenset(TASKWARRIOR_VIRTUAL_TAGS)


class TaskWarriorAdapter:
"""Low-level adapter for TaskWarrior CLI commands.
Expand Down Expand Up @@ -515,3 +549,23 @@ def get_projects(self) -> list[str]:

projects = [line.strip() for line in result.stdout.split("\n") if line.strip()]
return projects

def get_tags(self, include_virtual_tags: bool = False) -> list[str]:
"""Get all tags defined in TaskWarrior.

Args:
include_virtual_tags: If ``True``, include TaskWarrior virtual tags
such as ``TODAY`` and ``READY``.

Returns:
List of tag names.
"""
result = self.run_task_command(["_tags"])

if result.returncode != 0:
raise TaskWarriorError(f"Failed to get tags: {result.stderr}")

tags = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if include_virtual_tags:
return list(dict.fromkeys(tags + list(TASKWARRIOR_VIRTUAL_TAGS)))
return [tag for tag in tags if tag not in TASKWARRIOR_VIRTUAL_TAG_SET]
19 changes: 19 additions & 0 deletions src/taskwarrior/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,22 @@ def get_projects(self) -> list[str]:
['dmc.fil.aretordre', 'dmc.fil.adérouler', 'perso', 'perso.orl', 'pro']
"""
return self.adapter.get_projects()

def get_tags(self, include_virtual_tags: bool = False) -> list[str]:
"""Get all tags defined in TaskWarrior.

Args:
include_virtual_tags: If ``True``, include TaskWarrior virtual tags
such as ``TODAY`` and ``READY``.

Returns:
List of tag names.
"""
return self.adapter.get_tags(include_virtual_tags=include_virtual_tags)

def get_context_tags(self) -> list[str]:
"""Return tags that follow the ``@`` context convention.

This is a convenience filter for user-defined tags such as ``@work``.
"""
return [tag for tag in self.get_tags() if tag.startswith("@")]
26 changes: 26 additions & 0 deletions tests/unit/test_adapter_mocked.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,32 @@ def test_json_decode_error_raises_taskwarrior_error(self, adapter: TaskWarriorAd
adapter.get_tasks()


# ---------------------------------------------------------------------------
# get_tags — virtual tag filtering
# ---------------------------------------------------------------------------


class TestGetTags:
def test_filters_virtual_tags_by_default(self, adapter: TaskWarriorAdapter) -> None:
stdout = "work\nurgent\nTODAY\n@home\nREADY\n"
with patch.object(adapter, "run_task_command", return_value=_completed(stdout=stdout)):
assert adapter.get_tags() == ["work", "urgent", "@home"]

def test_can_include_virtual_tags(self, adapter: TaskWarriorAdapter) -> None:
stdout = "work\nurgent\nTODAY\n@home\nREADY\n"
with patch.object(adapter, "run_task_command", return_value=_completed(stdout=stdout)):
tags = adapter.get_tags(include_virtual_tags=True)

assert {"work", "urgent", "@home", "TODAY", "READY"}.issubset(set(tags))

def test_returncode_nonzero_raises(self, adapter: TaskWarriorAdapter) -> None:
with patch.object(
adapter, "run_task_command", return_value=_completed(returncode=1, stderr="fail")
):
with pytest.raises(TaskWarriorError, match="Failed to get tags"):
adapter.get_tags()


# ---------------------------------------------------------------------------
# get_recurring_instances — error paths
# ---------------------------------------------------------------------------
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/test_main_methods_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,22 @@ def get_tasks(self, filter="", include_completed=False, include_deleted=False):
# When no filter provided, should use just the context read_filter
tw.get_tasks()
assert adapter.last_filter == "project:work"


def test_get_tags_and_context_tags_delegate_and_filter():
tw = TaskWarrior.__new__(TaskWarrior)

class DummyAdapter:
def __init__(self):
self.calls = []

def get_tags(self, include_virtual_tags=False):
self.calls.append(include_virtual_tags)
return ["work", "TODAY", "@home", "READY", "urgent"]

adapter = DummyAdapter()
tw.adapter = adapter

assert tw.get_tags(include_virtual_tags=True) == ["work", "TODAY", "@home", "READY", "urgent"]
assert tw.get_context_tags() == ["@home"]
assert adapter.calls == [True, False]
14 changes: 14 additions & 0 deletions tests/unit/test_taskwarrior_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,17 @@ def test_get_projects(self, taskwarrior_config: str):
for project in projects:
assert isinstance(project, str)
assert project.strip() != ""

def test_get_tags(self, taskwarrior_config: str):
"""Test getting tags from TaskWarrior."""
tw = TaskWarrior(taskrc_file=taskwarrior_config)

tags = tw.get_tags()
assert isinstance(tags, list)
assert "TODAY" not in tags
assert "READY" not in tags

tags_with_virtual = tw.get_tags(include_virtual_tags=True)
assert "TODAY" in tags_with_virtual
assert "READY" in tags_with_virtual
assert tw.get_context_tags() == []
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading