aprf-reader 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Summarise APRF (Aggregate Performance Reporting) inbox placement reports."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,3 +1,3 @@
1
1
  """Read APRF aggregate performance reports."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -14,7 +14,7 @@ from email.message import Message
14
14
  from email.parser import BytesParser
15
15
  from pathlib import Path
16
16
 
17
- from .report import Report, ReportError, parse
17
+ from .report import Report, ReportError, parse_items
18
18
 
19
19
  SUFFIXES = (".json", ".json.gz", ".eml", ".mbox")
20
20
  # A daily report is a few KB; this stops a stray huge file or gzip bomb
@@ -116,16 +116,20 @@ def load(paths: Iterable[Path]) -> Loaded:
116
116
  return
117
117
  seen_bytes.add(digest)
118
118
  try:
119
- reports = parse(data)
119
+ items = parse_items(data)
120
120
  except ReportError as e:
121
121
  out.skipped.append((where, str(e)))
122
122
  return
123
- for r in reports:
123
+ for i, r in enumerate(items, 1):
124
+ label = f"{where} report {i}" if len(items) > 1 else where
125
+ if isinstance(r, ReportError):
126
+ out.skipped.append((label, str(r)))
127
+ continue
124
128
  # The draft does not yet say whether a resend replaces the first; keep the first
125
129
  if r.key in seen_keys:
126
- out.skipped.append((where, f"same day already reported in {seen_keys[r.key]}; kept that one"))
130
+ out.skipped.append((label, f"same day already reported in {seen_keys[r.key]}; kept that one"))
127
131
  continue
128
- seen_keys[r.key] = where
132
+ seen_keys[r.key] = label
129
133
  out.reports.append(r)
130
134
 
131
135
  for path in _files(paths):
@@ -11,6 +11,8 @@ from .load import Loaded
11
11
  from .report import DRAFT
12
12
  from .summary import Row, Stream, streams
13
13
 
14
+ NO_COUNTS = "These reports carry no counts."
15
+
14
16
  CAVEAT = (
15
17
  "Counts are bucket upper bounds chosen by each provider: a ceiling, not an exact figure. "
16
18
  "Placement counts mail received that day; engagement counts actions taken that day on mail "
@@ -50,6 +52,9 @@ def to_markdown(loaded: Loaded) -> str:
50
52
  for s in ss:
51
53
  lines += [f"## {_md(s.source)}: {_md(s.domain)}, selector {_md(s.selector)}", "", _span(s), ""]
52
54
  cols = [k for _, k in s.columns]
55
+ if not cols:
56
+ lines += [NO_COUNTS, ""]
57
+ continue
53
58
  lines.append("| Day | Segment | " + " | ".join(_md(c) for c in cols) + " |")
54
59
  lines.append("|---|---|" + "---:|" * len(cols))
55
60
  for row in s.rows:
@@ -112,6 +117,9 @@ def to_html(loaded: Loaded) -> str:
112
117
  for s in ss:
113
118
  cols = [k for _, k in s.columns]
114
119
  parts.append(f"<h2>{e(s.source)}: {e(s.domain)}, selector {e(s.selector)}</h2>")
120
+ if not cols:
121
+ parts.append(f"<p class=muted>{e(_span(s))}</p><p>{NO_COUNTS}</p>")
122
+ continue
115
123
  parts.append(f"<p class=muted>{e(_span(s))}</p><div class=wrap><table><thead><tr><th>Day<th>Segment")
116
124
  parts.append("".join(f"<th>{e(c)}" for c in cols) + "</thead><tbody>")
117
125
  parts.append("<tr class=trend><td>Trend<td class=muted>sum per day")
@@ -166,6 +174,13 @@ def _bars(points: list[tuple[int, int]], width: int) -> str:
166
174
  return "".join(" " if v is None else BARS[min(7, v * 7 // top)] if v else BARS[0] for v in cells)
167
175
 
168
176
 
177
+ def _spread(points: list[tuple[int, int]]) -> str:
178
+ lo, hi = min(v for _, v in points), max(v for _, v in points)
179
+ if lo == hi:
180
+ return f"{lo:,}" if len(points) == 1 else f"{lo:,} every day"
181
+ return f"{lo:,} to {hi:,}"
182
+
183
+
169
184
  def _date(d) -> str:
170
185
  return f"{d:%b} {d.day}"
171
186
 
@@ -195,10 +210,13 @@ def to_terminal(loaded: Loaded, color: bool = True, width: int = 100) -> str:
195
210
  line += st.dim(" · ") + st.warn(f"{_plural(s.missing, 'day')} with no report")
196
211
  out += [line, ""]
197
212
  names = [k for _, k in s.columns]
213
+ if not names:
214
+ out += [" " + st.dim(NO_COUNTS), ""]
215
+ continue
198
216
  nw = max([len(n) for n in names] + [5])
199
217
  trends = [s.trend(i) for i in range(len(names))]
200
218
  latest = [f"{t[-1][1]:,}" for t in trends]
201
- ranges = [f"{min(v for _, v in t):,} to {max(v for _, v in t):,}" for t in trends]
219
+ ranges = [_spread(t) for t in trends]
202
220
  lw, rw = max(len(x) for x in latest + ["latest"]), max(len(x) for x in ranges + ["range"])
203
221
  bw = max(10, min(60, width - (2 + nw + 2 + lw + 2 + rw + 2)))
204
222
  out.append(st.dim(f" {'':<{nw}} {'latest':>{lw}} {'range':<{rw}} trend"))
@@ -216,7 +234,8 @@ def to_terminal(loaded: Loaded, color: bool = True, width: int = 100) -> str:
216
234
  out.append("")
217
235
  if loaded.skipped:
218
236
  out.append(st.warn(f"Skipped {len(loaded.skipped)}"))
219
- out += [st.dim(f" {w}: {r}") for w, r in loaded.skipped]
237
+ for w, r in loaded.skipped:
238
+ out += [st.dim(line) for line in _wrap(f"{w}: {r}", width - 2, " ")]
220
239
  out.append("")
221
240
  out.append(st.dim(f"aprf-reader {__version__}, {DRAFT}. Daily tables: --markdown, or --html for a page."))
222
241
  return "\n".join(out) + "\n"
@@ -15,10 +15,16 @@ DRAFT = "draft-ietf-mailmaint-aprf-00"
15
15
  # Names the draft defines, in display order; providers may add others
16
16
  PLACEMENT_NAMES = ("inbox", "unwanted")
17
17
  ENGAGEMENT_NAMES = ("positive", "neutral", "negative")
18
- # sdi_used values (first word) that mean no SDI was applied (section 5.2.1)
18
+ # sdi_used values that mean no SDI was applied (section 5.2.1), compared on
19
+ # the first word without case or trailing punctuation
19
20
  NO_SDI = ("", "N/A", "N/F")
20
21
 
21
22
 
23
+ def uses_sdi(value: str) -> bool:
24
+ first = value.split(" ", 1)[0].rstrip(";,.").upper()
25
+ return first not in NO_SDI
26
+
27
+
22
28
  class ReportError(ValueError):
23
29
  """The data is not an APRF report this module can read."""
24
30
 
@@ -50,15 +56,45 @@ class Report:
50
56
  # Section 8: the report's date is its ending date, in UTC
51
57
  return _day(self.report_end)
52
58
 
59
+ @property
60
+ def stream_key(self) -> tuple[str, str, str]:
61
+ return (self.source, name_key(self.dkim_domain), self.dkim_selector.lower())
62
+
53
63
  @property
54
64
  def key(self) -> tuple[str, str, str, date]:
55
- return (self.source, self.dkim_domain.lower(), self.dkim_selector, self.day)
65
+ return (*self.stream_key, self.day)
66
+
67
+
68
+ def name_key(domain: str) -> str:
69
+ """DNS names compare without case, and bücher.example is xn--bcher-kva.example."""
70
+ d = domain.lower()
71
+ # Only plain letters, digits, dots and hyphens; the web page converts the same set
72
+ # shortcut: Python uses IDNA 2003 and browsers UTS 46, which differ on a few letters such as ß
73
+ if d.isascii() or not all(c.isalnum() or c in ".-" for c in d):
74
+ return d
75
+ try:
76
+ return d.encode("idna").decode("ascii")
77
+ except UnicodeError:
78
+ return d
56
79
 
57
80
 
58
81
  def _day(epoch: int) -> date:
59
82
  return datetime.fromtimestamp(epoch, UTC).date()
60
83
 
61
84
 
85
+ # JavaScript numbers are exact only up to here; the web page reads the same files
86
+ MAX_COUNT = 2**53 - 1
87
+
88
+
89
+ def _int(v: object) -> int | None:
90
+ """An integer, or a float with nothing after the point (100.0); otherwise None."""
91
+ if isinstance(v, bool):
92
+ return None
93
+ if isinstance(v, float) and v.is_integer():
94
+ v = int(v)
95
+ return v if isinstance(v, int) else None
96
+
97
+
62
98
  def _counts(value: object, where: str) -> dict[str, int]:
63
99
  if value is None:
64
100
  return {}
@@ -66,9 +102,10 @@ def _counts(value: object, where: str) -> dict[str, int]:
66
102
  raise ReportError(f"{where} is not an object")
67
103
  out = {}
68
104
  for name, n in value.items():
69
- if isinstance(n, bool) or not isinstance(n, int) or n < 0:
70
- raise ReportError(f"{where}.{name} is not a count: {n!r}")
71
- out[str(name)] = n
105
+ k = _int(n)
106
+ if k is None or k < 0 or k > MAX_COUNT:
107
+ raise ReportError(f"{where}.{name} is not a count: {json.dumps(n, ensure_ascii=False)}")
108
+ out[str(name)] = k
72
109
  return out
73
110
 
74
111
 
@@ -103,13 +140,19 @@ def _report(obj: object) -> Report:
103
140
  for name in ("dkim_domain", "dkim_selector"):
104
141
  if not isinstance(h.get(name), str) or not h[name]:
105
142
  raise ReportError(f"header.{name} is missing")
106
- for name in ("report_start", "report_end"):
107
- if isinstance(h.get(name), bool) or not isinstance(h.get(name), int):
143
+ start, end = _int(h.get("report_start")), _int(h.get("report_end"))
144
+ for name, v in (("report_start", start), ("report_end", end)):
145
+ if v is None:
108
146
  raise ReportError(f"header.{name} is not an epoch time")
147
+ # Seconds past 1e11 are beyond the year 5138; up to 1e14 that is milliseconds
148
+ if 10**11 <= abs(v) < 10**14:
149
+ raise ReportError(f"header.{name} looks like milliseconds, not seconds: {v}")
150
+ if end < start:
151
+ raise ReportError("header.report_end is before report_start")
109
152
  try:
110
- _day(h["report_end"])
153
+ _day(end)
111
154
  except (OverflowError, OSError, ValueError):
112
- raise ReportError(f"header.report_end is out of range: {h['report_end']}") from None
155
+ raise ReportError(f"header.report_end is out of range: {end}") from None
113
156
  body = obj.get("body", [])
114
157
  if not isinstance(body, list):
115
158
  raise ReportError("body is not a list")
@@ -117,8 +160,8 @@ def _report(obj: object) -> Report:
117
160
  source=_text(h.get("source")) or "unknown",
118
161
  dkim_domain=h["dkim_domain"],
119
162
  dkim_selector=h["dkim_selector"],
120
- report_start=h["report_start"],
121
- report_end=h["report_end"],
163
+ report_start=start,
164
+ report_end=end,
122
165
  version=_text(h.get("version")),
123
166
  # Comcast's beta sends " - N/A - Beta - Not in use"
124
167
  sdi_used=_text(h.get("sdi_used")).strip(" -"),
@@ -128,8 +171,9 @@ def _report(obj: object) -> Report:
128
171
  )
129
172
 
130
173
 
131
- def parse(data: bytes) -> list[Report]:
132
- """Parse one report document. The draft's samples wrap reports in a list."""
174
+ def parse_items(data: bytes) -> list[Report | ReportError]:
175
+ """Each report in a document, or the reason it cannot be read, so one bad
176
+ report does not cost the rest of the file."""
133
177
  try:
