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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.4.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.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
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 `.mbox`) or a folder of them, and skip
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.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
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 `.mbox`) or a folder of them, and skip
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.4.0"
7
+ version = "0.5.0"
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.4.0"
3
+ __version__ = "0.5.0"
@@ -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(path: Path) -> Iterator[tuple[str, bytes, Mail | None]]:
101
- """Yield (label, report JSON bytes, how it arrived) for each report in a file."""
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 path.name.lower().endswith(".eml"):
106
- found = list(_attachments(BytesParser(policy=policy.default).parsebytes(data), str(path)))
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 str(path), _gunzip(data), None
113
+ yield label, _gunzip(data), None
112
114
  else:
113
- yield str(path), data, None
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.4.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.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
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 `.mbox`) or a folder of them, and skip
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
- assert web("--md", paths) == to_markdown(loaded)
34
- assert web("--html", paths) == to_html(loaded)
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