Warn on unmatched CLI paths; send Kafka failure/SMTP TLS reports per-report (#890)

Fixes the two remaining bugs and the nits uncovered during the review
session:

- The CLI logs a warning for each file_path argument that matches no
  files, instead of silently succeeding with empty results — a typo'd
  path in a cron job previously 'worked' forever while processing
  nothing (_expand_file_path_args dropped it as a zero-match glob).
  Deliberately a warning, not an error: a glob legitimately matching
  nothing must not break existing workflows, and mailbox-only runs
  stay silent.

- save_failure_reports_to_kafka and save_smtp_tls_reports_to_kafka now
  send one message per report, mirroring the aggregate saver's
  per-slice sends. Every released version documented per-record sends,
  but the code sent the whole list as one message, which a large batch
  — failure reports carry message samples — could push past Kafka's
  default 1MB message limit. Consumer-visible: these topics now carry
  individual report objects, not one JSON array per batch.

- The CLI module docstring and argparse description now mention SMTP
  TLS reports; usage.md's CLI-help mirror regenerated.

- The elastic/opensearch to_header display-name tests now assert the
  joined 'RT <rcpt@example.com>' string reaches the saved document
  (autospec save), plus the exactly-once call, instead of only that
  save ran.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sean Whalen
2026-08-28 18:23:07 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 2d76de9ca6
commit 07b00a931b
8 changed files with 180 additions and 43 deletions
+72
View File
@@ -570,6 +570,78 @@ hosts = localhost
[nested_xml],
)
def test_expand_file_path_args_warns_on_nonexistent_path(self):
"""A file_path argument that matches no files at all -- a plain
path that doesn't exist and isn't a glob match -- logs a WARNING
naming that argument, instead of silently vanishing as a
zero-match glob. The argument still contributes no paths to the
returned list.
Regression test: ``parsedmarc --offline -o out /nonexistent.xml``
used to exit 0 with empty results and no message of any kind, so
a typo'd path in a cron job "worked" forever while processing
nothing.
"""
from parsedmarc.cli import _expand_file_path_args
with tempfile.TemporaryDirectory() as d:
missing = os.path.join(d, "nonexistent.xml")
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
result = _expand_file_path_args([missing])
self.assertEqual(result, [])
self.assertTrue(
any(missing in line for line in cm.output),
f"expected a warning naming {missing}, got: {cm.output}",
)
def test_expand_file_path_args_no_warning_for_matching_path(self):
"""A file_path argument that matches at least one file logs no
warning at all."""
from parsedmarc.cli import _expand_file_path_args
with tempfile.TemporaryDirectory() as d:
report = os.path.join(d, "report.xml")
with open(report, "w") as f:
f.write("x")
with self.assertNoLogs("parsedmarc.log", level="WARNING"):
result = _expand_file_path_args([report])
self.assertEqual(result, [report])
wildcard = os.path.join(d, "*.xml")
with self.assertNoLogs("parsedmarc.log", level="WARNING"):
result = _expand_file_path_args([wildcard])
self.assertEqual(result, [report])
def test_expand_file_path_args_empty_list_is_silent(self):
"""An empty ``paths`` list (e.g. a mailbox-only run with no file
arguments) logs nothing."""
from parsedmarc.cli import _expand_file_path_args
with self.assertNoLogs("parsedmarc.log", level="WARNING"):
result = _expand_file_path_args([])
self.assertEqual(result, [])
def test_expand_file_path_args_mixed_warns_only_for_unmatched(self):
"""When some arguments match and others don't, only the
unmatched argument produces a warning."""
from parsedmarc.cli import _expand_file_path_args
with tempfile.TemporaryDirectory() as d:
report = os.path.join(d, "report.xml")
with open(report, "w") as f:
f.write("x")
missing = os.path.join(d, "nonexistent.xml")
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
result = _expand_file_path_args([report, missing])
self.assertEqual(result, [report])
warning_lines = [line for line in cm.output if "WARNING" in line]
self.assertEqual(len(warning_lines), 1)
self.assertIn(missing, warning_lines[0])
self.assertNotIn(report, warning_lines[0])
def test_apply_env_overrides_injects_values(self):
"""Env vars are injected into an existing ConfigParser."""
from configparser import ConfigParser
+9 -2
View File
@@ -1200,16 +1200,23 @@ class TestSaveFailureReport(unittest.TestCase):
def test_to_header_with_non_empty_display_joins_with_brackets(self):
"""The other branch: non-empty display joins display+addr
with " <" and appends ">", e.g. 'RT <rcpt@example.com>'."""
with " <" and appends ">", e.g. 'RT <rcpt@example.com>'.
Asserts the joined string actually reaches the saved document
(see test_reply_to_header_flattened_and_indexed for the
autospec-save pattern), not merely that save ran."""
report = _failure_report()
report["parsed_sample"]["headers"]["To"] = [["RT", "rcpt@example.com"]]
with (
patch("parsedmarc.elastic.Search", return_value=_empty_search()),
patch("parsedmarc.elastic.Index"),
patch.object(elastic_module._FailureReportDoc, "save") as mock_save,
patch.object(
elastic_module._FailureReportDoc, "save", autospec=True
) as mock_save,
):
save_failure_report_to_elasticsearch(report)
mock_save.assert_called_once()
doc = mock_save.call_args.args[0]
self.assertEqual(doc.sample.headers["to"], "RT <rcpt@example.com>")
def test_sample_address_lists_indexed_for_reply_to_cc_bcc_attachments(self):
"""A failure report sample can carry reply_to / cc / bcc /
+35 -12
View File
@@ -186,22 +186,33 @@ class TestSaveFailureReportsToKafka(unittest.TestCase):
with patch("parsedmarc.kafkaclient.KafkaProducer"):
return KafkaClient(kafka_hosts=["b:9092"])
def test_sends_full_list_in_one_message(self):
"""Failure reports are sent as one Kafka message carrying the
whole list — unlike aggregate records, which are sent as
individual slices to stay under Kafka's default 1MB message
cap."""
def test_sends_one_message_per_report(self):
"""Failure reports are sent as one Kafka message per report,
mirroring the aggregate saver's per-slice sends, so a large batch
is far less likely to exceed Kafka's default 1MB message limit
— failure
reports carry message samples, so a whole-list send is
particularly likely to blow past that cap."""
client = self._client()
reports = [{"id": "f1"}, {"id": "f2"}]
client.save_failure_reports_to_kafka(reports, "dmarc-failure")
_producer(client).send.assert_called_once_with("dmarc-failure", reports)
producer = _producer(client)
self.assertEqual(producer.send.call_count, 2)
sent = [call.args for call in producer.send.call_args_list]
self.assertEqual(
sent, [("dmarc-failure", reports[0]), ("dmarc-failure", reports[1])]
)
self.assertEqual(producer.flush.call_count, 2)
def test_dict_input_normalized_to_list(self):
"""A single-report dict is wrapped to a one-element list
internally, then sent as that one unwrapped report (not the
list itself)."""
client = self._client()
client.save_failure_reports_to_kafka({"id": "single"}, "topic")
# The send payload is wrapped to a single-element list.
self.assertEqual(_producer(client).send.call_count, 1)
args = _producer(client).send.call_args.args
self.assertEqual(args[1], [{"id": "single"}])
self.assertEqual(args[1], {"id": "single"})
def test_empty_list_is_a_noop(self):
client = self._client()
@@ -232,17 +243,29 @@ class TestSaveSmtpTlsReportsToKafka(unittest.TestCase):
with patch("parsedmarc.kafkaclient.KafkaProducer"):
return KafkaClient(kafka_hosts=["b:9092"])
def test_sends_full_list_in_one_message(self):
def test_sends_one_message_per_report(self):
"""SMTP TLS reports are sent as one Kafka message per report,
mirroring the aggregate saver's per-slice sends, so a large batch
is far less likely to exceed Kafka's default 1MB message
limit."""
client = self._client()
reports = [{"organization_name": "x"}]
reports = [{"organization_name": "x"}, {"organization_name": "y"}]
client.save_smtp_tls_reports_to_kafka(reports, "smtp-tls")
_producer(client).send.assert_called_once_with("smtp-tls", reports)
producer = _producer(client)
self.assertEqual(producer.send.call_count, 2)
sent = [call.args for call in producer.send.call_args_list]
self.assertEqual(sent, [("smtp-tls", reports[0]), ("smtp-tls", reports[1])])
self.assertEqual(producer.flush.call_count, 2)
def test_dict_input_normalized_to_list(self):
"""A single-report dict is wrapped to a one-element list
internally, then sent as that one unwrapped report (not the
list itself)."""
client = self._client()
client.save_smtp_tls_reports_to_kafka({"organization_name": "x"}, "topic")
self.assertEqual(_producer(client).send.call_count, 1)
args = _producer(client).send.call_args.args
self.assertEqual(args[1], [{"organization_name": "x"}])
self.assertEqual(args[1], {"organization_name": "x"})
def test_empty_list_is_a_noop(self):
client = self._client()
+9 -2
View File
@@ -1193,16 +1193,23 @@ class TestSaveFailureReport(unittest.TestCase):
def test_to_header_with_non_empty_display_joins_with_brackets(self):
"""The other branch: non-empty display joins display+addr
with " <" and appends ">", e.g. 'RT <rcpt@example.com>'."""
with " <" and appends ">", e.g. 'RT <rcpt@example.com>'.
Asserts the joined string actually reaches the saved document
(see test_reply_to_header_flattened_and_indexed for the
autospec-save pattern), not merely that save ran."""
report = _failure_report()
report["parsed_sample"]["headers"]["To"] = [["RT", "rcpt@example.com"]]
with (
patch("parsedmarc.opensearch.Search", return_value=_empty_search()),
patch("parsedmarc.opensearch.Index"),
patch.object(opensearch_module._FailureReportDoc, "save") as mock_save,
patch.object(
opensearch_module._FailureReportDoc, "save", autospec=True
) as mock_save,
):
save_failure_report_to_opensearch(report)
mock_save.assert_called_once()
doc = mock_save.call_args.args[0]
self.assertEqual(doc.sample.headers["to"], "RT <rcpt@example.com>")
def test_sample_address_lists_indexed_for_reply_to_cc_bcc_attachments(self):
"""A failure report sample can carry reply_to / cc / bcc /