134
178
  doc = json.loads(data)
135
179
  except (UnicodeDecodeError, json.JSONDecodeError) as e:
@@ -139,4 +183,19 @@ def parse(data: bytes) -> list[Report]:
139
183
  items = doc if isinstance(doc, list) else [doc]
140
184
  if not items:
141
185
  raise ReportError("empty list")
142
- return [_report(o) for o in items]
186
+ out: list[Report | ReportError] = []
187
+ for o in items:
188
+ try:
189
+ out.append(_report(o))
190
+ except ReportError as e:
191
+ out.append(e)
192
+ return out
193
+
194
+
195
+ def parse(data: bytes) -> list[Report]:
196
+ """Parse one report document. The draft's samples wrap reports in a list."""
197
+ items = parse_items(data)
198
+ for r in items:
199
+ if isinstance(r, ReportError):
200
+ raise r
201
+ return items # type: ignore[return-value]
@@ -7,7 +7,7 @@ from dataclasses import dataclass
7
7
  from datetime import date, timedelta
8
8
  from functools import cached_property
9
9
 
10
- from .report import ENGAGEMENT_NAMES, NO_SDI, PLACEMENT_NAMES, Report
10
+ from .report import ENGAGEMENT_NAMES, PLACEMENT_NAMES, Report, uses_sdi
11
11
 
