mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-09-05 21:47:58 +00:00
* Import optional mailbox connections lazily in parsedmarc.mail * Fix docstring grammar Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Cover the lazy-import guards with tests and harden the AuthMethod placeholder Maintainer follow-up to the review of #884: - Add tests/test_mail.py: a sys.meta_path blocker simulates installs without the gmail/msgraph stacks, exercising both except branches. CI always has the extras installed, so without a simulated absence the guarded paths would never execute and the blocking codecov patch gate would fail. Includes isolation-safe purge/restore of sys.modules so test ordering cannot leak placeholder state into other suites. - Replace the AuthMethod = None sentinel with a placeholder that raises the msgraph extra's ImportError on non-dunder attribute access, iteration, call, and subscript (every Enum usage shape), instead of surfacing cryptic NoneType AttributeError/TypeError. - Raise a fresh ImportError chained to the stored original in the connection placeholders, so repeated constructions do not keep appending frames to one shared exception object's traceback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""Tests for parsedmarc.mail"""
|
|
|
|
import importlib
|
|
import importlib.abc
|
|
import importlib.machinery
|
|
import sys
|
|
import unittest
|
|
from collections.abc import Iterator, Sequence
|
|
from contextlib import contextmanager
|
|
from types import ModuleType
|
|
|
|
from parsedmarc import mail
|
|
|
|
# Top-level module roots pulled in by mailsuite's optional mailbox extras.
|
|
# Blocking these makes the corresponding extra look uninstalled.
|
|
GMAIL_ROOTS = ("google", "google_auth_oauthlib", "googleapiclient")
|
|
MSGRAPH_ROOTS = (
|
|
"azure",
|
|
"msgraph",
|
|
"msgraph_core",
|
|
"kiota_authentication_azure",
|
|
"kiota_abstractions",
|
|
)
|
|
|
|
|
|
class _ImportBlocker(importlib.abc.MetaPathFinder):
|
|
"""A meta path finder that makes chosen module roots unimportable.
|
|
|
|
Installed at the head of ``sys.meta_path``, it raises ``ImportError``
|
|
for any module whose top-level root is blocked, which is what an
|
|
install without the optional extra looks like to the import system.
|
|
"""
|
|
|
|
def __init__(self, roots: frozenset[str]) -> None:
|
|
self.roots = roots
|
|
|
|
def find_spec(
|
|
self,
|
|
fullname: str,
|
|
path: Sequence[str] | None = None,
|
|
target: ModuleType | None = None,
|
|
) -> importlib.machinery.ModuleSpec | None:
|
|
if fullname.split(".")[0] in self.roots:
|
|
raise ImportError(f"blocked for test: {fullname}")
|
|
return None
|
|
|
|
|
|
def _is_affected(name: str, roots: frozenset[str]) -> bool:
|
|
"""Is *name* a module the blocked roots or mailsuite own?
|
|
|
|
The whole ``mailsuite`` tree counts: ``mailsuite.mailbox`` caches its
|
|
``gmail``/``graph`` submodules as attributes once loaded, and a stale
|
|
``mailsuite.mailbox.graph`` in ``sys.modules`` would satisfy
|
|
``from mailsuite.mailbox.graph import AuthMethod`` without ever
|
|
reaching the blocked roots.
|
|
"""
|
|
root = name.split(".")[0]
|
|
return root in roots or root == "mailsuite"
|
|
|
|
|
|
@contextmanager
|
|
def blocked_stacks(*roots: str) -> Iterator[ModuleType]:
|
|
"""Reload :mod:`parsedmarc.mail` with *roots* unimportable.
|
|
|
|
Yields the reloaded module. On exit — including when the body raises,
|
|
so a failed assertion cannot leak state into other tests — the finder
|
|
is removed, anything imported under the blocked roots or the mailsuite
|
|
tree while blocked is discarded, the snapshotted modules are put back,
|
|
and :mod:`parsedmarc.mail` is reloaded once more so its attributes are
|
|
the real mailsuite objects again.
|
|
"""
|
|
blocked = frozenset(roots)
|
|
blocker = _ImportBlocker(blocked)
|
|
removed: dict[str, ModuleType] = {}
|
|
for name in list(sys.modules):
|
|
if _is_affected(name, blocked):
|
|
removed[name] = sys.modules.pop(name)
|
|
sys.meta_path.insert(0, blocker)
|
|
try:
|
|
importlib.invalidate_caches()
|
|
importlib.reload(mail)
|
|
yield mail
|
|
finally:
|
|
sys.meta_path.remove(blocker)
|
|
for name in list(sys.modules):
|
|
if _is_affected(name, blocked):
|
|
del sys.modules[name]
|
|
sys.modules.update(removed)
|
|
importlib.invalidate_caches()
|
|
importlib.reload(mail)
|
|
|
|
|
|
class Test(unittest.TestCase):
|
|
"""Guards the lazy optional-import contract of parsedmarc.mail (#884).
|
|
|
|
``parsedmarc.mail`` re-exports mailsuite's Gmail and Microsoft Graph
|
|
connections, which live behind optional extras. Importing the module
|
|
must succeed without those extras; the failure must surface only when
|
|
the missing connection is actually used.
|
|
"""
|
|
|
|
def test_import_succeeds_without_either_optional_stack(self):
|
|
"""parsedmarc.mail imports with both optional stacks absent.
|
|
|
|
This is the ``import parsedmarc`` guarantee for installs that only
|
|
parse reports: no Gmail or Microsoft Graph dependency is needed.
|
|
"""
|
|
with blocked_stacks(*GMAIL_ROOTS, *MSGRAPH_ROOTS) as blocked_mail:
|
|
self.assertIs(blocked_mail, sys.modules["parsedmarc.mail"])
|
|
self.assertIsNotNone(blocked_mail.IMAPConnection)
|
|
self.assertIsNotNone(blocked_mail.GmailConnection)
|
|
self.assertIsNotNone(blocked_mail.MSGraphConnection)
|
|
|
|
def test_placeholder_connections_raise_the_extras_import_error(self):
|
|
"""Constructing a placeholder raises the missing extra's ImportError.
|
|
|
|
The message must keep mailsuite's actionable
|
|
``pip install mailsuite[<extra>]`` text, and each construction must
|
|
raise a *fresh* exception chained to the one stored original, so
|
|
repeated constructions do not keep appending frames to one shared
|
|
exception's traceback. Constructing twice and comparing identities
|
|
discriminates that from re-raising the stored exception object,
|
|
which would also carry an ``ImportError`` cause (mailsuite raises
|
|
its guard error ``from`` the underlying one).
|
|
"""
|
|
with blocked_stacks(*GMAIL_ROOTS, *MSGRAPH_ROOTS) as blocked_mail:
|
|
with self.assertRaises(ImportError) as gmail_ctx:
|
|
blocked_mail.GmailConnection()
|
|
self.assertIn("gmail", str(gmail_ctx.exception))
|
|
self.assertIn("mailsuite[gmail]", str(gmail_ctx.exception))
|
|
self.assertIsInstance(gmail_ctx.exception.__cause__, ImportError)
|
|
with self.assertRaises(ImportError) as gmail_ctx_2:
|
|
blocked_mail.GmailConnection()
|
|
self.assertIsNot(gmail_ctx.exception, gmail_ctx_2.exception)
|
|
self.assertIs(
|
|
gmail_ctx.exception.__cause__, gmail_ctx_2.exception.__cause__
|
|
)
|
|
|
|
with self.assertRaises(ImportError) as graph_ctx:
|
|
blocked_mail.MSGraphConnection()
|
|
self.assertIn("msgraph", str(graph_ctx.exception))
|
|
self.assertIn("mailsuite[msgraph]", str(graph_ctx.exception))
|
|
self.assertIsInstance(graph_ctx.exception.__cause__, ImportError)
|
|
with self.assertRaises(ImportError) as graph_ctx_2:
|
|
blocked_mail.MSGraphConnection()
|
|
self.assertIsNot(graph_ctx.exception, graph_ctx_2.exception)
|
|
self.assertIs(
|
|
graph_ctx.exception.__cause__, graph_ctx_2.exception.__cause__
|
|
)
|
|
|
|
def test_placeholders_do_not_disturb_isinstance_checks(self):
|
|
"""Placeholders stay out of the MailboxConnection type hierarchy.
|
|
|
|
The mailbox-processing code in ``parsedmarc/__init__.py`` branches
|
|
on connection type with ``isinstance``, so a placeholder must never
|
|
answer ``True`` for a real connection instance, nor pass as a
|
|
``MailboxConnection`` subclass. ``object.__new__`` builds an
|
|
``IMAPConnection`` without opening a network connection.
|
|
"""
|
|
with blocked_stacks(*GMAIL_ROOTS, *MSGRAPH_ROOTS) as blocked_mail:
|
|
imap = object.__new__(blocked_mail.IMAPConnection)
|
|
self.assertIsInstance(imap, blocked_mail.MailboxConnection)
|
|
self.assertNotIsInstance(imap, blocked_mail.GmailConnection)
|
|
self.assertNotIsInstance(imap, blocked_mail.MSGraphConnection)
|
|
self.assertFalse(
|
|
issubclass(blocked_mail.GmailConnection, blocked_mail.MailboxConnection)
|
|
)
|
|
self.assertFalse(
|
|
issubclass(
|
|
blocked_mail.MSGraphConnection, blocked_mail.MailboxConnection
|
|
)
|
|
)
|
|
|
|
def test_auth_method_placeholder_raises_on_every_real_use(self):
|
|
"""AuthMethod raises the msgraph ImportError when accessed, iterated,
|
|
called, or subscripted.
|
|
|
|
``parsedmarc.cli`` accesses ``AuthMethod`` members by attribute
|
|
(``AuthMethod.UsernamePassword.name``) and iterates the enum
|
|
(``for method in AuthMethod``); Enum's value lookup
|
|
(``AuthMethod(value)``) is a call and its name lookup
|
|
(``AuthMethod["UsernamePassword"]``) is a subscript, so binding
|
|
``None`` would surface any of these as an unhelpful
|
|
``AttributeError``/``TypeError`` instead of the missing extra.
|
|
Dunder lookups are deliberately exempt so ordinary introspection —
|
|
the ``copy`` module probing ``__deepcopy__``, for instance — still
|
|
gets a plain ``AttributeError``.
|
|
"""
|
|
with blocked_stacks(*GMAIL_ROOTS, *MSGRAPH_ROOTS) as blocked_mail:
|
|
with self.assertRaises(ImportError) as attr_ctx:
|
|
blocked_mail.AuthMethod.UsernamePassword
|
|
self.assertIn("msgraph", str(attr_ctx.exception))
|
|
|
|
with self.assertRaises(ImportError) as iter_ctx:
|
|
iter(blocked_mail.AuthMethod)
|
|
self.assertIn("msgraph", str(iter_ctx.exception))
|
|
|
|
with self.assertRaises(ImportError) as call_ctx:
|
|
blocked_mail.AuthMethod(1)
|
|
self.assertIn("msgraph", str(call_ctx.exception))
|
|
|
|
with self.assertRaises(ImportError) as item_ctx:
|
|
blocked_mail.AuthMethod["UsernamePassword"]
|
|
self.assertIn("msgraph", str(item_ctx.exception))
|
|
|
|
self.assertIsNone(getattr(blocked_mail.AuthMethod, "__deepcopy__", None))
|
|
|
|
def test_the_two_extras_are_guarded_independently(self):
|
|
"""A missing extra disables only its own connection, in either
|
|
direction.
|
|
|
|
Each optional import has its own ``try``/``except``, so with only
|
|
the gmail stack absent the Graph connection and ``AuthMethod`` must
|
|
still be the real mailsuite objects rather than placeholders, and
|
|
with only the msgraph stack absent the Gmail connection must be.
|
|
"""
|
|
with blocked_stacks(*GMAIL_ROOTS) as blocked_mail:
|
|
with self.assertRaises(ImportError) as gmail_ctx:
|
|
blocked_mail.GmailConnection()
|
|
self.assertIn("mailsuite[gmail]", str(gmail_ctx.exception))
|
|
|
|
graph = importlib.import_module("mailsuite.mailbox.graph")
|
|
self.assertIs(blocked_mail.MSGraphConnection, graph.MSGraphConnection)
|
|
self.assertIs(blocked_mail.AuthMethod, graph.AuthMethod)
|
|
|
|
with blocked_stacks(*MSGRAPH_ROOTS) as blocked_mail:
|
|
with self.assertRaises(ImportError) as graph_ctx:
|
|
blocked_mail.MSGraphConnection()
|
|
self.assertIn("mailsuite[msgraph]", str(graph_ctx.exception))
|
|
|
|
gmail = importlib.import_module("mailsuite.mailbox.gmail")
|
|
self.assertIs(blocked_mail.GmailConnection, gmail.GmailConnection)
|
|
|
|
def test_full_dependencies_re_export_the_real_objects(self):
|
|
"""With every extra installed, the re-exports are mailsuite's own.
|
|
|
|
Guards against the ``try``/``except`` shadowing the real classes
|
|
with placeholders when the imports actually succeed.
|
|
"""
|
|
gmail = importlib.import_module("mailsuite.mailbox.gmail")
|
|
graph = importlib.import_module("mailsuite.mailbox.graph")
|
|
self.assertIs(mail.GmailConnection, gmail.GmailConnection)
|
|
self.assertIs(mail.MSGraphConnection, graph.MSGraphConnection)
|
|
self.assertIs(mail.AuthMethod, graph.AuthMethod)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|