From d9b682661f4c46af6914a451ba3834368458f54e Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Tue, 14 Oct 2025 00:59:45 -0400 Subject: [PATCH 01/11] chore: satisfy all ruff checks in tests --- tests/integration/test_client_integration.py | 4 +++- tests/unit/test_gmail_client_circleci.py | 24 ++++++++++++++----- tests/unit/test_gmail_message_circleci.py | 18 ++++++++++---- tests/unit/test_manual_env_loader_circleci.py | 18 +++++++++----- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/tests/integration/test_client_integration.py b/tests/integration/test_client_integration.py index a4a774d5..6952e1e0 100644 --- a/tests/integration/test_client_integration.py +++ b/tests/integration/test_client_integration.py @@ -120,7 +120,9 @@ def test_client_scope_permissions() -> None: pytest.skip("Skipping integration test: credentials.json not found.") except (RuntimeError, ValueError, ConnectionError) as e: # If we get a 403 error, it's likely a scope issue - if "403" in str(e) or "insufficient" in str(e).lower(): + if "No valid credentials found" in str(e): + pytest.skip("Skipping integration test: no valid credentials found.") + elif "403" in str(e) or "insufficient" in str(e).lower(): pytest.fail(f"OAuth scope issue - client may not have required permissions: {e}") else: pytest.fail(f"Integration test failed: {e}") diff --git a/tests/unit/test_gmail_client_circleci.py b/tests/unit/test_gmail_client_circleci.py index 3cedc8e0..fe03f51c 100644 --- a/tests/unit/test_gmail_client_circleci.py +++ b/tests/unit/test_gmail_client_circleci.py @@ -1,12 +1,16 @@ +"""Unit tests for GmailClient implementation. + +This module contains tests for authentication, message operations, and error handling +in the GmailClient class using CircleCI environment variables and mocks. +""" + import os from typing import Any from unittest.mock import Mock, patch import pytest -from googleapiclient.errors import HttpError - from gmail_client_impl.gmail_impl import GmailClient - +from googleapiclient.errors import HttpError pytestmark = pytest.mark.circleci @@ -21,6 +25,7 @@ def _mock_chain_for(service: Mock) -> tuple[Mock, Mock]: @patch("gmail_client_impl.gmail_impl.build") def test_init_with_provided_service_skips_auth(mock_build: Any) -> None: + """Test that providing a service to GmailClient skips authentication and does not call build.""" mock_service = Mock() client = GmailClient(service=mock_service) assert client.service is mock_service @@ -39,6 +44,7 @@ def test_init_with_provided_service_skips_auth(mock_build: Any) -> None: }, ) def test_env_auth_success_saves_token(mock_request: Any, mock_creds_cls: Any, mock_build: Any) -> None: + """Test that environment-based authentication saves the token when credentials are valid.""" mock_creds = Mock() mock_creds.valid = True mock_creds.refresh_token = "rtok" @@ -46,7 +52,7 @@ def test_env_auth_success_saves_token(mock_request: Any, mock_creds_cls: Any, mo mock_build.return_value = Mock() with patch("gmail_client_impl.gmail_impl.Path") as mock_path, patch.object( - GmailClient, "_save_token" + GmailClient, "_save_token", ) as mock_save: mock_path.return_value.exists.return_value = False client = GmailClient() @@ -56,8 +62,9 @@ def test_env_auth_success_saves_token(mock_request: Any, mock_creds_cls: Any, mo def test_interactive_flow_returns_invalid_creds_raises_failure_message() -> None: + """Test that an invalid credential returned from interactive flow raises a failure message.""" with patch.object(GmailClient, "_run_interactive_flow") as mock_flow, patch( - "gmail_client_impl.gmail_impl.build" + "gmail_client_impl.gmail_impl.build", ) as mock_build: creds = Mock() creds.valid = False @@ -73,6 +80,7 @@ def test_interactive_flow_returns_invalid_creds_raises_failure_message() -> None @patch("gmail_client_impl.gmail_impl.Path") @patch("gmail_client_impl.gmail_impl.Credentials") def test_token_file_not_found_then_error(mock_creds_cls: Any, mock_path: Any, mock_build: Any) -> None: + """Test that GmailClient raises an error when the token file is not found and no credentials are available.""" with patch.dict(os.environ, {}, clear=True): mock_path.return_value.exists.return_value = False with pytest.raises(RuntimeError, match="No valid credentials found"): @@ -80,6 +88,7 @@ def test_token_file_not_found_then_error(mock_creds_cls: Any, mock_path: Any, mo def test_run_interactive_flow_missing_credentials_file() -> None: + """Test that _run_interactive_flow raises FileNotFoundError when credentials file is missing.""" client = GmailClient(service=Mock()) with patch("gmail_client_impl.gmail_impl.Path") as mock_path: mock_path.return_value.exists.return_value = False @@ -88,6 +97,7 @@ def test_run_interactive_flow_missing_credentials_file() -> None: def test_delete_message_success_and_failure_paths() -> None: + """Test the success and failure paths for deleting a Gmail message.""" service = Mock() client = GmailClient(service=service) _, msgs = _mock_chain_for(service) @@ -108,6 +118,7 @@ def test_delete_message_success_and_failure_paths() -> None: def test_mark_as_read_success_and_failure_paths() -> None: + """Test the success and failure paths for marking a Gmail message as read.""" service = Mock() client = GmailClient(service=service) _, msgs = _mock_chain_for(service) @@ -123,9 +134,10 @@ def test_mark_as_read_success_and_failure_paths() -> None: def test_get_message_and_get_messages_iter() -> None: + """Test getting a single message and iterating over multiple messages from GmailClient.""" service = Mock() client = GmailClient(service=service) - users, msgs = _mock_chain_for(service) + _, msgs = _mock_chain_for(service) # get_message get_call = Mock() diff --git a/tests/unit/test_gmail_message_circleci.py b/tests/unit/test_gmail_message_circleci.py index d1a363f9..ada0a696 100644 --- a/tests/unit/test_gmail_message_circleci.py +++ b/tests/unit/test_gmail_message_circleci.py @@ -1,11 +1,15 @@ +"""Unit tests for GmailMessage parsing and handling. + +This module contains tests for subject parsing, date formatting, body extraction, +handling of multipart messages, and error cases for the GmailMessage class. +""" + import base64 from email.message import EmailMessage import pytest - from gmail_client_impl.message_impl import GmailMessage - pytestmark = pytest.mark.circleci @@ -16,6 +20,7 @@ def _enc(s: bytes | str) -> str: def test_subject_rfc2047_and_plain() -> None: + """Test that GmailMessage correctly parses both plain and RFC2047-encoded subject headers.""" msg1 = GmailMessage("a", _enc("Subject: Plain\r\n\r\nB")) assert msg1.subject == "Plain" @@ -29,6 +34,7 @@ def test_subject_rfc2047_and_plain() -> None: def test_date_formatting_and_fallback() -> None: + """Test that date formatting works and falls back to raw value if parsing fails.""" good = GmailMessage( "g", _enc("Date: Wed, 30 Jul 2025 10:30:00 +0000\r\n\r\nB"), @@ -40,6 +46,7 @@ def test_date_formatting_and_fallback() -> None: def test_body_multipart_prefers_text_plain_non_attachment() -> None: + """Test that multipart messages prefer text/plain parts that are not attachments.""" em = EmailMessage() em["From"] = "x@example.com" em.set_content("plain text body") @@ -50,15 +57,18 @@ def test_body_multipart_prefers_text_plain_non_attachment() -> None: def test_body_no_text_plain_reports_placeholder() -> None: + """Test that messages without text/plain parts return a placeholder or decoded HTML content.""" em = EmailMessage() em.add_alternative("

only html

