diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb62d1..f095581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PYPI_README.md b/PYPI_README.md index cab6776..e7b2850 100644 --- a/PYPI_README.md +++ b/PYPI_README.md @@ -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`, …) @@ -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/) diff --git a/README.md b/README.md index 10691b7..3ba5aec 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/pyproject.toml b/pyproject.toml index 2f14d69..9581389 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/taskwarrior/adapters/taskwarrior_adapter.py b/src/taskwarrior/adapters/taskwarrior_adapter.py index 150f006..cec13ab 100644 --- a/src/taskwarrior/adapters/taskwarrior_adapter.py +++ b/src/taskwarrior/adapters/taskwarrior_adapter.py @@ -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. @@ -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] diff --git a/src/taskwarrior/main.py b/src/taskwarrior/main.py index e62b5f7..90830f3 100644 --- a/src/taskwarrior/main.py +++ b/src/taskwarrior/main.py @@ -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("@")] diff --git a/tests/unit/test_adapter_mocked.py b/tests/unit/test_adapter_mocked.py index 38090b0..50e256c 100644 --- a/tests/unit/test_adapter_mocked.py +++ b/tests/unit/test_adapter_mocked.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/unit/test_main_methods_extended.py b/tests/unit/test_main_methods_extended.py index 1a3b130..2b17e2e 100644 --- a/tests/unit/test_main_methods_extended.py +++ b/tests/unit/test_main_methods_extended.py @@ -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] diff --git a/tests/unit/test_taskwarrior_init.py b/tests/unit/test_taskwarrior_init.py index 4e1147b..ef36f51 100644 --- a/tests/unit/test_taskwarrior_init.py +++ b/tests/unit/test_taskwarrior_init.py @@ -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() == [] diff --git a/uv.lock b/uv.lock index 51b6151..9bd3176 100644 --- a/uv.lock +++ b/uv.lock @@ -669,7 +669,7 @@ wheels = [ [[package]] name = "pytaskwarrior" -version = "2.0.1" +version = "2.0.3" source = { editable = "." } dependencies = [ { name = "pydantic" },