standardize packaging, tooling, docs, CI, and licensing

This commit is contained in:
2026-09-10 18:49:08 +05:30
parent c541577788
commit 3bc45ff9fa
36 changed files with 308 additions and 115 deletions

View File

@@ -137,7 +137,6 @@ No individual symbols are re-exported at the package root.
---
"""
from . import ingestion
from . import adapters
from . import auth

View File

@@ -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",

View File

@@ -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.

View File

@@ -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]: ...

View File

@@ -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.

View File

@@ -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]: ...

View File

@@ -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

View File

@@ -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: ...

View File

@@ -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.
"""

View File

@@ -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: ...

View File

@@ -1,5 +1,5 @@
from .store import CredentialStore
from .pickle import PickleCredentialStore
from .redis import RedisCredentialStore
from .store import CredentialStore
__all__ = ["CredentialStore", "PickleCredentialStore", "RedisCredentialStore"]

View File

@@ -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.

View File

@@ -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: ...

View File

@@ -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.

View File

@@ -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: ...

View File

@@ -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.

View File

@@ -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

View File

@@ -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`.

View File

@@ -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: ...

View File

@@ -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).
"""

View File

@@ -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: ...

View File

@@ -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.
"""

View File

@@ -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: ...

View File

@@ -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"]

View File

@@ -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.

View File

@@ -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: ...

View File

@@ -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.

View File

@@ -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]: ...

View File

@@ -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.

0
mail_intake/py.typed Normal file
View File