Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,14 @@ namespaces are reserved so they plug in later as sibling providers + tool mixins
- `providers/soverin/` -- `SoverinMailProvider` (historical name; it is the plain
IMAP/SMTP backend) delegates to `imap-tools` (IMAP) and stdlib SMTP. This is the
only file that knows about IMAP/SMTP.
- `providers/factory.py` -- picks the provider from `SQUIRREL_MAIL_PROVIDER`.
- `providers/factory.py` -- picks the provider from `SQUIRREL_MAIL_PROVIDER` via
`MAIL_PROVIDER_REGISTRY` (one lazily-loaded entry per backend, the same shape
as pan_mail_pro's `PROVIDER_CLIENTS`). `tests/test_provider_contract.py`
guards the seam: registry and config name the same backends, every registered
backend implements the whole `MailProvider` protocol (a structural Protocol
fails at first call, not at import -- the test moves that to `make test`),
and the tool layer contains no reference to a concrete client, which used to
be only a convention below.
- `SoverinImapClient.flag` is the one deliberate exception to principle 3: it
issues its own `UID STORE` instead of calling `imap-tools`' `mb.flag`, because
that helper follows every STORE with an `EXPUNGE` -- which would permanently
Expand Down
60 changes: 42 additions & 18 deletions squirrel_mcp/providers/factory.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,52 @@
"""Provider selection.

Maps ``config.mail_provider`` to a concrete ``MailProvider``. Adding Gmail/Outlook
later is a new branch here plus a new subpackage -- nothing in the tool layer moves.
Maps ``config.mail_provider`` to a concrete ``MailProvider`` through
``MAIL_PROVIDER_REGISTRY`` -- one entry per backend, loaded lazily so only the
selected backend's dependencies are imported. Adding Gmail/Outlook later is a
new entry here plus a new subpackage -- nothing in the tool layer moves.

The registry mirrors ``pan_mail_pro``'s ``PROVIDER_CLIENTS``: the set of names
lives in exactly one place, and ``tests/test_provider_contract.py`` asserts it
agrees with ``config.SUPPORTED_MAIL_PROVIDERS`` and that every registered class
implements the whole ``MailProvider`` protocol.
"""

from __future__ import annotations

from typing import Callable, Dict, Type

from ..config import SUPPORTED_MAIL_PROVIDERS, SquirrelConfig
from .protocol import CalendarProvider, ContactsProvider, MailProvider


def _load_imap() -> Type:
# Imported lazily so the IMAP backend only loads when selected.
from .soverin import SoverinMailProvider

return SoverinMailProvider


# One entry per backend Squirrel can build. "imap" is any IMAP/SMTP mailbox --
# you supply the host names yourself; there are no per-provider presets. A
# future API-based backend ("gmail", "outlook") registers its loader here.
MAIL_PROVIDER_REGISTRY: Dict[str, Callable[[], Type]] = {
"imap": _load_imap,
}


def create_mail_provider(config: SquirrelConfig) -> MailProvider:
"""Instantiate the mail provider named by ``config.mail_provider``.

Today the only backend is ``imap``: any IMAP/SMTP mailbox, with host/port/
security taken straight from the config. A future API-based backend (Gmail,
Outlook) would branch here to its own ``MailProvider`` implementation.

The provider is returned unconnected; the server calls ``connect()`` during
startup so credential/connection errors surface early.
"""
provider = config.mail_provider

if provider in SUPPORTED_MAIL_PROVIDERS:
# Imported lazily so the IMAP backend only loads when selected.
from .soverin import SoverinMailProvider

return SoverinMailProvider(config)

raise ValueError(
f"Unsupported mail provider: {provider!r}. "
f"Supported: {', '.join(sorted(SUPPORTED_MAIL_PROVIDERS))}."
)
loader = MAIL_PROVIDER_REGISTRY.get(config.mail_provider)
if loader is None:
raise ValueError(
f"Unsupported mail provider: {config.mail_provider!r}. "
f"Supported: {', '.join(sorted(MAIL_PROVIDER_REGISTRY))}."
)
return loader()(config)


def create_calendar_provider(config: SquirrelConfig) -> CalendarProvider:
Expand All @@ -46,3 +61,12 @@ def create_contacts_provider(config: SquirrelConfig) -> ContactsProvider:
from .soverin import SoverinContactsProvider

return SoverinContactsProvider(config)


__all__ = [
"MAIL_PROVIDER_REGISTRY",
"SUPPORTED_MAIL_PROVIDERS",
"create_mail_provider",
"create_calendar_provider",
"create_contacts_provider",
]
81 changes: 81 additions & 0 deletions tests/test_provider_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Guards the provider seam itself, the way pan_mail_pro guards its
``mail.provider.client``: the registry and the config agree on which backends
exist, every registered backend implements the whole ``MailProvider`` protocol,
and the tool layer never touches a concrete client.

The protocol is structural (``typing.Protocol``), so nothing fails at import
time when a backend misses a method -- it fails at the first call, at runtime,
in a user's session. These tests move that failure to ``make test``.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

from squirrel_mcp.config import SUPPORTED_MAIL_PROVIDERS, SquirrelConfig
from squirrel_mcp.providers.factory import (
MAIL_PROVIDER_REGISTRY,
create_mail_provider,
)
from squirrel_mcp.providers.protocol import MailProvider

TOOLS_DIR = Path(__file__).resolve().parent.parent / "squirrel_mcp" / "tools"

# The tool layer may only see providers.protocol. Any of these names appearing
# under tools/ means a concrete transport leaked past the seam.
FORBIDDEN_IN_TOOLS = ("soverin", "imap_tools", "imaplib", "smtplib", "caldav")


def _protocol_members(protocol: type) -> list:
return [name for name in dir(protocol) if not name.startswith("_")]


class TestProviderRegistry:
def test_registry_and_config_name_the_same_backends(self):
"""`config` validates the name, `factory` builds from it. Two lists,
one meaning -- so they must be mechanically held equal."""
assert set(MAIL_PROVIDER_REGISTRY) == set(SUPPORTED_MAIL_PROVIDERS)

def test_every_registered_backend_implements_the_whole_protocol(self):
members = _protocol_members(MailProvider)
assert members, "MailProvider protocol reports no public members"
for name, loader in MAIL_PROVIDER_REGISTRY.items():
cls = loader()
missing = [m for m in members if not hasattr(cls, m)]
assert not missing, (
f"Backend {name!r} ({cls.__name__}) misses protocol "
f"members: {missing}"
)

def test_unknown_provider_fails_naming_the_supported_set(self):
config = SquirrelConfig(mail_provider="carrier-pigeon", skip_validation=True)
with pytest.raises(ValueError, match="carrier-pigeon"):
create_mail_provider(config)


class TestToolLayerBoundary:
"""Tools never import a concrete client -- previously a convention in
CLAUDE.md, now a test, for the reason pan_mail_pro greps its boundary in
CI: a convention nobody can check is a convention that is already broken
somewhere."""

def test_no_concrete_backend_reference_under_tools(self):
pattern = re.compile("|".join(FORBIDDEN_IN_TOOLS))
offenders = []
for path in sorted(TOOLS_DIR.rglob("*.py")):
for lineno, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
):
if pattern.search(line):
offenders.append(f"{path.name}:{lineno}: {line.strip()}")
assert not offenders, (
"The tool layer references a concrete backend; it may only "
"import providers.protocol:\n" + "\n".join(offenders)
)

def test_tools_dir_exists_and_is_scanned(self):
assert TOOLS_DIR.is_dir()
assert list(TOOLS_DIR.rglob("*.py")), "boundary scan found no files"
Loading