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.
- {aprf_reader-0.3.0/src/aprf_reader.egg-info → aprf_reader-0.3.1}/PKG-INFO +1 -1
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/pyproject.toml +1 -1
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader/__init__.py +1 -1
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader/load.py +9 -5
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader/render.py +21 -2
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader/report.py +73 -14
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader/summary.py +4 -5
- {aprf_reader-0.3.0 → aprf_reader-0.3.1/src/aprf_reader.egg-info}/PKG-INFO +1 -1
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/tests/test_reader.py +21 -0
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/tests/test_web_parity.py +2 -1
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/LICENSE +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/README.md +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/setup.cfg +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader/cli.py +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/SOURCES.txt +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/entry_points.txt +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/requires.txt +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.3.1}/src/aprf_reader.egg-info/top_level.txt +0 -0
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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((
|
|
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] =
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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(
|
|
153
|
+
_day(end)
|
|
111
154
|
except (OverflowError, OSError, ValueError):
|
|
112
|
-
raise ReportError(f"header.report_end is out of range: {
|
|
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=
|
|
121
|
-
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
|
|
132
|
-
"""
|
|
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
|
-
|
|
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,
|
|
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[
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|