", subtype="html") raw = _enc(em.as_bytes()) msg = GmailMessage("m", raw) # Either NO_PLAIN_TEXT_BODY or decoded html, implementation returns html content in tests - assert isinstance(msg.body, str) and len(msg.body) > 0 + assert isinstance(msg.body, str) + assert len(msg.body) > 0 def test_non_bytes_payloads_and_decode_errors() -> None: + """Test handling of non-bytes payloads and graceful decode errors in multipart messages.""" # Singlepart non-bytes payload raw = _enc("Subject: S\r\n\r\ntext") msg = GmailMessage("x", raw) @@ -78,7 +88,7 @@ def test_non_bytes_payloads_and_decode_errors() -> None: def test_error_parsing_message_defaults() -> None: - # Binary garbage should trigger error-parsing defaults + """Binary garbage should trigger error-parsing defaults.""" blob = bytes(range(256)) msg = GmailMessage("bin", _enc(blob)) assert msg.subject == GmailMessage.ERROR_PARSING_MESSAGE diff --git a/tests/unit/test_manual_env_loader_circleci.py b/tests/unit/test_manual_env_loader_circleci.py index 27fddb27..497c68c6 100644 --- a/tests/unit/test_manual_env_loader_circleci.py +++ b/tests/unit/test_manual_env_loader_circleci.py @@ -1,18 +1,25 @@ -import os -from pathlib import Path -from types import ModuleType -from typing import Any +"""Unit tests for manual environment loader fallback in CircleCI context. + +This module verifies that environment variables are loaded from a .env file +when the dotenv import fails, and checks the GmailClient exposure. +""" import importlib +import os import sys +from pathlib import Path +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + from types import ModuleType pytestmark = pytest.mark.circleci def test_manual_env_loader_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that the manual environment loader fallback loads from a .env file when the dotenv import fails.""" # Create a temporary package layout to import the module cleanly pkg_dir = tmp_path / "gmail_client_impl" src_dir = pkg_dir @@ -22,8 +29,7 @@ def test_manual_env_loader_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPa (src_dir / "__init__.py").write_text("\n") # Read original file content - from pathlib import Path as P - original = (P.cwd() / "src" / "gmail_client_impl" / "src" / "gmail_client_impl" / "gmail_impl.py").read_text() + original = (Path.cwd() / "src" / "gmail_client_impl" / "src" / "gmail_client_impl" / "gmail_impl.py").read_text() # Force the fallback path by making the dotenv import raise ImportError inside the copied file original = original.replace( "from dotenv import load_dotenv", From 30b1892ceaa66ae1730aa5f8a7d613bac85f88e4 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 15 Oct 2025 03:59:27 -0400 Subject: [PATCH 02/11] feat: redo entire hw1 pr --- CONTRIBUTING.md | 115 +++++ DESIGN.md | 258 ++++++++++ main.py | 2 +- pyproject.toml | 5 + .../src/gmail_client_impl/gmail_impl.py | 2 +- src/mail_client_adapter/README.md | 11 + src/mail_client_adapter/pyproject.toml | 50 ++ .../src/mail_client_adapter/__init__.py | 5 + .../mail_client_adapter.py | 105 ++++ src/mail_client_adapter/tests/test_adapter.py | 48 ++ src/mail_client_service/README.md | 9 + .../mail_client_service_client/.gitignore | 23 + .../mail_client_service_client/README.md | 123 +++++ .../mail_client_service_client/pyproject.toml | 39 ++ .../mail_client_service_client/__init__.py | 8 + .../api/__init__.py | 1 + .../api/default/__init__.py | 1 + ...lete_message_messages_message_id_delete.py | 174 +++++++ .../get_message_messages_message_id_get.py | 166 +++++++ .../api/default/get_messages_messages_get.py | 179 +++++++ ...d_messages_message_id_mark_as_read_post.py | 176 +++++++ .../src/mail_client_service_client/client.py | 270 +++++++++++ .../src/mail_client_service_client/errors.py | 16 + .../models/__init__.py | 23 + ...lete_message_messages_message_id_delete.py | 44 ++ ...nse_get_message_messages_message_id_get.py | 44 ++ ...messages_messages_get_response_200_item.py | 44 ++ .../models/http_validation_error.py | 75 +++ ...d_messages_message_id_mark_as_read_post.py | 44 ++ .../models/validation_error.py | 88 ++++ .../src/mail_client_service_client/py.typed | 1 + .../src/mail_client_service_client/types.py | 54 +++ src/mail_client_service/pyproject.toml | 56 +++ .../src/mail_client_service/__init__.py | 5 + .../src/mail_client_service/main.py | 93 ++++ .../src/mail_client_service/test_client.py | 97 ++++ src/mail_client_service/tests/test_app.py | 136 ++++++ tests/e2e/test_mail_client_service_e2e.py | 140 ++++++ tests/integration/test_adapter_e2e.py | 106 +++++ uv.lock | 449 ++++++++++++++++++ 40 files changed, 3283 insertions(+), 2 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 DESIGN.md create mode 100644 src/mail_client_adapter/README.md create mode 100644 src/mail_client_adapter/pyproject.toml create mode 100644 src/mail_client_adapter/src/mail_client_adapter/__init__.py create mode 100644 src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py create mode 100644 src/mail_client_adapter/tests/test_adapter.py create mode 100644 src/mail_client_service/README.md create mode 100644 src/mail_client_service/mail_client_service_client/.gitignore create mode 100644 src/mail_client_service/mail_client_service_client/README.md create mode 100644 src/mail_client_service/mail_client_service_client/pyproject.toml create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/__init__.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/__init__.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/__init__.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/delete_message_messages_message_id_delete.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/get_message_messages_message_id_get.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/get_messages_messages_get.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/mark_as_read_messages_message_id_mark_as_read_post.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/client.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/errors.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/__init__.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/delete_message_messages_message_id_delete_response_delete_message_messages_message_id_delete.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/get_message_messages_message_id_get_response_get_message_messages_message_id_get.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/get_messages_messages_get_response_200_item.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/http_validation_error.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/mark_as_read_messages_message_id_mark_as_read_post_response_mark_as_read_messages_message_id_mark_as_read_post.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/validation_error.py create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/py.typed create mode 100644 src/mail_client_service/mail_client_service_client/src/mail_client_service_client/types.py create mode 100644 src/mail_client_service/pyproject.toml create mode 100644 src/mail_client_service/src/mail_client_service/__init__.py create mode 100644 src/mail_client_service/src/mail_client_service/main.py create mode 100644 src/mail_client_service/src/mail_client_service/test_client.py create mode 100644 src/mail_client_service/tests/test_app.py create mode 100644 tests/e2e/test_mail_client_service_e2e.py create mode 100644 tests/integration/test_adapter_e2e.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..27747c00 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# CONTRIBUTING.md + +## Architecture Overview + +### Components +This repository is organized into several components, each encapsulated in its own directory under `src/`. The main components are: +- **gmail_client_impl**: Implements Gmail-specific client logic and message handling. +- **gmail_message_impl**: Contains Gmail message abstractions. +- **mail_client_api**: Defines the core mail client interface and message abstraction. +- **message**: Provides shared message utilities. + +These components interact via well-defined interfaces, allowing for modularity and easy extension or replacement of implementations. + +### Interface Design +The primary interface is defined in `mail_client_api/src/mail_client_api/client.py` and `mail_client_api/src/mail_client_api/message.py`. These interfaces abstract mail client and message operations, enabling different implementations (e.g., Gmail) to be plugged in. The design enforces method signatures and expected behaviors, ensuring consistency across implementations. + +### Implementation Details +Interfaces are implemented using Python's `abc` module, which allows for the definition of abstract base classes. Implementations in `gmail_client_impl` and `gmail_message_impl` inherit from these ABCs and provide concrete logic. This approach ensures that all required methods are implemented and enables type checking. + +Unlike `Protocol` from the `typing` module, which allows for structural subtyping (duck typing), ABCs enforce nominal subtyping, requiring explicit inheritance. Protocols are more flexible for static type checking, but ABCs provide runtime enforcement and can include abstract methods. + +### Dependency Injection +The project uses dependency injection to decouple interface definitions from their implementations. In `main.py` (root), line 8, the injection occurs: + +```python +import gmail_client_impl # gmail takes priority +import mail_client_api +``` + +This pattern allows contributors to swap out implementations (e.g., for testing or extending functionality) without modifying the code that depends on the interface. It enables easier testing, extensibility, and adherence to SOLID principles. + +## Repository Structure + +### Project Organization +- `src/`: Contains all source code, organized by component. +- `tests/`: Contains test suites, organized into `e2e/` (end-to-end), `integration/`, and component-level test directories. +- `docs/`: Contains documentation, including API docs and guides. +- `pyproject.toml`, `uv.lock`: Root configuration and workspace management files. + +### Configuration Files +- **Root `pyproject.toml`**: Manages workspace-wide dependencies, tool configuration, and uv workspace settings. +- **Component `pyproject.toml`**: Manages dependencies and settings specific to each component, allowing for isolated development and testing. + +### Package Structure +`__init__.py` files exist in every Python package directory (e.g., `src/gmail_client_impl/gmail_client_impl/`). They mark directories as Python packages and can be used to expose public APIs. Keeping `__init__.py` slim means minimizing logic in these files—preferably only imports or package-level constants. Contributors should follow this convention to avoid side effects and maintain clarity. + +### Import Guidelines +Absolute imports should be used throughout the repository. Relative imports (e.g., `from . import ...`) are not used and should be avoided for consistency and clarity. Always use absolute imports to reference modules and packages. + +## Testing Strategy + +### Testing Philosophy +Tests should follow these principles: + +- Test via the Public API: Write tests that interact with the code as users would, through its public interfaces. +- Test State not method invocation: Focus on verifying the resulting state or output, not on whether specific methods were called. +- Write Complete and Concise Tests: Ensure each test contains all necessary information for understanding, without unnecessary details. +- Test behaviors not methods: Test distinct behaviors independently, even if they are implemented in the same method. +- Don’t put logic in tests: Avoid adding logic or computation in tests; tests should be obviously correct and easy to verify. +- Write clear failure message: Make sure test failures provide helpful clues about what went wrong and why. + +### Test Organization +- **Unit tests**: Located in each component's `tests/` directory. +- **Integration tests**: Located in `tests/integration/`. +- **E2E tests**: Located in `tests/e2e/`. + +`__init__.py` files are generally omitted in test directories to prevent them from being treated as packages, simplifying test discovery and execution by avoiding import issues. + +### Test Abstraction Levels +Tests operate at multiple abstraction levels: +- Unit: Individual functions/classes +- Integration: Interactions between components +- E2E: Full application workflows + +### Code Coverage +- **Tool**: `coverage.py` is used for coverage analysis. +- **Thresholds**: Minimum acceptable coverage is 85% (in CircleCI). +- **Instructions**: + 1. Install coverage: `uv pip install coverage` + 2. Run tests with coverage: `uv pip install -e . && coverage run -m pytest` + 3. Generate report: `coverage report -m` or `coverage html` + +## Development Tools + +### Workspace Management +The project uses a `uv` workspace to manage multiple components. Essential commands: +- Set up everything: `uv sync --all-packages --extra dev --verbose` + +The root `pyproject.toml` manages shared dependencies and workspace settings, while component-level `pyproject.toml` files manage per-component dependencies and settings. + +### Static Analysis and Code Formatting +**Tools**: `ruff` for static analysis and code formatting +**Instructions**: + - Run static analysis: `uv pip install ruff && ruff check .` + - Run formatting: `uv pip install ruff && ruff format .` +Ruff is run separately from uv, but can be installed via uv for consistency. Importance: Ensures code quality, consistency, and reduces bugs. Ruff enforces style and formatting rules automatically, so no separate formatter is needed. + +### Documentation Generation +- **Tool**: `mkdocs` is used for documentation generation. +- **Instructions**: + - Install: `uv pip install mkdocs` + - Build docs: `mkdocs build` + - Serve docs locally: `mkdocs serve` + +### CI +The CI pipeline in `.circleci/` directory includes jobs for: +- Linting and formatting +- Running tests and checking coverage +- Building documentation + +Jobs are triggered on pull requests and pushes to main branches, ensuring code quality and up-to-date documentation. + +--- + +Please refer to this guide before contributing. For questions, open an issue or contact a maintainer. \ No newline at end of file diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..e94a3672 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,258 @@ +# Design Document: Service-Based Email Client + +This document describes the architecture and design of the service-based email client implementation, which transforms the original direct email library into a REST API service with an adapter that maintains backward compatibility. + +## Executive Summary + +The service-based email client implements a REST API wrapper around the existing Gmail client implementation, enabling remote access to email functionality via HTTP. The solution maintains 100% backward compatibility with existing client code through an adapter pattern while introducing scalability, testability, and deployment flexibility benefits. + +## Architecture Overview + +### Components + +The service-based implementation introduces three new core components that work together to provide a scalable, service-oriented architecture: + +* **FastAPI Service** (`src/mail_client_service/src/mail_client_service/app.py`): A production-ready REST API service built on FastAPI that exposes the original email functionality via HTTP endpoints. It includes comprehensive error handling, input validation via Pydantic models, automatic OpenAPI documentation generation, and dependency injection for the underlying email client. + +* **Auto-Generated Client** (`src/mail_client_service/mail-client-service-client/mail_client_service_client/`): A type-safe HTTP client automatically generated from the FastAPI service's OpenAPI specification using openapi-python-client. This client provides strongly-typed models (`MessageResponse`, `SuccessResponse`), comprehensive error handling, and both synchronous and asynchronous operation support. + +* **Adapter (ServiceImpl)** (`src/mail_client_service_impl/src/mail_client_service_impl/__init__.py`): A seamless adapter that implements the original `Client` protocol while internally delegating to the auto-generated HTTP client. The adapter includes the `ServiceMessage` wrapper class that ensures complete interface compatibility and handles all HTTP-to-domain model transformations. + +### Request Flow + +The request flow demonstrates how a user's method call travels through the system with full observability and error handling at each layer: + +1. **User Code**: Calls a method on what appears to be the original email client interface (e.g., `client.get_messages(max_results=5)`) +2. **Service Adapter**: Receives the call, validates parameters, and translates it into an HTTP request using the auto-generated client +3. **Auto-Generated HTTP Client**: Serializes the request, handles HTTP transport (including retries and timeouts), and manages the connection to the FastAPI service +4. **FastAPI Service**: Receives and validates the HTTP request, applies rate limiting and authentication if configured, then delegates to the original email implementation +5. **Original Email Library**: Performs the actual email operation (Gmail API calls, OAuth token management, etc.) +6. **Response Path**: Results flow back through the same components with proper error mapping, response transformation, and type safety at each layer + +**Service Boundaries**: Each component can be deployed independently, scaled horizontally, and monitored separately for production environments. + +### Sample API Responses + +**List Messages Response** (`GET /messages?max_results=2`): +```json +[ + { + "id": "msg_123", + "from": "sender@example.com", + "to": "recipient@example.com", + "date": "2025-10-03", + "subject": "Test Message", + "body": "This is a test message body" + }, + { + "id": "msg_456", + "from": "another@example.com", + "to": "recipient@example.com", + "date": "2025-10-02", + "subject": "Another Message", + "body": "Another message body" + } +] +``` + +**Success Response** (`POST /messages/{message_id}/read`, `DELETE /messages/{message_id}`): +```json +{ + "success": true +} +``` + +**Error Response** (404 Not Found): +```json +{ + "detail": "Message not found" +} +``` + +## API Design + +### Endpoints + +The FastAPI service exposes the following REST endpoints with full OpenAPI documentation available at `/docs`: + +* **GET /messages**: Lists messages from the inbox + - Query parameters: + - `max_results` (integer, default: 10, minimum: 1): Maximum number of messages to return + - Returns: `200 OK` with Array of `MessageResponse` objects + - Error responses: `500 Internal Server Error` for email service failures + +* **GET /messages/{message_id}**: Retrieves a specific message by ID + - Path parameters: + - `message_id` (string, required): Unique identifier of the message + - Returns: `200 OK` with Single `MessageResponse` object + - Error responses: `404 Not Found` if message doesn't exist, `500 Internal Server Error` for service failures + +* **POST /messages/{message_id}/read**: Marks a message as read + - Path parameters: + - `message_id` (string, required): Unique identifier of the message + - Returns: `200 OK` with `SuccessResponse` containing boolean success field + - Error responses: `404 Not Found` if message doesn't exist + +* **DELETE /messages/{message_id}**: Deletes a specific message + - Path parameters: + - `message_id` (string, required): Unique identifier of the message + - Returns: `200 OK` with `SuccessResponse` containing boolean success field + - Error responses: `404 Not Found` if message doesn't exist + +**Base URL**: Service runs on `http://127.0.0.1:8000` by default (configurable via environment variables) + +### Error Handling + +The service implements comprehensive error handling with proper HTTP status code mapping: + +**HTTP Status Codes**: +* **200 OK**: Successful operations with valid responses +* **404 Not Found**: When a requested message ID doesn't exist or operations fail due to invalid message references +* **422 Unprocessable Entity**: When request parameters are invalid (automatically handled by FastAPI/Pydantic validation) +* **500 Internal Server Error**: When underlying email operations fail (connection issues, authentication failures, Gmail API errors) + +**Error Response Format**: +All errors return a consistent JSON structure with a `detail` field containing the error message: +```json +{ + "detail": "Descriptive error message" +} +``` + +**Error Propagation**: The service gracefully catches exceptions from the underlying email library and maps them to appropriate HTTP responses, ensuring that sensitive internal details are not exposed to clients while providing meaningful error information. + +## The Adapter Pattern + +### Why It's Needed + +The auto-generated HTTP client provides excellent type safety and HTTP handling but operates at a different abstraction level than the original domain-specific `Client` interface. Key compatibility gaps include: + +* **Return Types**: The HTTP client returns `MessageResponse` DTOs while the original interface expects `Message` domain objects +* **Error Handling**: HTTP exceptions need to be translated to domain-appropriate error handling +* **Method Signatures**: HTTP client methods include additional parameters (headers, timeouts) not present in the original interface +* **Async vs Sync**: The generated client supports both sync and async operations, while the original interface is purely synchronous + +The adapter pattern solves these incompatibilities by implementing the exact same `Client` abstract base class that users expect, ensuring zero code changes are required when switching between implementations. + +### How It Works + +The adapter (`MailClientAdapter`) provides seamless integration through several key mechanisms: + +**Interface Implementation**: +```python +class MailClientAdapter(Client): + + def __init__(self, base_url: str = "http://localhost:8000") -> None: + self.client = ServiceClient(base_url=base_url) + + def get_message(self, message_id: str) -> Message: + result = get_message_sync(message_id=message_id, client=self.client) + if hasattr(result, "additional_properties"): + return ServiceMessage(result.additional_properties) + if isinstance(result, dict): + return ServiceMessage(result) + msg = "Failed to fetch message" + raise ValueError(msg) +``` + +**User Code Compatibility**: +```python +# This code works identically with both implementations: +client = mail_client_api.get_client(interactive=False) # Returns adapter when service impl is active +messages = list(client.get_messages(max_results=3)) # Same interface, different backend +message = client.get_message(message_id) # Same return types +success = client.mark_as_read(message_id) # Same error handling +success = client.delete_message(message_id) # Same behavior +``` + +**Message Wrapping**: The `ServiceMessage` class implements all `Message` interface properties (`id`, `from_`, `to`, `date`, `subject`, `body`), ensuring complete behavioral compatibility. + +**Dependency Injection Integration**: The adapter integrates with the existing dependency injection system, allowing transparent switching between local and service-based implementations through configuration. + +## Deployment & Operations + +### Service Deployment + +**Development Mode**: +```bash +# Start the FastAPI service +cd src/mail_client_service +python -m mail_client_service.main +# Service runs on http://127.0.0.1:8000 with auto-reload +``` + +**Production Considerations**: +* **Process Management**: Use a production ASGI server like Gunicorn with Uvicorn workers +* **Reverse Proxy**: Deploy behind nginx or similar for SSL termination and load balancing +* **Environment Variables**: Configure base URLs, authentication, and service discovery through environment variables +* **Health Checks**: The service exposes standard FastAPI health endpoints for container orchestration +* **Monitoring**: Built-in OpenAPI documentation at `/docs` and `/redoc` for service introspection + +### Configuration Management + +**Service Discovery**: The adapter accepts a configurable `base_url` parameter, enabling dynamic service discovery in containerized environments. + +**Workspace Integration**: The service is integrated into the uv workspace (`pyproject.toml`) as a separate package, enabling independent versioning and deployment. + +### Scalability + +* **Horizontal Scaling**: Multiple service instances can be deployed behind a load balancer +* **Stateless Design**: The service maintains no internal state, making it suitable for container orchestration +* **Resource Isolation**: Email processing can be scaled independently from client applications + +## Testing Strategy + +### What You Tested + +The comprehensive testing strategy covers all architectural layers and integration points: + +* **FastAPI Service Layer**: Direct testing of REST endpoints, request/response serialization, error handling, and dependency injection +* **Auto-Generated Client**: Validation of HTTP client functionality, error propagation, and type safety +* **Service Adapter**: Verification that `MailClientAdapter` correctly implements the `Client` interface with proper domain object wrapping +* **End-to-End Integration**: Complete request flow testing from user code through all service layers to email operations +* **Interface Compliance**: Behavioral verification that the service-based implementation produces identical results to the original direct implementation + +### Test Types + +The test suite follows a pyramid structure with comprehensive coverage at each level: + +* **Unit Tests**: Individual component testing with mocked dependencies, focusing on business logic and error handling +* **Integration Tests** (`tests/integration/test_mail_client_service.py`): Service-to-service testing with a running FastAPI service process, using mocked Gmail operations to ensure HTTP layer functionality +* **End-to-End Tests** (`tests/e2e/`): Full system testing that verifies the complete flow from user code through all components, including actual service startup and teardown +* **Contract Tests**: Verification that the adapter maintains strict behavioral compatibility with the original interface + +**Test Isolation**: Each test type uses appropriate isolation techniques to ensure fast, reliable, and independent test execution. + +### Mocking Strategy + +The testing approach uses layered mocking to provide comprehensive coverage while maintaining test performance: + +**Layer-Specific Mocking**: +* **Integration Tests**: Mock the underlying Gmail client (`mail_client_api.get_client`) while preserving real HTTP communication, ensuring network serialization and transport layer functionality +* **Service Process Isolation**: Run the FastAPI service in a separate multiprocessing.Process during integration tests (port 8001) to ensure realistic HTTP client-server interaction +* **Gmail API Mocking**: Use `unittest.mock.patch` to provide controlled, predictable responses from the Gmail service layer +* **Fixture Management**: Pytest fixtures provide consistent mock configurations across test suites with proper setup and teardown + +**Mock Data Management**: Standardized mock message creation (`create_mock_message()`) ensures consistent test data across all test types. + +**Real vs Mock Boundaries**: Clear separation between what's mocked (external APIs) and what's real (internal HTTP communication, serialization, type conversion) ensures confidence in the service layer implementation. + +### Interface Compliance + +Interface compliance is rigorously verified through multiple validation approaches: + +**Static Analysis**: +* **Type Checking**: The adapter implements the abstract `Client` protocol with full mypy strict mode compliance, ensuring compile-time interface correctness +* **Protocol Validation**: All method signatures, return types, and exception behaviors match the original interface specification + +**Dynamic Verification**: +* **Behavioral Testing**: Integration tests verify that all methods return the expected types (`Message` objects, boolean results) and handle errors appropriately +* **Response Transformation**: Tests validate that `ServiceMessage` wrapper objects properly implement all `Message` interface properties +* **Error Handling Compliance**: Verification that adapter error handling matches original implementation behavior + +**Compatibility Testing**: +* **Drop-in Replacement**: End-to-end tests demonstrate that existing user code works identically with both implementations +* **Dependency Injection Integration**: Tests verify that the adapter correctly integrates with the existing factory function mechanism +* **Mock Verification**: Detailed verification that the adapter makes correct HTTP client calls with proper parameter mapping and response handling + +**Continuous Validation**: The test suite runs in CI/CD to ensure interface compliance is maintained across code changes and refactoring. \ No newline at end of file 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/pyproject.toml b/pyproject.toml index 521078ec..4e341fea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,12 +18,15 @@ dev = [ "pytest-cov>=6.2.1", "ruff>=0.12.7", "types-requests>=2.32.4.20250611", + "openapi-python-client>=0.26.2", ] [tool.uv.workspace] members = [ "src/mail_client_api", "src/gmail_client_impl", + "src/mail_client_adapter", + "src/mail_client_service", ] [tool.ruff] @@ -51,6 +54,8 @@ warn_unused_ignores = false module = [ "google.*", "googleapiclient.*", + "mail_client_adapter.*", + "mail_client_service.*", ] ignore_missing_imports = true diff --git a/src/gmail_client_impl/src/gmail_client_impl/gmail_impl.py b/src/gmail_client_impl/src/gmail_client_impl/gmail_impl.py index ca3c5821..9a525b13 100644 --- a/src/gmail_client_impl/src/gmail_client_impl/gmail_impl.py +++ b/src/gmail_client_impl/src/gmail_client_impl/gmail_impl.py @@ -336,7 +336,7 @@ def get_messages(self, max_results: int = 10) -> Iterator[message.Message]: ) -def get_client_impl(*, interactive: bool = True) -> mail_client_api.Client: +def get_client_impl(*, interactive: bool = False) -> mail_client_api.Client: """Return a configured :class:`GmailClient` instance.""" return GmailClient(interactive=interactive) diff --git a/src/mail_client_adapter/README.md b/src/mail_client_adapter/README.md new file mode 100644 index 00000000..e1948567 --- /dev/null +++ b/src/mail_client_adapter/README.md @@ -0,0 +1,11 @@ +# mail-client-adapter + +Adapter for `mail_client_api.Client` that proxies calls to a running FastAPI mail_client_service. Consumers use this as a drop-in replacement for the local client, with all network complexity hidden. + +## Usage + +```python +from mail_client_adapter import RemoteMailClient +client = RemoteMailClient(base_url="http://localhost:8000") +messages = list(client.get_messages()) +``` diff --git a/src/mail_client_adapter/pyproject.toml b/src/mail_client_adapter/pyproject.toml new file mode 100644 index 00000000..98b1b3a9 --- /dev/null +++ b/src/mail_client_adapter/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "mail-client-adapter" +version = "0.1.0" +description = "Adapter for mail_client_api.Client to proxy calls to mail_client_service." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "requests>=2.31.0", + "mail-client-api", + "mail-client-service-client", +] + +[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 +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[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] +mail-client-api = { workspace = true } +mail-client-service-client = { path = "../mail_client_service/mail_client_service_client" } \ No newline at end of file diff --git a/src/mail_client_adapter/src/mail_client_adapter/__init__.py b/src/mail_client_adapter/src/mail_client_adapter/__init__.py new file mode 100644 index 00000000..a3c100b0 --- /dev/null +++ b/src/mail_client_adapter/src/mail_client_adapter/__init__.py @@ -0,0 +1,5 @@ +"""Public export surface for ``mail_client_adapter``.""" + +from mail_client_adapter.mail_client_adapter import MailClientAdapter, ServiceMessage + +__all__ = ["MailClientAdapter", "ServiceMessage"] diff --git a/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py b/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py new file mode 100644 index 00000000..b8a6b10a --- /dev/null +++ b/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py @@ -0,0 +1,105 @@ +"""Adapter for mail_client_api.Client to proxy calls to a running mail_client_service server.""" + +from collections.abc import Iterator + +from mail_client_api.client import Client +from mail_client_api.message import Message +from mail_client_service_client.api.default.delete_message_messages_message_id_delete import ( + sync as delete_message_sync, +) +from mail_client_service_client.api.default.get_message_messages_message_id_get import ( + sync as get_message_sync, +) +from mail_client_service_client.api.default.get_messages_messages_get import ( + sync as get_messages_sync, +) +from mail_client_service_client.api.default.mark_as_read_messages_message_id_mark_as_read_post import ( # noqa: E501 + sync as mark_as_read_sync, +) +from mail_client_service_client.client import Client as ServiceClient + + +class MailClientAdapter(Client): + """Adapter that proxies mail_client_api.Client calls to a FastAPI service.""" + + def __init__(self, base_url: str = "http://localhost:8000") -> None: + """Initialize RemoteMailClient with the FastAPI service base URL. + + Args: + base_url: The base URL of the FastAPI service. + + """ + self.client = ServiceClient(base_url=base_url) + + def get_message(self, message_id: str) -> Message: + """Fetch a message by ID from the remote service using OpenAPI client.""" + result = get_message_sync(message_id=message_id, client=self.client) + if hasattr(result, "additional_properties"): + return ServiceMessage(result.additional_properties) + if isinstance(result, dict): + return ServiceMessage(result) + msg = "Failed to fetch message" + raise ValueError(msg) + + def delete_message(self, message_id: str) -> bool: + """Delete a message by ID via the remote service using OpenAPI client.""" + result = delete_message_sync(message_id=message_id, client=self.client) + return result is not None + + def mark_as_read(self, message_id: str) -> bool: + """Mark a message as read by ID via the remote service using OpenAPI client.""" + result = mark_as_read_sync(message_id=message_id, client=self.client) + return result is not None + + def get_messages(self, max_results: int = 10) -> Iterator[Message]: + """Return an iterator of message summaries from the remote service using OpenAPI client.""" + results = get_messages_sync(client=self.client, max_results=max_results) + if isinstance(results, list): + for item in results: + if hasattr(item, "additional_properties"): + yield ServiceMessage(item.additional_properties) + elif isinstance(item, dict): + yield ServiceMessage(item) + + +class ServiceMessage(Message): + """Proxy for Message objects returned by the remote service.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize ServiceMessage with message data. + + Args: + data: Dictionary containing message fields. + + """ + self._data: dict[str, str] = data + + @property + def id(self) -> str: + """Return the message ID.""" + return self._data["id"] + + @property + def from_(self) -> str: + """Return the sender's email address.""" + return self._data["from"] + + @property + def to(self) -> str: + """Return the recipient's email address.""" + return self._data["to"] + + @property + def date(self) -> str: + """Return the date the message was sent.""" + return self._data["date"] + + @property + def subject(self) -> str: + """Return the subject line of the message.""" + return self._data["subject"] + + @property + def body(self) -> str: + """Return the body of the message.""" + return self._data.get("body", "") diff --git a/src/mail_client_adapter/tests/test_adapter.py b/src/mail_client_adapter/tests/test_adapter.py new file mode 100644 index 00000000..37d001a4 --- /dev/null +++ b/src/mail_client_adapter/tests/test_adapter.py @@ -0,0 +1,48 @@ +"""Tests for the MailClientAdapter.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from mail_client_adapter.mail_client_adapter import MailClientAdapter + + +@patch("requests.get") +def test_get_messages_remote(mock_get: MagicMock | AsyncMock) -> None: + """Test fetching a message via the remote service.""" + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = [ + {"id": "1", "from": "a", "to": "b", "date": "2023", "subject": "Test"}, + ] + client = MailClientAdapter(base_url="http://localhost:8000") + messages = list(client.get_messages(max_results=1)) + assert len(messages) == 1 + assert messages[0].id == "1" + assert messages[0].from_ == "a" + assert messages[0].to == "b" + assert messages[0].date == "2023" + assert messages[0].subject == "Test" + +@patch("requests.get") +def test_get_message_remote(mock_get: MagicMock | AsyncMock) -> None: + """Test fetching a single message via the remote service.""" + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "id": "1", "from": "a", "to": "b", "date": "2023", "subject": "Test", "body": "Body", + } + client = MailClientAdapter(base_url="http://localhost:8000") + msg = client.get_message("1") + assert msg.id == "1" + assert msg.body == "Body" + +@patch("requests.delete") +def test_delete_message_remote(mock_delete: MagicMock | AsyncMock) -> None: + """Test deleting a message via the remote service.""" + mock_delete.return_value.status_code = 200 + client = MailClientAdapter(base_url="http://localhost:8000") + assert client.delete_message("1") is True + +@patch("requests.post") +def test_mark_as_read_remote(mock_post: MagicMock | AsyncMock) -> None: + """Test marking a message as read via the remote service.""" + mock_post.return_value.status_code = 200 + client = MailClientAdapter(base_url="http://localhost:8000") + assert client.mark_as_read("1") is True diff --git a/src/mail_client_service/README.md b/src/mail_client_service/README.md new file mode 100644 index 00000000..6e969062 --- /dev/null +++ b/src/mail_client_service/README.md @@ -0,0 +1,9 @@ +# mail-client-service + +FastAPI service for mail client abstraction. Wraps `mail_client_api.Client` and exposes RESTful endpoints for message operations. + +## Endpoints +- `GET /messages`: List message summaries +- `GET /messages/{message_id}`: Get message details +- `POST /messages/{message_id}/mark-as-read`: Mark message as read +- `DELETE /messages/{message_id}`: Delete message diff --git a/src/mail_client_service/mail_client_service_client/.gitignore b/src/mail_client_service/mail_client_service_client/.gitignore new file mode 100644 index 00000000..79a2c3d7 --- /dev/null +++ b/src/mail_client_service/mail_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/mail_client_service/mail_client_service_client/README.md b/src/mail_client_service/mail_client_service_client/README.md new file mode 100644 index 00000000..840fd676 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/README.md @@ -0,0 +1,123 @@ +# fast-api-client +A client library for accessing FastAPI + +## Usage +First, create a client: + +```python +from fast_api_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 fast_api_client import AuthenticatedClient + +client = AuthenticatedClient(base_url="https://api.example.com", token="SuperSecretToken") +``` + +Now call your endpoint and use your models: + +```python +from fast_api_client.models import MyDataModel +from fast_api_client.api.my_tag import get_my_data_model +from fast_api_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 fast_api_client.models import MyDataModel +from fast_api_client.api.my_tag import get_my_data_model +from fast_api_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 `fast_api_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 fast_api_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 fast_api_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 [uv](https://github.com/astral-sh/uv) to manage dependencies and packaging. Here are the basics: +1. Update the metadata in `pyproject.toml` (e.g. authors, version). +2. If you're using a private repository: https://docs.astral.sh/uv/guides/integration/alternative-indexes/ +3. Build a distribution with `uv build`, builds `sdist` and `wheel` by default. +1. Publish the client with `uv publish`, see documentation for publishing to private indexes. + +If you want to install this client into another project without publishing it (e.g. for development) then: +1. If that project **is using uv**, you can simply do `uv add ` from that project +1. If that project is not using uv: + 1. Build a wheel with `uv build --wheel`. + 1. Install that wheel from the other project `pip install `. diff --git a/src/mail_client_service/mail_client_service_client/pyproject.toml b/src/mail_client_service/mail_client_service_client/pyproject.toml new file mode 100644 index 00000000..666b5c96 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "mail-client-service-client" +version = "0.1.0" +description = "A client library for accessing FastAPI" +authors = [] +requires-python = ">=3.9,<4.0" +readme = "README.md" +dependencies = [ + "httpx>=0.23.0,<0.29.0", + "attrs>=22.2.0", + "python-dateutil>=2.8.0,<3", +] + +[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 +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["F", "I", "UP"] diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/__init__.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/__init__.py new file mode 100644 index 00000000..a7e88ff8 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing FastAPI""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/__init__.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/__init__.py new file mode 100644 index 00000000..81f9fa24 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/__init__.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/__init__.py new file mode 100644 index 00000000..2d7c0b23 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/delete_message_messages_message_id_delete.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/delete_message_messages_message_id_delete.py new file mode 100644 index 00000000..46ba4cc6 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/delete_message_messages_message_id_delete.py @@ -0,0 +1,174 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.delete_message_messages_message_id_delete_response_delete_message_messages_message_id_delete import ( + DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete, +) +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + message_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/messages/{message_id}", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response, +) -> DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete | HTTPValidationError | None: + if response.status_code == 200: + response_200 = DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete.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) + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response, +) -> Response[ + DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete | 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( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ + DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete | HTTPValidationError +]: + """Delete Message + + Delete a message. + + Args: + message_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[Union[DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete, HTTPValidationError]] + + """ + kwargs = _get_kwargs( + message_id=message_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete | HTTPValidationError | None: + """Delete Message + + Delete a message. + + Args: + message_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: + Union[DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete, HTTPValidationError] + + """ + return sync_detailed( + message_id=message_id, + client=client, + ).parsed + + +async def asyncio_detailed( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ + DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete | HTTPValidationError +]: + """Delete Message + + Delete a message. + + Args: + message_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[Union[DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete, HTTPValidationError]] + + """ + kwargs = _get_kwargs( + message_id=message_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete | HTTPValidationError | None: + """Delete Message + + Delete a message. + + Args: + message_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: + Union[DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete, HTTPValidationError] + + """ + return ( + await asyncio_detailed( + message_id=message_id, + client=client, + ) + ).parsed diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/get_message_messages_message_id_get.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/get_message_messages_message_id_get.py new file mode 100644 index 00000000..6d1d92a4 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/get_message_messages_message_id_get.py @@ -0,0 +1,166 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.get_message_messages_message_id_get_response_get_message_messages_message_id_get import ( + GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet, +) +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + message_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/messages/{message_id}", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response, +) -> GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet | HTTPValidationError | None: + if response.status_code == 200: + response_200 = GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet.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) + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response, +) -> Response[GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet | 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( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet | HTTPValidationError]: + """Get Message + + Return the full detail of a single message. + + Args: + message_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[Union[GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet, HTTPValidationError]] + + """ + kwargs = _get_kwargs( + message_id=message_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet | HTTPValidationError | None: + """Get Message + + Return the full detail of a single message. + + Args: + message_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: + Union[GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet, HTTPValidationError] + + """ + return sync_detailed( + message_id=message_id, + client=client, + ).parsed + + +async def asyncio_detailed( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet | HTTPValidationError]: + """Get Message + + Return the full detail of a single message. + + Args: + message_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[Union[GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet, HTTPValidationError]] + + """ + kwargs = _get_kwargs( + message_id=message_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet | HTTPValidationError | None: + """Get Message + + Return the full detail of a single message. + + Args: + message_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: + Union[GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet, HTTPValidationError] + + """ + return ( + await asyncio_detailed( + message_id=message_id, + client=client, + ) + ).parsed diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/get_messages_messages_get.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/get_messages_messages_get.py new file mode 100644 index 00000000..60035406 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/get_messages_messages_get.py @@ -0,0 +1,179 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.get_messages_messages_get_response_200_item import ( + GetMessagesMessagesGetResponse200Item, +) +from ...models.http_validation_error import HTTPValidationError +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + max_results: Unset | int = 10, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["max_results"] = max_results + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/messages", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response, +) -> HTTPValidationError | list["GetMessagesMessagesGetResponse200Item"] | None: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = GetMessagesMessagesGetResponse200Item.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) + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response, +) -> Response[HTTPValidationError | list["GetMessagesMessagesGetResponse200Item"]]: + 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, + max_results: Unset | int = 10, +) -> Response[HTTPValidationError | list["GetMessagesMessagesGetResponse200Item"]]: + """Get Messages + + Return a list of message summaries. + + Args: + max_results (Union[Unset, int]): Default: 10. + + 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[Union[HTTPValidationError, list['GetMessagesMessagesGetResponse200Item']]] + + """ + kwargs = _get_kwargs( + max_results=max_results, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + max_results: Unset | int = 10, +) -> HTTPValidationError | list["GetMessagesMessagesGetResponse200Item"] | None: + """Get Messages + + Return a list of message summaries. + + Args: + max_results (Union[Unset, int]): Default: 10. + + 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: + Union[HTTPValidationError, list['GetMessagesMessagesGetResponse200Item']] + + """ + return sync_detailed( + client=client, + max_results=max_results, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + max_results: Unset | int = 10, +) -> Response[HTTPValidationError | list["GetMessagesMessagesGetResponse200Item"]]: + """Get Messages + + Return a list of message summaries. + + Args: + max_results (Union[Unset, int]): Default: 10. + + 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[Union[HTTPValidationError, list['GetMessagesMessagesGetResponse200Item']]] + + """ + kwargs = _get_kwargs( + max_results=max_results, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + max_results: Unset | int = 10, +) -> HTTPValidationError | list["GetMessagesMessagesGetResponse200Item"] | None: + """Get Messages + + Return a list of message summaries. + + Args: + max_results (Union[Unset, int]): Default: 10. + + 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: + Union[HTTPValidationError, list['GetMessagesMessagesGetResponse200Item']] + + """ + return ( + await asyncio_detailed( + client=client, + max_results=max_results, + ) + ).parsed diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/mark_as_read_messages_message_id_mark_as_read_post.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/mark_as_read_messages_message_id_mark_as_read_post.py new file mode 100644 index 00000000..1761b857 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/api/default/mark_as_read_messages_message_id_mark_as_read_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.mark_as_read_messages_message_id_mark_as_read_post_response_mark_as_read_messages_message_id_mark_as_read_post import ( + MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost, +) +from ...types import Response + + +def _get_kwargs( + message_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/messages/{message_id}/mark-as-read", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response, +) -> HTTPValidationError | MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost | None: + if response.status_code == 200: + response_200 = ( + MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost.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) + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response, +) -> Response[ + HTTPValidationError | MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost +]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ + HTTPValidationError | MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost +]: + """Mark As Read + + Mark a message as read. + + Args: + message_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[Union[HTTPValidationError, MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost]] + + """ + kwargs = _get_kwargs( + message_id=message_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> HTTPValidationError | MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost | None: + """Mark As Read + + Mark a message as read. + + Args: + message_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: + Union[HTTPValidationError, MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost] + + """ + return sync_detailed( + message_id=message_id, + client=client, + ).parsed + + +async def asyncio_detailed( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> Response[ + HTTPValidationError | MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost +]: + """Mark As Read + + Mark a message as read. + + Args: + message_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[Union[HTTPValidationError, MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost]] + + """ + kwargs = _get_kwargs( + message_id=message_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + message_id: str, + *, + client: AuthenticatedClient | Client, +) -> HTTPValidationError | MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost | None: + """Mark As Read + + Mark a message as read. + + Args: + message_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: + Union[HTTPValidationError, MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost] + + """ + return ( + await asyncio_detailed( + message_id=message_id, + client=client, + ) + ).parsed diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/client.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/client.py new file mode 100644 index 00000000..a1147c06 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/client.py @@ -0,0 +1,270 @@ +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 (in seconds)""" + 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: object, **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 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: object, **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 (in seconds)""" + 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: object, **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 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: object, **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/mail_client_service/mail_client_service_client/src/mail_client_service_client/errors.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/errors.py new file mode 100644 index 00000000..b51b9a26 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_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/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/__init__.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/__init__.py new file mode 100644 index 00000000..8126874c --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/__init__.py @@ -0,0 +1,23 @@ +"""Contains all the data models used in inputs/outputs""" + +from .delete_message_messages_message_id_delete_response_delete_message_messages_message_id_delete import ( + DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete, +) +from .get_message_messages_message_id_get_response_get_message_messages_message_id_get import ( + GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet, +) +from .get_messages_messages_get_response_200_item import GetMessagesMessagesGetResponse200Item +from .http_validation_error import HTTPValidationError +from .mark_as_read_messages_message_id_mark_as_read_post_response_mark_as_read_messages_message_id_mark_as_read_post import ( + MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost, +) +from .validation_error import ValidationError + +__all__ = ( + "DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete", + "GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet", + "GetMessagesMessagesGetResponse200Item", + "HTTPValidationError", + "MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost", + "ValidationError", +) diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/delete_message_messages_message_id_delete_response_delete_message_messages_message_id_delete.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/delete_message_messages_message_id_delete_response_delete_message_messages_message_id_delete.py new file mode 100644 index 00000000..9c9fc0e6 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/delete_message_messages_message_id_delete_response_delete_message_messages_message_id_delete.py @@ -0,0 +1,44 @@ +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete") + + +@_attrs_define +class DeleteMessageMessagesMessageIdDeleteResponseDeleteMessageMessagesMessageIdDelete: + """ """ + + additional_properties: dict[str, bool] = _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, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + delete_message_messages_message_id_delete_response_delete_message_messages_message_id_delete = cls() + + delete_message_messages_message_id_delete_response_delete_message_messages_message_id_delete.additional_properties = d + return delete_message_messages_message_id_delete_response_delete_message_messages_message_id_delete + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> bool: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: bool) -> 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/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/get_message_messages_message_id_get_response_get_message_messages_message_id_get.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/get_message_messages_message_id_get_response_get_message_messages_message_id_get.py new file mode 100644 index 00000000..1d9f54d6 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/get_message_messages_message_id_get_response_get_message_messages_message_id_get.py @@ -0,0 +1,44 @@ +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet") + + +@_attrs_define +class GetMessageMessagesMessageIdGetResponseGetMessageMessagesMessageIdGet: + """ """ + + 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, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + get_message_messages_message_id_get_response_get_message_messages_message_id_get = cls() + + get_message_messages_message_id_get_response_get_message_messages_message_id_get.additional_properties = d + return get_message_messages_message_id_get_response_get_message_messages_message_id_get + + @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/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/get_messages_messages_get_response_200_item.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/get_messages_messages_get_response_200_item.py new file mode 100644 index 00000000..f3815379 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/get_messages_messages_get_response_200_item.py @@ -0,0 +1,44 @@ +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="GetMessagesMessagesGetResponse200Item") + + +@_attrs_define +class GetMessagesMessagesGetResponse200Item: + """ """ + + 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, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + get_messages_messages_get_response_200_item = cls() + + get_messages_messages_get_response_200_item.additional_properties = d + return get_messages_messages_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/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/http_validation_error.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/http_validation_error.py new file mode 100644 index 00000000..94aef9ac --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/http_validation_error.py @@ -0,0 +1,75 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, 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 (Union[Unset, list['ValidationError']]): + + """ + + detail: Unset | list["ValidationError"] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + detail: Unset | list[dict[str, Any]] = 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, src_dict: Mapping[str, Any]) -> Self: + from ..models.validation_error import ValidationError + + d = dict(src_dict) + detail = [] + _detail = d.pop("detail", UNSET) + for detail_item_data in _detail or []: + 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/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/mark_as_read_messages_message_id_mark_as_read_post_response_mark_as_read_messages_message_id_mark_as_read_post.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/mark_as_read_messages_message_id_mark_as_read_post_response_mark_as_read_messages_message_id_mark_as_read_post.py new file mode 100644 index 00000000..81d18d22 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/mark_as_read_messages_message_id_mark_as_read_post_response_mark_as_read_messages_message_id_mark_as_read_post.py @@ -0,0 +1,44 @@ +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost") + + +@_attrs_define +class MarkAsReadMessagesMessageIdMarkAsReadPostResponseMarkAsReadMessagesMessageIdMarkAsReadPost: + """ """ + + additional_properties: dict[str, bool] = _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, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + mark_as_read_messages_message_id_mark_as_read_post_response_mark_as_read_messages_message_id_mark_as_read_post = cls() + + mark_as_read_messages_message_id_mark_as_read_post_response_mark_as_read_messages_message_id_mark_as_read_post.additional_properties = d + return mark_as_read_messages_message_id_mark_as_read_post_response_mark_as_read_messages_message_id_mark_as_read_post + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> bool: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: bool) -> 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/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/validation_error.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/validation_error.py new file mode 100644 index 00000000..79c06516 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/models/validation_error.py @@ -0,0 +1,88 @@ +from collections.abc import Mapping +from typing import Any, Self, 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[Union[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, src_dict: Mapping[str, Any]) -> Self: + 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/mail_client_service/mail_client_service_client/src/mail_client_service_client/py.typed b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/py.typed new file mode 100644 index 00000000..1aad3271 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 \ No newline at end of file diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/types.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/types.py new file mode 100644 index 00000000..67a552b5 --- /dev/null +++ b/src/mail_client_service/mail_client_service_client/src/mail_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, Union + +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 = Union[IO[bytes], bytes, str] +FileTypes = Union[ + # (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"] diff --git a/src/mail_client_service/pyproject.toml b/src/mail_client_service/pyproject.toml new file mode 100644 index 00000000..12fab91c --- /dev/null +++ b/src/mail_client_service/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "mail-client-service" +version = "0.1.0" +description = "FastAPI service for mail client abstraction." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.100.0", + "uvicorn>=0.23.0", + "mail-client-api", + "gmail-client-impl", +] + +[project.optional-dependencies] +test = [ + "pytest>=7.0.0", + "pytest-mock>=3.10.0", + "httpx>=0.24.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 +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 100 # Default formatting width +target-version = "py311" # Adjust based on actual Python version +extend = "../../pyproject.toml" + +[tool.ruff.lint] +ignore = [] +typing-extensions = false + +[tool.uv.sources] +mail-client-api = { workspace = true } +gmail-client-impl = { workspace = true } + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["fastapi.Depends", "fastapi.params.Depends", "fastapi.Query", "fastapi.params.Query"] diff --git a/src/mail_client_service/src/mail_client_service/__init__.py b/src/mail_client_service/src/mail_client_service/__init__.py new file mode 100644 index 00000000..5a15f449 --- /dev/null +++ b/src/mail_client_service/src/mail_client_service/__init__.py @@ -0,0 +1,5 @@ +"""Public export surface for ``mail_client_service``.""" + +from .main import app + +__all__ = ["app"] diff --git a/src/mail_client_service/src/mail_client_service/main.py b/src/mail_client_service/src/mail_client_service/main.py new file mode 100644 index 00000000..dc35f6d6 --- /dev/null +++ b/src/mail_client_service/src/mail_client_service/main.py @@ -0,0 +1,93 @@ +"""FastAPI service exposing mail_client_api.Client endpoints.""" + +import os +from typing import Annotated + +from mail_client_service import test_client + +if os.environ.get("MOCK_CLIENT") == "1": + from . import test_client as gmail_client_impl + test_client.register() +else: + import gmail_client_impl # noqa: F401 +from fastapi import Depends, FastAPI, HTTPException +from mail_client_api import get_client +from mail_client_api.client import Client + +app = FastAPI() + +def get_client_dep() -> Client: + """Dependency to get the mail client instance.""" + client = get_client() + if client is None: + raise HTTPException(status_code=500, detail="Mail client initialization failed") + return client + + +@app.get("/messages") +def get_messages( + client: Annotated[Client, Depends(get_client_dep)], + max_results: int = 10, +) -> list[dict[str, str]]: + """Return a list of message summaries.""" + if max_results < 1: + raise HTTPException(status_code=400, detail="max_results must be at least 1") + try: + return [ + { + "id": msg.id, + "from": msg.from_, + "to": msg.to, + "date": msg.date, + "subject": msg.subject, + "body": msg.body, + } + for msg in client.get_messages(max_results=max_results) + ] + except Exception as e: + raise HTTPException(status_code=500, detail="Internal server error") from e + + +@app.get("/messages/{message_id}") +def get_message( + message_id: str, + client: Annotated[Client, Depends(get_client_dep)], +) -> dict[str, str]: + """Return the full detail of a single message.""" + try: + message = client.get_message(message_id) + except Exception as e: + raise HTTPException(status_code=404, detail="Message not found") from e + else: + return { + "id": message.id, + "from": message.from_, + "to": message.to, + "date": message.date, + "subject": message.subject, + "body": message.body, + } + + +@app.post("/messages/{message_id}/mark-as-read") +def mark_as_read( + message_id: str, + client: Annotated[Client, Depends(get_client_dep)], +) -> dict[str, bool]: + """Mark a message as read.""" + result = client.mark_as_read(message_id) + if not result: + raise HTTPException(status_code=404, detail="Message not found") + return {"success": True} + + +@app.delete("/messages/{message_id}") +def delete_message( + message_id: str, + client: Annotated[Client, Depends(get_client_dep)], +) -> dict[str, bool]: + """Delete a message.""" + result = client.delete_message(message_id) + if not result: + raise HTTPException(status_code=404, detail="Message not found") + return {"success": True} diff --git a/src/mail_client_service/src/mail_client_service/test_client.py b/src/mail_client_service/src/mail_client_service/test_client.py new file mode 100644 index 00000000..fb725587 --- /dev/null +++ b/src/mail_client_service/src/mail_client_service/test_client.py @@ -0,0 +1,97 @@ +"""Test implementation of the mail client for testing.""" + +from collections.abc import Iterator + +import mail_client_api +from mail_client_api import Client +from mail_client_api.message import Message as AbstractMessage + + +class Message(AbstractMessage): + """A simple implementation of the Message class for testing purposes.""" + + def __init__(self, _id: str, from_: str, to: str, date: str, subject: str, body: str) -> None: + """Initialize a new message.""" + self._id = _id + self._from_ = from_ + self._to = to + self._date = date + self._subject = subject + self._body = body + + @property + def id(self) -> str: + """Return the unique identifier of the message.""" + return self._id + + @property + def from_(self) -> str: + """Return the sender's email address.""" + return self._from_ + + @property + def to(self) -> str: + """Return the recipient's email address.""" + return self._to + + @property + def date(self) -> str: + """Return the date the message was sent.""" + return self._date + + @property + def subject(self) -> str: + """Return the subject line of the message.""" + return self._subject + + @property + def body(self) -> str: + """Return the plain text content of the message.""" + return self._body + +class TestClient(Client): + """A test implementation of the mail client for testing. + + This implementation stores messages in memory and provides basic operations + for testing purposes. + """ + + def __init__(self) -> None: + """Initialize a new test client with some sample messages.""" + self._messages: dict[str, Message] = { + "1": Message("1", "sender1@example.com", "recipient@example.com", "2025-10-03", "Test Message 1", "Body 1"), + "2": Message("2", "sender2@example.com", "recipient@example.com", "2025-10-03", "Test Message 2", "Body 2"), + "3": Message("3", "sender3@example.com", "recipient@example.com", "2025-10-03", "Test Message 3", "Body 3"), + } + + def get_message(self, message_id: str) -> Message: + """Get a message by ID.""" + if message_id not in self._messages: + msg = f"Message {message_id} not found" + raise ValueError(msg) + return self._messages[message_id] + + def delete_message(self, message_id: str) -> bool: + """Delete a message by ID.""" + if message_id not in self._messages: + return False + del self._messages[message_id] + return True + + def mark_as_read(self, message_id: str) -> bool: + """Mark a message as read by ID.""" + return message_id in self._messages + + def get_messages(self, max_results: int = 10) -> Iterator[Message]: + """Get an iterator of messages.""" + messages = sorted(self._messages.values(), key=lambda m: m.id) + return iter(messages[:max_results]) + +_singleton_client = TestClient() +def get_client_impl() -> mail_client_api.Client: + """Return a singleton :class:`TestClient` instance.""" + return _singleton_client + +def register() -> None: + """Register the Gmail client implementation with the mail client API.""" + mail_client_api.get_client = get_client_impl diff --git a/src/mail_client_service/tests/test_app.py b/src/mail_client_service/tests/test_app.py new file mode 100644 index 00000000..561168b9 --- /dev/null +++ b/src/mail_client_service/tests/test_app.py @@ -0,0 +1,136 @@ + +"""Unit tests for the FastAPI mail client service. + +These tests verify that the FastAPI endpoints handle requests and responses correctly, +using a mocked mail client to isolate the tests from the actual implementation. +""" + +from collections.abc import Generator +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from mail_client_service import app + +client = TestClient(app) + +@pytest.fixture +def mock_mail_client() -> Generator[MagicMock, Any, None]: + """Create a mock mail client for testing.""" + with patch("mail_client_service.main.get_client") as mock: + mock_instance = MagicMock() + mock.return_value = mock_instance + yield mock_instance + +def create_mock_message(msg_id: str, subject: str = "Test Subject") -> MagicMock: + """Return a mock message object.""" + mock_msg = MagicMock() + mock_msg.id = msg_id + mock_msg.from_ = "sender@example.com" + mock_msg.to = "recipient@example.com" + mock_msg.date = "2025-10-03" + mock_msg.subject = subject + mock_msg.body = f"Test body for message {msg_id}" + return mock_msg + +def test_list_messages(mock_mail_client: MagicMock) -> None: + """Test listing messages returns correct format and status code.""" + mock_messages = [ + create_mock_message("1", "First Message"), + create_mock_message("2", "Second Message"), + ] + mock_mail_client.get_messages.return_value = iter(mock_messages) + + # Make request + response = client.get("/messages") + + # Verify response + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == len(mock_messages) + assert data[0] == { + "id": "1", + "from": "sender@example.com", + "to": "recipient@example.com", + "date": "2025-10-03", + "subject": "First Message", + "body": "Test body for message 1", + } + +def test_list_messages_with_max_results(mock_mail_client: MagicMock) -> None: + """Test that max_results parameter is respected.""" + mock_messages = [create_mock_message(str(i)) for i in range(5)] + mock_mail_client.get_messages.return_value = iter(mock_messages[:3]) + + response = client.get("/messages?max_results=3") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert len(data) == len(mock_messages[:3]) + +def test_get_message_found(mock_mail_client: MagicMock) -> None: + """Test retrieving a specific message that exists.""" + mock_message = create_mock_message("123", "Test Message") + mock_mail_client.get_message.side_effect = lambda i: mock_message if i == "123" else ValueError("Message not found") + + response = client.get("/messages/123") + + assert response.status_code == HTTPStatus.OK + assert response.json() == { + "id": "123", + "from": "sender@example.com", + "to": "recipient@example.com", + "date": "2025-10-03", + "subject": "Test Message", + "body": "Test body for message 123", + } + +def test_get_message_not_found(mock_mail_client: MagicMock) -> None: + """Test retrieving a non-existent message.""" + mock_mail_client.get_message.side_effect = Exception("Message not found") + response = client.get("/messages/999") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["detail"] == "Message not found" + +def test_mark_as_read_success(mock_mail_client: MagicMock) -> None: + """Test marking a message as read successfully.""" + mock_mail_client.mark_as_read.return_value = True + response = client.post("/messages/123/mark-as-read") + assert response.status_code == HTTPStatus.OK + assert response.json() == {"success": True} + mock_mail_client.mark_as_read.assert_called_once_with("123") + +def test_mark_as_read_not_found(mock_mail_client: MagicMock) -> None: + """Test marking a non-existent message as read.""" + mock_mail_client.mark_as_read.return_value = False + response = client.post("/messages/999/mark-as-read") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["detail"] == "Message not found" + +def test_delete_message_success(mock_mail_client: MagicMock) -> None: + """Test deleting a message successfully.""" + mock_mail_client.delete_message.return_value = True + response = client.delete("/messages/123") + assert response.status_code == HTTPStatus.OK + assert response.json() == {"success": True} + mock_mail_client.delete_message.assert_called_once_with("123") + +def test_delete_message_not_found(mock_mail_client: MagicMock) -> None: + """Test deleting a non-existent message.""" + mock_mail_client.delete_message.return_value = False + response = client.delete("/messages/999") + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json()["detail"] == "Message not found" + +def test_invalid_max_results(mock_mail_client: MagicMock) -> None: + """Test that invalid max_results parameter returns 400.""" + response = client.get("/messages?max_results=0") + assert response.status_code == HTTPStatus.BAD_REQUEST + +def test_messages_error_handling(mock_mail_client: MagicMock) -> None: + """Test error handling when client throws an error.""" + mock_mail_client.get_messages.side_effect = Exception("Internal error") + response = client.get("/messages") + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/tests/e2e/test_mail_client_service_e2e.py b/tests/e2e/test_mail_client_service_e2e.py new file mode 100644 index 00000000..9847698c --- /dev/null +++ b/tests/e2e/test_mail_client_service_e2e.py @@ -0,0 +1,140 @@ +"""End-to-end tests for the mail client service. + +These tests verify that the entire system works with real Gmail API. +They require proper Gmail API credentials to be configured. +""" + +import multiprocessing +import time +from collections.abc import Generator +from multiprocessing.context import Process +from typing import Any + +import pytest +import uvicorn +from mail_client_adapter.mail_client_adapter import MailClientAdapter +from mail_client_api.message import Message + +import gmail_client_impl # Register Gmail implementation # noqa: F401 + +# Constants +SERVICE_HOST = "127.0.0.1" +SERVICE_PORT = 8002 # Use different port than development/integration +SERVICE_URL = f"http://{SERVICE_HOST}:{SERVICE_PORT}" + +def _run_service() -> None: + uvicorn.run( + "mail_client_service:app", + host=SERVICE_HOST, + port=SERVICE_PORT, + log_level="error", + ) + +@pytest.fixture(scope="session") +def service_process() -> Generator[Process, Any, None]: + """Start the FastAPI service in a separate process.""" + # Start service in a separate process + process = multiprocessing.Process(target=_run_service) + process.start() + + # Wait for service to start + time.sleep(2) + + yield process + + # Cleanup + process.terminate() + process.join() + +@pytest.fixture +def service_client(service_process: Process) -> MailClientAdapter: + """Create a service client connected to the test service.""" + return MailClientAdapter(base_url=SERVICE_URL) + +@pytest.mark.e2e +@pytest.mark.gmail +def test_list_messages_e2e(service_client: MailClientAdapter) -> None: + """Test listing messages from real Gmail.""" + # Get messages from Gmail + max_results = 3 + messages = list(service_client.get_messages(max_results=max_results)) + + # Basic validation + assert len(messages) <= max_results + if messages: + assert isinstance(messages[0], Message) + assert messages[0].id + assert messages[0].subject + assert messages[0].from_ + assert messages[0].to + assert messages[0].date + +@pytest.mark.e2e +@pytest.mark.gmail +def test_get_message_e2e(service_client: MailClientAdapter) -> None: + """Test getting a specific message from real Gmail.""" + # First get a list of messages to get a valid ID + messages = list(service_client.get_messages(max_results=1)) + if not messages: + pytest.skip("No messages available in Gmail") + + # Get specific message + message_id = messages[0].id + message = service_client.get_message(message_id) + + # Verify message + assert isinstance(message, Message) + assert message.id == message_id + assert message.subject + assert message.from_ + assert message.to + assert message.date + assert message.body is not None + +@pytest.mark.e2e +@pytest.mark.gmail +def test_mark_as_read_e2e(service_client: MailClientAdapter) -> None: + """Test marking a message as read in real Gmail.""" + # Get a message to mark as read + messages = list(service_client.get_messages(max_results=1)) + if not messages: + pytest.skip("No messages available in Gmail") + + # Mark as read + message_id = messages[0].id + result = service_client.mark_as_read(message_id) + + # Verify result + assert result is True + +@pytest.mark.e2e +@pytest.mark.gmail +def test_full_message_lifecycle_e2e(service_client: MailClientAdapter) -> None: + """Test a complete message lifecycle with real Gmail. + + This test demonstrates: + 1. Listing messages + 2. Getting a specific message + 3. Marking it as read + 4. Optional: Deleting it (commented out for safety) + """ + # List messages + messages = list(service_client.get_messages(max_results=1)) + if not messages: + pytest.skip("No messages available in Gmail") + + message_id = messages[0].id + + # Get specific message + message = service_client.get_message(message_id) + assert message.id == message_id + + # Mark as read + result = service_client.mark_as_read(message_id) + assert result is True + + # Delete message - Commented out for safety + # Uncomment these lines if you want to test deletion + # result = service_client.delete_message(message_id) # noqa: ERA001 + # assert result is True + # print(f"Successfully deleted message") # noqa: ERA001 diff --git a/tests/integration/test_adapter_e2e.py b/tests/integration/test_adapter_e2e.py new file mode 100644 index 00000000..af7de9aa --- /dev/null +++ b/tests/integration/test_adapter_e2e.py @@ -0,0 +1,106 @@ +"""Integration tests for the mail client service. + +These tests verify that the service client works correctly with the running service. +They use a mock Gmail client but test the real HTTP communication layer. +""" + +import multiprocessing +import os +from collections.abc import Generator +from multiprocessing.context import Process +from typing import Any + +import pytest +import uvicorn + +from mail_client_adapter import MailClientAdapter + +pytestmark = pytest.mark.integration + +# Constants +SERVICE_HOST = "127.0.0.1" +SERVICE_PORT = 8001 # Use different port than development +SERVICE_URL = f"http://{SERVICE_HOST}:{SERVICE_PORT}" + +def _run_service() -> None: + os.environ["MOCK_CLIENT"] = "1" + uvicorn.run( + "mail_client_service:app", + host=SERVICE_HOST, + port=SERVICE_PORT, + log_level="error", + ) + +@pytest.fixture(scope="session") +def service_process() -> Generator[Process, Any, None]: + """Fixture to start and stop the mail client service for testing.""" + # Start service in a separate process + process = multiprocessing.Process(target=_run_service) + process.start() + + # Wait for service to start + import time + time.sleep(2) + + yield process + + # Cleanup + process.terminate() + process.join() + +@pytest.fixture +def service_client(service_process: Process) -> MailClientAdapter: + """Create a service client connected to the test service.""" + return MailClientAdapter(base_url=SERVICE_URL) + +def test_list_messages(service_client: MailClientAdapter) -> None: + """Test that listing messages works through the service.""" + # Call through service + max_results = 2 + messages = list(service_client.get_messages(max_results=max_results)) + + # Verify results + assert len(messages) == max_results + assert messages[0].id == "1" + assert messages[0].subject == "Test Message 1" + assert messages[1].id == "2" + assert messages[1].subject == "Test Message 2" + +def test_get_message(service_client: MailClientAdapter) -> None: + """Test that getting a specific message works through the service.""" + # Call through service + message = service_client.get_message("3") + + # Verify result + assert message.id == "3" + assert message.subject == "Test Message 3" + assert message.from_ == "sender3@example.com" + assert message.to == "recipient@example.com" + assert message.date == "2025-10-03" + assert message.body == "Body 3" + +def test_mark_as_read(service_client: MailClientAdapter) -> None: + """Test that marking a message as read works through the service.""" + # Call through service + result = service_client.mark_as_read("3") + + # Verify result + assert result is True + +def test_delete_message(service_client: MailClientAdapter) -> None: + """Test that deleting a message works through the service.""" + # Call through service + result = service_client.delete_message("3") + + # Verify result + assert result is True + + # check that getting the message now fails + with pytest.raises(ValueError, match="Failed to fetch message"): + service_client.get_message("3") + +def test_error_handling(service_client: MailClientAdapter) -> None: + """Test that service errors are handled correctly.""" + # Verify that the error is handled gracefully + with pytest.raises(ValueError, match="Failed to fetch message"): + service_client.get_message("999") diff --git a/uv.lock b/uv.lock index a7e575ba..0e39da77 100644 --- a/uv.lock +++ b/uv.lock @@ -9,10 +9,44 @@ resolution-markers = [ [manifest] members = [ "gmail-client-impl", + "mail-client-adapter", "mail-client-api", + "mail-client-service", "ta-assignment", ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -231,6 +265,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, ] +[[package]] +name = "fastapi" +version = "0.119.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/f9/5c5bcce82a7997cc0eb8c47b7800f862f6b56adc40486ed246e5010d443b/fastapi-0.119.0.tar.gz", hash = "sha256:451082403a2c1f0b99c6bd57c09110ed5463856804c8078d38e5a1f1035dbbb7", size = 336756, upload-time = "2025-10-11T17:13:40.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/70/584c4d7cad80f5e833715c0a29962d7c93b4d18eed522a02981a6d1b6ee5/fastapi-0.119.0-py3-none-any.whl", hash = "sha256:90a2e49ed19515320abb864df570dd766be0662c5d577688f1600170f7f73cf2", size = 107095, upload-time = "2025-10-11T17:13:39.048Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -383,6 +431,28 @@ 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 = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httplib2" version = "0.31.0" @@ -395,6 +465,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl", hash = "sha256:b9cd78abea9b4e43a7714c6e0f8b6b8561a6fc1e95d5dbd367f5bf0ef35f5d24", size = 91148, upload-time = "2025-09-11T12:16:01.803Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -425,11 +510,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "mail-client-adapter" +version = "0.1.0" +source = { editable = "src/mail_client_adapter" } +dependencies = [ + { name = "mail-client-api" }, + { name = "mail-client-service-client" }, + { name = "requests" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "mail-client-api", editable = "src/mail_client_api" }, + { name = "mail-client-service-client", directory = "src/mail_client_service/mail_client_service_client" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.10.0" }, + { name = "requests", specifier = ">=2.31.0" }, +] +provides-extras = ["test"] + [[package]] name = "mail-client-api" version = "0.1.0" source = { editable = "src/mail_client_api" } +[[package]] +name = "mail-client-service" +version = "0.1.0" +source = { editable = "src/mail_client_service" } +dependencies = [ + { name = "fastapi" }, + { name = "gmail-client-impl" }, + { name = "mail-client-api" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +test = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.100.0" }, + { name = "gmail-client-impl", editable = "src/gmail_client_impl" }, + { name = "httpx", marker = "extra == 'test'", specifier = ">=0.24.0" }, + { name = "mail-client-api", editable = "src/mail_client_api" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.10.0" }, + { name = "uvicorn", specifier = ">=0.23.0" }, +] +provides-extras = ["test"] + +[[package]] +name = "mail-client-service-client" +version = "0.1.0" +source = { directory = "src/mail_client_service/mail_client_service_client" } +dependencies = [ + { name = "attrs" }, + { name = "httpx" }, + { name = "python-dateutil" }, +] + +[package.metadata] +requires-dist = [ + { name = "attrs", specifier = ">=22.2.0" }, + { name = "httpx", specifier = ">=0.23.0,<0.29.0" }, + { name = "python-dateutil", specifier = ">=2.8.0,<3" }, +] + [[package]] name = "markdown" version = "3.9" @@ -439,6 +597,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -513,6 +683,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -693,6 +872,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] +[[package]] +name = "openapi-python-client" +version = "0.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "ruamel-yaml" }, + { name = "ruff" }, + { name = "shellingham" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/dd/eda1ecf8bec21dd92f4729a743adb1e496b2bb67e2b60ce6652d74efc477/openapi_python_client-0.26.2.tar.gz", hash = "sha256:99ed575573b49c322456052a344bcdb352edc520c3b7a7fed62347ab6a03c60b", size = 126186, upload-time = "2025-10-06T14:20:42.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/97/5cbc4147d67bf4dbe428f4c41cfac808e2b61c2c1a71ce2ec52a4f604569/openapi_python_client-0.26.2-py3-none-any.whl", hash = "sha256:5b615bf359872a77c220797cce0214d02ab7552d9ef5bc2c11b24d8dda609ad4", size = 183641, upload-time = "2025-10-06T14:20:40.457Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -785,6 +986,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/a7/d0d7b3c128948ece6676a6a21b9036e3ca53765d35052dbcc8c303886a44/pydantic-2.12.1.tar.gz", hash = "sha256:0af849d00e1879199babd468ec9db13b956f6608e9250500c1a9d69b6a62824e", size = 815997, upload-time = "2025-10-13T21:00:41.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/69/ce4e60e5e67aa0c339a5dc3391a02b4036545efb6308c54dc4aa9425386f/pydantic-2.12.1-py3-none-any.whl", hash = "sha256:665931f5b4ab40c411439e66f99060d631d1acc58c3d481957b9123343d674d1", size = 460511, upload-time = "2025-10-13T21:00:38.935Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/e9/3916abb671bffb00845408c604ff03480dc8dc273310d8268547a37be0fb/pydantic_core-2.41.3.tar.gz", hash = "sha256:cdebb34b36ad05e8d77b4e797ad38a2a775c2a07a8fa386d4f6943b7778dcd39", size = 457489, upload-time = "2025-10-13T19:34:51.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/60/f7291e1264831136917e417b1ec9ed70dd64174a4c8ff4d75cad3028aab5/pydantic_core-2.41.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:91dfe6a6e02916fd1fb630f1ebe0c18f9fd9d3cbfe84bb2599f195ebbb0edb9b", size = 2107996, upload-time = "2025-10-13T19:31:04.902Z" }, + { url = "https://files.pythonhosted.org/packages/43/05/362832ea8b890f5821ada95cd72a0da1b2466f88f6ac1a47cf1350136722/pydantic_core-2.41.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e301551c63d46122972ab5523a1438772cdde5d62d34040dac6f11017f18cc5d", size = 1916194, upload-time = "2025-10-13T19:31:06.313Z" }, + { url = "https://files.pythonhosted.org/packages/90/ca/893c63b84ca961d81ae33e4d1e3e00191e29845a874c7f4cc3ca1aa61157/pydantic_core-2.41.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d986b1defbe27867812dc3d8b3401d72be14449b255081e505046c02687010a", size = 1969065, upload-time = "2025-10-13T19:31:07.719Z" }, + { url = "https://files.pythonhosted.org/packages/55/b9/fecd085420a500acbf3bfc542d2662f2b37497f740461b5e960277f199f0/pydantic_core-2.41.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:351b2c5c073ae8caaa11e4336f8419d844c9b936e123e72dbe2c43fa97e54781", size = 2049849, upload-time = "2025-10-13T19:31:09.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/55/e351b6f51c6b568a911c672c8e3fd809d10f6deaa475007b54e3c0b89f0f/pydantic_core-2.41.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7be34f5217ffc28404fc0ca6f07491a2a6a770faecfcf306384c142bccd2fdb4", size = 2244780, upload-time = "2025-10-13T19:31:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/e3/17/87873bb56e5055d1aadfd84affa33cbf164e923d674c17ca898ad53db08e/pydantic_core-2.41.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3cbcad992c281b4960cb5550e218ff39a679c730a59859faa0bc9b8d87efbe6a", size = 2362221, upload-time = "2025-10-13T19:31:13.183Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f9/2a3fb1e3b5f47754935a726ff77887246804156a029c5394daf4263a3e88/pydantic_core-2.41.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8741b0ab2acdd20c804432e08052791e66cf797afa5451e7e435367f88474b0b", size = 2070695, upload-time = "2025-10-13T19:31:14.849Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/d66c1048fcd60e995913809f9e3fcca1e6890bc3588902eab9ade63aa6d8/pydantic_core-2.41.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ac3ba94f3be9437da4ad611dacd356f040120668c5b1733b8ae035a13663c48", size = 2185138, upload-time = "2025-10-13T19:31:16.772Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/6fbbd67d0629392ccd5eea8a8b4c005f0151c5505ad22f9b1ff74d63d9f1/pydantic_core-2.41.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:971efe83bac3d5db781ee1b4836ac2cdd53cf7f727edfd4bb0a18029f9409ef2", size = 2148858, upload-time = "2025-10-13T19:31:18.311Z" }, + { url = "https://files.pythonhosted.org/packages/1c/08/453385212db8db39ed0b6a67f2282b825ad491fed46c88329a0b9d0e543e/pydantic_core-2.41.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:98c54e5ad0399ac79c0b6b567693d0f8c44b5a0d67539826cc1dd495e47d1307", size = 2315038, upload-time = "2025-10-13T19:31:19.95Z" }, + { url = "https://files.pythonhosted.org/packages/53/b9/271298376dc561de57679a82bf4777b9cf7df23881d487b17f658ef78eab/pydantic_core-2.41.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60110fe616b599c6e057142f2d75873e213bc0cbdac88f58dda8afb27a82f978", size = 2324458, upload-time = "2025-10-13T19:31:21.501Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/126ac22c310a64dc24d833d47bd175098daa3f9eab93043502a2c11348b4/pydantic_core-2.41.3-cp311-cp311-win32.whl", hash = "sha256:75428ae73865ee366f159b68b9281c754df832494419b4eb46b7c3fbdb27756c", size = 1986636, upload-time = "2025-10-13T19:31:23.08Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a7/703a31dc6ede00b4e394e5b81c14f462fe5654d3064def17dd64d4389a1a/pydantic_core-2.41.3-cp311-cp311-win_amd64.whl", hash = "sha256:c0178ad5e586d3e394f4b642f0bb7a434bcf34d1e9716cc4bd74e34e35283152", size = 2023792, upload-time = "2025-10-13T19:31:25.011Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e3/2166b56df1bbe92663b8971012bf7dbd28b6a95e1dc9ad1ec9c99511c41e/pydantic_core-2.41.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dd40bb57cdae2a35e20d06910b93b13e8f57ffff5a0b0a45927953bad563a03", size = 1968147, upload-time = "2025-10-13T19:31:26.611Z" }, + { url = "https://files.pythonhosted.org/packages/20/11/3149cae2a61ddd11c206cde9dab7598a53cfabe8e69850507876988d2047/pydantic_core-2.41.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7bdc8b70bc4b68e4d891b46d018012cac7bbfe3b981a7c874716dde09ff09fd5", size = 2098919, upload-time = "2025-10-13T19:31:28.727Z" }, + { url = "https://files.pythonhosted.org/packages/53/64/1717c7c5b092c64e5022b0d02b11703c2c94c31d897366b6c8d160b7d1de/pydantic_core-2.41.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446361e93f4ffe509edae5862fb89a0d24cbc8f2935f05c6584c2f2ca6e7b6df", size = 1910372, upload-time = "2025-10-13T19:31:30.351Z" }, + { url = "https://files.pythonhosted.org/packages/99/ba/0231b5dde6c1c436e0d58aed7d63f927694d92c51aff739bf692142ce6e6/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9af9a9ae24b866ce58462a7de61c33ff035e052b7a9c05c29cf496bd6a16a63f", size = 1952392, upload-time = "2025-10-13T19:31:32.345Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5d/1adbfa682a56544d70b42931f19de44a4e58a4fc2152da343a2fdfd4cad5/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc836eb8561f04fede7b73747463bd08715be0f55c427e0f0198aa2f1d92f913", size = 2041093, upload-time = "2025-10-13T19:31:34.534Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d3/9d14041f0b125a5d6388957cace43f9dfb80d862e56a0685dde431a20b6a/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16f80f366472eb6a3744149289c263e5ef182c8b18422192166b67625fef3c50", size = 2214331, upload-time = "2025-10-13T19:31:36.575Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cd/384988d065596fafecf9baeab0c66ef31610013b26eec3b305a80ab5f669/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d699904cd13d0f509bdbb17f0784abb332d4aa42df4b0a8b65932096fcd4b21", size = 2344450, upload-time = "2025-10-13T19:31:38.905Z" }, + { url = "https://files.pythonhosted.org/packages/a3/13/1b0dd34fce51a746823a347d7f9e02c6ea09078ec91c5f656594c23d2047/pydantic_core-2.41.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485398dacc5dddb2be280fd3998367531eccae8631f4985d048c2406a5ee5ecc", size = 2070507, upload-time = "2025-10-13T19:31:41.093Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/0f8d6d67d917318d842fe8dba2489b0c5989ce01fc1ed58bf204f80663df/pydantic_core-2.41.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6dfe0898272bf675941cd1ea701677341357b77acadacabbd43d71e09763dceb", size = 2185401, upload-time = "2025-10-13T19:31:42.785Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/b8a82253736f2efd3b79338dfe53866b341b68868fbce7111ff6b040b680/pydantic_core-2.41.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:86ffbf5291c367a56b5718590dc3452890f2c1ac7b76d8f4a1e66df90bd717f6", size = 2131929, upload-time = "2025-10-13T19:31:46.226Z" }, + { url = "https://files.pythonhosted.org/packages/7c/16/efe252cbf852ebfcb4978820e7681d83ae45c526cbfc0cf847f70de49850/pydantic_core-2.41.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:c58c5acda77802eedde3aaf22be09e37cfec060696da64bf6e6ffb2480fdabd0", size = 2307223, upload-time = "2025-10-13T19:31:48.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ea/7d8eba2c37769d8768871575be449390beb2452a2289b0090ea7fa63f920/pydantic_core-2.41.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40db5705aec66371ca5792415c3e869137ae2bab48c48608db3f84986ccaf016", size = 2312962, upload-time = "2025-10-13T19:31:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/02/c4/b617e33c3b6f4a99c7d252cc42df958d14627a09a1a935141fb9abe44189/pydantic_core-2.41.3-cp312-cp312-win32.whl", hash = "sha256:668fcb317a0b3c84781796891128111c32f83458d436b022014ed0ea07f66e1b", size = 1988735, upload-time = "2025-10-13T19:31:51.778Z" }, + { url = "https://files.pythonhosted.org/packages/24/fc/05bb0249782893b52baa7732393c0bac9422d6aab46770253f57176cddba/pydantic_core-2.41.3-cp312-cp312-win_amd64.whl", hash = "sha256:248a5d1dac5382454927edf32660d0791d2df997b23b06a8cac6e3375bc79cee", size = 2032239, upload-time = "2025-10-13T19:31:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/75/1d/7637f6aaafdbc27205296bde9843096bd449192986b5523869444f844b82/pydantic_core-2.41.3-cp312-cp312-win_arm64.whl", hash = "sha256:347a23094c98b7ea2ba6fff93b52bd2931a48c9c1790722d9e841f30e4b7afcd", size = 1969072, upload-time = "2025-10-13T19:31:55.7Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7533cba20b8b66e209d8d2acbb9ccc0bc1b883b0654776d676e02696ef5d/pydantic_core-2.41.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a8596700fdd3ee12b0d9c1f2395f4c32557e7ebfbfacdc08055b0bcbe7d2827e", size = 2105686, upload-time = "2025-10-13T19:31:57.675Z" }, + { url = "https://files.pythonhosted.org/packages/84/d7/2d15cb9dfb9f94422fb4a8820cbfeb397e3823087c2361ef46df5c172000/pydantic_core-2.41.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:624503f918e472c0eed6935020c01b6a6b4bcdb7955a848da5c8805d40f15c0f", size = 1910554, upload-time = "2025-10-13T19:32:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fc/cbd1caa19e88fd64df716a37b49e5864c1ac27dbb9eb870b8977a584fa42/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36388958d0c614df9f5de1a5f88f4b79359016b9ecdfc352037788a628616aa2", size = 1957559, upload-time = "2025-10-13T19:32:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/3b/fe/da942ae51f602173556c627304dc24b9fa8bd04423bce189bf397ba0419e/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c50eba144add9104cf43ef9a3d81c37ebf48bfd0924b584b78ec2e03ec91daf", size = 2051084, upload-time = "2025-10-13T19:32:05.056Z" }, + { url = "https://files.pythonhosted.org/packages/c8/62/0abd59a7107d1ef502b9cfab68145c6bb87115c2d9e883afbf18b98fe6db/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6ea2102958eb5ad560d570c49996e215a6939d9bffd0e9fd3b9e808a55008cc", size = 2218098, upload-time = "2025-10-13T19:32:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/93a36aa119b70126f3f0d06b6f9a81ca864115962669d8a85deb39c82ecc/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd0d26f1e4335d5f84abfc880da0afa080c8222410482f9ee12043bb05f55ec8", size = 2341954, upload-time = "2025-10-13T19:32:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/0f/be/7c2563b53b71ff3e41950b0ffa9eeba3d702091c6d59036fff8a39050528/pydantic_core-2.41.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41c38700094045b12c0cff35c8585954de66cf6dd63909fed1c2e6b8f38e1e1e", size = 2069474, upload-time = "2025-10-13T19:32:10.808Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ac/2394004db9f6e03712c1e52f40f0979750fa87721f6baf5f76ad92b8be46/pydantic_core-2.41.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4061cc82d7177417fdb90e23e67b27425ecde2652cfd2053b5b4661a489ddc19", size = 2190633, upload-time = "2025-10-13T19:32:12.731Z" }, + { url = "https://files.pythonhosted.org/packages/7d/31/7b70c2d1fe41f450f8022f5523edaaea19c17a2d321fab03efd03aea1fe8/pydantic_core-2.41.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:b1d9699a4dae10a7719951cca1e30b591ef1dd9cdda9fec39282a283576c0241", size = 2137097, upload-time = "2025-10-13T19:32:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ae/f872198cffc8564f52c4ef83bcd3e324e5ac914e168c6b812f5ce3f80aab/pydantic_core-2.41.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:d5099f1b97e79f0e45cb6a236a5bd1a20078ed50b1b28f3d17f6c83ff3585baa", size = 2316771, upload-time = "2025-10-13T19:32:16.586Z" }, + { url = "https://files.pythonhosted.org/packages/23/50/f0fce3a9a7554ced178d943e1eada58b15fca896e9eb75d50244fc12007c/pydantic_core-2.41.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:b5ff0467a8c1b6abb0ab9c9ea80e2e3a9788592e44c726c2db33fdaf1b5e7d0b", size = 2319449, upload-time = "2025-10-13T19:32:18.503Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/86a6948408e8388604c02ffde651a2e39b711bd1ab6eeaff376094553a10/pydantic_core-2.41.3-cp313-cp313-win32.whl", hash = "sha256:edfe9b4cee4a91da7247c25732f24504071f3e101c050694d18194b7d2d320bf", size = 1995352, upload-time = "2025-10-13T19:32:20.5Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4b/6dac37c3f62684dc459a31623d8ae97ee433fd68bb827e5c64dd831a5087/pydantic_core-2.41.3-cp313-cp313-win_amd64.whl", hash = "sha256:44af3276c0c2c14efde6590523e4d7e04bcd0e46e0134f0dbef1be0b64b2d3e3", size = 2031894, upload-time = "2025-10-13T19:32:23.11Z" }, + { url = "https://files.pythonhosted.org/packages/fd/75/3d9ba041a3fcb147279fbb37d2468efe62606809fec97b8de78174335ef4/pydantic_core-2.41.3-cp313-cp313-win_arm64.whl", hash = "sha256:59aeed341f92440d51fdcc82c8e930cfb234f1843ed1d4ae1074f5fb9789a64b", size = 1974036, upload-time = "2025-10-13T19:32:25.219Z" }, + { url = "https://files.pythonhosted.org/packages/50/68/45842628ccdb384df029f884ef915306d195c4f08b66ca4d99867edc6338/pydantic_core-2.41.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef37228238b3a280170ac43a010835c4a7005742bc8831c2c1a9560de4595dbe", size = 1876856, upload-time = "2025-10-13T19:32:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/99/73/336a82910c6a482a0ba9a255c08dcc456ebca9735df96d7a82dffe17626a/pydantic_core-2.41.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cb19f36253152c509abe76c1d1b185436e0c75f392a82934fe37f4a1264449", size = 1884665, upload-time = "2025-10-13T19:32:29.567Z" }, + { url = "https://files.pythonhosted.org/packages/34/87/ec610a7849561e0ef7c25b74ef934d154454c3aac8fb595b899557f3c6ab/pydantic_core-2.41.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91be4756e05367ce19a70e1db3b77f01f9e40ca70d26fb4cdfa993e53a08964a", size = 2043067, upload-time = "2025-10-13T19:32:31.506Z" }, + { url = "https://files.pythonhosted.org/packages/db/b4/5f2b0cf78752f9111177423bd5f2bc0815129e587c13401636b8900a417e/pydantic_core-2.41.3-cp313-cp313t-win_amd64.whl", hash = "sha256:ce7d8f4353f82259b55055bd162bbaf599f6c40cd0c098e989eeb95f9fdc022f", size = 1996799, upload-time = "2025-10-13T19:32:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/49/7f/07e7f19a6a44a52abd48846e348e11fa1b3de5ed7c0231d53f055ffb365f/pydantic_core-2.41.3-cp313-cp313t-win_arm64.whl", hash = "sha256:f06a9e81da60e5a0ef584f6f4790f925c203880ae391bf363d97126fd1790b21", size = 1969574, upload-time = "2025-10-13T19:32:35.533Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/db32fbced75853c1d8e7ada8cb2b837ade99b2f281de569908de3e29f0bf/pydantic_core-2.41.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0c77e8e72344e34052ea26905fa7551ecb75fc12795ca1a8e44f816918f4c718", size = 2103383, upload-time = "2025-10-13T19:32:37.522Z" }, + { url = "https://files.pythonhosted.org/packages/de/28/5bcb3327b3777994633f4cb459c5dc34a9cbe6cf0ac449d3e8f1e74bdaaa/pydantic_core-2.41.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32be442a017e82a6c496a52ef5db5f5ac9abf31c3064f5240ee15a1d27cc599e", size = 1904974, upload-time = "2025-10-13T19:32:39.513Z" }, + { url = "https://files.pythonhosted.org/packages/71/8d/c9d8cad7c02d63869079fb6fb61b8ab27adbeeda0bf130c684fe43daa126/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af10c78f0e9086d2d883ddd5a6482a613ad435eb5739cf1467b1f86169e63d91", size = 1956879, upload-time = "2025-10-13T19:32:41.849Z" }, + { url = "https://files.pythonhosted.org/packages/15/b1/8a84b55631a45375a467df288d8f905bec0abadb1e75bce3b32402b49733/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6212874118704e27d177acee5b90b83556b14b2eb88aae01bae51cd9efe27019", size = 2051787, upload-time = "2025-10-13T19:32:43.86Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/a84ea9cb7ba4dbfd43865e5dd536b22c78ee763d82d501c6f6a553403c00/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6a24c82674a3a8e7f7306e57e98219e5c1cdfc0f57bc70986930dda136230b2", size = 2217830, upload-time = "2025-10-13T19:32:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/64233c77410e314dbb7f2e8112be7f56de57cf64198a32d8ab3f7b74adf4/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e0c81dc047c18059410c959a437540abcefea6a882d6e43b9bf45c291eaacd9", size = 2341131, upload-time = "2025-10-13T19:32:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/23/3d/915b90eb0de93bd522b293fd1a986289f5d576c72e640f3bb426b496d095/pydantic_core-2.41.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0d7e1a9f80f00a8180b9194ecef66958eb03f3c3ae2d77195c9d665ac0a61e", size = 2063797, upload-time = "2025-10-13T19:32:50.458Z" }, + { url = "https://files.pythonhosted.org/packages/4d/25/a65665caa86e496e19feef48e6bd9263c1a46f222e8f9b0818f67bd98dc3/pydantic_core-2.41.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2868fabfc35ec0738539ce0d79aab37aeffdcb9682b9b91f0ac4b0ba31abb1eb", size = 2193041, upload-time = "2025-10-13T19:32:52.686Z" }, + { url = "https://files.pythonhosted.org/packages/cd/46/a7f7e17f99ee691a7d93a53aa41bf7d1b1d425945b6e9bc8020498a413e1/pydantic_core-2.41.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:cb4f40c93307e1c50996e4edcddf338e1f3f1fb86fb69b654111c6050ae3b081", size = 2136119, upload-time = "2025-10-13T19:32:54.737Z" }, + { url = "https://files.pythonhosted.org/packages/5f/92/c27c1f3edd06e04af71358aa8f4d244c8bc6726e3fb47e00157d3dffe66f/pydantic_core-2.41.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:287cbcd3407a875eaf0b1efa2e5288493d5b79bfd3629459cf0b329ad8a9071a", size = 2317223, upload-time = "2025-10-13T19:32:56.927Z" }, + { url = "https://files.pythonhosted.org/packages/51/6c/20aabe3c32888fb13d4726e405716fed14b1d4d1d4292d585862c1458b7b/pydantic_core-2.41.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5253835aa145049205a67056884555a936f9b3fea7c3ce860bff62be6a1ae4d1", size = 2320425, upload-time = "2025-10-13T19:32:59.454Z" }, + { url = "https://files.pythonhosted.org/packages/67/d2/476d4bc6b3070e151ae920167f27f26415e12f8fcc6cf5a47a613aba7267/pydantic_core-2.41.3-cp314-cp314-win32.whl", hash = "sha256:69297795efe5349156d18eebea818b75d29a1d3d1d5f26a250f22ab4220aacd6", size = 1994216, upload-time = "2025-10-13T19:33:01.484Z" }, + { url = "https://files.pythonhosted.org/packages/16/ca/2cd8515584b3d665ca3c4d946364c2a9932d0d5648694c2a10d273cde81c/pydantic_core-2.41.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1c133e3447c2f6d95e47ede58fff0053370758112a1d39117d0af8c93584049", size = 2026522, upload-time = "2025-10-13T19:33:03.546Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/c9f2791d7188594f0abdc1b7fe8ec3efc123ee2d9c553fd3b6da2d9fd53d/pydantic_core-2.41.3-cp314-cp314-win_arm64.whl", hash = "sha256:54534eecbb7a331521f832e15fc307296f491ee1918dacfd4d5b900da6ee3332", size = 1969070, upload-time = "2025-10-13T19:33:05.604Z" }, + { url = "https://files.pythonhosted.org/packages/b5/eb/45f9a91f8c09f4cfb62f78dce909b20b6047ce4fd8d89310fcac5ad62e54/pydantic_core-2.41.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6b4be10152098b43c093a4b5e9e9da1ac7a1c954c1934d4438d07ba7b7bcf293", size = 1876593, upload-time = "2025-10-13T19:33:07.814Z" }, + { url = "https://files.pythonhosted.org/packages/99/f8/5c9d0959e0e1f260eea297a5ecc1dc29a14e03ee6a533e805407e8403c1a/pydantic_core-2.41.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe4ebd676c158a7994253161151b476dbbef2acbd2f547cfcfdf332cf67cc29", size = 1882977, upload-time = "2025-10-13T19:33:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/7ab918e35f55e7beee471ba8c67dfc4c9c19a8904e4867bfda7f9c76a72e/pydantic_core-2.41.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:984ca0113b39dda1d7c358d6db03dd6539ef244d0558351806c1327239e035bf", size = 2041033, upload-time = "2025-10-13T19:33:12.216Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c8/5b12e5a36410ebcd0082ae5b0258150d72762e306f298cc3fe731b5574ec/pydantic_core-2.41.3-cp314-cp314t-win_amd64.whl", hash = "sha256:2a7dd8a6f5a9a2f8c7f36e4fc0982a985dbc4ac7176ee3df9f63179b7295b626", size = 1994462, upload-time = "2025-10-13T19:33:14.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f6/c6f3b7244a2a0524f4a04052e3d590d3be0ba82eb1a2f0fe5d068237701e/pydantic_core-2.41.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b387f08b378924fa82bd86e03c9d61d6daca1a73ffb3947bdcfe12ea14c41f68", size = 1973551, upload-time = "2025-10-13T19:33:16.87Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/837dc1d5f09728590ace987fcaad83ec4539dcd73ce4ea5a0b786ee0a921/pydantic_core-2.41.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:98ad9402d6cc194b21adb4626ead88fcce8bc287ef434502dbb4d5b71bdb9a47", size = 2122049, upload-time = "2025-10-13T19:33:49.808Z" }, + { url = "https://files.pythonhosted.org/packages/00/7d/d9c6d70571219d826381049df60188777de0283d7f01077bfb7ec26cb121/pydantic_core-2.41.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:539b1c01251fbc0789ad4e1dccf3e888062dd342b2796f403406855498afbc36", size = 1936957, upload-time = "2025-10-13T19:33:52.768Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d3/5e69eba2752a47815adcf9ff7fcfdb81c600b7c87823037d8e746db835cf/pydantic_core-2.41.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12019e3a4ded7c4e84b11a761be843dfa9837444a1d7f621888ad499f0f72643", size = 1957032, upload-time = "2025-10-13T19:33:55.46Z" }, + { url = "https://files.pythonhosted.org/packages/4c/98/799db4be56a16fb22152c5473f806c7bb818115f1648bee3ac29a7d5fb9e/pydantic_core-2.41.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e01519c8322a489167abb1aceaab1a9e4c7d3e665dc3f7b0b1355910fcb698", size = 2140010, upload-time = "2025-10-13T19:33:57.881Z" }, + { url = "https://files.pythonhosted.org/packages/68/e6/a41dec3d50cfbd7445334459e847f97a62c5658d2c6da268886928ffd357/pydantic_core-2.41.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:a6ded5abbb7391c0db9e002aaa5f0e3a49a024b0a22e2ed09ab69087fd5ab8a8", size = 2112077, upload-time = "2025-10-13T19:34:00.77Z" }, + { url = "https://files.pythonhosted.org/packages/44/38/e136a52ae85265a07999439cd8dcd24ba4e83e23d61e40000cd74b426f19/pydantic_core-2.41.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:43abc869cce9104ff35cb4eff3028e9a87346c95fe44e0173036bf4d782bdc3d", size = 1920464, upload-time = "2025-10-13T19:34:03.454Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/a3f509f682818ded836bd006adce08d731d81c77694a26a0a1a448f3e351/pydantic_core-2.41.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb3c63f4014a603caee687cd5c3c63298d2c8951b7acb2ccd0befbf2e1c0b8ad", size = 1951926, upload-time = "2025-10-13T19:34:05.983Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/cb30ad2a0147cc7763c0c805ee1c534f6ed5d5db7bc8cf8ebaf34b4c9dab/pydantic_core-2.41.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88461e25f62e58db4d8b180e2612684f31b5844db0a8f8c1c421498c97bc197b", size = 2139233, upload-time = "2025-10-13T19:34:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/1d/84/14c7ed3428feb718792fc2ecc5d04c12e46cb5c65620717c6826428ee468/pydantic_core-2.41.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5e67f86ffb40127851dba662b2d0ab400264ed37cfedeab6100515df41ccb325", size = 2106894, upload-time = "2025-10-13T19:34:30.905Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5d/d129794fc3990a49b12963d7cc25afc6a458fe85221b8a78cf46c5f22135/pydantic_core-2.41.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ecad4d7d264f6df23db68ca3024919a7aab34b4c44d9a9280952863a7a0c5e81", size = 1929911, upload-time = "2025-10-13T19:34:33.399Z" }, + { url = "https://files.pythonhosted.org/packages/d3/89/8fe254b1725a48f4da1978fa21268f142846c2d653715161afc394e67486/pydantic_core-2.41.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fce6e6505b9807d3c20476fa016d0bd4d54a858fe648d6f5ef065286410c3da7", size = 2133972, upload-time = "2025-10-13T19:34:35.994Z" }, + { url = "https://files.pythonhosted.org/packages/75/26/eefc7f23167a8060e29fcbb99d15158729ea794ee5b5c11ecc4df73b21c9/pydantic_core-2.41.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05974468cff84ea112ad4992823f1300d822ad51df0eba4c3af3c4a4cbe5eca0", size = 2181777, upload-time = "2025-10-13T19:34:38.762Z" }, + { url = "https://files.pythonhosted.org/packages/67/ba/03c5a00a9251fc5fe22d5807bc52cf0863b9486f0086a45094adee77fa0b/pydantic_core-2.41.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:091d3966dc2379e07b45b4fd9651fbab5b24ea3c62cc40637beaf691695e5f5a", size = 2144699, upload-time = "2025-10-13T19:34:41.29Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/ee90dc6c99c8261c89ce1c2311395e7a0432dfc20db1bd6d9be917a92320/pydantic_core-2.41.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:16f216e4371a05ad3baa5aed152eae056c7e724663c2bcbb38edd607c17baa89", size = 2311388, upload-time = "2025-10-13T19:34:43.843Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/7f3e4ed3963113e5e9df8077f3015facae0cd3a65ac5688d308010405a0e/pydantic_core-2.41.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:2e169371f88113c8e642f7ac42c798109f1270832b577b5144962a7a028bfb0c", size = 2320916, upload-time = "2025-10-13T19:34:46.417Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d7/91ef73afa5c275962edd708559148e153d95866f8baf96142ab4804da67a/pydantic_core-2.41.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:83847aa6026fb7149b9ef06e10c73ff83ac1d2aa478b28caa4f050670c1c9a37", size = 2148327, upload-time = "2025-10-13T19:34:48.929Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -974,6 +1283,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + [[package]] name = "rsa" version = "4.9.1" @@ -986,6 +1308,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/db/f3950f5e5031b618aae9f423a39bf81a55c148aecd15a34527898e752cf4/ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700", size = 146865, upload-time = "2025-08-19T11:15:10.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701", size = 119702, upload-time = "2025-08-19T11:15:07.696Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/e9/39ec4d4b3f91188fad1842748f67d4e749c77c37e353c4e545052ee8e893/ruamel.yaml.clib-0.2.14.tar.gz", hash = "sha256:803f5044b13602d58ea378576dd75aa759f52116a0232608e8fdada4da33752e", size = 225394, upload-time = "2025-09-22T19:51:23.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/9f/3c51e9578b8c36fcc4bdd271a1a5bb65963a74a4b6ad1a989768a22f6c2a/ruamel.yaml.clib-0.2.14-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5bae1a073ca4244620425cd3d3aa9746bde590992b98ee8c7c8be8c597ca0d4e", size = 270207, upload-time = "2025-09-23T14:24:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/4a/16/cb02815bc2ae9c66760c0c061d23c7358f9ba51dae95ac85247662b7fbe2/ruamel.yaml.clib-0.2.14-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:0a54e5e40a7a691a426c2703b09b0d61a14294d25cfacc00631aa6f9c964df0d", size = 137780, upload-time = "2025-09-22T19:50:37.734Z" }, + { url = "https://files.pythonhosted.org/packages/31/c6/fc687cd1b93bff8e40861eea46d6dc1a6a778d9a085684e4045ff26a8e40/ruamel.yaml.clib-0.2.14-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:10d9595b6a19778f3269399eff6bab642608e5966183abc2adbe558a42d4efc9", size = 641590, upload-time = "2025-09-22T19:50:41.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/5d/65a2bc08b709b08576b3f307bf63951ee68a8e047cbbda6f1c9864ecf9a7/ruamel.yaml.clib-0.2.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba72975485f2b87b786075e18a6e5d07dc2b4d8973beb2732b9b2816f1bad70", size = 738090, upload-time = "2025-09-22T19:50:39.152Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d0/a70a03614d9a6788a3661ab1538879ed2aae4e84d861f101243116308a37/ruamel.yaml.clib-0.2.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29757bdb7c142f9595cc1b62ec49a3d1c83fab9cef92db52b0ccebaad4eafb98", size = 700744, upload-time = "2025-09-22T19:50:40.811Z" }, + { url = "https://files.pythonhosted.org/packages/77/30/c93fa457611f79946d5cb6cc97493ca5425f3f21891d7b1f9b44eaa1b38e/ruamel.yaml.clib-0.2.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:557df28dbccf79b152fe2d1b935f6063d9cc431199ea2b0e84892f35c03bb0ee", size = 742321, upload-time = "2025-09-23T18:42:48.916Z" }, + { url = "https://files.pythonhosted.org/packages/40/85/e2c54ad637117cd13244a4649946eaa00f32edcb882d1f92df90e079ab00/ruamel.yaml.clib-0.2.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:26a8de280ab0d22b6e3ec745b4a5a07151a0f74aad92dd76ab9c8d8d7087720d", size = 743805, upload-time = "2025-09-22T19:50:43.58Z" }, + { url = "https://files.pythonhosted.org/packages/81/50/f899072c38877d8ef5382e0b3d47f8c4346226c1f52d6945d6f64fec6a2f/ruamel.yaml.clib-0.2.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e501c096aa3889133d674605ebd018471bc404a59cbc17da3c5924421c54d97c", size = 769529, upload-time = "2025-09-22T19:50:45.707Z" }, + { url = "https://files.pythonhosted.org/packages/99/7c/96d4b5075e30c65ea2064e40c2d657c7c235d7b6ef18751cf89a935b9041/ruamel.yaml.clib-0.2.14-cp311-cp311-win32.whl", hash = "sha256:915748cfc25b8cfd81b14d00f4bfdb2ab227a30d6d43459034533f4d1c207a2a", size = 100256, upload-time = "2025-09-22T19:50:48.26Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8c/73ee2babd04e8bfcf1fd5c20aa553d18bf0ebc24b592b4f831d12ae46cc0/ruamel.yaml.clib-0.2.14-cp311-cp311-win_amd64.whl", hash = "sha256:4ccba93c1e5a40af45b2f08e4591969fa4697eae951c708f3f83dcbf9f6c6bb1", size = 118234, upload-time = "2025-09-22T19:50:47.019Z" }, + { url = "https://files.pythonhosted.org/packages/b4/42/ccfb34a25289afbbc42017e4d3d4288e61d35b2e00cfc6b92974a6a1f94b/ruamel.yaml.clib-0.2.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6aeadc170090ff1889f0d2c3057557f9cd71f975f17535c26a5d37af98f19c27", size = 271775, upload-time = "2025-09-23T14:24:12.771Z" }, + { url = "https://files.pythonhosted.org/packages/82/73/e628a92e80197ff6a79ab81ec3fa00d4cc082d58ab78d3337b7ba7043301/ruamel.yaml.clib-0.2.14-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5e56ac47260c0eed992789fa0b8efe43404a9adb608608631a948cee4fc2b052", size = 138842, upload-time = "2025-09-22T19:50:49.156Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c5/346c7094344a60419764b4b1334d9e0285031c961176ff88ffb652405b0c/ruamel.yaml.clib-0.2.14-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:a911aa73588d9a8b08d662b9484bc0567949529824a55d3885b77e8dd62a127a", size = 647404, upload-time = "2025-09-22T19:50:52.921Z" }, + { url = "https://files.pythonhosted.org/packages/df/99/65080c863eb06d4498de3d6c86f3e90595e02e159fd8529f1565f56cfe2c/ruamel.yaml.clib-0.2.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a05ba88adf3d7189a974b2de7a9d56731548d35dc0a822ec3dc669caa7019b29", size = 753141, upload-time = "2025-09-22T19:50:50.294Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e3/0de85f3e3333f8e29e4b10244374a202a87665d1131798946ee22cf05c7c/ruamel.yaml.clib-0.2.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb04c5650de6668b853623eceadcdb1a9f2fee381f5d7b6bc842ee7c239eeec4", size = 703477, upload-time = "2025-09-22T19:50:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/d9/25/0d2f09d8833c7fd77ab8efeff213093c16856479a9d293180a0d89f6bed9/ruamel.yaml.clib-0.2.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df3ec9959241d07bc261f4983d25a1205ff37703faf42b474f15d54d88b4f8c9", size = 741157, upload-time = "2025-09-23T18:42:50.408Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8c/959f10c2e2153cbdab834c46e6954b6dd9e3b109c8f8c0a3cf1618310985/ruamel.yaml.clib-0.2.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fbc08c02e9b147a11dfcaa1ac8a83168b699863493e183f7c0c8b12850b7d259", size = 745859, upload-time = "2025-09-22T19:50:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6b/e580a7c18b485e1a5f30a32cda96b20364b0ba649d9d2baaf72f8bd21f83/ruamel.yaml.clib-0.2.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c099cafc1834d3c5dac305865d04235f7c21c167c8dd31ebc3d6bbc357e2f023", size = 770200, upload-time = "2025-09-22T19:50:55.718Z" }, + { url = "https://files.pythonhosted.org/packages/ef/44/3455eebc761dc8e8fdced90f2b0a3fa61e32ba38b50de4130e2d57db0f21/ruamel.yaml.clib-0.2.14-cp312-cp312-win32.whl", hash = "sha256:b5b0f7e294700b615a3bcf6d28b26e6da94e8eba63b079f4ec92e9ba6c0d6b54", size = 98829, upload-time = "2025-09-22T19:50:58.895Z" }, + { url = "https://files.pythonhosted.org/packages/76/ab/5121f7f3b651db93de546f8c982c241397aad0a4765d793aca1dac5eadee/ruamel.yaml.clib-0.2.14-cp312-cp312-win_amd64.whl", hash = "sha256:a37f40a859b503304dd740686359fcf541d6fb3ff7fc10f539af7f7150917c68", size = 115570, upload-time = "2025-09-22T19:50:57.981Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ae/e3811f05415594025e96000349d3400978adaed88d8f98d494352d9761ee/ruamel.yaml.clib-0.2.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7e4f9da7e7549946e02a6122dcad00b7c1168513acb1f8a726b1aaf504a99d32", size = 269205, upload-time = "2025-09-23T14:24:15.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/7d51f4688d6d72bb72fa74254e1593c4f5ebd0036be5b41fe39315b275e9/ruamel.yaml.clib-0.2.14-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:dd7546c851e59c06197a7c651335755e74aa383a835878ca86d2c650c07a2f85", size = 137417, upload-time = "2025-09-22T19:50:59.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/08/b4499234a420ef42960eeb05585df5cc7eb25ccb8c980490b079e6367050/ruamel.yaml.clib-0.2.14-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:1c1acc3a0209ea9042cc3cfc0790edd2eddd431a2ec3f8283d081e4d5018571e", size = 642558, upload-time = "2025-09-22T19:51:03.388Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ba/1975a27dedf1c4c33306ee67c948121be8710b19387aada29e2f139c43ee/ruamel.yaml.clib-0.2.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2070bf0ad1540d5c77a664de07ebcc45eebd1ddcab71a7a06f26936920692beb", size = 744087, upload-time = "2025-09-22T19:51:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/20/15/8a19a13d27f3bd09fa18813add8380a29115a47b553845f08802959acbce/ruamel.yaml.clib-0.2.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd8fe07f49c170e09d76773fb86ad9135e0beee44f36e1576a201b0676d3d1d", size = 699709, upload-time = "2025-09-22T19:51:02.075Z" }, + { url = "https://files.pythonhosted.org/packages/19/ee/8d6146a079ad21e534b5083c9ee4a4c8bec42f79cf87594b60978286b39a/ruamel.yaml.clib-0.2.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ff86876889ea478b1381089e55cf9e345707b312beda4986f823e1d95e8c0f59", size = 708926, upload-time = "2025-09-23T18:42:51.707Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/426b714abdc222392e68f3b8ad323930d05a214a27c7e7a0f06c69126401/ruamel.yaml.clib-0.2.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1f118b707eece8cf84ecbc3e3ec94d9db879d85ed608f95870d39b2d2efa5dca", size = 740202, upload-time = "2025-09-22T19:51:04.673Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ac/3c5c2b27a183f4fda8a57c82211721c016bcb689a4a175865f7646db9f94/ruamel.yaml.clib-0.2.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b30110b29484adc597df6bd92a37b90e63a8c152ca8136aad100a02f8ba6d1b6", size = 765196, upload-time = "2025-09-22T19:51:05.916Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/06f56a71fd55021c993ed6e848c9b2e5e9cfce180a42179f0ddd28253f7c/ruamel.yaml.clib-0.2.14-cp313-cp313-win32.whl", hash = "sha256:f4e97a1cf0b7a30af9e1d9dad10a5671157b9acee790d9e26996391f49b965a2", size = 98635, upload-time = "2025-09-22T19:51:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/51/79/76aba16a1689b50528224b182f71097ece338e7a4ab55e84c2e73443b78a/ruamel.yaml.clib-0.2.14-cp313-cp313-win_amd64.whl", hash = "sha256:090782b5fb9d98df96509eecdbcaffd037d47389a89492320280d52f91330d78", size = 115238, upload-time = "2025-09-22T19:51:07.081Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/a59ff65c26aaf21a24eb38df777cb9af5d87ba8fc8107c163c2da9d1e85e/ruamel.yaml.clib-0.2.14-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7df6f6e9d0e33c7b1d435defb185095386c469109de723d514142632a7b9d07f", size = 271441, upload-time = "2025-09-23T14:24:16.498Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fa/3234f913fe9a6525a7b97c6dad1f51e72b917e6872e051a5e2ffd8b16fbb/ruamel.yaml.clib-0.2.14-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:70eda7703b8126f5e52fcf276e6c0f40b0d314674f896fc58c47b0aef2b9ae83", size = 137970, upload-time = "2025-09-22T19:51:09.472Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ec/4edbf17ac2c87fa0845dd366ef8d5852b96eb58fcd65fc1ecf5fe27b4641/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a0cb71ccc6ef9ce36eecb6272c81afdc2f565950cdcec33ae8e6cd8f7fc86f27", size = 739639, upload-time = "2025-09-22T19:51:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/15/18/b0e1fafe59051de9e79cdd431863b03593ecfa8341c110affad7c8121efc/ruamel.yaml.clib-0.2.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7cb9ad1d525d40f7d87b6df7c0ff916a66bc52cb61b66ac1b2a16d0c1b07640", size = 764456, upload-time = "2025-09-22T19:51:11.736Z" }, +] + [[package]] name = "ruff" version = "0.13.2" @@ -1012,6 +1388,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1021,6 +1406,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + [[package]] name = "ta-assignment" version = "0.1.0" @@ -1033,6 +1440,7 @@ dev = [ { name = "mkdocs-material" }, { name = "mkdocstrings-python" }, { name = "mypy" }, + { name = "openapi-python-client" }, { name = "pymdown-extensions" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1047,6 +1455,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.6.15" }, { name = "mkdocstrings-python", marker = "extra == 'dev'", specifier = ">=1.16.12" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.17.0" }, + { name = "openapi-python-client", marker = "extra == 'dev'", specifier = ">=0.26.2" }, { name = "pymdown-extensions", marker = "extra == 'dev'", specifier = ">=10.16.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.2.1" }, @@ -1094,6 +1503,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "typer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" }, +] + [[package]] name = "types-httplib2" version = "0.31.0.20250913" @@ -1124,6 +1548,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "uritemplate" version = "4.2.0" @@ -1142,6 +1578,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "uvicorn" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" From e849131c16b36f296c8d520ee29ed146866ef0f0 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 15 Oct 2025 04:08:57 -0400 Subject: [PATCH 03/11] fix: redo adapter test --- src/mail_client_adapter/tests/test_adapter.py | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/mail_client_adapter/tests/test_adapter.py b/src/mail_client_adapter/tests/test_adapter.py index 37d001a4..147ce7e7 100644 --- a/src/mail_client_adapter/tests/test_adapter.py +++ b/src/mail_client_adapter/tests/test_adapter.py @@ -1,17 +1,19 @@ """Tests for the MailClientAdapter.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from mail_client_adapter.mail_client_adapter import MailClientAdapter -@patch("requests.get") -def test_get_messages_remote(mock_get: MagicMock | AsyncMock) -> None: +@patch("httpx.Client.request") +def test_get_messages_remote(mock_request: MagicMock) -> None: """Test fetching a message via the remote service.""" - mock_get.return_value.status_code = 200 - mock_get.return_value.json.return_value = [ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ {"id": "1", "from": "a", "to": "b", "date": "2023", "subject": "Test"}, ] + mock_request.return_value = mock_response client = MailClientAdapter(base_url="http://localhost:8000") messages = list(client.get_messages(max_results=1)) assert len(messages) == 1 @@ -21,28 +23,36 @@ def test_get_messages_remote(mock_get: MagicMock | AsyncMock) -> None: assert messages[0].date == "2023" assert messages[0].subject == "Test" -@patch("requests.get") -def test_get_message_remote(mock_get: MagicMock | AsyncMock) -> None: +@patch("httpx.Client.request") +def test_get_message_remote(mock_request: MagicMock) -> None: """Test fetching a single message via the remote service.""" - mock_get.return_value.status_code = 200 - mock_get.return_value.json.return_value = { + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { "id": "1", "from": "a", "to": "b", "date": "2023", "subject": "Test", "body": "Body", } + mock_request.return_value = mock_response client = MailClientAdapter(base_url="http://localhost:8000") msg = client.get_message("1") assert msg.id == "1" assert msg.body == "Body" -@patch("requests.delete") -def test_delete_message_remote(mock_delete: MagicMock | AsyncMock) -> None: +@patch("httpx.Client.request") +def test_delete_message_remote(mock_request: MagicMock) -> None: """Test deleting a message via the remote service.""" - mock_delete.return_value.status_code = 200 + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_request.return_value = mock_response client = MailClientAdapter(base_url="http://localhost:8000") assert client.delete_message("1") is True -@patch("requests.post") -def test_mark_as_read_remote(mock_post: MagicMock | AsyncMock) -> None: +@patch("httpx.Client.request") +def test_mark_as_read_remote(mock_request: MagicMock) -> None: """Test marking a message as read via the remote service.""" - mock_post.return_value.status_code = 200 + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_request.return_value = mock_response client = MailClientAdapter(base_url="http://localhost:8000") assert client.mark_as_read("1") is True From fa584373b474663913dceb2f3fcdcc06a961be5a Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 15 Oct 2025 04:23:27 -0400 Subject: [PATCH 04/11] fix: satisfy mypy --- .../src/mail_client_adapter/mail_client_adapter.py | 3 +++ .../src/mail_client_service_client/client.py | 1 + src/mail_client_service/src/mail_client_service/main.py | 2 +- src/mail_client_service/src/mail_client_service/test_client.py | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py b/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py index b8a6b10a..08d07bb9 100644 --- a/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py +++ b/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py @@ -34,6 +34,9 @@ def __init__(self, base_url: str = "http://localhost:8000") -> None: def get_message(self, message_id: str) -> Message: """Fetch a message by ID from the remote service using OpenAPI client.""" result = get_message_sync(message_id=message_id, client=self.client) + if result is None: + msg = "Failed to fetch message" + raise ValueError(msg) if hasattr(result, "additional_properties"): return ServiceMessage(result.additional_properties) if isinstance(result, dict): diff --git a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/client.py b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/client.py index a1147c06..5c70d0c4 100644 --- a/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/client.py +++ b/src/mail_client_service/mail_client_service_client/src/mail_client_service_client/client.py @@ -1,3 +1,4 @@ +# mypy: disable_error_code="arg-type" import ssl from typing import Any diff --git a/src/mail_client_service/src/mail_client_service/main.py b/src/mail_client_service/src/mail_client_service/main.py index dc35f6d6..3f8d6e90 100644 --- a/src/mail_client_service/src/mail_client_service/main.py +++ b/src/mail_client_service/src/mail_client_service/main.py @@ -9,7 +9,7 @@ from . import test_client as gmail_client_impl test_client.register() else: - import gmail_client_impl # noqa: F401 + import gmail_client_impl # type: ignore[no-redef] # noqa: F401 from fastapi import Depends, FastAPI, HTTPException from mail_client_api import get_client from mail_client_api.client import Client diff --git a/src/mail_client_service/src/mail_client_service/test_client.py b/src/mail_client_service/src/mail_client_service/test_client.py index fb725587..b4ca8b69 100644 --- a/src/mail_client_service/src/mail_client_service/test_client.py +++ b/src/mail_client_service/src/mail_client_service/test_client.py @@ -88,7 +88,7 @@ def get_messages(self, max_results: int = 10) -> Iterator[Message]: return iter(messages[:max_results]) _singleton_client = TestClient() -def get_client_impl() -> mail_client_api.Client: +def get_client_impl(*, interactive: bool = False) -> mail_client_api.Client: """Return a singleton :class:`TestClient` instance.""" return _singleton_client From b091eefd7e1f5b07fbed34795c16216be5811f7e Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Sun, 19 Oct 2025 13:05:35 -0400 Subject: [PATCH 05/11] fix: let adapter set environment variable outside --- tests/integration/test_adapter_e2e.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_adapter_e2e.py b/tests/integration/test_adapter_e2e.py index af7de9aa..6801e3d4 100644 --- a/tests/integration/test_adapter_e2e.py +++ b/tests/integration/test_adapter_e2e.py @@ -22,8 +22,9 @@ SERVICE_PORT = 8001 # Use different port than development SERVICE_URL = f"http://{SERVICE_HOST}:{SERVICE_PORT}" +os.environ["MOCK_CLIENT"] = "1" + def _run_service() -> None: - os.environ["MOCK_CLIENT"] = "1" uvicorn.run( "mail_client_service:app", host=SERVICE_HOST, From c8343ca0fb94a559a9881cb3836669851aee4592 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Sun, 19 Oct 2025 13:28:08 -0400 Subject: [PATCH 06/11] fix(test): use subprocess instead to fix circleci --- .../src/mail_client_service/main.py | 4 +- tests/integration/test_adapter_e2e.py | 90 ++++++++++++------- 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/mail_client_service/src/mail_client_service/main.py b/src/mail_client_service/src/mail_client_service/main.py index 3f8d6e90..de58cdde 100644 --- a/src/mail_client_service/src/mail_client_service/main.py +++ b/src/mail_client_service/src/mail_client_service/main.py @@ -3,10 +3,8 @@ import os from typing import Annotated -from mail_client_service import test_client - if os.environ.get("MOCK_CLIENT") == "1": - from . import test_client as gmail_client_impl + from . import test_client test_client.register() else: import gmail_client_impl # type: ignore[no-redef] # noqa: F401 diff --git a/tests/integration/test_adapter_e2e.py b/tests/integration/test_adapter_e2e.py index 6801e3d4..1e5e38fd 100644 --- a/tests/integration/test_adapter_e2e.py +++ b/tests/integration/test_adapter_e2e.py @@ -4,53 +4,83 @@ They use a mock Gmail client but test the real HTTP communication layer. """ -import multiprocessing import os +import subprocess +import time from collections.abc import Generator -from multiprocessing.context import Process +from http import HTTPStatus +from subprocess import Popen from typing import Any import pytest -import uvicorn +import requests from mail_client_adapter import MailClientAdapter -pytestmark = pytest.mark.integration - -# Constants SERVICE_HOST = "127.0.0.1" SERVICE_PORT = 8001 # Use different port than development SERVICE_URL = f"http://{SERVICE_HOST}:{SERVICE_PORT}" - -os.environ["MOCK_CLIENT"] = "1" - -def _run_service() -> None: - uvicorn.run( - "mail_client_service:app", - host=SERVICE_HOST, - port=SERVICE_PORT, - log_level="error", - ) +UVICORN_CMD = [ + "uvicorn", + "mail_client_service:app", + "--host", + SERVICE_HOST, + "--port", + str(SERVICE_PORT), + "--log-level", + "error", +] @pytest.fixture(scope="session") -def service_process() -> Generator[Process, Any, None]: - """Fixture to start and stop the mail client service for testing.""" - # Start service in a separate process - process = multiprocessing.Process(target=_run_service) - process.start() - - # Wait for service to start - import time - time.sleep(2) +def service_process() -> Generator[Popen[str], Any, None]: + """Start uvicorn as a subprocess with MOCK_CLIENT=1 and tear it down afterwards.""" + env = os.environ.copy() + env["MOCK_CLIENT"] = "1" + + # Launch uvicorn as a separate process, passing env explicitly. + proc = subprocess.Popen( # noqa: S603 + UVICORN_CMD, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) - yield process + # Wait for it to become reachable (timeout if not ready). + timeout = 15.0 + poll_interval = 0.2 + deadline = time.time() + timeout + while time.time() < deadline: + # Quick health check by connecting to the port + try: + resp = requests.get(SERVICE_URL, timeout=0.5) + # If you have a specific health endpoint, check that instead. + if resp.status_code < HTTPStatus.INTERNAL_SERVER_ERROR: + break + except Exception: + time.sleep(poll_interval) + else: + # Timed out waiting for the server. Kill and raise with helpful logs. + proc.kill() + msg = "uvicorn failed to start within timeout." + raise RuntimeError( + msg, + ) + + try: + yield proc + finally: + # Terminate the uvicorn process on teardown. + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() - # Cleanup - process.terminate() - process.join() @pytest.fixture -def service_client(service_process: Process) -> MailClientAdapter: +def service_client(service_process: Popen[Any]) -> MailClientAdapter: """Create a service client connected to the test service.""" return MailClientAdapter(base_url=SERVICE_URL) From ab9a4715cfe310c308d9b1c5c4312d270bb1a96f Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Sun, 19 Oct 2025 14:08:31 -0400 Subject: [PATCH 07/11] chore: move gmail-related tests to appropriate unit test location --- .../gmail_client_impl/tests}/test_gmail_client_circleci.py | 0 .../gmail_client_impl/tests}/test_gmail_message_circleci.py | 0 .../gmail_client_impl/tests}/test_manual_env_loader_circleci.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {tests/unit => src/gmail_client_impl/tests}/test_gmail_client_circleci.py (100%) rename {tests/unit => src/gmail_client_impl/tests}/test_gmail_message_circleci.py (100%) rename {tests/unit => src/gmail_client_impl/tests}/test_manual_env_loader_circleci.py (100%) diff --git a/tests/unit/test_gmail_client_circleci.py b/src/gmail_client_impl/tests/test_gmail_client_circleci.py similarity index 100% rename from tests/unit/test_gmail_client_circleci.py rename to src/gmail_client_impl/tests/test_gmail_client_circleci.py diff --git a/tests/unit/test_gmail_message_circleci.py b/src/gmail_client_impl/tests/test_gmail_message_circleci.py similarity index 100% rename from tests/unit/test_gmail_message_circleci.py rename to src/gmail_client_impl/tests/test_gmail_message_circleci.py diff --git a/tests/unit/test_manual_env_loader_circleci.py b/src/gmail_client_impl/tests/test_manual_env_loader_circleci.py similarity index 100% rename from tests/unit/test_manual_env_loader_circleci.py rename to src/gmail_client_impl/tests/test_manual_env_loader_circleci.py From eb52a293346247e2fb7a53eccbdc73bd42994e88 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Sun, 19 Oct 2025 14:15:13 -0400 Subject: [PATCH 08/11] chore: mkdocs --- README.md | 11 ++++++++--- docs/api/gmail_client_impl.md | 2 -- docs/api/mail_client_adapter.md | 8 ++++++++ docs/api/mail_client_service.md | 3 +++ mkdocs.yml | 4 ++-- 5 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 docs/api/mail_client_adapter.md create mode 100644 docs/api/mail_client_service.md diff --git a/README.md b/README.md index 0da0eb13..bf80be96 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,11 @@ This project is built on the principle of "programming integrated over time." Th The project is a `uv` workspace containing four primary packages: -3. **`mail_client_api`**: Defines the abstract `Client` base class (ABC). This is the contract for what actions a mail client can perform (e.g., `get_messages`). -4. **`gmail_client_impl`**: Provides the `GmailClient` class, a concrete implementation that uses the Google API to perform the actions defined in the `Client` abstraction. +1. **`mail_client_api`**: Defines the abstract `Client` base class (ABC). This is the contract for what actions a mail client can perform (e.g., `get_messages`). +2. **`gmail_client_impl`**: Provides the `GmailClient` class, a concrete implementation that uses the Google API to perform the actions defined in the `Client` abstraction. +3. **`mail_client_service`**: Provides a FastAPI service as well as an accompanying OpenAPI client to interact with a `Client`. +4. **`mail_client_adapter`**: Provides an adapter to interact with the aforemention FastAPI service as a `Client`. + ## Project Structure @@ -30,7 +33,9 @@ The project is a `uv` workspace containing four primary packages: ta-assignment/ ├── src/ # Source packages (uv workspace members) │ ├── mail_client_api/ # Abstract mail client base class (ABC) -│ └── gmail_client_impl/ # Gmail-specific client implementation +│ ├── gmail_client_impl/ # Gmail-specific client implementation +│ ├── mail_client_adapter/ # mail_client_api adapter of FastAPI +│ └── mail_client_service/ # FastAPI and its API client ├── tests/ # Integration and E2E tests │ ├── integration/ # Component integration tests │ └── e2e/ # End-to-end application tests diff --git a/docs/api/gmail_client_impl.md b/docs/api/gmail_client_impl.md index 150167cd..2e4cd146 100644 --- a/docs/api/gmail_client_impl.md +++ b/docs/api/gmail_client_impl.md @@ -2,8 +2,6 @@ `gmail_client_impl` implements the Mail Client API using Google's Gmail SDK. The reference below is produced from the package docstrings. -## Package Overview - ::: gmail_client_impl options: show_root_heading: true diff --git a/docs/api/mail_client_adapter.md b/docs/api/mail_client_adapter.md new file mode 100644 index 00000000..8394c2ff --- /dev/null +++ b/docs/api/mail_client_adapter.md @@ -0,0 +1,8 @@ +# Mail Client Adapter Implementation + +`mail_client_adapter` implements the Mail Client API as an adapter to the REST API client in `mail_client_service`. The reference below is produced from the package docstrings. + +::: mail_client_adapter + options: + show_root_heading: true + show_source: false diff --git a/docs/api/mail_client_service.md b/docs/api/mail_client_service.md new file mode 100644 index 00000000..af6ef199 --- /dev/null +++ b/docs/api/mail_client_service.md @@ -0,0 +1,3 @@ +# Mail Client Adapter Implementation + +`mail_client_service` implements a FastAPI application to expose any `mail_client_api` clients (like `gmail_client_impl`) as a REST API. It also includes a REST API client to interact with it, which is in turn used by `mail_client_adapter`. The package will import the active `gmail_client_impl` if exists. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 2785d921..a8cea5b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,10 +22,10 @@ nav: - 'Overview': 'index.md' - 'Architecture': 'component.md' - 'API Reference': - - 'Message Protocol': 'api/message.md' - 'Mail Client API': 'api/mail_client_api.md' - 'Gmail Client Implementation': 'api/gmail_client_impl.md' - - 'Gmail Message Implementation': 'api/gmail_message_impl.md' + - 'Mail Client Adapter': 'api/mail_client_adapter.md' + - 'Mail Client Service': 'api/mail_client_service.md' markdown_extensions: - pymdownx.highlight: From c83ce944b59d13abafae4b85e9ce65a9e282ffff Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Sun, 19 Oct 2025 14:39:20 -0400 Subject: [PATCH 09/11] chore: lint, remove test conflict --- .../tests/test_gmail_client_circleci.py | 3 +- .../tests/test_gmail_message_circleci.py | 1 + .../tests/test_manual_env_loader_circleci.py | 65 ------------------- 3 files changed, 3 insertions(+), 66 deletions(-) delete mode 100644 src/gmail_client_impl/tests/test_manual_env_loader_circleci.py diff --git a/src/gmail_client_impl/tests/test_gmail_client_circleci.py b/src/gmail_client_impl/tests/test_gmail_client_circleci.py index fe03f51c..1be7c092 100644 --- a/src/gmail_client_impl/tests/test_gmail_client_circleci.py +++ b/src/gmail_client_impl/tests/test_gmail_client_circleci.py @@ -9,9 +9,10 @@ from unittest.mock import Mock, patch import pytest -from gmail_client_impl.gmail_impl import GmailClient from googleapiclient.errors import HttpError +from gmail_client_impl.gmail_impl import GmailClient + pytestmark = pytest.mark.circleci diff --git a/src/gmail_client_impl/tests/test_gmail_message_circleci.py b/src/gmail_client_impl/tests/test_gmail_message_circleci.py index ada0a696..1f7bc479 100644 --- a/src/gmail_client_impl/tests/test_gmail_message_circleci.py +++ b/src/gmail_client_impl/tests/test_gmail_message_circleci.py @@ -8,6 +8,7 @@ from email.message import EmailMessage import pytest + from gmail_client_impl.message_impl import GmailMessage pytestmark = pytest.mark.circleci diff --git a/src/gmail_client_impl/tests/test_manual_env_loader_circleci.py b/src/gmail_client_impl/tests/test_manual_env_loader_circleci.py deleted file mode 100644 index 497c68c6..00000000 --- a/src/gmail_client_impl/tests/test_manual_env_loader_circleci.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Unit tests for manual environment loader fallback in CircleCI context. - -This module verifies that environment variables are loaded from a .env file -when the dotenv import fails, and checks the GmailClient exposure. -""" - -import importlib -import os -import sys -from pathlib import Path -from typing import TYPE_CHECKING - -import pytest - -if TYPE_CHECKING: - from types import ModuleType - -pytestmark = pytest.mark.circleci - - -def test_manual_env_loader_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Test that the manual environment loader fallback loads from a .env file when the dotenv import fails.""" - # Create a temporary package layout to import the module cleanly - pkg_dir = tmp_path / "gmail_client_impl" - src_dir = pkg_dir - src_dir.mkdir(parents=True) - - # Write a minimal __init__ and copy of the target file contents - (src_dir / "__init__.py").write_text("\n") - - # Read original file content - original = (Path.cwd() / "src" / "gmail_client_impl" / "src" / "gmail_client_impl" / "gmail_impl.py").read_text() - # Force the fallback path by making the dotenv import raise ImportError inside the copied file - original = original.replace( - "from dotenv import load_dotenv", - "raise ImportError() # forced fallback", - ) - - # Save as gmail_impl.py within temp package path so import triggers its top-level loader - (src_dir / "gmail_impl.py").write_text(original) - - # Create a .env in current working directory of import to be picked up by manual loader - (tmp_path / ".env").write_text("TEST_ENV_LOADER_KEY=loaded_value\n") - - # Ensure python can import from our temp directory and chdir there - monkeypatch.chdir(tmp_path) - monkeypatch.syspath_prepend(str(tmp_path)) - - # Do not forcefully block python-dotenv; allow either dotenv or fallback to load .env - - # Ensure prior cached modules don't short-circuit our temp import - for name in ["gmail_client_impl.gmail_impl", "gmail_client_impl"]: - if name in sys.modules: - del sys.modules[name] - - # Import the temp module which will execute top-level fallback loader - mod: ModuleType = importlib.import_module("gmail_client_impl.gmail_impl") - - # Verify the env var from .env was loaded into process env - assert os.environ.get("TEST_ENV_LOADER_KEY") == "loaded_value" - - # Sanity: module exposes GmailClient - assert hasattr(mod, "GmailClient") - - From 48bcf92730c33f1d00898b7c5e653f71481405a3 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 22 Oct 2025 23:14:08 -0400 Subject: [PATCH 10/11] feat: add dockerfile --- .dockerignore | 38 ++++++++++++++++++++++++++++++++++++++ Dockerfile | 28 ++++++++++++++++++++++++++++ README.md | 29 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5c1d484f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Ignore Python cache and build artifacts +**/__pycache__/ +*.pyc +*.pyo +*.pyd + +# Ignore virtual environments +.venv/ + + +# Ignore test and coverage files +*.coverage +.coverage +htmlcov/ +tests/ +src/*/tests/ + +# Ignore docs build artifacts +docs/ +dist/ +build/ + +# Ignore hidden folders +**/.*/ + +# Ignore editor/project files +*.swp +*.swo +*.idea/ +*.vscode/ + +# Ignore Markdown and design docs +*.md +*.rst + +# Ignore Dockerfile itself (not needed in container) +Dockerfile +.dockerignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a349b8fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Dockerfile for the FastAPI service using uv + +FROM python:3.11-slim + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# Set work directory +WORKDIR /app + +# Copy all source code and workspace config +COPY . /app + +# Install all dependencies using uv sync (from uv.lock) +RUN uv sync --all-packages + +# Expose port for FastAPI +EXPOSE 8000 + +# Set environment variables (optional) +ENV PYTHONUNBUFFERED=1 + +# Run FastAPI app using uv +CMD ["uv", "run", "uvicorn", "mail_client_service:app", "--host", "0.0.0.0", "--port", "8000"] + +# Healthcheck: expects a 404 response from root +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --spider --server-response http://0.0.0.0:8000 2>&1 | grep '{"detail":"Not Found"}' || exit 1 diff --git a/README.md b/README.md index bf80be96..43d77a08 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,35 @@ uv run mkdocs serve ``` Open your browser to `http://127.0.0.1:8000` to view the site. +## Running with Docker + +To build and run the FastAPI service in a Docker container: + +1. **Get credentials.** See [Running the Application](#running-the-application). + +2. **Build the Docker image:** + + ```sh + docker build -t mail-client-service . + ``` + +3. **Run the Docker container:** + + ```sh + docker run -p 8000:8000 mail-client-service + ``` + +The Dockerfile uses `uv` for dependency management, matching the local workflow. All dependencies are installed using: + + ```sh + uv sync --all-packages + ``` + +The container will start the FastAPI service and expose it on [http://localhost:8000](http://localhost:8000). + +- The Dockerfile runs the FastAPI app defined in `src/mail_client_service/main.py` using `uv`. +- You can modify the Dockerfile or container settings as needed for development or production. + ## Testing Infrastructure The project implements a sophisticated testing strategy designed for both local development and CI/CD environments: From ea30073943d823f3b72a6972eef26991454faa23 Mon Sep 17 00:00:00 2001 From: Austin Huang Date: Wed, 22 Oct 2025 23:27:13 -0400 Subject: [PATCH 11/11] chore: remove duplicate lint/coverage config --- src/gmail_client_impl/pyproject.toml | 18 ------------------ src/mail_client_adapter/pyproject.toml | 18 ------------------ .../mail_client_adapter.py | 2 +- src/mail_client_api/pyproject.toml | 6 ------ .../mail_client_service_client/pyproject.toml | 13 ------------- src/mail_client_service/pyproject.toml | 19 ------------------- 6 files changed, 1 insertion(+), 75 deletions(-) diff --git a/src/gmail_client_impl/pyproject.toml b/src/gmail_client_impl/pyproject.toml index fd3f0ce2..cc3aa17d 100644 --- a/src/gmail_client_impl/pyproject.toml +++ b/src/gmail_client_impl/pyproject.toml @@ -21,19 +21,6 @@ test = [ [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"] @@ -46,12 +33,7 @@ packages = ["src/gmail_client_impl"] "src/gmail_client_impl/py.typed" = "gmail_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] mail-client-api = { workspace = true } diff --git a/src/mail_client_adapter/pyproject.toml b/src/mail_client_adapter/pyproject.toml index 98b1b3a9..30f240d7 100644 --- a/src/mail_client_adapter/pyproject.toml +++ b/src/mail_client_adapter/pyproject.toml @@ -19,32 +19,14 @@ test = [ [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 -exclude_lines = [ - "pragma: no cover", - "raise NotImplementedError", - "if TYPE_CHECKING:", -] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [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] mail-client-api = { workspace = true } mail-client-service-client = { path = "../mail_client_service/mail_client_service_client" } \ No newline at end of file diff --git a/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py b/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py index 08d07bb9..1694b8fd 100644 --- a/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py +++ b/src/mail_client_adapter/src/mail_client_adapter/mail_client_adapter.py @@ -13,7 +13,7 @@ from mail_client_service_client.api.default.get_messages_messages_get import ( sync as get_messages_sync, ) -from mail_client_service_client.api.default.mark_as_read_messages_message_id_mark_as_read_post import ( # noqa: E501 +from mail_client_service_client.api.default.mark_as_read_messages_message_id_mark_as_read_post import ( sync as mark_as_read_sync, ) from mail_client_service_client.client import Client as ServiceClient diff --git a/src/mail_client_api/pyproject.toml b/src/mail_client_api/pyproject.toml index 32e1bc8d..fd0629a1 100644 --- a/src/mail_client_api/pyproject.toml +++ b/src/mail_client_api/pyproject.toml @@ -17,10 +17,4 @@ packages = ["src/mail_client_api"] "src/mail_client_api/py.typed" = "mail_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/mail_client_service/mail_client_service_client/pyproject.toml b/src/mail_client_service/mail_client_service_client/pyproject.toml index 666b5c96..60e47586 100644 --- a/src/mail_client_service/mail_client_service_client/pyproject.toml +++ b/src/mail_client_service/mail_client_service_client/pyproject.toml @@ -14,19 +14,6 @@ dependencies = [ [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 -exclude_lines = [ - "pragma: no cover", - "raise NotImplementedError", - "if TYPE_CHECKING:", -] [build-system] requires = ["hatchling"] diff --git a/src/mail_client_service/pyproject.toml b/src/mail_client_service/pyproject.toml index 12fab91c..68308a2f 100644 --- a/src/mail_client_service/pyproject.toml +++ b/src/mail_client_service/pyproject.toml @@ -21,33 +21,14 @@ test = [ [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 -exclude_lines = [ - "pragma: no cover", - "raise NotImplementedError", - "if TYPE_CHECKING:", -] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.ruff] -line-length = 100 # Default formatting width -target-version = "py311" # Adjust based on actual Python version extend = "../../pyproject.toml" -[tool.ruff.lint] -ignore = [] -typing-extensions = false - [tool.uv.sources] mail-client-api = { workspace = true } gmail-client-impl = { workspace = true }