docs: compress prose/module docstrings, em-dash->hyphen (de-bloat wave 1)

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-03 00:12:22 -04:00
parent d478ed0d4c
commit f1e52ff1ac
8 changed files with 40 additions and 79 deletions
+5 -5
View File
@@ -11,22 +11,22 @@ This reads codes from email; it does not generate them (that is `pyotp`'s job).
`requirements.txt`: `requirements.txt`:
``` ```
aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.8 aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.9
# OAuth token providers (Microsoft / Google) need the extra: # OAuth token providers (Microsoft / Google) need the extra:
aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.8 aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.9
``` ```
Direct: Direct:
```bash ```bash
pip install "aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.8" pip install "aiomail @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.9"
pip install "aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.8" pip install "aiomail[oauth] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiomail.git@v0.1.9"
``` ```
Requires `aioimaplib` and `beautifulsoup4` (pulled transitively). The `oauth` Requires `aioimaplib` and `beautifulsoup4` (pulled transitively). The `oauth`
extra adds `aiohttp` for the refresh-token providers. extra adds `aiohttp` for the refresh-token providers.
Drop the `@v0.1.8` suffix from the line above to install the latest unpinned. Drop the `@v0.1.9` suffix from the line above to install the latest unpinned.
## Password auth ## Password auth
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aiomail" name = "aiomail"
version = "0.1.8" version = "0.1.9"
description = "async IMAP one-time-code retrieval with password/OAuth2 auth and dynamic matching" description = "async IMAP one-time-code retrieval with password/OAuth2 auth and dynamic matching"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+2 -7
View File
@@ -1,9 +1,4 @@
"""aiomail async IMAP one-time-code retrieval. """aiomail - async IMAP one-time-code retrieval, password or OAuth2 auth, dynamic matching. see README."""
reads OTP / login codes out of IMAP mailboxes (accounts you own). supports plain
password and OAuth2 (XOAUTH2) auth, and dynamic sender/subject/code matching via
substrings, regexes, or callables.
"""
from .auth import Auth, OAuth2Auth, PasswordAuth from .auth import Auth, OAuth2Auth, PasswordAuth
from .client import IMAPClient from .client import IMAPClient
from .extract import ( from .extract import (
@@ -31,4 +26,4 @@ __all__ = [
"DEFAULT_FOLDERS", "DEFAULT_FOLDERS",
] ]
__version__ = "0.1.8" __version__ = "0.1.9"
+5 -10
View File
@@ -1,15 +1,11 @@
"""authentication mechanisms for the IMAP client. """authentication mechanisms for the IMAP client: `PasswordAuth` (LOGIN), `OAuth2Auth` (XOAUTH2)."""
`PasswordAuth` (LOGIN) and `OAuth2Auth` (XOAUTH2); credentials always injected.
"""
import base64 import base64
import logging import logging
from typing import Awaitable, Callable, Optional, Protocol, Union, runtime_checkable from typing import Awaitable, Callable, Optional, Protocol, Union, runtime_checkable
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# a token provider is any (optionally async) callable returning a fresh access # see aiomail.oauth for ready-made Microsoft / Google providers
# token string; see aiomail.oauth for ready-made Microsoft / Google providers
TokenProvider = Callable[[], Union[str, Awaitable[str]]] TokenProvider = Callable[[], Union[str, Awaitable[str]]]
@@ -36,7 +32,7 @@ class PasswordAuth:
def _as_str(token) -> str: def _as_str(token) -> str:
"""coerce a token to str (a provider may hand back bytes); both XOAUTH2 entrypoints downstream need str""" """coerce a token to str (a provider may hand back bytes)"""
return token.decode() if isinstance(token, bytes) else token return token.decode() if isinstance(token, bytes) else token
@@ -73,9 +69,8 @@ class OAuth2Auth:
async def authenticate(self, mail) -> None: async def authenticate(self, mail) -> None:
token = await self._resolve_token() token = await self._resolve_token()
# mail.xoauth2(user, token) f-string-interpolates token, so it MUST be str # mail.xoauth2(user, token) f-string-interpolates token, so it MUST be str -
# bytes would interpolate the b'...' repr and corrupt the Bearer value. # bytes would interpolate the b'...' repr and corrupt the Bearer value.
# _resolve_token already guarantees str via _as_str.
xoauth2 = getattr(mail, "xoauth2", None) xoauth2 = getattr(mail, "xoauth2", None)
if xoauth2 is not None: if xoauth2 is not None:
result, data = await xoauth2(self.user, token) result, data = await xoauth2(self.user, token)
+15 -30
View File
@@ -1,9 +1,9 @@
"""async IMAP client wrapping aioimaplib: connect/retry/reconnect/close plus folders/search/fetch/mark-seen. """async IMAP client wrapping aioimaplib: connect/retry/reconnect/close plus folders/search/fetch/mark-seen.
auth is injected. reconnect-on-stale re-selects the prior folder, but sequence-number ids from auth is injected. reconnect-on-stale re-selects the prior folder, but sequence-number ids from
before a reconnect are not valid after (a fresh SELECT can renumber the mailbox) before a reconnect are not valid after (a fresh SELECT can renumber the mailbox) - pass
— pass `use_uid=True` if ids need to survive a reconnect. one instance is not `use_uid=True` if ids need to survive a reconnect. one instance is not safe for concurrent
safe for concurrent callers beyond the internal connect/reconnect lock. callers beyond the internal connect/reconnect lock.
""" """
import asyncio import asyncio
import email import email
@@ -18,7 +18,7 @@ from .auth import Auth
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# IMAP LIST reply: (flags) "<delim>" <name> delim is server-defined (often "/" or # IMAP LIST reply: (flags) "<delim>" <name> - delim is server-defined (often "/" or
# "." or NIL); capture the trailing name regardless, quoted or bare # "." or NIL); capture the trailing name regardless, quoted or bare
_LIST_RE = re.compile(rb'^\([^)]*\)\s+(?:"[^"]*"|NIL)\s+(.+)$') _LIST_RE = re.compile(rb'^\([^)]*\)\s+(?:"[^"]*"|NIL)\s+(.+)$')
@@ -26,9 +26,9 @@ _LIST_RE = re.compile(rb'^\([^)]*\)\s+(?:"[^"]*"|NIL)\s+(.+)$')
def _folder_name(raw: bytes) -> Optional[str]: def _folder_name(raw: bytes) -> Optional[str]:
"""extract the folder name from a LIST reply line, delimiter-agnostic """extract the folder name from a LIST reply line, delimiter-agnostic
returns None on a non-matching line instead of a last-token rsplit fallback, returns None (not a last-token rsplit fallback) on a non-matching line, so the
so the tagged completion line aioimaplib appends to the same response list tagged completion line aioimaplib appends (e.g. `b"LIST completed."`) is dropped
(e.g. `b"LIST completed."`) gets dropped instead of read as a phantom folder. instead of read as a phantom folder.
""" """
match = _LIST_RE.match(raw.strip()) match = _LIST_RE.match(raw.strip())
if not match: if not match:
@@ -39,8 +39,7 @@ def _folder_name(raw: bytes) -> Optional[str]:
class IMAPClient: class IMAPClient:
"""connection-managing IMAP client driven by an injected auth mechanism """connection-managing IMAP client driven by an injected auth mechanism
`use_uid` (UID vs sequence-number addressing) is independent of `use_ssl` `use_uid` (UID vs sequence-number addressing) is independent of `use_ssl` - unrelated concerns.
an earlier draft conflated them (`use_uid = use_ssl`); unrelated concerns.
""" """
def __init__( def __init__(
@@ -73,11 +72,7 @@ class IMAPClient:
await self.close() await self.close()
async def connect(self) -> bool: async def connect(self) -> bool:
"""open a connection and authenticate, retrying with linear backoff """open a connection and authenticate, retrying with linear backoff; serialized by an internal lock"""
serialized by an internal lock: queued callers never tear down each
other's in-progress handshake, and a superseded connection is logged out.
"""
async with self._lock: async with self._lock:
return await self._connect_locked() return await self._connect_locked()
@@ -107,19 +102,14 @@ class IMAPClient:
@staticmethod @staticmethod
async def _discard_mail(mail) -> None: async def _discard_mail(mail) -> None:
"""tear down a half-built IMAP4 without leaking its connect task """tear down a half-built IMAP4 without leaking its fire-and-forget connect task (avoids an
asyncio "Task exception was never retrieved" traceback)"""
aioimaplib schedules `create_connection` as a fire-and-forget task; on a
refused connection it raises and asyncio logs a noisy "Task exception was
never retrieved" traceback unless retrieved here first.
"""
task = getattr(mail, "_client_task", None) task = getattr(mail, "_client_task", None)
if task is not None and not task.done(): if task is not None and not task.done():
task.cancel() task.cancel()
if task is not None: if task is not None:
# shield distinguishes our own task.cancel() from an external cancel of # shield: a bare `await task` would also swallow an external cancel; under
# this coroutine: a bare `await task` swallowed both, resisting # shield, CancelledError here means external only.
# cancellation. under shield, CancelledError here means external only.
try: try:
await asyncio.shield(task) await asyncio.shield(task)
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -148,13 +138,8 @@ class IMAPClient:
self._selected_folder = None self._selected_folder = None
async def ensure_connection(self) -> bool: async def ensure_connection(self) -> bool:
"""return a live, SELECTED-if-applicable connection, reconnecting if the link is stale """return a live, SELECTED-if-applicable connection, reconnecting (and re-selecting the prior
folder) if the link is stale; see module docstring for the sequence-number-vs-use_uid caveat"""
re-selects the previously-selected folder after a reconnect; sequence-number
ids from before the reconnect are NOT valid against the new session (a fresh
SELECT can renumber the mailbox) unless use_uid=True. serialized by the
internal lock so a queued caller rechecks liveness before reconnecting.
"""
async with self._lock: async with self._lock:
if self._mail is not None: if self._mail is not None:
try: try:
+3 -9
View File
@@ -1,9 +1,4 @@
"""code extraction and dynamic matching for email messages, pure logic with no network IO. """code extraction and dynamic matching for email messages, pure logic with no network IO."""
`extract_code` pulls a one-time code out of a message; `as_predicate` turns a
string / compiled regex / callable into a uniform match function for filtering
senders and subjects.
"""
import email.message import email.message
import logging import logging
import re import re
@@ -103,9 +98,8 @@ def extract_code(
) -> Optional[str]: ) -> Optional[str]:
"""extract a one-time code from a message, subject first then body parts """extract a one-time code from a message, subject first then body parts
`patterns` are regexes tried in order (first capturing group wins, else the `patterns` are regexes tried in order (first capturing group wins, else the whole
whole match); if none hit, a standalone digit run whose length is in match); if none hit, a standalone digit run whose length is in `lengths` is returned.
`lengths` is returned. both are parameters so callers tune per provider.
""" """
compiled = _compile(patterns) compiled = _compile(patterns)
length_set = set(lengths) length_set = set(lengths)
+5 -8
View File
@@ -1,8 +1,6 @@
"""optional OAuth2 token providers (refresh-token -> access-token) for `OAuth2Auth`, credentials always caller-supplied. """optional OAuth2 token providers (refresh-token -> access-token) for `OAuth2Auth`, credentials always
caller-supplied. aiohttp is an optional extra; missing it raises a clear error only when a provider is
aiohttp is an optional extra so the core stays light; missing it raises a clear instantiated, not on import."""
error only when a provider is instantiated, not on import.
"""
import asyncio import asyncio
import logging import logging
import time import time
@@ -80,14 +78,13 @@ class _RefreshTokenProvider:
if token: if token:
self._failures = 0 self._failures = 0
return token return token
# log a truncated body (never whole, may carry sensitive # truncated, never whole: the body may carry sensitive material
# material) so a 200-with-no-token isn't a silent drop
log.warning( log.warning(
"token endpoint %s -> 200 with no access_token: %s", "token endpoint %s -> 200 with no access_token: %s",
endpoint, str(body_json)[:200], endpoint, str(body_json)[:200],
) )
else: else:
# truncated only the body may carry sensitive material # truncated only - the body may carry sensitive material
body = (await resp.text())[:200] body = (await resp.text())[:200]
log.warning("token endpoint %s -> %s: %s", endpoint, resp.status, body) log.warning("token endpoint %s -> %s: %s", endpoint, resp.status, body)
except Exception as exc: except Exception as exc:
+4 -9
View File
@@ -1,8 +1,4 @@
"""orchestration: `retrieve_otp` ties the client and extractor together to find the most recent valid OTP. """orchestration: `retrieve_otp` ties the client and extractor together to find the most recent valid OTP."""
sender/subject accept the flexible match specs from `extract`; provider quirks
(folders, age, patterns) live in the arguments, not hardcoded branches.
"""
import asyncio import asyncio
import logging import logging
import time import time
@@ -27,11 +23,10 @@ DEFAULT_FOLDERS: Sequence[str] = ("INBOX", "Junk", "Spam", "Archive", "All Mail"
def _server_query(sender: MatchSpec, subject: MatchSpec, match_field: str = "from") -> str: def _server_query(sender: MatchSpec, subject: MatchSpec, match_field: str = "from") -> str:
"""build a narrowing IMAP query from plain-string specs only, falling back to ALL for regex/callable specs """build a narrowing IMAP query from plain-string specs, falling back to ALL for regex/callable specs
`match_field="to"` searches TO OR FROM (a forwarded code may keep the `match_field="to"` searches TO OR FROM (a forwarded code may keep the original
original From), matching the client-side forwarded-From fallback so the From) so the server query never narrows out a result the client would accept.
server query never narrows out a result the client would have accepted.
""" """
parts: List[str] = [] parts: List[str] = []
if isinstance(sender, str): if isinstance(sender, str):