Centralize configuration handling with ParserConfig (#503) (#851)

* Centralize configuration handling with ParserConfig (#503)

Add parsedmarc/config.py with ParserConfig, a frozen dataclass carrying
every parsing/enrichment option plus the three shared caches (IP address
info, seen aggregate report IDs, reverse DNS map). All eight public
parsing/mailbox functions accept a keyword-only config= argument; when
provided, the individual option keyword arguments are ignored in favor
of the config's values, and every existing per-option keyword argument
keeps working unchanged. The three hand-copied parse_kwargs dicts and
the dns_timeout<->timeout rename chain are gone; the CLI builds one
ParserConfig per run (rebuilt on SIGHUP) and passes it everywhere.

Explicitly constructed configs own fresh isolated caches;
dataclasses.replace() shares them; pickling drops cache contents and
rebinds the unpickling process's module defaults, preserving the
per-worker cache behavior of n_procs parallel parsing. The module
globals IP_ADDRESS_CACHE / SEEN_AGGREGATE_REPORT_IDS / REVERSE_DNS_MAP
remain, identity-preserved, as re-exports of the default caches.

Bug fixes that ride along, each with a regression test:

- One-shot mailbox runs now honor [general] dns_timeout/dns_retries;
  the CLI call site never forwarded dns_timeout, so the library's
  stray 6.0 default silently applied.
- Lazily-triggered reverse DNS map loads (get_ip_address_info /
  get_service_from_reverse_dns_base_domain, including in n_procs
  workers) now thread psl_overrides_path/psl_overrides_url through to
  load_reverse_dns_map instead of clobbering operator-configured PSL
  overrides with the bundled defaults.
- get_dmarc_reports_from_mailbox() and watch_inbox() dns_timeout
  defaults unified to DEFAULT_DNS_TIMEOUT (2.0s) from a stray 6.0, and
  normalize_timespan_threshold_hours to the float 24.0 used everywhere
  else.

Closes #503

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address CI and review feedback on #851

- Wrap the IMAPConnection example in usage.md so ruff format is clean
  over the docs code blocks (CI runs ruff format --check on the whole
  repo; the local runs were scoped to parsedmarc/ and tests/ and
  missed it).
- Fix the pre-existing "URL ro a reverse DNS map" docstring typo in
  get_service_from_reverse_dns_base_domain, caught by Copilot on the
  adjacent hunk.
- Import parsedmarc.config once, as an aliased plain import, in
  tests/test_config.py instead of mixing import and import-from of the
  same module (flagged by code quality scanning); the aliased module
  import also keeps pyright able to resolve the submodule attribute
  access.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address second Copilot review round on #851

- Add parse_aggregate_report_file() to the library entry-point list in
  usage.md; the following paragraph describes the config= contract for
  "each of these functions", so the list must name all eight
  config-accepting entry points.
- ParserConfig.__setstate__ now initializes every non-cache field to
  its class default before applying the pickled state, so a config
  serialized by an older parsedmarc version (whose state predates
  fields added later) unpickles with the newer fields at their
  defaults instead of unset entirely (__init__ never runs during
  unpickling, so an absent field would raise AttributeError on first
  access). Covered by a regression test that feeds __setstate__ a
  partial state dict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sean Whalen
2026-07-25 19:27:09 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 4f4733003c
commit 4e80047e68
13 changed files with 1301 additions and 293 deletions
+249 -13
View File
@@ -7,6 +7,7 @@ extract_report, get_dmarc_reports_from_mbox, and the CSV / JSON renderers.
import base64
import gzip
import inspect
import json
import logging
import mailbox
@@ -24,6 +25,7 @@ from unittest.mock import MagicMock, patch
from lxml import etree # type: ignore[import-untyped]
import parsedmarc
import parsedmarc.constants as constants
from parsedmarc.mail import MaildirConnection, MSGraphConnection
from parsedmarc.types import (
AggregateReport,
@@ -749,7 +751,9 @@ class Test(unittest.TestCase):
"auth_results": {"dkim": [], "spf": []},
}
with self.assertRaises(ValueError):
parsedmarc._parse_report_record(record, offline=True)
parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
def testParseReportRecordMissingDkimSpf(self):
"""Record with missing dkim/spf auth results defaults correctly"""
@@ -766,7 +770,9 @@ class Test(unittest.TestCase):
"identifiers": {"header_from": "example.com"},
"auth_results": {},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
self.assertEqual(result["auth_results"]["dkim"], [])
self.assertEqual(result["auth_results"]["spf"], [])
@@ -786,7 +792,9 @@ class Test(unittest.TestCase):
"identifiers": {"header_from": "example.com"},
"auth_results": {"dkim": [], "spf": []},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
reasons = result["policy_evaluated"]["policy_override_reasons"]
self.assertEqual(len(reasons), 1)
self.assertEqual(reasons[0]["type"], "forwarded")
@@ -811,7 +819,9 @@ class Test(unittest.TestCase):
"identifiers": {"header_from": "example.com"},
"auth_results": {"dkim": [], "spf": []},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
reasons = result["policy_evaluated"]["policy_override_reasons"]
self.assertEqual(len(reasons), 2)
self.assertEqual(reasons[0]["comment"], "relay")
@@ -835,7 +845,9 @@ class Test(unittest.TestCase):
},
"auth_results": {"dkim": [], "spf": []},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
self.assertIn("identifiers", result)
self.assertEqual(result["identifiers"]["header_from"], "example.com")
@@ -857,7 +869,9 @@ class Test(unittest.TestCase):
"spf": [],
},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
dkim = result["auth_results"]["dkim"][0]
self.assertEqual(dkim["selector"], "none")
self.assertEqual(dkim["result"], "none")
@@ -881,7 +895,9 @@ class Test(unittest.TestCase):
"spf": {"domain": "example.com"},
},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
spf = result["auth_results"]["spf"][0]
self.assertEqual(spf["scope"], "mfrom")
self.assertEqual(spf["result"], "none")
@@ -919,7 +935,9 @@ class Test(unittest.TestCase):
],
},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
self.assertEqual(result["auth_results"]["dkim"][0]["human_result"], "good key")
self.assertEqual(
result["auth_results"]["spf"][0]["human_result"], "sender valid"
@@ -945,7 +963,9 @@ class Test(unittest.TestCase):
],
},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
self.assertEqual(result["identifiers"]["envelope_from"], "bounce.example.com")
def testParseReportRecordEnvelopeFromNullFallback(self):
@@ -971,7 +991,9 @@ class Test(unittest.TestCase):
],
},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
self.assertEqual(result["identifiers"]["envelope_from"], "spf.example.com")
def testParseReportRecordEnvelopeFromNullNoSpfDomain(self):
@@ -998,7 +1020,9 @@ class Test(unittest.TestCase):
"spf": [{"scope": "mfrom", "result": "pass"}],
},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
self.assertIsNone(result["identifiers"]["envelope_from"])
def testParseReportRecordEnvelopeTo(self):
@@ -1020,7 +1044,9 @@ class Test(unittest.TestCase):
},
"auth_results": {"dkim": [], "spf": []},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
self.assertEqual(result["identifiers"]["envelope_to"], "recipient@example.com")
def testParseReportRecordAlignment(self):
@@ -1038,7 +1064,9 @@ class Test(unittest.TestCase):
"identifiers": {"header_from": "example.com"},
"auth_results": {"dkim": [], "spf": []},
}
result = parsedmarc._parse_report_record(record, offline=True)
result = parsedmarc._parse_report_record(
record, config=parsedmarc.ParserConfig(offline=True)
)
self.assertTrue(result["alignment"]["dkim"])
self.assertFalse(result["alignment"]["spf"])
self.assertTrue(result["alignment"]["dmarc"])
@@ -2709,6 +2737,214 @@ class TestGetDmarcReportsFromMboxParallel(unittest.TestCase):
)
class TestCentralizedConfig(unittest.TestCase):
"""Regression coverage for the centralize-config-503 refactor
(parsedmarc/config.py's ``ParserConfig``): the kwargs-style public API
must keep observing/mutating the same module-default caches it always
has (via ``_resolve_config`` injecting ``IP_ADDRESS_CACHE`` /
``SEEN_AGGREGATE_REPORT_IDS`` / ``REVERSE_DNS_MAP`` rather than letting
a fresh ``ParserConfig()`` default-factory hand back empty ones), an
explicit ``config=`` must win over individual option keyword arguments
when both are given, and the DNS-timeout/retry/normalize-threshold
defaults must stay consistent between each public function's own
signature and ``ParserConfig``'s field defaults.
"""
AGGREGATE = "samples/aggregate/twilight.eml"
def _build_single_aggregate_mbox(self) -> str:
"""Builds a temporary mbox containing one copy of AGGREGATE."""
tmp = mkdtemp()
self.addCleanup(rmtree, tmp, ignore_errors=True)
path = os.path.join(tmp, "reports.mbox")
box = mailbox.mbox(path)
box.lock()
try:
with open(self.AGGREGATE, "rb") as source_file:
box.add(mailbox.mboxMessage(source_file.read()))
box.flush()
finally:
box.unlock()
box.close()
return path
def test_kwargs_path_uses_module_default_caches_not_fresh_ones(self):
"""Regression guard for _resolve_config: calling
get_dmarc_reports_from_mbox with plain kwargs (no config=) twice in
a row over the same mbox must dedup the second run's aggregate
report against the first run's, because both calls must resolve to
the SAME module-default parsedmarc.SEEN_AGGREGATE_REPORT_IDS cache.
If _resolve_config ever let the kwargs path fall through to
ParserConfig's default_factory instead of explicitly injecting the
module-default caches, each call would get a fresh, empty cache and
this dedup would silently stop working.
"""
parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear()
self.addCleanup(parsedmarc.SEEN_AGGREGATE_REPORT_IDS.clear)
path = self._build_single_aggregate_mbox()
first = parsedmarc.get_dmarc_reports_from_mbox(path, offline=True)
second = parsedmarc.get_dmarc_reports_from_mbox(path, offline=True)
self.assertEqual(len(first["aggregate_reports"]), 1)
self.assertEqual(len(second["aggregate_reports"]), 0)
report_metadata = first["aggregate_reports"][0]["report_metadata"]
report_key = f"{report_metadata['org_name']}_{report_metadata['report_id']}"
self.assertIn(report_key, parsedmarc.SEEN_AGGREGATE_REPORT_IDS)
def test_kwargs_and_config_equivalence(self):
"""parse_report_file must produce identical results whether called
with individual option keyword arguments or an equivalent explicit
ParserConfig, for one sample of each report type. None of these
paths touch dedup (that's only in _classify_parsed_email /
get_dmarc_reports_from_mbox / get_dmarc_reports_from_mailbox), so
no cache clearing is needed between calls.
"""
sample_paths = [
"samples/aggregate/rfc9990-sample.xml",
"samples/failure/dmarc_ruf_report_linkedin.eml",
"samples/smtp_tls/google.com_smtp_tls_report.eml",
]
for sample_path in sample_paths:
with self.subTest(sample=sample_path):
kwargs_result = parsedmarc.parse_report_file(
sample_path, offline=True, always_use_local_files=True
)
config_result = parsedmarc.parse_report_file(
sample_path,
config=parsedmarc.ParserConfig(
offline=True, always_use_local_files=True
),
)
self.assertEqual(kwargs_result, config_result)
def test_config_wins_over_kwargs_normalize_threshold(self):
"""When both config= and normalize_timespan_threshold_hours= are
given, the config's value must win -- per the documented contract,
an explicit config makes the individual option kwargs inert.
samples/aggregate/ikea.com!example.de!1538690400!1538776800.xml
spans exactly 86400 seconds (24h), so a 1.0-hour threshold
normalizes it and a 1000-hour threshold does not; this makes the
config-vs-kwarg outcome observable in normalized_timespan.
"""
sample_path = "samples/aggregate/ikea.com!example.de!1538690400!1538776800.xml"
with open(sample_path, "rb") as f:
data = f.read()
# Config's high threshold must win over the kwarg's low threshold:
# the report must NOT be normalized.
report = parsedmarc.parse_aggregate_report_file(
data,
offline=True,
config=parsedmarc.ParserConfig(
offline=True, normalize_timespan_threshold_hours=1000.0
),
normalize_timespan_threshold_hours=1.0,
)
for record in report["records"]:
self.assertFalse(record["normalized_timespan"]) # type: ignore[typeddict-item]
# Converse: config's low threshold must win over the kwarg's high
# threshold: the report MUST be normalized.
report = parsedmarc.parse_aggregate_report_file(
data,
offline=True,
config=parsedmarc.ParserConfig(
offline=True, normalize_timespan_threshold_hours=1.0
),
normalize_timespan_threshold_hours=1000.0,
)
for record in report["records"]:
self.assertTrue(record["normalized_timespan"]) # type: ignore[typeddict-item]
def test_separate_configs_isolate_dedup_state(self):
"""Two independently constructed ParserConfig(offline=True)
instances must NOT share dedup state (each has its own
seen_aggregate_report_ids cache), but reusing the SAME instance
across two calls must dedup, exactly like the module-default-cache
kwargs path does.
"""
path = self._build_single_aggregate_mbox()
cfg_a = parsedmarc.ParserConfig(offline=True)
cfg_b = parsedmarc.ParserConfig(offline=True)
result_a = parsedmarc.get_dmarc_reports_from_mbox(path, config=cfg_a)
result_b = parsedmarc.get_dmarc_reports_from_mbox(path, config=cfg_b)
self.assertEqual(len(result_a["aggregate_reports"]), 1)
self.assertEqual(len(result_b["aggregate_reports"]), 1)
cfg_c = parsedmarc.ParserConfig(offline=True)
first = parsedmarc.get_dmarc_reports_from_mbox(path, config=cfg_c)
second = parsedmarc.get_dmarc_reports_from_mbox(path, config=cfg_c)
self.assertEqual(len(first["aggregate_reports"]), 1)
self.assertEqual(len(second["aggregate_reports"]), 0)
def test_dns_and_normalize_defaults_match_constants_and_parser_config(self):
"""DNS timeout/retries and normalize-timespan-threshold defaults
must match parsedmarc.constants (the authoritative source --
DEFAULT_DNS_TIMEOUT, DEFAULT_DNS_MAX_RETRIES) and
parsedmarc.config.ParserConfig's own field defaults, across every
public function that accepts them.
Regression guard: before this refactor, get_dmarc_reports_from_mailbox
and watch_inbox each had a stray literal ``dns_timeout=6.0`` (instead
of ``DEFAULT_DNS_TIMEOUT == 2.0``) and
``normalize_timespan_threshold_hours=24`` (an int, instead of the
float ``24.0`` used everywhere else) -- exactly the kind of drift
_resolve_config's shared construction path is meant to prevent from
silently reappearing.
"""
functions_and_dns_params = [
(parsedmarc.parse_aggregate_report_xml, "timeout", "retries"),
(parsedmarc.parse_aggregate_report_file, "dns_timeout", "dns_retries"),
(parsedmarc.parse_failure_report, "dns_timeout", "dns_retries"),
(parsedmarc.parse_report_email, "dns_timeout", "dns_retries"),
(parsedmarc.parse_report_file, "dns_timeout", "dns_retries"),
(parsedmarc.get_dmarc_reports_from_mbox, "dns_timeout", "dns_retries"),
(
parsedmarc.get_dmarc_reports_from_mailbox,
"dns_timeout",
"dns_retries",
),
(parsedmarc.watch_inbox, "dns_timeout", "dns_retries"),
]
default_config = parsedmarc.ParserConfig()
for func, timeout_param, retries_param in functions_and_dns_params:
with self.subTest(func=func.__name__, param="dns"):
sig = inspect.signature(func)
timeout_default = sig.parameters[timeout_param].default
retries_default = sig.parameters[retries_param].default
self.assertEqual(timeout_default, constants.DEFAULT_DNS_TIMEOUT)
self.assertEqual(retries_default, constants.DEFAULT_DNS_MAX_RETRIES)
self.assertEqual(timeout_default, default_config.dns_timeout)
self.assertEqual(retries_default, default_config.dns_retries)
# parse_failure_report has no normalize_timespan_threshold_hours
# parameter -- normalization is an aggregate-report-only concept.
normalize_funcs = [
parsedmarc.parse_aggregate_report_xml,
parsedmarc.parse_aggregate_report_file,
parsedmarc.parse_report_email,
parsedmarc.parse_report_file,
parsedmarc.get_dmarc_reports_from_mbox,
parsedmarc.get_dmarc_reports_from_mailbox,
parsedmarc.watch_inbox,
]
for func in normalize_funcs:
with self.subTest(func=func.__name__, param="normalize"):
sig = inspect.signature(func)
default = sig.parameters["normalize_timespan_threshold_hours"].default
self.assertEqual(default, 24.0)
self.assertIsInstance(default, float)
self.assertEqual(
default, default_config.normalize_timespan_threshold_hours
)
class TestGetDmarcReportsFromMailboxValidation(unittest.TestCase):
"""Input validation on get_dmarc_reports_from_mailbox.