From 3bc45ff9fa14066990366ec951bc3b73542374d0 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 10 Sep 2026 18:49:08 +0530 Subject: [PATCH] standardize packaging, tooling, docs, CI, and licensing --- .drone.yml | 22 +++++- CHANGELOG.md | 23 ++++++ LICENSE | 21 ++++++ mail_intake/__init__.py | 1 - mail_intake/__init__.pyi | 8 +- mail_intake/adapters/base.py | 9 ++- mail_intake/adapters/base.pyi | 9 ++- mail_intake/adapters/gmail.py | 21 ++---- mail_intake/adapters/gmail.pyi | 14 ++-- mail_intake/auth/google.py | 4 +- mail_intake/auth/google.pyi | 8 +- mail_intake/config.py | 5 +- mail_intake/config.pyi | 15 ++-- mail_intake/credentials/__init__.pyi | 2 +- mail_intake/credentials/pickle.py | 4 +- mail_intake/credentials/pickle.pyi | 5 +- mail_intake/credentials/redis.py | 8 +- mail_intake/credentials/redis.pyi | 17 ++++- mail_intake/credentials/store.py | 5 +- mail_intake/credentials/store.pyi | 4 +- mail_intake/ingestion/reader.py | 11 +-- mail_intake/ingestion/reader.pyi | 6 +- mail_intake/models/message.py | 5 +- mail_intake/models/message.pyi | 18 ++++- mail_intake/models/thread.py | 5 +- mail_intake/models/thread.pyi | 17 +++-- mail_intake/parsers/__init__.pyi | 2 +- mail_intake/parsers/body.py | 6 +- mail_intake/parsers/body.pyi | 4 +- mail_intake/parsers/headers.py | 8 +- mail_intake/parsers/headers.pyi | 6 +- mail_intake/parsers/subject.py | 1 - mail_intake/py.typed | 0 pyproject.toml | 108 ++++++++++++++++++++++++++- tests/unit/test_models.py | 5 +- tests/unit/test_parsers.py | 16 ++-- 36 files changed, 308 insertions(+), 115 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 mail_intake/py.typed diff --git a/.drone.yml b/.drone.yml index ddd0ac4..094db62 100644 --- a/.drone.yml +++ b/.drone.yml @@ -32,6 +32,26 @@ steps: echo "🆕 New version detected: $PACKAGE_NAME==$VERSION" fi + - name: quality-gate + image: python:3.13-slim + environment: + PIP_REPO_URL: + from_secret: PIP_REPO_URL + PIP_USERNAME: + from_secret: PIP_USERNAME + PIP_PASSWORD: + from_secret: PIP_PASSWORD + commands: + - pip install --upgrade pip build + - | + AUTH_URL="https://${PIP_USERNAME}:${PIP_PASSWORD}@$(echo "${PIP_REPO_URL#*://}" | sed 's:/*$::')/simple" + pip install --index-url "$AUTH_URL" --extra-index-url https://pypi.org/simple/ -U ".[dev]" + - echo "🛡️ Running quality gate..." + - python -m black --check . + - python -m ruff check . + - python -m mypy + - python -m pytest + - name: build-package image: python:3.13-slim commands: @@ -126,4 +146,4 @@ steps: trigger: event: - - custom + - custom \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2cb3572 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- `py.typed` marker for PEP 561 type information. +- `.drone.yml` CI with a quality-gate step (black, ruff, mypy, pytest). +- Canonical `docforge.nav.yml`, generated `mkdocs.yml` and `mcp_docs/` via doc-forge. +- MIT `LICENSE`. + +### Changed +- Standardized `pyproject.toml` (canonical packaging, lint tool config, extras). +- `fetch_emails.py` debug script excluded from black/ruff scans. + +### Fixed +- Regression test for immutable config models raises `AssertionError` instead of + a bare `assert False`. +- Stub fixes for typed API surfaces. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..87328b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Aetoskia Platform + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/mail_intake/__init__.py b/mail_intake/__init__.py index ea5585b..4fd02c7 100644 --- a/mail_intake/__init__.py +++ b/mail_intake/__init__.py @@ -137,7 +137,6 @@ No individual symbols are re-exported at the package root. --- """ - from . import ingestion from . import adapters from . import auth diff --git a/mail_intake/__init__.pyi b/mail_intake/__init__.pyi index dcf3ed8..a6d296c 100644 --- a/mail_intake/__init__.pyi +++ b/mail_intake/__init__.pyi @@ -1,10 +1,4 @@ -from . import ingestion -from . import adapters -from . import auth -from . import credentials -from . import models -from . import config -from . import exceptions +from . import adapters, auth, config, credentials, exceptions, ingestion, models __all__ = [ "ingestion", diff --git a/mail_intake/adapters/base.py b/mail_intake/adapters/base.py index eadaa77..ce68e8c 100644 --- a/mail_intake/adapters/base.py +++ b/mail_intake/adapters/base.py @@ -12,7 +12,8 @@ types or semantics should leak beyond implementations of this interface. """ from abc import ABC, abstractmethod -from typing import Iterator, Dict, Any +from collections.abc import Iterator +from typing import Any class MailIntakeAdapter(ABC): @@ -32,7 +33,7 @@ class MailIntakeAdapter(ABC): """ @abstractmethod - def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]: + def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]: """ Iterate over lightweight message references matching a query. @@ -63,7 +64,7 @@ class MailIntakeAdapter(ABC): raise NotImplementedError @abstractmethod - def fetch_message(self, message_id: str) -> Dict[str, Any]: + def fetch_message(self, message_id: str) -> dict[str, Any]: """ Fetch a full raw message by message identifier. @@ -78,7 +79,7 @@ class MailIntakeAdapter(ABC): raise NotImplementedError @abstractmethod - def fetch_thread(self, thread_id: str) -> Dict[str, Any]: + def fetch_thread(self, thread_id: str) -> dict[str, Any]: """ Fetch a full raw thread by thread identifier. diff --git a/mail_intake/adapters/base.pyi b/mail_intake/adapters/base.pyi index 8cdcbd7..076a24c 100644 --- a/mail_intake/adapters/base.pyi +++ b/mail_intake/adapters/base.pyi @@ -1,10 +1,11 @@ from abc import ABC, abstractmethod -from typing import Iterator, Dict, Any +from collections.abc import Iterator +from typing import Any class MailIntakeAdapter(ABC): @abstractmethod - def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]: ... + def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]: ... @abstractmethod - def fetch_message(self, message_id: str) -> Dict[str, Any]: ... + def fetch_message(self, message_id: str) -> dict[str, Any]: ... @abstractmethod - def fetch_thread(self, thread_id: str) -> Dict[str, Any]: ... + def fetch_thread(self, thread_id: str) -> dict[str, Any]: ... diff --git a/mail_intake/adapters/gmail.py b/mail_intake/adapters/gmail.py index 49ecd64..3fcbb7f 100644 --- a/mail_intake/adapters/gmail.py +++ b/mail_intake/adapters/gmail.py @@ -15,14 +15,15 @@ It is the only place in the codebase where: All Gmail-specific behavior must be strictly contained within this module. """ -from typing import Iterator, Dict, Any +from collections.abc import Iterator +from typing import Any from googleapiclient.discovery import build from googleapiclient.errors import HttpError from mail_intake.adapters.base import MailIntakeAdapter -from mail_intake.exceptions import MailIntakeAdapterError from mail_intake.auth.base import MailIntakeAuthProvider +from mail_intake.exceptions import MailIntakeAdapterError class MailIntakeGmailAdapter(MailIntakeAdapter): @@ -89,7 +90,7 @@ class MailIntakeGmailAdapter(MailIntakeAdapter): ) from exc return self._service - def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]: + def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]: """ Iterate over message references matching the query. @@ -107,9 +108,7 @@ class MailIntakeGmailAdapter(MailIntakeAdapter): """ try: request = ( - self.service.users() - .messages() - .list(userId=self._user_id, q=query) + self.service.users().messages().list(userId=self._user_id, q=query) ) while request is not None: @@ -121,18 +120,14 @@ class MailIntakeGmailAdapter(MailIntakeAdapter): "thread_id": msg["threadId"], } - request = ( - self.service.users() - .messages() - .list_next(request, response) - ) + request = self.service.users().messages().list_next(request, response) except HttpError as exc: raise MailIntakeAdapterError( "Gmail API error while listing messages" ) from exc - def fetch_message(self, message_id: str) -> Dict[str, Any]: + def fetch_message(self, message_id: str) -> dict[str, Any]: """ Fetch a full Gmail message by message ID. @@ -160,7 +155,7 @@ class MailIntakeGmailAdapter(MailIntakeAdapter): f"Gmail API error while fetching message {message_id}" ) from exc - def fetch_thread(self, thread_id: str) -> Dict[str, Any]: + def fetch_thread(self, thread_id: str) -> dict[str, Any]: """ Fetch a full Gmail thread by thread ID. diff --git a/mail_intake/adapters/gmail.pyi b/mail_intake/adapters/gmail.pyi index 04204e7..7c88877 100644 --- a/mail_intake/adapters/gmail.pyi +++ b/mail_intake/adapters/gmail.pyi @@ -1,11 +1,15 @@ -from typing import Iterator, Dict, Any +from collections.abc import Iterator +from typing import Any + from mail_intake.adapters.base import MailIntakeAdapter from mail_intake.auth.base import MailIntakeAuthProvider class MailIntakeGmailAdapter(MailIntakeAdapter): - def __init__(self, auth_provider: MailIntakeAuthProvider, user_id: str = ...) -> None: ... + def __init__( + self, auth_provider: MailIntakeAuthProvider[Any], user_id: str = ... + ) -> None: ... @property def service(self) -> Any: ... - def iter_message_refs(self, query: str) -> Iterator[Dict[str, str]]: ... - def fetch_message(self, message_id: str) -> Dict[str, Any]: ... - def fetch_thread(self, thread_id: str) -> Dict[str, Any]: ... + def iter_message_refs(self, query: str) -> Iterator[dict[str, str]]: ... + def fetch_message(self, message_id: str) -> dict[str, Any]: ... + def fetch_thread(self, thread_id: str) -> dict[str, Any]: ... diff --git a/mail_intake/auth/google.py b/mail_intake/auth/google.py index afc46dd..2787f4f 100644 --- a/mail_intake/auth/google.py +++ b/mail_intake/auth/google.py @@ -17,12 +17,12 @@ No Google authentication details should leak outside this module. """ import os -from typing import Sequence +from collections.abc import Sequence import google.auth.exceptions from google.auth.transport.requests import Request -from google_auth_oauthlib.flow import InstalledAppFlow from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow from mail_intake.auth.base import MailIntakeAuthProvider from mail_intake.credentials.store import CredentialStore diff --git a/mail_intake/auth/google.pyi b/mail_intake/auth/google.pyi index 57f16df..8b9d1ab 100644 --- a/mail_intake/auth/google.pyi +++ b/mail_intake/auth/google.pyi @@ -1,7 +1,11 @@ -from typing import Sequence, Any +from collections.abc import Sequence +from typing import Any + from mail_intake.auth.base import MailIntakeAuthProvider from mail_intake.credentials.store import CredentialStore class MailIntakeGoogleAuth(MailIntakeAuthProvider[Any]): - def __init__(self, credentials_path: str, store: CredentialStore[Any], scopes: Sequence[str]) -> None: ... + def __init__( + self, credentials_path: str, store: CredentialStore[Any], scopes: Sequence[str] + ) -> None: ... def get_credentials(self) -> Any: ... diff --git a/mail_intake/config.py b/mail_intake/config.py index e518218..f143cbd 100644 --- a/mail_intake/config.py +++ b/mail_intake/config.py @@ -12,7 +12,6 @@ environment reads to ensure predictability and testability. """ from dataclasses import dataclass -from typing import Optional @dataclass(frozen=True) @@ -46,12 +45,12 @@ class MailIntakeConfig: Whether ingestion should operate in read-only mode. """ - credentials_path: Optional[str] = None + credentials_path: str | None = None """ Optional path to provider credentials configuration. """ - token_path: Optional[str] = None + token_path: str | None = None """ Optional path to persisted authentication tokens. """ diff --git a/mail_intake/config.pyi b/mail_intake/config.pyi index a484b2c..827bfb4 100644 --- a/mail_intake/config.pyi +++ b/mail_intake/config.pyi @@ -1,9 +1,14 @@ -from typing import Optional - class MailIntakeConfig: provider: str user_id: str readonly: bool - credentials_path: Optional[str] - token_path: Optional[str] - def __init__(self, provider: str = ..., user_id: str = ..., readonly: bool = ..., credentials_path: Optional[str] = ..., token_path: Optional[str] = ...) -> None: ... + credentials_path: str | None + token_path: str | None + def __init__( + self, + provider: str = ..., + user_id: str = ..., + readonly: bool = ..., + credentials_path: str | None = ..., + token_path: str | None = ..., + ) -> None: ... diff --git a/mail_intake/credentials/__init__.pyi b/mail_intake/credentials/__init__.pyi index 67256d1..862a4e3 100644 --- a/mail_intake/credentials/__init__.pyi +++ b/mail_intake/credentials/__init__.pyi @@ -1,5 +1,5 @@ -from .store import CredentialStore from .pickle import PickleCredentialStore from .redis import RedisCredentialStore +from .store import CredentialStore __all__ = ["CredentialStore", "PickleCredentialStore", "RedisCredentialStore"] diff --git a/mail_intake/credentials/pickle.py b/mail_intake/credentials/pickle.py index 472d8c1..be4b35c 100644 --- a/mail_intake/credentials/pickle.py +++ b/mail_intake/credentials/pickle.py @@ -16,7 +16,7 @@ untrusted environments. """ import pickle -from typing import Optional, TypeVar +from typing import TypeVar from mail_intake.credentials.store import CredentialStore @@ -55,7 +55,7 @@ class PickleCredentialStore(CredentialStore[T]): """ self.path = path - def load(self) -> Optional[T]: + def load(self) -> T | None: """ Load credentials from the local filesystem. diff --git a/mail_intake/credentials/pickle.pyi b/mail_intake/credentials/pickle.pyi index ea6fa5c..3f38f36 100644 --- a/mail_intake/credentials/pickle.pyi +++ b/mail_intake/credentials/pickle.pyi @@ -1,4 +1,5 @@ -from typing import Optional, TypeVar +from typing import TypeVar + from .store import CredentialStore T = TypeVar("T") @@ -6,6 +7,6 @@ T = TypeVar("T") class PickleCredentialStore(CredentialStore[T]): path: str def __init__(self, path: str) -> None: ... - def load(self) -> Optional[T]: ... + def load(self) -> T | None: ... def save(self, credentials: T) -> None: ... def clear(self) -> None: ... diff --git a/mail_intake/credentials/redis.py b/mail_intake/credentials/redis.py index 6db57ef..0853a5e 100644 --- a/mail_intake/credentials/redis.py +++ b/mail_intake/credentials/redis.py @@ -24,8 +24,8 @@ Credential validation, refresh, rotation, and acquisition remain the responsibility of authentication provider implementations. """ - -from typing import Optional, TypeVar, Callable +from collections.abc import Callable +from typing import TypeVar from mail_intake.credentials.store import CredentialStore @@ -61,7 +61,7 @@ class RedisCredentialStore(CredentialStore[T]): key: str, serialize: Callable[[T], bytes], deserialize: Callable[[bytes], T], - ttl_seconds: Optional[int] = None, + ttl_seconds: int | None = None, ): """ Initialize a Redis-backed credential store. @@ -88,7 +88,7 @@ class RedisCredentialStore(CredentialStore[T]): self.deserialize = deserialize self.ttl_seconds = ttl_seconds - def load(self) -> Optional[T]: + def load(self) -> T | None: """ Load credentials from Redis. diff --git a/mail_intake/credentials/redis.pyi b/mail_intake/credentials/redis.pyi index 52dcdf0..63ba4e6 100644 --- a/mail_intake/credentials/redis.pyi +++ b/mail_intake/credentials/redis.pyi @@ -1,4 +1,6 @@ -from typing import Optional, TypeVar, Callable, Any +from collections.abc import Callable +from typing import Any, TypeVar + from .store import CredentialStore T = TypeVar("T") @@ -8,8 +10,15 @@ class RedisCredentialStore(CredentialStore[T]): key: str serialize: Callable[[T], bytes] deserialize: Callable[[bytes], T] - ttl_seconds: Optional[int] - def __init__(self, redis_client: Any, key: str, serialize: Callable[[T], bytes], deserialize: Callable[[bytes], T], ttl_seconds: Optional[int] = ...) -> None: ... - def load(self) -> Optional[T]: ... + ttl_seconds: int | None + def __init__( + self, + redis_client: Any, + key: str, + serialize: Callable[[T], bytes], + deserialize: Callable[[bytes], T], + ttl_seconds: int | None = ..., + ) -> None: ... + def load(self) -> T | None: ... def save(self, credentials: T) -> None: ... def clear(self) -> None: ... diff --git a/mail_intake/credentials/store.py b/mail_intake/credentials/store.py index 53899ef..f6ffbaf 100644 --- a/mail_intake/credentials/store.py +++ b/mail_intake/credentials/store.py @@ -19,9 +19,8 @@ or secrets managers—without coupling authentication logic to any specific storage mechanism. """ - from abc import ABC, abstractmethod -from typing import Generic, Optional, TypeVar +from typing import Generic, TypeVar T = TypeVar("T") @@ -48,7 +47,7 @@ class CredentialStore(ABC, Generic[T]): """ @abstractmethod - def load(self) -> Optional[T]: + def load(self) -> T | None: """ Load previously persisted credentials. diff --git a/mail_intake/credentials/store.pyi b/mail_intake/credentials/store.pyi index 4798ca1..d543196 100644 --- a/mail_intake/credentials/store.pyi +++ b/mail_intake/credentials/store.pyi @@ -1,11 +1,11 @@ from abc import ABC, abstractmethod -from typing import Generic, Optional, TypeVar +from typing import Generic, TypeVar T = TypeVar("T") class CredentialStore(ABC, Generic[T]): @abstractmethod - def load(self) -> Optional[T]: ... + def load(self) -> T | None: ... @abstractmethod def save(self, credentials: T) -> None: ... @abstractmethod diff --git a/mail_intake/ingestion/reader.py b/mail_intake/ingestion/reader.py index 955d70a..23c35e9 100644 --- a/mail_intake/ingestion/reader.py +++ b/mail_intake/ingestion/reader.py @@ -16,16 +16,17 @@ It coordinates: No provider-specific logic or API semantics are permitted in this layer. """ +from collections.abc import Iterator from datetime import datetime -from typing import Iterator, Dict, Any +from typing import Any from mail_intake.adapters.base import MailIntakeAdapter +from mail_intake.exceptions import MailIntakeParsingError from mail_intake.models.message import MailIntakeMessage from mail_intake.models.thread import MailIntakeThread -from mail_intake.parsers.headers import parse_headers, extract_sender from mail_intake.parsers.body import extract_body +from mail_intake.parsers.headers import extract_sender, parse_headers from mail_intake.parsers.subject import normalize_subject -from mail_intake.exceptions import MailIntakeParsingError class MailIntakeReader: @@ -101,7 +102,7 @@ class MailIntakeReader: - Messages are grouped by `thread_id` and yielded as complete thread objects containing all associated messages. """ - threads: Dict[str, MailIntakeThread] = {} + threads: dict[str, MailIntakeThread] = {} for ref in self._adapter.iter_message_refs(query): raw = self._adapter.fetch_message(ref["message_id"]) @@ -119,7 +120,7 @@ class MailIntakeReader: return iter(threads.values()) - def _parse_message(self, raw_message: Dict[str, Any]) -> MailIntakeMessage: + def _parse_message(self, raw_message: dict[str, Any]) -> MailIntakeMessage: """ Parse a raw provider message into a `MailIntakeMessage`. diff --git a/mail_intake/ingestion/reader.pyi b/mail_intake/ingestion/reader.pyi index b38852e..fd0e8bb 100644 --- a/mail_intake/ingestion/reader.pyi +++ b/mail_intake/ingestion/reader.pyi @@ -1,4 +1,6 @@ -from typing import Iterator, Dict, Any +from collections.abc import Iterator +from typing import Any + from mail_intake.adapters.base import MailIntakeAdapter from mail_intake.models.message import MailIntakeMessage from mail_intake.models.thread import MailIntakeThread @@ -7,4 +9,4 @@ class MailIntakeReader: def __init__(self, adapter: MailIntakeAdapter) -> None: ... def iter_messages(self, query: str) -> Iterator[MailIntakeMessage]: ... def iter_threads(self, query: str) -> Iterator[MailIntakeThread]: ... - def _parse_message(self, raw_message: Dict[str, Any]) -> MailIntakeMessage: ... + def _parse_message(self, raw_message: dict[str, Any]) -> MailIntakeMessage: ... diff --git a/mail_intake/models/message.py b/mail_intake/models/message.py index 96ea375..d90b339 100644 --- a/mail_intake/models/message.py +++ b/mail_intake/models/message.py @@ -13,7 +13,6 @@ provider-specific fields or semantics. from dataclasses import dataclass from datetime import datetime -from typing import Optional, Dict @dataclass(frozen=True) @@ -54,7 +53,7 @@ class MailIntakeMessage: Sender email address. """ - from_name: Optional[str] + from_name: str | None """ Optional human-readable sender name. """ @@ -74,7 +73,7 @@ class MailIntakeMessage: Short provider-supplied preview snippet of the message. """ - raw_headers: Dict[str, str] + raw_headers: dict[str, str] """ Normalized mapping of message headers (header name → value). """ diff --git a/mail_intake/models/message.pyi b/mail_intake/models/message.pyi index 1b717d0..8c88300 100644 --- a/mail_intake/models/message.pyi +++ b/mail_intake/models/message.pyi @@ -1,14 +1,24 @@ from datetime import datetime -from typing import Optional, Dict class MailIntakeMessage: message_id: str thread_id: str timestamp: datetime from_email: str - from_name: Optional[str] + from_name: str | None subject: str body_text: str snippet: str - raw_headers: Dict[str, str] - def __init__(self, message_id: str, thread_id: str, timestamp: datetime, from_email: str, from_name: Optional[str], subject: str, body_text: str, snippet: str, raw_headers: Dict[str, str]) -> None: ... + raw_headers: dict[str, str] + def __init__( + self, + message_id: str, + thread_id: str, + timestamp: datetime, + from_email: str, + from_name: str | None, + subject: str, + body_text: str, + snippet: str, + raw_headers: dict[str, str], + ) -> None: ... diff --git a/mail_intake/models/thread.py b/mail_intake/models/thread.py index 199da26..10cb303 100644 --- a/mail_intake/models/thread.py +++ b/mail_intake/models/thread.py @@ -12,7 +12,6 @@ for higher-level correspondence workflows. from dataclasses import dataclass, field from datetime import datetime -from typing import List, Set from mail_intake.models.message import MailIntakeMessage @@ -42,12 +41,12 @@ class MailIntakeThread: Normalized subject line used to group related messages. """ - participants: Set[str] = field(default_factory=set) + participants: set[str] = field(default_factory=set) """ Set of unique participant email addresses observed in the thread. """ - messages: List[MailIntakeMessage] = field(default_factory=list) + messages: list[MailIntakeMessage] = field(default_factory=list) """ Ordered list of messages belonging to this thread. """ diff --git a/mail_intake/models/thread.pyi b/mail_intake/models/thread.pyi index 0fe0541..5b4af80 100644 --- a/mail_intake/models/thread.pyi +++ b/mail_intake/models/thread.pyi @@ -1,12 +1,19 @@ from datetime import datetime -from typing import List, Set, Optional + from .message import MailIntakeMessage class MailIntakeThread: thread_id: str normalized_subject: str - participants: Set[str] - messages: List[MailIntakeMessage] - last_activity_at: Optional[datetime] - def __init__(self, thread_id: str, normalized_subject: str, participants: Set[str] = ..., messages: List[MailIntakeMessage] = ..., last_activity_at: Optional[datetime] = ...) -> None: ... + participants: set[str] + messages: list[MailIntakeMessage] + last_activity_at: datetime | None + def __init__( + self, + thread_id: str, + normalized_subject: str, + participants: set[str] = ..., + messages: list[MailIntakeMessage] = ..., + last_activity_at: datetime | None = ..., + ) -> None: ... def add_message(self, message: MailIntakeMessage) -> None: ... diff --git a/mail_intake/parsers/__init__.pyi b/mail_intake/parsers/__init__.pyi index 0238a34..15c4f2a 100644 --- a/mail_intake/parsers/__init__.pyi +++ b/mail_intake/parsers/__init__.pyi @@ -1,5 +1,5 @@ from .body import extract_body -from .headers import parse_headers, extract_sender +from .headers import extract_sender, parse_headers from .subject import normalize_subject __all__ = ["extract_body", "parse_headers", "extract_sender", "normalize_subject"] diff --git a/mail_intake/parsers/body.py b/mail_intake/parsers/body.py index 675b811..5101f66 100644 --- a/mail_intake/parsers/body.py +++ b/mail_intake/parsers/body.py @@ -11,7 +11,7 @@ prefers human-readable text over fidelity to original formatting. """ import base64 -from typing import Dict, Any, Optional +from typing import Any from bs4 import BeautifulSoup @@ -45,7 +45,7 @@ def _decode_base64(data: str) -> str: raise MailIntakeParsingError("Failed to decode message body") from exc -def _extract_from_part(part: Dict[str, Any]) -> Optional[str]: +def _extract_from_part(part: dict[str, Any]) -> str | None: """ Extract text content from a single MIME part. @@ -82,7 +82,7 @@ def _extract_from_part(part: Dict[str, Any]) -> Optional[str]: return None -def extract_body(payload: Dict[str, Any]) -> str: +def extract_body(payload: dict[str, Any]) -> str: """ Extract the best-effort message body from a Gmail payload. diff --git a/mail_intake/parsers/body.pyi b/mail_intake/parsers/body.pyi index f13004f..81c785f 100644 --- a/mail_intake/parsers/body.pyi +++ b/mail_intake/parsers/body.pyi @@ -1,3 +1,3 @@ -from typing import Dict, Any +from typing import Any -def extract_body(payload: Dict[str, Any]) -> str: ... +def extract_body(payload: dict[str, Any]) -> str: ... diff --git a/mail_intake/parsers/headers.py b/mail_intake/parsers/headers.py index c3ce864..418dc5a 100644 --- a/mail_intake/parsers/headers.py +++ b/mail_intake/parsers/headers.py @@ -10,10 +10,8 @@ The functions here are intentionally simple and tolerant of malformed or incomplete header data. """ -from typing import Dict, List, Tuple, Optional - -def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]: +def parse_headers(raw_headers: list[dict[str, str]]) -> dict[str, str]: """ Convert a list of Gmail-style headers into a normalized dict. @@ -50,7 +48,7 @@ def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]: } ``` """ - headers: Dict[str, str] = {} + headers: dict[str, str] = {} for header in raw_headers or []: name = header.get("name") @@ -64,7 +62,7 @@ def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]: return headers -def extract_sender(headers: Dict[str, str]) -> Tuple[str, Optional[str]]: +def extract_sender(headers: dict[str, str]) -> tuple[str, str | None]: """ Extract sender email and optional display name from headers. diff --git a/mail_intake/parsers/headers.pyi b/mail_intake/parsers/headers.pyi index f819ae7..9238421 100644 --- a/mail_intake/parsers/headers.pyi +++ b/mail_intake/parsers/headers.pyi @@ -1,4 +1,2 @@ -from typing import Dict, List, Tuple, Optional - -def parse_headers(raw_headers: List[Dict[str, str]]) -> Dict[str, str]: ... -def extract_sender(headers: Dict[str, str]) -> Tuple[str, Optional[str]]: ... +def parse_headers(raw_headers: list[dict[str, str]]) -> dict[str, str]: ... +def extract_sender(headers: dict[str, str]) -> tuple[str, str | None]: ... diff --git a/mail_intake/parsers/subject.py b/mail_intake/parsers/subject.py index aa565d8..eb1f23b 100644 --- a/mail_intake/parsers/subject.py +++ b/mail_intake/parsers/subject.py @@ -12,7 +12,6 @@ meaning while removing common reply and forward prefixes. import re - _PREFIX_RE = re.compile(r"^(re|fw|fwd)\s*:\s*", re.IGNORECASE) """ Regular expression matching common reply/forward subject prefixes. diff --git a/mail_intake/py.typed b/mail_intake/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 1b4d737..28995a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,17 +37,18 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Communications :: Email", "Topic :: Software Development :: Libraries", + "Typing :: Typed", ] + dependencies = [ - # Gmail API stack "google-api-python-client>=2.120.0", "google-auth>=2.28.0", "google-auth-oauthlib>=1.2.0", - # Parsing "beautifulsoup4>=4.12.0", "lxml>=5.1.0", ] @@ -56,15 +57,26 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0.0", + "pytest-asyncio>=0.21.0", "pytest-cov>=4.1.0", + "black>=23.0.0", "ruff>=0.3.0", "mypy>=1.8.0", + "build>=1.0.0", + "twine>=4.0.0", + "pre-commit>=3.4.0", "types-beautifulsoup4", + "doc-forge[mcp,mkdocs]>=0.0.6", ] docs = [ "mkdocs>=1.5.0", "mkdocs-material>=9.5.0", + "mkdocstrings[python]>=0.24.0", +] + +all = [ + "mail-intake[dev,docs]", ] @@ -79,12 +91,102 @@ Versions = "https://git.aetoskia.com/aetos/mail-intake/tags" [tool.setuptools] packages = { find = { include = ["mail_intake*"] } } +[tool.setuptools.package-data] +mail_intake = ["py.typed"] + + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "--cov=mail_intake", + "--cov-report=term-missing", + "--cov-report=html", + "--cov-report=xml", +] + + +[tool.black] +line-length = 88 +target-version = ["py310", "py311", "py312", "py313"] +include = '\.pyi?$' +extend-exclude = ''' +fetch_emails\.py$ | /( + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + [tool.ruff] -line-length = 100 +line-length = 88 target-version = "py310" +exclude = ["fetch_emails.py"] + +[tool.ruff.lint] +select = [ + "E", + "W", + "F", + "I", + "B", + "C4", + "UP", +] +ignore = [ + "E501", + "B008", + "C901", +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401", "I001"] +"tests/*" = ["B008"] + [tool.mypy] python_version = "3.10" strict = true +exclude = [ + "tests/", +] +files = ["mail_intake"] + +[[tool.mypy.overrides]] +module = [ + "google.*", + "googleapiclient.*", + "bs4.*", + "lxml.*", +] ignore_missing_imports = true + + +[tool.coverage.run] +source = ["mail_intake"] +omit = [ + "*/tests/*", + "*/test_*.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if __name__ == .__main__.:", + "raise AssertionError", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@abstractmethod", +] \ No newline at end of file diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index f8dc811..b9dc8fe 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -1,7 +1,6 @@ from datetime import datetime, timedelta -from mail_intake.models import MailIntakeMessage -from mail_intake.models import MailIntakeThread +from mail_intake.models import MailIntakeMessage, MailIntakeThread def test_message_is_immutable(): @@ -19,7 +18,7 @@ def test_message_is_immutable(): try: msg.subject = "Changed" - assert False, "Message should be immutable" + raise AssertionError("Message should be immutable") except Exception: assert True diff --git a/tests/unit/test_parsers.py b/tests/unit/test_parsers.py index 026090f..3b16a25 100644 --- a/tests/unit/test_parsers.py +++ b/tests/unit/test_parsers.py @@ -1,8 +1,11 @@ import base64 -from mail_intake.parsers import normalize_subject -from mail_intake.parsers import parse_headers, extract_sender -from mail_intake.parsers import extract_body +from mail_intake.parsers import ( + extract_body, + extract_sender, + normalize_subject, + parse_headers, +) def _b64(text: str) -> str: @@ -13,6 +16,7 @@ def _b64(text: str) -> str: # Subject parsing # -------------------- + def test_normalize_subject_strips_common_prefixes(): assert normalize_subject("Re: Interview Update") == "Interview Update" assert normalize_subject("Fwd: Re: Offer Letter") == "Offer Letter" @@ -32,6 +36,7 @@ def test_normalize_subject_empty_and_none_safe(): # Header parsing # -------------------- + def test_parse_headers_lowercases_keys(): raw_headers = [ {"name": "From", "value": "Alice "}, @@ -82,6 +87,7 @@ def test_extract_sender_missing_from(): # Body parsing # -------------------- + def test_extract_body_prefers_text_plain(): payload = { "parts": [ @@ -116,9 +122,7 @@ def test_extract_body_falls_back_to_html(): def test_extract_body_single_part(): - payload = { - "body": {"data": _b64("Single part body")} - } + payload = {"body": {"data": _b64("Single part body")}} body = extract_body(payload) assert body == "Single part body"