12
12
 
13
13
  # Counts where a jump up is bad news, or a drop is
@@ -64,7 +64,7 @@ class Stream:
64
64
  out.append(Row(prev + timedelta(1), d - timedelta(1), None))
65
65
  for s in self.by_day[d].segments:
66
66
  vals = tuple(getattr(s, a).get(k) for a, k in self.columns)
67
- out.append(Row(d, d, " / ".join(s.path) or "all mail", vals))
67
+ out.append(Row(d, d, " / ".join(p or "(blank)" for p in s.path) or "all mail", vals))
68
68
  prev = d
69
69
  return out
70
70
 
@@ -106,8 +106,7 @@ class Stream:
106
106
  prev = r.version
107
107
  # "TBD" is the draft samples' placeholder
108
108
  out += [f"Provider note: {t}" for t in sorted({r.extra_info for r in self.by_day.values()} - {"", "TBD"})]
109
- sdi = sorted({r.sdi_used for r in self.by_day.values()
110
- if r.sdi_used.split(" ", 1)[0] not in NO_SDI})
109
+ sdi = sorted({r.sdi_used for r in self.by_day.values() if uses_sdi(r.sdi_used)})
111
110
  if sdi:
112
111
  out.append("SDI used: " + ", ".join(sdi))
113
112
  return out
