Files
parsedmarc/tests/test_parallel.py
T
4e80047e68 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>
2026-07-25 19:27:09 -04:00

291 lines
10 KiB
Python

"""Tests for parsedmarc.parallel"""
import functools
import logging
import os
import tempfile
import unittest
from unittest.mock import patch
import parsedmarc
from parsedmarc.parallel import (
_init_worker_logging,
_parse_report_email_job,
_parse_report_file_job,
parallel_map,
)
# Stable sample files reused from tests/test_cli.py's TestDirectoryFilePaths,
# plus a third plain aggregate sample, so parity/order tests exercise more
# than one worker submission.
SAMPLE_PATHS = [
"samples/aggregate/!example.com!1538204542!1538463818.xml",
"samples/aggregate/!large-example.com!1711897200!1711983600.xml",
"samples/aggregate/example.net!example.com!1529366400!1529452799.xml",
]
def _echo_job(x):
"""Trivial module-level (spawn-picklable) worker used to exercise
parallel_map's scheduling behavior without involving real parsing."""
return x
class _CountingIterable:
"""Wraps a range so tests can observe how many items parallel_map has
pulled from a lazily-iterated jobs source at any point during
iteration, without materializing the whole sequence up front."""
def __init__(self, n):
self.n = n
self.pulled = 0
def __iter__(self):
for i in range(self.n):
self.pulled += 1
yield i
class _ParallelTestCase(unittest.TestCase):
"""Common env setup shared by parallel.py tests: offline mode, no DNS."""
def setUp(self):
self._env_patcher = patch.dict(
os.environ, {"GITHUB_ACTIONS": "true"}, clear=False
)
self._env_patcher.start()
self.addCleanup(self._env_patcher.stop)
class TestParallelMapParseReportFile(_ParallelTestCase):
"""parallel_map + _parse_report_file_job over real sample files must
behave identically to calling parse_report_file sequentially, and
must preserve submission order in its results."""
def test_results_match_sequential_parsing_in_order(self):
expected = [
(path, parsedmarc.parse_report_file(path, offline=True))
for path in SAMPLE_PATHS
]
job = functools.partial(
_parse_report_file_job, config=parsedmarc.ParserConfig(offline=True)
)
results = list(parallel_map(job, SAMPLE_PATHS, n_procs=2))
self.assertEqual(len(results), len(SAMPLE_PATHS))
# Order must match submission order (SAMPLE_PATHS), not completion
# order.
self.assertEqual([path for path, _ in results], SAMPLE_PATHS)
for (path, report), (expected_path, expected_report) in zip(results, expected):
self.assertEqual(path, expected_path)
self.assertNotIsInstance(report, Exception)
self.assertEqual(report, expected_report)
class TestParallelMapJunkFile(_ParallelTestCase):
"""A worker crash on an unparseable file must surface as an Exception
*value* in the result tuple, not hang the whole run. This is a
regression guard for the old Pipe/Process CLI worker, which left the
parent blocked forever on conn.recv() when a child died from a
non-ParserError exception."""
def test_junk_file_yields_exception_value_and_completes(self):
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False, mode="wb") as tf:
tf.write(b"not a report")
junk_path = tf.name
self.addCleanup(os.remove, junk_path)
job = functools.partial(
_parse_report_file_job, config=parsedmarc.ParserConfig(offline=True)
)
results = list(parallel_map(job, [junk_path, junk_path], n_procs=2))
self.assertEqual(len(results), 2)
for path, result in results:
self.assertEqual(path, junk_path)
self.assertIsInstance(result, Exception)
class TestParallelMapBoundedLaziness(unittest.TestCase):
"""The jobs iterable must never be materialized up front. At any point
during iteration, the number of items pulled from the source should
stay within window_factor * n_procs of the number of results already
yielded, bounding memory use for very large inputs (e.g. a 20,000
message mbox)."""
def test_consumption_stays_bounded_and_results_are_complete_and_ordered(self):
n = 20
window_factor = 2
n_procs = 2
window_size = window_factor * n_procs
jobs = _CountingIterable(n)
results = []
gen = parallel_map(
_echo_job, jobs, n_procs=n_procs, window_factor=window_factor
)
for result in gen:
results.append(result)
# +1 buffer: the generator may pull one extra job before it
# can submit-then-harvest on a given step.
self.assertLessEqual(jobs.pulled, window_size + len(results) + 1)
self.assertEqual(results, list(range(n)))
class TestParallelMapShouldStop(unittest.TestCase):
"""should_stop lets a caller (e.g. a CLI handling SIGTERM) end a run
early without raising, while still yielding results already
completed in the submission window."""
def test_should_stop_ends_iteration_early(self):
n = 20
jobs = _CountingIterable(n)
results = list(
parallel_map(_echo_job, jobs, n_procs=2, should_stop=lambda: True)
)
self.assertLess(len(results), n)
self.assertLess(jobs.pulled, n)
# Results still seen so far must be a prefix of submission order.
self.assertEqual(results, list(range(len(results))))
class TestParallelMapValidation(unittest.TestCase):
"""parallel_map is a reusable helper, so it validates n_procs itself
with a clear message instead of surfacing ProcessPoolExecutor's
max_workers error later - and it must do so eagerly at the call, not
on first iteration of the returned iterator (callers that pass the
iterator elsewhere before consuming it would otherwise see the error
far from the bad argument)."""
def test_n_procs_below_one_raises_value_error_eagerly(self):
with self.assertRaises(ValueError):
parallel_map(_echo_job, [1, 2], n_procs=0)
class TestParallelMapEmptyJobs(unittest.TestCase):
"""An empty jobs iterable must return immediately without spawning a
process pool."""
def test_empty_jobs_yields_nothing_and_spawns_no_pool(self):
with patch("parsedmarc.parallel.ProcessPoolExecutor") as mock_executor:
results = list(parallel_map(_echo_job, [], n_procs=2))
self.assertEqual(results, [])
mock_executor.assert_not_called()
class TestWorkerLogging(_ParallelTestCase):
"""Worker processes must reconstruct the parent parsedmarc logger's
level and FileHandler(s) so records emitted during parsing (e.g.
parse_report_file's "Parsing <path>" debug line) aren't silently
dropped just because they happened in a child process."""
def setUp(self):
super().setUp()
from parsedmarc.log import logger as plog
self._saved_handlers = list(plog.handlers)
self._saved_level = plog.level
def tearDown(self):
from parsedmarc.log import logger as plog
for handler in list(plog.handlers):
if handler not in self._saved_handlers:
plog.removeHandler(handler)
if isinstance(handler, logging.FileHandler):
handler.close()
plog.handlers[:] = self._saved_handlers
plog.setLevel(self._saved_level)
super().tearDown()
def test_worker_debug_log_reaches_parent_file_handler(self):
from parsedmarc.log import configure_logging
with tempfile.NamedTemporaryFile(suffix=".log", delete=False) as tf:
log_path = tf.name
self.addCleanup(lambda: os.path.exists(log_path) and os.remove(log_path))
configure_logging(logging.DEBUG, log_path)
sample = SAMPLE_PATHS[0]
job = functools.partial(
_parse_report_file_job, config=parsedmarc.ParserConfig(offline=True)
)
results = list(parallel_map(job, [sample], n_procs=2))
self.assertEqual(len(results), 1)
path, report = results[0]
self.assertEqual(path, sample)
self.assertNotIsInstance(report, Exception)
with open(log_path) as f:
contents = f.read()
# parse_report_file logs `Parsing {file_path}` at DEBUG
# (parsedmarc/__init__.py) -- this line only appears if the
# worker process's reconstructed logger actually wrote to the
# parent's log file.
self.assertIn(f"Parsing {sample}", contents)
class TestInitWorkerLogging(unittest.TestCase):
"""_init_worker_logging must be usable directly as a pool initializer:
with no log files it just sets the level (adds a console handler),
and with log files it attaches a FileHandler per path."""
def setUp(self):
from parsedmarc.log import logger as plog
self._saved_handlers = list(plog.handlers)
self._saved_level = plog.level
def tearDown(self):
from parsedmarc.log import logger as plog
for handler in list(plog.handlers):
if handler not in self._saved_handlers:
plog.removeHandler(handler)
if isinstance(handler, logging.FileHandler):
handler.close()
plog.handlers[:] = self._saved_handlers
plog.setLevel(self._saved_level)
def test_no_log_files_sets_level_only(self):
from parsedmarc.log import logger as plog
_init_worker_logging(logging.WARNING, [])
self.assertEqual(plog.level, logging.WARNING)
def test_log_files_attach_file_handlers(self):
from parsedmarc.log import logger as plog
with tempfile.NamedTemporaryFile(suffix=".log", delete=False) as tf:
log_path = tf.name
self.addCleanup(lambda: os.path.exists(log_path) and os.remove(log_path))
_init_worker_logging(logging.DEBUG, [log_path])
self.assertEqual(plog.level, logging.DEBUG)
file_handlers = [h for h in plog.handlers if isinstance(h, logging.FileHandler)]
self.assertTrue(any(h.baseFilename == log_path for h in file_handlers))
for h in file_handlers:
h.close()
class TestParseReportEmailJob(_ParallelTestCase):
"""_parse_report_email_job must return a ParserError as a value (never
raise it) on an invalid message."""
def test_invalid_email_returns_parser_error_value(self):
result = _parse_report_email_job(
b"not a valid email", config=parsedmarc.ParserConfig(offline=True)
)
self.assertIsInstance(result, parsedmarc.ParserError)
if __name__ == "__main__":
unittest.main(verbosity=2)