From 4dc3665af8774b8f04427898849cda20d5a6c198 Mon Sep 17 00:00:00 2001 From: Faith Villarreal Date: Wed, 29 Oct 2025 01:07:17 -0400 Subject: [PATCH 01/33] Creating unimplemented API shell for Google Tasks impl. --- .gitignore | 3 + pyproject.toml | 11 +- src/task_client_api/README.md | 162 ++++++++++++++++++ src/task_client_api/pyproject.toml | 26 +++ .../src/mail_client_api/__init__.py | 16 ++ .../src/mail_client_api/client.py | 57 ++++++ .../src/mail_client_api/task.py | 103 +++++++++++ .../src/mail_client_api/tasklist.py | 54 ++++++ uv.lock | 6 + 9 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 src/task_client_api/README.md create mode 100644 src/task_client_api/pyproject.toml create mode 100644 src/task_client_api/src/mail_client_api/__init__.py create mode 100644 src/task_client_api/src/mail_client_api/client.py create mode 100644 src/task_client_api/src/mail_client_api/task.py create mode 100644 src/task_client_api/src/mail_client_api/tasklist.py diff --git a/.gitignore b/.gitignore index c269815d..0c4809dc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ build/ .vscode/ .idea/ +# macOS system files +.DS_Store + # MkDocs build artifacts site/ diff --git a/pyproject.toml b/pyproject.toml index 5d58d3fc..bb331490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ members = [ "src/mail_client_service", "src/mail_client_adapter", "src/mail_client_service_client", + "src/task_client_api", ] [tool.ruff] @@ -49,7 +50,14 @@ per-file-ignores = { "**/test_*.py" = ["TRY300", "BLE001", "ANN401", "SLF001", " strict = true explicit_package_bases = true # Required for src layout -mypy_path = ["src/mail_client_api/src", "src/gmail_client_impl/src", "src/mail_client_service/src", "src/mail_client_adapter/src", "src/mail_client_service_client/src"] +mypy_path = [ + "src/mail_client_api/src", + "src/gmail_client_impl/src", + "src/mail_client_service/src", + "src/mail_client_adapter/src", + "src/mail_client_service_client/src", + "src/task_client_api", +] ignore_missing_imports = false warn_unused_ignores = false @@ -64,6 +72,7 @@ module = [ "mail_client_service.*", "mail_client_adapter.*", "mail_client_service_client.*", + "task_client_api.*", ] ignore_missing_imports = true diff --git a/src/task_client_api/README.md b/src/task_client_api/README.md new file mode 100644 index 00000000..bb5c5f59 --- /dev/null +++ b/src/task_client_api/README.md @@ -0,0 +1,162 @@ +# Task Client API + +## Overview +`task_client_api` defines the `Client` abstract base class that every task client must implement. The package contains the abstraction, factory hooks, and no concrete logic. + +## Purpose +- Document the operations available to consumers. +- Provide a single factory (`get_client`) that implementations can override. +- Keep task-type dependencies explicit through the `task_client_api.task` and `task_client_api.tasklist` modules. + +## Architecture + +### Component Design +The package exposes one abstract base class focused on task operations—create, delete, list, and manage tasks and tasklists. It depends only on the `Task` and `TaskList` abstractions. + +### API Integration +```python +from task_client_api import Client, get_client +from task_client_api.task import Task +from task_client_api.tasklist import TaskList + +client: Client = get_client() +tasklists = client.list_tasklists() +for tasklist in tasklists: + tasks = client.list_tasks(tasklist) +``` + +### Dependency Injection +Implementation packages (for example `gtask_client_impl`) replace the factory at import time: +```python +import gtask_client_impl # rebinds task_client_api.get_client + +from task_client_api import get_client +client = get_client(interactive=False) +``` + +## API Reference + +### Client Abstract Base Class +```python +class Client(ABC): + ... +``` + +#### TaskList Operations +- `list_tasklists() -> list[TaskList]`: Return all available task lists. +- `insert_tasklist(tasklist: TaskList) -> TaskList`: Create a new task list. +- `delete_tasklist(tasklist: TaskList) -> bool`: Remove a task list. + +#### Task Operations +- `list_tasks(tasklist: TaskList) -> list[Task]`: Return all tasks in a task list. +- `get_task(task_id: str) -> Task`: Return a single task by ID. +- `insert_task(tasklist: TaskList, task: Task, parent: str = None, previous: str = None) -> Task`: Create a new task. +- `delete_task(task_id: str) -> bool`: Remove a task. + +### Factory Functions +- `get_client(*, interactive: bool = False) -> Client`: Returns the bound implementation or raises `NotImplementedError` if none registered. +- `get_task(task_id: str, raw_data: str) -> Task`: Returns a Task instance from raw data. +- `get_tasklist(task_list_id: str, raw_data: str) -> TaskList`: Returns a TaskList instance from raw data. + +### Task Abstraction +```python +class Task(ABC): + @property + def id(self) -> str: ... + @property + def title(self) -> str: ... + @property + def notes(self) -> str | None: ... + @property + def status(self) -> str: ... + @property + def due(self) -> str | None: ... + @property + def completed(self) -> str | None: ... + @property + def deleted(self) -> bool: ... + @property + def hidden(self) -> bool: ... + @property + def parent(self) -> str | None: ... + @property + def position(self) -> str | None: ... + @property + def links(self) -> list[dict[str, str]]: ... + @property + def web_view_link(self) -> str | None: ... + @property + def assignment_info(self) -> dict[str, Any] | None: ... +``` + +### TaskList Abstraction +```python +class TaskList(ABC): + @property + def id(self) -> str: ... + @property + def title(self) -> str: ... + @property + def etag(self) -> str: ... + @property + def updated(self) -> str: ... + @property + def self_link(self) -> str: ... +``` + +## Usage Examples + +### Basic Operations +```python +from task_client_api import get_client + +client = get_client(interactive=False) +tasklists = client.list_tasklists() +for tasklist in tasklists: + print(f"TaskList: {tasklist.title}") + tasks = client.list_tasks(tasklist) + for task in tasks: + print(f" - {task.title} ({task.status})") +``` + +### Task Management +```python +from task_client_api import get_client +from task_client_api.task import Task +from task_client_api.tasklist import TaskList + +client = get_client() +tasklist = client.list_tasklists()[0] # Get first tasklist + +# Create a new task +new_task = client.insert_task(tasklist, task_data) +client.delete_task(new_task.id) +``` + +### Working with Subtasks +```python +from task_client_api import get_client + +client = get_client() +tasklist = client.list_tasklists()[0] + +# Create parent task +parent_task = client.insert_task(tasklist, parent_task_data) + +# Create subtask +subtask = client.insert_task(tasklist, subtask_data, parent=parent_task.id) +``` + +## Implementation Checklist +1. Implement every method in the abstract base class. +2. Return objects compatible with `task_client_api.task.Task` and `task_client_api.tasklist.TaskList`. +3. Publish a factory (`get_client_impl`) and assign it to `task_client_api.get_client`. +4. Honour the `interactive` flag (prompting only when `True`). +5. Handle task hierarchy (parent-child relationships). +6. Support task positioning and ordering within tasklists. + +## Testing +```bash +uv run pytest src/task_client_api/tests/ -q +uv run pytest src/task_client_api/tests/ --cov=src/task_client_api --cov-report=term-missing +``` diff --git a/src/task_client_api/pyproject.toml b/src/task_client_api/pyproject.toml new file mode 100644 index 00000000..0d22a134 --- /dev/null +++ b/src/task_client_api/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "task_client_api" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/task_client_api"] + +[tool.hatch.build.targets.wheel.force-include] +"src/task_client_api/py.typed" = "task_client_api/py.typed" + +[tool.ruff] +line-length = 100 # Default formatting width +target-version = "py311" # Adjust based on actual Python version +extend = "../../pyproject.toml" + +[tool.ruff.lint] +ignore = [] + diff --git a/src/task_client_api/src/mail_client_api/__init__.py b/src/task_client_api/src/mail_client_api/__init__.py new file mode 100644 index 00000000..9b648bb3 --- /dev/null +++ b/src/task_client_api/src/mail_client_api/__init__.py @@ -0,0 +1,16 @@ +"""Public export surface for ``mail_client_api``.""" + +from task_client_api.client import Client, get_client +from task_client_api.task import Task +from task_client_api.tasklist import TaskList + +__all__ = [ + "Client", + "Task", + "TaskList", + "get_client", + "get_task", + "get_tasklist", + "task", + "tasklist", +] diff --git a/src/task_client_api/src/mail_client_api/client.py b/src/task_client_api/src/mail_client_api/client.py new file mode 100644 index 00000000..bfff5791 --- /dev/null +++ b/src/task_client_api/src/mail_client_api/client.py @@ -0,0 +1,57 @@ +"""Core mail client contract definitions and factory placeholder.""" + +from abc import ABC, abstractmethod + +from task_client_api.task import Task +from task_client_api.tasklist import TaskList + +__all__ = ["Client", "get_client"] + + +class Client(ABC): + """Abstract base class representing a task client for task operations.""" + + """ TASKLIST OPERATIONS """ + + @abstractmethod + def delete_tasklist(self, tasklist: TaskList) -> bool: + """Delete a tasklist by ...""" + raise NotImplementedError + + @abstractmethod + def insert_tasklist(self, tasklist: TaskList) -> TaskList: + """Insert a tasklist by ...""" + raise NotImplementedError + + @abstractmethod + def list_tasklists(self) -> list[TaskList]: + """List all tasklists.""" + raise NotImplementedError + + """ TASK OPERATIONS """ + + def list_tasks(self, tasklist: TaskList) -> list[Task]: + """List all tasks in a tasklist.""" + raise NotImplementedError + + @abstractmethod + def insert_task( + self, tasklist: TaskList, task: Task, parent: Task.id = None, previous: Task.id = None + ) -> Task: + """Insert a task into a tasklist.""" + raise NotImplementedError + + @abstractmethod + def delete_task(self, task_id: Task.id) -> bool: + """Delete a task by ...""" + raise NotImplementedError + + @abstractmethod + def get_task(self, task_id: Task.id) -> Task: + """Get a task by its ID.""" + raise NotImplementedError + + +def get_client(*, interactive: bool = False) -> Client: + """Return an instance of a Mail Client.""" + raise NotImplementedError diff --git a/src/task_client_api/src/mail_client_api/task.py b/src/task_client_api/src/mail_client_api/task.py new file mode 100644 index 00000000..466121f0 --- /dev/null +++ b/src/task_client_api/src/mail_client_api/task.py @@ -0,0 +1,103 @@ +"""Tasks contract - Core task representation.""" + +from abc import ABC, abstractmethod +from typing import Any + + +class Task(ABC): + """Abstract base class representing a Google Task.""" + + @property + @abstractmethod + def id(self) -> str: + """Return the unique identifier of the task.""" + raise NotImplementedError + + @property + @abstractmethod + def title(self) -> str: + """Return the title of the task. Maximum length: 1024 characters.""" + raise NotImplementedError + + @property + @abstractmethod + def notes(self) -> str | None: + """Return the notes describing the task. Maximum length: 8192 characters.""" + raise NotImplementedError + + @property + @abstractmethod + def status(self) -> str: + """Return the status of the task. Either 'needsAction' or 'completed'.""" + raise NotImplementedError + + @property + @abstractmethod + def due(self) -> str | None: + """Return the due date of the task (RFC 3339 timestamp).""" + raise NotImplementedError + + @property + @abstractmethod + def completed(self) -> str | None: + """Return the completion date of the task (RFC 3339 timestamp).""" + raise NotImplementedError + + @property + @abstractmethod + def deleted(self) -> bool: + """Return whether the task has been deleted.""" + raise NotImplementedError + + @property + @abstractmethod + def hidden(self) -> bool: + """Return whether the task is hidden.""" + raise NotImplementedError + + @property + @abstractmethod + def parent(self) -> str | None: + """Return the parent task identifier if this is a subtask.""" + raise NotImplementedError + + @property + @abstractmethod + def position(self) -> str | None: + """Return the position of the task among its siblings.""" + raise NotImplementedError + + @property + @abstractmethod + def links(self) -> list[dict[str, str]]: + """Return collection of links associated with the task.""" + raise NotImplementedError + + @property + @abstractmethod + def web_view_link(self) -> str | None: + """Return an absolute link to the task in the Google Tasks Web UI.""" + raise NotImplementedError + + @property + @abstractmethod + def assignment_info(self) -> dict[str, Any] | None: + """Return context information for assigned tasks.""" + raise NotImplementedError + + +def get_task(task_id: str, raw_data: str) -> Task: + """Return an instance of a Task. + + Args: + task_id (str): The unique identifier for the task. + raw_data (str): The raw data used to construct the task. + + Returns: + Task: An instance conforming to the Task contract. + + Raises: + NotImplementedError: If the function is not overridden by an implementation. + + """ + raise NotImplementedError diff --git a/src/task_client_api/src/mail_client_api/tasklist.py b/src/task_client_api/src/mail_client_api/tasklist.py new file mode 100644 index 00000000..33c7d617 --- /dev/null +++ b/src/task_client_api/src/mail_client_api/tasklist.py @@ -0,0 +1,54 @@ +"""TaskList contract - Core task list representation.""" + +from abc import ABC, abstractmethod + + +class TaskList(ABC): + """Abstract base class representing a Google Task List.""" + + @property + @abstractmethod + def id(self) -> str: + """Return the unique identifier of the task list.""" + raise NotImplementedError + + @property + @abstractmethod + def title(self) -> str: + """Return the title of the task list. Maximum length: 1024 characters.""" + raise NotImplementedError + + @property + @abstractmethod + def etag(self) -> str: + """Return the ETag of the resource.""" + raise NotImplementedError + + @property + @abstractmethod + def updated(self) -> str: + """Return the last modification time of the task list (RFC 3339 timestamp).""" + raise NotImplementedError + + @property + @abstractmethod + def self_link(self) -> str: + """Return the URL pointing to this task list.""" + raise NotImplementedError + + +def get_task_list(task_list_id: str, raw_data: str) -> TaskList: + """Return an instance of a TaskList. + + Args: + task_list_id (str): The unique identifier for the task list. + raw_data (str): The raw data used to construct the task list. + + Returns: + TaskList: An instance conforming to the TaskList contract. + + Raises: + NotImplementedError: If the function is not overridden by an implementation. + + """ + raise NotImplementedError diff --git a/uv.lock b/uv.lock index a6b5a635..6bf434df 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,7 @@ members = [ "mail-client-service", "mail-client-service-client", "ta-assignment", + "task-client-api", ] [[package]] @@ -1364,6 +1365,11 @@ dev = [ { name = "ruff", specifier = ">=0.13.2" }, ] +[[package]] +name = "task-client-api" +version = "0.1.0" +source = { editable = "src/task_client_api" } + [[package]] name = "tomli" version = "2.2.1" From 2f83b6e50a32c8e64c09834acf5b9590df54ef05 Mon Sep 17 00:00:00 2001 From: Faith Villarreal Date: Wed, 29 Oct 2025 13:35:59 -0400 Subject: [PATCH 02/33] feat: adding implementation for GTasks DI --- pyproject.toml | 5 +- src/gtask_client_impl/README.md | 0 src/gtask_client_impl/pyproject.toml | 58 +++ .../src/gtask_client_impl/__init__.py | 44 ++ .../src/gtask_client_impl/gtask_impl.py | 453 ++++++++++++++++++ .../src/gtask_client_impl/py.typed | 0 .../src/gtask_client_impl/task_impl.py | 71 +++ .../src/gtask_client_impl/tasklist_impl.py | 56 +++ .../src/mail_client_api/__init__.py | 16 - .../src/task_client_api/__init__.py | 17 + .../client.py | 8 +- .../src/task_client_api/py.typed | 0 .../task.py | 31 -- .../tasklist.py | 2 +- test_gtask.py | 134 ++++++ uv.lock | 31 ++ 16 files changed, 872 insertions(+), 54 deletions(-) create mode 100644 src/gtask_client_impl/README.md create mode 100644 src/gtask_client_impl/pyproject.toml create mode 100644 src/gtask_client_impl/src/gtask_client_impl/__init__.py create mode 100644 src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py create mode 100644 src/gtask_client_impl/src/gtask_client_impl/py.typed create mode 100644 src/gtask_client_impl/src/gtask_client_impl/task_impl.py create mode 100644 src/gtask_client_impl/src/gtask_client_impl/tasklist_impl.py delete mode 100644 src/task_client_api/src/mail_client_api/__init__.py create mode 100644 src/task_client_api/src/task_client_api/__init__.py rename src/task_client_api/src/{mail_client_api => task_client_api}/client.py (85%) create mode 100644 src/task_client_api/src/task_client_api/py.typed rename src/task_client_api/src/{mail_client_api => task_client_api}/task.py (67%) rename src/task_client_api/src/{mail_client_api => task_client_api}/tasklist.py (95%) create mode 100644 test_gtask.py diff --git a/pyproject.toml b/pyproject.toml index bb331490..c371db29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ members = [ "src/mail_client_adapter", "src/mail_client_service_client", "src/task_client_api", + "src/gtask_client_impl", ] [tool.ruff] @@ -56,7 +57,8 @@ mypy_path = [ "src/mail_client_service/src", "src/mail_client_adapter/src", "src/mail_client_service_client/src", - "src/task_client_api", + "src/task_client_api/src", + "src/gtask_client_impl/src", ] ignore_missing_imports = false @@ -73,6 +75,7 @@ module = [ "mail_client_adapter.*", "mail_client_service_client.*", "task_client_api.*", + "gtask_client_impl.*", ] ignore_missing_imports = true diff --git a/src/gtask_client_impl/README.md b/src/gtask_client_impl/README.md new file mode 100644 index 00000000..e69de29b diff --git a/src/gtask_client_impl/pyproject.toml b/src/gtask_client_impl/pyproject.toml new file mode 100644 index 00000000..f89af70b --- /dev/null +++ b/src/gtask_client_impl/pyproject.toml @@ -0,0 +1,58 @@ +[project] +name = "gtask-client-impl" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "dotenv>=0.9.9", + "google-api-python-client>=2.177.0", + "google-auth>=2.40.3", + "google-auth-oauthlib>=1.2.2", + "task-client-api", +] + +[project.optional-dependencies] +test = [ + "pytest>=7.0.0", + "pytest-mock>=3.10.0", +] + +[tool.pytest.ini_options] +pythonpath = [".", "src"] +testpaths = ["tests", "src"] +addopts = ["--cov", "--cov-report=term-missing"] + +[tool.coverage.run] +source = ["src"] +omit = ["*/tests/*", ] + +[tool.coverage.report] +fail_under = 85 # Justification: A high threshold ensures most code is tested. +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/gtask_client_impl"] + +[tool.hatch.build.targets.wheel.force-include] +"src/gtask_client_impl/py.typed" = "gtask_client_impl/py.typed" + +[tool.ruff] +line-length = 100 # Default formatting width +target-version = "py311" # Adjust based on actual Python version +extend = "../../pyproject.toml" + +[tool.ruff.lint] +ignore = [] + +[tool.uv.sources] +task-client-api = { workspace = true } + diff --git a/src/gtask_client_impl/src/gtask_client_impl/__init__.py b/src/gtask_client_impl/src/gtask_client_impl/__init__.py new file mode 100644 index 00000000..088230e5 --- /dev/null +++ b/src/gtask_client_impl/src/gtask_client_impl/__init__.py @@ -0,0 +1,44 @@ +"""Public exports for the Google Tasks client implementation package.""" + +from gtask_client_impl.gtask_impl import ( + GTaskClient, + get_client_impl, +) +from gtask_client_impl.gtask_impl import ( + register as _register_client, +) +from gtask_client_impl.task_impl import ( + GTask, + get_task_impl, +) +from gtask_client_impl.task_impl import ( + register as _register_task, +) +from gtask_client_impl.tasklist_impl import ( + GTaskList, + get_tasklist_impl, +) +from gtask_client_impl.tasklist_impl import ( + register as _register_tasklist, +) + +__all__ = [ + "GTask", + "GTaskClient", + "GTaskList", + "get_client_impl", + "get_task_impl", + "get_tasklist_impl", + "register", +] + + +def register() -> None: + """Register the Google Tasks client, task, and tasklist implementations.""" + _register_client() + _register_task() + _register_tasklist() + + +# Dependency Injection happens at import time +register() diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py new file mode 100644 index 00000000..8a8d1866 --- /dev/null +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -0,0 +1,453 @@ +"""Google Tasks Client Implementation. + +This module provides a concrete implementation of the task client API using the Google Tasks API. +It handles OAuth2 authentication and provides methods to interact with Google Tasks and TaskLists. + +The implementation supports multiple authentication modes: + - Environment variables (for CI/CD environments) + - Local token file (for development) + - Interactive OAuth flow (for initial setup) +""" + +import json +import logging +import os +from pathlib import Path +from typing import ClassVar + +import task_client_api +from google.auth.exceptions import GoogleAuthError, RefreshError +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow # type: ignore[import-untyped] +from googleapiclient.discovery import Resource, build +from googleapiclient.errors import HttpError +from task_client_api import task, tasklist + +# Try to load .env file if python-dotenv is available +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + # If python-dotenv is not available, check if .env file exists + # and manually load it + env_path = Path(".env") + if env_path.exists(): + with env_path.open() as f: + for raw_line in f: + line = raw_line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + os.environ[key.strip()] = value.strip() + + +class GTaskClient(task_client_api.Client): + """Concrete implementation of the Client abstraction using Google Tasks API. + + This class provides a complete implementation of the mail_client_api.Client abstraction + using Google's Tasks API. It handles OAuth2 authentication automatically and provides + methods to interact with Google Tasks. + + Attributes: + SCOPES: List of OAuth2 scopes required for Gmail API access. + FAILURE_TO_CRED: Error message for authentication failures. + service: The authenticated Tasks API service object. + + Authentication Flow: + 1. If `interactive=True`, forces interactive OAuth flow + 2. Try environment variables (TASKS_CLIENT_ID, TASKS_CLIENT_SECRET, TASKS_REFRESH_TOKEN) + 3. Try local token.json file + 4. Fallback to interactive OAuth flow + + Environment Variables: + - TASKS_CLIENT_ID: OAuth2 client ID + - TASKS_CLIENT_SECRET: OAuth2 client secret + - TASKS_REFRESH_TOKEN: OAuth2 refresh token + - TASKS_TOKEN_URI: OAuth2 token URI (optional, defaults to Google's endpoint) + + """ + + TOKEN_PATH: ClassVar[str] = "token.json" # noqa: S105 + CREDENTIALS_PATH: ClassVar[str] = "credentials.json" + SCOPES: ClassVar[list[str]] = [ + "https://www.googleapis.com/auth/tasks", + ] + FAILURE_TO_CRED = "Failed to obtain credentials. Please check your setup." + + def __init__(self, service: Resource | None = None, *, interactive: bool = False) -> None: + """Initialize the GTaskClient, handling authentication.""" + self.logger = logging.getLogger(__name__) + if service: + self.service = service + return # Skip auth if service is provided + + creds: Credentials | None = None + token_path = self.TOKEN_PATH + creds_path = self.CREDENTIALS_PATH + + if interactive: + creds = self._run_interactive_flow(creds_path) + + if not creds and not interactive: + creds = self._auth_from_env() + + if not creds and not interactive: + creds = self._auth_from_token_file(token_path) + + if not creds or (creds and not creds.valid and not creds.refresh_token): + if not interactive: + msg = ( + "No valid credentials found and interactive mode is disabled. " + "Please provide valid credentials via environment variables or token file." + ) + raise RuntimeError(msg) + + creds = self._run_interactive_flow(creds_path) + if not creds: + msg = "Interactive authentication failed." + raise RuntimeError(msg) + + if not creds or not creds.valid: + raise RuntimeError(self.FAILURE_TO_CRED) + + if interactive or (creds.refresh_token and not Path(token_path).exists()): + self._save_token(creds, token_path) + + self.service = build("tasks", "v1", credentials=creds) + + def _run_interactive_flow(self, creds_path: str) -> Credentials | None: + """Run the interactive OAuth flow. + + This method launches a local web server to handle the OAuth2 flow, + opening the user's browser to complete authentication with Google. + """ + if not Path(creds_path).exists(): + raise FileNotFoundError(f"'{creds_path}' not found. Cannot run interactive auth.") # noqa: EM102 TRY003 + flow = InstalledAppFlow.from_client_secrets_file( + creds_path, + self.SCOPES, + ) + return flow.run_local_server(port=0) # type: ignore[no-any-return] + + def _auth_from_env(self) -> Credentials | None: + """Attempt to authenticate using environment variables. + + Expected environment variables: + TASKS_CLIENT_ID, TASKS_CLIENT_SECRET, TASKS_REFRESH_TOKEN + optional: TASKS_TOKEN_URI + + Returns: + A refreshed Credentials object on success, or None on failure. + + """ + client_id = os.environ.get("TASKS_CLIENT_ID") + client_secret = os.environ.get("TASKS_CLIENT_SECRET") + refresh_token = os.environ.get("TASKS_REFRESH_TOKEN") + token_uri = os.environ.get("TASKS_TOKEN_URI", "https://oauth2.googleapis.com/token") + + if not (client_id and client_secret and refresh_token): + return None + + try: + creds = Credentials( # type: ignore[no-untyped-call] + None, + refresh_token=refresh_token, + token_uri=token_uri, + client_id=client_id, + client_secret=client_secret, + scopes=self.SCOPES, + ) + creds.refresh(Request()) # type: ignore[no-untyped-call] + return creds # noqa: TRY300 + except (GoogleAuthError, RefreshError, OSError, ValueError): + return None + + def _auth_from_token_file(self, token_path: str) -> Credentials | None: + """Attempt to load credentials from a token file and refresh if needed. + + Args: + token_path: Path to token file. + + Returns: + A valid Credentials object or None if loading/refresh fails. + + """ + try: + if not Path(token_path).exists(): + return None + + creds = Credentials.from_authorized_user_file( # type: ignore[no-untyped-call] + token_path, + self.SCOPES, + ) + + if creds and not creds.valid and creds.refresh_token: + try: + creds.refresh(Request()) # type: ignore[no-untyped-call] + except (GoogleAuthError, RefreshError, OSError, ValueError): + return None + except (GoogleAuthError, RefreshError, OSError, ValueError): + return None + + return creds # type: ignore[no-any-return] + + def _save_token(self, creds: Credentials, token_path: str) -> None: + """Save the credentials token to a file. + + Persists the OAuth2 credentials to a JSON file for future use, + avoiding the need to re-authenticate on subsequent runs. + + Args: + creds: The credentials object to save. + token_path: Path where the token file should be saved. + + Note: + The token file contains sensitive information and should be kept secure. + It's automatically added to .gitignore in most project templates. + + """ + with Path(token_path).open("w") as token: + token.write(creds.to_json()) # type: ignore[no-untyped-call] + + """ TASKLIST OPERATIONS """ + + def delete_tasklist(self, tasklist: tasklist.TaskList) -> bool: + """Delete a tasklist by its ID. + + Args: + tasklist: The tasklist to delete. + + Returns: + True if the tasklist was successfully deleted, False otherwise. + + """ + tasklist_id = tasklist.id + try: + ( + self.service.tasklists() # type: ignore[attr-defined] + .delete(tasklist=tasklist_id) + .execute() + ) + except (HttpError, OSError, ValueError) as e: + self.logger.exception("Failed to delete tasklist %s", tasklist_id) + self.logger.debug("Error details: %s", e) + return False + else: + return True + + def insert_tasklist(self, tasklist: tasklist.TaskList) -> tasklist.TaskList: + """Insert a tasklist. + + Args: + tasklist: The tasklist to insert. + + Returns: + The inserted tasklist with updated fields (e.g., id, etag). + + """ + try: + body = {"title": tasklist.title} + result = ( + self.service.tasklists() # type: ignore[attr-defined] + .insert(body=body) + .execute() + ) + # Convert result dict to JSON string for raw_data + raw_data = json.dumps(result) + return task_client_api.tasklist.get_tasklist( + task_list_id=result["id"], + raw_data=raw_data, + ) + except (HttpError, OSError, ValueError) as e: + self.logger.exception("Failed to insert tasklist") + self.logger.debug("Error details: %s", e) + raise + + def list_tasklists(self) -> list[tasklist.TaskList]: + """List all tasklists. + + Returns: + A list of TaskList objects. + + """ + try: + result = ( + self.service.tasklists() # type: ignore[attr-defined] + .list() + .execute() + ) + tasklists = [] + for item in result.get("items", []): + raw_data = json.dumps(item) + tasklists.append( + tasklist.get_tasklist( + task_list_id=item["id"], + raw_data=raw_data, + ) + ) + except (HttpError, OSError, ValueError) as e: + self.logger.exception("Failed to list tasklists") + self.logger.debug("Error details: %s", e) + return [] + else: + return tasklists + + """ TASK OPERATIONS """ + + def list_tasks(self, tasklist: tasklist.TaskList) -> list[task.Task]: + """List all tasks in a tasklist. + + Args: + tasklist: The tasklist to list tasks from. + + Returns: + A list of Task objects. + + """ + tasklist_id = tasklist.id + try: + result = ( + self.service.tasks() # type: ignore[attr-defined] + .list(tasklist=tasklist_id) + .execute() + ) + tasks = [] + for item in result.get("items", []): + raw_data = json.dumps(item) + tasks.append( + task.get_task( + task_id=item["id"], + raw_data=raw_data, + ) + ) + except (HttpError, OSError, ValueError) as e: + self.logger.exception("Failed to list tasks for tasklist %s", tasklist_id) + self.logger.debug("Error details: %s", e) + return [] + else: + return tasks + + def insert_task( + self, + tasklist: tasklist.TaskList, + task: task.Task, + parent: str | None = None, + previous: str | None = None, + ) -> task.Task: + """Insert a task into a tasklist. + + Args: + tasklist: The tasklist to insert the task into. + task: The task to insert. + parent: Optional parent task ID if this is a subtask. + previous: Optional previous task ID for positioning. + + Returns: + The inserted task with updated fields. + + """ + tasklist_id = tasklist.id + try: + body: dict[str, str | None] = { + "title": task.title, + } + if task.notes: + body["notes"] = task.notes + if task.status: + body["status"] = task.status + if task.due: + body["due"] = task.due + if parent: + body["parent"] = parent + if previous: + body["previous"] = previous + + result = ( + self.service.tasks() # type: ignore[attr-defined] + .insert(tasklist=tasklist_id, body=body) + .execute() + ) + raw_data = json.dumps(result) + return task_client_api.task.get_task( + task_id=result["id"], + raw_data=raw_data, + ) + except (HttpError, OSError, ValueError) as e: + self.logger.exception("Failed to insert task") + self.logger.debug("Error details: %s", e) + raise + + def delete_task(self, task_id: str) -> bool: + """Delete a task by its ID. + + Note: The Google Tasks API requires both tasklist ID and task ID. + This implementation attempts to delete from the default "@default" tasklist. + For more control, use a task object that contains the tasklist reference. + + Args: + task_id: The unique identifier of the task to delete. + + Returns: + True if the task was successfully deleted, False otherwise. + + """ + try: + # Note: Google Tasks API requires tasklist ID, defaulting to "@default" + # In a production system, you might want to store tasklist_id with tasks + ( + self.service.tasks() # type: ignore[attr-defined] + .delete(tasklist="@default", task=task_id) + .execute() + ) + except (HttpError, OSError, ValueError) as e: + self.logger.exception("Failed to delete task %s", task_id) + self.logger.debug("Error details: %s", e) + return False + else: + return True + + def get_task(self, task_id: str) -> task.Task: + """Get a task by its ID. + + Note: The Google Tasks API requires both tasklist ID and task ID. + This implementation attempts to get from the default "@default" tasklist. + + Args: + task_id: The unique identifier of the task to retrieve. + + Returns: + A Task object containing the task data. + + Raises: + ValueError: If the task cannot be retrieved. + + """ + try: + # Note: Google Tasks API requires tasklist ID, defaulting to "@default" + result = ( + self.service.tasks() # type: ignore[attr-defined] + .get(tasklist="@default", task=task_id) + .execute() + ) + raw_data = json.dumps(result) + except (HttpError, OSError, ValueError) as e: + self.logger.exception("Failed to get task %s", task_id) + self.logger.debug("Error details: %s", e) + error_msg = f"Failed to retrieve task {task_id}" + raise ValueError(error_msg) from e + else: + return task.get_task( + task_id=task_id, + raw_data=raw_data, + ) + + +def get_client_impl(*, interactive: bool = False) -> task_client_api.Client: + """Return a configured :class:`GTaskClient` instance.""" + return GTaskClient(interactive=interactive) + + +def register() -> None: + """Register the GTask client implementation with the task client API.""" + task_client_api.get_client = get_client_impl diff --git a/src/gtask_client_impl/src/gtask_client_impl/py.typed b/src/gtask_client_impl/src/gtask_client_impl/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/gtask_client_impl/src/gtask_client_impl/task_impl.py b/src/gtask_client_impl/src/gtask_client_impl/task_impl.py new file mode 100644 index 00000000..8f6377dd --- /dev/null +++ b/src/gtask_client_impl/src/gtask_client_impl/task_impl.py @@ -0,0 +1,71 @@ +"""Google Task Implementation colocated with the GTask client.""" + +import json +from typing import cast + +import task_client_api +from task_client_api import task + + +class GTask(task.Task): + """Concrete implementation of the Task abstraction for Google Tasks.""" + + def __init__(self, task_id: str, raw_data: str) -> None: + """Initialize the task from raw JSON data.""" + self._id = task_id + self._raw_data = raw_data + try: + self._data = json.loads(raw_data) + except json.JSONDecodeError: + self._data = {} + + @property + def id(self) -> str: + """Get the unique task identifier.""" + return self._id + + @property + def title(self) -> str: + """Get the task title.""" + return cast("str", self._data.get("title", "")) + + @property + def notes(self) -> str | None: + """Get the task notes.""" + return cast("str | None", self._data.get("notes")) + + @property + def status(self) -> str: + """Get the task status.""" + return cast("str", self._data.get("status", "needsAction")) + + @property + def due(self) -> str | None: + """Get the task due date (RFC 3339 timestamp).""" + return cast("str | None", self._data.get("due")) + + @property + def completed(self) -> str | None: + """Get the task completion date (RFC 3339 timestamp).""" + return cast("str | None", self._data.get("completed")) + + @property + def deleted(self) -> bool: + """Check if the task has been deleted.""" + return cast("bool", self._data.get("deleted", False)) + + @property + def hidden(self) -> bool: + """Check if the task is hidden.""" + return cast("bool", self._data.get("hidden", False)) + + +def get_task_impl(task_id: str, raw_data: str) -> task.Task: + """Return an instance of the concrete GTask implementation.""" + return GTask(task_id=task_id, raw_data=raw_data) + + +def register() -> None: + """Register the Google Task implementation with the task abstraction.""" + task.get_task = get_task_impl + task_client_api.get_task = get_task_impl diff --git a/src/gtask_client_impl/src/gtask_client_impl/tasklist_impl.py b/src/gtask_client_impl/src/gtask_client_impl/tasklist_impl.py new file mode 100644 index 00000000..16e61b07 --- /dev/null +++ b/src/gtask_client_impl/src/gtask_client_impl/tasklist_impl.py @@ -0,0 +1,56 @@ +"""Google TaskList Implementation colocated with the GTask client.""" + +import json +from typing import cast + +import task_client_api +from task_client_api import tasklist + + +class GTaskList(tasklist.TaskList): + """Concrete implementation of the TaskList abstraction for Google TaskLists.""" + + def __init__(self, task_list_id: str, raw_data: str) -> None: + """Initialize the tasklist from raw JSON data.""" + self._id = task_list_id + self._raw_data = raw_data + try: + self._data = json.loads(raw_data) + except json.JSONDecodeError: + self._data = {} + + @property + def id(self) -> str: + """Get the unique task list identifier.""" + return self._id + + @property + def title(self) -> str: + """Get the task list title.""" + return cast("str", self._data.get("title", "")) + + @property + def etag(self) -> str: + """Get the ETag of the resource.""" + return cast("str", self._data.get("etag", "")) + + @property + def updated(self) -> str: + """Get the last modification time of the task list (RFC 3339 timestamp).""" + return cast("str", self._data.get("updated", "")) + + @property + def self_link(self) -> str: + """Get the URL pointing to this task list.""" + return cast("str", self._data.get("selfLink", "")) + + +def get_tasklist_impl(task_list_id: str, raw_data: str) -> tasklist.TaskList: + """Return an instance of the concrete GTaskList implementation.""" + return GTaskList(task_list_id=task_list_id, raw_data=raw_data) + + +def register() -> None: + """Register the Google TaskList implementation with the tasklist abstraction.""" + tasklist.get_tasklist = get_tasklist_impl + task_client_api.get_tasklist = get_tasklist_impl diff --git a/src/task_client_api/src/mail_client_api/__init__.py b/src/task_client_api/src/mail_client_api/__init__.py deleted file mode 100644 index 9b648bb3..00000000 --- a/src/task_client_api/src/mail_client_api/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Public export surface for ``mail_client_api``.""" - -from task_client_api.client import Client, get_client -from task_client_api.task import Task -from task_client_api.tasklist import TaskList - -__all__ = [ - "Client", - "Task", - "TaskList", - "get_client", - "get_task", - "get_tasklist", - "task", - "tasklist", -] diff --git a/src/task_client_api/src/task_client_api/__init__.py b/src/task_client_api/src/task_client_api/__init__.py new file mode 100644 index 00000000..97b85be8 --- /dev/null +++ b/src/task_client_api/src/task_client_api/__init__.py @@ -0,0 +1,17 @@ +"""Public export surface for ``task_client_api``.""" + +from task_client_api import task, tasklist +from task_client_api.client import Client, get_client +from task_client_api.task import Task, get_task +from task_client_api.tasklist import TaskList, get_tasklist + +__all__ = [ + "Client", + "Task", + "TaskList", + "get_client", + "get_task", + "get_tasklist", + "task", + "tasklist", +] diff --git a/src/task_client_api/src/mail_client_api/client.py b/src/task_client_api/src/task_client_api/client.py similarity index 85% rename from src/task_client_api/src/mail_client_api/client.py rename to src/task_client_api/src/task_client_api/client.py index bfff5791..d39d19f7 100644 --- a/src/task_client_api/src/mail_client_api/client.py +++ b/src/task_client_api/src/task_client_api/client.py @@ -35,19 +35,17 @@ def list_tasks(self, tasklist: TaskList) -> list[Task]: raise NotImplementedError @abstractmethod - def insert_task( - self, tasklist: TaskList, task: Task, parent: Task.id = None, previous: Task.id = None - ) -> Task: + def insert_task(self, tasklist: TaskList, task: Task) -> Task: """Insert a task into a tasklist.""" raise NotImplementedError @abstractmethod - def delete_task(self, task_id: Task.id) -> bool: + def delete_task(self, task_id: str) -> bool: """Delete a task by ...""" raise NotImplementedError @abstractmethod - def get_task(self, task_id: Task.id) -> Task: + def get_task(self, task_id: str) -> Task: """Get a task by its ID.""" raise NotImplementedError diff --git a/src/task_client_api/src/task_client_api/py.typed b/src/task_client_api/src/task_client_api/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/task_client_api/src/mail_client_api/task.py b/src/task_client_api/src/task_client_api/task.py similarity index 67% rename from src/task_client_api/src/mail_client_api/task.py rename to src/task_client_api/src/task_client_api/task.py index 466121f0..ec0cc5cd 100644 --- a/src/task_client_api/src/mail_client_api/task.py +++ b/src/task_client_api/src/task_client_api/task.py @@ -1,7 +1,6 @@ """Tasks contract - Core task representation.""" from abc import ABC, abstractmethod -from typing import Any class Task(ABC): @@ -55,36 +54,6 @@ def hidden(self) -> bool: """Return whether the task is hidden.""" raise NotImplementedError - @property - @abstractmethod - def parent(self) -> str | None: - """Return the parent task identifier if this is a subtask.""" - raise NotImplementedError - - @property - @abstractmethod - def position(self) -> str | None: - """Return the position of the task among its siblings.""" - raise NotImplementedError - - @property - @abstractmethod - def links(self) -> list[dict[str, str]]: - """Return collection of links associated with the task.""" - raise NotImplementedError - - @property - @abstractmethod - def web_view_link(self) -> str | None: - """Return an absolute link to the task in the Google Tasks Web UI.""" - raise NotImplementedError - - @property - @abstractmethod - def assignment_info(self) -> dict[str, Any] | None: - """Return context information for assigned tasks.""" - raise NotImplementedError - def get_task(task_id: str, raw_data: str) -> Task: """Return an instance of a Task. diff --git a/src/task_client_api/src/mail_client_api/tasklist.py b/src/task_client_api/src/task_client_api/tasklist.py similarity index 95% rename from src/task_client_api/src/mail_client_api/tasklist.py rename to src/task_client_api/src/task_client_api/tasklist.py index 33c7d617..2d89006e 100644 --- a/src/task_client_api/src/mail_client_api/tasklist.py +++ b/src/task_client_api/src/task_client_api/tasklist.py @@ -37,7 +37,7 @@ def self_link(self) -> str: raise NotImplementedError -def get_task_list(task_list_id: str, raw_data: str) -> TaskList: +def get_tasklist(task_list_id: str, raw_data: str) -> TaskList: """Return an instance of a TaskList. Args: diff --git a/test_gtask.py b/test_gtask.py new file mode 100644 index 00000000..ff02ef00 --- /dev/null +++ b/test_gtask.py @@ -0,0 +1,134 @@ +"""Main module for demonstrating the task client.""" + +import contextlib +import logging + +import gtask_client_impl # noqa: F401 +import task_client_api + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def main() -> None: # noqa: PLR0915, PLR0912, C901 # Just a test script + """Initialize the client and demonstrate all task client methods.""" + # Now, get_client() returns a GTaskClient instance... + client = task_client_api.get_client(interactive=False) + + # Test 1: List all tasklists + logger.info("Test 1: Listing all tasklists...") + tasklists = client.list_tasklists() + logger.info("Found %d tasklist(s)", len(tasklists)) + + if not tasklists: + logger.info("No tasklists found. Creating a test tasklist...") + # For insert, we'd need a TaskList object, but we can't create one without raw_data + # So we'll skip this test if no tasklists exist + logger.info("Skipping further tests - no tasklists available.") + return + + # Display tasklists + for i, tasklist in enumerate(tasklists, 1): + logger.info("TaskList %d: %s (ID: %s)", i, tasklist.title, tasklist.id) + + # Test 2: Get tasks from the first tasklist + test_tasklist = tasklists[0] + logger.info("\nTest 2: Listing tasks from tasklist '%s'...", test_tasklist.title) + tasks = client.list_tasks(test_tasklist) + + logger.info("Found %d task(s) in tasklist '%s'", len(tasks), test_tasklist.title) + + # Display tasks + for i, task in enumerate(tasks, 1): + status_emoji = "✓" if task.status == "completed" else "○" + logger.info( + "Task %d: %s %s (ID: %s)", + i, + status_emoji, + task.title, + task.id, + ) + if task.notes: + logger.info(" Notes: %s", task.notes[:50]) + if task.due: + logger.info(" Due: %s", task.due) + + # Test 3: Get a specific task by ID + if tasks: + test_task_id = tasks[0].id + logger.info("\nTest 3: Getting task by ID: %s...", test_task_id) + with contextlib.suppress(Exception): + task = client.get_task(test_task_id) + logger.info("Retrieved task: %s (Status: %s)", task.title, task.status) + + # Test 4: Create a new task (if we have a tasklist) + if test_tasklist and len(tasks) >= 0: # Allow creating even if no tasks exist + logger.info("\nTest 4: Creating a new test task...") + # We need to create a Task object, but we can't easily do that without raw_data + # For now, we'll note that this requires a Task object with at least a title + logger.info("Note: insert_task requires a Task object. Skipping creation test.") + + # Test 5: Delete a tasklist (WARNING: This is destructive!) + # Only test if we have more than one tasklist to avoid deleting all tasklists + if len(tasklists) > 1: + logger.info("\nTest 5: Deleting a tasklist (destructive operation)...") + delete_tasklist = tasklists[-1] # Delete the last tasklist + try: + confirmation = input(f"Type 'DELETE' to confirm deletion of tasklist '{delete_tasklist.title}': ") + if confirmation == "DELETE": + success = client.delete_tasklist(delete_tasklist) + if success: + logger.info( + "Tasklist '%s' (ID: %s) deleted successfully.", + delete_tasklist.title, + delete_tasklist.id, + ) + else: + logger.info( + "Failed to delete tasklist '%s' (ID: %s).", + delete_tasklist.title, + delete_tasklist.id, + ) + except EOFError: + # This means that CircleCI or another non-interactive environment is + # not going to actually delete anything + logger.info("Non-interactive environment detected. Skipping deletion.") + else: + logger.info( + "\nTest 5: Skipping tasklist deletion (only %d tasklist(s) found)", + len(tasklists), + ) + + # Test 6: Delete a task (WARNING: This is destructive!) + # Only test if we have tasks in the first tasklist + if tasks and len(tasks) > 0: + logger.info("\nTest 6: Deleting a task (destructive operation)...") + delete_task = tasks[-1] # Delete the last task + try: + confirmation = input(f"Type 'DELETE' to confirm deletion of task '{delete_task.title}': ") + if confirmation == "DELETE": + success = client.delete_task(delete_task.id) + if success: + logger.info( + "Task '%s' (ID: %s) deleted successfully.", + delete_task.title, + delete_task.id, + ) + else: + logger.info( + "Failed to delete task '%s' (ID: %s).", + delete_task.title, + delete_task.id, + ) + except EOFError: + # This means that CircleCI or another non-interactive environment is + # not going to actually delete anything + logger.info("Non-interactive environment detected. Skipping deletion.") + else: + logger.info("\nTest 6: Skipping task deletion (no tasks found)") + + logger.info("\nDemo complete.") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 6bf434df..74d78582 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,7 @@ resolution-markers = [ [manifest] members = [ "gmail-client-impl", + "gtask-client-impl", "mail-client-adapter", "mail-client-api", "mail-client-service", @@ -433,6 +434,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, ] +[[package]] +name = "gtask-client-impl" +version = "0.1.0" +source = { editable = "src/gtask_client_impl" } +dependencies = [ + { name = "dotenv" }, + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "google-auth-oauthlib" }, + { name = "task-client-api" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "google-api-python-client", specifier = ">=2.177.0" }, + { name = "google-auth", specifier = ">=2.40.3" }, + { name = "google-auth-oauthlib", specifier = ">=1.2.2" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.10.0" }, + { name = "task-client-api", editable = "src/task_client_api" }, +] +provides-extras = ["test"] + [[package]] name = "h11" version = "0.16.0" From 76aefd04c1ec7216db2dd6e114f70eca7b386049 Mon Sep 17 00:00:00 2001 From: Faith Villarreal Date: Wed, 29 Oct 2025 13:35:59 -0400 Subject: [PATCH 03/33] feat: adding implementation for GTasks DI --- .../src/gtask_client_impl/gtask_impl.py | 36 ++--- .../src/task_client_api/client.py | 10 +- test_gtask.py | 136 +++++++++--------- 3 files changed, 84 insertions(+), 98 deletions(-) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index 8a8d1866..25139939 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -212,17 +212,16 @@ def _save_token(self, creds: Credentials, token_path: str) -> None: """ TASKLIST OPERATIONS """ - def delete_tasklist(self, tasklist: tasklist.TaskList) -> bool: + def delete_tasklist(self, tasklist_id: str) -> bool: """Delete a tasklist by its ID. Args: - tasklist: The tasklist to delete. + tasklist_id: The ID of the tasklist to delete. Returns: True if the tasklist was successfully deleted, False otherwise. """ - tasklist_id = tasklist.id try: ( self.service.tasklists() # type: ignore[attr-defined] @@ -295,17 +294,16 @@ def list_tasklists(self) -> list[tasklist.TaskList]: """ TASK OPERATIONS """ - def list_tasks(self, tasklist: tasklist.TaskList) -> list[task.Task]: + def list_tasks(self, tasklist_id: str) -> list[task.Task]: """List all tasks in a tasklist. Args: - tasklist: The tasklist to list tasks from. + tasklist_id: The ID of the tasklist to list tasks from. Returns: A list of Task objects. """ - tasklist_id = tasklist.id try: result = ( self.service.tasks() # type: ignore[attr-defined] @@ -330,24 +328,19 @@ def list_tasks(self, tasklist: tasklist.TaskList) -> list[task.Task]: def insert_task( self, - tasklist: tasklist.TaskList, - task: task.Task, - parent: str | None = None, - previous: str | None = None, + tasklist_id: str, + task: task.Task ) -> task.Task: """Insert a task into a tasklist. Args: - tasklist: The tasklist to insert the task into. + tasklist_id: The ID of the tasklist to insert the task into. task: The task to insert. - parent: Optional parent task ID if this is a subtask. - previous: Optional previous task ID for positioning. Returns: The inserted task with updated fields. """ - tasklist_id = tasklist.id try: body: dict[str, str | None] = { "title": task.title, @@ -358,10 +351,6 @@ def insert_task( body["status"] = task.status if task.due: body["due"] = task.due - if parent: - body["parent"] = parent - if previous: - body["previous"] = previous result = ( self.service.tasks() # type: ignore[attr-defined] @@ -378,7 +367,7 @@ def insert_task( self.logger.debug("Error details: %s", e) raise - def delete_task(self, task_id: str) -> bool: + def delete_task(self, tasklist_id: str, task_id: str) -> bool: """Delete a task by its ID. Note: The Google Tasks API requires both tasklist ID and task ID. @@ -386,6 +375,7 @@ def delete_task(self, task_id: str) -> bool: For more control, use a task object that contains the tasklist reference. Args: + tasklist_id: The ID of the tasklist to delete the task from. task_id: The unique identifier of the task to delete. Returns: @@ -397,7 +387,7 @@ def delete_task(self, task_id: str) -> bool: # In a production system, you might want to store tasklist_id with tasks ( self.service.tasks() # type: ignore[attr-defined] - .delete(tasklist="@default", task=task_id) + .delete(tasklist=tasklist_id, task=task_id) .execute() ) except (HttpError, OSError, ValueError) as e: @@ -407,13 +397,14 @@ def delete_task(self, task_id: str) -> bool: else: return True - def get_task(self, task_id: str) -> task.Task: + def get_task(self, tasklist_id: str, task_id: str) -> task.Task: """Get a task by its ID. Note: The Google Tasks API requires both tasklist ID and task ID. This implementation attempts to get from the default "@default" tasklist. Args: + tasklist_id: The ID of the tasklist to get the task from. task_id: The unique identifier of the task to retrieve. Returns: @@ -424,10 +415,9 @@ def get_task(self, task_id: str) -> task.Task: """ try: - # Note: Google Tasks API requires tasklist ID, defaulting to "@default" result = ( self.service.tasks() # type: ignore[attr-defined] - .get(tasklist="@default", task=task_id) + .get(tasklist=tasklist_id, task=task_id) .execute() ) raw_data = json.dumps(result) diff --git a/src/task_client_api/src/task_client_api/client.py b/src/task_client_api/src/task_client_api/client.py index d39d19f7..4bff8165 100644 --- a/src/task_client_api/src/task_client_api/client.py +++ b/src/task_client_api/src/task_client_api/client.py @@ -14,7 +14,7 @@ class Client(ABC): """ TASKLIST OPERATIONS """ @abstractmethod - def delete_tasklist(self, tasklist: TaskList) -> bool: + def delete_tasklist(self, tasklist_id: str) -> bool: """Delete a tasklist by ...""" raise NotImplementedError @@ -30,22 +30,22 @@ def list_tasklists(self) -> list[TaskList]: """ TASK OPERATIONS """ - def list_tasks(self, tasklist: TaskList) -> list[Task]: + def list_tasks(self, tasklist_id: str) -> list[Task]: """List all tasks in a tasklist.""" raise NotImplementedError @abstractmethod - def insert_task(self, tasklist: TaskList, task: Task) -> Task: + def insert_task(self, tasklist_id: str, task: Task) -> Task: """Insert a task into a tasklist.""" raise NotImplementedError @abstractmethod - def delete_task(self, task_id: str) -> bool: + def delete_task(self, tasklist_id: str, task_id: str) -> bool: """Delete a task by ...""" raise NotImplementedError @abstractmethod - def get_task(self, task_id: str) -> Task: + def get_task(self, tasklist_id: str, task_id: str) -> Task: """Get a task by its ID.""" raise NotImplementedError diff --git a/test_gtask.py b/test_gtask.py index ff02ef00..782dde0d 100644 --- a/test_gtask.py +++ b/test_gtask.py @@ -1,6 +1,6 @@ """Main module for demonstrating the task client.""" -import contextlib +import json import logging import gtask_client_impl # noqa: F401 @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -def main() -> None: # noqa: PLR0915, PLR0912, C901 # Just a test script +def main() -> None: # noqa: C901 # Just a test script """Initialize the client and demonstrate all task client methods.""" # Now, get_client() returns a GTaskClient instance... client = task_client_api.get_client(interactive=False) @@ -34,7 +34,7 @@ def main() -> None: # noqa: PLR0915, PLR0912, C901 # Just a test script # Test 2: Get tasks from the first tasklist test_tasklist = tasklists[0] logger.info("\nTest 2: Listing tasks from tasklist '%s'...", test_tasklist.title) - tasks = client.list_tasks(test_tasklist) + tasks = client.list_tasks(test_tasklist.id) logger.info("Found %d task(s) in tasklist '%s'", len(tasks), test_tasklist.title) @@ -53,79 +53,75 @@ def main() -> None: # noqa: PLR0915, PLR0912, C901 # Just a test script if task.due: logger.info(" Due: %s", task.due) - # Test 3: Get a specific task by ID - if tasks: - test_task_id = tasks[0].id - logger.info("\nTest 3: Getting task by ID: %s...", test_task_id) - with contextlib.suppress(Exception): - task = client.get_task(test_task_id) - logger.info("Retrieved task: %s (Status: %s)", task.title, task.status) - - # Test 4: Create a new task (if we have a tasklist) - if test_tasklist and len(tasks) >= 0: # Allow creating even if no tasks exist - logger.info("\nTest 4: Creating a new test task...") - # We need to create a Task object, but we can't easily do that without raw_data - # For now, we'll note that this requires a Task object with at least a title - logger.info("Note: insert_task requires a Task object. Skipping creation test.") - - # Test 5: Delete a tasklist (WARNING: This is destructive!) - # Only test if we have more than one tasklist to avoid deleting all tasklists - if len(tasklists) > 1: - logger.info("\nTest 5: Deleting a tasklist (destructive operation)...") - delete_tasklist = tasklists[-1] # Delete the last tasklist + # Test 3: Create and store a task locally + if test_tasklist: + logger.info("\nTest 3: Creating and storing a new test task...") try: - confirmation = input(f"Type 'DELETE' to confirm deletion of tasklist '{delete_tasklist.title}': ") - if confirmation == "DELETE": - success = client.delete_tasklist(delete_tasklist) - if success: - logger.info( - "Tasklist '%s' (ID: %s) deleted successfully.", - delete_tasklist.title, - delete_tasklist.id, - ) - else: - logger.info( - "Failed to delete tasklist '%s' (ID: %s).", - delete_tasklist.title, - delete_tasklist.id, + # Create a minimal Task object with just a title + # We need raw_data to create a Task, so we'll use a minimal JSON structure + task_data = {"title": "Test Task - Delete and Reinsert"} + raw_data = json.dumps(task_data) + + # Create task with temporary ID (will be replaced after insert) + # Calling the create task function associated with task object, NOT the service call + new_task = task_client_api.task.get_task(task_id="temp", raw_data=raw_data) + + # Insert the task via service call + inserted_task = client.insert_task(test_tasklist.id, new_task) + logger.info( + "Created and stored task: '%s' (ID: %s)", + inserted_task.title, + inserted_task.id, + ) + # Store the task locally for later operations + stored_task = inserted_task + stored_task_data = { + "title": stored_task.title, + "notes": stored_task.notes, + "status": stored_task.status, + "due": stored_task.due, + } + + # Test 4: Delete the stored task + logger.info("\nTest 4: Deleting the stored task...") + deletion_success = client.delete_task(test_tasklist.id, stored_task.id) + if deletion_success: + logger.info( + "Task '%s' (ID: %s) deleted successfully.", + stored_task.title, + stored_task.id, + ) + else: + logger.info( + "Failed to delete task '%s' (ID: %s).", + stored_task.title, + stored_task.id, + ) + + # Test 5: Reinsert the task (only if deletion was successful) + if deletion_success: + logger.info("\nTest 5: Reinserting the deleted task...") + try: + # Recreate the task object with the stored data + raw_data_reinsert = json.dumps(stored_task_data) + task_to_reinsert = task_client_api.task.get_task( + task_id="temp", raw_data=raw_data_reinsert ) - except EOFError: - # This means that CircleCI or another non-interactive environment is - # not going to actually delete anything - logger.info("Non-interactive environment detected. Skipping deletion.") - else: - logger.info( - "\nTest 5: Skipping tasklist deletion (only %d tasklist(s) found)", - len(tasklists), - ) - - # Test 6: Delete a task (WARNING: This is destructive!) - # Only test if we have tasks in the first tasklist - if tasks and len(tasks) > 0: - logger.info("\nTest 6: Deleting a task (destructive operation)...") - delete_task = tasks[-1] # Delete the last task - try: - confirmation = input(f"Type 'DELETE' to confirm deletion of task '{delete_task.title}': ") - if confirmation == "DELETE": - success = client.delete_task(delete_task.id) - if success: - logger.info( - "Task '%s' (ID: %s) deleted successfully.", - delete_task.title, - delete_task.id, + # Reinsert the task + reinserted_task = client.insert_task( + test_tasklist.id, task_to_reinsert ) - else: logger.info( - "Failed to delete task '%s' (ID: %s).", - delete_task.title, - delete_task.id, + "Reinserted task: '%s' (ID: %s)", + reinserted_task.title, + reinserted_task.id, ) - except EOFError: - # This means that CircleCI or another non-interactive environment is - # not going to actually delete anything - logger.info("Non-interactive environment detected. Skipping deletion.") + except Exception as e: + logger.info("Failed to reinsert task: %s", e) + except Exception as e: + logger.info("Failed to create/store task: %s", e) else: - logger.info("\nTest 6: Skipping task deletion (no tasks found)") + logger.info("\nTest 3: Skipping task creation (no tasklist available)") logger.info("\nDemo complete.") From fd3460eb5a3c64c4970aee7d5b647489c038f072 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Wed, 29 Oct 2025 16:34:02 -0400 Subject: [PATCH 04/33] feat: update insert tasklist param to str for simplier api calls --- .../src/gtask_client_impl/gtask_impl.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index 25139939..6d5eea33 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -75,7 +75,9 @@ class GTaskClient(task_client_api.Client): ] FAILURE_TO_CRED = "Failed to obtain credentials. Please check your setup." - def __init__(self, service: Resource | None = None, *, interactive: bool = False) -> None: + def __init__( + self, service: Resource | None = None, *, interactive: bool = False + ) -> None: """Initialize the GTaskClient, handling authentication.""" self.logger = logging.getLogger(__name__) if service: @@ -123,7 +125,9 @@ def _run_interactive_flow(self, creds_path: str) -> Credentials | None: opening the user's browser to complete authentication with Google. """ if not Path(creds_path).exists(): - raise FileNotFoundError(f"'{creds_path}' not found. Cannot run interactive auth.") # noqa: EM102 TRY003 + raise FileNotFoundError( + f"'{creds_path}' not found. Cannot run interactive auth." + ) # noqa: EM102 TRY003 flow = InstalledAppFlow.from_client_secrets_file( creds_path, self.SCOPES, @@ -144,7 +148,9 @@ def _auth_from_env(self) -> Credentials | None: client_id = os.environ.get("TASKS_CLIENT_ID") client_secret = os.environ.get("TASKS_CLIENT_SECRET") refresh_token = os.environ.get("TASKS_REFRESH_TOKEN") - token_uri = os.environ.get("TASKS_TOKEN_URI", "https://oauth2.googleapis.com/token") + token_uri = os.environ.get( + "TASKS_TOKEN_URI", "https://oauth2.googleapis.com/token" + ) if not (client_id and client_secret and refresh_token): return None @@ -235,7 +241,7 @@ def delete_tasklist(self, tasklist_id: str) -> bool: else: return True - def insert_tasklist(self, tasklist: tasklist.TaskList) -> tasklist.TaskList: + def insert_tasklist(self, title: str) -> tasklist.TaskList: """Insert a tasklist. Args: @@ -246,7 +252,7 @@ def insert_tasklist(self, tasklist: tasklist.TaskList) -> tasklist.TaskList: """ try: - body = {"title": tasklist.title} + body = {"title": title} result = ( self.service.tasklists() # type: ignore[attr-defined] .insert(body=body) @@ -272,9 +278,7 @@ def list_tasklists(self) -> list[tasklist.TaskList]: """ try: result = ( - self.service.tasklists() # type: ignore[attr-defined] - .list() - .execute() + self.service.tasklists().list().execute() # type: ignore[attr-defined] ) tasklists = [] for item in result.get("items", []): From 8ba88692e14d3ee63292f7e47a9baa1217be9d1f Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Wed, 29 Oct 2025 16:35:21 -0400 Subject: [PATCH 05/33] chore: add template task_client_service dir and integrate with root pyproject --- pyproject.toml | 1 + src/task_client_service/README.md | 0 src/task_client_service/pyproject.toml | 7 +++++++ src/task_client_service/src/__init__.py | 0 uv.lock | 6 ++++++ 5 files changed, 14 insertions(+) create mode 100644 src/task_client_service/README.md create mode 100644 src/task_client_service/pyproject.toml create mode 100644 src/task_client_service/src/__init__.py diff --git a/pyproject.toml b/pyproject.toml index c371db29..fc64b3f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ members = [ "src/mail_client_service_client", "src/task_client_api", "src/gtask_client_impl", + "src/task_client_service", ] [tool.ruff] diff --git a/src/task_client_service/README.md b/src/task_client_service/README.md new file mode 100644 index 00000000..e69de29b diff --git a/src/task_client_service/pyproject.toml b/src/task_client_service/pyproject.toml new file mode 100644 index 00000000..162ef895 --- /dev/null +++ b/src/task_client_service/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "task-client-service" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] diff --git a/src/task_client_service/src/__init__.py b/src/task_client_service/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/uv.lock b/uv.lock index 74d78582..e17bda06 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,7 @@ members = [ "mail-client-service-client", "ta-assignment", "task-client-api", + "task-client-service", ] [[package]] @@ -1401,6 +1402,11 @@ name = "task-client-api" version = "0.1.0" source = { editable = "src/task_client_api" } +[[package]] +name = "task-client-service" +version = "0.1.0" +source = { virtual = "src/task_client_service" } + [[package]] name = "tomli" version = "2.2.1" From b998c77ea73a3784baaa4115cc081c6ba77a5945 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Wed, 29 Oct 2025 16:36:00 -0400 Subject: [PATCH 06/33] feat: implement base fastapi service with get and create tasklists --- .../src/fast_api_service.py | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/task_client_service/src/fast_api_service.py diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py new file mode 100644 index 00000000..b549c7a2 --- /dev/null +++ b/src/task_client_service/src/fast_api_service.py @@ -0,0 +1,121 @@ +"""FastAPI service for task client operations.""" + +import logging +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Annotated, cast + +import gtask_client_impl # noqa: F401 +from fastapi import Body, Depends, FastAPI, HTTPException, Request +from task_client_api import Client, Task, TaskList, get_client + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Manage application lifespan and initialize mail client.""" + logger.info("Starting Task Client Service...") + try: + client = get_client(interactive=False) + app.state.task_client = client + logger.info("Task client initialized successfully") + yield + except Exception as e: + logger.error(f"Failed to initialize task client: {e}") + raise + finally: + logger.info("Shutting down Task Client Service...") + + +app = FastAPI( + title="Task Client Service", + description="REST API for task client operations.", + lifespan=lifespan, +) + + +# --- Dependency: obtain the task client --- +def get_task_client(request: Request) -> Client: + """Get the already constructed task client.""" + return cast("Client", request.app.state.task_client) + + +# --- Define a type alias for reuse (from FastAPI docs) --- +TaskClientDep = Annotated[Client, Depends(get_task_client)] + +""" +TODO: +implement these: + - def list_tasklists(self) -> list[tasklist.TaskList]: + - delete_tasklist + - list_tasks + - insert_task + - delete_task + - get_task + +Done: + - def list_tasklists(self) -> list[tasklist.TaskList]: + + +""" + + +def format_tasklist_object(tasklist: TaskList) -> dict[str, str]: + """Format a TaskList object into a JSON-serializable dictionary.""" + return { + "id": tasklist.id, + "title": tasklist.title, + "etag": tasklist.etag, + "updated": tasklist.updated, + "self_link": tasklist.self_link, + } + + +@app.get("/tasklists") +async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: + """Get a list of messages from the mail client.""" + logger.info("Received request to list tasklists") + try: + tasklists = client.list_tasklists() + logger.info(f"Retrieved {len(tasklists)} tasklists") + + formatted_tasklists: list[dict[str, str]] = [] + + for tasklist in tasklists: + formatted_tasklists.append(format_tasklist_object(tasklist)) + + logger.info(f"Successfully formatted {len(formatted_tasklists)} tasklists") + return formatted_tasklists + except Exception as e: + logger.error(f"Error listing tasklists: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@app.post("/tasklists") +async def insert_tasklist( + client: TaskClientDep, + title: str = Body(..., example="My New Task List"), +) -> dict[str, str]: + """Insert a new tasklist.""" + logger.info(f"Received request to insert tasklist with title: '{title}'") + try: + new_tasklist = client.insert_tasklist(title) + logger.info(f"Successfully created tasklist with ID: {new_tasklist.id}") + + return format_tasklist_object(new_tasklist) + except Exception as e: + logger.error(f"Error inserting tasklist '{title}': {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +if __name__ == "__main__": + import uvicorn + + logger.info("Starting FastAPI server...") + uvicorn.run("fast_api_service:app", host="0.0.0.0", port=8000, reload=True) From 6b5ba8576c45f305a02082bafb9736f83762f3cb Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Wed, 29 Oct 2025 17:39:35 -0400 Subject: [PATCH 07/33] feat: impl delete tasklist and change param in gtask impl to str --- .../src/fast_api_service.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index b549c7a2..02f87355 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -52,7 +52,6 @@ def get_task_client(request: Request) -> Client: """ TODO: implement these: - - def list_tasklists(self) -> list[tasklist.TaskList]: - delete_tasklist - list_tasks - insert_task @@ -61,6 +60,7 @@ def get_task_client(request: Request) -> Client: Done: - def list_tasklists(self) -> list[tasklist.TaskList]: + - def list_tasklists(self) -> list[tasklist.TaskList]: """ @@ -114,6 +114,48 @@ async def insert_tasklist( raise HTTPException(status_code=500, detail=str(e)) from e +@app.delete("/tasklists/{tasklist_id}") +async def delete_tasklist( + client: TaskClientDep, + tasklist_id: str, +) -> dict[str, str]: + """Delete a tasklist.""" + logger.info(f"Received request to delete tasklist with ID: '{tasklist_id}'") + try: + # Find the tasklist to delete + target_tasklist: TaskList | None = None + tasklists: list[TaskList] = client.list_tasklists() + + if tasklists[0].id == tasklist_id: + raise HTTPException( + status_code=400, + detail=f"Error: Invalid request cannot delete default tasklist", + ) + + # Check if the tasklist exists + for tasklist in tasklists: + if tasklist.id == tasklist_id: + target_tasklist = tasklist + break + + if not target_tasklist: + raise HTTPException( + status_code=404, detail=f"Error: Tasklist '{tasklist_id}' not found" + ) + + success = client.delete_tasklist(tasklist_id) + + if not success: + raise Exception + + logger.info(f"Successfully deleted tasklist with ID: {tasklist_id}") + + return {"detail": f"Tasklist '{target_tasklist.id}' deleted."} + except Exception as e: + logger.error(f"Error deleting tasklist '{tasklist_id}': {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + if __name__ == "__main__": import uvicorn From badc9b2555936402ab959310efcd08cc45e7bda2 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 00:55:59 -0400 Subject: [PATCH 08/33] feat: add list tasks --- .../src/fast_api_service.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index 02f87355..bfb7be76 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -52,7 +52,6 @@ def get_task_client(request: Request) -> Client: """ TODO: implement these: - - delete_tasklist - list_tasks - insert_task - delete_task @@ -61,6 +60,7 @@ def get_task_client(request: Request) -> Client: Done: - def list_tasklists(self) -> list[tasklist.TaskList]: - def list_tasklists(self) -> list[tasklist.TaskList]: + - delete_tasklist """ @@ -97,6 +97,20 @@ async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: raise HTTPException(status_code=500, detail=str(e)) from e +@app.get("/tasks/{tasklist_id}") +async def list_tasks(client: TaskClientDep, tasklist_id: str) -> list[dict[str, str]]: + """Get a list of messages from the mail client.""" + logger.info("Received request to list tasklists") + try: + tasklist = client.get_tasklist(tasklist_id) + + client.list_tasks(tasklist) + + except Exception as e: + logger.error(f"Error listing tasklists: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + @app.post("/tasklists") async def insert_tasklist( client: TaskClientDep, @@ -109,6 +123,9 @@ async def insert_tasklist( logger.info(f"Successfully created tasklist with ID: {new_tasklist.id}") return format_tasklist_object(new_tasklist) + except ValueError as e: + logger.error(f"Conflict: Tasklist with title '{title}' already exists") + raise HTTPException(status_code=409, detail=str(e)) from e except Exception as e: logger.error(f"Error inserting tasklist '{title}': {e}") raise HTTPException(status_code=500, detail=str(e)) from e From 02ea8e70b88ca15ac80be65308e608ba2d3082d6 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 11:55:14 -0400 Subject: [PATCH 09/33] feat: fix list tasks sync with gtask impl and create task to dict func --- .../src/fast_api_service.py | 73 +++++++++++++------ 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index bfb7be76..116c2f90 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -5,8 +5,9 @@ from contextlib import asynccontextmanager from typing import Annotated, cast -import gtask_client_impl # noqa: F401 from fastapi import Body, Depends, FastAPI, HTTPException, Request + +import gtask_client_impl # noqa: F401 from task_client_api import Client, Task, TaskList, get_client # Configure logging @@ -27,7 +28,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.info("Task client initialized successfully") yield except Exception as e: - logger.error(f"Failed to initialize task client: {e}") + logger.critical(e, exc_info=True) raise finally: logger.info("Shutting down Task Client Service...") @@ -66,8 +67,8 @@ def get_task_client(request: Request) -> Client: """ -def format_tasklist_object(tasklist: TaskList) -> dict[str, str]: - """Format a TaskList object into a JSON-serializable dictionary.""" +def tasklist_to_dict(tasklist: TaskList) -> dict[str, str]: + """Convert a TaskList object to a JSON-serializable dictionary.""" return { "id": tasklist.id, "title": tasklist.title, @@ -77,57 +78,79 @@ def format_tasklist_object(tasklist: TaskList) -> dict[str, str]: } +def task_to_dict(task: Task) -> dict[str, str | None | bool]: + """Convert a Task object to a JSON-serializable dictionary.""" + return { + "id": task.id, + "title": task.title, + "notes": task.notes, + "status": task.status, + "due": task.due, + "completed": task.completed, + "deleted": task.deleted, + "hidden": task.hidden, + } + + @app.get("/tasklists") async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: """Get a list of messages from the mail client.""" logger.info("Received request to list tasklists") try: tasklists = client.list_tasklists() - logger.info(f"Retrieved {len(tasklists)} tasklists") + logger.info("Retrieved %d tasklists", len(tasklists)) formatted_tasklists: list[dict[str, str]] = [] - for tasklist in tasklists: - formatted_tasklists.append(format_tasklist_object(tasklist)) + formatted_tasklists = [tasklist_to_dict(tasklist) for tasklist in tasklists] - logger.info(f"Successfully formatted {len(formatted_tasklists)} tasklists") - return formatted_tasklists except Exception as e: - logger.error(f"Error listing tasklists: {e}") + logger.critical(e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) from e + else: + logger.info("Successfully formatted %d tasklists", len(formatted_tasklists)) + return formatted_tasklists @app.get("/tasks/{tasklist_id}") -async def list_tasks(client: TaskClientDep, tasklist_id: str) -> list[dict[str, str]]: +async def list_tasks( + client: TaskClientDep, tasklist_id: str +) -> list[dict[str, str | None | bool]]: """Get a list of messages from the mail client.""" logger.info("Received request to list tasklists") try: - tasklist = client.get_tasklist(tasklist_id) + tasks = client.list_tasks(tasklist_id) + + formatted_tasks: list[dict[str, str]] = [] + + formatted_tasks = [task_to_dict(task) for task in tasks] - client.list_tasks(tasklist) + return formatted_tasks except Exception as e: - logger.error(f"Error listing tasklists: {e}") + logger.critical(e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) from e @app.post("/tasklists") async def insert_tasklist( client: TaskClientDep, - title: str = Body(..., example="My New Task List"), + title: str = Annotated[str, Body(..., example="My New Task List")], ) -> dict[str, str]: """Insert a new tasklist.""" - logger.info(f"Received request to insert tasklist with title: '{title}'") + logger.info("Received request to insert tasklist with title: '%s'", title) try: new_tasklist = client.insert_tasklist(title) - logger.info(f"Successfully created tasklist with ID: {new_tasklist.id}") + logger.info("Successfully created tasklist with ID: %s", new_tasklist.id) - return format_tasklist_object(new_tasklist) + return tasklist_to_dict(new_tasklist) except ValueError as e: - logger.error(f"Conflict: Tasklist with title '{title}' already exists") + logger.critical( + "Conflict: Tasklist with title '%s' already exists", title, exc_info=True + ) raise HTTPException(status_code=409, detail=str(e)) from e except Exception as e: - logger.error(f"Error inserting tasklist '{title}': {e}") + logger.critical("Error inserting tasklist '%s': %s", title, e, exc_info=True) raise HTTPException(status_code=500, detail=str(e)) from e @@ -137,7 +160,7 @@ async def delete_tasklist( tasklist_id: str, ) -> dict[str, str]: """Delete a tasklist.""" - logger.info(f"Received request to delete tasklist with ID: '{tasklist_id}'") + logger.info("Received request to delete tasklist with ID: '%s'", tasklist_id) try: # Find the tasklist to delete target_tasklist: TaskList | None = None @@ -146,7 +169,7 @@ async def delete_tasklist( if tasklists[0].id == tasklist_id: raise HTTPException( status_code=400, - detail=f"Error: Invalid request cannot delete default tasklist", + detail="Error: Invalid request cannot delete default tasklist", ) # Check if the tasklist exists @@ -165,11 +188,13 @@ async def delete_tasklist( if not success: raise Exception - logger.info(f"Successfully deleted tasklist with ID: {tasklist_id}") + logger.info("Successfully deleted tasklist with ID: %s", tasklist_id) return {"detail": f"Tasklist '{target_tasklist.id}' deleted."} except Exception as e: - logger.error(f"Error deleting tasklist '{tasklist_id}': {e}") + logger.critical( + "Error deleting tasklist '%s': %s", tasklist_id, e, exc_info=True + ) raise HTTPException(status_code=500, detail=str(e)) from e From 31c015721e06189dd4a3efa8855ee22f330c39ee Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 12:08:10 -0400 Subject: [PATCH 10/33] feat: organize task and tasklist methods in fastapi file --- .../src/fast_api_service.py | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index 116c2f90..16f184ef 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -10,7 +10,6 @@ import gtask_client_impl # noqa: F401 from task_client_api import Client, Task, TaskList, get_client -# Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", @@ -53,7 +52,6 @@ def get_task_client(request: Request) -> Client: """ TODO: implement these: - - list_tasks - insert_task - delete_task - get_task @@ -62,7 +60,7 @@ def get_task_client(request: Request) -> Client: - def list_tasklists(self) -> list[tasklist.TaskList]: - def list_tasklists(self) -> list[tasklist.TaskList]: - delete_tasklist - + - list_tasks """ @@ -112,26 +110,6 @@ async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: return formatted_tasklists -@app.get("/tasks/{tasklist_id}") -async def list_tasks( - client: TaskClientDep, tasklist_id: str -) -> list[dict[str, str | None | bool]]: - """Get a list of messages from the mail client.""" - logger.info("Received request to list tasklists") - try: - tasks = client.list_tasks(tasklist_id) - - formatted_tasks: list[dict[str, str]] = [] - - formatted_tasks = [task_to_dict(task) for task in tasks] - - return formatted_tasks - - except Exception as e: - logger.critical(e, exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) from e - - @app.post("/tasklists") async def insert_tasklist( client: TaskClientDep, @@ -198,6 +176,31 @@ async def delete_tasklist( raise HTTPException(status_code=500, detail=str(e)) from e +# --- TASK OPERATIONS --- + + +@app.get("/tasks/{tasklist_id}") +async def list_tasks( + client: TaskClientDep, tasklist_id: str +) -> list[dict[str, str | None | bool]]: + """Get a list of messages from the mail client.""" + logger.info("Received request to list tasklists") + try: + tasks = client.list_tasks(tasklist_id) + + formatted_tasks: list[dict[str, str]] = [] + + formatted_tasks = [task_to_dict(task) for task in tasks] + + return formatted_tasks + + except Exception as e: + logger.critical(e, exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) from e + + # - insert_task + + if __name__ == "__main__": import uvicorn From 09e99d8d6b6bf4078b5e8968d6e064cfb2db1b58 Mon Sep 17 00:00:00 2001 From: Ruimeng Chen Date: Thu, 30 Oct 2025 13:12:03 -0400 Subject: [PATCH 11/33] Add delete_task and get_task methods --- .../src/fast_api_service.py | 69 +++++++++++++++---- 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index 16f184ef..35297cff 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -178,29 +178,68 @@ async def delete_tasklist( # --- TASK OPERATIONS --- - -@app.get("/tasks/{tasklist_id}") -async def list_tasks( - client: TaskClientDep, tasklist_id: str -) -> list[dict[str, str | None | bool]]: - """Get a list of messages from the mail client.""" - logger.info("Received request to list tasklists") +# Get a specific task by ID from a tasklist +@app.get("/tasks/{tasklist_id}/{task_id}") +async def get_task( + client: TaskClientDep, + tasklist_id: str, + task_id: str, +) -> dict[str, str | None | bool]: + logger.info("Work to get task '%s' from tasklist '%s'", task_id, tasklist_id) try: - tasks = client.list_tasks(tasklist_id) - - formatted_tasks: list[dict[str, str]] = [] - - formatted_tasks = [task_to_dict(task) for task in tasks] - - return formatted_tasks + task = client.get_task(tasklist_id, task_id) + logger.info("Successfully got task '%s' from tasklist '%s'", task_id, tasklist_id) + return { + "id": task.id, + "title": task.title, + "notes": task.notes, + "status": task.status, + "due": task.due, + "completed": task.completed, + "deleted": task.deleted, + "hidden": task.hidden, + } + except Exception as e: + logger.critical( + "Error getting task '%s' from tasklist '%s': %s", + task_id, + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) from e +# Detele a task by ID from a specific tasklist +@app.delete("/tasks/{tasklist_id}/{task_id}") +async def delete_task( + client: TaskClientDep, + tasklist_id: str, + task_id: str, +) -> dict[str, str]: + logger.info("Work to delete task '%s' from tasklist '%s'", task_id, tasklist_id) + try: + success = client.delete_task(tasklist_id, task_id) + if not success: + raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found") + logger.info("Successfully deleted task '%s' from tasklist '%s'", task_id, tasklist_id) + return {"detail": f"Task '{task_id}' deleted from tasklist '{tasklist_id}'."} + except HTTPException: + raise except Exception as e: - logger.critical(e, exc_info=True) + logger.critical( + "Error deleting task '%s' from tasklist '%s': %s", + task_id, + tasklist_id, + e, + exc_info=True, + ) raise HTTPException(status_code=500, detail=str(e)) from e # - insert_task + + if __name__ == "__main__": import uvicorn From e68a0360dabe763f46051f3af08091f797564cef Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 13:12:01 -0400 Subject: [PATCH 12/33] refactor: change insert task param from task to dict[str,str|bool], add due date datetime format validation --- .../src/gtask_client_impl/gtask_impl.py | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index 6d5eea33..56598c93 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -14,6 +14,7 @@ import os from pathlib import Path from typing import ClassVar +from datetime import datetime import task_client_api from google.auth.exceptions import GoogleAuthError, RefreshError @@ -331,9 +332,7 @@ def list_tasks(self, tasklist_id: str) -> list[task.Task]: return tasks def insert_task( - self, - tasklist_id: str, - task: task.Task + self, tasklist_id: str, task_input: dict[str, str | bool | None] ) -> task.Task: """Insert a task into a tasklist. @@ -346,30 +345,33 @@ def insert_task( """ try: - body: dict[str, str | None] = { - "title": task.title, - } - if task.notes: - body["notes"] = task.notes - if task.status: - body["status"] = task.status - if task.due: - body["due"] = task.due - + if task_input["due"]: + due_value = task_input["due"] + if isinstance(due_value, str): + try: + datetime.fromisoformat(due_value.replace("Z", "+00:00")) + except ValueError: + raise ValueError( + f"Invalid RFC 3339 timestamp format: {due_value}" + ) + else: + task_input["due"] = due_value result = ( self.service.tasks() # type: ignore[attr-defined] - .insert(tasklist=tasklist_id, body=body) + .insert(tasklist=tasklist_id, body=task_input) .execute() ) raw_data = json.dumps(result) - return task_client_api.task.get_task( - task_id=result["id"], - raw_data=raw_data, - ) except (HttpError, OSError, ValueError) as e: self.logger.exception("Failed to insert task") self.logger.debug("Error details: %s", e) raise + else: + self.logger.info("Successfully created task with ID: %s", result["id"]) + return task_client_api.task.get_task( + task_id=result["id"], + raw_data=raw_data, + ) def delete_task(self, tasklist_id: str, task_id: str) -> bool: """Delete a task by its ID. From 85944b7f4775d92c0e33d98474c5446a6a375057 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 13:12:59 -0400 Subject: [PATCH 13/33] feat: impl insert task endpoint --- .../src/fast_api_service.py | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index 35297cff..ec517f71 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -49,21 +49,6 @@ def get_task_client(request: Request) -> Client: # --- Define a type alias for reuse (from FastAPI docs) --- TaskClientDep = Annotated[Client, Depends(get_task_client)] -""" -TODO: -implement these: - - insert_task - - delete_task - - get_task - -Done: - - def list_tasklists(self) -> list[tasklist.TaskList]: - - def list_tasklists(self) -> list[tasklist.TaskList]: - - delete_tasklist - - list_tasks - -""" - def tasklist_to_dict(tasklist: TaskList) -> dict[str, str]: """Convert a TaskList object to a JSON-serializable dictionary.""" @@ -168,7 +153,7 @@ async def delete_tasklist( logger.info("Successfully deleted tasklist with ID: %s", tasklist_id) - return {"detail": f"Tasklist '{target_tasklist.id}' deleted."} + return {"detail": f"Tasklist '{target_tasklist.title}' deleted."} except Exception as e: logger.critical( "Error deleting tasklist '%s': %s", tasklist_id, e, exc_info=True @@ -235,7 +220,42 @@ async def delete_task( ) raise HTTPException(status_code=500, detail=str(e)) from e - # - insert_task + +@app.post("/tasks/{tasklist_id}") +async def insert_task( + client: TaskClientDep, + tasklist_id: str, + task_input: dict[str, str | None | bool] = Body( + ..., + example={ + "title": "My New Task", + "notes": "This is a new task", + "status": "needsAction", + "due": "2025-11-15T00:00:00.000Z", + }, + ), +) -> dict[str, str | bool | None]: + """Insert a new task into a tasklist.""" + logger.info( + "Received request to insert task with title: '%s' into tasklist: '%s'", + task_input["title"], + tasklist_id, + ) + try: + new_task = client.insert_task(tasklist_id, task_input) + except Exception as e: + logger.critical( + "Error inserting task '%s' into tasklist '%s': %s", + task_input["title"], + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) from e + else: + + logger.info("Successfully created task with ID: %s", new_task.id) + return task_to_dict(new_task) From ee3ba7b7b2b6aa30b49b5a9b86c084b8601576ba Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 13:49:25 -0400 Subject: [PATCH 14/33] refactor: organize endpoints into routers task and tasklist --- src/task_client_service/src/dependencies.py | 16 ++ .../src/fast_api_service.py | 229 +----------------- src/task_client_service/src/task_router.py | 155 ++++++++++++ .../src/tasklist_router.py | 110 +++++++++ 4 files changed, 288 insertions(+), 222 deletions(-) create mode 100644 src/task_client_service/src/dependencies.py create mode 100644 src/task_client_service/src/task_router.py create mode 100644 src/task_client_service/src/tasklist_router.py diff --git a/src/task_client_service/src/dependencies.py b/src/task_client_service/src/dependencies.py new file mode 100644 index 00000000..113d4e09 --- /dev/null +++ b/src/task_client_service/src/dependencies.py @@ -0,0 +1,16 @@ +"""Shared dependencies for the FastAPI service.""" + +from typing import Annotated, cast + +from fastapi import Depends, Request + +from task_client_api import Client + + +def get_task_client(request: Request) -> Client: + """Get the already constructed task client.""" + return cast("Client", request.app.state.task_client) + + +# Define a type alias for reuse (from FastAPI docs) +TaskClientDep = Annotated[Client, Depends(get_task_client)] diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index ec517f71..0c353e7b 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -3,12 +3,13 @@ import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import Annotated, cast -from fastapi import Body, Depends, FastAPI, HTTPException, Request +from fastapi import FastAPI import gtask_client_impl # noqa: F401 -from task_client_api import Client, Task, TaskList, get_client +from task_client_api import get_client +from task_router import router as task_router +from tasklist_router import router as tasklist_router logging.basicConfig( level=logging.INFO, @@ -39,225 +40,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: lifespan=lifespan, ) - -# --- Dependency: obtain the task client --- -def get_task_client(request: Request) -> Client: - """Get the already constructed task client.""" - return cast("Client", request.app.state.task_client) - - -# --- Define a type alias for reuse (from FastAPI docs) --- -TaskClientDep = Annotated[Client, Depends(get_task_client)] - - -def tasklist_to_dict(tasklist: TaskList) -> dict[str, str]: - """Convert a TaskList object to a JSON-serializable dictionary.""" - return { - "id": tasklist.id, - "title": tasklist.title, - "etag": tasklist.etag, - "updated": tasklist.updated, - "self_link": tasklist.self_link, - } - - -def task_to_dict(task: Task) -> dict[str, str | None | bool]: - """Convert a Task object to a JSON-serializable dictionary.""" - return { - "id": task.id, - "title": task.title, - "notes": task.notes, - "status": task.status, - "due": task.due, - "completed": task.completed, - "deleted": task.deleted, - "hidden": task.hidden, - } - - -@app.get("/tasklists") -async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: - """Get a list of messages from the mail client.""" - logger.info("Received request to list tasklists") - try: - tasklists = client.list_tasklists() - logger.info("Retrieved %d tasklists", len(tasklists)) - - formatted_tasklists: list[dict[str, str]] = [] - - formatted_tasklists = [tasklist_to_dict(tasklist) for tasklist in tasklists] - - except Exception as e: - logger.critical(e, exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) from e - else: - logger.info("Successfully formatted %d tasklists", len(formatted_tasklists)) - return formatted_tasklists - - -@app.post("/tasklists") -async def insert_tasklist( - client: TaskClientDep, - title: str = Annotated[str, Body(..., example="My New Task List")], -) -> dict[str, str]: - """Insert a new tasklist.""" - logger.info("Received request to insert tasklist with title: '%s'", title) - try: - new_tasklist = client.insert_tasklist(title) - logger.info("Successfully created tasklist with ID: %s", new_tasklist.id) - - return tasklist_to_dict(new_tasklist) - except ValueError as e: - logger.critical( - "Conflict: Tasklist with title '%s' already exists", title, exc_info=True - ) - raise HTTPException(status_code=409, detail=str(e)) from e - except Exception as e: - logger.critical("Error inserting tasklist '%s': %s", title, e, exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) from e - - -@app.delete("/tasklists/{tasklist_id}") -async def delete_tasklist( - client: TaskClientDep, - tasklist_id: str, -) -> dict[str, str]: - """Delete a tasklist.""" - logger.info("Received request to delete tasklist with ID: '%s'", tasklist_id) - try: - # Find the tasklist to delete - target_tasklist: TaskList | None = None - tasklists: list[TaskList] = client.list_tasklists() - - if tasklists[0].id == tasklist_id: - raise HTTPException( - status_code=400, - detail="Error: Invalid request cannot delete default tasklist", - ) - - # Check if the tasklist exists - for tasklist in tasklists: - if tasklist.id == tasklist_id: - target_tasklist = tasklist - break - - if not target_tasklist: - raise HTTPException( - status_code=404, detail=f"Error: Tasklist '{tasklist_id}' not found" - ) - - success = client.delete_tasklist(tasklist_id) - - if not success: - raise Exception - - logger.info("Successfully deleted tasklist with ID: %s", tasklist_id) - - return {"detail": f"Tasklist '{target_tasklist.title}' deleted."} - except Exception as e: - logger.critical( - "Error deleting tasklist '%s': %s", tasklist_id, e, exc_info=True - ) - raise HTTPException(status_code=500, detail=str(e)) from e - - -# --- TASK OPERATIONS --- - -# Get a specific task by ID from a tasklist -@app.get("/tasks/{tasklist_id}/{task_id}") -async def get_task( - client: TaskClientDep, - tasklist_id: str, - task_id: str, -) -> dict[str, str | None | bool]: - logger.info("Work to get task '%s' from tasklist '%s'", task_id, tasklist_id) - try: - task = client.get_task(tasklist_id, task_id) - logger.info("Successfully got task '%s' from tasklist '%s'", task_id, tasklist_id) - return { - "id": task.id, - "title": task.title, - "notes": task.notes, - "status": task.status, - "due": task.due, - "completed": task.completed, - "deleted": task.deleted, - "hidden": task.hidden, - } - except Exception as e: - logger.critical( - "Error getting task '%s' from tasklist '%s': %s", - task_id, - tasklist_id, - e, - exc_info=True, - ) - raise HTTPException(status_code=500, detail=str(e)) from e - -# Detele a task by ID from a specific tasklist -@app.delete("/tasks/{tasklist_id}/{task_id}") -async def delete_task( - client: TaskClientDep, - tasklist_id: str, - task_id: str, -) -> dict[str, str]: - logger.info("Work to delete task '%s' from tasklist '%s'", task_id, tasklist_id) - try: - success = client.delete_task(tasklist_id, task_id) - if not success: - raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found") - logger.info("Successfully deleted task '%s' from tasklist '%s'", task_id, tasklist_id) - return {"detail": f"Task '{task_id}' deleted from tasklist '{tasklist_id}'."} - except HTTPException: - raise - except Exception as e: - logger.critical( - "Error deleting task '%s' from tasklist '%s': %s", - task_id, - tasklist_id, - e, - exc_info=True, - ) - raise HTTPException(status_code=500, detail=str(e)) from e - - -@app.post("/tasks/{tasklist_id}") -async def insert_task( - client: TaskClientDep, - tasklist_id: str, - task_input: dict[str, str | None | bool] = Body( - ..., - example={ - "title": "My New Task", - "notes": "This is a new task", - "status": "needsAction", - "due": "2025-11-15T00:00:00.000Z", - }, - ), -) -> dict[str, str | bool | None]: - """Insert a new task into a tasklist.""" - logger.info( - "Received request to insert task with title: '%s' into tasklist: '%s'", - task_input["title"], - tasklist_id, - ) - try: - new_task = client.insert_task(tasklist_id, task_input) - except Exception as e: - logger.critical( - "Error inserting task '%s' into tasklist '%s': %s", - task_input["title"], - tasklist_id, - e, - exc_info=True, - ) - raise HTTPException(status_code=500, detail=str(e)) from e - else: - - logger.info("Successfully created task with ID: %s", new_task.id) - return task_to_dict(new_task) - - +# Include routers +app.include_router(tasklist_router) +app.include_router(task_router) if __name__ == "__main__": diff --git a/src/task_client_service/src/task_router.py b/src/task_client_service/src/task_router.py new file mode 100644 index 00000000..e198be30 --- /dev/null +++ b/src/task_client_service/src/task_router.py @@ -0,0 +1,155 @@ +"""Router for task operations.""" + +import logging +from datetime import datetime + +from fastapi import APIRouter, Body, HTTPException + +from dependencies import TaskClientDep +from task_client_api import Task + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +def task_to_dict(task: Task) -> dict[str, str | None | bool]: + """Convert a Task object to a JSON-serializable dictionary.""" + return { + "id": task.id, + "title": task.title, + "notes": task.notes, + "status": task.status, + "due": task.due, + "completed": task.completed, + "deleted": task.deleted, + "hidden": task.hidden, + } + + +@router.get("/{tasklist_id}") +async def list_tasks( + client: TaskClientDep, + tasklist_id: str, +) -> list[dict[str, str | None | bool]]: + logger.info("Work to list tasks from tasklist '%s'", tasklist_id) + try: + tasks = client.list_tasks(tasklist_id) + except Exception as e: + logger.critical( + "Error listing tasks from tasklist '%s': %s", + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) from e + else: + logger.info("Successfully listed tasks from tasklist '%s'", tasklist_id) + return [task_to_dict(task) for task in tasks] + + +@router.get("/{tasklist_id}/{task_id}") +async def get_task( + client: TaskClientDep, + tasklist_id: str, + task_id: str, +) -> dict[str, str | None | bool]: + logger.info("Work to get task '%s' from tasklist '%s'", task_id, tasklist_id) + try: + task = client.get_task(tasklist_id, task_id) + except Exception as e: + logger.critical( + "Error getting task '%s' from tasklist '%s': %s", + task_id, + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) from e + else: + logger.info( + "Successfully got task '%s' from tasklist '%s'", task_id, tasklist_id + ) + return { + "id": task.id, + "title": task.title, + "notes": task.notes, + "status": task.status, + "due": task.due, + "completed": task.completed, + "deleted": task.deleted, + "hidden": task.hidden, + } + + +@router.post("/{tasklist_id}") +async def insert_task( + client: TaskClientDep, + tasklist_id: str, + task_input: dict[str, str | None | bool] = Body( + ..., + example={ + "title": "My New Task", + "notes": "This is a new task", + "status": "needsAction", + "due": "2025-11-15T00:00:00.000Z", + }, + ), +) -> dict[str, str | bool | None]: + """Insert a new task into a tasklist.""" + logger.info( + "Received request to insert task with title: '%s' into tasklist: '%s'", + task_input.get("title", "Unknown"), + tasklist_id, + ) + try: + if task_input.get("due"): + due_value = task_input["due"] + if isinstance(due_value, str): + try: + datetime.fromisoformat(due_value.replace("Z", "+00:00")) + except ValueError: + raise ValueError(f"Invalid RFC 3339 timestamp format: {due_value}") + + new_task = client.insert_task(tasklist_id, task_input) + except Exception as e: + logger.critical( + "Error inserting task '%s' into tasklist '%s': %s", + task_input.get("title", "Unknown"), + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) from e + else: + + logger.info("Successfully created task with ID: %s", new_task.id) + return task_to_dict(new_task) + + +@router.delete("/{tasklist_id}/{task_id}") +async def delete_task( + client: TaskClientDep, + tasklist_id: str, + task_id: str, +) -> dict[str, str]: + + logger.info("Work to delete task '%s' from tasklist '%s'", task_id, tasklist_id) + try: + client.delete_task(tasklist_id, task_id) + + except Exception as e: + logger.critical( + "Error deleting task '%s' from tasklist '%s': %s", + task_id, + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) from e + + else: + logger.info( + "Successfully deleted task '%s' from tasklist '%s'", task_id, tasklist_id + ) + return {"detail": f"Task '{task_id}' deleted from tasklist '{tasklist_id}'."} diff --git a/src/task_client_service/src/tasklist_router.py b/src/task_client_service/src/tasklist_router.py new file mode 100644 index 00000000..e10c5e01 --- /dev/null +++ b/src/task_client_service/src/tasklist_router.py @@ -0,0 +1,110 @@ +"""Router for tasklist operations.""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Body, HTTPException + +from dependencies import TaskClientDep +from task_client_api import TaskList + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tasklists", tags=["tasklists"]) + + +def tasklist_to_dict(tasklist: TaskList) -> dict[str, str]: + """Convert a TaskList object to a JSON-serializable dictionary.""" + return { + "id": tasklist.id, + "title": tasklist.title, + "etag": tasklist.etag, + "updated": tasklist.updated, + "self_link": tasklist.self_link, + } + + +@router.get("") +async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: + """Get a list of tasklists from the task client.""" + logger.info("Received request to list tasklists") + try: + tasklists = client.list_tasklists() + logger.info("Retrieved %d tasklists", len(tasklists)) + + formatted_tasklists: list[dict[str, str]] = [] + + formatted_tasklists = [tasklist_to_dict(tasklist) for tasklist in tasklists] + + except Exception as e: + logger.critical(e, exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) from e + else: + logger.info("Successfully formatted %d tasklists", len(formatted_tasklists)) + return formatted_tasklists + + +@router.post("") +async def insert_tasklist( + client: TaskClientDep, + title: str = Annotated[str, Body(..., example="My New Task List")], +) -> dict[str, str]: + """Insert a new tasklist.""" + logger.info("Received request to insert tasklist with title: '%s'", title) + try: + new_tasklist = client.insert_tasklist(title) + logger.info("Successfully created tasklist with ID: %s", new_tasklist.id) + + return tasklist_to_dict(new_tasklist) + except ValueError as e: + logger.critical( + "Conflict: Tasklist with title '%s' already exists", title, exc_info=True + ) + raise HTTPException(status_code=409, detail=str(e)) from e + except Exception as e: + logger.critical("Error inserting tasklist '%s': %s", title, e, exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.delete("/{tasklist_id}") +async def delete_tasklist( + client: TaskClientDep, + tasklist_id: str, +) -> dict[str, str]: + """Delete a tasklist.""" + logger.info("Received request to delete tasklist with ID: '%s'", tasklist_id) + try: + # Find the tasklist to delete + target_tasklist: TaskList | None = None + tasklists: list[TaskList] = client.list_tasklists() + + if tasklists[0].id == tasklist_id: + raise HTTPException( + status_code=400, + detail="Error: Invalid request cannot delete default tasklist", + ) + + # Check if the tasklist exists + for tasklist in tasklists: + if tasklist.id == tasklist_id: + target_tasklist = tasklist + break + + if not target_tasklist: + raise HTTPException( + status_code=404, detail=f"Error: Tasklist '{tasklist_id}' not found" + ) + + success = client.delete_tasklist(tasklist_id) + + if not success: + raise Exception + + logger.info("Successfully deleted tasklist with ID: %s", tasklist_id) + + return {"detail": f"Tasklist '{target_tasklist.title}' deleted."} + except Exception as e: + logger.critical( + "Error deleting tasklist '%s': %s", tasklist_id, e, exc_info=True + ) + raise HTTPException(status_code=500, detail=str(e)) from e From af7576e21cd40369c36d213f92dc40cf9bce3172 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 13:54:17 -0400 Subject: [PATCH 15/33] refactor: remove due field valdation from gtask impl insert_task --- .../src/gtask_client_impl/gtask_impl.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index 56598c93..154544d9 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -345,17 +345,6 @@ def insert_task( """ try: - if task_input["due"]: - due_value = task_input["due"] - if isinstance(due_value, str): - try: - datetime.fromisoformat(due_value.replace("Z", "+00:00")) - except ValueError: - raise ValueError( - f"Invalid RFC 3339 timestamp format: {due_value}" - ) - else: - task_input["due"] = due_value result = ( self.service.tasks() # type: ignore[attr-defined] .insert(tasklist=tasklist_id, body=task_input) From 552073fc0aff60a15469da17084fe6effc3b0e5f Mon Sep 17 00:00:00 2001 From: Ruimeng Chen Date: Thu, 30 Oct 2025 18:34:12 -0400 Subject: [PATCH 16/33] Fix ruff check errors --- .../src/gtask_client_impl/gtask_impl.py | 23 +++----- .../mail_client_service/fast_api_service.py | 2 +- src/task_client_service/src/__init__.py | 1 + .../src/fast_api_service.py | 8 +-- src/task_client_service/src/task_router.py | 58 ++++++++++++------- .../src/tasklist_router.py | 58 ++++++++----------- test_gtask.py | 8 +-- 7 files changed, 77 insertions(+), 81 deletions(-) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index 154544d9..fbdea368 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -14,7 +14,6 @@ import os from pathlib import Path from typing import ClassVar -from datetime import datetime import task_client_api from google.auth.exceptions import GoogleAuthError, RefreshError @@ -76,9 +75,7 @@ class GTaskClient(task_client_api.Client): ] FAILURE_TO_CRED = "Failed to obtain credentials. Please check your setup." - def __init__( - self, service: Resource | None = None, *, interactive: bool = False - ) -> None: + def __init__(self, service: Resource | None = None, *, interactive: bool = False) -> None: """Initialize the GTaskClient, handling authentication.""" self.logger = logging.getLogger(__name__) if service: @@ -126,9 +123,8 @@ def _run_interactive_flow(self, creds_path: str) -> Credentials | None: opening the user's browser to complete authentication with Google. """ if not Path(creds_path).exists(): - raise FileNotFoundError( - f"'{creds_path}' not found. Cannot run interactive auth." - ) # noqa: EM102 TRY003 + msg = f"'{creds_path}' not found. Cannot run interactive auth." + raise FileNotFoundError(msg) flow = InstalledAppFlow.from_client_secrets_file( creds_path, self.SCOPES, @@ -149,9 +145,7 @@ def _auth_from_env(self) -> Credentials | None: client_id = os.environ.get("TASKS_CLIENT_ID") client_secret = os.environ.get("TASKS_CLIENT_SECRET") refresh_token = os.environ.get("TASKS_REFRESH_TOKEN") - token_uri = os.environ.get( - "TASKS_TOKEN_URI", "https://oauth2.googleapis.com/token" - ) + token_uri = os.environ.get("TASKS_TOKEN_URI", "https://oauth2.googleapis.com/token") if not (client_id and client_secret and refresh_token): return None @@ -246,7 +240,7 @@ def insert_tasklist(self, title: str) -> tasklist.TaskList: """Insert a tasklist. Args: - tasklist: The tasklist to insert. + title: The name of the new tasklist. Returns: The inserted tasklist with updated fields (e.g., id, etag). @@ -331,14 +325,13 @@ def list_tasks(self, tasklist_id: str) -> list[task.Task]: else: return tasks - def insert_task( - self, tasklist_id: str, task_input: dict[str, str | bool | None] - ) -> task.Task: + def insert_task(self, tasklist_id: str, task_input: dict[str, str | bool | None]) -> task.Task: """Insert a task into a tasklist. Args: tasklist_id: The ID of the tasklist to insert the task into. - task: The task to insert. + task_input: Dictionary of task fields + (e.g., title, notes, status, due, parent, previous). Returns: The inserted task with updated fields. diff --git a/src/mail_client_service/src/mail_client_service/fast_api_service.py b/src/mail_client_service/src/mail_client_service/fast_api_service.py index 744927ca..5b07235f 100644 --- a/src/mail_client_service/src/mail_client_service/fast_api_service.py +++ b/src/mail_client_service/src/mail_client_service/fast_api_service.py @@ -12,7 +12,7 @@ @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Manage application lifespan and initialize mail client.""" - client = get_client(interactive=False) + client = get_client(interactive=True) app.state.mail_client = client yield diff --git a/src/task_client_service/src/__init__.py b/src/task_client_service/src/__init__.py index e69de29b..0b04b7fa 100644 --- a/src/task_client_service/src/__init__.py +++ b/src/task_client_service/src/__init__.py @@ -0,0 +1 @@ +"""Package init file for task_client_service.""" diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index 0c353e7b..b0165032 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -5,11 +5,11 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from task_router import router as task_router +from tasklist_router import router as tasklist_router import gtask_client_impl # noqa: F401 from task_client_api import get_client -from task_router import router as task_router -from tasklist_router import router as tasklist_router logging.basicConfig( level=logging.INFO, @@ -23,7 +23,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Manage application lifespan and initialize mail client.""" logger.info("Starting Task Client Service...") try: - client = get_client(interactive=False) + client = get_client(interactive=True) app.state.task_client = client logger.info("Task client initialized successfully") yield @@ -49,4 +49,4 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: import uvicorn logger.info("Starting FastAPI server...") - uvicorn.run("fast_api_service:app", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("fast_api_service:app", host="0.0.0.0", port=8000, reload=True) # noqa: S104 diff --git a/src/task_client_service/src/task_router.py b/src/task_client_service/src/task_router.py index e198be30..86cdd84b 100644 --- a/src/task_client_service/src/task_router.py +++ b/src/task_client_service/src/task_router.py @@ -2,10 +2,11 @@ import logging from datetime import datetime +from typing import Annotated, NotRequired, Required, TypedDict +from dependencies import TaskClientDep from fastapi import APIRouter, Body, HTTPException -from dependencies import TaskClientDep from task_client_api import Task logger = logging.getLogger(__name__) @@ -32,6 +33,7 @@ async def list_tasks( client: TaskClientDep, tasklist_id: str, ) -> list[dict[str, str | None | bool]]: + """List all tasks under a tasklist.""" logger.info("Work to list tasks from tasklist '%s'", tasklist_id) try: tasks = client.list_tasks(tasklist_id) @@ -54,6 +56,7 @@ async def get_task( tasklist_id: str, task_id: str, ) -> dict[str, str | None | bool]: + """Get a task by ID under a tasklist.""" logger.info("Work to get task '%s' from tasklist '%s'", task_id, tasklist_id) try: task = client.get_task(tasklist_id, task_id) @@ -67,9 +70,7 @@ async def get_task( ) raise HTTPException(status_code=500, detail=str(e)) from e else: - logger.info( - "Successfully got task '%s' from tasklist '%s'", task_id, tasklist_id - ) + logger.info("Successfully got task '%s' from tasklist '%s'", task_id, tasklist_id) return { "id": task.id, "title": task.title, @@ -86,15 +87,20 @@ async def get_task( async def insert_task( client: TaskClientDep, tasklist_id: str, - task_input: dict[str, str | None | bool] = Body( - ..., - example={ - "title": "My New Task", - "notes": "This is a new task", - "status": "needsAction", - "due": "2025-11-15T00:00:00.000Z", - }, - ), + task_input: Annotated[ + dict[str, str | None | bool], + Body( + examples=[{ + "summary": "Basic", + "value": { + "title": "My New Task", + "notes": "This is a new task", + "status": "needsAction", + "due": "2025-11-15T00:00:00.000Z", + }, + }] + ), + ], ) -> dict[str, str | bool | None]: """Insert a new task into a tasklist.""" logger.info( @@ -107,9 +113,11 @@ async def insert_task( due_value = task_input["due"] if isinstance(due_value, str): try: - datetime.fromisoformat(due_value.replace("Z", "+00:00")) - except ValueError: - raise ValueError(f"Invalid RFC 3339 timestamp format: {due_value}") + # Let it raise if 'Z' is not accepted; we just validate format. + datetime.fromisoformat(due_value) + except ValueError as err: + msg = f"Invalid RFC 3339 timestamp format: {due_value}" + raise ValueError(msg) from err new_task = client.insert_task(tasklist_id, task_input) except Exception as e: @@ -122,7 +130,6 @@ async def insert_task( ) raise HTTPException(status_code=500, detail=str(e)) from e else: - logger.info("Successfully created task with ID: %s", new_task.id) return task_to_dict(new_task) @@ -133,7 +140,7 @@ async def delete_task( tasklist_id: str, task_id: str, ) -> dict[str, str]: - + """Delete a task by ID under a tasklist.""" logger.info("Work to delete task '%s' from tasklist '%s'", task_id, tasklist_id) try: client.delete_task(tasklist_id, task_id) @@ -149,7 +156,16 @@ async def delete_task( raise HTTPException(status_code=500, detail=str(e)) from e else: - logger.info( - "Successfully deleted task '%s' from tasklist '%s'", task_id, tasklist_id - ) + logger.info("Successfully deleted task '%s' from tasklist '%s'", task_id, tasklist_id) return {"detail": f"Task '{task_id}' deleted from tasklist '{tasklist_id}'."} + + +class CreateTaskBody(TypedDict): + """Schema for creating a new task.""" + + title: Required[str] + notes: NotRequired[str | None] + status: NotRequired[str | None] # "needsAction" | "completed" + due: NotRequired[str | None] # RFC3339 timestamp + parent: NotRequired[str | None] + previous: NotRequired[str | None] diff --git a/src/task_client_service/src/tasklist_router.py b/src/task_client_service/src/tasklist_router.py index e10c5e01..28d58955 100644 --- a/src/task_client_service/src/tasklist_router.py +++ b/src/task_client_service/src/tasklist_router.py @@ -3,9 +3,9 @@ import logging from typing import Annotated +from dependencies import TaskClientDep from fastapi import APIRouter, Body, HTTPException -from dependencies import TaskClientDep from task_client_api import TaskList logger = logging.getLogger(__name__) @@ -57,9 +57,7 @@ async def insert_tasklist( return tasklist_to_dict(new_tasklist) except ValueError as e: - logger.critical( - "Conflict: Tasklist with title '%s' already exists", title, exc_info=True - ) + logger.critical("Conflict: Tasklist with title '%s' already exists", title, exc_info=True) raise HTTPException(status_code=409, detail=str(e)) from e except Exception as e: logger.critical("Error inserting tasklist '%s': %s", title, e, exc_info=True) @@ -71,40 +69,32 @@ async def delete_tasklist( client: TaskClientDep, tasklist_id: str, ) -> dict[str, str]: - """Delete a tasklist.""" - logger.info("Received request to delete tasklist with ID: '%s'", tasklist_id) - try: - # Find the tasklist to delete - target_tasklist: TaskList | None = None - tasklists: list[TaskList] = client.list_tasklists() - - if tasklists[0].id == tasklist_id: - raise HTTPException( - status_code=400, - detail="Error: Invalid request cannot delete default tasklist", - ) - - # Check if the tasklist exists - for tasklist in tasklists: - if tasklist.id == tasklist_id: - target_tasklist = tasklist - break - - if not target_tasklist: - raise HTTPException( - status_code=404, detail=f"Error: Tasklist '{tasklist_id}' not found" - ) - - success = client.delete_tasklist(tasklist_id) + """Delete a tasklist by ID.""" + tasklists = client.list_tasklists() - if not success: - raise Exception + if tasklists and tasklists[0].id == tasklist_id: + raise HTTPException( + status_code=400, + detail="Error: Invalid request cannot delete default tasklist", + ) - logger.info("Successfully deleted tasklist with ID: %s", tasklist_id) + target_tasklist = next((tl for tl in tasklists if tl.id == tasklist_id), None) + if not target_tasklist: + raise HTTPException( + status_code=404, + detail=f"Error: Tasklist '{tasklist_id}' not found", + ) - return {"detail": f"Tasklist '{target_tasklist.title}' deleted."} + try: + success = client.delete_tasklist(tasklist_id) except Exception as e: logger.critical( "Error deleting tasklist '%s': %s", tasklist_id, e, exc_info=True ) - raise HTTPException(status_code=500, detail=str(e)) from e + raise HTTPException(status_code=500, detail="Failed to delete tasklist.") from e + + if not success: + raise HTTPException(status_code=500, detail="Failed to delete tasklist.") + + logger.info("Successfully deleted tasklist with ID: %s", tasklist_id) + return {"detail": f"Tasklist '{target_tasklist.title}' deleted."} diff --git a/test_gtask.py b/test_gtask.py index 782dde0d..5bb961ad 100644 --- a/test_gtask.py +++ b/test_gtask.py @@ -104,13 +104,9 @@ def main() -> None: # noqa: C901 # Just a test script try: # Recreate the task object with the stored data raw_data_reinsert = json.dumps(stored_task_data) - task_to_reinsert = task_client_api.task.get_task( - task_id="temp", raw_data=raw_data_reinsert - ) + task_to_reinsert = task_client_api.task.get_task(task_id="temp", raw_data=raw_data_reinsert) # Reinsert the task - reinserted_task = client.insert_task( - test_tasklist.id, task_to_reinsert - ) + reinserted_task = client.insert_task(test_tasklist.id, task_to_reinsert) logger.info( "Reinserted task: '%s' (ID: %s)", reinserted_task.title, From f13106b856c7d4df52a8d25129d4019d64414e75 Mon Sep 17 00:00:00 2001 From: Ruimeng Chen Date: Thu, 30 Oct 2025 20:37:25 -0400 Subject: [PATCH 17/33] Fix mypy errors --- main.py | 2 +- .../src/gtask_client_impl/gtask_impl.py | 32 +++++++++++++--- .../src/fast_api_service.py | 5 ++- src/task_client_service/src/task_router.py | 38 +++++++++++++++++-- .../src/tasklist_router.py | 37 +++++++++++++++--- 5 files changed, 96 insertions(+), 18 deletions(-) diff --git a/main.py b/main.py index 48d5cff1..5ac72180 100644 --- a/main.py +++ b/main.py @@ -15,7 +15,7 @@ def main() -> None: """Initialize the client and demonstrate all mail client methods.""" # Now, get_client() returns a GmailClient instance... - client = mail_client_api.get_client(interactive=False) + client = mail_client_api.get_client(interactive=True) # Test 1: Get messages (existing functionality) messages = list(client.get_messages(max_results=3)) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index fbdea368..14cf8f05 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -236,18 +236,18 @@ def delete_tasklist(self, tasklist_id: str) -> bool: else: return True - def insert_tasklist(self, title: str) -> tasklist.TaskList: + def insert_tasklist(self, tl: tasklist.TaskList) -> tasklist.TaskList: """Insert a tasklist. Args: - title: The name of the new tasklist. + tl: TaskList carrying the title to create. Returns: - The inserted tasklist with updated fields (e.g., id, etag). + The created TaskList as returned by the API. """ try: - body = {"title": title} + body = {"title": tl.title} result = ( self.service.tasklists() # type: ignore[attr-defined] .insert(body=body) @@ -325,7 +325,27 @@ def list_tasks(self, tasklist_id: str) -> list[task.Task]: else: return tasks - def insert_task(self, tasklist_id: str, task_input: dict[str, str | bool | None]) -> task.Task: + def insert_task(self, tasklist_id: str, t: task.Task) -> task.Task: + """Insert a task into a tasklist. + + Args: + tasklist_id: ID of the target tasklist. + t: Task data to create. + + Returns: + The created Task as returned by the API. + + """ + body = { + "title": t.title, + "notes": getattr(t, "notes", None), + "due": getattr(t, "due", None), + "completed": getattr(t, "completed", None), + "status": getattr(t, "status", None), + "deleted": getattr(t, "deleted", None), + "hidden": getattr(t, "hidden", None), + "parent": getattr(t, "parent", None), + } """Insert a task into a tasklist. Args: @@ -340,7 +360,7 @@ def insert_task(self, tasklist_id: str, task_input: dict[str, str | bool | None] try: result = ( self.service.tasks() # type: ignore[attr-defined] - .insert(tasklist=tasklist_id, body=task_input) + .insert(tasklist=tasklist_id, body=body) .execute() ) raw_data = json.dumps(result) diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index b0165032..3838397b 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -5,12 +5,13 @@ from contextlib import asynccontextmanager from fastapi import FastAPI -from task_router import router as task_router -from tasklist_router import router as tasklist_router import gtask_client_impl # noqa: F401 from task_client_api import get_client +from .task_router import router as task_router +from .tasklist_router import router as tasklist_router + logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", diff --git a/src/task_client_service/src/task_router.py b/src/task_client_service/src/task_router.py index 86cdd84b..79556f67 100644 --- a/src/task_client_service/src/task_router.py +++ b/src/task_client_service/src/task_router.py @@ -1,18 +1,30 @@ """Router for task operations.""" import logging +from dataclasses import dataclass from datetime import datetime -from typing import Annotated, NotRequired, Required, TypedDict +from typing import Annotated, Any, NotRequired, Required, TypedDict -from dependencies import TaskClientDep from fastapi import APIRouter, Body, HTTPException from task_client_api import Task +from .dependencies import TaskClientDep + logger = logging.getLogger(__name__) router = APIRouter(prefix="/tasks", tags=["tasks"]) +@dataclass +class _NewTask: + id: str | None + title: str + notes: str | None = None + due: str | None = None + completed: str | None = None + status: str | None = None + deleted: bool | None = None + hidden: bool | None = None def task_to_dict(task: Task) -> dict[str, str | None | bool]: """Convert a Task object to a JSON-serializable dictionary.""" @@ -82,6 +94,15 @@ async def get_task( "hidden": task.hidden, } +def _get_str(d: dict[str, Any], key: str, default: str | None = None) -> str | None: + v = d.get(key) + if isinstance(v, str): + return v + return default + +def _get_bool(d: dict[str, Any], key: str) -> bool | None: + v = d.get(key) + return v if isinstance(v, bool) else None @router.post("/{tasklist_id}") async def insert_task( @@ -119,7 +140,18 @@ async def insert_task( msg = f"Invalid RFC 3339 timestamp format: {due_value}" raise ValueError(msg) from err - new_task = client.insert_task(tasklist_id, task_input) + new_task_obj = _NewTask( + id=None, + title=_get_str(task_input, "title", "") or "", + notes=_get_str(task_input, "notes"), + due=_get_str(task_input, "due"), + completed=_get_str(task_input, "completed"), + status=_get_str(task_input, "status"), + deleted=_get_bool(task_input, "deleted"), + hidden=_get_bool(task_input, "hidden"), + ) + # _NewTask is not the exact nominal type of tasks_api.Task, so silence type check here + new_task = client.insert_task(tasklist_id, new_task_obj) # type: ignore[arg-type] except Exception as e: logger.critical( "Error inserting task '%s' into tasklist '%s': %s", diff --git a/src/task_client_service/src/tasklist_router.py b/src/task_client_service/src/tasklist_router.py index 28d58955..8d41056e 100644 --- a/src/task_client_service/src/tasklist_router.py +++ b/src/task_client_service/src/tasklist_router.py @@ -1,17 +1,24 @@ """Router for tasklist operations.""" import logging -from typing import Annotated +from typing import Annotated, Any, TypedDict -from dependencies import TaskClientDep from fastapi import APIRouter, Body, HTTPException from task_client_api import TaskList +from task_client_api import tasklist as tasklist_model + +from .dependencies import TaskClientDep logger = logging.getLogger(__name__) router = APIRouter(prefix="/tasklists", tags=["tasklists"]) +class CreateTaskListBody(TypedDict): + """Body schema for creating a tasklist.""" + + title: str + def tasklist_to_dict(tasklist: TaskList) -> dict[str, str]: """Convert a TaskList object to a JSON-serializable dictionary.""" @@ -43,18 +50,36 @@ async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: logger.info("Successfully formatted %d tasklists", len(formatted_tasklists)) return formatted_tasklists +def _get_str(d: dict[str, Any], key: str, default: str | None = None) -> str | None: + v = d.get(key) + if isinstance(v, str): + return v + return default + +def _get_bool(d: dict[str, Any], key: str) -> bool | None: + v = d.get(key) + return v if isinstance(v, bool) else None @router.post("") async def insert_tasklist( client: TaskClientDep, - title: str = Annotated[str, Body(..., example="My New Task List")], + body: Annotated[ + CreateTaskListBody, + Body( + examples=[{ + "summary": "Basic", + "value": {"title": "My New Task List"}, + }] + ), + ], ) -> dict[str, str]: - """Insert a new tasklist.""" + """Create a new tasklist.""" + title = body["title"] logger.info("Received request to insert tasklist with title: '%s'", title) try: - new_tasklist = client.insert_tasklist(title) + tl = tasklist_model.get_tasklist(task_list_id="", raw_data='{"title": "' + title + '"}') + new_tasklist = client.insert_tasklist(tl) logger.info("Successfully created tasklist with ID: %s", new_tasklist.id) - return tasklist_to_dict(new_tasklist) except ValueError as e: logger.critical("Conflict: Tasklist with title '%s' already exists", title, exc_info=True) From a2be13db4452270650f8404957d6f634685aaf81 Mon Sep 17 00:00:00 2001 From: Faith Villarreal Date: Wed, 29 Oct 2025 13:35:59 -0400 Subject: [PATCH 18/33] feat: adding implementation for GTasks DI --- .../src/gtask_client_impl/gtask_impl.py | 75 ++++++----------- .../src/gtask_client_impl/task_impl.py | 9 +- .../src/gtask_client_impl/tasklist_impl.py | 9 +- .../src/task_client_api/task.py | 2 +- .../src/task_client_api/tasklist.py | 2 +- test_gtask.py | 84 +++++++++++++++++-- 6 files changed, 110 insertions(+), 71 deletions(-) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index 14cf8f05..df429d6a 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -123,8 +123,7 @@ def _run_interactive_flow(self, creds_path: str) -> Credentials | None: opening the user's browser to complete authentication with Google. """ if not Path(creds_path).exists(): - msg = f"'{creds_path}' not found. Cannot run interactive auth." - raise FileNotFoundError(msg) + raise FileNotFoundError(f"'{creds_path}' not found. Cannot run interactive auth.") # noqa: EM102 TRY003 flow = InstalledAppFlow.from_client_secrets_file( creds_path, self.SCOPES, @@ -236,18 +235,18 @@ def delete_tasklist(self, tasklist_id: str) -> bool: else: return True - def insert_tasklist(self, tl: tasklist.TaskList) -> tasklist.TaskList: + def insert_tasklist(self, tasklist: tasklist.TaskList) -> tasklist.TaskList: """Insert a tasklist. Args: - tl: TaskList carrying the title to create. + tasklist: The tasklist to insert. Returns: - The created TaskList as returned by the API. + The inserted tasklist with updated fields (e.g., id, etag). """ try: - body = {"title": tl.title} + body = {"title": tasklist.title} result = ( self.service.tasklists() # type: ignore[attr-defined] .insert(body=body) @@ -255,10 +254,7 @@ def insert_tasklist(self, tl: tasklist.TaskList) -> tasklist.TaskList: ) # Convert result dict to JSON string for raw_data raw_data = json.dumps(result) - return task_client_api.tasklist.get_tasklist( - task_list_id=result["id"], - raw_data=raw_data, - ) + return task_client_api.tasklist.get_tasklist(raw_data=raw_data) except (HttpError, OSError, ValueError) as e: self.logger.exception("Failed to insert tasklist") self.logger.debug("Error details: %s", e) @@ -273,17 +269,14 @@ def list_tasklists(self) -> list[tasklist.TaskList]: """ try: result = ( - self.service.tasklists().list().execute() # type: ignore[attr-defined] + self.service.tasklists() # type: ignore[attr-defined] + .list() + .execute() ) tasklists = [] for item in result.get("items", []): raw_data = json.dumps(item) - tasklists.append( - tasklist.get_tasklist( - task_list_id=item["id"], - raw_data=raw_data, - ) - ) + tasklists.append(tasklist.get_tasklist(raw_data=raw_data)) except (HttpError, OSError, ValueError) as e: self.logger.exception("Failed to list tasklists") self.logger.debug("Error details: %s", e) @@ -314,7 +307,6 @@ def list_tasks(self, tasklist_id: str) -> list[task.Task]: raw_data = json.dumps(item) tasks.append( task.get_task( - task_id=item["id"], raw_data=raw_data, ) ) @@ -325,55 +317,39 @@ def list_tasks(self, tasklist_id: str) -> list[task.Task]: else: return tasks - def insert_task(self, tasklist_id: str, t: task.Task) -> task.Task: - """Insert a task into a tasklist. - - Args: - tasklist_id: ID of the target tasklist. - t: Task data to create. - - Returns: - The created Task as returned by the API. - - """ - body = { - "title": t.title, - "notes": getattr(t, "notes", None), - "due": getattr(t, "due", None), - "completed": getattr(t, "completed", None), - "status": getattr(t, "status", None), - "deleted": getattr(t, "deleted", None), - "hidden": getattr(t, "hidden", None), - "parent": getattr(t, "parent", None), - } + def insert_task(self, tasklist_id: str, task: task.Task) -> task.Task: """Insert a task into a tasklist. Args: tasklist_id: The ID of the tasklist to insert the task into. - task_input: Dictionary of task fields - (e.g., title, notes, status, due, parent, previous). + task: The task to insert. Returns: The inserted task with updated fields. """ try: + body: dict[str, str | None] = { + "title": task.title, + } + if task.notes: + body["notes"] = task.notes + if task.status: + body["status"] = task.status + if task.due: + body["due"] = task.due + result = ( self.service.tasks() # type: ignore[attr-defined] .insert(tasklist=tasklist_id, body=body) .execute() ) raw_data = json.dumps(result) + return task_client_api.task.get_task(raw_data=raw_data) except (HttpError, OSError, ValueError) as e: self.logger.exception("Failed to insert task") self.logger.debug("Error details: %s", e) raise - else: - self.logger.info("Successfully created task with ID: %s", result["id"]) - return task_client_api.task.get_task( - task_id=result["id"], - raw_data=raw_data, - ) def delete_task(self, tasklist_id: str, task_id: str) -> bool: """Delete a task by its ID. @@ -435,10 +411,7 @@ def get_task(self, tasklist_id: str, task_id: str) -> task.Task: error_msg = f"Failed to retrieve task {task_id}" raise ValueError(error_msg) from e else: - return task.get_task( - task_id=task_id, - raw_data=raw_data, - ) + return task.get_task(raw_data=raw_data) def get_client_impl(*, interactive: bool = False) -> task_client_api.Client: diff --git a/src/gtask_client_impl/src/gtask_client_impl/task_impl.py b/src/gtask_client_impl/src/gtask_client_impl/task_impl.py index 8f6377dd..c1b8d23b 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/task_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/task_impl.py @@ -10,9 +10,8 @@ class GTask(task.Task): """Concrete implementation of the Task abstraction for Google Tasks.""" - def __init__(self, task_id: str, raw_data: str) -> None: + def __init__(self, raw_data: str) -> None: """Initialize the task from raw JSON data.""" - self._id = task_id self._raw_data = raw_data try: self._data = json.loads(raw_data) @@ -22,7 +21,7 @@ def __init__(self, task_id: str, raw_data: str) -> None: @property def id(self) -> str: """Get the unique task identifier.""" - return self._id + return cast("str", self._data.get("id", "")) @property def title(self) -> str: @@ -60,9 +59,9 @@ def hidden(self) -> bool: return cast("bool", self._data.get("hidden", False)) -def get_task_impl(task_id: str, raw_data: str) -> task.Task: +def get_task_impl(raw_data: str) -> task.Task: """Return an instance of the concrete GTask implementation.""" - return GTask(task_id=task_id, raw_data=raw_data) + return GTask(raw_data=raw_data) def register() -> None: diff --git a/src/gtask_client_impl/src/gtask_client_impl/tasklist_impl.py b/src/gtask_client_impl/src/gtask_client_impl/tasklist_impl.py index 16e61b07..922c2b39 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/tasklist_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/tasklist_impl.py @@ -10,9 +10,8 @@ class GTaskList(tasklist.TaskList): """Concrete implementation of the TaskList abstraction for Google TaskLists.""" - def __init__(self, task_list_id: str, raw_data: str) -> None: + def __init__(self, raw_data: str) -> None: """Initialize the tasklist from raw JSON data.""" - self._id = task_list_id self._raw_data = raw_data try: self._data = json.loads(raw_data) @@ -22,7 +21,7 @@ def __init__(self, task_list_id: str, raw_data: str) -> None: @property def id(self) -> str: """Get the unique task list identifier.""" - return self._id + return cast("str", self._data.get("id", "")) @property def title(self) -> str: @@ -45,9 +44,9 @@ def self_link(self) -> str: return cast("str", self._data.get("selfLink", "")) -def get_tasklist_impl(task_list_id: str, raw_data: str) -> tasklist.TaskList: +def get_tasklist_impl(raw_data: str) -> tasklist.TaskList: """Return an instance of the concrete GTaskList implementation.""" - return GTaskList(task_list_id=task_list_id, raw_data=raw_data) + return GTaskList(raw_data=raw_data) def register() -> None: diff --git a/src/task_client_api/src/task_client_api/task.py b/src/task_client_api/src/task_client_api/task.py index ec0cc5cd..f203d9e3 100644 --- a/src/task_client_api/src/task_client_api/task.py +++ b/src/task_client_api/src/task_client_api/task.py @@ -55,7 +55,7 @@ def hidden(self) -> bool: raise NotImplementedError -def get_task(task_id: str, raw_data: str) -> Task: +def get_task(raw_data: str) -> Task: """Return an instance of a Task. Args: diff --git a/src/task_client_api/src/task_client_api/tasklist.py b/src/task_client_api/src/task_client_api/tasklist.py index 2d89006e..3b0e3d52 100644 --- a/src/task_client_api/src/task_client_api/tasklist.py +++ b/src/task_client_api/src/task_client_api/tasklist.py @@ -37,7 +37,7 @@ def self_link(self) -> str: raise NotImplementedError -def get_tasklist(task_list_id: str, raw_data: str) -> TaskList: +def get_tasklist(raw_data: str) -> TaskList: """Return an instance of a TaskList. Args: diff --git a/test_gtask.py b/test_gtask.py index 5bb961ad..04f5baa8 100644 --- a/test_gtask.py +++ b/test_gtask.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -def main() -> None: # noqa: C901 # Just a test script +def main() -> None: # noqa: PLR0912, PLR0915, C901 # Just a test script """Initialize the client and demonstrate all task client methods.""" # Now, get_client() returns a GTaskClient instance... client = task_client_api.get_client(interactive=False) @@ -20,12 +20,46 @@ def main() -> None: # noqa: C901 # Just a test script tasklists = client.list_tasklists() logger.info("Found %d tasklist(s)", len(tasklists)) + # Test 1.5: Create a test tasklist if none exist, or test insert/delete functionality + test_tasklist_for_operations = None if not tasklists: logger.info("No tasklists found. Creating a test tasklist...") - # For insert, we'd need a TaskList object, but we can't create one without raw_data - # So we'll skip this test if no tasklists exist - logger.info("Skipping further tests - no tasklists available.") - return + try: + # Create a minimal TaskList object with just a title + tasklist_data = {"title": "Test Tasklist - Insert/Delete Demo"} + raw_data = json.dumps(tasklist_data) + # Create tasklist - the implementation should handle minimal data + new_tasklist = task_client_api.tasklist.get_tasklist(raw_data=raw_data) + # Insert the tasklist via service call + inserted_tasklist = client.insert_tasklist(new_tasklist) + logger.info( + "Created test tasklist: '%s' (ID: %s)", + inserted_tasklist.title, + inserted_tasklist.id, + ) + tasklists = [inserted_tasklist] + test_tasklist_for_operations = inserted_tasklist + except Exception as e: + logger.info("Failed to create test tasklist: %s", e) + logger.info("Skipping further tests - no tasklists available.") + return + else: + # Test inserting and deleting a tasklist when tasklists already exist + logger.info("\nTest 1.5: Testing insert_tasklist functionality...") + try: + tasklist_data = {"title": "Test Tasklist - Delete Me"} + raw_data = json.dumps(tasklist_data) + new_tasklist = task_client_api.tasklist.get_tasklist(raw_data=raw_data) + inserted_tasklist = client.insert_tasklist(new_tasklist) + logger.info( + "Created test tasklist: '%s' (ID: %s)", + inserted_tasklist.title, + inserted_tasklist.id, + ) + test_tasklist_for_operations = inserted_tasklist + except Exception as e: + logger.info("Failed to create test tasklist: %s", e) + test_tasklist_for_operations = None # Display tasklists for i, tasklist in enumerate(tasklists, 1): @@ -59,12 +93,12 @@ def main() -> None: # noqa: C901 # Just a test script try: # Create a minimal Task object with just a title # We need raw_data to create a Task, so we'll use a minimal JSON structure - task_data = {"title": "Test Task - Delete and Reinsert"} + task_data = {"title": "Test Task 101- Delete and Reinsert"} raw_data = json.dumps(task_data) # Create task with temporary ID (will be replaced after insert) # Calling the create task function associated with task object, NOT the service call - new_task = task_client_api.task.get_task(task_id="temp", raw_data=raw_data) + new_task = task_client_api.task.get_task(raw_data=raw_data) # Insert the task via service call inserted_task = client.insert_task(test_tasklist.id, new_task) @@ -104,7 +138,7 @@ def main() -> None: # noqa: C901 # Just a test script try: # Recreate the task object with the stored data raw_data_reinsert = json.dumps(stored_task_data) - task_to_reinsert = task_client_api.task.get_task(task_id="temp", raw_data=raw_data_reinsert) + task_to_reinsert = task_client_api.task.get_task(raw_data=raw_data_reinsert) # Reinsert the task reinserted_task = client.insert_task(test_tasklist.id, task_to_reinsert) logger.info( @@ -119,6 +153,40 @@ def main() -> None: # noqa: C901 # Just a test script else: logger.info("\nTest 3: Skipping task creation (no tasklist available)") + # Test 6: Delete the test tasklist (if we created one) + if test_tasklist_for_operations: + logger.info("\nTest 6: Deleting the test tasklist...") + logger.info( + "Tasklist to delete: '%s' (ID: %s)", + test_tasklist_for_operations.title, + test_tasklist_for_operations.id, + ) + user_input = input("Do you want to delete this tasklist? Type 'DELETE' to confirm: ").strip() + + if user_input == "DELETE": + try: + deletion_success = client.delete_tasklist(test_tasklist_for_operations.id) + if deletion_success: + logger.info( + "Tasklist '%s' (ID: %s) deleted successfully.", + test_tasklist_for_operations.title, + test_tasklist_for_operations.id, + ) + else: + logger.info( + "Failed to delete tasklist '%s' (ID: %s).", + test_tasklist_for_operations.title, + test_tasklist_for_operations.id, + ) + except Exception as e: + logger.info("Failed to delete tasklist: %s", e) + else: + logger.info( + "Deletion cancelled. Tasklist '%s' (ID: %s) was not deleted.", + test_tasklist_for_operations.title, + test_tasklist_for_operations.id, + ) + logger.info("\nDemo complete.") From 4952a84deb3f9dd3b0cee20ea7e077117285a08a Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 13:12:01 -0400 Subject: [PATCH 19/33] refactor: change insert task param from task to dict[str,str|bool], add due date datetime format validation --- .../src/gtask_client_impl/gtask_impl.py | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index df429d6a..3e0c9b07 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -14,6 +14,7 @@ import os from pathlib import Path from typing import ClassVar +from datetime import datetime import task_client_api from google.auth.exceptions import GoogleAuthError, RefreshError @@ -329,19 +330,20 @@ def insert_task(self, tasklist_id: str, task: task.Task) -> task.Task: """ try: - body: dict[str, str | None] = { - "title": task.title, - } - if task.notes: - body["notes"] = task.notes - if task.status: - body["status"] = task.status - if task.due: - body["due"] = task.due - + if task_input["due"]: + due_value = task_input["due"] + if isinstance(due_value, str): + try: + datetime.fromisoformat(due_value.replace("Z", "+00:00")) + except ValueError: + raise ValueError( + f"Invalid RFC 3339 timestamp format: {due_value}" + ) + else: + task_input["due"] = due_value result = ( self.service.tasks() # type: ignore[attr-defined] - .insert(tasklist=tasklist_id, body=body) + .insert(tasklist=tasklist_id, body=task_input) .execute() ) raw_data = json.dumps(result) @@ -350,6 +352,12 @@ def insert_task(self, tasklist_id: str, task: task.Task) -> task.Task: self.logger.exception("Failed to insert task") self.logger.debug("Error details: %s", e) raise + else: + self.logger.info("Successfully created task with ID: %s", result["id"]) + return task_client_api.task.get_task( + task_id=result["id"], + raw_data=raw_data, + ) def delete_task(self, tasklist_id: str, task_id: str) -> bool: """Delete a task by its ID. From 4721bbf59322e44a38e0dd6242f74f94f90e7ca1 Mon Sep 17 00:00:00 2001 From: Ruimeng Chen Date: Thu, 30 Oct 2025 18:34:12 -0400 Subject: [PATCH 20/33] Fix ruff check errors --- .../src/gtask_client_impl/gtask_impl.py | 9 +++++---- src/task_client_service/src/fast_api_service.py | 2 ++ src/task_client_service/src/task_router.py | 3 ++- src/task_client_service/src/tasklist_router.py | 1 + 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index 3e0c9b07..b130a33e 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -14,7 +14,6 @@ import os from pathlib import Path from typing import ClassVar -from datetime import datetime import task_client_api from google.auth.exceptions import GoogleAuthError, RefreshError @@ -124,7 +123,8 @@ def _run_interactive_flow(self, creds_path: str) -> Credentials | None: opening the user's browser to complete authentication with Google. """ if not Path(creds_path).exists(): - raise FileNotFoundError(f"'{creds_path}' not found. Cannot run interactive auth.") # noqa: EM102 TRY003 + msg = f"'{creds_path}' not found. Cannot run interactive auth." + raise FileNotFoundError(msg) flow = InstalledAppFlow.from_client_secrets_file( creds_path, self.SCOPES, @@ -240,7 +240,7 @@ def insert_tasklist(self, tasklist: tasklist.TaskList) -> tasklist.TaskList: """Insert a tasklist. Args: - tasklist: The tasklist to insert. + title: The name of the new tasklist. Returns: The inserted tasklist with updated fields (e.g., id, etag). @@ -323,7 +323,8 @@ def insert_task(self, tasklist_id: str, task: task.Task) -> task.Task: Args: tasklist_id: The ID of the tasklist to insert the task into. - task: The task to insert. + task_input: Dictionary of task fields + (e.g., title, notes, status, due, parent, previous). Returns: The inserted task with updated fields. diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index 3838397b..923b31db 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -5,6 +5,8 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from task_router import router as task_router +from tasklist_router import router as tasklist_router import gtask_client_impl # noqa: F401 from task_client_api import get_client diff --git a/src/task_client_service/src/task_router.py b/src/task_client_service/src/task_router.py index 79556f67..cd16f910 100644 --- a/src/task_client_service/src/task_router.py +++ b/src/task_client_service/src/task_router.py @@ -3,8 +3,9 @@ import logging from dataclasses import dataclass from datetime import datetime -from typing import Annotated, Any, NotRequired, Required, TypedDict +from typing import Annotated, NotRequired, Required, TypedDict +from dependencies import TaskClientDep from fastapi import APIRouter, Body, HTTPException from task_client_api import Task diff --git a/src/task_client_service/src/tasklist_router.py b/src/task_client_service/src/tasklist_router.py index 8d41056e..db7eea5a 100644 --- a/src/task_client_service/src/tasklist_router.py +++ b/src/task_client_service/src/tasklist_router.py @@ -3,6 +3,7 @@ import logging from typing import Annotated, Any, TypedDict +from dependencies import TaskClientDep from fastapi import APIRouter, Body, HTTPException from task_client_api import TaskList From 239b7a4152dd52b090f2c5aad1953b591f501931 Mon Sep 17 00:00:00 2001 From: Ruimeng Chen Date: Thu, 30 Oct 2025 20:37:25 -0400 Subject: [PATCH 21/33] Fix mypy errors --- src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py | 6 +++--- src/task_client_service/src/fast_api_service.py | 2 -- src/task_client_service/src/task_router.py | 3 +-- src/task_client_service/src/tasklist_router.py | 1 - 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index b130a33e..4917907f 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -240,10 +240,10 @@ def insert_tasklist(self, tasklist: tasklist.TaskList) -> tasklist.TaskList: """Insert a tasklist. Args: - title: The name of the new tasklist. + tl: TaskList carrying the title to create. Returns: - The inserted tasklist with updated fields (e.g., id, etag). + The created TaskList as returned by the API. """ try: @@ -344,7 +344,7 @@ def insert_task(self, tasklist_id: str, task: task.Task) -> task.Task: task_input["due"] = due_value result = ( self.service.tasks() # type: ignore[attr-defined] - .insert(tasklist=tasklist_id, body=task_input) + .insert(tasklist=tasklist_id, body=body) .execute() ) raw_data = json.dumps(result) diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/fast_api_service.py index 923b31db..3838397b 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/fast_api_service.py @@ -5,8 +5,6 @@ from contextlib import asynccontextmanager from fastapi import FastAPI -from task_router import router as task_router -from tasklist_router import router as tasklist_router import gtask_client_impl # noqa: F401 from task_client_api import get_client diff --git a/src/task_client_service/src/task_router.py b/src/task_client_service/src/task_router.py index cd16f910..79556f67 100644 --- a/src/task_client_service/src/task_router.py +++ b/src/task_client_service/src/task_router.py @@ -3,9 +3,8 @@ import logging from dataclasses import dataclass from datetime import datetime -from typing import Annotated, NotRequired, Required, TypedDict +from typing import Annotated, Any, NotRequired, Required, TypedDict -from dependencies import TaskClientDep from fastapi import APIRouter, Body, HTTPException from task_client_api import Task diff --git a/src/task_client_service/src/tasklist_router.py b/src/task_client_service/src/tasklist_router.py index db7eea5a..8d41056e 100644 --- a/src/task_client_service/src/tasklist_router.py +++ b/src/task_client_service/src/tasklist_router.py @@ -3,7 +3,6 @@ import logging from typing import Annotated, Any, TypedDict -from dependencies import TaskClientDep from fastapi import APIRouter, Body, HTTPException from task_client_api import TaskList From ef0e3b92800c99168f86eccc1ffb83386732fcc5 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 20:02:27 -0400 Subject: [PATCH 22/33] refactor: ruff fix back to faith's version ruff fixes chore: re export task client service methods fix ruff check fix ruff and import errors for task dirs fix mypy errors in conftests of integration and e2e fix ruff for testing --- pyproject.toml | 1 + .../src/gtask_client_impl/gtask_impl.py | 43 ++-- .../mail_client_service/fast_api_service.py | 2 +- src/task_client_service/src/__init__.py | 1 - .../src/task_client_service/__init__.py | 14 ++ .../{ => task_client_service}/dependencies.py | 0 .../fast_api_service.py | 2 +- .../src/task_client_service/task_router.py | 164 ++++++++++++++ .../tasklist_router.py | 56 ++--- src/task_client_service/src/task_router.py | 203 ------------------ tests/e2e/conftest.py | 5 +- tests/integration/conftest.py | 5 +- 12 files changed, 239 insertions(+), 257 deletions(-) delete mode 100644 src/task_client_service/src/__init__.py create mode 100644 src/task_client_service/src/task_client_service/__init__.py rename src/task_client_service/src/{ => task_client_service}/dependencies.py (100%) rename src/task_client_service/src/{ => task_client_service}/fast_api_service.py (93%) create mode 100644 src/task_client_service/src/task_client_service/task_router.py rename src/task_client_service/src/{ => task_client_service}/tasklist_router.py (68%) delete mode 100644 src/task_client_service/src/task_router.py diff --git a/pyproject.toml b/pyproject.toml index fc64b3f9..8f18ba7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ mypy_path = [ "src/mail_client_service_client/src", "src/task_client_api/src", "src/gtask_client_impl/src", + "src/task_client_service/src", ] ignore_missing_imports = false diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index 4917907f..bbe91f60 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -75,7 +75,9 @@ class GTaskClient(task_client_api.Client): ] FAILURE_TO_CRED = "Failed to obtain credentials. Please check your setup." - def __init__(self, service: Resource | None = None, *, interactive: bool = False) -> None: + def __init__( + self, service: Resource | None = None, *, interactive: bool = False + ) -> None: """Initialize the GTaskClient, handling authentication.""" self.logger = logging.getLogger(__name__) if service: @@ -145,7 +147,9 @@ def _auth_from_env(self) -> Credentials | None: client_id = os.environ.get("TASKS_CLIENT_ID") client_secret = os.environ.get("TASKS_CLIENT_SECRET") refresh_token = os.environ.get("TASKS_REFRESH_TOKEN") - token_uri = os.environ.get("TASKS_TOKEN_URI", "https://oauth2.googleapis.com/token") + token_uri = os.environ.get( + "TASKS_TOKEN_URI", "https://oauth2.googleapis.com/token" + ) if not (client_id and client_secret and refresh_token): return None @@ -240,7 +244,7 @@ def insert_tasklist(self, tasklist: tasklist.TaskList) -> tasklist.TaskList: """Insert a tasklist. Args: - tl: TaskList carrying the title to create. + tasklist: TaskList carrying the title to create. Returns: The created TaskList as returned by the API. @@ -270,9 +274,7 @@ def list_tasklists(self) -> list[tasklist.TaskList]: """ try: result = ( - self.service.tasklists() # type: ignore[attr-defined] - .list() - .execute() + self.service.tasklists().list().execute() # type: ignore[attr-defined] ) tasklists = [] for item in result.get("items", []): @@ -323,7 +325,7 @@ def insert_task(self, tasklist_id: str, task: task.Task) -> task.Task: Args: tasklist_id: The ID of the tasklist to insert the task into. - task_input: Dictionary of task fields + task: Task carrying the title to create. (e.g., title, notes, status, due, parent, previous). Returns: @@ -331,24 +333,20 @@ def insert_task(self, tasklist_id: str, task: task.Task) -> task.Task: """ try: - if task_input["due"]: - due_value = task_input["due"] - if isinstance(due_value, str): - try: - datetime.fromisoformat(due_value.replace("Z", "+00:00")) - except ValueError: - raise ValueError( - f"Invalid RFC 3339 timestamp format: {due_value}" - ) - else: - task_input["due"] = due_value + body: dict[str, str | None] = { + "title": task.title, + } + if task.notes: + body["notes"] = task.notes + if task.status: + body["status"] = task.status + if task.due: + body["due"] = task.due + result = ( - self.service.tasks() # type: ignore[attr-defined] - .insert(tasklist=tasklist_id, body=body) - .execute() + self.service.tasks().insert(tasklist=tasklist_id, body=body).execute() ) raw_data = json.dumps(result) - return task_client_api.task.get_task(raw_data=raw_data) except (HttpError, OSError, ValueError) as e: self.logger.exception("Failed to insert task") self.logger.debug("Error details: %s", e) @@ -356,7 +354,6 @@ def insert_task(self, tasklist_id: str, task: task.Task) -> task.Task: else: self.logger.info("Successfully created task with ID: %s", result["id"]) return task_client_api.task.get_task( - task_id=result["id"], raw_data=raw_data, ) diff --git a/src/mail_client_service/src/mail_client_service/fast_api_service.py b/src/mail_client_service/src/mail_client_service/fast_api_service.py index 5b07235f..744927ca 100644 --- a/src/mail_client_service/src/mail_client_service/fast_api_service.py +++ b/src/mail_client_service/src/mail_client_service/fast_api_service.py @@ -12,7 +12,7 @@ @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Manage application lifespan and initialize mail client.""" - client = get_client(interactive=True) + client = get_client(interactive=False) app.state.mail_client = client yield diff --git a/src/task_client_service/src/__init__.py b/src/task_client_service/src/__init__.py deleted file mode 100644 index 0b04b7fa..00000000 --- a/src/task_client_service/src/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package init file for task_client_service.""" diff --git a/src/task_client_service/src/task_client_service/__init__.py b/src/task_client_service/src/task_client_service/__init__.py new file mode 100644 index 00000000..1c386424 --- /dev/null +++ b/src/task_client_service/src/task_client_service/__init__.py @@ -0,0 +1,14 @@ +"""Task Client Service - FastAPI service for task client operations.""" + +from .dependencies import TaskClientDep, get_task_client +from .fast_api_service import app +from .task_router import router as task_router +from .tasklist_router import router as tasklist_router + +__all__ = [ + "TaskClientDep", + "app", + "get_task_client", + "task_router", + "tasklist_router", +] diff --git a/src/task_client_service/src/dependencies.py b/src/task_client_service/src/task_client_service/dependencies.py similarity index 100% rename from src/task_client_service/src/dependencies.py rename to src/task_client_service/src/task_client_service/dependencies.py diff --git a/src/task_client_service/src/fast_api_service.py b/src/task_client_service/src/task_client_service/fast_api_service.py similarity index 93% rename from src/task_client_service/src/fast_api_service.py rename to src/task_client_service/src/task_client_service/fast_api_service.py index 3838397b..1fcfc8f3 100644 --- a/src/task_client_service/src/fast_api_service.py +++ b/src/task_client_service/src/task_client_service/fast_api_service.py @@ -50,4 +50,4 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: import uvicorn logger.info("Starting FastAPI server...") - uvicorn.run("fast_api_service:app", host="0.0.0.0", port=8000, reload=True) # noqa: S104 + uvicorn.run("fast_api_service:app", host="127.0.0.1", port=8000, reload=True) diff --git a/src/task_client_service/src/task_client_service/task_router.py b/src/task_client_service/src/task_client_service/task_router.py new file mode 100644 index 00000000..b3011dd8 --- /dev/null +++ b/src/task_client_service/src/task_client_service/task_router.py @@ -0,0 +1,164 @@ +"""Router for task operations.""" + +import json +import logging +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Body, HTTPException + +import gtask_client_impl # noqa: F401 +from task_client_api import Task as ServiceTask +from task_client_api import get_task as get_service_task + +from .dependencies import TaskClientDep + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@dataclass +class _NewTask: + id: str | None + title: str + notes: str | None = None + due: str | None = None + completed: str | None = None + status: str | None = None + deleted: bool | None = None + hidden: bool | None = None + + +def task_to_dict(task: ServiceTask) -> dict[str, str | bool | None]: + """Convert a Task object to a JSON-serializable dictionary.""" + return { + "id": task.id, + "title": task.title, + "notes": task.notes, + "status": task.status, + "due": task.due, + "completed": task.completed, + "deleted": task.deleted, + "hidden": task.hidden, + } + + +@router.get("/{tasklist_id}") +async def list_tasks( + client: TaskClientDep, + tasklist_id: str, +) -> list[dict[str, str | None | bool]]: + """List all the tasks within a given tasklist.""" + logger.info("Work to list tasks from tasklist '%s'", tasklist_id) + try: + tasks = client.list_tasks(tasklist_id) + formatted_tasks = [task_to_dict(task) for task in tasks] + except Exception as e: + logger.critical( + "Error listing tasks from tasklist '%s': %s", + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) from e + else: + logger.info("Successfully listed tasks from tasklist '%s'", tasklist_id) + return formatted_tasks + + +@router.get("/{tasklist_id}/{task_id}") +async def get_task( + client: TaskClientDep, + tasklist_id: str, + task_id: str, +) -> dict[str, str | None | bool]: + """Read a single task from a given tasklist.""" + logger.info("Recievd request to get task '%s' from tasklist '%s'", task_id, tasklist_id) + try: + task = client.get_task(tasklist_id, task_id) + formatted_task = task_to_dict(task) + except Exception as e: + logger.critical( + "Error getting task '%s' from tasklist '%s': %s", + task_id, + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) from e + else: + logger.info("Successfully retrieved task '%s' from tasklist '%s'", task_id, tasklist_id) + return formatted_task + + +@router.post("/{tasklist_id}") +async def insert_task( + client: TaskClientDep, + tasklist_id: str, + task_input: Annotated[ + dict[str, str | None | bool], + Body( + example={ + "title": "My New Task", + "notes": "This is a new task", + "status": "needsAction", + "due": "2025-11-15T00:00:00.000Z", + } + ), + ], +) -> dict[str, str | bool | None]: + """Insert a new task into a tasklist.""" + logger.info( + "Received request to insert task with title: '%s' into tasklist: '%s'", + task_input.get("title", "Unknown"), + tasklist_id, + ) + try: + if isinstance(task_input["due"], str): + due_date = datetime.fromisoformat(task_input["due"]) + task_input["due"] = due_date.isoformat() + + raw_data = json.dumps(task_input) + + new_task: ServiceTask = get_service_task(raw_data) + + client.insert_task(tasklist_id, new_task) + except ValueError as e: + logger.critical( + "Error inserting task '%s' into tasklist '%s': %s", + task_input.get("title", "Unknown"), + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=400, detail=str(e)) from e + + except Exception as e: + logger.critical( + "Error inserting task '%s' into tasklist '%s': %s", + task_input.get("title", "Unknown"), + tasklist_id, + e, + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) from e + else: + logger.info("Successfully created task with ID: %s", new_task.id) + return task_to_dict(new_task) + + +@router.delete("/{tasklist_id}/{task_id}") +async def delete_task( + client: TaskClientDep, + tasklist_id: str, + task_id: str, +) -> dict[str, str]: + """Mark a task as deleted.""" + logger.info("Recieved request to delete task '%s' from tasklist '%s'", task_id, tasklist_id) + if not client.delete_task(tasklist_id, task_id): + raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found") + + logger.info("Successfully deleted task '%s' from tasklist '%s'", task_id, tasklist_id) + return {"detail": f"Task '{task_id}' deleted from tasklist '{tasklist_id}'."} diff --git a/src/task_client_service/src/tasklist_router.py b/src/task_client_service/src/task_client_service/tasklist_router.py similarity index 68% rename from src/task_client_service/src/tasklist_router.py rename to src/task_client_service/src/task_client_service/tasklist_router.py index 8d41056e..60b89ab0 100644 --- a/src/task_client_service/src/tasklist_router.py +++ b/src/task_client_service/src/task_client_service/tasklist_router.py @@ -1,12 +1,13 @@ """Router for tasklist operations.""" +import json import logging from typing import Annotated, Any, TypedDict from fastapi import APIRouter, Body, HTTPException -from task_client_api import TaskList -from task_client_api import tasklist as tasklist_model +from task_client_api import TaskList as ServiceTaskList +from task_client_api import get_tasklist as get_service_tasklist from .dependencies import TaskClientDep @@ -14,13 +15,14 @@ router = APIRouter(prefix="/tasklists", tags=["tasklists"]) + class CreateTaskListBody(TypedDict): """Body schema for creating a tasklist.""" title: str -def tasklist_to_dict(tasklist: TaskList) -> dict[str, str]: +def tasklist_to_dict(tasklist: ServiceTaskList) -> dict[str, str]: """Convert a TaskList object to a JSON-serializable dictionary.""" return { "id": tasklist.id, @@ -36,7 +38,7 @@ async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: """Get a list of tasklists from the task client.""" logger.info("Received request to list tasklists") try: - tasklists = client.list_tasklists() + tasklists: list[ServiceTaskList] = client.list_tasklists() logger.info("Retrieved %d tasklists", len(tasklists)) formatted_tasklists: list[dict[str, str]] = [] @@ -50,26 +52,31 @@ async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: logger.info("Successfully formatted %d tasklists", len(formatted_tasklists)) return formatted_tasklists + def _get_str(d: dict[str, Any], key: str, default: str | None = None) -> str | None: v = d.get(key) if isinstance(v, str): return v return default + def _get_bool(d: dict[str, Any], key: str) -> bool | None: v = d.get(key) return v if isinstance(v, bool) else None + @router.post("") async def insert_tasklist( client: TaskClientDep, body: Annotated[ CreateTaskListBody, Body( - examples=[{ - "summary": "Basic", - "value": {"title": "My New Task List"}, - }] + examples=[ + { + "summary": "Basic", + "value": {"title": "My New Task List"}, + } + ] ), ], ) -> dict[str, str]: @@ -77,8 +84,9 @@ async def insert_tasklist( title = body["title"] logger.info("Received request to insert tasklist with title: '%s'", title) try: - tl = tasklist_model.get_tasklist(task_list_id="", raw_data='{"title": "' + title + '"}') - new_tasklist = client.insert_tasklist(tl) + new_tasklist = get_service_tasklist(json.dumps({"title": title})) + + new_tasklist = client.insert_tasklist(new_tasklist) logger.info("Successfully created tasklist with ID: %s", new_tasklist.id) return tasklist_to_dict(new_tasklist) except ValueError as e: @@ -94,32 +102,32 @@ async def delete_tasklist( client: TaskClientDep, tasklist_id: str, ) -> dict[str, str]: - """Delete a tasklist by ID.""" - tasklists = client.list_tasklists() + """Delete a tasklist.""" + logger.info("Received request to delete tasklist with ID: '%s'", tasklist_id) + + tasklists: list[ServiceTaskList] = client.list_tasklists() + # Reject deleting the default tasklist if tasklists and tasklists[0].id == tasklist_id: raise HTTPException( status_code=400, detail="Error: Invalid request cannot delete default tasklist", ) - target_tasklist = next((tl for tl in tasklists if tl.id == tasklist_id), None) - if not target_tasklist: - raise HTTPException( - status_code=404, - detail=f"Error: Tasklist '{tasklist_id}' not found", - ) + # Find the target tasklist + target_tasklist = next((t for t in tasklists if t.id == tasklist_id), None) + + if target_tasklist is None: + raise HTTPException(status_code=404, detail=f"Error: Tasklist '{tasklist_id}' not found") try: success = client.delete_tasklist(tasklist_id) - except Exception as e: - logger.critical( - "Error deleting tasklist '%s': %s", tasklist_id, e, exc_info=True - ) - raise HTTPException(status_code=500, detail="Failed to delete tasklist.") from e + except Exception as e: # unexpected failures from the client + logger.exception("Error deleting tasklist '%s'", tasklist_id) + raise HTTPException(status_code=500, detail="Internal error while deleting tasklist") from e if not success: - raise HTTPException(status_code=500, detail="Failed to delete tasklist.") + raise HTTPException(status_code=500, detail=f"Failed to delete tasklist '{tasklist_id}'") logger.info("Successfully deleted tasklist with ID: %s", tasklist_id) return {"detail": f"Tasklist '{target_tasklist.title}' deleted."} diff --git a/src/task_client_service/src/task_router.py b/src/task_client_service/src/task_router.py deleted file mode 100644 index 79556f67..00000000 --- a/src/task_client_service/src/task_router.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Router for task operations.""" - -import logging -from dataclasses import dataclass -from datetime import datetime -from typing import Annotated, Any, NotRequired, Required, TypedDict - -from fastapi import APIRouter, Body, HTTPException - -from task_client_api import Task - -from .dependencies import TaskClientDep - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/tasks", tags=["tasks"]) - -@dataclass -class _NewTask: - id: str | None - title: str - notes: str | None = None - due: str | None = None - completed: str | None = None - status: str | None = None - deleted: bool | None = None - hidden: bool | None = None - -def task_to_dict(task: Task) -> dict[str, str | None | bool]: - """Convert a Task object to a JSON-serializable dictionary.""" - return { - "id": task.id, - "title": task.title, - "notes": task.notes, - "status": task.status, - "due": task.due, - "completed": task.completed, - "deleted": task.deleted, - "hidden": task.hidden, - } - - -@router.get("/{tasklist_id}") -async def list_tasks( - client: TaskClientDep, - tasklist_id: str, -) -> list[dict[str, str | None | bool]]: - """List all tasks under a tasklist.""" - logger.info("Work to list tasks from tasklist '%s'", tasklist_id) - try: - tasks = client.list_tasks(tasklist_id) - except Exception as e: - logger.critical( - "Error listing tasks from tasklist '%s': %s", - tasklist_id, - e, - exc_info=True, - ) - raise HTTPException(status_code=500, detail=str(e)) from e - else: - logger.info("Successfully listed tasks from tasklist '%s'", tasklist_id) - return [task_to_dict(task) for task in tasks] - - -@router.get("/{tasklist_id}/{task_id}") -async def get_task( - client: TaskClientDep, - tasklist_id: str, - task_id: str, -) -> dict[str, str | None | bool]: - """Get a task by ID under a tasklist.""" - logger.info("Work to get task '%s' from tasklist '%s'", task_id, tasklist_id) - try: - task = client.get_task(tasklist_id, task_id) - except Exception as e: - logger.critical( - "Error getting task '%s' from tasklist '%s': %s", - task_id, - tasklist_id, - e, - exc_info=True, - ) - raise HTTPException(status_code=500, detail=str(e)) from e - else: - logger.info("Successfully got task '%s' from tasklist '%s'", task_id, tasklist_id) - return { - "id": task.id, - "title": task.title, - "notes": task.notes, - "status": task.status, - "due": task.due, - "completed": task.completed, - "deleted": task.deleted, - "hidden": task.hidden, - } - -def _get_str(d: dict[str, Any], key: str, default: str | None = None) -> str | None: - v = d.get(key) - if isinstance(v, str): - return v - return default - -def _get_bool(d: dict[str, Any], key: str) -> bool | None: - v = d.get(key) - return v if isinstance(v, bool) else None - -@router.post("/{tasklist_id}") -async def insert_task( - client: TaskClientDep, - tasklist_id: str, - task_input: Annotated[ - dict[str, str | None | bool], - Body( - examples=[{ - "summary": "Basic", - "value": { - "title": "My New Task", - "notes": "This is a new task", - "status": "needsAction", - "due": "2025-11-15T00:00:00.000Z", - }, - }] - ), - ], -) -> dict[str, str | bool | None]: - """Insert a new task into a tasklist.""" - logger.info( - "Received request to insert task with title: '%s' into tasklist: '%s'", - task_input.get("title", "Unknown"), - tasklist_id, - ) - try: - if task_input.get("due"): - due_value = task_input["due"] - if isinstance(due_value, str): - try: - # Let it raise if 'Z' is not accepted; we just validate format. - datetime.fromisoformat(due_value) - except ValueError as err: - msg = f"Invalid RFC 3339 timestamp format: {due_value}" - raise ValueError(msg) from err - - new_task_obj = _NewTask( - id=None, - title=_get_str(task_input, "title", "") or "", - notes=_get_str(task_input, "notes"), - due=_get_str(task_input, "due"), - completed=_get_str(task_input, "completed"), - status=_get_str(task_input, "status"), - deleted=_get_bool(task_input, "deleted"), - hidden=_get_bool(task_input, "hidden"), - ) - # _NewTask is not the exact nominal type of tasks_api.Task, so silence type check here - new_task = client.insert_task(tasklist_id, new_task_obj) # type: ignore[arg-type] - except Exception as e: - logger.critical( - "Error inserting task '%s' into tasklist '%s': %s", - task_input.get("title", "Unknown"), - tasklist_id, - e, - exc_info=True, - ) - raise HTTPException(status_code=500, detail=str(e)) from e - else: - logger.info("Successfully created task with ID: %s", new_task.id) - return task_to_dict(new_task) - - -@router.delete("/{tasklist_id}/{task_id}") -async def delete_task( - client: TaskClientDep, - tasklist_id: str, - task_id: str, -) -> dict[str, str]: - """Delete a task by ID under a tasklist.""" - logger.info("Work to delete task '%s' from tasklist '%s'", task_id, tasklist_id) - try: - client.delete_task(tasklist_id, task_id) - - except Exception as e: - logger.critical( - "Error deleting task '%s' from tasklist '%s': %s", - task_id, - tasklist_id, - e, - exc_info=True, - ) - raise HTTPException(status_code=500, detail=str(e)) from e - - else: - logger.info("Successfully deleted task '%s' from tasklist '%s'", task_id, tasklist_id) - return {"detail": f"Task '{task_id}' deleted from tasklist '{tasklist_id}'."} - - -class CreateTaskBody(TypedDict): - """Schema for creating a new task.""" - - title: Required[str] - notes: NotRequired[str | None] - status: NotRequired[str | None] # "needsAction" | "completed" - due: NotRequired[str | None] # RFC3339 timestamp - parent: NotRequired[str | None] - previous: NotRequired[str | None] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 736cb271..469a842a 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,5 +1,6 @@ """Configuration for e2e tests to handle dependency injection isolation.""" +from collections.abc import Generator from typing import Never import pytest @@ -8,7 +9,7 @@ @pytest.fixture(autouse=True) -def reset_dependency_injection() -> None: +def reset_dependency_injection() -> Generator[None, None, None]: """Reset dependency injection before each test to ensure test isolation. This fixture runs automatically before each test function to ensure that @@ -34,7 +35,7 @@ def reset_get_client(*, interactive: bool = False) -> Never: @pytest.fixture(autouse=True) -def reset_message_dependency_injection() -> None: +def reset_message_dependency_injection() -> Generator[None, None, None]: """Reset message dependency injection before each test to ensure test isolation.""" # Store the original get_message function original_get_message = getattr(mail_client_api.message, "get_message", None) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a84cf6bc..88c314ea 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,5 +1,6 @@ """Configuration for integration tests to handle dependency injection isolation.""" +from collections.abc import Generator from typing import Never import pytest @@ -8,7 +9,7 @@ @pytest.fixture(autouse=True) -def reset_dependency_injection() -> None: +def reset_dependency_injection() -> Generator[None, None, None]: """Reset dependency injection before each test to ensure test isolation. This fixture runs automatically before each test function to ensure that @@ -34,7 +35,7 @@ def reset_get_client(*, interactive: bool = False) -> Never: @pytest.fixture(autouse=True) -def reset_message_dependency_injection() -> None: +def reset_message_dependency_injection() -> Generator[None, None, None]: """Reset message dependency injection before each test to ensure test isolation.""" # Store the original get_message function original_get_message = getattr(mail_client_api.message, "get_message", None) From 674767edb2e19ca53eb9e2ac216540c0e9abc589 Mon Sep 17 00:00:00 2001 From: Ruimeng Chen Date: Thu, 30 Oct 2025 23:09:03 -0400 Subject: [PATCH 23/33] Add conftest.py --- src/task_client_service/tests/__init__.py | 1 + src/task_client_service/tests/conftest.py | 125 ++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/task_client_service/tests/__init__.py create mode 100644 src/task_client_service/tests/conftest.py diff --git a/src/task_client_service/tests/__init__.py b/src/task_client_service/tests/__init__.py new file mode 100644 index 00000000..9d7d3fac --- /dev/null +++ b/src/task_client_service/tests/__init__.py @@ -0,0 +1 @@ +"""Mark tests package for pytest discovery.""" diff --git a/src/task_client_service/tests/conftest.py b/src/task_client_service/tests/conftest.py new file mode 100644 index 00000000..b55bac28 --- /dev/null +++ b/src/task_client_service/tests/conftest.py @@ -0,0 +1,125 @@ +"""Test configuration for task client service (Google Tasks).""" + +import sys + +# need this part to find src/task_client_service in this file. Neet TYPE_CHECKING to avoid mypy error +from typing import TYPE_CHECKING + +if not TYPE_CHECKING: + import sys + from pathlib import Path + + sys.path.insert(0, str(Path("src/gmail_client_impl/src").resolve())) + +from collections.abc import Callable +from enum import Enum +from typing import cast +from unittest.mock import Mock, create_autospec + +import pytest +from dependencies import get_task_client # type: ignore[import-not-found] +from fast_api_service import app # type: ignore[import-not-found] +from fastapi.testclient import TestClient +from task_client_api.client import Client # type: ignore[import-not-found] +from task_client_api.task import Task as TaskEg # type: ignore[import-not-found] +from task_client_api.tasklist import TaskList as TaskListEg # type: ignore[import-not-found] + + +class HTTPStatus(Enum): + """HTTP status codes used in the API.""" + + OK = 200 + CREATED = 201 + ACCEPTED = 202 + NO_CONTENT = 204 + BAD_REQUEST = 400 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_FOUND = 404 + METHOD_NOT_ALLOWED = 405 + CONFLICT = 409 + INTERNAL_SERVER_ERROR = 500 + + +@pytest.fixture +def http_status() -> type[HTTPStatus]: + """Provide HTTP status codes enum.""" + return HTTPStatus + + +@pytest.fixture +def create_mock_tasklist() -> Callable[..., Mock]: + """Create a mock TaskList object matching the TaskList contract.""" + def _create_mock_tasklist( + tl_id: str, + title: str, + *, + etag: str = "etag-123", + updated: str = "2025-01-01T00:00:00.000Z", + self_link: str = "https://example.com/tasks/lists/tl_id", + ) -> Mock: + mock_tl = create_autospec(TaskListEg, spec_set=True, instance=True) + mock_tl.id = tl_id + mock_tl.title = title + mock_tl.etag = etag + mock_tl.updated = updated + mock_tl.self_link = self_link + return cast("Mock", mock_tl) + return _create_mock_tasklist + + +@pytest.fixture +def create_mock_task() -> Callable[..., Mock]: + """Create a mock Task object matching the Task contract.""" + def _create_mock_task( # noqa: PLR0913 + task_id: str, + title: str, + *, + notes: str | None = None, + status: str = "needsAction", + due: str | None = None, + completed: str | None = None, + deleted: bool = False, + hidden: bool = False, + ) -> Mock: + mock_task = create_autospec(TaskEg, spec_set=True, instance=True) + mock_task.id = task_id + mock_task.title = title + mock_task.notes = notes + mock_task.status = status + mock_task.due = due + mock_task.completed = completed + mock_task.deleted = deleted + mock_task.hidden = hidden + return cast("Mock", mock_task) + return _create_mock_task + + +@pytest.fixture +def mock_task_client() -> Client: + """Provide a mock task client.""" + return cast("Client", create_autospec(Client, spec_set=True, instance=True)) + + +@pytest.fixture +def client(mock_task_client: Mock) -> TestClient: + """Provide a test client with mocked dependencies.""" + app.dependency_overrides[get_task_client] = lambda: mock_task_client + return TestClient(app) + + +@pytest.fixture(autouse=True) +def setup_test(mock_task_client: Mock) -> None: + """Reset mock state before each test (but reuse the same mock object).""" + mock_task_client.reset_mock() + + # Tasklist endpoints + mock_task_client.list_tasklists.side_effect = None + mock_task_client.insert_tasklist.side_effect = None + mock_task_client.delete_tasklist.side_effect = None + + # Task endpoints + mock_task_client.list_tasks.side_effect = None + mock_task_client.get_task.side_effect = None + mock_task_client.insert_task.side_effect = None + mock_task_client.delete_task.side_effect = None From e03d0cdbf9bd681e24348f90604200639e66b217 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 23:11:40 -0400 Subject: [PATCH 24/33] add faiths changes --- src/gtask_client_impl/tests/__init__.py | 1 + .../tests/test_authentication.py | 413 ++++++++++++++ .../tests/test_core_methods.py | 531 ++++++++++++++++++ .../tests/test_registration.py | 52 ++ .../tests/test_task_edge_cases.py | 290 ++++++++++ .../tests/test_tasklist_edge_cases.py | 280 +++++++++ .../tests/test_tasklist_impl.py | 121 ++++ 7 files changed, 1688 insertions(+) create mode 100644 src/gtask_client_impl/tests/__init__.py create mode 100644 src/gtask_client_impl/tests/test_authentication.py create mode 100644 src/gtask_client_impl/tests/test_core_methods.py create mode 100644 src/gtask_client_impl/tests/test_registration.py create mode 100644 src/gtask_client_impl/tests/test_task_edge_cases.py create mode 100644 src/gtask_client_impl/tests/test_tasklist_edge_cases.py create mode 100644 src/gtask_client_impl/tests/test_tasklist_impl.py diff --git a/src/gtask_client_impl/tests/__init__.py b/src/gtask_client_impl/tests/__init__.py new file mode 100644 index 00000000..09540044 --- /dev/null +++ b/src/gtask_client_impl/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for gtask_client_impl package.""" diff --git a/src/gtask_client_impl/tests/test_authentication.py b/src/gtask_client_impl/tests/test_authentication.py new file mode 100644 index 00000000..7c8d0370 --- /dev/null +++ b/src/gtask_client_impl/tests/test_authentication.py @@ -0,0 +1,413 @@ +"""Unit tests for GTaskClient authentication and helper methods. + +This module contains unit tests for the authentication flow and helper methods +of the GTaskClient class, mocking all external dependencies. +""" + +import os +from typing import Any +from unittest.mock import MagicMock, Mock, patch + +import pytest +from google.auth.exceptions import RefreshError +from google.oauth2.credentials import Credentials + +from gtask_client_impl.gtask_impl import GTaskClient + + +class TestGTaskClientAuthentication: + """Test cases for GTaskClient authentication logic.""" + + @patch("gtask_client_impl.gtask_impl.build") + def test_init_with_provided_service_skips_auth(self, mock_build: Any) -> None: + """Test that providing a service skips authentication.""" + # ARRANGE + mock_service = Mock() + + # ACT + client = GTaskClient(service=mock_service) + + # ASSERT + assert client.service is mock_service + mock_build.assert_not_called() + + @patch("gtask_client_impl.gtask_impl.build") + @patch("gtask_client_impl.gtask_impl.Credentials") + @patch("gtask_client_impl.gtask_impl.Request") + @patch.dict( + os.environ, + { + "TASKS_CLIENT_ID": "test_client_id", + "TASKS_CLIENT_SECRET": "test_client_secret", + "TASKS_REFRESH_TOKEN": "test_refresh_token", + }, + ) + def test_init_with_env_vars_success( + self, + mock_request: Any, + mock_creds_class: Any, + mock_build: Any, + ) -> None: + """Test successful initialization with environment variables.""" + # ARRANGE + mock_creds = Mock(spec=Credentials) + mock_creds.valid = True + mock_creds.refresh_token = "test_refresh_token" + mock_creds.to_json.return_value = '{"token": "test"}' # Mock to_json method + mock_creds_class.return_value = mock_creds + + mock_service = Mock() + mock_build.return_value = mock_service + + # ACT + with ( + patch.object(GTaskClient, "_save_token") as mock_save, + patch("gtask_client_impl.gtask_impl.Path") as mock_path, + ): + # Mock token.json doesn't exist so _save_token will be called + mock_path.return_value.exists.return_value = False + + client = GTaskClient() + + # ASSERT + assert client.service is mock_service + mock_creds_class.assert_called_once_with( + None, + refresh_token="test_refresh_token", + token_uri="https://oauth2.googleapis.com/token", + client_id="test_client_id", + client_secret="test_client_secret", + scopes=GTaskClient.SCOPES, + ) + mock_creds.refresh.assert_called_once() + mock_build.assert_called_once_with("tasks", "v1", credentials=mock_creds) + mock_save.assert_called_once_with(mock_creds, "token.json") + + @patch("gtask_client_impl.gtask_impl.build") + @patch("gtask_client_impl.gtask_impl.Credentials") + @patch("gtask_client_impl.gtask_impl.Request") + @patch.dict( + os.environ, + { + "TASKS_CLIENT_ID": "test_client_id", + "TASKS_CLIENT_SECRET": "test_client_secret", + "TASKS_REFRESH_TOKEN": "test_refresh_token", + "TASKS_TOKEN_URI": "https://custom.oauth.com/token", + }, + ) + def test_init_with_custom_token_uri( + self, + mock_request: Any, + mock_creds_class: Any, + mock_build: Any, + ) -> None: + """Test initialization with custom token URI from environment.""" + # ARRANGE + mock_creds = Mock(spec=Credentials) + mock_creds.valid = True + mock_creds.refresh_token = "test_refresh_token" + mock_creds.to_json.return_value = ( + '{"mock": "token_data"}' # Fix: Return a proper JSON string + ) + mock_creds_class.return_value = mock_creds + + mock_service = Mock() + mock_build.return_value = mock_service + + # ACT + GTaskClient() + + # ASSERT + mock_creds_class.assert_called_once_with( + None, + refresh_token="test_refresh_token", + token_uri="https://custom.oauth.com/token", + client_id="test_client_id", + client_secret="test_client_secret", + scopes=GTaskClient.SCOPES, + ) + + @patch("gtask_client_impl.gtask_impl.build") + @patch("gtask_client_impl.gtask_impl.Path") # Add this patch + @patch("gtask_client_impl.gtask_impl.Credentials") + @patch("gtask_client_impl.gtask_impl.Request") + @patch.dict( + os.environ, + { + "TASKS_CLIENT_ID": "test_client_id", + "TASKS_CLIENT_SECRET": "test_client_secret", + "TASKS_REFRESH_TOKEN": "test_refresh_token", + }, + ) + def test_init_env_vars_refresh_failure( + self, + mock_request: Any, + mock_creds_class: Any, + mock_path: Any, + mock_build: Any, + ) -> None: + """Test handling of refresh failure with environment variables.""" + # ARRANGE + mock_creds = Mock(spec=Credentials) + mock_creds.refresh.side_effect = RefreshError("Token expired") # type: ignore[no-untyped-call] + mock_creds_class.return_value = mock_creds + + mock_service = Mock() + mock_build.return_value = mock_service + + # Mock Path to prevent finding local token file + mock_path.return_value.exists.return_value = False # Add this line + + # ACT & ASSERT - Should now raise error instead of falling back to interactive + with pytest.raises( + RuntimeError, + match="No valid credentials found and interactive mode is disabled", + ): + GTaskClient() + + @patch("gtask_client_impl.gtask_impl.build") + @patch("gtask_client_impl.gtask_impl.Path") + @patch("gtask_client_impl.gtask_impl.Credentials") + def test_init_with_token_file_success( + self, + mock_creds_class: Any, + mock_path: Any, + mock_build: Any, + ) -> None: + """Test successful initialization with token file.""" + # ARRANGE + # Mock environment to not have credentials + with patch.dict(os.environ, {}, clear=True): + mock_token_path = Mock() + mock_token_path.exists.return_value = True + mock_path.return_value = mock_token_path + + mock_creds = Mock(spec=Credentials) + mock_creds.valid = True + mock_creds.refresh_token = "file_token" + mock_creds_class.from_authorized_user_file.return_value = mock_creds + + mock_service = Mock() + mock_build.return_value = mock_service + + # ACT + client = GTaskClient() + + # ASSERT + mock_creds_class.from_authorized_user_file.assert_called_once_with( + "token.json", + GTaskClient.SCOPES, + ) + assert client.service is mock_service + + @patch("gtask_client_impl.gtask_impl.build") + @patch("gtask_client_impl.gtask_impl.Path") + @patch("gtask_client_impl.gtask_impl.Credentials") + @patch("gtask_client_impl.gtask_impl.Request") + def test_init_token_file_needs_refresh( + self, + mock_request: Any, + mock_creds_class: Any, + mock_path: Any, + mock_build: Any, + ) -> None: + """Test token file that needs refresh.""" + # ARRANGE + with patch.dict(os.environ, {}, clear=True): + mock_token_path = Mock() + mock_token_path.exists.return_value = True + mock_path.return_value = mock_token_path + + mock_creds = Mock(spec=Credentials) + mock_creds.valid = False + mock_creds.refresh_token = "file_token" + mock_creds_class.from_authorized_user_file.return_value = mock_creds + + # After refresh, make it valid + def refresh_effect(request: Any) -> None: + mock_creds.valid = True + + mock_creds.refresh.side_effect = refresh_effect + + mock_service = Mock() + mock_build.return_value = mock_service + + # ACT + client = GTaskClient() + + # ASSERT + mock_creds.refresh.assert_called_once() + assert client.service is mock_service + + @patch("gtask_client_impl.gtask_impl.build") + def test_init_interactive_mode_forces_flow(self, mock_build: Any) -> None: + """Test that interactive=True forces interactive flow.""" + # ARRANGE + mock_service = Mock() + mock_build.return_value = mock_service + + with patch.object(GTaskClient, "_run_interactive_flow") as mock_interactive: + mock_creds = Mock(spec=Credentials) + mock_creds.valid = True + mock_creds.refresh_token = "interactive_token" + mock_interactive.return_value = mock_creds + + with patch.object(GTaskClient, "_save_token") as mock_save: + # ACT + GTaskClient(interactive=True) + + # ASSERT + mock_interactive.assert_called_once_with("credentials.json") + mock_save.assert_called_once_with(mock_creds, "token.json") + + def test_init_no_valid_credentials_raises_error(self) -> None: + """Test that initialization raises error when no valid credentials found.""" + # ARRANGE + with ( + patch.dict(os.environ, {}, clear=True), + patch("gtask_client_impl.gtask_impl.Path") as mock_path, + ): + mock_token_path = Mock() + mock_token_path.exists.return_value = False + mock_path.return_value = mock_token_path + + with patch.object(GTaskClient, "_run_interactive_flow") as mock_interactive: + mock_interactive.return_value = None + + # ACT & ASSERT + with pytest.raises( + RuntimeError, + match="No valid credentials found and interactive mode is disabled", + ): + GTaskClient() + + @patch("gtask_client_impl.gtask_impl.build") + def test_build_service_failure(self, mock_build: Any) -> None: + """Test handling of build service failure.""" + # ARRANGE + mock_build.side_effect = Exception("Service build failed") + + with patch.object(GTaskClient, "_run_interactive_flow") as mock_interactive: + mock_creds = Mock(spec=Credentials) + mock_creds.valid = True + mock_creds.to_json.return_value = '{"fake": "token"}' # Add this line + mock_interactive.return_value = mock_creds + + # ACT & ASSERT + with pytest.raises(Exception, match="Service build failed"): + GTaskClient(interactive=True) + + +class TestGTaskClientHelperMethods: + """Test cases for GTaskClient helper methods.""" + + @patch("gtask_client_impl.gtask_impl.InstalledAppFlow") + @patch("gtask_client_impl.gtask_impl.Path") + def test_run_interactive_flow_success(self, mock_path: Any, mock_flow_class: Any) -> None: + """Test successful interactive OAuth flow.""" + # ARRANGE + mock_creds_path = Mock() + mock_creds_path.exists.return_value = True + mock_path.return_value = mock_creds_path + + mock_flow = Mock() + mock_creds = Mock(spec=Credentials) + mock_flow.run_local_server.return_value = mock_creds + mock_flow_class.from_client_secrets_file.return_value = mock_flow + + client = GTaskClient(service=Mock()) # Skip normal init + + # ACT + result = client._run_interactive_flow("credentials.json") + + # ASSERT + assert result is mock_creds + mock_flow_class.from_client_secrets_file.assert_called_once_with( + "credentials.json", + GTaskClient.SCOPES, + ) + mock_flow.run_local_server.assert_called_once_with(port=0) + + @patch("gtask_client_impl.gtask_impl.Path") + def test_run_interactive_flow_missing_credentials(self, mock_path: Any) -> None: + """Test interactive flow with missing credentials file.""" + # ARRANGE + mock_creds_path = Mock() + mock_creds_path.exists.return_value = False + mock_path.return_value = mock_creds_path + + client = GTaskClient(service=Mock()) # Skip normal init + + # ACT & ASSERT + with pytest.raises(FileNotFoundError, match=r"'credentials.json' not found"): + client._run_interactive_flow("credentials.json") + + @patch("gtask_client_impl.gtask_impl.InstalledAppFlow") + @patch("gtask_client_impl.gtask_impl.Path") + def test_run_interactive_flow_exception(self, mock_path: Any, mock_flow_class: Any) -> None: + """Test interactive flow with exception during flow.""" + # ARRANGE + mock_creds_path = Mock() + mock_creds_path.exists.return_value = True + mock_path.return_value = mock_creds_path + + mock_flow_class.from_client_secrets_file.side_effect = Exception("Flow failed") + + client = GTaskClient(service=Mock()) # Skip normal init + + # ACT & ASSERT + with pytest.raises(Exception, match="Flow failed"): + client._run_interactive_flow("credentials.json") + + @patch("gtask_client_impl.gtask_impl.Path") + def test_save_token_success(self, mock_path: Any) -> None: + """Test successful token saving.""" + # ARRANGE + mock_token_path = mock_path.return_value + mock_file_handle = MagicMock() + mock_token_path.open.return_value.__enter__.return_value = mock_file_handle + + client = GTaskClient(service=Mock()) # A dummy client to call the method on + mock_creds = Mock(spec=Credentials) + mock_creds.to_json.return_value = '{"fake": "token"}' + + # ACT + client._save_token(mock_creds, "token.json") + + # ASSERT + mock_token_path.open.assert_called_once_with("w") + mock_file_handle.write.assert_called_once_with('{"fake": "token"}') + + @patch("gtask_client_impl.gtask_impl.Path") + def test_save_token_exception(self, mock_path: Any) -> None: + """Test token saving with exception.""" + # ARRANGE + mock_token_path = Mock() + mock_token_path.open.side_effect = Exception("Write failed") + mock_path.return_value = mock_token_path + + mock_creds = Mock(spec=Credentials) + client = GTaskClient(service=Mock()) # Skip normal init + + # ACT & ASSERT + with pytest.raises(Exception, match="Write failed"): + client._save_token(mock_creds, "token.json") + + +class TestGTaskClientConstants: + """Test cases for GTaskClient constants and class attributes.""" + + def test_scopes_constant(self) -> None: + """Test that SCOPES constant is correctly defined.""" + expected_scopes = [ + "https://www.googleapis.com/auth/tasks", + ] + assert expected_scopes == GTaskClient.SCOPES + + def test_failure_message_constant(self) -> None: + """Test that failure message constant is defined.""" + assert ( + GTaskClient.FAILURE_TO_CRED == "Failed to obtain credentials. Please check your setup." + ) + assert isinstance(GTaskClient.FAILURE_TO_CRED, str) + assert len(GTaskClient.FAILURE_TO_CRED) > 0 diff --git a/src/gtask_client_impl/tests/test_core_methods.py b/src/gtask_client_impl/tests/test_core_methods.py new file mode 100644 index 00000000..65b9acd7 --- /dev/null +++ b/src/gtask_client_impl/tests/test_core_methods.py @@ -0,0 +1,531 @@ +"""Unit tests for GTaskClient core methods. + +This module contains comprehensive unit tests for the core business logic +of the GTaskClient class, mocking all external dependencies. +""" + +import json +from unittest.mock import Mock, patch + +import pytest +from googleapiclient.errors import HttpError + +from gtask_client_impl.gtask_impl import GTaskClient + +# Constants for Tasks API values +DEFAULT_TASKLIST_ID = "@default" +TEST_TASKLIST_ID = "test_tasklist_123" +TEST_TASK_ID = "test_task_123" + + +class TestGTaskClientTaskOperations: + """Test cases for GTaskClient task operations with mocked dependencies.""" + + def setup_method(self) -> None: + """Create a GTaskClient with mocked service.""" + self.mock_service = Mock() + self.client = GTaskClient(service=self.mock_service) + + def test_get_task_success(self) -> None: + """Test successful task retrieval.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + task_id = TEST_TASK_ID + task_data = {"id": task_id, "title": "Test Task", "status": "needsAction"} + raw_content = json.dumps(task_data) + + # Mock the Tasks API call chain + mock_tasks = Mock() + mock_get = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.get.return_value = mock_get + mock_get.execute.return_value = task_data + + # Mock the task factory + mock_task = Mock() + with patch( + "gtask_client_impl.gtask_impl.task.get_task", + return_value=mock_task, + ) as mock_factory: + # ACT + result = self.client.get_task(tasklist_id, task_id) + + # ASSERT + assert result is mock_task + mock_tasks.get.assert_called_once_with( + tasklist=tasklist_id, + task=task_id, + ) + mock_get.execute.assert_called_once() + mock_factory.assert_called_once_with(raw_data=raw_content) + + def test_get_task_api_exception(self) -> None: + """Test get_task when Tasks API raises an exception.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + task_id = TEST_TASK_ID + + mock_tasks = Mock() + mock_get = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.get.return_value = mock_get + mock_get.execute.side_effect = HttpError( + Mock(status=404, reason="Not Found"), b"Task not found" + ) + + # ACT & ASSERT + with pytest.raises(ValueError, match=f"Failed to retrieve task {task_id}"): + self.client.get_task(tasklist_id, task_id) + + def test_delete_task_success(self) -> None: + """Test successful task deletion.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + task_id = TEST_TASK_ID + + mock_tasks = Mock() + mock_delete = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.delete.return_value = mock_delete + mock_delete.execute.return_value = None + + # ACT + result = self.client.delete_task(tasklist_id, task_id) + + # ASSERT + assert result is True + mock_tasks.delete.assert_called_once_with(tasklist=tasklist_id, task=task_id) + mock_delete.execute.assert_called_once() + + def test_delete_task_api_exception(self) -> None: + """Test delete_task when Tasks API raises an exception.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + task_id = TEST_TASK_ID + + mock_tasks = Mock() + mock_delete = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.delete.return_value = mock_delete + error_response = Mock(status=500, reason="Internal Server Error") + mock_delete.execute.side_effect = HttpError(error_response, b"Delete failed") + + # ACT + result = self.client.delete_task(tasklist_id, task_id) + + # ASSERT + assert result is False + + def test_list_tasks_success(self) -> None: + """Test successful task listing.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + mock_tasks_list = [ + {"id": "task1", "title": "Task 1", "status": "needsAction"}, + {"id": "task2", "title": "Task 2", "status": "completed"}, + {"id": "task3", "title": "Task 3", "status": "needsAction"}, + ] + + # Mock the list call + mock_tasks = Mock() + mock_list = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.list.return_value = mock_list + mock_list.execute.return_value = {"items": mock_tasks_list} + + # Mock the task factory + mock_task_1 = Mock() + mock_task_2 = Mock() + mock_task_3 = Mock() + + with patch( + "gtask_client_impl.gtask_impl.task.get_task", + side_effect=[ + mock_task_1, + mock_task_2, + mock_task_3, + ], + ) as mock_factory: + # ACT + tasks = self.client.list_tasks(tasklist_id) + + # ASSERT + assert len(tasks) == len(mock_tasks_list) + assert tasks == [mock_task_1, mock_task_2, mock_task_3] + + # Verify list call + mock_tasks.list.assert_called_once_with(tasklist=tasklist_id) + + # Verify factory calls + assert mock_factory.call_count == len(mock_tasks_list) + mock_factory.assert_any_call(raw_data=json.dumps(mock_tasks_list[0])) + mock_factory.assert_any_call(raw_data=json.dumps(mock_tasks_list[1])) + mock_factory.assert_any_call(raw_data=json.dumps(mock_tasks_list[2])) + + def test_list_tasks_empty_tasklist(self) -> None: + """Test list_tasks when tasklist is empty.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + + mock_tasks = Mock() + mock_list = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.list.return_value = mock_list + mock_list.execute.return_value = {"items": []} + + # ACT + tasks = self.client.list_tasks(tasklist_id) + + # ASSERT + assert len(tasks) == 0 + mock_tasks.list.assert_called_once_with(tasklist=tasklist_id) + + def test_list_tasks_no_items_key(self) -> None: + """Test list_tasks when API response has no 'items' key.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + + mock_tasks = Mock() + mock_list = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.list.return_value = mock_list + mock_list.execute.return_value = {} # No 'items' key + + # ACT + tasks = self.client.list_tasks(tasklist_id) + + # ASSERT + assert len(tasks) == 0 + + def test_list_tasks_api_exception(self) -> None: + """Test list_tasks when Tasks API raises an exception.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + + mock_tasks = Mock() + mock_list = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.list.return_value = mock_list + error_response = Mock(status=500, reason="Internal Server Error") + mock_list.execute.side_effect = HttpError(error_response, b"List failed") + + # ACT + tasks = self.client.list_tasks(tasklist_id) + + # ASSERT + assert len(tasks) == 0 + + def test_insert_task_success(self) -> None: + """Test successful task insertion.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + task_data = {"id": TEST_TASK_ID, "title": "New Task", "status": "needsAction"} + raw_content = json.dumps(task_data) + + # Mock task object + mock_task_input = Mock() + mock_task_input.title = "New Task" + mock_task_input.notes = None + mock_task_input.status = None + mock_task_input.due = None + + # Mock the Tasks API call chain + mock_tasks = Mock() + mock_insert = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.insert.return_value = mock_insert + mock_insert.execute.return_value = task_data + + # Mock the task factory + mock_task_output = Mock() + with patch( + "gtask_client_impl.gtask_impl.task.get_task", + return_value=mock_task_output, + ) as mock_factory: + # ACT + result = self.client.insert_task(tasklist_id, mock_task_input) + + # ASSERT + assert result is mock_task_output + mock_tasks.insert.assert_called_once_with( + tasklist=tasklist_id, + body={"title": "New Task"}, + ) + mock_insert.execute.assert_called_once() + mock_factory.assert_called_once_with(raw_data=raw_content) + + def test_insert_task_with_all_fields(self) -> None: + """Test insert_task with all optional fields.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + task_data = { + "id": TEST_TASK_ID, + "title": "New Task", + "notes": "Task notes", + "status": "needsAction", + "due": "2024-12-31T00:00:00Z", + } + + # Mock task object with all fields + mock_task_input = Mock() + mock_task_input.title = "New Task" + mock_task_input.notes = "Task notes" + mock_task_input.status = "needsAction" + mock_task_input.due = "2024-12-31T00:00:00Z" + + mock_tasks = Mock() + mock_insert = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.insert.return_value = mock_insert + mock_insert.execute.return_value = task_data + + mock_task_output = Mock() + with patch( + "gtask_client_impl.gtask_impl.task.get_task", + return_value=mock_task_output, + ): + # ACT + self.client.insert_task(tasklist_id, mock_task_input) + + # ASSERT + mock_tasks.insert.assert_called_once_with( + tasklist=tasklist_id, + body={ + "title": "New Task", + "notes": "Task notes", + "status": "needsAction", + "due": "2024-12-31T00:00:00Z", + }, + ) + + def test_insert_task_api_exception(self) -> None: + """Test insert_task when Tasks API raises an exception.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + + mock_task_input = Mock() + mock_task_input.title = "New Task" + mock_task_input.notes = None + mock_task_input.status = None + mock_task_input.due = None + + mock_tasks = Mock() + mock_insert = Mock() + + self.mock_service.tasks.return_value = mock_tasks + mock_tasks.insert.return_value = mock_insert + error_response = Mock(status=400, reason="Bad Request") + mock_insert.execute.side_effect = HttpError(error_response, b"Insert failed") + + # ACT & ASSERT + with pytest.raises(HttpError): + self.client.insert_task(tasklist_id, mock_task_input) + + +class TestGTaskClientTaskListOperations: + """Test cases for GTaskClient tasklist operations with mocked dependencies.""" + + def setup_method(self) -> None: + """Create a GTaskClient with mocked service.""" + self.mock_service = Mock() + self.client = GTaskClient(service=self.mock_service) + + def test_list_tasklists_success(self) -> None: + """Test successful tasklist listing.""" + # ARRANGE + mock_tasklists_list = [ + {"id": "list1", "title": "TaskList 1", "etag": "etag1"}, + {"id": "list2", "title": "TaskList 2", "etag": "etag2"}, + {"id": "list3", "title": "TaskList 3", "etag": "etag3"}, + ] + + # Mock the list call + mock_tasklists = Mock() + mock_list = Mock() + + self.mock_service.tasklists.return_value = mock_tasklists + mock_tasklists.list.return_value = mock_list + mock_list.execute.return_value = {"items": mock_tasklists_list} + + # Mock the tasklist factory + mock_tasklist_1 = Mock() + mock_tasklist_2 = Mock() + mock_tasklist_3 = Mock() + + with patch( + "gtask_client_impl.gtask_impl.tasklist.get_tasklist", + side_effect=[ + mock_tasklist_1, + mock_tasklist_2, + mock_tasklist_3, + ], + ) as mock_factory: + # ACT + tasklists = self.client.list_tasklists() + + # ASSERT + assert len(tasklists) == len(mock_tasklists_list) + assert tasklists == [mock_tasklist_1, mock_tasklist_2, mock_tasklist_3] + + # Verify list call + mock_tasklists.list.assert_called_once() + + # Verify factory calls + assert mock_factory.call_count == len(mock_tasklists_list) + mock_factory.assert_any_call(raw_data=json.dumps(mock_tasklists_list[0])) + mock_factory.assert_any_call(raw_data=json.dumps(mock_tasklists_list[1])) + mock_factory.assert_any_call(raw_data=json.dumps(mock_tasklists_list[2])) + + def test_list_tasklists_empty(self) -> None: + """Test list_tasklists when user has no tasklists.""" + # ARRANGE + mock_tasklists = Mock() + mock_list = Mock() + + self.mock_service.tasklists.return_value = mock_tasklists + mock_tasklists.list.return_value = mock_list + mock_list.execute.return_value = {"items": []} + + # ACT + tasklists = self.client.list_tasklists() + + # ASSERT + assert len(tasklists) == 0 + mock_tasklists.list.assert_called_once() + + def test_list_tasklists_no_items_key(self) -> None: + """Test list_tasklists when API response has no 'items' key.""" + # ARRANGE + mock_tasklists = Mock() + mock_list = Mock() + + self.mock_service.tasklists.return_value = mock_tasklists + mock_tasklists.list.return_value = mock_list + mock_list.execute.return_value = {} # No 'items' key + + # ACT + tasklists = self.client.list_tasklists() + + # ASSERT + assert len(tasklists) == 0 + + def test_list_tasklists_api_exception(self) -> None: + """Test list_tasklists when Tasks API raises an exception.""" + # ARRANGE + mock_tasklists = Mock() + mock_list = Mock() + + self.mock_service.tasklists.return_value = mock_tasklists + mock_tasklists.list.return_value = mock_list + error_response = Mock(status=500, reason="Internal Server Error") + mock_list.execute.side_effect = HttpError(error_response, b"List failed") + + # ACT + tasklists = self.client.list_tasklists() + + # ASSERT + assert len(tasklists) == 0 + + def test_insert_tasklist_success(self) -> None: + """Test successful tasklist insertion.""" + # ARRANGE + tasklist_data = { + "id": TEST_TASKLIST_ID, + "title": "New TaskList", + "etag": "etag123", + } + raw_content = json.dumps(tasklist_data) + + # Mock tasklist object + mock_tasklist_input = Mock() + mock_tasklist_input.title = "New TaskList" + + # Mock the TaskLists API call chain + mock_tasklists = Mock() + mock_insert = Mock() + + self.mock_service.tasklists.return_value = mock_tasklists + mock_tasklists.insert.return_value = mock_insert + mock_insert.execute.return_value = tasklist_data + + # Mock the tasklist factory + mock_tasklist_output = Mock() + with patch( + "gtask_client_impl.gtask_impl.tasklist.get_tasklist", + return_value=mock_tasklist_output, + ) as mock_factory: + # ACT + result = self.client.insert_tasklist(mock_tasklist_input) + + # ASSERT + assert result is mock_tasklist_output + mock_tasklists.insert.assert_called_once_with(body={"title": "New TaskList"}) + mock_insert.execute.assert_called_once() + mock_factory.assert_called_once_with(raw_data=raw_content) + + def test_insert_tasklist_api_exception(self) -> None: + """Test insert_tasklist when Tasks API raises an exception.""" + # ARRANGE + mock_tasklist_input = Mock() + mock_tasklist_input.title = "New TaskList" + + mock_tasklists = Mock() + mock_insert = Mock() + + self.mock_service.tasklists.return_value = mock_tasklists + mock_tasklists.insert.return_value = mock_insert + error_response = Mock(status=400, reason="Bad Request") + mock_insert.execute.side_effect = HttpError(error_response, b"Insert failed") + + # ACT & ASSERT + with pytest.raises(HttpError): + self.client.insert_tasklist(mock_tasklist_input) + + def test_delete_tasklist_success(self) -> None: + """Test successful tasklist deletion.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + + mock_tasklists = Mock() + mock_delete = Mock() + + self.mock_service.tasklists.return_value = mock_tasklists + mock_tasklists.delete.return_value = mock_delete + mock_delete.execute.return_value = None + + # ACT + result = self.client.delete_tasklist(tasklist_id) + + # ASSERT + assert result is True + mock_tasklists.delete.assert_called_once_with(tasklist=tasklist_id) + mock_delete.execute.assert_called_once() + + def test_delete_tasklist_api_exception(self) -> None: + """Test delete_tasklist when Tasks API raises an exception.""" + # ARRANGE + tasklist_id = TEST_TASKLIST_ID + + mock_tasklists = Mock() + mock_delete = Mock() + + self.mock_service.tasklists.return_value = mock_tasklists + mock_tasklists.delete.return_value = mock_delete + error_response = Mock(status=500, reason="Internal Server Error") + mock_delete.execute.side_effect = HttpError(error_response, b"Delete failed") + + # ACT + result = self.client.delete_tasklist(tasklist_id) + + # ASSERT + assert result is False diff --git a/src/gtask_client_impl/tests/test_registration.py b/src/gtask_client_impl/tests/test_registration.py new file mode 100644 index 00000000..5c5301b0 --- /dev/null +++ b/src/gtask_client_impl/tests/test_registration.py @@ -0,0 +1,52 @@ +"""Tests for explicit registration helpers exposed by ``gtask_client_impl``.""" + +import importlib + +import pytest +import task_client_api +from task_client_api import task as task_protocol +from task_client_api import tasklist as tasklist_protocol + +import gtask_client_impl + + +def test_register_binds_factories(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure calling register wires the client, task, and tasklist factories.""" + client_protocol = importlib.import_module("task_client_api.client") + task_protocol_module = importlib.import_module("task_client_api.task") + tasklist_protocol_module = importlib.import_module("task_client_api.tasklist") + + # Reset to protocol defaults before invoking register. + monkeypatch.setattr(task_client_api, "get_client", client_protocol.get_client, raising=False) + monkeypatch.setattr( + task_protocol, + "get_task", + task_protocol_module.get_task, + raising=False, + ) + monkeypatch.setattr( + task_client_api, + "get_task", + task_protocol_module.get_task, + raising=False, + ) + monkeypatch.setattr( + tasklist_protocol, + "get_tasklist", + tasklist_protocol_module.get_tasklist, + raising=False, + ) + monkeypatch.setattr( + task_client_api, + "get_tasklist", + tasklist_protocol_module.get_tasklist, + raising=False, + ) + + gtask_client_impl.register() + + assert task_client_api.get_client is gtask_client_impl.get_client_impl + assert task_protocol.get_task is gtask_client_impl.get_task_impl + assert task_client_api.get_task is gtask_client_impl.get_task_impl + assert tasklist_protocol.get_tasklist is gtask_client_impl.get_tasklist_impl + assert task_client_api.get_tasklist is gtask_client_impl.get_tasklist_impl diff --git a/src/gtask_client_impl/tests/test_task_edge_cases.py b/src/gtask_client_impl/tests/test_task_edge_cases.py new file mode 100644 index 00000000..1063e3fc --- /dev/null +++ b/src/gtask_client_impl/tests/test_task_edge_cases.py @@ -0,0 +1,290 @@ +"""Edge case tests for the colocated GTask implementation.""" + +import json + +from gtask_client_impl.task_impl import GTask + + +class TestEdgeCases: + """Test cases for edge cases and error conditions.""" + + VERY_LONG_TITLE_MIN_LENGTH = 1000 + VERY_LONG_NOTES_MIN_LENGTH = 30000 + + def test_extremely_large_task_id(self) -> None: + """Test handling of extremely large task IDs.""" + long_id = "x" * 1000 + task_data = { + "id": long_id, + "title": "Long ID Test", + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.id == long_id + assert task.title == "Long ID Test" + + def test_unicode_in_task_content(self) -> None: + """Test handling of Unicode characters in title and notes.""" + task_data = { + "id": "unicode123", + "title": "🎉 Unicode Test 测试 🌟", + "notes": "Unicode notes: こんにちは 世界! 🌍 Café naïve résumé", + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert "Unicode Test" in task.title + assert "🎉" in task.title + assert task.notes is not None + assert "こんにちは 世界! 🌍" in task.notes + assert "Café naïve résumé" in task.notes + + def test_very_long_title(self) -> None: + """Test handling of very long title.""" + long_title = "Very Long Title " * 100 + task_data = { + "id": "longtitle123", + "title": long_title, + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.title == long_title + assert len(task.title) > self.VERY_LONG_TITLE_MIN_LENGTH + + def test_very_long_notes(self) -> None: + """Test handling of very long notes.""" + long_notes = "This is a very very long note. " * 1000 + task_data = { + "id": "longnotes123", + "title": "Long Notes Test", + "notes": long_notes, + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.title == "Long Notes Test" + assert task.notes is not None + assert len(task.notes) > self.VERY_LONG_NOTES_MIN_LENGTH + assert "This is a very very long note." in task.notes + + def test_malformed_json(self) -> None: + """Test handling of malformed JSON data.""" + malformed_json = '{"id": "test", "title": "Test", invalid}' + + task = GTask(raw_data=malformed_json) + + assert task.id == "" # Empty dict defaults + assert task.title == "" + assert task.notes is None + assert task.status == "needsAction" + assert task.deleted is False + assert task.hidden is False + + def test_empty_json_string(self) -> None: + """Test handling of empty JSON string.""" + empty_json = "{}" + + task = GTask(raw_data=empty_json) + + assert task.id == "" + assert task.title == "" + assert task.notes is None + assert task.status == "needsAction" + assert task.due is None + assert task.completed is None + assert task.deleted is False + assert task.hidden is False + + def test_invalid_json(self) -> None: + """Test handling of completely invalid JSON.""" + invalid_json = "This is not JSON at all!!!" + + task = GTask(raw_data=invalid_json) + + assert task.id == "" + assert task.title == "" + assert task.notes is None + assert task.status == "needsAction" + assert task.deleted is False + assert task.hidden is False + + def test_whitespace_only_json(self) -> None: + """Test JSON string that is only whitespace characters.""" + whitespace_json = " \t\n " + + task = GTask(raw_data=whitespace_json) + + assert task.id == "" + assert task.title == "" + assert isinstance(task.status, str) + + def test_non_ascii_task_id(self) -> None: + """Test non-ASCII characters in task ID.""" + unicode_id = "task_测试_🎉_123" + task_data = { + "id": unicode_id, + "title": "Unicode ID Test", + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.id == unicode_id + assert task.title == "Unicode ID Test" + + def test_nested_json_structure(self) -> None: + """Test JSON with nested structures (should ignore extra nested data).""" + task_data = { + "id": "nested123", + "title": "Nested Test", + "extra": { + "nested": {"data": "ignored"}, + "array": [1, 2, 3], + }, + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.id == "nested123" + assert task.title == "Nested Test" + + def test_task_with_null_values(self) -> None: + """Test task containing null values for optional fields.""" + task_data = { + "id": "null123", + "title": "Null Test", + "notes": None, + "due": None, + "completed": None, + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.id == "null123" + assert task.title == "Null Test" + assert task.notes is None + assert task.due is None + assert task.completed is None + + def test_repeated_property_access(self) -> None: + """Test that accessing properties multiple times yields consistent results.""" + task_data = { + "id": "repeat123", + "title": "Repeat Test", + "notes": "Consistent notes", + "status": "completed", + "due": "2025-07-30T10:30:00Z", + "completed": "2025-07-29T15:45:00Z", + "deleted": True, + "hidden": True, + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + for _ in range(5): + assert task.id == "repeat123" + assert task.title == "Repeat Test" + assert task.notes == "Consistent notes" + assert task.status == "completed" + assert task.due == "2025-07-30T10:30:00Z" + assert task.completed == "2025-07-29T15:45:00Z" + assert task.deleted is True + assert task.hidden is True + + def test_task_with_only_id_and_title(self) -> None: + """Test task that contains only id and title fields.""" + task_data = { + "id": "minimal123", + "title": "Minimal Fields", + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.id == "minimal123" + assert task.title == "Minimal Fields" + assert task.notes is None + assert task.status == "needsAction" + assert task.due is None + assert task.completed is None + assert task.deleted is False + assert task.hidden is False + + def test_task_with_boolean_edge_cases(self) -> None: + """Test task with boolean values set explicitly.""" + task_data_true = { + "id": "booltrue123", + "title": "Boolean True", + "deleted": True, + "hidden": True, + } + + task_data_false = { + "id": "boolfalse123", + "title": "Boolean False", + "deleted": False, + "hidden": False, + } + + raw_data_true = json.dumps(task_data_true) + raw_data_false = json.dumps(task_data_false) + + task_true = GTask(raw_data=raw_data_true) + task_false = GTask(raw_data=raw_data_false) + + assert task_true.deleted is True + assert task_true.hidden is True + assert task_false.deleted is False + assert task_false.hidden is False + + def test_task_with_rfc3339_timestamp_variations(self) -> None: + """Test task with various RFC 3339 timestamp formats.""" + task_data = { + "id": "rfc3339_123", + "title": "RFC 3339 Test", + "due": "2025-07-30T10:30:00Z", + "completed": "2025-07-29T15:45:00+00:00", + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.due == "2025-07-30T10:30:00Z" + assert task.completed == "2025-07-29T15:45:00+00:00" + + def test_task_with_extra_unexpected_fields(self) -> None: + """Test JSON with extra fields not part of the task schema.""" + task_data = { + "id": "extra123", + "title": "Extra Fields Test", + "unknown_field": "should be ignored", + "another_unknown": 12345, + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.id == "extra123" + assert task.title == "Extra Fields Test" + + def test_task_with_numeric_id(self) -> None: + """Test task with numeric ID (should be converted to string).""" + task_data = { + "id": "12345", + "title": "Numeric ID Test", + } + + raw_data = json.dumps(task_data) + task = GTask(raw_data=raw_data) + + assert task.id == "12345" # JSON number gets converted + assert task.title == "Numeric ID Test" diff --git a/src/gtask_client_impl/tests/test_tasklist_edge_cases.py b/src/gtask_client_impl/tests/test_tasklist_edge_cases.py new file mode 100644 index 00000000..e2bc20e4 --- /dev/null +++ b/src/gtask_client_impl/tests/test_tasklist_edge_cases.py @@ -0,0 +1,280 @@ +"""Edge case tests for the colocated GTaskList implementation.""" + +import json + +from gtask_client_impl.tasklist_impl import GTaskList + + +class TestEdgeCases: + """Test cases for edge cases and error conditions.""" + + VERY_LONG_TITLE_MIN_LENGTH = 1000 + VERY_LONG_ETAG_MIN_LENGTH = 500 + + def test_extremely_large_tasklist_id(self) -> None: + """Test handling of extremely large tasklist IDs.""" + long_id = "x" * 1000 + tasklist_data = { + "id": long_id, + "title": "Long ID Test", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == long_id + assert tasklist.title == "Long ID Test" + + def test_unicode_in_tasklist_content(self) -> None: + """Test handling of Unicode characters in title.""" + tasklist_data = { + "id": "unicode123", + "title": "🎉 Unicode Test 测试 🌟", + "etag": "etag_测试_🎉", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert "Unicode Test" in tasklist.title + assert "🎉" in tasklist.title + assert "etag_测试_🎉" in tasklist.etag + + def test_very_long_title(self) -> None: + """Test handling of very long title.""" + long_title = "Very Long Title " * 100 + tasklist_data = { + "id": "longtitle123", + "title": long_title, + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.title == long_title + assert len(tasklist.title) > self.VERY_LONG_TITLE_MIN_LENGTH + + def test_very_long_etag(self) -> None: + """Test handling of very long ETag.""" + long_etag = 'W/"etag_value_' + "x" * 500 + '"' + tasklist_data = { + "id": "longetag123", + "title": "Long ETag Test", + "etag": long_etag, + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.title == "Long ETag Test" + assert len(tasklist.etag) > self.VERY_LONG_ETAG_MIN_LENGTH + assert "etag_value_" in tasklist.etag + + def test_malformed_json(self) -> None: + """Test handling of malformed JSON data.""" + malformed_json = '{"id": "test", "title": "Test", invalid}' + + tasklist = GTaskList(raw_data=malformed_json) + + assert tasklist.id == "" # Empty dict defaults + assert tasklist.title == "" + assert tasklist.etag == "" + assert tasklist.updated == "" + assert tasklist.self_link == "" + + def test_empty_json_string(self) -> None: + """Test handling of empty JSON string.""" + empty_json = "{}" + + tasklist = GTaskList(raw_data=empty_json) + + assert tasklist.id == "" + assert tasklist.title == "" + assert tasklist.etag == "" + assert tasklist.updated == "" + assert tasklist.self_link == "" + + def test_invalid_json(self) -> None: + """Test handling of completely invalid JSON.""" + invalid_json = "This is not JSON at all!!!" + + tasklist = GTaskList(raw_data=invalid_json) + + assert tasklist.id == "" + assert tasklist.title == "" + assert tasklist.etag == "" + assert tasklist.updated == "" + assert tasklist.self_link == "" + + def test_whitespace_only_json(self) -> None: + """Test JSON string that is only whitespace characters.""" + whitespace_json = " \t\n " + + tasklist = GTaskList(raw_data=whitespace_json) + + assert tasklist.id == "" + assert tasklist.title == "" + assert isinstance(tasklist.etag, str) + + def test_non_ascii_tasklist_id(self) -> None: + """Test non-ASCII characters in tasklist ID.""" + unicode_id = "tasklist_测试_🎉_123" + tasklist_data = { + "id": unicode_id, + "title": "Unicode ID Test", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == unicode_id + assert tasklist.title == "Unicode ID Test" + + def test_nested_json_structure(self) -> None: + """Test JSON with nested structures (should ignore extra nested data).""" + tasklist_data = { + "id": "nested123", + "title": "Nested Test", + "extra": { + "nested": {"data": "ignored"}, + "array": [1, 2, 3], + }, + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == "nested123" + assert tasklist.title == "Nested Test" + + def test_tasklist_with_null_values(self) -> None: + """Test tasklist containing null values for optional fields.""" + tasklist_data = { + "id": "null123", + "title": "Null Test", + "etag": None, + "updated": None, + "selfLink": None, + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == "null123" + assert tasklist.title == "Null Test" + assert tasklist.etag is None + assert tasklist.updated is None + assert tasklist.self_link is None + + def test_repeated_property_access(self) -> None: + """Test that accessing properties multiple times yields consistent results.""" + tasklist_data = { + "id": "repeat123", + "title": "Repeat Test", + "etag": "etag_repeat", + "updated": "2025-07-30T10:30:00Z", + "selfLink": "https://www.googleapis.com/tasks/v1/lists/repeat123", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + for _ in range(5): + assert tasklist.id == "repeat123" + assert tasklist.title == "Repeat Test" + assert tasklist.etag == "etag_repeat" + assert tasklist.updated == "2025-07-30T10:30:00Z" + assert tasklist.self_link == "https://www.googleapis.com/tasks/v1/lists/repeat123" + + def test_tasklist_with_only_id_and_title(self) -> None: + """Test tasklist that contains only id and title fields.""" + tasklist_data = { + "id": "minimal123", + "title": "Minimal Fields", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == "minimal123" + assert tasklist.title == "Minimal Fields" + assert tasklist.etag == "" + assert tasklist.updated == "" + assert tasklist.self_link == "" + + def test_tasklist_with_rfc3339_timestamp_variations(self) -> None: + """Test tasklist with various RFC 3339 timestamp formats.""" + tasklist_data = { + "id": "rfc3339_123", + "title": "RFC 3339 Test", + "updated": "2025-07-30T10:30:00Z", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.updated == "2025-07-30T10:30:00Z" + + def test_tasklist_with_extra_unexpected_fields(self) -> None: + """Test JSON with extra fields not part of the tasklist schema.""" + tasklist_data = { + "id": "extra123", + "title": "Extra Fields Test", + "unknown_field": "should be ignored", + "another_unknown": 12345, + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == "extra123" + assert tasklist.title == "Extra Fields Test" + + def test_tasklist_with_numeric_id(self) -> None: + """Test tasklist with numeric ID (should be converted to string).""" + tasklist_data = { + "id": "12345", + "title": "Numeric ID Test", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == "12345" + assert tasklist.title == "Numeric ID Test" + + def test_tasklist_with_complex_self_link(self) -> None: + """Test tasklist with complex selfLink URL.""" + complex_link = ( + "https://www.googleapis.com/tasks/v1/users/@me/lists/tasklist123?fields=id,title" + ) + tasklist_data = { + "id": "complexlink123", + "title": "Complex Self Link Test", + "selfLink": complex_link, + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.self_link == complex_link + + def test_tasklist_with_etag_variations(self) -> None: + """Test tasklist with different ETag formats.""" + etag_variations = [ + 'W/"etag_value"', + "etag_simple", + 'W/"etag_with_special_chars_!@#$%"', + ] + + for etag in etag_variations: + tasklist_data = { + "id": f"etag_{etag_variations.index(etag)}", + "title": "ETag Variation Test", + "etag": etag, + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.etag == etag diff --git a/src/gtask_client_impl/tests/test_tasklist_impl.py b/src/gtask_client_impl/tests/test_tasklist_impl.py new file mode 100644 index 00000000..bc6c63e0 --- /dev/null +++ b/src/gtask_client_impl/tests/test_tasklist_impl.py @@ -0,0 +1,121 @@ +"""Tests for GTaskList implementation colocated with GTaskClient.""" + +import json + +from gtask_client_impl.tasklist_impl import GTaskList + + +class TestGTaskList: + """Test cases for the GTaskList class.""" + + def test_basic_tasklist_creation(self) -> None: + """Test creating a GTaskList with valid data.""" + tasklist_data = { + "id": "tasklist123", + "title": "Test TaskList", + "etag": "etag123456", + "updated": "2025-07-30T10:30:00Z", + "selfLink": "https://www.googleapis.com/tasks/v1/lists/tasklist123", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == "tasklist123" + assert tasklist.title == "Test TaskList" + assert tasklist.etag == "etag123456" + assert tasklist.updated == "2025-07-30T10:30:00Z" + assert tasklist.self_link == "https://www.googleapis.com/tasks/v1/lists/tasklist123" + + def test_tasklist_with_minimal_data(self) -> None: + """Test tasklist with only required fields (title).""" + tasklist_data = { + "id": "minimal123", + "title": "Minimal TaskList", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == "minimal123" + assert tasklist.title == "Minimal TaskList" + assert tasklist.etag == "" # Default value + assert tasklist.updated == "" # Default value + assert tasklist.self_link == "" # Default value + + def test_tasklist_with_all_fields(self) -> None: + """Test tasklist with all fields populated.""" + tasklist_data = { + "id": "full123", + "title": "Complete TaskList", + "etag": "etag789012", + "updated": "2025-07-30T15:45:00Z", + "selfLink": "https://www.googleapis.com/tasks/v1/lists/full123", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == "full123" + assert tasklist.title == "Complete TaskList" + assert tasklist.etag == "etag789012" + assert tasklist.updated == "2025-07-30T15:45:00Z" + assert tasklist.self_link == "https://www.googleapis.com/tasks/v1/lists/full123" + + def test_tasklist_with_etag(self) -> None: + """Test tasklist with ETag field.""" + tasklist_data = { + "id": "etag123", + "title": "TaskList with ETag", + "etag": 'W/"etag_value_123"', + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.etag == 'W/"etag_value_123"' + + def test_tasklist_with_updated_timestamp(self) -> None: + """Test tasklist with updated timestamp (RFC 3339).""" + tasklist_data = { + "id": "updated123", + "title": "TaskList with Updated", + "updated": "2025-08-15T23:59:59Z", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.updated == "2025-08-15T23:59:59Z" + + def test_tasklist_with_self_link(self) -> None: + """Test tasklist with selfLink field.""" + tasklist_data = { + "id": "selflink123", + "title": "TaskList with Self Link", + "selfLink": "https://www.googleapis.com/tasks/v1/lists/selflink123", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.self_link == "https://www.googleapis.com/tasks/v1/lists/selflink123" + + def test_tasklist_with_empty_strings(self) -> None: + """Test tasklist with empty string values.""" + tasklist_data = { + "id": "", + "title": "", + "etag": "", + "updated": "", + "selfLink": "", + } + + raw_data = json.dumps(tasklist_data) + tasklist = GTaskList(raw_data=raw_data) + + assert tasklist.id == "" + assert tasklist.title == "" + assert tasklist.etag == "" + assert tasklist.updated == "" + assert tasklist.self_link == "" From ce878b3aafd1738eb4c7f98611a2a94415a35cc1 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Thu, 30 Oct 2025 23:11:40 -0400 Subject: [PATCH 25/33] fix: resolve import errors task-client-service config: update task-client-service config fix: task-client-service imports and insert task creation handling docs: create doc for task-client-service a --- src/task_client_api/pyproject.toml | 2 +- src/task_client_service/README.md | 112 ++++++++++++++++++ src/task_client_service/pyproject.toml | 27 ++++- .../src/task_client_service/__init__.py | 7 -- .../src/task_client_service/dependencies.py | 1 - .../task_client_service/fast_api_service.py | 12 +- .../src/task_client_service/task_router.py | 25 ++-- .../task_client_service/tasklist_router.py | 38 ++---- uv.lock | 16 ++- 9 files changed, 179 insertions(+), 61 deletions(-) diff --git a/src/task_client_api/pyproject.toml b/src/task_client_api/pyproject.toml index 0d22a134..ff230cba 100644 --- a/src/task_client_api/pyproject.toml +++ b/src/task_client_api/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "task_client_api" +name = "task-client-api" version = "0.1.0" description = "Add your description here" readme = "README.md" diff --git a/src/task_client_service/README.md b/src/task_client_service/README.md index e69de29b..73ddd65f 100644 --- a/src/task_client_service/README.md +++ b/src/task_client_service/README.md @@ -0,0 +1,112 @@ +## Task Client Service (FastAPI) + +A FastAPI service that wires up the workspace `task-client-api` with the `gtask-client-impl`. It initializes the task client on startup to ensure the Google Tasks implementation registers correctly. + +### Available Endpoints + +The service provides a RESTful API for task and tasklist operations, following REST conventions: + +#### Tasklist Endpoints + +- `GET /tasklists` - List all tasklists from the task client +- `POST /tasklists` - Create a new tasklist +- `DELETE /tasklists/{tasklist_id}` - Delete a tasklist by ID + +#### Task Endpoints + +- `GET /tasks/{tasklist_id}` - List all tasks within a given tasklist +- `GET /tasks/{tasklist_id}/{task_id}` - Retrieve a specific task by ID from a tasklist +- `POST /tasks/{tasklist_id}` - Create a new task in a tasklist +- `DELETE /tasks/{tasklist_id}/{task_id}` - Delete a task from a tasklist + +All endpoints return JSON responses and use appropriate HTTP status codes: + +- `200 OK` - Successful operations +- `400 Bad Request` - Invalid request data or attempting to delete default tasklist +- `404 Not Found` - Task or tasklist not found +- `409 Conflict` - Tasklist with the same title already exists +- `500 Internal Server Error` - Client exceptions or server errors + +### Prerequisites + +- **Python 3.11+** +- **uv** (package manager) + +Note: This repository already contains a `token.json` at the repo root used by the Google Tasks implementation for non-interactive startup. + +### Running the Service + +#### Run with uv (recommended) + +1. Install dependencies for this package (and link workspace members): + ```bash + uv sync --all-packages --extra dev + ``` +2. Start the FastAPI development server: + ```bash + uvicorn task_client_service.fast_api_service:app --reload --port 8001 + ``` +3. The service will be available at `http://127.0.0.1:8001` + +4. Open the docs UI: + - Swagger UI: `http://127.0.0.1:8001/docs` + +#### Example Usage + +```bash +# List all tasklists +curl http://127.0.0.1:8001/tasklists + +# Create a new tasklist +curl -X POST http://127.0.0.1:8001/tasklists \ + -H "Content-Type: application/json" \ + -d '{"title": "My New Task List"}' + +# List tasks in a tasklist +curl http://127.0.0.1:8001/tasks/tasklist_12345 + +# Get a specific task +curl http://127.0.0.1:8001/tasks/tasklist_12345/task_67890 + +# Create a new task +curl -X POST http://127.0.0.1:8001/tasks/tasklist_12345 \ + -H "Content-Type: application/json" \ + -d '{ + "title": "My New Task", + "notes": "This is a new task", + "status": "needsAction", + "due": "2025-11-15T00:00:00.000Z" + }' + +# Delete a task +curl -X DELETE http://127.0.0.1:8001/tasks/tasklist_12345/task_67890 + +# Delete a tasklist +curl -X DELETE http://127.0.0.1:8001/tasklists/tasklist_12345 +``` + +### Testing + +This service includes comprehensive unit tests for all endpoints using FastAPI's testing best practices. + +#### Run Tests with uv + +```bash +# Install test dependencies +uv sync --extra test + +# Run all tests +uv run pytest + +# Run tests with coverage +uv run pytest --cov=src/task_client_service --cov-report=term-missing +``` + +### Notes + +- The application imports `gtask_client_impl` and calls `task_client_api.get_client(interactive=False)` during FastAPI startup to validate registration and basic initialization. +- If Google Tasks credentials are not present/valid, startup may log an error in environments without `token.json`. In this repo, a `token.json` exists at the root. +- All endpoints delegate operations to the underlying `task_client_api.Client` implementation, providing a thin REST wrapper over the component functionality. +- Error handling consistently returns appropriate HTTP status codes with error messages for debugging while maintaining API consistency. +- The service follows REST conventions with appropriate HTTP methods (GET for retrieval, POST for creation, DELETE for removal). +- The default tasklist cannot be deleted and will return a 400 Bad Request error if attempted. diff --git a/src/task_client_service/pyproject.toml b/src/task_client_service/pyproject.toml index 162ef895..e3cfa3e5 100644 --- a/src/task_client_service/pyproject.toml +++ b/src/task_client_service/pyproject.toml @@ -4,4 +4,29 @@ version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.11" -dependencies = [] + +dependencies = [ + "fastapi>=0.115.0", + "uvicorn>=0.30.0", + "task_client_api", + "gtask_client_impl", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/task_client_service"] + +[tool.ruff] +line-length = 100 +target-version = "py311" +extend = "../../pyproject.toml" + +[tool.ruff.lint] +ignore = [] + +[tool.uv.sources] +task-client-api = { workspace = true } +gtask-client-impl = { workspace = true } diff --git a/src/task_client_service/src/task_client_service/__init__.py b/src/task_client_service/src/task_client_service/__init__.py index 1c386424..de5af174 100644 --- a/src/task_client_service/src/task_client_service/__init__.py +++ b/src/task_client_service/src/task_client_service/__init__.py @@ -1,14 +1,7 @@ """Task Client Service - FastAPI service for task client operations.""" -from .dependencies import TaskClientDep, get_task_client from .fast_api_service import app -from .task_router import router as task_router -from .tasklist_router import router as tasklist_router __all__ = [ - "TaskClientDep", "app", - "get_task_client", - "task_router", - "tasklist_router", ] diff --git a/src/task_client_service/src/task_client_service/dependencies.py b/src/task_client_service/src/task_client_service/dependencies.py index 113d4e09..f30a43ef 100644 --- a/src/task_client_service/src/task_client_service/dependencies.py +++ b/src/task_client_service/src/task_client_service/dependencies.py @@ -3,7 +3,6 @@ from typing import Annotated, cast from fastapi import Depends, Request - from task_client_api import Client diff --git a/src/task_client_service/src/task_client_service/fast_api_service.py b/src/task_client_service/src/task_client_service/fast_api_service.py index 1fcfc8f3..7f5522d5 100644 --- a/src/task_client_service/src/task_client_service/fast_api_service.py +++ b/src/task_client_service/src/task_client_service/fast_api_service.py @@ -4,9 +4,8 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from fastapi import FastAPI - import gtask_client_impl # noqa: F401 +from fastapi import FastAPI from task_client_api import get_client from .task_router import router as task_router @@ -24,7 +23,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Manage application lifespan and initialize mail client.""" logger.info("Starting Task Client Service...") try: - client = get_client(interactive=True) + client = get_client(interactive=False) app.state.task_client = client logger.info("Task client initialized successfully") yield @@ -44,10 +43,3 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # Include routers app.include_router(tasklist_router) app.include_router(task_router) - - -if __name__ == "__main__": - import uvicorn - - logger.info("Starting FastAPI server...") - uvicorn.run("fast_api_service:app", host="127.0.0.1", port=8000, reload=True) diff --git a/src/task_client_service/src/task_client_service/task_router.py b/src/task_client_service/src/task_client_service/task_router.py index b3011dd8..40b80e46 100644 --- a/src/task_client_service/src/task_client_service/task_router.py +++ b/src/task_client_service/src/task_client_service/task_router.py @@ -6,9 +6,8 @@ from datetime import datetime from typing import Annotated -from fastapi import APIRouter, Body, HTTPException - import gtask_client_impl # noqa: F401 +from fastapi import APIRouter, Body, HTTPException from task_client_api import Task as ServiceTask from task_client_api import get_task as get_service_task @@ -100,12 +99,14 @@ async def insert_task( task_input: Annotated[ dict[str, str | None | bool], Body( - example={ - "title": "My New Task", - "notes": "This is a new task", - "status": "needsAction", - "due": "2025-11-15T00:00:00.000Z", - } + examples=[ + { + "title": "My New Task", + "notes": "This is a new task", + "status": "needsAction", + "due": "2025-11-15T00:00:00.000Z", + } + ] ), ], ) -> dict[str, str | bool | None]: @@ -122,9 +123,9 @@ async def insert_task( raw_data = json.dumps(task_input) - new_task: ServiceTask = get_service_task(raw_data) + input_task: ServiceTask = get_service_task(raw_data) - client.insert_task(tasklist_id, new_task) + created_task: ServiceTask = client.insert_task(tasklist_id, input_task) except ValueError as e: logger.critical( "Error inserting task '%s' into tasklist '%s': %s", @@ -145,8 +146,8 @@ async def insert_task( ) raise HTTPException(status_code=500, detail=str(e)) from e else: - logger.info("Successfully created task with ID: %s", new_task.id) - return task_to_dict(new_task) + logger.info("Successfully created task with ID: %s", created_task.id) + return task_to_dict(created_task) @router.delete("/{tasklist_id}/{task_id}") diff --git a/src/task_client_service/src/task_client_service/tasklist_router.py b/src/task_client_service/src/task_client_service/tasklist_router.py index 60b89ab0..6b55449d 100644 --- a/src/task_client_service/src/task_client_service/tasklist_router.py +++ b/src/task_client_service/src/task_client_service/tasklist_router.py @@ -2,10 +2,9 @@ import json import logging -from typing import Annotated, Any, TypedDict +from typing import Annotated from fastapi import APIRouter, Body, HTTPException - from task_client_api import TaskList as ServiceTaskList from task_client_api import get_tasklist as get_service_tasklist @@ -16,12 +15,6 @@ router = APIRouter(prefix="/tasklists", tags=["tasklists"]) -class CreateTaskListBody(TypedDict): - """Body schema for creating a tasklist.""" - - title: str - - def tasklist_to_dict(tasklist: ServiceTaskList) -> dict[str, str]: """Convert a TaskList object to a JSON-serializable dictionary.""" return { @@ -53,28 +46,15 @@ async def list_tasklists(client: TaskClientDep) -> list[dict[str, str]]: return formatted_tasklists -def _get_str(d: dict[str, Any], key: str, default: str | None = None) -> str | None: - v = d.get(key) - if isinstance(v, str): - return v - return default - - -def _get_bool(d: dict[str, Any], key: str) -> bool | None: - v = d.get(key) - return v if isinstance(v, bool) else None - - @router.post("") async def insert_tasklist( client: TaskClientDep, body: Annotated[ - CreateTaskListBody, + dict[str, str], Body( examples=[ { - "summary": "Basic", - "value": {"title": "My New Task List"}, + "title": "My New Task List", } ] ), @@ -125,9 +105,11 @@ async def delete_tasklist( except Exception as e: # unexpected failures from the client logger.exception("Error deleting tasklist '%s'", tasklist_id) raise HTTPException(status_code=500, detail="Internal error while deleting tasklist") from e + else: + if not success: + raise HTTPException( + status_code=500, detail=f"Failed to delete tasklist '{tasklist_id}'" + ) - if not success: - raise HTTPException(status_code=500, detail=f"Failed to delete tasklist '{tasklist_id}'") - - logger.info("Successfully deleted tasklist with ID: %s", tasklist_id) - return {"detail": f"Tasklist '{target_tasklist.title}' deleted."} + logger.info("Successfully deleted tasklist with ID: %s", tasklist_id) + return {"detail": f"Tasklist '{target_tasklist.title}' deleted."} diff --git a/uv.lock b/uv.lock index e17bda06..cdb80407 100644 --- a/uv.lock +++ b/uv.lock @@ -1405,7 +1405,21 @@ source = { editable = "src/task_client_api" } [[package]] name = "task-client-service" version = "0.1.0" -source = { virtual = "src/task_client_service" } +source = { editable = "src/task_client_service" } +dependencies = [ + { name = "fastapi" }, + { name = "gtask-client-impl" }, + { name = "task-client-api" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "gtask-client-impl", editable = "src/gtask_client_impl" }, + { name = "task-client-api", editable = "src/task_client_api" }, + { name = "uvicorn", specifier = ">=0.30.0" }, +] [[package]] name = "tomli" From b0d6a9ceaa8db1d5e44bd84382d3fdd9faf071f7 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Mon, 3 Nov 2025 15:01:07 -0500 Subject: [PATCH 26/33] fix: remove extra class --- .../src/task_client_service/task_router.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/task_client_service/src/task_client_service/task_router.py b/src/task_client_service/src/task_client_service/task_router.py index 40b80e46..06ab02e7 100644 --- a/src/task_client_service/src/task_client_service/task_router.py +++ b/src/task_client_service/src/task_client_service/task_router.py @@ -2,7 +2,6 @@ import json import logging -from dataclasses import dataclass from datetime import datetime from typing import Annotated @@ -18,18 +17,6 @@ router = APIRouter(prefix="/tasks", tags=["tasks"]) -@dataclass -class _NewTask: - id: str | None - title: str - notes: str | None = None - due: str | None = None - completed: str | None = None - status: str | None = None - deleted: bool | None = None - hidden: bool | None = None - - def task_to_dict(task: ServiceTask) -> dict[str, str | bool | None]: """Convert a Task object to a JSON-serializable dictionary.""" return { From 2572cde7b7363dd037fbdd0af68a10134506c977 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Mon, 3 Nov 2025 15:01:35 -0500 Subject: [PATCH 27/33] refactor: conftest imports adjustment --- src/task_client_service/tests/conftest.py | 35 +++++++---------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/src/task_client_service/tests/conftest.py b/src/task_client_service/tests/conftest.py index b55bac28..f9c01064 100644 --- a/src/task_client_service/tests/conftest.py +++ b/src/task_client_service/tests/conftest.py @@ -1,42 +1,25 @@ """Test configuration for task client service (Google Tasks).""" -import sys - -# need this part to find src/task_client_service in this file. Neet TYPE_CHECKING to avoid mypy error -from typing import TYPE_CHECKING - -if not TYPE_CHECKING: - import sys - from pathlib import Path - - sys.path.insert(0, str(Path("src/gmail_client_impl/src").resolve())) - from collections.abc import Callable from enum import Enum from typing import cast from unittest.mock import Mock, create_autospec import pytest -from dependencies import get_task_client # type: ignore[import-not-found] -from fast_api_service import app # type: ignore[import-not-found] from fastapi.testclient import TestClient -from task_client_api.client import Client # type: ignore[import-not-found] -from task_client_api.task import Task as TaskEg # type: ignore[import-not-found] -from task_client_api.tasklist import TaskList as TaskListEg # type: ignore[import-not-found] +from task_client_api import Client +from task_client_api import Task as ServiceTask +from task_client_api import TaskList as ServiceTaskList + +from task_client_service import app, get_task_client class HTTPStatus(Enum): """HTTP status codes used in the API.""" OK = 200 - CREATED = 201 - ACCEPTED = 202 - NO_CONTENT = 204 BAD_REQUEST = 400 - UNAUTHORIZED = 401 - FORBIDDEN = 403 NOT_FOUND = 404 - METHOD_NOT_ALLOWED = 405 CONFLICT = 409 INTERNAL_SERVER_ERROR = 500 @@ -50,6 +33,7 @@ def http_status() -> type[HTTPStatus]: @pytest.fixture def create_mock_tasklist() -> Callable[..., Mock]: """Create a mock TaskList object matching the TaskList contract.""" + def _create_mock_tasklist( tl_id: str, title: str, @@ -58,19 +42,21 @@ def _create_mock_tasklist( updated: str = "2025-01-01T00:00:00.000Z", self_link: str = "https://example.com/tasks/lists/tl_id", ) -> Mock: - mock_tl = create_autospec(TaskListEg, spec_set=True, instance=True) + mock_tl = create_autospec(ServiceTaskList, spec_set=True, instance=True) mock_tl.id = tl_id mock_tl.title = title mock_tl.etag = etag mock_tl.updated = updated mock_tl.self_link = self_link return cast("Mock", mock_tl) + return _create_mock_tasklist @pytest.fixture def create_mock_task() -> Callable[..., Mock]: """Create a mock Task object matching the Task contract.""" + def _create_mock_task( # noqa: PLR0913 task_id: str, title: str, @@ -82,7 +68,7 @@ def _create_mock_task( # noqa: PLR0913 deleted: bool = False, hidden: bool = False, ) -> Mock: - mock_task = create_autospec(TaskEg, spec_set=True, instance=True) + mock_task = create_autospec(ServiceTask, spec_set=True, instance=True) mock_task.id = task_id mock_task.title = title mock_task.notes = notes @@ -92,6 +78,7 @@ def _create_mock_task( # noqa: PLR0913 mock_task.deleted = deleted mock_task.hidden = hidden return cast("Mock", mock_task) + return _create_mock_task From 5db52b5e571308060148f997d9dbbabab24ebd12 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Mon, 3 Nov 2025 15:22:58 -0500 Subject: [PATCH 28/33] fix: remove init files for service tests to fix build error --- src/mail_client_service/tests/__init__.py | 1 - src/task_client_service/tests/__init__.py | 1 - 2 files changed, 2 deletions(-) delete mode 100644 src/mail_client_service/tests/__init__.py delete mode 100644 src/task_client_service/tests/__init__.py diff --git a/src/mail_client_service/tests/__init__.py b/src/mail_client_service/tests/__init__.py deleted file mode 100644 index 7dc55be3..00000000 --- a/src/mail_client_service/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test package for mail client service.""" diff --git a/src/task_client_service/tests/__init__.py b/src/task_client_service/tests/__init__.py deleted file mode 100644 index 9d7d3fac..00000000 --- a/src/task_client_service/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Mark tests package for pytest discovery.""" From 2e7178242866b0e5c0dbd5456cfec2844efc5e44 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Mon, 3 Nov 2025 15:48:47 -0500 Subject: [PATCH 29/33] config: add lint ignore for init file in testing dir to ensure build runs --- pyproject.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8f18ba7d..6d30b9b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,14 @@ ignore = [ "D213", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible. Ignoring `multi-line-summary-second-line`. "S101", # Use of `assert` detected --> Asserts are good in our opinion. Tests use asserts profusely. ] -per-file-ignores = { "**/test_*.py" = ["TRY300", "BLE001", "ANN401", "SLF001", "E501", "S105", "ARG001", "ARG002", "S106", "INP001", "PLC0415"] } + +[tool.ruff.lint.per-file-ignores] +"**/test_*.py" = [ + "TRY300", "BLE001", "ANN401", "SLF001", "E501", + "S105", "ARG001", "ARG002", "S106", "INP001", + "PLC0415", "ERA001" +] +"**/tests/conftest.py" = ["INP001"] # Adding __init__.py causes pytest namespace conflicts with --import-mode=importlib [tool.mypy] strict = true From 2d690234446fe0d3e0c97c8de8c290306af8439b Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Mon, 3 Nov 2025 22:25:04 -0500 Subject: [PATCH 30/33] feat: add task endpoint tests fix/refactor: error handling insert due field validation and logging of delete task lint: fix lint in e2e tests testing: update conftest and add tests for tasklist endpoints lint: mypy fix mypy fix --- .../src/gtask_client_impl/gtask_impl.py | 10 +- .../src/task_client_service/__init__.py | 2 + .../src/task_client_service/task_router.py | 48 ++-- src/task_client_service/tests/conftest.py | 27 +- .../tests/test_delete_task.py | 93 +++++++ .../tests/test_get_task.py | 88 ++++++ .../tests/test_insert_task.py | 99 +++++++ .../tests/test_list_tasks.py | 58 ++++ tests/e2e/test_mail_service_e2e.py | 255 ++++++++++++------ 9 files changed, 576 insertions(+), 104 deletions(-) create mode 100644 src/task_client_service/tests/test_delete_task.py create mode 100644 src/task_client_service/tests/test_get_task.py create mode 100644 src/task_client_service/tests/test_insert_task.py create mode 100644 src/task_client_service/tests/test_list_tasks.py diff --git a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py index bbe91f60..d349015b 100644 --- a/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py +++ b/src/gtask_client_impl/src/gtask_client_impl/gtask_impl.py @@ -75,9 +75,7 @@ class GTaskClient(task_client_api.Client): ] FAILURE_TO_CRED = "Failed to obtain credentials. Please check your setup." - def __init__( - self, service: Resource | None = None, *, interactive: bool = False - ) -> None: + def __init__(self, service: Resource | None = None, *, interactive: bool = False) -> None: """Initialize the GTaskClient, handling authentication.""" self.logger = logging.getLogger(__name__) if service: @@ -147,9 +145,7 @@ def _auth_from_env(self) -> Credentials | None: client_id = os.environ.get("TASKS_CLIENT_ID") client_secret = os.environ.get("TASKS_CLIENT_SECRET") refresh_token = os.environ.get("TASKS_REFRESH_TOKEN") - token_uri = os.environ.get( - "TASKS_TOKEN_URI", "https://oauth2.googleapis.com/token" - ) + token_uri = os.environ.get("TASKS_TOKEN_URI", "https://oauth2.googleapis.com/token") if not (client_id and client_secret and refresh_token): return None @@ -344,7 +340,7 @@ def insert_task(self, tasklist_id: str, task: task.Task) -> task.Task: body["due"] = task.due result = ( - self.service.tasks().insert(tasklist=tasklist_id, body=body).execute() + self.service.tasks().insert(tasklist=tasklist_id, body=body).execute() # type: ignore[attr-defined] ) raw_data = json.dumps(result) except (HttpError, OSError, ValueError) as e: diff --git a/src/task_client_service/src/task_client_service/__init__.py b/src/task_client_service/src/task_client_service/__init__.py index de5af174..d5427614 100644 --- a/src/task_client_service/src/task_client_service/__init__.py +++ b/src/task_client_service/src/task_client_service/__init__.py @@ -1,7 +1,9 @@ """Task Client Service - FastAPI service for task client operations.""" +from .dependencies import get_task_client from .fast_api_service import app __all__ = [ "app", + "get_task_client", ] diff --git a/src/task_client_service/src/task_client_service/task_router.py b/src/task_client_service/src/task_client_service/task_router.py index 06ab02e7..177b92d9 100644 --- a/src/task_client_service/src/task_client_service/task_router.py +++ b/src/task_client_service/src/task_client_service/task_router.py @@ -61,7 +61,9 @@ async def get_task( task_id: str, ) -> dict[str, str | None | bool]: """Read a single task from a given tasklist.""" - logger.info("Recievd request to get task '%s' from tasklist '%s'", task_id, tasklist_id) + logger.info( + "Recievd request to get task '%s' from tasklist '%s'", task_id, tasklist_id + ) try: task = client.get_task(tasklist_id, task_id) formatted_task = task_to_dict(task) @@ -75,7 +77,9 @@ async def get_task( ) raise HTTPException(status_code=500, detail=str(e)) from e else: - logger.info("Successfully retrieved task '%s' from tasklist '%s'", task_id, tasklist_id) + logger.info( + "Successfully retrieved task '%s' from tasklist '%s'", task_id, tasklist_id + ) return formatted_task @@ -104,25 +108,33 @@ async def insert_task( tasklist_id, ) try: - if isinstance(task_input["due"], str): - due_date = datetime.fromisoformat(task_input["due"]) - task_input["due"] = due_date.isoformat() + # Validate and normalize due date if provided + if task_input.get("due") and isinstance(task_input["due"], str): + try: + due_date = datetime.fromisoformat(task_input["due"]) + task_input["due"] = due_date.isoformat() + except ValueError as date_error: + logger.critical( + "Invalid due date format for task '%s' in tasklist '%s': %s", + task_input.get("title", "Unknown"), + tasklist_id, + date_error, + exc_info=True, + ) + raise HTTPException( + status_code=400, + detail=f"""Invalid due date format: {date_error!s}. + Expected ISO format (e.g., '2025-11-15T00:00:00.000Z')""", + ) from date_error raw_data = json.dumps(task_input) input_task: ServiceTask = get_service_task(raw_data) created_task: ServiceTask = client.insert_task(tasklist_id, input_task) - except ValueError as e: - logger.critical( - "Error inserting task '%s' into tasklist '%s': %s", - task_input.get("title", "Unknown"), - tasklist_id, - e, - exc_info=True, - ) - raise HTTPException(status_code=400, detail=str(e)) from e + except HTTPException: + raise except Exception as e: logger.critical( "Error inserting task '%s' into tasklist '%s': %s", @@ -144,9 +156,13 @@ async def delete_task( task_id: str, ) -> dict[str, str]: """Mark a task as deleted.""" - logger.info("Recieved request to delete task '%s' from tasklist '%s'", task_id, tasklist_id) + logger.info( + "Recieved request to delete task '%s' from tasklist '%s'", task_id, tasklist_id + ) if not client.delete_task(tasklist_id, task_id): raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found") - logger.info("Successfully deleted task '%s' from tasklist '%s'", task_id, tasklist_id) + logger.info( + "Successfully deleted task '%s' from tasklist '%s'", task_id, tasklist_id + ) return {"detail": f"Task '{task_id}' deleted from tasklist '{tasklist_id}'."} diff --git a/src/task_client_service/tests/conftest.py b/src/task_client_service/tests/conftest.py index f9c01064..8544afe3 100644 --- a/src/task_client_service/tests/conftest.py +++ b/src/task_client_service/tests/conftest.py @@ -7,23 +7,40 @@ import pytest from fastapi.testclient import TestClient -from task_client_api import Client from task_client_api import Task as ServiceTask from task_client_api import TaskList as ServiceTaskList +from task_client_api.client import Client -from task_client_service import app, get_task_client +from task_client_service import app +from task_client_service.dependencies import get_task_client class HTTPStatus(Enum): """HTTP status codes used in the API.""" OK = 200 + CREATED = 201 + ACCEPTED = 202 + NO_CONTENT = 204 BAD_REQUEST = 400 + UNAUTHORIZED = 401 + FORBIDDEN = 403 NOT_FOUND = 404 + METHOD_NOT_ALLOWED = 405 CONFLICT = 409 INTERNAL_SERVER_ERROR = 500 +def assert_response_matches_mock( + data: dict[str, str | bool | None], mock_obj: Mock +) -> None: + """Assert that the data matches the mock object.""" + for key, value in data.items(): + assert value == getattr( + mock_obj, key + ), f"Mismatch for key '{key}': expected {getattr(mock_obj, key)}, got {value}" + + @pytest.fixture def http_status() -> type[HTTPStatus]: """Provide HTTP status codes enum.""" @@ -83,13 +100,13 @@ def _create_mock_task( # noqa: PLR0913 @pytest.fixture -def mock_task_client() -> Client: +def mock_task_client() -> Mock: """Provide a mock task client.""" - return cast("Client", create_autospec(Client, spec_set=True, instance=True)) + return cast("Mock", create_autospec(Client, spec_set=True, instance=True)) @pytest.fixture -def client(mock_task_client: Mock) -> TestClient: +def service_client(mock_task_client: Mock) -> TestClient: """Provide a test client with mocked dependencies.""" app.dependency_overrides[get_task_client] = lambda: mock_task_client return TestClient(app) diff --git a/src/task_client_service/tests/test_delete_task.py b/src/task_client_service/tests/test_delete_task.py new file mode 100644 index 00000000..ee59dbdb --- /dev/null +++ b/src/task_client_service/tests/test_delete_task.py @@ -0,0 +1,93 @@ +"""Unit tests for delete task endpoint.""" + +from collections.abc import Callable +from unittest.mock import Mock + +from fastapi.testclient import TestClient + +from .conftest import HTTPStatus + + +def test_delete_task_success( + service_client: TestClient, + create_mock_task: Callable[..., Mock], + create_mock_tasklist: Callable[..., Mock], + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests delete task fastapi endpoint success.""" + # Arrange + mock_tasklist = create_mock_tasklist("tl_1", "groceries") + mock_task = create_mock_task("tsk_1", "bananas") + mock_task_client.delete_task.return_value = True + + # Act + response = service_client.delete( + f"/tasks/{mock_tasklist.id}/{mock_task.id}", + ) + + # Assert + assert http_status(response.status_code) == http_status.OK + data = response.json() + assert "detail" in data + assert ( + f"Task '{mock_task.id}' deleted from tasklist '{mock_tasklist.id}'." + in data["detail"] + ) + + mock_task_client.delete_task.assert_called_once_with(mock_tasklist.id, mock_task.id) + + +def test_delete_task_not_found( + service_client: TestClient, + create_mock_tasklist: Callable[..., Mock], + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests delete task endpoint with non-existent task expecting 404 Not Found.""" + # Arrange + mock_tasklist = create_mock_tasklist("tl_1", "groceries") + nonexistent_task_id = "nonexistent_task_id" + + # Configure mock to return False when trying to delete non-existent task + mock_task_client.delete_task.return_value = False + + # Act + response = service_client.delete(f"/tasks/{mock_tasklist.id}/{nonexistent_task_id}") + + # Assert + assert http_status(response.status_code) == http_status.NOT_FOUND + data = response.json() + assert "detail" in data + assert f"Task '{nonexistent_task_id}' not found" in data["detail"] + + mock_task_client.delete_task.assert_called_once_with( + mock_tasklist.id, nonexistent_task_id + ) + + +def test_delete_task_tasklist_not_found( + service_client: TestClient, + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests delete task endpoint with non-existent tasklist expecting 500 Internal Server Error.""" + # Arrange + nonexistent_tasklist_id = "nonexistent_tl_id" + task_id = "tsk_1" + + # Configure mock to return False when trying to delete from non-existent tasklist + mock_task_client.delete_task.return_value = False + + # Act + response = service_client.delete(f"/tasks/{nonexistent_tasklist_id}/{task_id}") + + # Assert + assert http_status(response.status_code) == http_status.NOT_FOUND + data = response.json() + assert "detail" in data + assert f"Task '{task_id}' not found" in data["detail"] + + mock_task_client.delete_task.assert_called_once_with( + nonexistent_tasklist_id, task_id + ) diff --git a/src/task_client_service/tests/test_get_task.py b/src/task_client_service/tests/test_get_task.py new file mode 100644 index 00000000..572e9c37 --- /dev/null +++ b/src/task_client_service/tests/test_get_task.py @@ -0,0 +1,88 @@ +"""Unit tests for get task endpoint.""" + +from collections.abc import Callable +from unittest.mock import Mock + +from fastapi.testclient import TestClient + +from .conftest import HTTPStatus, assert_response_matches_mock + + +def test_get_task_success( + service_client: TestClient, + create_mock_task: Callable[..., Mock], + create_mock_tasklist: Callable[..., Mock], + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests get task fastapi endpoint success.""" + # Arrange + mock_tasklist = create_mock_tasklist("tl_1", "groceries") + mock_task = create_mock_task("tsk_1", "bananas") + mock_task_client.get_task.return_value = mock_task + + # Act + response = service_client.get( + f"/tasks/{mock_tasklist.id}/{mock_task.id}", + ) + + # Assert + assert http_status(response.status_code) == http_status.OK + data = response.json() + + assert_response_matches_mock(data, mock_task) + mock_task_client.get_task.assert_called_once_with(mock_tasklist.id, mock_task.id) + + +def test_get_task_not_found( + service_client: TestClient, + create_mock_tasklist: Callable[..., Mock], + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests get task endpoint with non-existent task expecting 500 Internal Server Error.""" + # Arrange + mock_tasklist = create_mock_tasklist("tl_1", "groceries") + nonexistent_task_id = "nonexistent_task_id" + + # Configure mock to raise an exception when trying to get non-existent task + mock_task_client.get_task.side_effect = Exception("Task not found") + + # Act + response = service_client.get(f"/tasks/{mock_tasklist.id}/{nonexistent_task_id}") + + # Assert + assert http_status(response.status_code) == http_status.INTERNAL_SERVER_ERROR + data = response.json() + assert "detail" in data + assert "Task not found" in data["detail"] + + mock_task_client.get_task.assert_called_once_with( + mock_tasklist.id, nonexistent_task_id + ) + + +def test_get_task_client_error( + service_client: TestClient, + create_mock_tasklist: Callable[..., Mock], + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests get task endpoint with client error expecting 500 Internal Server Error.""" + # Arrange + mock_tasklist = create_mock_tasklist("tl_1", "groceries") + task_id = "tsk_1" + + # Configure mock to raise a different exception + mock_task_client.get_task.side_effect = RuntimeError("Connection error") + + # Act + response = service_client.get(f"/tasks/{mock_tasklist.id}/{task_id}") + + # Assert + assert http_status(response.status_code) == http_status.INTERNAL_SERVER_ERROR + data = response.json() + assert "detail" in data + assert "Connection error" in data["detail"] + + mock_task_client.get_task.assert_called_once_with(mock_tasklist.id, task_id) diff --git a/src/task_client_service/tests/test_insert_task.py b/src/task_client_service/tests/test_insert_task.py new file mode 100644 index 00000000..b71a92db --- /dev/null +++ b/src/task_client_service/tests/test_insert_task.py @@ -0,0 +1,99 @@ +"""Unit tests for insert task endpoint.""" + +from collections.abc import Callable +from unittest.mock import Mock + +from fastapi.testclient import TestClient + +from .conftest import HTTPStatus, assert_response_matches_mock + + +def test_insert_task_success( + service_client: TestClient, + create_mock_task: Callable[..., Mock], + create_mock_tasklist: Callable[..., Mock], + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests insert task fastapi endpoint success.""" + # Arrange + mock_task = create_mock_task("tsk_1", "bananas") + mock_tasklist = create_mock_tasklist("tl_1", "groceries") + + mock_task_client.list_tasklists.return_value = [mock_tasklist] + mock_task_client.insert_task.return_value = mock_task + + # Act + task_data = { + "title": "This is a new task", + "notes": "Task notes", + "status": "needsAction", + } + + response = service_client.post(f"/tasks/{mock_tasklist.id}", json=task_data) + + # Assert + assert http_status(response.status_code) == http_status.OK + data = response.json() + + assert_response_matches_mock(data, mock_task) + + mock_task_client.insert_task.assert_called_once() + + +def test_insert_task_invalid_date_format( + service_client: TestClient, + create_mock_tasklist: Callable[..., Mock], + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests insert task endpoint with invalid date format expecting 400 Bad Request.""" + # Arrange + mock_tasklist = create_mock_tasklist("tl_1", "groceries") + mock_task_client.list_tasklists.return_value = [mock_tasklist] + + # Act + task_data = { + "title": "Task with bad date", + "notes": "This task has an invalid date format", + "status": "needsAction", + "due": "invalid-date-format", # This should cause a ValueError + } + + response = service_client.post(f"/tasks/{mock_tasklist.id}", json=task_data) + + # Assert + assert http_status(response.status_code) == http_status.BAD_REQUEST + data = response.json() + assert "detail" in data + + mock_task_client.insert_task.assert_not_called() + + +def test_insert_task_nonexistent_tasklist( + service_client: TestClient, + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests insert task endpoint with non-existent tasklist expecting 500 Internal Server Error.""" + # Arrange + nonexistent_tasklist_id = "nonexistent_tl_id" + + # Configure mock to raise an exception when trying to insert into non-existent tasklist + mock_task_client.insert_task.side_effect = Exception("Tasklist not found") + + # Act + task_data = { + "title": "Task for non-existent list", + "notes": "This task is for a tasklist that doesn't exist", + "status": "needsAction", + } + + response = service_client.post(f"/tasks/{nonexistent_tasklist_id}", json=task_data) + + # Assert + assert http_status(response.status_code) == http_status.INTERNAL_SERVER_ERROR + data = response.json() + assert "detail" in data + + mock_task_client.insert_task.assert_called_once() diff --git a/src/task_client_service/tests/test_list_tasks.py b/src/task_client_service/tests/test_list_tasks.py new file mode 100644 index 00000000..61573a42 --- /dev/null +++ b/src/task_client_service/tests/test_list_tasks.py @@ -0,0 +1,58 @@ +"""Unit tests for get tasks endpoint.""" + +from collections.abc import Callable +from unittest.mock import Mock + +from fastapi.testclient import TestClient + +from .conftest import HTTPStatus, assert_response_matches_mock + + +def test_get_tasks_success( + service_client: TestClient, + create_mock_task: Callable[..., Mock], + create_mock_tasklist: Callable[..., Mock], + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests insert task fastapi endpoint success.""" + # Arrange + mock_tasklist = create_mock_tasklist("tl_1", "groceries") + mock_task = create_mock_task("tsk_1", "bananas") + mock_task_client.list_tasks.return_value = [mock_task] + + # Act + response = service_client.get( + f"/tasks/{mock_tasklist.id}", + ) + + # Assert + assert http_status(response.status_code) == http_status.OK + data = response.json() + + assert len(data) == 1 + assert_response_matches_mock(data[0], mock_task) + mock_task_client.list_tasks.assert_called_once() + + +def test_list_tasks_nonexistent_tasklist( + service_client: TestClient, + mock_task_client: Mock, + http_status: type[HTTPStatus], +) -> None: + """Tests insert task endpoint with non-existent tasklist expecting 500 Internal Server Error.""" + # Arrange + nonexistent_tasklist_id = "nonexistent_tl_id" + + # Configure mock to raise an exception when trying to insert into non-existent tasklist + mock_task_client.list_tasks.side_effect = Exception("Tasklist not found") + + # Act + response = service_client.get(f"/tasks/{nonexistent_tasklist_id}") + + # Assert + assert http_status(response.status_code) == http_status.INTERNAL_SERVER_ERROR + data = response.json() + assert "detail" in data + + mock_task_client.list_tasks.assert_called_once() diff --git a/tests/e2e/test_mail_service_e2e.py b/tests/e2e/test_mail_service_e2e.py index ca946473..2962ffae 100644 --- a/tests/e2e/test_mail_service_e2e.py +++ b/tests/e2e/test_mail_service_e2e.py @@ -1,17 +1,23 @@ -# ruff: noqa: ERA001 """End-to-end tests for the mail service.""" import os +import queue import socket import subprocess import time +from collections.abc import Callable, Generator from contextlib import closing, suppress from enum import Enum from pathlib import Path +from typing import Any +import httpx import pytest from dotenv import load_dotenv +from mail_client_adapter import ServiceClientAdapter +from mail_client_service_client import Client + # Load environment variables from .env file load_dotenv() @@ -32,7 +38,13 @@ class HTTPStatus(Enum): INTERNAL_SERVER_ERROR = 500 -def _free_port() -> int: +# Constants for test thresholds +MAX_SUBJECT_LENGTH = 200 +LARGE_MESSAGE_BODY_THRESHOLD = 1000 +TUPLE_WITH_RESPONSE_TIME_LENGTH = 2 + + +def _free_port() -> Any: with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.bind(("", 0)) return s.getsockname()[1] @@ -55,7 +67,9 @@ def _wait_for_ready(base_url: str, timeout_s: int = 45) -> None: @pytest.fixture(scope="session") -def service_base_url(tmp_path_factory) -> None: # noqa: ANN001 +def service_base_url( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[str, None, None]: """Start the real FastAPI service in a separate process (uvicorn) so we hit it over HTTP.""" port = _free_port() base_url = f"http://127.0.0.1:{port}" @@ -120,18 +134,16 @@ def service_base_url(tmp_path_factory) -> None: # noqa: ANN001 @pytest.fixture(scope="session") -def shared_client(service_base_url: str): # noqa: ANN201 # return type: Client +def shared_client(service_base_url: str) -> Client: """Session-scoped fixture that provides a shared HTTP client for all tests.""" - from mail_client_service_client import Client - return Client( base_url=service_base_url, - timeout=30.0, # Reasonable timeout for E2E tests + timeout=httpx.Timeout(30.0), # Reasonable timeout for E2E tests ) @pytest.fixture -def mail_adapter_client(shared_client): # noqa: ANN001, ANN201 # Client +def mail_adapter_client(shared_client: Client) -> ServiceClientAdapter: """Fixture that provides a configured mail adapter client for testing.""" from mail_client_adapter import ServiceClientAdapter @@ -139,7 +151,7 @@ def mail_adapter_client(shared_client): # noqa: ANN001, ANN201 # Client @pytest.fixture -def ci_mail_adapter_client(shared_client): # noqa: ANN001, ANN201 # Client +def ci_mail_adapter_client(shared_client: Client) -> ServiceClientAdapter: """Fixture that provides a CI-optimized mail adapter client for testing.""" from mail_client_adapter import ServiceClientAdapter @@ -147,7 +159,7 @@ def ci_mail_adapter_client(shared_client): # noqa: ANN001, ANN201 # Client @pytest.fixture -def sample_messages(mail_adapter_client): # noqa: ANN001, ANN201 # ServiceClientAdapter +def sample_messages(mail_adapter_client: ServiceClientAdapter) -> list[Any]: """Fixture that provides sample messages for testing.""" try: return list(mail_adapter_client.get_messages(max_results=3)) @@ -169,7 +181,7 @@ def service_health_check(service_base_url: str) -> bool | None: # Test utility functions -def validate_message_structure(message) -> bool: # noqa: ANN001 # Message +def validate_message_structure(message: Any) -> bool: """Validate that a message has the required structure.""" required_fields = ["id", "subject", "from_", "date", "body"] @@ -183,7 +195,7 @@ def validate_message_structure(message) -> bool: # noqa: ANN001 # Message return True -def validate_http_response_structure(response_data: dict) -> bool: +def validate_http_response_structure(response_data: dict[str, Any]) -> bool: """Validate that HTTP response has the required structure.""" required_keys = ["id", "from", "to", "subject", "date", "body"] @@ -196,7 +208,9 @@ def validate_http_response_structure(response_data: dict) -> bool: return True -def measure_performance(func, *args, **kwargs): # noqa: ANN001, ANN003, ANN002, ANN201 +def measure_performance( + func: Callable[..., Any], *args: Any, **kwargs: Any +) -> tuple[Any, float]: """Measure the performance of a function call.""" import time @@ -210,7 +224,7 @@ def measure_performance(func, *args, **kwargs): # noqa: ANN001, ANN003, ANN002, @pytest.mark.e2e @pytest.mark.local_credentials def test_e2e_service_adapter_calls_real_gmail_api( - mail_adapter_client, # noqa: ANN001 # ServiceClientAdapter + mail_adapter_client: ServiceClientAdapter, service_health_check: bool | None, # noqa: FBT001 ) -> None: """E2E test that uses mail_client_adapter to call running service with real Gmail API. @@ -243,7 +257,7 @@ def test_e2e_service_adapter_calls_real_gmail_api( @pytest.mark.local_credentials def test_e2e_comprehensive_service_operations( service_base_url: str, - mail_adapter_client, # noqa: ANN001 # ServiceClientAdapter + mail_adapter_client: ServiceClientAdapter, ) -> None: """Comprehensive E2E test with error handling and delete operations. @@ -258,7 +272,9 @@ def test_e2e_comprehensive_service_operations( # Test 0: Verify service is healthy try: response = httpx.get(f"{service_base_url}/openapi.json", timeout=5.0) - assert response.status_code == httpx.codes.OK, f"Service health check failed: {response.status_code}" + assert ( + response.status_code == httpx.codes.OK + ), f"Service health check failed: {response.status_code}" except Exception as e: pytest.fail(f"Service is not healthy: {e}") @@ -452,7 +468,7 @@ def test_e2e_service_failure_scenarios() -> None: @pytest.mark.local_credentials def test_e2e_comprehensive_error_scenarios( # noqa: PLR0915, PLR0912, C901 service_base_url: str, - mail_adapter_client, # noqa: ANN001 # ServiceClientAdapter + mail_adapter_client: ServiceClientAdapter, ) -> None: """Comprehensive E2E test for various error scenarios and edge cases. @@ -486,7 +502,9 @@ def test_e2e_comprehensive_error_scenarios( # noqa: PLR0915, PLR0912, C901 adapter.get_message(invalid_id) pytest.fail(f"Expected RuntimeError for invalid ID: {invalid_id!r}") except RuntimeError as e: - assert "not found" in str(e).lower() or "failed" in str(e).lower() # noqa: PT017 + assert ( # noqa: PT017 - Need to assert exception message content to validate error handling + "not found" in str(e).lower() or "failed" in str(e).lower() + ) except Exception as e: # Some invalid IDs might cause different types of errors pytest.fail(f"Invalid ID test failed with Exception: {e}") @@ -497,7 +515,7 @@ def test_e2e_comprehensive_error_scenarios( # noqa: PLR0915, PLR0912, C901 timeout_client = Client( base_url=service_base_url, - timeout=0.001, # Very short timeout + timeout=httpx.Timeout(0.001), # Very short timeout ) timeout_adapter = ServiceClientAdapter(timeout_client) @@ -509,10 +527,9 @@ def test_e2e_comprehensive_error_scenarios( # noqa: PLR0915, PLR0912, C901 pytest.fail(f"Network timeout scenarios test failed: {e}") # Test 3: Concurrent request handling - import queue import threading - results_queue = queue.Queue() + results_queue: queue.Queue[tuple[str, Any]] = queue.Queue() def make_concurrent_request() -> None: try: @@ -571,7 +588,7 @@ def make_concurrent_request() -> None: @pytest.mark.local_credentials def test_e2e_data_integrity_and_validation( # noqa: PLR0915, PLR0912, C901 service_base_url: str, - mail_adapter_client, # noqa: ANN001 # ServiceClientAdapter + mail_adapter_client: ServiceClientAdapter, ) -> None: """E2E test for data integrity, validation, and message format verification. @@ -598,24 +615,34 @@ def test_e2e_data_integrity_and_validation( # noqa: PLR0915, PLR0912, C901 for field in required_fields: assert hasattr(msg, field), f"Message {i + 1} missing field: {field}" value = getattr(msg, field) - assert isinstance(value, str), f"Message {i + 1} field {field} is not string: {type(value)}" + assert isinstance( + value, str + ), f"Message {i + 1} field {field} is not string: {type(value)}" assert value is not None, f"Message {i + 1} field {field} is None" # Validate field content patterns assert len(msg.id) > 0, f"Message {i + 1} has empty ID" - assert len(msg.subject) >= 0, f"Message {i + 1} subject validation failed" # Subject can be empty + assert ( + len(msg.subject) >= 0 + ), f"Message {i + 1} subject validation failed" # Subject can be empty # Validate email format in from_ field (basic check) if msg.from_ and "@" in msg.from_: # Note: Gmail API might return complex formats like "Name " # So we just check for basic email structure - assert "@" in msg.from_, f"Message {i + 1} from_ field doesn't contain email: {msg.from_}" + assert ( + "@" in msg.from_ + ), f"Message {i + 1} from_ field doesn't contain email: {msg.from_}" # Validate date format (should be a string, could be ISO format or other) - assert isinstance(msg.date, str), f"Message {i + 1} date is not string: {type(msg.date)}" + assert isinstance( + msg.date, str + ), f"Message {i + 1} date is not string: {type(msg.date)}" # Validate body is a string (can be empty) - assert isinstance(msg.body, str), f"Message {i + 1} body is not string: {type(msg.body)}" + assert isinstance( + msg.body, str + ), f"Message {i + 1} body is not string: {type(msg.body)}" except Exception as e: pytest.fail(f"Data structure validation failed: {e}") @@ -631,10 +658,18 @@ def test_e2e_data_integrity_and_validation( # noqa: PLR0915, PLR0912, C901 # Verify data consistency assert retrieved_message.id == first_message.id, "Message ID inconsistency" - assert retrieved_message.subject == first_message.subject, "Message subject inconsistency" - assert retrieved_message.from_ == first_message.from_, "Message from_ inconsistency" - assert retrieved_message.date == first_message.date, "Message date inconsistency" - assert retrieved_message.body == first_message.body, "Message body inconsistency" + assert ( + retrieved_message.subject == first_message.subject + ), "Message subject inconsistency" + assert ( + retrieved_message.from_ == first_message.from_ + ), "Message from_ inconsistency" + assert ( + retrieved_message.date == first_message.date + ), "Message date inconsistency" + assert ( + retrieved_message.body == first_message.body + ), "Message body inconsistency" except Exception as e: pytest.fail(f"Data consistency check failed: {e}") @@ -645,7 +680,9 @@ def test_e2e_data_integrity_and_validation( # noqa: PLR0915, PLR0912, C901 try: # Test GET /messages endpoint structure response = httpx.get(f"{service_base_url}/messages", timeout=10.0) - assert response.status_code == httpx.codes.OK, f"GET /messages failed: {response.status_code}" + assert ( + response.status_code == httpx.codes.OK + ), f"GET /messages failed: {response.status_code}" messages_data = response.json() assert isinstance(messages_data, list), "GET /messages should return a list" @@ -657,7 +694,9 @@ def test_e2e_data_integrity_and_validation( # noqa: PLR0915, PLR0912, C901 required_keys = ["id", "from", "to", "subject", "date", "body"] for key in required_keys: assert key in msg_data, f"Message {i} missing key: {key}" - assert isinstance(msg_data[key], str), f"Message {i} key {key} is not string" + assert isinstance( + msg_data[key], str + ), f"Message {i} key {key} is not string" except Exception as e: pytest.fail(f"Response structure validation failed: {e}") @@ -674,7 +713,7 @@ def test_e2e_data_integrity_and_validation( # noqa: PLR0915, PLR0912, C901 if len(msg.body) > 10000: # noqa: PLR2004 # arbitrary for long body pass - if len(msg.subject) > 200: # noqa: PLR2004 # arbitrary for long subject line + if len(msg.subject) > MAX_SUBJECT_LENGTH: # arbitrary for long subject line pass # Check for special characters in content @@ -973,7 +1012,7 @@ def test_e2e_data_integrity_and_validation( # noqa: PLR0915, PLR0912, C901 @pytest.mark.local_credentials @pytest.mark.parametrize("max_results", [1, 3, 5, 10]) def test_e2e_get_messages_with_different_limits( - mail_adapter_client, # noqa: ANN001 # ServiceClientAdapter + mail_adapter_client: ServiceClientAdapter, max_results: int, ) -> None: """Parametrized test for get_messages with different result limits. @@ -983,7 +1022,9 @@ def test_e2e_get_messages_with_different_limits( - Response consistency - Performance with different limits """ - messages, _execution_time = measure_performance(lambda: list(mail_adapter_client.get_messages(max_results=max_results))) + messages, _execution_time = measure_performance( + lambda: list(mail_adapter_client.get_messages(max_results=max_results)) + ) assert isinstance(messages, list) assert len(messages) <= max_results @@ -1006,7 +1047,9 @@ def test_e2e_get_messages_with_different_limits( "!@#$%^&*()", ], ) -def test_e2e_get_message_with_invalid_ids(mail_adapter_client, invalid_id: str) -> None: # noqa: ANN001 # ServiceClientAdapter +def test_e2e_get_message_with_invalid_ids( + mail_adapter_client: ServiceClientAdapter, invalid_id: str +) -> None: """Parametrized test for get_message with various invalid IDs. Tests: @@ -1022,10 +1065,14 @@ def test_e2e_get_message_with_invalid_ids(mail_adapter_client, invalid_id: str) try: mail_adapter_client.get_message(invalid_id) # If we get here, the invalid ID was not successfully caught - pytest.fail(f"Expected exception for invalid ID: {invalid_id!r}, but no exception was raised") + pytest.fail( + f"Expected exception for invalid ID: {invalid_id!r}, but no exception was raised" + ) except RuntimeError as e: # Invalid ID was successfully caught with RuntimeError - assert "not found" in str(e).lower() or "failed" in str(e).lower() # noqa: PT017 + assert ( # noqa: PT017 - Need to assert exception message content to validate error handling + "not found" in str(e).lower() or "failed" in str(e).lower() + ) except Exception as e: pytest.fail(f"Invalid ID test failed with Exception: {e}") @@ -1034,8 +1081,8 @@ def test_e2e_get_message_with_invalid_ids(mail_adapter_client, invalid_id: str) @pytest.mark.local_credentials @pytest.mark.parametrize("operation", ["get_message", "mark_as_read", "delete_message"]) def test_e2e_operations_with_sample_messages( - mail_adapter_client, # noqa: ANN001 # ServiceClientAdapter - sample_messages, # noqa: ANN001 # list[Message] + mail_adapter_client: ServiceClientAdapter, + sample_messages: list[Any], operation: str, ) -> None: """Parametrized test for different operations on sample messages. @@ -1067,7 +1114,9 @@ def test_e2e_operations_with_sample_messages( result = mail_adapter_client.delete_message(delete_message.id) assert result is True else: - pytest.skip("Only one message available - skipping delete operation for safety") + pytest.skip( + "Only one message available - skipping delete operation for safety" + ) except Exception as e: pytest.fail(f"{operation} operation failed: {e}") @@ -1144,7 +1193,9 @@ def test_e2e_operations_with_sample_messages( @pytest.mark.e2e @pytest.mark.local_credentials -def test_e2e_performance_benchmarks(mail_adapter_client) -> None: # noqa: ANN001 # import ServiceClientAdapter kept seperate +def test_e2e_performance_benchmarks( + mail_adapter_client: ServiceClientAdapter, +) -> None: """Performance benchmark tests using utility functions. Tests: @@ -1153,17 +1204,23 @@ def test_e2e_performance_benchmarks(mail_adapter_client) -> None: # noqa: ANN00 - Resource usage patterns """ # Benchmark get_messages - messages, _get_time = measure_performance(lambda: list(mail_adapter_client.get_messages(max_results=5))) + messages, _get_time = measure_performance( + lambda: list(mail_adapter_client.get_messages(max_results=5)) + ) # Benchmark individual operations if messages exist if messages: test_message = messages[0] # Benchmark get_message - _, _get_single_time = measure_performance(lambda: mail_adapter_client.get_message(test_message.id)) + _, _get_single_time = measure_performance( + lambda: mail_adapter_client.get_message(test_message.id) + ) # Benchmark mark_as_read - _, _mark_time = measure_performance(lambda: mail_adapter_client.mark_as_read(test_message.id)) + _, _mark_time = measure_performance( + lambda: mail_adapter_client.mark_as_read(test_message.id) + ) # @pytest.mark.e2e @@ -1456,7 +1513,9 @@ def test_e2e_performance_benchmarks(mail_adapter_client) -> None: # noqa: ANN00 @pytest.mark.e2e @pytest.mark.local_credentials -def test_e2e_gmail_message_parsing_edge_cases(service_base_url: str) -> None: # noqa: C901 +def test_e2e_gmail_message_parsing_edge_cases( # noqa: C901 - E2E test intentionally comprehensive to cover multiple edge cases + service_base_url: str, +) -> None: """E2E test for Gmail message parsing edge cases and error handling. Tests: @@ -1515,7 +1574,9 @@ def test_e2e_gmail_message_parsing_edge_cases(service_base_url: str) -> None: # # Request larger messages to test size handling messages = list(adapter.get_messages(max_results=20)) - large_messages = [msg for msg in messages if len(msg.body) > 1000] # noqa: PLR2004 # arbitrary for large message + large_messages = [ + msg for msg in messages if len(msg.body) > LARGE_MESSAGE_BODY_THRESHOLD + ] # arbitrary for large message for _i, msg in enumerate(large_messages[:3]): # Test first 3 large messages # Test that we can still access all fields @@ -1554,7 +1615,9 @@ def test_e2e_gmail_message_parsing_edge_cases(service_base_url: str) -> None: # @pytest.mark.e2e @pytest.mark.local_credentials -def test_e2e_service_initialization_and_lifecycle(service_base_url: str) -> None: # noqa: PLR0915, PLR0912, C901 +def test_e2e_service_initialization_and_lifecycle( # noqa: C901, PLR0912, PLR0915 - E2E test intentionally comprehensive to validate full service lifecycle + service_base_url: str, +) -> None: """E2E test for service initialization and lifecycle management. Tests: @@ -1651,7 +1714,7 @@ def test_e2e_service_initialization_and_lifecycle(service_base_url: str) -> None import queue import threading - results_queue = queue.Queue() + results_queue: queue.Queue[tuple[str, ...]] = queue.Queue() def make_request() -> None: try: @@ -1676,13 +1739,15 @@ def make_request() -> None: # Collect results success_count = 0 error_count = 0 - total_time = 0 + total_time = 0.0 while not results_queue.empty(): - result_type, _data, response_time = results_queue.get() + result_tuple = results_queue.get() + result_type = result_tuple[0] if result_type == "success": success_count += 1 - total_time += response_time + if len(result_tuple) > TUPLE_WITH_RESPONSE_TIME_LENGTH: + total_time += result_tuple[2] # response_time else: error_count += 1 @@ -1697,7 +1762,9 @@ def make_request() -> None: @pytest.mark.e2e @pytest.mark.local_credentials -def test_e2e_mail_client_service_direct_integration(service_base_url: str) -> None: # noqa: PLR0915, PLR0912, C901 +def test_e2e_mail_client_service_direct_integration( # noqa: C901, PLR0912, PLR0915 - E2E test intentionally comprehensive to cover all integration scenarios + service_base_url: str, +) -> None: """E2E test for direct mail client service integration. Tests: @@ -1729,14 +1796,18 @@ def test_e2e_mail_client_service_direct_integration(service_base_url: str) -> No # Test GET /messages/{id} endpoint message_id = first_message["id"] - response = httpx.get(f"{service_base_url}/messages/{message_id}", timeout=10.0) + response = httpx.get( + f"{service_base_url}/messages/{message_id}", timeout=10.0 + ) assert response.status_code == httpx.codes.OK single_message = response.json() assert single_message["id"] == message_id # Test POST /messages/{id}/mark-as-read endpoint - response = httpx.post(f"{service_base_url}/messages/{message_id}/mark-as-read", timeout=10.0) + response = httpx.post( + f"{service_base_url}/messages/{message_id}/mark-as-read", timeout=10.0 + ) assert response.status_code == httpx.codes.OK mark_response = response.json() @@ -1749,7 +1820,9 @@ def test_e2e_mail_client_service_direct_integration(service_base_url: str) -> No try: # Test 404 for non-existent message - response = httpx.get(f"{service_base_url}/messages/non-existent-id", timeout=5.0) + response = httpx.get( + f"{service_base_url}/messages/non-existent-id", timeout=5.0 + ) # Test invalid endpoint with suppress(httpx.HTTPStatusError): @@ -1831,7 +1904,9 @@ def test_e2e_mail_client_service_direct_integration(service_base_url: str) -> No @pytest.mark.e2e @pytest.mark.local_credentials -def test_e2e_comprehensive_api_coverage(service_base_url: str) -> None: # noqa: PLR0915, PLR0912, C901 +def test_e2e_comprehensive_api_coverage( # noqa: C901, PLR0912, PLR0915 - E2E test intentionally comprehensive to test all API endpoints and edge cases + service_base_url: str, +) -> None: """E2E test for comprehensive API coverage and edge cases. Tests: @@ -1840,13 +1915,11 @@ def test_e2e_comprehensive_api_coverage(service_base_url: str) -> None: # noqa: - Error scenarios - Performance under load """ - import queue import threading import time import httpx - from mail_client_adapter import ServiceClientAdapter from mail_client_service_client import Client # Test 1: Complete API endpoint coverage @@ -1880,7 +1953,9 @@ def test_e2e_comprehensive_api_coverage(service_base_url: str) -> None: # noqa: except Exception as e: # This level: error occurred during the entirety of HTTP API direct coverage phase (including both static and dynamic endpoints) - pytest.fail(f"HTTP API direct coverage test failed in overall HTTP API coverage block: {e}") + pytest.fail( + f"HTTP API direct coverage test failed in overall HTTP API coverage block: {e}" + ) # Test 2: HTTP API direct coverage @@ -1896,7 +1971,9 @@ def test_e2e_comprehensive_api_coverage(service_base_url: str) -> None: # noqa: if method == "GET": response = httpx.get(f"{service_base_url}{endpoint}", timeout=10.0) else: - response = httpx.request(method, f"{service_base_url}{endpoint}", timeout=10.0) + response = httpx.request( + method, f"{service_base_url}{endpoint}", timeout=10.0 + ) # Validate response content if response.status_code == httpx.codes.OK: @@ -1909,7 +1986,9 @@ def test_e2e_comprehensive_api_coverage(service_base_url: str) -> None: # noqa: except Exception as e: # This level: error occurred while calling a specific static endpoint (e.g., GET /messages, GET /openapi.json) - pytest.fail(f"HTTP API direct coverage test failed at static endpoint invocation: {e}") + pytest.fail( + f"HTTP API direct coverage test failed at static endpoint invocation: {e}" + ) # Test dynamic endpoints if we have messages try: @@ -1925,27 +2004,37 @@ def test_e2e_comprehensive_api_coverage(service_base_url: str) -> None: # noqa: for method, endpoint in dynamic_endpoints: try: if method == "GET": - response = httpx.get(f"{service_base_url}{endpoint}", timeout=10.0) + response = httpx.get( + f"{service_base_url}{endpoint}", timeout=10.0 + ) elif method == "POST": - response = httpx.post(f"{service_base_url}{endpoint}", timeout=10.0) + response = httpx.post( + f"{service_base_url}{endpoint}", timeout=10.0 + ) except Exception as e: # This level: error occurred while calling a specific dynamic endpoint (e.g., GET /messages/{id}, POST /messages/{id}/mark-as-read) - pytest.fail(f"HTTP API direct coverage test failed at endpoint invocation: {e}") + pytest.fail( + f"HTTP API direct coverage test failed at endpoint invocation: {e}" + ) except Exception as e: # This level: error occurred while attempting any of the dynamic endpoint operations, possibly for all endpoints or before entering their loop - pytest.fail(f"HTTP API direct coverage test failed during dynamic endpoints setup or iteration: {e}") + pytest.fail( + f"HTTP API direct coverage test failed during dynamic endpoints setup or iteration: {e}" + ) except Exception as e: # This level: error occurred during the entirety of HTTP API direct coverage phase (including both static and dynamic endpoints) - pytest.fail(f"HTTP API direct coverage test failed in overall HTTP API coverage block: {e}") + pytest.fail( + f"HTTP API direct coverage test failed in overall HTTP API coverage block: {e}" + ) # Test 3: Performance under load try: - def load_test_worker(results_queue: queue.Queue) -> None: + def load_test_worker(results_queue: queue.Queue[tuple[str, Any]]) -> None: try: start_time = time.time() @@ -1964,7 +2053,7 @@ def load_test_worker(results_queue: queue.Queue) -> None: results_queue.put(("error", str(e))) # Run load test with multiple workers - results_queue = queue.Queue() + results_queue: queue.Queue[tuple[str, Any]] = queue.Queue() threads = [] for _i in range(5): # 5 concurrent workers @@ -2129,7 +2218,9 @@ def load_test_worker(results_queue: queue.Queue) -> None: @pytest.mark.e2e @pytest.mark.local_credentials -def test_e2e_mail_client_service_coverage(service_base_url: str) -> None: # noqa: PLR0915, PLR0912, C901 +def test_e2e_mail_client_service_coverage( # noqa: C901, PLR0912, PLR0915 - E2E test intentionally comprehensive to ensure full service coverage + service_base_url: str, +) -> None: """E2E test for mail client service coverage. Tests: @@ -2172,7 +2263,9 @@ def test_e2e_mail_client_service_coverage(service_base_url: str) -> None: # noq if method == "GET": response = httpx.get(f"{service_base_url}{endpoint}", timeout=10.0) else: - response = httpx.request(method, f"{service_base_url}{endpoint}", timeout=10.0) + response = httpx.request( + method, f"{service_base_url}{endpoint}", timeout=10.0 + ) # Test response content if response.status_code == httpx.codes.OK: @@ -2223,7 +2316,9 @@ def test_e2e_mail_client_service_coverage(service_base_url: str) -> None: # noq if method == "GET": response = httpx.get(f"{service_base_url}{endpoint}", timeout=5.0) else: - response = httpx.request(method, f"{service_base_url}{endpoint}", timeout=5.0) + response = httpx.request( + method, f"{service_base_url}{endpoint}", timeout=5.0 + ) except httpx.HTTPStatusError as e: pytest.fail(f"Service error handling test failed: {e}") @@ -2250,8 +2345,12 @@ def test_e2e_mail_client_service_coverage(service_base_url: str) -> None: # noq avg_time = sum(response_times) / len(response_times) + expected_response_time = 2.0 + # Validate performance - assert avg_time < 2.0, f"Average response time too high: {avg_time:.3f}s" # noqa: PLR2004 + assert ( + avg_time < expected_response_time + ), f"Average response time too high: {avg_time:.3f}s" except Exception as e: pytest.fail(f"Performance and reliability test failed: {e}") @@ -2665,7 +2764,9 @@ def test_e2e_gmail_message_implementation_coverage(service_base_url: str) -> Non """ # Encode as base64 - encoded_content = base64.b64encode(complex_content.encode("utf-8")).decode("utf-8") + encoded_content = base64.b64encode(complex_content.encode("utf-8")).decode( + "utf-8" + ) message = GmailMessage("complex_id", encoded_content) @@ -2702,7 +2803,9 @@ def test_e2e_gmail_message_implementation_coverage(service_base_url: str) -> Non try: # Test with minimal valid data - minimal_data = base64.b64encode(b"From: test@example.com\nSubject: Test\n\nBody").decode("utf-8") + minimal_data = base64.b64encode( + b"From: test@example.com\nSubject: Test\n\nBody" + ).decode("utf-8") message = GmailMessage("minimal_id", minimal_data) # Validate all properties are strings From 2761c3f0bcff92da9e8cccb1ddb34a6f9d052c88 Mon Sep 17 00:00:00 2001 From: Ruimeng Chen Date: Tue, 4 Nov 2025 00:08:33 -0500 Subject: [PATCH 31/33] Add unit test for delete/insert/list tasklists --- .../tests/test_delete_tasklists.py | 33 +++++++++++++++ .../tests/test_insert_tasklists.py | 41 +++++++++++++++++++ .../tests/test_list_tasklists.py | 40 ++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/task_client_service/tests/test_delete_tasklists.py create mode 100644 src/task_client_service/tests/test_insert_tasklists.py create mode 100644 src/task_client_service/tests/test_list_tasklists.py diff --git a/src/task_client_service/tests/test_delete_tasklists.py b/src/task_client_service/tests/test_delete_tasklists.py new file mode 100644 index 00000000..ced6a379 --- /dev/null +++ b/src/task_client_service/tests/test_delete_tasklists.py @@ -0,0 +1,33 @@ +"""Tests for DELETE /tasklists/{tasklist_id} endpoint.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from fastapi.testclient import TestClient + + +@pytest.mark.usefixtures("service_client") +class TestDeleteTasklist: + """Delete-tasklist should respond with a clearly defined status.""" + + def test_delete_tasklist_success(self, service_client: TestClient) -> None: + """Deleting an existing tasklist should normally respond 204 or 404.""" + resp = service_client.delete("/tasklists/tl_1") + # current service tends to return either "deleted" (204) or "not found" (404) + assert resp.status_code in {204, 404, 500} + + def test_delete_tasklist_not_found(self, service_client: TestClient) -> None: + """Deleting a clearly non-existent tasklist should respond 404 (or 500 on server error).""" + resp = service_client.delete("/tasklists/not-exist-id") + assert resp.status_code in {404, 500} + + def test_delete_tasklist_server_error(self, service_client: TestClient) -> None: + """Endpoint must not crash the test run even if the backend fails.""" + resp = service_client.delete("/tasklists/force-error-id") + # if the service surfaces internal error it should be 500 + assert resp.status_code in {500, 404} + diff --git a/src/task_client_service/tests/test_insert_tasklists.py b/src/task_client_service/tests/test_insert_tasklists.py new file mode 100644 index 00000000..29c016db --- /dev/null +++ b/src/task_client_service/tests/test_insert_tasklists.py @@ -0,0 +1,41 @@ +"""Tests for POST /tasklists endpoint, matching current router behavior.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi.exceptions import ResponseValidationError + +if TYPE_CHECKING: + from fastapi.testclient import TestClient + + +@pytest.mark.usefixtures("service_client") +class TestInsertTasklist: + """Insert-tasklist currently fails at response serialization because of mocked client.""" + + def test_insert_tasklist_success(self, service_client: TestClient) -> None: + """Sending a normal payload currently produces a ResponseValidationError. + + This happens because the dependency returns MagicMock fields that FastAPI can't serialize. + """ + with pytest.raises(ResponseValidationError): + service_client.post("/tasklists", json={"title": "New List"}) + + def test_insert_tasklist_validation_error(self, service_client: TestClient) -> None: + """Sending an empty JSON hits body['title'] in the router and raises KeyError('title'). + + We assert this exact error so the test is strict. + """ + with pytest.raises(KeyError) as excinfo: + service_client.post("/tasklists", json={}) + assert str(excinfo.value) == "'title'" + + def test_insert_tasklist_server_error(self, service_client: TestClient) -> None: + """Even with another payload, current implementation still ends up. + + In ResponseValidationError, so we assert that exactly. + """ + with pytest.raises(ResponseValidationError): + service_client.post("/tasklists", json={"title": "maybe-bad"}) diff --git a/src/task_client_service/tests/test_list_tasklists.py b/src/task_client_service/tests/test_list_tasklists.py new file mode 100644 index 00000000..e9355e1e --- /dev/null +++ b/src/task_client_service/tests/test_list_tasklists.py @@ -0,0 +1,40 @@ +"""Tests for GET /tasklists endpoint.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from fastapi.testclient import TestClient + + +HTTP_OK = 200 +HTTP_SERVER_ERROR = 500 + + +@pytest.mark.usefixtures("service_client") +class TestListTasklists: + """List-tasklists should return 200 with a list, or 500 on server error.""" + + def test_list_tasklists_success(self, service_client: TestClient) -> None: + """Normal GET should return 200 and a JSON list; if server fails, 500.""" + resp = service_client.get("/tasklists") + assert resp.status_code in {HTTP_OK, HTTP_SERVER_ERROR} + if resp.status_code == HTTP_OK: + data = resp.json() + assert isinstance(data, list) + + def test_list_tasklists_empty(self, service_client: TestClient) -> None: + """A second GET should behave the same way.""" + resp = service_client.get("/tasklists") + assert resp.status_code in {HTTP_OK, HTTP_SERVER_ERROR} + if resp.status_code == HTTP_OK: + data = resp.json() + assert isinstance(data, list) + + def test_list_tasklists_server_error(self, service_client: TestClient) -> None: + """If the backend raises, the endpoint should surface it as 500.""" + resp = service_client.get("/tasklists") + assert resp.status_code in {HTTP_OK, HTTP_SERVER_ERROR} From a1cdd0cfdc50676813ea825966da873f3bf33259 Mon Sep 17 00:00:00 2001 From: Ruimeng Chen Date: Wed, 5 Nov 2025 11:27:05 -0500 Subject: [PATCH 32/33] Modified tasklists test files for a higher coverage --- .../tests/test_delete_tasklists.py | 126 +++++++++++++++--- .../tests/test_insert_tasklists.py | 106 +++++++++++---- .../tests/test_list_tasklists.py | 95 +++++++++---- 3 files changed, 261 insertions(+), 66 deletions(-) diff --git a/src/task_client_service/tests/test_delete_tasklists.py b/src/task_client_service/tests/test_delete_tasklists.py index ced6a379..aea88f8d 100644 --- a/src/task_client_service/tests/test_delete_tasklists.py +++ b/src/task_client_service/tests/test_delete_tasklists.py @@ -1,33 +1,123 @@ -"""Tests for DELETE /tasklists/{tasklist_id} endpoint.""" +"""Tests for DELETE /tasklists.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Any import pytest -if TYPE_CHECKING: - from fastapi.testclient import TestClient +from task_client_service.dependencies import get_task_client # type: ignore[import-untyped] + +HTTP_200_OK = 200 +HTTP_400_BAD_REQUEST = 400 +HTTP_404_NOT_FOUND = 404 +HTTP_500_INTERNAL_SERVER_ERROR = 500 + + +class _FakeTasklist: + """Simple tasklist stub used for router tests.""" + + def __init__(self, tasklist_id: str, title: str = "dummy") -> None: + self.id = tasklist_id + self.title = title + self.etag = "etag" + self.updated = "2025-01-01T00:00:00Z" + self.self_link = f"https://example.com/{tasklist_id}" @pytest.mark.usefixtures("service_client") class TestDeleteTasklist: - """Delete-tasklist should respond with a clearly defined status.""" + """Covers delete-tasklist router branches.""" + + def _override(self, service_client: Any, client: Any) -> None: + # mypy: service_client is a pytest fixture (runtime TestClient) + service_client.app.dependency_overrides[get_task_client] = lambda: client + + def test_delete_default_tasklist_400(self, service_client: Any) -> None: + """Deleting the first (default) tasklist should return 400.""" + class FakeClient: + def list_tasklists(self) -> list[_FakeTasklist]: + return [ + _FakeTasklist("tl_default", "Default"), + _FakeTasklist("tl_other", "Other"), + ] + + def delete_tasklist(self, tasklist_id: str) -> bool: + return True + + self._override(service_client, FakeClient()) + + resp = service_client.delete("/tasklists/tl_default") + assert resp.status_code == HTTP_400_BAD_REQUEST + assert "cannot delete default tasklist" in resp.json()["detail"] + + def test_delete_missing_tasklist_404(self, service_client: Any) -> None: + """Deleting a tasklist that is not present should return 404.""" + class FakeClient: + def list_tasklists(self) -> list[_FakeTasklist]: + return [_FakeTasklist("tl_1"), _FakeTasklist("tl_2")] + + def delete_tasklist(self, tasklist_id: str) -> bool: + return True + + self._override(service_client, FakeClient()) + + resp = service_client.delete("/tasklists/not-exist") + assert resp.status_code == HTTP_404_NOT_FOUND + assert "not found" in resp.json()["detail"] + + def test_delete_ok_returns_200(self, service_client: Any) -> None: + """Deleting a non-default, existing tasklist should return 200 with detail.""" + class FakeClient: + def list_tasklists(self) -> list[_FakeTasklist]: + return [ + _FakeTasklist("tl_default", "Default"), + _FakeTasklist("tl_2", "Second"), + ] + + def delete_tasklist(self, tasklist_id: str) -> bool: + return True + + self._override(service_client, FakeClient()) + + resp = service_client.delete("/tasklists/tl_2") + assert resp.status_code == HTTP_200_OK + data = resp.json() + assert "Tasklist 'Second' deleted." in data["detail"] + + def test_delete_client_returns_false_500(self, service_client: Any) -> None: + """If backend delete returns False, router should respond 500.""" + class FakeClient: + def list_tasklists(self) -> list[_FakeTasklist]: + return [ + _FakeTasklist("tl_default", "Default"), + _FakeTasklist("tl_1", "First"), + ] + + def delete_tasklist(self, tasklist_id: str) -> bool: + return False + + self._override(service_client, FakeClient()) - def test_delete_tasklist_success(self, service_client: TestClient) -> None: - """Deleting an existing tasklist should normally respond 204 or 404.""" resp = service_client.delete("/tasklists/tl_1") - # current service tends to return either "deleted" (204) or "not found" (404) - assert resp.status_code in {204, 404, 500} + assert resp.status_code == HTTP_500_INTERNAL_SERVER_ERROR + assert "Failed to delete tasklist 'tl_1'" in resp.json()["detail"] - def test_delete_tasklist_not_found(self, service_client: TestClient) -> None: - """Deleting a clearly non-existent tasklist should respond 404 (or 500 on server error).""" - resp = service_client.delete("/tasklists/not-exist-id") - assert resp.status_code in {404, 500} + def test_delete_client_raises_500(self, service_client: Any) -> None: + """If backend delete raises, router should respond 500.""" + class FakeClient: + def list_tasklists(self) -> list[_FakeTasklist]: + return [ + _FakeTasklist("tl_default", "Default"), + _FakeTasklist("tl_1", "First"), + ] - def test_delete_tasklist_server_error(self, service_client: TestClient) -> None: - """Endpoint must not crash the test run even if the backend fails.""" - resp = service_client.delete("/tasklists/force-error-id") - # if the service surfaces internal error it should be 500 - assert resp.status_code in {500, 404} + def delete_tasklist(self, tasklist_id: str) -> bool: + msg = "boom" + raise RuntimeError(msg) + self._override(service_client, FakeClient()) + + resp = service_client.delete("/tasklists/tl_1") + assert resp.status_code == HTTP_500_INTERNAL_SERVER_ERROR + assert "Internal error while deleting tasklist" in resp.json()["detail"] diff --git a/src/task_client_service/tests/test_insert_tasklists.py b/src/task_client_service/tests/test_insert_tasklists.py index 29c016db..e1f7e56f 100644 --- a/src/task_client_service/tests/test_insert_tasklists.py +++ b/src/task_client_service/tests/test_insert_tasklists.py @@ -1,41 +1,97 @@ -"""Tests for POST /tasklists endpoint, matching current router behavior.""" +"""Tests for POST /tasklists.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Any import pytest -from fastapi.exceptions import ResponseValidationError -if TYPE_CHECKING: - from fastapi.testclient import TestClient +from task_client_service import tasklist_router +from task_client_service.dependencies import get_task_client # type: ignore[import-untyped] + +HTTP_200_OK = 200 +HTTP_409_CONFLICT = 409 +HTTP_500_INTERNAL_SERVER_ERROR = 500 + + +class _DummyTasklist: + """Simple tasklist object returned by patched helpers.""" + + def __init__(self, tasklist_id: str, title: str) -> None: + self.id = tasklist_id + self.title = title + self.etag = "etag" + self.updated = "2025-01-01T00:00:00Z" + self.self_link = f"https://example.com/{tasklist_id}" @pytest.mark.usefixtures("service_client") class TestInsertTasklist: - """Insert-tasklist currently fails at response serialization because of mocked client.""" + """Covers insert-tasklist router branches.""" + + def test_insert_ok( + self, + service_client: Any, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Happy path: service builds tasklist and client inserts it.""" + def fake_get_service_tasklist(_: str) -> _DummyTasklist: + return _DummyTasklist("generated-id", "My New Task List") + + monkeypatch.setattr(tasklist_router, "get_service_tasklist", fake_get_service_tasklist) + + class FakeClient: + def insert_tasklist(self, tl: _DummyTasklist) -> _DummyTasklist: + return tl + + service_client.app.dependency_overrides[get_task_client] = lambda: FakeClient() + + resp = service_client.post("/tasklists", json={"title": "My New Task List"}) + assert resp.status_code == HTTP_200_OK + data = resp.json() + assert data["id"] == "generated-id" + assert data["title"] == "My New Task List" + + def test_insert_conflict_409( + self, + service_client: Any, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """If client raises ValueError, router should return 409.""" + def fake_get_service_tasklist(_: str) -> _DummyTasklist: + return _DummyTasklist("dup-id", "Dup Title") + + monkeypatch.setattr(tasklist_router, "get_service_tasklist", fake_get_service_tasklist) + + class FakeClient: + def insert_tasklist(self, tl: _DummyTasklist) -> _DummyTasklist: + msg = "already exists" + raise ValueError(msg) + + service_client.app.dependency_overrides[get_task_client] = lambda: FakeClient() - def test_insert_tasklist_success(self, service_client: TestClient) -> None: - """Sending a normal payload currently produces a ResponseValidationError. + resp = service_client.post("/tasklists", json={"title": "Dup Title"}) + assert resp.status_code == HTTP_409_CONFLICT + assert "already exists" in resp.json()["detail"] - This happens because the dependency returns MagicMock fields that FastAPI can't serialize. - """ - with pytest.raises(ResponseValidationError): - service_client.post("/tasklists", json={"title": "New List"}) + def test_insert_other_error_500( + self, + service_client: Any, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """If client raises non-ValueError, router should return 500.""" + def fake_get_service_tasklist(_: str) -> _DummyTasklist: + return _DummyTasklist("some-id", "Err Title") - def test_insert_tasklist_validation_error(self, service_client: TestClient) -> None: - """Sending an empty JSON hits body['title'] in the router and raises KeyError('title'). + monkeypatch.setattr(tasklist_router, "get_service_tasklist", fake_get_service_tasklist) - We assert this exact error so the test is strict. - """ - with pytest.raises(KeyError) as excinfo: - service_client.post("/tasklists", json={}) - assert str(excinfo.value) == "'title'" + class FakeClient: + def insert_tasklist(self, tl: _DummyTasklist) -> _DummyTasklist: + msg = "unexpected" + raise RuntimeError(msg) - def test_insert_tasklist_server_error(self, service_client: TestClient) -> None: - """Even with another payload, current implementation still ends up. + service_client.app.dependency_overrides[get_task_client] = lambda: FakeClient() - In ResponseValidationError, so we assert that exactly. - """ - with pytest.raises(ResponseValidationError): - service_client.post("/tasklists", json={"title": "maybe-bad"}) + resp = service_client.post("/tasklists", json={"title": "Err Title"}) + assert resp.status_code == HTTP_500_INTERNAL_SERVER_ERROR + assert "unexpected" in resp.json()["detail"] diff --git a/src/task_client_service/tests/test_list_tasklists.py b/src/task_client_service/tests/test_list_tasklists.py index e9355e1e..a9f68713 100644 --- a/src/task_client_service/tests/test_list_tasklists.py +++ b/src/task_client_service/tests/test_list_tasklists.py @@ -1,40 +1,89 @@ -"""Tests for GET /tasklists endpoint.""" +"""Tests for GET /tasklists.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Any import pytest -if TYPE_CHECKING: - from fastapi.testclient import TestClient +from task_client_service import fast_api_service +from task_client_service.dependencies import get_task_client # type: ignore[import-untyped] + +HTTP_200_OK = 200 +HTTP_500_INTERNAL_SERVER_ERROR = 500 + +class _TL: + """Stub tasklist used to emulate backend list responses.""" -HTTP_OK = 200 -HTTP_SERVER_ERROR = 500 + def __init__(self, tasklist_id: str) -> None: + self.id = tasklist_id + self.title = f"title-{tasklist_id}" + self.etag = "etag" + self.updated = "2025-01-01T00:00:00Z" + self.self_link = f"https://example.com/{tasklist_id}" @pytest.mark.usefixtures("service_client") class TestListTasklists: - """List-tasklists should return 200 with a list, or 500 on server error.""" + """Covers list-tasklists router branches.""" - def test_list_tasklists_success(self, service_client: TestClient) -> None: - """Normal GET should return 200 and a JSON list; if server fails, 500.""" - resp = service_client.get("/tasklists") - assert resp.status_code in {HTTP_OK, HTTP_SERVER_ERROR} - if resp.status_code == HTTP_OK: - data = resp.json() - assert isinstance(data, list) + def test_list_tasklists_ok(self, service_client: Any) -> None: + """Client returns 2 tasklists -> 200 and list.""" + class FakeClient: + def list_tasklists(self) -> list[_TL]: + return [_TL("tl1"), _TL("tl2")] + + service_client.app.dependency_overrides[get_task_client] = lambda: FakeClient() - def test_list_tasklists_empty(self, service_client: TestClient) -> None: - """A second GET should behave the same way.""" resp = service_client.get("/tasklists") - assert resp.status_code in {HTTP_OK, HTTP_SERVER_ERROR} - if resp.status_code == HTTP_OK: - data = resp.json() - assert isinstance(data, list) + assert resp.status_code == HTTP_200_OK + data = resp.json() + assert isinstance(data, list) + assert data[0]["id"] == "tl1" + assert data[1]["id"] == "tl2" + + def test_list_tasklists_server_error(self, service_client: Any) -> None: + """Client raises -> router returns 500.""" + class BoomClient: + def list_tasklists(self) -> list[Any]: + msg = "backend died" + raise RuntimeError(msg) + + service_client.app.dependency_overrides[get_task_client] = lambda: BoomClient() - def test_list_tasklists_server_error(self, service_client: TestClient) -> None: - """If the backend raises, the endpoint should surface it as 500.""" resp = service_client.get("/tasklists") - assert resp.status_code in {HTTP_OK, HTTP_SERVER_ERROR} + assert resp.status_code == HTTP_500_INTERNAL_SERVER_ERROR + assert "backend died" in resp.json()["detail"] + + +def test_lifespan_starts_successfully(monkeypatch: pytest.MonkeyPatch) -> None: + """Lifespan should init client and shut down cleanly.""" + class DummyClient: + """Fake task client.""" + + + def fake_get_client(*, interactive: bool = False) -> DummyClient: + return DummyClient() + + monkeypatch.setattr(fast_api_service, "get_client", fake_get_client) + + from fastapi.testclient import TestClient + + with TestClient(fast_api_service.app): + pass + + +def test_lifespan_init_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """Lifespan should propagate errors from get_client.""" + err_msg = "boom from get_client" + + def fake_get_client(*, interactive: bool = False) -> None: + raise RuntimeError(err_msg) + + monkeypatch.setattr(fast_api_service, "get_client", fake_get_client) + + from fastapi.testclient import TestClient + + with pytest.raises(RuntimeError, match=err_msg), TestClient(fast_api_service.app): + pass From 0fc54b2dd7c293662bfbee5ce6d3fcbc77d84db8 Mon Sep 17 00:00:00 2001 From: sirichartchai Date: Wed, 5 Nov 2025 13:01:51 -0500 Subject: [PATCH 33/33] feat: add openapi generated code for task-client-service --- src/task-client-service-client/.gitignore | 23 ++ src/task-client-service-client/README.md | 124 ++++++++ src/task-client-service-client/pyproject.toml | 26 ++ .../task_client_service_client/__init__.py | 8 + .../api/__init__.py | 1 + .../api/tasklists/__init__.py | 1 + ...e_tasklist_tasklists_tasklist_id_delete.py | 181 ++++++++++++ .../insert_tasklist_tasklists_post.py | 176 ++++++++++++ .../tasklists/list_tasklists_tasklists_get.py | 140 +++++++++ .../api/tasks/__init__.py | 1 + ...e_task_tasks_tasklist_id_task_id_delete.py | 182 ++++++++++++ .../get_task_tasks_tasklist_id_task_id_get.py | 180 ++++++++++++ .../insert_task_tasks_tasklist_id_post.py | 189 ++++++++++++ .../tasks/list_tasks_tasks_tasklist_id_get.py | 170 +++++++++++ .../task_client_service_client/client.py | 268 ++++++++++++++++++ .../task_client_service_client/errors.py | 16 ++ .../models/__init__.py | 37 +++ ...e_task_tasks_tasklist_id_task_id_delete.py | 46 +++ ...e_tasklist_tasklists_tasklist_id_delete.py | 46 +++ ..._get_task_tasks_tasklist_id_task_id_get.py | 61 ++++ .../models/http_validation_error.py | 79 ++++++ ...onse_insert_task_tasks_tasklist_id_post.py | 61 ++++ ..._task_tasks_tasklist_id_post_task_input.py | 59 ++++ .../insert_tasklist_tasklists_post_body.py | 46 +++ ...response_insert_tasklist_tasklists_post.py | 46 +++ ...sklists_tasklists_get_response_200_item.py | 46 +++ ...tasks_tasklist_id_get_response_200_item.py | 59 ++++ .../models/validation_error.py | 90 ++++++ .../task_client_service_client/py.typed | 1 + .../task_client_service_client/types.py | 54 ++++ 30 files changed, 2417 insertions(+) create mode 100644 src/task-client-service-client/.gitignore create mode 100644 src/task-client-service-client/README.md create mode 100644 src/task-client-service-client/pyproject.toml create mode 100644 src/task-client-service-client/task_client_service_client/__init__.py create mode 100644 src/task-client-service-client/task_client_service_client/api/__init__.py create mode 100644 src/task-client-service-client/task_client_service_client/api/tasklists/__init__.py create mode 100644 src/task-client-service-client/task_client_service_client/api/tasklists/delete_tasklist_tasklists_tasklist_id_delete.py create mode 100644 src/task-client-service-client/task_client_service_client/api/tasklists/insert_tasklist_tasklists_post.py create mode 100644 src/task-client-service-client/task_client_service_client/api/tasklists/list_tasklists_tasklists_get.py create mode 100644 src/task-client-service-client/task_client_service_client/api/tasks/__init__.py create mode 100644 src/task-client-service-client/task_client_service_client/api/tasks/delete_task_tasks_tasklist_id_task_id_delete.py create mode 100644 src/task-client-service-client/task_client_service_client/api/tasks/get_task_tasks_tasklist_id_task_id_get.py create mode 100644 src/task-client-service-client/task_client_service_client/api/tasks/insert_task_tasks_tasklist_id_post.py create mode 100644 src/task-client-service-client/task_client_service_client/api/tasks/list_tasks_tasks_tasklist_id_get.py create mode 100644 src/task-client-service-client/task_client_service_client/client.py create mode 100644 src/task-client-service-client/task_client_service_client/errors.py create mode 100644 src/task-client-service-client/task_client_service_client/models/__init__.py create mode 100644 src/task-client-service-client/task_client_service_client/models/delete_task_tasks_tasklist_id_task_id_delete_response_delete_task_tasks_tasklist_id_task_id_delete.py create mode 100644 src/task-client-service-client/task_client_service_client/models/delete_tasklist_tasklists_tasklist_id_delete_response_delete_tasklist_tasklists_tasklist_id_delete.py create mode 100644 src/task-client-service-client/task_client_service_client/models/get_task_tasks_tasklist_id_task_id_get_response_get_task_tasks_tasklist_id_task_id_get.py create mode 100644 src/task-client-service-client/task_client_service_client/models/http_validation_error.py create mode 100644 src/task-client-service-client/task_client_service_client/models/insert_task_tasks_tasklist_id_post_response_insert_task_tasks_tasklist_id_post.py create mode 100644 src/task-client-service-client/task_client_service_client/models/insert_task_tasks_tasklist_id_post_task_input.py create mode 100644 src/task-client-service-client/task_client_service_client/models/insert_tasklist_tasklists_post_body.py create mode 100644 src/task-client-service-client/task_client_service_client/models/insert_tasklist_tasklists_post_response_insert_tasklist_tasklists_post.py create mode 100644 src/task-client-service-client/task_client_service_client/models/list_tasklists_tasklists_get_response_200_item.py create mode 100644 src/task-client-service-client/task_client_service_client/models/list_tasks_tasks_tasklist_id_get_response_200_item.py create mode 100644 src/task-client-service-client/task_client_service_client/models/validation_error.py create mode 100644 src/task-client-service-client/task_client_service_client/py.typed create mode 100644 src/task-client-service-client/task_client_service_client/types.py diff --git a/src/task-client-service-client/.gitignore b/src/task-client-service-client/.gitignore new file mode 100644 index 00000000..79a2c3d7 --- /dev/null +++ b/src/task-client-service-client/.gitignore @@ -0,0 +1,23 @@ +__pycache__/ +build/ +dist/ +*.egg-info/ +.pytest_cache/ + +# pyenv +.python-version + +# Environments +.env +.venv + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# JetBrains +.idea/ + +/coverage.xml +/.coverage diff --git a/src/task-client-service-client/README.md b/src/task-client-service-client/README.md new file mode 100644 index 00000000..b5e54264 --- /dev/null +++ b/src/task-client-service-client/README.md @@ -0,0 +1,124 @@ +# task-client-service-client +A client library for accessing Task Client Service + +## Usage +First, create a client: + +```python +from task_client_service_client import Client + +client = Client(base_url="https://api.example.com") +``` + +If the endpoints you're going to hit require authentication, use `AuthenticatedClient` instead: + +```python +from task_client_service_client import AuthenticatedClient + +client = AuthenticatedClient(base_url="https://api.example.com", token="SuperSecretToken") +``` + +Now call your endpoint and use your models: + +```python +from task_client_service_client.models import MyDataModel +from task_client_service_client.api.my_tag import get_my_data_model +from task_client_service_client.types import Response + +with client as client: + my_data: MyDataModel = get_my_data_model.sync(client=client) + # or if you need more info (e.g. status_code) + response: Response[MyDataModel] = get_my_data_model.sync_detailed(client=client) +``` + +Or do the same thing with an async version: + +```python +from task_client_service_client.models import MyDataModel +from task_client_service_client.api.my_tag import get_my_data_model +from task_client_service_client.types import Response + +async with client as client: + my_data: MyDataModel = await get_my_data_model.asyncio(client=client) + response: Response[MyDataModel] = await get_my_data_model.asyncio_detailed(client=client) +``` + +By default, when you're calling an HTTPS API it will attempt to verify that SSL is working correctly. Using certificate verification is highly recommended most of the time, but sometimes you may need to authenticate to a server (especially an internal server) using a custom certificate bundle. + +```python +client = AuthenticatedClient( + base_url="https://internal_api.example.com", + token="SuperSecretToken", + verify_ssl="/path/to/certificate_bundle.pem", +) +``` + +You can also disable certificate validation altogether, but beware that **this is a security risk**. + +```python +client = AuthenticatedClient( + base_url="https://internal_api.example.com", + token="SuperSecretToken", + verify_ssl=False +) +``` + +Things to know: +1. Every path/method combo becomes a Python module with four functions: + 1. `sync`: Blocking request that returns parsed data (if successful) or `None` + 1. `sync_detailed`: Blocking request that always returns a `Request`, optionally with `parsed` set if the request was successful. + 1. `asyncio`: Like `sync` but async instead of blocking + 1. `asyncio_detailed`: Like `sync_detailed` but async instead of blocking + +1. All path/query params, and bodies become method arguments. +1. If your endpoint had any tags on it, the first tag will be used as a module name for the function (my_tag above) +1. Any endpoint which did not have a tag will be in `task_client_service_client.api.default` + +## Advanced customizations + +There are more settings on the generated `Client` class which let you control more runtime behavior, check out the docstring on that class for more info. You can also customize the underlying `httpx.Client` or `httpx.AsyncClient` (depending on your use-case): + +```python +from task_client_service_client import Client + +def log_request(request): + print(f"Request event hook: {request.method} {request.url} - Waiting for response") + +def log_response(response): + request = response.request + print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}") + +client = Client( + base_url="https://api.example.com", + httpx_args={"event_hooks": {"request": [log_request], "response": [log_response]}}, +) + +# Or get the underlying httpx client to modify directly with client.get_httpx_client() or client.get_async_httpx_client() +``` + +You can even set the httpx client directly, but beware that this will override any existing settings (e.g., base_url): + +```python +import httpx +from task_client_service_client import Client + +client = Client( + base_url="https://api.example.com", +) +# Note that base_url needs to be re-set, as would any shared cookies, headers, etc. +client.set_httpx_client(httpx.Client(base_url="https://api.example.com", proxies="http://localhost:8030")) +``` + +## Building / publishing this package +This project uses [Poetry](https://python-poetry.org/) to manage dependencies and packaging. Here are the basics: +1. Update the metadata in pyproject.toml (e.g. authors, version) +1. If you're using a private repository, configure it with Poetry + 1. `poetry config repositories. ` + 1. `poetry config http-basic. ` +1. Publish the client with `poetry publish --build -r ` or, if for public PyPI, just `poetry publish --build` + +If you want to install this client into another project without publishing it (e.g. for development) then: +1. If that project **is using Poetry**, you can simply do `poetry add ` from that project +1. If that project is not using Poetry: + 1. Build a wheel with `poetry build -f wheel` + 1. Install that wheel from the other project `pip install ` diff --git a/src/task-client-service-client/pyproject.toml b/src/task-client-service-client/pyproject.toml new file mode 100644 index 00000000..025e07d3 --- /dev/null +++ b/src/task-client-service-client/pyproject.toml @@ -0,0 +1,26 @@ +[tool.poetry] +name = "task-client-service-client" +version = "0.1.0" +description = "A client library for accessing Task Client Service" +authors = [] +readme = "README.md" +packages = [ + { include = "task_client_service_client" }, +] +include = ["task_client_service_client/py.typed"] + +[tool.poetry.dependencies] +python = "^3.10" +httpx = ">=0.23.0,<0.29.0" +attrs = ">=22.2.0" +python-dateutil = "^2.8.0" + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["F", "I", "UP"] diff --git a/src/task-client-service-client/task_client_service_client/__init__.py b/src/task-client-service-client/task_client_service_client/__init__.py new file mode 100644 index 00000000..75972fdc --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing Task Client Service""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/src/task-client-service-client/task_client_service_client/api/__init__.py b/src/task-client-service-client/task_client_service_client/api/__init__.py new file mode 100644 index 00000000..81f9fa24 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/src/task-client-service-client/task_client_service_client/api/tasklists/__init__.py b/src/task-client-service-client/task_client_service_client/api/tasklists/__init__.py new file mode 100644 index 00000000..2d7c0b23 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/tasklists/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/src/task-client-service-client/task_client_service_client/api/tasklists/delete_tasklist_tasklists_tasklist_id_delete.py b/src/task-client-service-client/task_client_service_client/api/tasklists/delete_tasklist_tasklists_tasklist_id_delete.py new file mode 100644 index 00000000..5dcbddcd --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/tasklists/delete_tasklist_tasklists_tasklist_id_delete.py @@ -0,0 +1,181 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.delete_tasklist_tasklists_tasklist_id_delete_response_delete_tasklist_tasklists_tasklist_id_delete import ( + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete, +) +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + tasklist_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/tasklists/{tasklist_id}", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> ( + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError | None +): + if response.status_code == 200: + response_200 = DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete.from_dict( + response.json() + ) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[ + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError +]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError +]: + """Delete Tasklist + + Delete a tasklist. + + Args: + tasklist_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, +) -> ( + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError | None +): + """Delete Tasklist + + Delete a tasklist. + + Args: + tasklist_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError + """ + + return sync_detailed( + tasklist_id=tasklist_id, + client=client, + ).parsed + + +async def asyncio_detailed( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError +]: + """Delete Tasklist + + Delete a tasklist. + + Args: + tasklist_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, +) -> ( + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError | None +): + """Delete Tasklist + + Delete a tasklist. + + Args: + tasklist_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete | HTTPValidationError + """ + + return ( + await asyncio_detailed( + tasklist_id=tasklist_id, + client=client, + ) + ).parsed diff --git a/src/task-client-service-client/task_client_service_client/api/tasklists/insert_tasklist_tasklists_post.py b/src/task-client-service-client/task_client_service_client/api/tasklists/insert_tasklist_tasklists_post.py new file mode 100644 index 00000000..3de36c02 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/tasklists/insert_tasklist_tasklists_post.py @@ -0,0 +1,176 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.insert_tasklist_tasklists_post_body import InsertTasklistTasklistsPostBody +from ...models.insert_tasklist_tasklists_post_response_insert_tasklist_tasklists_post import ( + InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost, +) +from ...types import Response + + +def _get_kwargs( + *, + body: InsertTasklistTasklistsPostBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/tasklists", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost | None: + if response.status_code == 200: + response_200 = InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: InsertTasklistTasklistsPostBody, +) -> Response[HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost]: + """Insert Tasklist + + Create a new tasklist. + + Args: + body (InsertTasklistTasklistsPostBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: InsertTasklistTasklistsPostBody, +) -> HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost | None: + """Insert Tasklist + + Create a new tasklist. + + Args: + body (InsertTasklistTasklistsPostBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: InsertTasklistTasklistsPostBody, +) -> Response[HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost]: + """Insert Tasklist + + Create a new tasklist. + + Args: + body (InsertTasklistTasklistsPostBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: InsertTasklistTasklistsPostBody, +) -> HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost | None: + """Insert Tasklist + + Create a new tasklist. + + Args: + body (InsertTasklistTasklistsPostBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/src/task-client-service-client/task_client_service_client/api/tasklists/list_tasklists_tasklists_get.py b/src/task-client-service-client/task_client_service_client/api/tasklists/list_tasklists_tasklists_get.py new file mode 100644 index 00000000..c8eea0a0 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/tasklists/list_tasklists_tasklists_get.py @@ -0,0 +1,140 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.list_tasklists_tasklists_get_response_200_item import ListTasklistsTasklistsGetResponse200Item +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/tasklists", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> list[ListTasklistsTasklistsGetResponse200Item] | None: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = ListTasklistsTasklistsGetResponse200Item.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[list[ListTasklistsTasklistsGetResponse200Item]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[list[ListTasklistsTasklistsGetResponse200Item]]: + """List Tasklists + + Get a list of tasklists from the task client. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[list[ListTasklistsTasklistsGetResponse200Item]] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, +) -> list[ListTasklistsTasklistsGetResponse200Item] | None: + """List Tasklists + + Get a list of tasklists from the task client. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + list[ListTasklistsTasklistsGetResponse200Item] + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[list[ListTasklistsTasklistsGetResponse200Item]]: + """List Tasklists + + Get a list of tasklists from the task client. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[list[ListTasklistsTasklistsGetResponse200Item]] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, +) -> list[ListTasklistsTasklistsGetResponse200Item] | None: + """List Tasklists + + Get a list of tasklists from the task client. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + list[ListTasklistsTasklistsGetResponse200Item] + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/src/task-client-service-client/task_client_service_client/api/tasks/__init__.py b/src/task-client-service-client/task_client_service_client/api/tasks/__init__.py new file mode 100644 index 00000000..2d7c0b23 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/tasks/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/src/task-client-service-client/task_client_service_client/api/tasks/delete_task_tasks_tasklist_id_task_id_delete.py b/src/task-client-service-client/task_client_service_client/api/tasks/delete_task_tasks_tasklist_id_task_id_delete.py new file mode 100644 index 00000000..bfa5a406 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/tasks/delete_task_tasks_tasklist_id_task_id_delete.py @@ -0,0 +1,182 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.delete_task_tasks_tasklist_id_task_id_delete_response_delete_task_tasks_tasklist_id_task_id_delete import ( + DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete, +) +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + tasklist_id: str, + task_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/tasks/{tasklist_id}/{task_id}", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError | None: + if response.status_code == 200: + response_200 = DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete.from_dict( + response.json() + ) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + tasklist_id: str, + task_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError]: + """Delete Task + + Mark a task as deleted. + + Args: + tasklist_id (str): + task_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + task_id=task_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + tasklist_id: str, + task_id: str, + *, + client: AuthenticatedClient | Client, +) -> DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError | None: + """Delete Task + + Mark a task as deleted. + + Args: + tasklist_id (str): + task_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError + """ + + return sync_detailed( + tasklist_id=tasklist_id, + task_id=task_id, + client=client, + ).parsed + + +async def asyncio_detailed( + tasklist_id: str, + task_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError]: + """Delete Task + + Mark a task as deleted. + + Args: + tasklist_id (str): + task_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + task_id=task_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + tasklist_id: str, + task_id: str, + *, + client: AuthenticatedClient | Client, +) -> DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError | None: + """Delete Task + + Mark a task as deleted. + + Args: + tasklist_id (str): + task_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete | HTTPValidationError + """ + + return ( + await asyncio_detailed( + tasklist_id=tasklist_id, + task_id=task_id, + client=client, + ) + ).parsed diff --git a/src/task-client-service-client/task_client_service_client/api/tasks/get_task_tasks_tasklist_id_task_id_get.py b/src/task-client-service-client/task_client_service_client/api/tasks/get_task_tasks_tasklist_id_task_id_get.py new file mode 100644 index 00000000..6a3b20b0 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/tasks/get_task_tasks_tasklist_id_task_id_get.py @@ -0,0 +1,180 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.get_task_tasks_tasklist_id_task_id_get_response_get_task_tasks_tasklist_id_task_id_get import ( + GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet, +) +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + tasklist_id: str, + task_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/tasks/{tasklist_id}/{task_id}", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError | None: + if response.status_code == 200: + response_200 = GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + tasklist_id: str, + task_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError]: + """Get Task + + Read a single task from a given tasklist. + + Args: + tasklist_id (str): + task_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + task_id=task_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + tasklist_id: str, + task_id: str, + *, + client: AuthenticatedClient | Client, +) -> GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError | None: + """Get Task + + Read a single task from a given tasklist. + + Args: + tasklist_id (str): + task_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError + """ + + return sync_detailed( + tasklist_id=tasklist_id, + task_id=task_id, + client=client, + ).parsed + + +async def asyncio_detailed( + tasklist_id: str, + task_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError]: + """Get Task + + Read a single task from a given tasklist. + + Args: + tasklist_id (str): + task_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + task_id=task_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + tasklist_id: str, + task_id: str, + *, + client: AuthenticatedClient | Client, +) -> GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError | None: + """Get Task + + Read a single task from a given tasklist. + + Args: + tasklist_id (str): + task_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet | HTTPValidationError + """ + + return ( + await asyncio_detailed( + tasklist_id=tasklist_id, + task_id=task_id, + client=client, + ) + ).parsed diff --git a/src/task-client-service-client/task_client_service_client/api/tasks/insert_task_tasks_tasklist_id_post.py b/src/task-client-service-client/task_client_service_client/api/tasks/insert_task_tasks_tasklist_id_post.py new file mode 100644 index 00000000..7ef76014 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/tasks/insert_task_tasks_tasklist_id_post.py @@ -0,0 +1,189 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.insert_task_tasks_tasklist_id_post_response_insert_task_tasks_tasklist_id_post import ( + InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost, +) +from ...models.insert_task_tasks_tasklist_id_post_task_input import InsertTaskTasksTasklistIdPostTaskInput +from ...types import Response + + +def _get_kwargs( + tasklist_id: str, + *, + body: InsertTaskTasksTasklistIdPostTaskInput, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/tasks/{tasklist_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost | None: + if response.status_code == 200: + response_200 = InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, + body: InsertTaskTasksTasklistIdPostTaskInput, +) -> Response[HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost]: + """Insert Task + + Insert a new task into a tasklist. + + Args: + tasklist_id (str): + body (InsertTaskTasksTasklistIdPostTaskInput): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, + body: InsertTaskTasksTasklistIdPostTaskInput, +) -> HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost | None: + """Insert Task + + Insert a new task into a tasklist. + + Args: + tasklist_id (str): + body (InsertTaskTasksTasklistIdPostTaskInput): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost + """ + + return sync_detailed( + tasklist_id=tasklist_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, + body: InsertTaskTasksTasklistIdPostTaskInput, +) -> Response[HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost]: + """Insert Task + + Insert a new task into a tasklist. + + Args: + tasklist_id (str): + body (InsertTaskTasksTasklistIdPostTaskInput): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, + body: InsertTaskTasksTasklistIdPostTaskInput, +) -> HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost | None: + """Insert Task + + Insert a new task into a tasklist. + + Args: + tasklist_id (str): + body (InsertTaskTasksTasklistIdPostTaskInput): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost + """ + + return ( + await asyncio_detailed( + tasklist_id=tasklist_id, + client=client, + body=body, + ) + ).parsed diff --git a/src/task-client-service-client/task_client_service_client/api/tasks/list_tasks_tasks_tasklist_id_get.py b/src/task-client-service-client/task_client_service_client/api/tasks/list_tasks_tasks_tasklist_id_get.py new file mode 100644 index 00000000..4a373d38 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/api/tasks/list_tasks_tasks_tasklist_id_get.py @@ -0,0 +1,170 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.list_tasks_tasks_tasklist_id_get_response_200_item import ListTasksTasksTasklistIdGetResponse200Item +from ...types import Response + + +def _get_kwargs( + tasklist_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/tasks/{tasklist_id}", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item] | None: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = ListTasksTasksTasklistIdGetResponse200Item.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item]]: + """List Tasks + + List all the tasks within a given tasklist. + + Args: + tasklist_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item]] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, +) -> HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item] | None: + """List Tasks + + List all the tasks within a given tasklist. + + Args: + tasklist_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item] + """ + + return sync_detailed( + tasklist_id=tasklist_id, + client=client, + ).parsed + + +async def asyncio_detailed( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item]]: + """List Tasks + + List all the tasks within a given tasklist. + + Args: + tasklist_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item]] + """ + + kwargs = _get_kwargs( + tasklist_id=tasklist_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + tasklist_id: str, + *, + client: AuthenticatedClient | Client, +) -> HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item] | None: + """List Tasks + + List all the tasks within a given tasklist. + + Args: + tasklist_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | list[ListTasksTasksTasklistIdGetResponse200Item] + """ + + return ( + await asyncio_detailed( + tasklist_id=tasklist_id, + client=client, + ) + ).parsed diff --git a/src/task-client-service-client/task_client_service_client/client.py b/src/task-client-service-client/task_client_service_client/client.py new file mode 100644 index 00000000..1b7055ab --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/client.py @@ -0,0 +1,268 @@ +import ssl +from typing import Any + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: httpx.Client | None = field(default=None, init=False) + _async_client: httpx.AsyncClient | None = field(default=None, init=False) + + def with_headers(self, headers: dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout configuration""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually set the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + token: The token to use for authentication + prefix: The prefix to use for the Authorization header + auth_header_name: The name of the Authorization header + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: httpx.Client | None = field(default=None, init=False) + _async_client: httpx.AsyncClient | None = field(default=None, init=False) + + token: str + prefix: str = "Bearer" + auth_header_name: str = "Authorization" + + def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout configuration""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": + """Manually set the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/src/task-client-service-client/task_client_service_client/errors.py b/src/task-client-service-client/task_client_service_client/errors.py new file mode 100644 index 00000000..5f92e76a --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/errors.py @@ -0,0 +1,16 @@ +"""Contains shared errors types that can be raised from API functions""" + + +class UnexpectedStatus(Exception): + """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content + + super().__init__( + f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}" + ) + + +__all__ = ["UnexpectedStatus"] diff --git a/src/task-client-service-client/task_client_service_client/models/__init__.py b/src/task-client-service-client/task_client_service_client/models/__init__.py new file mode 100644 index 00000000..c542d9ec --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/__init__.py @@ -0,0 +1,37 @@ +"""Contains all the data models used in inputs/outputs""" + +from .delete_task_tasks_tasklist_id_task_id_delete_response_delete_task_tasks_tasklist_id_task_id_delete import ( + DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete, +) +from .delete_tasklist_tasklists_tasklist_id_delete_response_delete_tasklist_tasklists_tasklist_id_delete import ( + DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete, +) +from .get_task_tasks_tasklist_id_task_id_get_response_get_task_tasks_tasklist_id_task_id_get import ( + GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet, +) +from .http_validation_error import HTTPValidationError +from .insert_task_tasks_tasklist_id_post_response_insert_task_tasks_tasklist_id_post import ( + InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost, +) +from .insert_task_tasks_tasklist_id_post_task_input import InsertTaskTasksTasklistIdPostTaskInput +from .insert_tasklist_tasklists_post_body import InsertTasklistTasklistsPostBody +from .insert_tasklist_tasklists_post_response_insert_tasklist_tasklists_post import ( + InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost, +) +from .list_tasklists_tasklists_get_response_200_item import ListTasklistsTasklistsGetResponse200Item +from .list_tasks_tasks_tasklist_id_get_response_200_item import ListTasksTasksTasklistIdGetResponse200Item +from .validation_error import ValidationError + +__all__ = ( + "DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete", + "DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete", + "GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet", + "HTTPValidationError", + "InsertTasklistTasklistsPostBody", + "InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost", + "InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost", + "InsertTaskTasksTasklistIdPostTaskInput", + "ListTasklistsTasklistsGetResponse200Item", + "ListTasksTasksTasklistIdGetResponse200Item", + "ValidationError", +) diff --git a/src/task-client-service-client/task_client_service_client/models/delete_task_tasks_tasklist_id_task_id_delete_response_delete_task_tasks_tasklist_id_task_id_delete.py b/src/task-client-service-client/task_client_service_client/models/delete_task_tasks_tasklist_id_task_id_delete_response_delete_task_tasks_tasklist_id_task_id_delete.py new file mode 100644 index 00000000..63f1db77 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/delete_task_tasks_tasklist_id_task_id_delete_response_delete_task_tasks_tasklist_id_task_id_delete.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete") + + +@_attrs_define +class DeleteTaskTasksTasklistIdTaskIdDeleteResponseDeleteTaskTasksTasklistIdTaskIdDelete: + """ """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + delete_task_tasks_tasklist_id_task_id_delete_response_delete_task_tasks_tasklist_id_task_id_delete = cls() + + delete_task_tasks_tasklist_id_task_id_delete_response_delete_task_tasks_tasklist_id_task_id_delete.additional_properties = d + return delete_task_tasks_tasklist_id_task_id_delete_response_delete_task_tasks_tasklist_id_task_id_delete + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/delete_tasklist_tasklists_tasklist_id_delete_response_delete_tasklist_tasklists_tasklist_id_delete.py b/src/task-client-service-client/task_client_service_client/models/delete_tasklist_tasklists_tasklist_id_delete_response_delete_tasklist_tasklists_tasklist_id_delete.py new file mode 100644 index 00000000..b6359b4b --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/delete_tasklist_tasklists_tasklist_id_delete_response_delete_tasklist_tasklists_tasklist_id_delete.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete") + + +@_attrs_define +class DeleteTasklistTasklistsTasklistIdDeleteResponseDeleteTasklistTasklistsTasklistIdDelete: + """ """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + delete_tasklist_tasklists_tasklist_id_delete_response_delete_tasklist_tasklists_tasklist_id_delete = cls() + + delete_tasklist_tasklists_tasklist_id_delete_response_delete_tasklist_tasklists_tasklist_id_delete.additional_properties = d + return delete_tasklist_tasklists_tasklist_id_delete_response_delete_tasklist_tasklists_tasklist_id_delete + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/get_task_tasks_tasklist_id_task_id_get_response_get_task_tasks_tasklist_id_task_id_get.py b/src/task-client-service-client/task_client_service_client/models/get_task_tasks_tasklist_id_task_id_get_response_get_task_tasks_tasklist_id_task_id_get.py new file mode 100644 index 00000000..3822f170 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/get_task_tasks_tasklist_id_task_id_get_response_get_task_tasks_tasklist_id_task_id_get.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet") + + +@_attrs_define +class GetTaskTasksTasklistIdTaskIdGetResponseGetTaskTasksTasklistIdTaskIdGet: + """ """ + + additional_properties: dict[str, bool | None | str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + get_task_tasks_tasklist_id_task_id_get_response_get_task_tasks_tasklist_id_task_id_get = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + + def _parse_additional_property(data: object) -> bool | None | str: + if data is None: + return data + return cast(bool | None | str, data) + + additional_property = _parse_additional_property(prop_dict) + + additional_properties[prop_name] = additional_property + + get_task_tasks_tasklist_id_task_id_get_response_get_task_tasks_tasklist_id_task_id_get.additional_properties = ( + additional_properties + ) + return get_task_tasks_tasklist_id_task_id_get_response_get_task_tasks_tasklist_id_task_id_get + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> bool | None | str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: bool | None | str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/http_validation_error.py b/src/task-client-service-client/task_client_service_client/models/http_validation_error.py new file mode 100644 index 00000000..195e5a76 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/http_validation_error.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.validation_error import ValidationError + + +T = TypeVar("T", bound="HTTPValidationError") + + +@_attrs_define +class HTTPValidationError: + """ + Attributes: + detail (list[ValidationError] | Unset): + """ + + detail: list[ValidationError] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + detail: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.detail, Unset): + detail = [] + for detail_item_data in self.detail: + detail_item = detail_item_data.to_dict() + detail.append(detail_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if detail is not UNSET: + field_dict["detail"] = detail + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.validation_error import ValidationError + + d = dict(src_dict) + _detail = d.pop("detail", UNSET) + detail: list[ValidationError] | Unset = UNSET + if _detail is not UNSET: + detail = [] + for detail_item_data in _detail: + detail_item = ValidationError.from_dict(detail_item_data) + + detail.append(detail_item) + + http_validation_error = cls( + detail=detail, + ) + + http_validation_error.additional_properties = d + return http_validation_error + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/insert_task_tasks_tasklist_id_post_response_insert_task_tasks_tasklist_id_post.py b/src/task-client-service-client/task_client_service_client/models/insert_task_tasks_tasklist_id_post_response_insert_task_tasks_tasklist_id_post.py new file mode 100644 index 00000000..d12ce988 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/insert_task_tasks_tasklist_id_post_response_insert_task_tasks_tasklist_id_post.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost") + + +@_attrs_define +class InsertTaskTasksTasklistIdPostResponseInsertTaskTasksTasklistIdPost: + """ """ + + additional_properties: dict[str, bool | None | str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + insert_task_tasks_tasklist_id_post_response_insert_task_tasks_tasklist_id_post = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + + def _parse_additional_property(data: object) -> bool | None | str: + if data is None: + return data + return cast(bool | None | str, data) + + additional_property = _parse_additional_property(prop_dict) + + additional_properties[prop_name] = additional_property + + insert_task_tasks_tasklist_id_post_response_insert_task_tasks_tasklist_id_post.additional_properties = ( + additional_properties + ) + return insert_task_tasks_tasklist_id_post_response_insert_task_tasks_tasklist_id_post + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> bool | None | str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: bool | None | str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/insert_task_tasks_tasklist_id_post_task_input.py b/src/task-client-service-client/task_client_service_client/models/insert_task_tasks_tasklist_id_post_task_input.py new file mode 100644 index 00000000..b8135fce --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/insert_task_tasks_tasklist_id_post_task_input.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InsertTaskTasksTasklistIdPostTaskInput") + + +@_attrs_define +class InsertTaskTasksTasklistIdPostTaskInput: + """ """ + + additional_properties: dict[str, bool | None | str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + insert_task_tasks_tasklist_id_post_task_input = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + + def _parse_additional_property(data: object) -> bool | None | str: + if data is None: + return data + return cast(bool | None | str, data) + + additional_property = _parse_additional_property(prop_dict) + + additional_properties[prop_name] = additional_property + + insert_task_tasks_tasklist_id_post_task_input.additional_properties = additional_properties + return insert_task_tasks_tasklist_id_post_task_input + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> bool | None | str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: bool | None | str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/insert_tasklist_tasklists_post_body.py b/src/task-client-service-client/task_client_service_client/models/insert_tasklist_tasklists_post_body.py new file mode 100644 index 00000000..aaa445db --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/insert_tasklist_tasklists_post_body.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InsertTasklistTasklistsPostBody") + + +@_attrs_define +class InsertTasklistTasklistsPostBody: + """ """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + insert_tasklist_tasklists_post_body = cls() + + insert_tasklist_tasklists_post_body.additional_properties = d + return insert_tasklist_tasklists_post_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/insert_tasklist_tasklists_post_response_insert_tasklist_tasklists_post.py b/src/task-client-service-client/task_client_service_client/models/insert_tasklist_tasklists_post_response_insert_tasklist_tasklists_post.py new file mode 100644 index 00000000..18d9504d --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/insert_tasklist_tasklists_post_response_insert_tasklist_tasklists_post.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost") + + +@_attrs_define +class InsertTasklistTasklistsPostResponseInsertTasklistTasklistsPost: + """ """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + insert_tasklist_tasklists_post_response_insert_tasklist_tasklists_post = cls() + + insert_tasklist_tasklists_post_response_insert_tasklist_tasklists_post.additional_properties = d + return insert_tasklist_tasklists_post_response_insert_tasklist_tasklists_post + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/list_tasklists_tasklists_get_response_200_item.py b/src/task-client-service-client/task_client_service_client/models/list_tasklists_tasklists_get_response_200_item.py new file mode 100644 index 00000000..f31e0c3e --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/list_tasklists_tasklists_get_response_200_item.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ListTasklistsTasklistsGetResponse200Item") + + +@_attrs_define +class ListTasklistsTasklistsGetResponse200Item: + """ """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + list_tasklists_tasklists_get_response_200_item = cls() + + list_tasklists_tasklists_get_response_200_item.additional_properties = d + return list_tasklists_tasklists_get_response_200_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/list_tasks_tasks_tasklist_id_get_response_200_item.py b/src/task-client-service-client/task_client_service_client/models/list_tasks_tasks_tasklist_id_get_response_200_item.py new file mode 100644 index 00000000..6f13bece --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/list_tasks_tasks_tasklist_id_get_response_200_item.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ListTasksTasksTasklistIdGetResponse200Item") + + +@_attrs_define +class ListTasksTasksTasklistIdGetResponse200Item: + """ """ + + additional_properties: dict[str, bool | None | str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + list_tasks_tasks_tasklist_id_get_response_200_item = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + + def _parse_additional_property(data: object) -> bool | None | str: + if data is None: + return data + return cast(bool | None | str, data) + + additional_property = _parse_additional_property(prop_dict) + + additional_properties[prop_name] = additional_property + + list_tasks_tasks_tasklist_id_get_response_200_item.additional_properties = additional_properties + return list_tasks_tasks_tasklist_id_get_response_200_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> bool | None | str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: bool | None | str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/models/validation_error.py b/src/task-client-service-client/task_client_service_client/models/validation_error.py new file mode 100644 index 00000000..cb0708f3 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/models/validation_error.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ValidationError") + + +@_attrs_define +class ValidationError: + """ + Attributes: + loc (list[int | str]): + msg (str): + type_ (str): + """ + + loc: list[int | str] + msg: str + type_: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + loc = [] + for loc_item_data in self.loc: + loc_item: int | str + loc_item = loc_item_data + loc.append(loc_item) + + msg = self.msg + + type_ = self.type_ + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "loc": loc, + "msg": msg, + "type": type_, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + loc = [] + _loc = d.pop("loc") + for loc_item_data in _loc: + + def _parse_loc_item(data: object) -> int | str: + return cast(int | str, data) + + loc_item = _parse_loc_item(loc_item_data) + + loc.append(loc_item) + + msg = d.pop("msg") + + type_ = d.pop("type") + + validation_error = cls( + loc=loc, + msg=msg, + type_=type_, + ) + + validation_error.additional_properties = d + return validation_error + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/task-client-service-client/task_client_service_client/py.typed b/src/task-client-service-client/task_client_service_client/py.typed new file mode 100644 index 00000000..1aad3271 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 \ No newline at end of file diff --git a/src/task-client-service-client/task_client_service_client/types.py b/src/task-client-service-client/task_client_service_client/types.py new file mode 100644 index 00000000..b64af095 --- /dev/null +++ b/src/task-client-service-client/task_client_service_client/types.py @@ -0,0 +1,54 @@ +"""Contains some shared types for properties""" + +from collections.abc import Mapping, MutableMapping +from http import HTTPStatus +from typing import IO, BinaryIO, Generic, Literal, TypeVar + +from attrs import define + + +class Unset: + def __bool__(self) -> Literal[False]: + return False + + +UNSET: Unset = Unset() + +# The types that `httpx.Client(files=)` can accept, copied from that library. +FileContent = IO[bytes] | bytes | str +FileTypes = ( + # (filename, file (or bytes), content_type) + tuple[str | None, FileContent, str | None] + # (filename, file (or bytes), content_type, headers) + | tuple[str | None, FileContent, str | None, Mapping[str, str]] +) +RequestFiles = list[tuple[str, FileTypes]] + + +@define +class File: + """Contains information for file uploads""" + + payload: BinaryIO + file_name: str | None = None + mime_type: str | None = None + + def to_tuple(self) -> FileTypes: + """Return a tuple representation that httpx will accept for multipart/form-data""" + return self.file_name, self.payload, self.mime_type + + +T = TypeVar("T") + + +@define +class Response(Generic[T]): + """A response from an endpoint""" + + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: T | None + + +__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"]