aprf-reader 0.4.0__tar.gz → 0.5.0__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.4.0/src/aprf_reader.egg-info → aprf_reader-0.5.0}/PKG-INFO +4 -4
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/README.md +3 -3
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/pyproject.toml +1 -1
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader/__init__.py +1 -1
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader/cli.py +1 -1
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader/load.py +50 -9
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader/report.py +2 -2
- {aprf_reader-0.4.0 → aprf_reader-0.5.0/src/aprf_reader.egg-info}/PKG-INFO +4 -4
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/tests/test_web_parity.py +43 -3
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/LICENSE +0 -0
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/setup.cfg +0 -0
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader/render.py +0 -0
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader/summary.py +0 -0
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader.egg-info/SOURCES.txt +0 -0
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader.egg-info/entry_points.txt +0 -0
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader.egg-info/requires.txt +0 -0
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/src/aprf_reader.egg-info/top_level.txt +0 -0
- {aprf_reader-0.4.0 → aprf_reader-0.5.0}/tests/test_reader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aprf-reader
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -24,7 +24,7 @@ Two ways to use it, with the same results:
|
|
|
24
24
|
your reports on the page. They are read on your computer and never uploaded.
|
|
25
25
|
- **On the command line:** `pipx install aprf-reader`.
|
|
26
26
|
|
|
27
|
-
**Status: 0.
|
|
27
|
+
**Status: 0.5, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
28
28
|
|
|
29
29
|
## What APRF is
|
|
30
30
|
|
|
@@ -59,7 +59,7 @@ the changes worth a look. Piped or written to a file with `--md`, the output
|
|
|
59
59
|
is Markdown with the full daily tables.
|
|
60
60
|
|
|
61
61
|
Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
|
|
62
|
-
mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
|
|
62
|
+
mailbox exports (`.mbox`), `.zip` files of any of those, or folders of them. The easiest way to collect reports
|
|
63
63
|
is a mail filter that moves them into one folder, then an export of that folder:
|
|
64
64
|
Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
|
|
65
65
|
label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
|
|
@@ -78,7 +78,7 @@ to mark a change in what a category counts.
|
|
|
78
78
|
|
|
79
79
|
## What it does
|
|
80
80
|
|
|
81
|
-
- Read report files (`.json`, `.json.gz`, `.eml` and `.
|
|
81
|
+
- Read report files (`.json`, `.json.gz`, `.eml`, `.mbox` and `.zip`) or a folder of them, and skip
|
|
82
82
|
duplicates and files it cannot read, with a reason for each.
|
|
83
83
|
- Summarise placement per signing domain and selector: inbox, unwanted and
|
|
84
84
|
recipient reactions, by day and as a trend.
|
|
@@ -11,7 +11,7 @@ Two ways to use it, with the same results:
|
|
|
11
11
|
your reports on the page. They are read on your computer and never uploaded.
|
|
12
12
|
- **On the command line:** `pipx install aprf-reader`.
|
|
13
13
|
|
|
14
|
-
**Status: 0.
|
|
14
|
+
**Status: 0.5, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
15
15
|
|
|
16
16
|
## What APRF is
|
|
17
17
|
|
|
@@ -46,7 +46,7 @@ the changes worth a look. Piped or written to a file with `--md`, the output
|
|
|
46
46
|
is Markdown with the full daily tables.
|
|
47
47
|
|
|
48
48
|
Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
|
|
49
|
-
mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
|
|
49
|
+
mailbox exports (`.mbox`), `.zip` files of any of those, or folders of them. The easiest way to collect reports
|
|
50
50
|
is a mail filter that moves them into one folder, then an export of that folder:
|
|
51
51
|
Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
|
|
52
52
|
label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
|
|
@@ -65,7 +65,7 @@ to mark a change in what a category counts.
|
|
|
65
65
|
|
|
66
66
|
## What it does
|
|
67
67
|
|
|
68
|
-
- Read report files (`.json`, `.json.gz`, `.eml` and `.
|
|
68
|
+
- Read report files (`.json`, `.json.gz`, `.eml`, `.mbox` and `.zip`) or a folder of them, and skip
|
|
69
69
|
duplicates and files it cannot read, with a reason for each.
|
|
70
70
|
- Summarise placement per signing domain and selector: inbox, unwanted and
|
|
71
71
|
recipient reactions, by day and as a trend.
|
|
@@ -17,7 +17,7 @@ from .summary import to_dict
|
|
|
17
17
|
|
|
18
18
|
def main(argv: list[str] | None = None) -> int:
|
|
19
19
|
p = argparse.ArgumentParser(prog="aprf-reader", description="Summarise APRF placement reports.")
|
|
20
|
-
p.add_argument("paths", nargs="+", type=Path, help="report files (.json, .json.gz, .eml, .mbox) or folders")
|
|
20
|
+
p.add_argument("paths", nargs="+", type=Path, help="report files (.json, .json.gz, .eml, .mbox, .zip) or folders")
|
|
21
21
|
p.add_argument("--md", type=Path, help="write the summary here instead of printing it")
|
|
22
22
|
p.add_argument("--html", type=Path, help="also write a self-contained HTML page here")
|
|
23
23
|
p.add_argument("--markdown", action="store_true", help="print Markdown with daily tables, even in a terminal")
|
|
@@ -7,6 +7,7 @@ import hashlib
|
|
|
7
7
|
import io
|
|
8
8
|
import mailbox
|
|
9
9
|
import re
|
|
10
|
+
import zipfile
|
|
10
11
|
import zlib
|
|
11
12
|
from collections.abc import Iterable, Iterator
|
|
12
13
|
from dataclasses import dataclass, field
|
|
@@ -17,7 +18,9 @@ from pathlib import Path
|
|
|
17
18
|
|
|
18
19
|
from .report import Report, ReportError, parse_items
|
|
19
20
|
|
|
20
|
-
SUFFIXES = (".json", ".json.gz", ".eml", ".mbox")
|
|
21
|
+
SUFFIXES = (".json", ".json.gz", ".eml", ".mbox", ".zip")
|
|
22
|
+
# A zip of a year of daily reports for a few selectors is a few thousand files
|
|
23
|
+
MAX_ZIP_ENTRIES = 20_000
|
|
21
24
|
# A daily report is a few KB; this stops a stray huge file or gzip bomb
|
|
22
25
|
MAX_BYTES = 10 * 1024 * 1024
|
|
23
26
|
|
|
@@ -97,20 +100,50 @@ def _attachments(msg: Message, label: str) -> Iterator[tuple[str, bytes, Mail]]:
|
|
|
97
100
|
yield where, data, mail(False)
|
|
98
101
|
|
|
99
102
|
|
|
100
|
-
def _payloads(
|
|
101
|
-
"""Yield (label, report JSON bytes, how it arrived) for each report in
|
|
102
|
-
data = path.read_bytes()
|
|
103
|
+
def _payloads(label: str, data: bytes) -> Iterator[tuple[str, bytes, Mail | None]]:
|
|
104
|
+
"""Yield (label, report JSON bytes, how it arrived) for each report in one file's bytes."""
|
|
103
105
|
if len(data) > MAX_BYTES:
|
|
104
106
|
raise ReportError("larger than 10 MB")
|
|
105
|
-
if
|
|
106
|
-
found = list(_attachments(BytesParser(policy=policy.default).parsebytes(data),
|
|
107
|
+
if label.lower().endswith(".eml"):
|
|
108
|
+
found = list(_attachments(BytesParser(policy=policy.default).parsebytes(data), label))
|
|
107
109
|
if not found:
|
|
108
110
|
raise ReportError("no report attachment, which section 8 calls for")
|
|
109
111
|
yield from found
|
|
110
112
|
elif data[:2] == b"\x1f\x8b":
|
|
111
|
-
yield
|
|
113
|
+
yield label, _gunzip(data), None
|
|
112
114
|
else:
|
|
113
|
-
yield
|
|
115
|
+
yield label, data, None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _zip(path: Path, out: Loaded) -> Iterator[tuple[str, bytes]]:
|
|
119
|
+
"""Yield (label, bytes) for each report file in a zip, in name order; a bad member is skipped, not fatal."""
|
|
120
|
+
try:
|
|
121
|
+
z = zipfile.ZipFile(path)
|
|
122
|
+
except zipfile.BadZipFile:
|
|
123
|
+
raise ReportError("not a readable zip file") from None
|
|
124
|
+
with z:
|
|
125
|
+
# __MACOSX holds Finder metadata copies named like the real files
|
|
126
|
+
members = sorted((i for i in z.infolist() if not i.is_dir() and not i.filename.startswith("__MACOSX/")
|
|
127
|
+
and i.filename.lower().endswith(SUFFIXES)), key=lambda i: i.filename)
|
|
128
|
+
if len(members) > MAX_ZIP_ENTRIES:
|
|
129
|
+
raise ReportError(f"more than {MAX_ZIP_ENTRIES:,} report files in one zip")
|
|
130
|
+
for i in members:
|
|
131
|
+
label = f"{path}/{i.filename}"
|
|
132
|
+
name = i.filename.lower()
|
|
133
|
+
if name.endswith((".zip", ".mbox")):
|
|
134
|
+
out.skipped.append((label, "a zip or mailbox inside a zip is not read; unzip it first"))
|
|
135
|
+
elif i.flag_bits & 1:
|
|
136
|
+
out.skipped.append((label, "encrypted in the zip"))
|
|
137
|
+
elif i.file_size > MAX_BYTES:
|
|
138
|
+
out.skipped.append((label, "larger than 10 MB"))
|
|
139
|
+
else:
|
|
140
|
+
try:
|
|
141
|
+
with z.open(i) as f:
|
|
142
|
+
data = f.read(MAX_BYTES + 1)
|
|
143
|
+
except (zipfile.BadZipFile, NotImplementedError, zlib.error, EOFError, OSError):
|
|
144
|
+
out.skipped.append((label, "could not be unpacked from the zip"))
|
|
145
|
+
continue
|
|
146
|
+
yield label, data
|
|
114
147
|
|
|
115
148
|
|
|
116
149
|
def _mbox(path: Path, out: Loaded) -> Iterator[tuple[str, bytes, Mail | None]]:
|
|
@@ -232,8 +265,16 @@ def load(paths: Iterable[Path]) -> Loaded:
|
|
|
232
265
|
|
|
233
266
|
for path in _files(paths):
|
|
234
267
|
try:
|
|
268
|
+
if path.name.lower().endswith(".zip"):
|
|
269
|
+
for label, data in _zip(path, out):
|
|
270
|
+
try:
|
|
271
|
+
for where, d, mail in list(_payloads(label, data)):
|
|
272
|
+
add(where, d, mail)
|
|
273
|
+
except (OSError, EOFError, zlib.error, ReportError) as e:
|
|
274
|
+
out.skipped.append((label, str(e)))
|
|
275
|
+
continue
|
|
235
276
|
# An mbox can be large, so it is read one message at a time
|
|
236
|
-
payloads = _mbox(path, out) if _is_mbox(path) else list(_payloads(path))
|
|
277
|
+
payloads = _mbox(path, out) if _is_mbox(path) else list(_payloads(str(path), path.read_bytes()))
|
|
237
278
|
for where, data, mail in payloads:
|
|
238
279
|
add(where, data, mail)
|
|
239
280
|
except (OSError, EOFError, zlib.error, ReportError) as e:
|
|
@@ -115,7 +115,7 @@ def _counts(value: object, where: str, section: str) -> dict[str, int]:
|
|
|
115
115
|
for name, n in value.items():
|
|
116
116
|
k = _int(n)
|
|
117
117
|
if k is None or k < 0 or k > MAX_COUNT:
|
|
118
|
-
raise ReportError(f"{where}.{name} is not a count ({section}): {json.dumps(n, ensure_ascii=False, separators=(
|
|
118
|
+
raise ReportError(f"{where}.{name} is not a count ({section}): {json.dumps(n, ensure_ascii=False, separators=(',', ':'))}")
|
|
119
119
|
out[str(name)] = k
|
|
120
120
|
return out
|
|
121
121
|
|
|
@@ -194,7 +194,7 @@ def _warnings(h: dict, segments: tuple[Segment, ...]) -> tuple[tuple[str, str],
|
|
|
194
194
|
raw = h["sdi_used"]
|
|
195
195
|
if not (raw in ("N/A", "N/F") or (isinstance(raw, str) and SDI_FORMAT.fullmatch(raw))):
|
|
196
196
|
out.append(("sdi_used is not N/A, N/F, or the sdi value as published in DNS (section 5.2.1)",
|
|
197
|
-
json.dumps(raw, ensure_ascii=False, separators=(
|
|
197
|
+
json.dumps(raw, ensure_ascii=False, separators=(',', ':'))))
|
|
198
198
|
keys = lambda attr: sorted({k for s in segments for k in getattr(s, attr)})
|
|
199
199
|
for k in keys("classification"):
|
|
200
200
|
if k not in NAMED_CLASSIFICATION:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aprf-reader
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -24,7 +24,7 @@ Two ways to use it, with the same results:
|
|
|
24
24
|
your reports on the page. They are read on your computer and never uploaded.
|
|
25
25
|
- **On the command line:** `pipx install aprf-reader`.
|
|
26
26
|
|
|
27
|
-
**Status: 0.
|
|
27
|
+
**Status: 0.5, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
|
|
28
28
|
|
|
29
29
|
## What APRF is
|
|
30
30
|
|
|
@@ -59,7 +59,7 @@ the changes worth a look. Piped or written to a file with `--md`, the output
|
|
|
59
59
|
is Markdown with the full daily tables.
|
|
60
60
|
|
|
61
61
|
Point it at report files (`.json`, `.json.gz`), saved report emails (`.eml`),
|
|
62
|
-
mailbox exports (`.mbox`), or folders of them. The easiest way to collect reports
|
|
62
|
+
mailbox exports (`.mbox`), `.zip` files of any of those, or folders of them. The easiest way to collect reports
|
|
63
63
|
is a mail filter that moves them into one folder, then an export of that folder:
|
|
64
64
|
Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
|
|
65
65
|
label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
|
|
@@ -78,7 +78,7 @@ to mark a change in what a category counts.
|
|
|
78
78
|
|
|
79
79
|
## What it does
|
|
80
80
|
|
|
81
|
-
- Read report files (`.json`, `.json.gz`, `.eml` and `.
|
|
81
|
+
- Read report files (`.json`, `.json.gz`, `.eml`, `.mbox` and `.zip`) or a folder of them, and skip
|
|
82
82
|
duplicates and files it cannot read, with a reason for each.
|
|
83
83
|
- Summarise placement per signing domain and selector: inbox, unwanted and
|
|
84
84
|
recipient reactions, by day and as a trend.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import gzip
|
|
4
4
|
import json
|
|
5
5
|
import mailbox
|
|
6
|
+
import re
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
8
9
|
from email.message import EmailMessage
|
|
@@ -27,11 +28,18 @@ def web(mode: str, paths: list[Path]) -> str:
|
|
|
27
28
|
capture_output=True, text=True, env={"APRF_VERSION": __version__}).stdout
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _engine(doc: dict) -> dict:
|
|
32
|
+
"""Python and browsers word JSON parse errors differently; compare only our part."""
|
|
33
|
+
cut = lambda r: r.split(": ", 1)[0] if r.startswith("not valid JSON") else r
|
|
34
|
+
return {**doc, "skipped": [{**k, "reason": cut(k["reason"])} for k in doc["skipped"]]}
|
|
35
|
+
|
|
36
|
+
|
|
30
37
|
def same(paths: list[Path]) -> None:
|
|
31
38
|
loaded = load(paths)
|
|
32
|
-
assert json.loads(web("--json", paths)) == to_dict(loaded)
|
|
33
|
-
|
|
34
|
-
assert web("--
|
|
39
|
+
assert _engine(json.loads(web("--json", paths))) == _engine(to_dict(loaded))
|
|
40
|
+
text = lambda t: re.sub(r"(not valid JSON, which section 5 requires): [^\n<]*", r"\1", t)
|
|
41
|
+
assert text(web("--md", paths)) == text(to_markdown(loaded))
|
|
42
|
+
assert text(web("--html", paths)) == text(to_html(loaded))
|
|
35
43
|
|
|
36
44
|
|
|
37
45
|
def report(day: int, selector: str = "s1", inbox: int = 100) -> dict:
|
|
@@ -128,3 +136,35 @@ def test_section_8_checks_the_same(tmp_path):
|
|
|
128
136
|
assert "attachment name stands in for a character a file name cannot hold, such as / or *" in by_file["safe.eml"]
|
|
129
137
|
assert {"attachment name does not follow section 8", "subject does not follow section 8",
|
|
130
138
|
"attachment Content-Type is not application/json, or application/gzip when compressed"} <= by_file["wrong.eml"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_zips_read_the_same(tmp_path):
|
|
142
|
+
import zipfile
|
|
143
|
+
raw = lambda day, **kw: json.dumps(report(day, **kw)).encode()
|
|
144
|
+
z = tmp_path / "reports.zip"
|
|
145
|
+
with zipfile.ZipFile(z, "w") as f:
|
|
146
|
+
f.writestr("b/20261002.json", raw(1), compress_type=zipfile.ZIP_DEFLATED)
|
|
147
|
+
f.writestr("a/20261001.json", raw(0), compress_type=zipfile.ZIP_STORED)
|
|
148
|
+
f.writestr("c/20261003.json.gz", gzip.compress(raw(2)))
|
|
149
|
+
f.writestr("d/20261004.eml", email(raw(3), "json", "x.json").as_bytes(), compress_type=zipfile.ZIP_DEFLATED)
|
|
150
|
+
f.writestr("__MACOSX/a/._20261001.json", b"\x00\x05\x16\x07 Finder junk")
|
|
151
|
+
f.writestr("e/inner.zip", b"PK nested")
|
|
152
|
+
f.writestr("e/box.mbox", b"From x\n\nhi\n")
|
|
153
|
+
f.writestr("f/huge.json", b" " * (11 * 1024 * 1024), compress_type=zipfile.ZIP_DEFLATED)
|
|
154
|
+
f.writestr("g/notes.txt", b"not a report suffix")
|
|
155
|
+
f.writestr("h/broken.json", b"{oops", compress_type=zipfile.ZIP_DEFLATED)
|
|
156
|
+
f.writestr("i/crc.json", raw(5), compress_type=zipfile.ZIP_STORED)
|
|
157
|
+
# Flip one byte of the stored crc.json so its CRC no longer matches
|
|
158
|
+
data = bytearray(z.read_bytes())
|
|
159
|
+
at = data.index(b'"dkim_selector"', data.index(b"i/crc.json") + 10)
|
|
160
|
+
data[at + 2] ^= 0x01
|
|
161
|
+
z.write_bytes(bytes(data))
|
|
162
|
+
(tmp_path / "fake.zip").write_bytes(b"not a zip at all")
|
|
163
|
+
same([z, tmp_path / "fake.zip"])
|
|
164
|
+
loaded = load([z, tmp_path / "fake.zip"])
|
|
165
|
+
assert sorted(r.day.day for r in loaded.reports) == [1, 2, 3, 4]
|
|
166
|
+
reasons = {w.rsplit("/", 1)[-1]: r for w, r in loaded.skipped}
|
|
167
|
+
assert reasons["crc.json"] == "could not be unpacked from the zip"
|
|
168
|
+
assert reasons["inner.zip"] == reasons["box.mbox"] == "a zip or mailbox inside a zip is not read; unzip it first"
|
|
169
|
+
assert reasons["huge.json"] == "larger than 10 MB" and reasons["fake.zip"] == "not a readable zip file"
|
|
170
|
+
assert "broken.json" in reasons and not any("MACOSX" in w for w, _ in loaded.skipped)
|
|
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
|
|
File without changes
|