diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 33f81a9..1905002 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,7 +41,7 @@ repos: rev: v3.3.1 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py39-plus, --keep-runtime-typing] - repo: https://github.com/PyCQA/isort rev: 5.11.4 hooks: diff --git a/pyproject.toml b/pyproject.toml index 7c1154f..67f0ce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,7 @@ max-line-length-suggestions = 88 runtime-typing = false [tool.pytest.ini_options] -addopts = "-v -Wdefault --cov=aiortm --cov-report=term-missing:skip-covered" +addopts = "-Wdefault --cov=aiortm --cov-report=term-missing:skip-covered" asyncio_mode = "auto" pythonpath = ["src"] diff --git a/src/aiortm/client.py b/src/aiortm/client.py index 92424d7..88b0888 100644 --- a/src/aiortm/client.py +++ b/src/aiortm/client.py @@ -97,6 +97,8 @@ async def call_api_auth(self, api_method: str, **params: Any) -> dict[str, Any]: async def call_api(self, api_method: str, **params: Any) -> dict[str, Any]: """Call an api method.""" + # Remove empty values. + params = {key: value for key, value in params.items() if value is not None} all_params = {"method": api_method} | params | {"format": "json"} all_params |= {"api_sig": self._sign_request(all_params)} response = await self.request(REST_URL, params=all_params) @@ -111,7 +113,10 @@ async def call_api(self, api_method: str, **params: Any) -> dict[str, Any]: response_text = await response.text() if "rtm.auth" not in api_method: - _LOGGER.debug("Response text: %s", response_text) + logged_response_text = response_text + if self.api_key in response_text: + logged_response_text = response_text.replace(self.api_key, "API_KEY") + _LOGGER.debug("Response text: %s", logged_response_text) # API doesn't return a JSON encoded response. # It's text/javascript mimetype but with a JSON string in the text. diff --git a/src/aiortm/model/__init__.py b/src/aiortm/model/__init__.py index e4e2144..0e4268a 100644 --- a/src/aiortm/model/__init__.py +++ b/src/aiortm/model/__init__.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from .contacts import Contacts +from .tasks import Tasks from .timelines import Timelines if TYPE_CHECKING: @@ -15,9 +16,11 @@ class RTM: api: "Auth" contacts: "Contacts" = field(init=False) + tasks: "Tasks" = field(init=False) timelines: "Timelines" = field(init=False) def __post_init__(self) -> None: """Set up the instance.""" self.contacts = Contacts(self.api) + self.tasks = Tasks(self.api) self.timelines = Timelines(self.api) diff --git a/src/aiortm/model/tasks.py b/src/aiortm/model/tasks.py new file mode 100644 index 0000000..1e3cb5a --- /dev/null +++ b/src/aiortm/model/tasks.py @@ -0,0 +1,153 @@ +"""Provide a model for tasks.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING, Any, Literal, Optional + +from pydantic import BaseModel as PydanticBaseModel, Field, validator + +from .response import BaseResponse, TransactionResponse + +if TYPE_CHECKING: + from ..client import Auth + +# pylint: disable=consider-alternative-union-syntax + + +class BaseModel(PydanticBaseModel): + """Represent a base model that turns empty string to None.""" + + @validator("*", pre=True) + def empty_str_to_none(cls, value: Any) -> Any: # pylint: disable=no-self-argument + """Turn empty string to None.""" + if value == "": + return None + return value + + +class TaskResponse(BaseModel): + """Represent a response for a task.""" + + id: int + due: Optional[datetime] + has_due_time: bool + added: datetime + completed: Optional[datetime] + deleted: Optional[datetime] + priority: Literal["N", "1", "2", "3"] + postponed: bool + estimate: Optional[str] + + +class TaskSeriesResponse(BaseModel): + """Represent a response for a task series.""" + + id: int + created: datetime + modified: datetime + name: str + source: str + location_id: Optional[str] + url: Optional[str] + tags: list[str] + participants: list[str] + notes: list[str] + task: list[TaskResponse] + + +class TaskListResponse(BaseModel): + """Represent a response for a task list.""" + + id: int + taskseries: list[TaskSeriesResponse] + current: Optional[datetime] + + +class RootTaskResponse(BaseModel): + """Represent a response for the root tasks object.""" + + rev: str + task_list: list[TaskListResponse] = Field(..., alias="list") + + @validator("task_list", pre=True) + def ensure_taskseries( # pylint: disable=no-self-argument + cls, value: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Ensure that taskseries exist.""" + for item in value: + if "taskseries" not in item: + item["taskseries"] = [] + return value + + +class TaskModifiedResponse(BaseResponse): + """Represent a response for a modified task.""" + + transaction: TransactionResponse + task_list: TaskListResponse = Field(..., alias="list") + + +class TasksResponse(BaseResponse): + """Represent a response for a list of tasks.""" + + tasks: RootTaskResponse + + +@dataclass +class Tasks: + """Represent the tasks model.""" + + api: Auth + + async def add( + self, + timeline: int, + name: str, + list_id: int | None = None, + parse: bool | None = None, + ) -> TaskModifiedResponse: + """Add a task.""" + result = await self.api.call_api_auth( + "rtm.tasks.add", timeline=timeline, name=name, list_id=list_id, parse=parse + ) + return TaskModifiedResponse(**result) + + async def complete( + self, timeline: int, list_id: int, taskseries_id: int, task_id: int + ) -> TaskModifiedResponse: + """Complete a task.""" + result = await self.api.call_api_auth( + "rtm.tasks.complete", + timeline=timeline, + list_id=list_id, + taskseries_id=taskseries_id, + task_id=task_id, + ) + return TaskModifiedResponse(**result) + + async def delete( + self, timeline: int, list_id: int, taskseries_id: int, task_id: int + ) -> TaskModifiedResponse: + """Delete a task.""" + result = await self.api.call_api_auth( + "rtm.tasks.delete", + timeline=timeline, + list_id=list_id, + taskseries_id=taskseries_id, + task_id=task_id, + ) + return TaskModifiedResponse(**result) + + async def get_list( + self, list_id: int | None = None, last_sync: datetime | None = None + ) -> TasksResponse: + """Get a list of tasks.""" + last_sync_string: str | None = None + if last_sync is not None: + last_sync_string = last_sync.isoformat() + + result = await self.api.call_api_auth( + "rtm.tasks.getList", list_id=list_id, last_sync=last_sync_string + ) + return TasksResponse(**result) diff --git a/tests/fixtures/tasks/add.json b/tests/fixtures/tasks/add.json new file mode 100644 index 0000000..d9a166e --- /dev/null +++ b/tests/fixtures/tasks/add.json @@ -0,0 +1,36 @@ +{ + "rsp": { + "stat": "ok", + "transaction": { "id": "12451745176", "undoable": "0" }, + "list": { + "id": "48730705", + "taskseries": [ + { + "id": "493137362", + "created": "2023-01-02T01:55:25Z", + "modified": "2023-01-02T01:55:25Z", + "name": "Test task", + "source": "api:test-api-key", + "url": "", + "location_id": "", + "tags": [], + "participants": [], + "notes": [], + "task": [ + { + "id": "924832826", + "due": "", + "has_due_time": "0", + "added": "2023-01-02T01:55:25Z", + "completed": "", + "deleted": "", + "priority": "N", + "postponed": "0", + "estimate": "" + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/tasks/complete.json b/tests/fixtures/tasks/complete.json new file mode 100644 index 0000000..75be80a --- /dev/null +++ b/tests/fixtures/tasks/complete.json @@ -0,0 +1,36 @@ +{ + "rsp": { + "stat": "ok", + "transaction": { "id": "12475899884", "undoable": "1" }, + "list": { + "id": "48730705", + "taskseries": [ + { + "id": "493137362", + "created": "2023-01-02T01:55:25Z", + "modified": "2023-01-05T00:04:52Z", + "name": "Test task", + "source": "api:test-api-key", + "url": "", + "location_id": "", + "tags": [], + "participants": [], + "notes": [], + "task": [ + { + "id": "924832826", + "due": "", + "has_due_time": "0", + "added": "2023-01-02T01:55:25Z", + "completed": "2023-01-05T00:04:52Z", + "deleted": "", + "priority": "N", + "postponed": "0", + "estimate": "" + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/tasks/delete.json b/tests/fixtures/tasks/delete.json new file mode 100644 index 0000000..00efd1f --- /dev/null +++ b/tests/fixtures/tasks/delete.json @@ -0,0 +1,36 @@ +{ + "rsp": { + "stat": "ok", + "transaction": { "id": "12476056249", "undoable": "1" }, + "list": { + "id": "48730705", + "taskseries": [ + { + "id": "493137362", + "created": "2023-01-02T01:55:25Z", + "modified": "2023-01-05T00:34:45Z", + "name": "Test task", + "source": "api:test-api-key", + "url": "", + "location_id": "", + "tags": [], + "participants": [], + "notes": [], + "task": [ + { + "id": "924832826", + "due": "", + "has_due_time": "0", + "added": "2023-01-02T01:55:25Z", + "completed": "2023-01-05T00:04:52Z", + "deleted": "2023-01-05T00:34:45Z", + "priority": "N", + "postponed": "0", + "estimate": "" + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/tasks/get_list.json b/tests/fixtures/tasks/get_list.json index 00eb7a5..62db371 100644 --- a/tests/fixtures/tasks/get_list.json +++ b/tests/fixtures/tasks/get_list.json @@ -7,6 +7,31 @@ { "id": "48730705", "taskseries": [ + { + "id": "493137362", + "created": "2023-01-02T01:55:25Z", + "modified": "2023-01-02T01:55:25Z", + "name": "Test task", + "source": "api:test-api-key", + "url": "", + "location_id": "", + "tags": [], + "participants": [], + "notes": [], + "task": [ + { + "id": "924832826", + "due": "", + "has_due_time": "0", + "added": "2023-01-02T01:55:25Z", + "completed": "", + "deleted": "", + "priority": "N", + "postponed": "0", + "estimate": "" + } + ] + }, { "id": "475830808", "created": "2022-05-18T14:27:08Z", diff --git a/tests/fixtures/tasks/get_list_last_sync.json b/tests/fixtures/tasks/get_list_last_sync.json new file mode 100644 index 0000000..9bb68c8 --- /dev/null +++ b/tests/fixtures/tasks/get_list_last_sync.json @@ -0,0 +1,69 @@ +{ + "rsp": { + "stat": "ok", + "tasks": { + "rev": "828de7te6o7skrgkhnd0jxkwi9e8orv", + "list": [ + { + "id": "48730705", + "current": "2022-05-18T14:27:08Z", + "taskseries": [ + { + "id": "493137362", + "created": "2023-01-02T01:55:25Z", + "modified": "2023-01-02T01:55:25Z", + "name": "Test task", + "source": "api:test-api-key", + "url": "", + "location_id": "", + "tags": [], + "participants": [], + "notes": [], + "task": [ + { + "id": "924832826", + "due": "", + "has_due_time": "0", + "added": "2023-01-02T01:55:25Z", + "completed": "", + "deleted": "", + "priority": "N", + "postponed": "0", + "estimate": "" + } + ] + }, + { + "id": "475830808", + "created": "2022-05-18T14:27:08Z", + "modified": "2022-05-18T14:27:55Z", + "name": "Register for RTM", + "source": "js", + "url": "", + "location_id": "", + "tags": [], + "participants": [], + "notes": [], + "task": [ + { + "id": "878061281", + "due": "", + "has_due_time": "0", + "added": "2022-05-18T14:27:08Z", + "completed": "2022-05-18T14:27:55Z", + "deleted": "", + "priority": "N", + "postponed": "0", + "estimate": "" + } + ] + } + ] + }, + { "id": "48730706", "current": "2022-05-18T14:27:08Z" }, + { "id": "48730707", "current": "2022-05-18T14:27:08Z" }, + { "id": "48730708", "current": "2022-05-18T14:27:08Z" } + ] + } + } +} diff --git a/tests/model/test_tasks.py b/tests/model/test_tasks.py new file mode 100644 index 0000000..89766a2 --- /dev/null +++ b/tests/model/test_tasks.py @@ -0,0 +1,213 @@ +"""Test the tasks model.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Any + +from aioresponses import aioresponses +import pytest + +from aiortm.client import AioRTMClient + +from ..util import load_fixture + + +@pytest.fixture(name="tasks_add", scope="session") +def tasks_add_fixture() -> str: + """Return a response for rtm.tasks.getList.""" + return load_fixture("tasks/add.json") + + +@pytest.fixture(name="response") +def response_fixture(request: pytest.FixtureRequest) -> str: + """Return a response for the rtm api.""" + return load_fixture(request.param) + + +async def test_tasks_add( + client: AioRTMClient, + mock_response: aioresponses, + tasks_add: str, + timelines_create: str, + generate_url: Callable[..., str], +) -> None: + """Test tasks add.""" + mock_response.get( + generate_url( + api_key="test-api-key", + auth_token="test-token", + method="rtm.timelines.create", + ), + body=timelines_create, + ) + mock_response.get( + generate_url( + api_key="test-api-key", + auth_token="test-token", + method="rtm.tasks.add", + timeline=1234567890, + name="Test task", + ), + body=tasks_add, + ) + + timeline_response = await client.rtm.timelines.create() + timeline = timeline_response.timeline + result = await client.rtm.tasks.add(timeline=timeline, name="Test task") + + assert result.stat == "ok" + assert result.transaction.id == 12451745176 + assert result.transaction.undoable == 0 + assert result.task_list.id == 48730705 + assert result.task_list.taskseries[0].id == 493137362 + assert result.task_list.taskseries[0].created == datetime.fromisoformat( + "2023-01-02T01:55:25+00:00" + ) + assert result.task_list.taskseries[0].modified == datetime.fromisoformat( + "2023-01-02T01:55:25+00:00" + ) + assert result.task_list.taskseries[0].name == "Test task" + assert result.task_list.taskseries[0].source == "api:test-api-key" + assert result.task_list.taskseries[0].task[0].id == 924832826 + assert result.task_list.taskseries[0].task[0].added == datetime.fromisoformat( + "2023-01-02T01:55:25+00:00" + ) + + +@pytest.mark.parametrize( + "method, transaction, modified, deleted, response", + [ + ( + "complete", + 12475899884, + datetime.fromisoformat("2023-01-05T00:04:52+00:00"), + None, + "tasks/complete.json", + ), + ( + "delete", + 12476056249, + datetime.fromisoformat("2023-01-05T00:34:45+00:00"), + datetime.fromisoformat("2023-01-05T00:34:45+00:00"), + "tasks/delete.json", + ), + ], + indirect=["response"], +) +async def test_tasks_complete_delete( + client: AioRTMClient, + mock_response: aioresponses, + timelines_create: str, + generate_url: Callable[..., str], + method: str, + transaction: int, + modified: datetime, + deleted: datetime | None, + response: str, +) -> None: + """Test tasks complete and delete.""" + # pylint: disable=too-many-arguments + mock_response.get( + generate_url( + api_key="test-api-key", + auth_token="test-token", + method="rtm.timelines.create", + ), + body=timelines_create, + ) + mock_response.get( + generate_url( + api_key="test-api-key", + auth_token="test-token", + method=f"rtm.tasks.{method}", + timeline=1234567890, + list_id=48730705, + taskseries_id=493137362, + task_id=924832826, + ), + body=response, + ) + + timeline_response = await client.rtm.timelines.create() + timeline = timeline_response.timeline + method_object = getattr(client.rtm.tasks, method) + result = await method_object( + timeline=timeline, list_id=48730705, taskseries_id=493137362, task_id=924832826 + ) + + assert result.stat == "ok" + assert result.transaction.id == transaction + assert result.transaction.undoable == 1 + assert result.task_list.id == 48730705 + assert result.task_list.taskseries[0].id == 493137362 + assert result.task_list.taskseries[0].created == datetime.fromisoformat( + "2023-01-02T01:55:25+00:00" + ) + assert result.task_list.taskseries[0].modified == modified + assert result.task_list.taskseries[0].name == "Test task" + assert result.task_list.taskseries[0].source == "api:test-api-key" + assert result.task_list.taskseries[0].task[0].id == 924832826 + assert result.task_list.taskseries[0].task[0].completed == datetime.fromisoformat( + "2023-01-05T00:04:52+00:00" + ) + assert result.task_list.taskseries[0].task[0].deleted == deleted + + +@pytest.mark.parametrize( + "response, response_params, method_params, current", + [ + ( + "tasks/get_list.json", + {}, + {}, + None, + ), + ( + "tasks/get_list_last_sync.json", + {"last_sync": "2022-05-18T14:27:08+00:00"}, + {"last_sync": datetime.fromisoformat("2022-05-18T14:27:08+00:00")}, + datetime.fromisoformat("2022-05-18T14:27:08+00:00"), + ), + ], + indirect=["response"], +) +async def test_tasks_get_list( + client: AioRTMClient, + mock_response: aioresponses, + generate_url: Callable[..., str], + response: str, + response_params: dict[str, Any], + method_params: dict[str, Any], + current: datetime | None, +) -> None: + """Test tasks get list.""" + mock_response.get( + generate_url( + api_key="test-api-key", + auth_token="test-token", + method="rtm.tasks.getList", + **response_params, + ), + body=response, + ) + + result = await client.rtm.tasks.get_list(**method_params) + + assert result.stat == "ok" + assert result.tasks.rev + assert result.tasks.task_list[0].id == 48730705 + assert result.tasks.task_list[0].current == current + assert result.tasks.task_list[0].taskseries[0].id == 493137362 + assert result.tasks.task_list[0].taskseries[0].created == datetime.fromisoformat( + "2023-01-02T01:55:25+00:00" + ) + assert result.tasks.task_list[0].taskseries[0].modified == datetime.fromisoformat( + "2023-01-02T01:55:25+00:00" + ) + assert result.tasks.task_list[0].taskseries[0].name == "Test task" + assert result.tasks.task_list[0].taskseries[0].source == "api:test-api-key" + assert result.tasks.task_list[0].taskseries[0].task[0].id == 924832826 + assert result.tasks.task_list[0].taskseries[0].task[ + 0 + ].added == datetime.fromisoformat("2023-01-02T01:55:25+00:00")