@@ -116,7 +115,7 @@ class Stream:
116
115
  def streams(reports: list[Report]) -> list[Stream]:
117
116
  groups: dict[tuple[str, str, str], dict[date, Report]] = defaultdict(dict)
118
117
  for r in reports:
119
- groups[(r.source, r.dkim_domain.lower(), r.dkim_selector)][r.day] = r
118
+ groups[r.stream_key][r.day] = r
120
119
  return [Stream(src, dom, sel, days) for (src, dom, sel), days in sorted(groups.items())]
121
120
 
122
121
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -234,3 +234,24 @@ def test_cli_terminal_when_interactive(monkeypatch, capsys):
234
234
  assert out.startswith("APRF placement summary") and "\033[" not in out
235
235
  assert main([str(FIX / "sample_six_weeks.json"), "--markdown"]) == 0
236
236
  assert capsys.readouterr().out.startswith("# APRF placement summary")
237
+
238
+
239
+ def test_stress_set_one_bad_report_does_not_cost_the_file():
240
+ loaded = load([FIX / "stress_100.json"])
241
+ assert len(loaded.reports) == 88
242
+ reasons = {w.rsplit(" ", 1)[-1]: r for w, r in loaded.skipped}
243
+ assert reasons["73"] == 'body[0].engagement.positive is not a count: "100"'
244
+ assert reasons["79"] == "header.report_end is before report_start"
245
+ assert reasons["81"].startswith("header.report_start looks like milliseconds")
246
+ assert "report 63" in reasons["64"] # bücher.example is xn--bcher-kva.example
247
+ assert "report 3" in reasons["70"] # DNS-AUDIT.COM / SELECTOR1 is dns-audit.com / selector1
248
+ ss = {(s.source, s.domain, s.selector): s for s in streams(loaded.reports)}
249
+ obj = report(0)
250
+ obj["body"][0]["classification"]["unwanted"] = 100.0
251
+ assert parse(json.dumps(obj).encode())[0].segments[0].classification["unwanted"] == 100
252
+ for sel in ("sdi0", "sdi1", "sdi2", "sdi3", "sdi4"): # n/a, "N/A ", "", null, N/F;
253
+ assert not ss[("Comcast/Xfinity", "dns-audit.com", sel)].notes, sel
254
+ blank = ss[("Comcast/Xfinity", "dns-audit.com", "s13")]
255
+ assert "(blank)" in [r.segment for r in blank.rows]
256
+ assert not ss[("Comcast/Xfinity", "dns-audit.com", "s8")].columns
257
+ assert "These reports carry no counts." in to_markdown(loaded)
@@ -52,7 +52,8 @@ def email(payload: bytes, subtype: str, filename: str | None, cte: str | None =
52
52
  return msg
53
53
 
54
54
 
55
- @pytest.mark.parametrize("name", ["draft_sample_1.json", "draft_sample_2.json", "comcast_beta.json", "sample_six_weeks.json"])
55
+ @pytest.mark.parametrize("name", ["draft_sample_1.json", "draft_sample_2.json", "comcast_beta.json", "sample_six_weeks.json",
56
+ "stress_100.json"])
56
57
  def test_fixtures(name):
57
58
  same([FIX / name])
58
59
 
File without changes
File without changes
File without changes