aprf-reader 0.3.1__tar.gz → 0.4.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.1
3
+ Version: 0.4.1
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.3, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
27
+ **Status: 0.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
28
28
 
29
29
  ## What APRF is
30
30
 
@@ -88,8 +88,62 @@ to mark a change in what a category counts.
88
88
  or share. Nothing is uploaded anywhere.
89
89
  - Follow the draft as it changes. The report format lives in one module
90
90
  (`src/aprf_reader/report.py`), so a new draft revision is a small update.
91
- - When the same day arrives twice, keep the first copy and say so. The draft
92
- has not yet decided whether a resend replaces the original.
91
+ - Check each report against the draft and skip, with the reason, any it
92
+ cannot trust. One bad report in a file does not cost the rest of the file.
93
+
94
+ ## Checking against the draft
95
+
96
+ Every report is checked against draft-ietf-mailmaint-aprf-00, and every
97
+ message names the section, so a list of them can go straight into a review of
98
+ the draft. On the web page, Copy for a draft review copies them as text.
99
+
100
+ **Skipped**, because the report cannot be trusted:
101
+
102
+ | Problem | Section |
103
+ |---|---|
104
+ | Not valid JSON | 5 |
105
+ | A required header field missing or of the wrong type; times not in epoch seconds | 5.2.1 |
106
+ | Not one UTC day from 00:00:00 to 23:59:59, or ending before it starts | 5.1 |
107
+ | Body not a list of segments; a segment not an object | 5.2.2 |
108
+ | A placement or engagement count that is not a whole number | 6, 7 |
109
+ | A saved email with no report attached | 8 |
110
+
111
+ **Warnings**, where the report is still read:
112
+
113
+ | Warning | Section |
114
+ |---|---|
115
+ | `sdi_used` is not N/A, N/F, or the `sdi` value as published in DNS, or is missing | 5.2.1 |
116
+ | Attachment name or subject not `<yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>` | 8 |
117
+ | Message not `multipart/report`; attachment not `application/json` (or `application/gzip` for `.json.gz`) | 8, 8.1 |
118
+ | One attachment holding several reports | 8 |
119
+ | A placement key other than the four section 6 names, or an engagement key other than positive, neutral and negative | 6, 5.2.2 |
120
+ | A name or subject that writes `/` or `*` some other way, as a file name must | 8 is silent |
121
+ | The same day twice from one provider | left open, note in 8.1 |
122
+
123
+ Comcast's beta sends `sdi_used` as ` - N/A - Beta - Not in use` and a source
124
+ of `Comcast/Xfinity`, so its reports carry the `sdi_used` warning, and a
125
+ correct file name for them cannot follow section 8 exactly. A file that
126
+ repeats a report already read, such as the same report as `.json` and as
127
+ `.eml`, is counted once and summed up in one line.
128
+
129
+ ## How it decides
130
+
131
+ - **The same day twice, from the same provider:** it keeps the first copy it
132
+ reads and skips the other, saying whether the skipped copy had a different
133
+ `version` or different counts. The draft leaves resends open (a note in
134
+ section 8.1 asks whether a later report should overwrite or be discarded),
135
+ and `version` is a free string with no order, so there is no defined way
136
+ to pick the newer one. Files are read in name order, so the first copy is
137
+ the first by file name, then by position in the file.
138
+ - **The same day from two providers:** both are kept, as separate streams.
139
+ - **Names:** signing domains and selectors compare without case, and an
140
+ international domain matches its `xn--` form, so `bücher.example` and
141
+ `xn--bcher-kva.example` are one stream.
142
+ - **The reporting window:** section 5.1 says a report MUST cover one UTC day,
143
+ from 00:00:00 to 23:59:59. A report that covers more, or is shifted off the
144
+ day boundary, is skipped with a reason that names section 5.1.
145
+ - **Counts:** whole numbers only. `100.0` reads as 100; `"100"` and negative
146
+ numbers are refused.
93
147
 
94
148
  ## How the web page is built
95
149
 
@@ -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.3, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
14
+ **Status: 0.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
15
15
 
16
16
  ## What APRF is
17
17
 
@@ -75,8 +75,62 @@ to mark a change in what a category counts.
75
75
  or share. Nothing is uploaded anywhere.
76
76
  - Follow the draft as it changes. The report format lives in one module
77
77
  (`src/aprf_reader/report.py`), so a new draft revision is a small update.
78
- - When the same day arrives twice, keep the first copy and say so. The draft
79
- has not yet decided whether a resend replaces the original.
78
+ - Check each report against the draft and skip, with the reason, any it
79
+ cannot trust. One bad report in a file does not cost the rest of the file.
80
+
81
+ ## Checking against the draft
82
+
83
+ Every report is checked against draft-ietf-mailmaint-aprf-00, and every
84
+ message names the section, so a list of them can go straight into a review of
85
+ the draft. On the web page, Copy for a draft review copies them as text.
86
+
87
+ **Skipped**, because the report cannot be trusted:
88
+
89
+ | Problem | Section |
90
+ |---|---|
91
+ | Not valid JSON | 5 |
92
+ | A required header field missing or of the wrong type; times not in epoch seconds | 5.2.1 |
93
+ | Not one UTC day from 00:00:00 to 23:59:59, or ending before it starts | 5.1 |
94
+ | Body not a list of segments; a segment not an object | 5.2.2 |
95
+ | A placement or engagement count that is not a whole number | 6, 7 |
96
+ | A saved email with no report attached | 8 |
97
+
98
+ **Warnings**, where the report is still read:
99
+
100
+ | Warning | Section |
101
+ |---|---|
102
+ | `sdi_used` is not N/A, N/F, or the `sdi` value as published in DNS, or is missing | 5.2.1 |
103
+ | Attachment name or subject not `<yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>` | 8 |
104
+ | Message not `multipart/report`; attachment not `application/json` (or `application/gzip` for `.json.gz`) | 8, 8.1 |
105
+ | One attachment holding several reports | 8 |
106
+ | A placement key other than the four section 6 names, or an engagement key other than positive, neutral and negative | 6, 5.2.2 |
107
+ | A name or subject that writes `/` or `*` some other way, as a file name must | 8 is silent |
108
+ | The same day twice from one provider | left open, note in 8.1 |
109
+
110
+ Comcast's beta sends `sdi_used` as ` - N/A - Beta - Not in use` and a source
111
+ of `Comcast/Xfinity`, so its reports carry the `sdi_used` warning, and a
112
+ correct file name for them cannot follow section 8 exactly. A file that
113
+ repeats a report already read, such as the same report as `.json` and as
114
+ `.eml`, is counted once and summed up in one line.
115
+
116
+ ## How it decides
117
+
118
+ - **The same day twice, from the same provider:** it keeps the first copy it
119
+ reads and skips the other, saying whether the skipped copy had a different
120
+ `version` or different counts. The draft leaves resends open (a note in
121
+ section 8.1 asks whether a later report should overwrite or be discarded),
122
+ and `version` is a free string with no order, so there is no defined way
123
+ to pick the newer one. Files are read in name order, so the first copy is
124
+ the first by file name, then by position in the file.
125
+ - **The same day from two providers:** both are kept, as separate streams.
126
+ - **Names:** signing domains and selectors compare without case, and an
127
+ international domain matches its `xn--` form, so `bücher.example` and
128
+ `xn--bcher-kva.example` are one stream.
129
+ - **The reporting window:** section 5.1 says a report MUST cover one UTC day,
130
+ from 00:00:00 to 23:59:59. A report that covers more, or is shifted off the
131
+ day boundary, is skipped with a reason that names section 5.1.
132
+ - **Counts:** whole numbers only. `100.0` reads as 100; `"100"` and negative
133
+ numbers are refused.
80
134
 
81
135
  ## How the web page is built
82
136
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.3.1"
7
+ version = "0.4.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.1"
3
+ __version__ = "0.4.1"
@@ -36,7 +36,7 @@ def main(argv: list[str] | None = None) -> int:
36
36
  return 1
37
37
 
38
38
  if args.json:
39
- text = json.dumps(to_dict(loaded.reports, loaded.skipped), indent=2) + "\n"
39
+ text = json.dumps(to_dict(loaded), indent=2) + "\n"
40
40
  elif args.md or args.markdown or not sys.stdout.isatty():
41
41
  text = to_markdown(loaded) # files and pipes get Markdown, which other tools read
42
42
  else:
@@ -0,0 +1,241 @@
1
+ """Find report files, open them, and drop duplicates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import hashlib
7
+ import io
8
+ import mailbox
9
+ import re
10
+ import zlib
11
+ from collections.abc import Iterable, Iterator
12
+ from dataclasses import dataclass, field
13
+ from email import policy
14
+ from email.message import Message
15
+ from email.parser import BytesParser
16
+ from pathlib import Path
17
+
18
+ from .report import Report, ReportError, parse_items
19
+
20
+ SUFFIXES = (".json", ".json.gz", ".eml", ".mbox")
21
+ # A daily report is a few KB; this stops a stray huge file or gzip bomb
22
+ MAX_BYTES = 10 * 1024 * 1024
23
+
24
+
25
+ @dataclass
26
+ class Loaded:
27
+ reports: list[Report] = field(default_factory=list)
28
+ skipped: list[tuple[str, str]] = field(default_factory=list) # (where, reason)
29
+ # (warning, detail, where): read, but departs from the draft or meets a case it leaves open
30
+ warnings: list[tuple[str, str, str]] = field(default_factory=list)
31
+ # Files whose report bytes were already read, often the same report as .json and .eml
32
+ copies: int = 0
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Mail:
37
+ """How a report arrived by email, for the section 8 checks."""
38
+ name: str # attachment file name as sent
39
+ ctype: str # attachment Content-Type
40
+ gz: bool
41
+ subject: str
42
+ msg_ctype: str # Content-Type of the message that carries it
43
+
44
+ DUPLICATE_DAY = ("the same day came twice from one provider; the first copy read was kept. "
45
+ "The draft leaves this open (a note in section 8.1)")
46
+
47
+
48
+ def _files(paths: Iterable[Path]) -> Iterator[Path]:
49
+ for p in paths:
50
+ if p.is_dir():
51
+ yield from sorted(f for f in p.rglob("*") if f.is_file() and (f.name.lower().endswith(SUFFIXES) or _is_mbox(f)))
52
+ else:
53
+ yield p
54
+
55
+
56
+ def _is_mbox(path: Path) -> bool:
57
+ # Apple Mail exports a folder "Name.mbox" holding a file named just "mbox"
58
+ return path.name.lower() == "mbox" or path.name.lower().endswith(".mbox")
59
+
60
+
61
+ def _gunzip(data: bytes) -> bytes:
62
+ with gzip.GzipFile(fileobj=io.BytesIO(data)) as g:
63
+ out = g.read(MAX_BYTES + 1)
64
+ if len(out) > MAX_BYTES:
65
+ raise ReportError("larger than 10 MB unpacked")
66
+ return out
67
+
68
+
69
+ def _leaves(m: Message, owner: Message) -> Iterator[tuple[Message, Message]]:
70
+ """Each non-multipart part with the email it belongs to; an attached email owns its own parts."""
71
+ if m.get_content_type() == "message/rfc822":
72
+ for inner in m.get_payload():
73
+ yield from _leaves(inner, inner)
74
+ elif m.is_multipart():
75
+ for p in m.get_payload():
76
+ yield from _leaves(p, owner)
77
+ else:
78
+ yield m, owner
79
+
80
+
81
+ def _attachments(msg: Message, label: str) -> Iterator[tuple[str, bytes, Mail]]:
82
+ """Yield (label, report JSON bytes, how it arrived) for each report attached to one email."""
83
+ for part, owner in _leaves(msg, msg):
84
+ name = part.get_filename() or ""
85
+ fname = name.lower()
86
+ ctype = part.get_content_type()
87
+ data = part.get_payload(decode=True) or b""
88
+ where = f"{label} ({fname or ctype})"
89
+ if len(data) > MAX_BYTES:
90
+ raise ReportError(f"{where} is larger than 10 MB")
91
+ mail = lambda gz: Mail(name, ctype, gz, " ".join(str(owner.get("subject", "")).split()), owner.get_content_type())
92
+ if fname.endswith(".gz") or ctype in ("application/gzip", "application/x-gzip") or data[:2] == b"\x1f\x8b":
93
+ yield where, _gunzip(data), mail(True)
94
+ # By name or type, or an attachment of unhelpful type that is a JSON array or object
95
+ elif fname.endswith(".json") or ctype == "application/json" or (
96
+ part.get_content_disposition() == "attachment" and data.lstrip()[:1] in (b"[", b"{")):
97
+ yield where, data, mail(False)
98
+
99
+
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
+ if len(data) > MAX_BYTES:
104
+ 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 not found:
108
+ raise ReportError("no report attachment, which section 8 calls for")
109
+ yield from found
110
+ elif data[:2] == b"\x1f\x8b":
111
+ yield str(path), _gunzip(data), None
112
+ else:
113
+ yield str(path), data, None
114
+
115
+
116
+ def _mbox(path: Path, out: Loaded) -> Iterator[tuple[str, bytes, Mail | None]]:
117
+ """Yield reports from a mailbox export; one skip line for all mail with none attached."""
118
+ box = mailbox.mbox(path, factory=lambda f: BytesParser(policy=policy.default).parse(f), create=False)
119
+ without = 0
120
+ try:
121
+ for i, msg in enumerate(box, 1):
122
+ label = f"{path} message {i}"
123
+ try:
124
+ found = list(_attachments(msg, label))
125
+ # OSError covers gzip.BadGzipFile; one bad message must not end the mailbox
126
+ except (OSError, EOFError, zlib.error, ReportError) as e:
127
+ out.skipped.append((label, str(e)))
128
+ continue
129
+ without += not found
130
+ yield from found
131
+ finally:
132
+ box.close()
133
+ if without:
134
+ out.skipped.append((str(path), f"{without} message{'s' if without != 1 else ''} with no report attached"))
135
+
136
+
137
+ def _differs(kept: Report, dropped: Report) -> str:
138
+ if kept.version != dropped.version:
139
+ return f", version {kept.version or 'none'} kept, version {dropped.version or 'none'} dropped"
140
+ return ", with different counts" if kept.segments != dropped.segments else ", same counts"
141
+
142
+
143
+ def _name(r: Report) -> str:
144
+ # Section 8: the ending date, then domain, selector and source with spaces removed
145
+ return f"{r.day:%Y%m%d}_{r.dkim_domain}_{r.dkim_selector}_{r.source.replace(' ', '')}"
146
+
147
+
148
+ # Characters a file name cannot hold on common systems; "Comcast/Xfinity" and a "*" roll-up
149
+ # selector both carry one, and section 8 only says to remove spaces
150
+ UNSAFE = '/\\:*?"<>|'
151
+ NAME_UNSAFE = ("attachment name stands in for a character a file name cannot hold, such as / or *; "
152
+ "section 8 does not say how to write those")
153
+ SUBJECT_UNSAFE = ("subject stands in for / or * as a file name must, though section 8 gives the source "
154
+ "and selector as they are")
155
+
156
+
157
+ def _loose(expected: str, actual: str) -> bool:
158
+ """Equal, ignoring case, if each file-name-unsafe character in expected may be any character."""
159
+ pattern = "".join("." if c in UNSAFE else re.escape(c) for c in expected.lower())
160
+ return re.fullmatch(pattern, actual.lower()) is not None
161
+
162
+
163
+ def _mail_warnings(mail: Mail, items: list) -> list[tuple[str, str]]:
164
+ out = []
165
+ if mail.msg_ctype != "multipart/report":
166
+ out.append(("message Content-Type is not multipart/report (section 8)", mail.msg_ctype))
167
+ want = "application/gzip" if mail.gz else "application/json"
168
+ if mail.ctype != want:
169
+ out.append(("attachment Content-Type is not application/json, or application/gzip when "
170
+ "compressed (sections 8 and 8.1)", mail.ctype))
171
+ if len(items) > 1:
172
+ out.append(("one attachment holds several reports; the section 8 name and subject fit one", f"{len(items)} reports"))
173
+ elif isinstance(items[0], Report):
174
+ base = _name(items[0])
175
+ file = base + (".json.gz" if mail.gz else ".json")
176
+ if mail.name.lower() == file.lower():
177
+ pass
178
+ elif _loose(file, mail.name):
179
+ out.append((NAME_UNSAFE, f"{mail.name}; section 8 gives {file}"))
180
+ else:
181
+ out.append(("attachment name does not follow section 8: <yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>.json",
182
+ f"{mail.name or '(none)'}; expected {file}"))
183
+ if mail.subject.lower() == f"APRF: {base}".lower():
184
+ pass
185
+ elif _loose(f"APRF: {base}", mail.subject):
186
+ out.append((SUBJECT_UNSAFE, f"{mail.subject}; section 8 gives APRF: {base}"))
187
+ else:
188
+ out.append(("subject does not follow section 8: APRF: <yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>",
189
+ f"{mail.subject or '(none)'}; expected APRF: {base}"))
190
+ return out
191
+
192
+
193
+ def load(paths: Iterable[Path]) -> Loaded:
194
+ out = Loaded()
195
+ seen_bytes: set[str] = set()
196
+ seen_keys: dict[tuple, tuple[str, Report]] = {}
197
+
198
+ parsed: dict[str, list | ReportError] = {}
199
+
200
+ def add(where: str, data: bytes, mail: Mail | None) -> None:
201
+ digest = hashlib.sha256(data).hexdigest()
202
+ copy = digest in parsed
203
+ if not copy:
204
+ try:
205
+ parsed[digest] = parse_items(data)
206
+ except ReportError as e:
207
+ parsed[digest] = e
208
+ items = parsed[digest]
209
+ # An email is checked even when its report was already read from another file
210
+ if mail is not None and not isinstance(items, ReportError):
211
+ out.warnings += [(w, d, where) for w, d in _mail_warnings(mail, items)]
212
+ if copy:
213
+ out.copies += 1
214
+ return
215
+ if isinstance(items, ReportError):
216
+ out.skipped.append((where, str(items)))
217
+ return
218
+ for i, r in enumerate(items, 1):
219
+ label = f"{where} report {i}" if len(items) > 1 else where
220
+ if isinstance(r, ReportError):
221
+ out.skipped.append((label, str(r)))
222
+ continue
223
+ out.warnings += [(w, d, label) for w, d in r.warnings]
224
+ # The draft leaves open whether a resend replaces the first (an open note in
225
+ # section 8.1), and its version is a free string with no order; keep the first
226
+ if r.key in seen_keys:
227
+ first_label, first = seen_keys[r.key]
228
+ out.warnings.append((DUPLICATE_DAY, f"{first_label} kept{_differs(first, r)}", label))
229
+ continue
230
+ seen_keys[r.key] = (label, r)
231
+ out.reports.append(r)
232
+
233
+ for path in _files(paths):
234
+ try:
235
+ # 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))
237
+ for where, data, mail in payloads:
238
+ add(where, data, mail)
239
+ except (OSError, EOFError, zlib.error, ReportError) as e:
240
+ out.skipped.append((str(path), str(e)))
241
+ return out
@@ -9,7 +9,14 @@ from datetime import date
9
9
  from . import __version__
10
10
  from .load import Loaded
11
11
  from .report import DRAFT
12
- from .summary import Row, Stream, streams
12
+ from .summary import Row, Stream, copies_line, grouped, streams
13
+
14
+ WARNINGS_NOTE = "Read anyway. Each names the draft section it departs from, or the case the draft leaves open."
15
+
16
+
17
+ def _warning(w: str, times: int, where: str, detail: str) -> str:
18
+ return f"{w}. {_plural(times, 'time')}, first in {where}" + (f": {detail}" if detail else "")
19
+
13
20
 
14
21
  NO_COUNTS = "These reports carry no counts."
15
22
 
@@ -67,8 +74,12 @@ def to_markdown(loaded: Loaded) -> str:
67
74
  lines += ["Changes worth a look:", ""] + [f"- {_md(c)}" for c in s.changes] + [""]
68
75
  if s.notes:
69
76
  lines += [f"- {_md(n)}" for n in s.notes] + [""]
77
+ if loaded.warnings:
78
+ lines += ["## Warnings", "", WARNINGS_NOTE, ""] + [f"- {_md(_warning(*g))}" for g in grouped(loaded.warnings)] + [""]
70
79
  if loaded.skipped:
71
80
  lines += ["## Skipped", ""] + [f"- {_md(w)}: {_md(r)}" for w, r in loaded.skipped] + [""]
81
+ if loaded.copies:
82
+ lines += [copies_line(loaded.copies), ""]
72
83
  lines.append(f"Read by aprf-reader {__version__} against {DRAFT}.")
73
84
  return "\n".join(lines) + "\n"
74
85
 
@@ -134,8 +145,13 @@ def to_html(loaded: Loaded) -> str:
134
145
  parts.append("<p>Changes worth a look:</p><ul>" + "".join(f"<li>{e(c)}" for c in s.changes) + "</ul>")
135
146
  if s.notes:
136
147
  parts.append("<ul>" + "".join(f"<li>{e(n)}" for n in s.notes) + "</ul>")
148
+ if loaded.warnings:
149
+ parts.append(f"<h2>Warnings</h2><p class=muted>{WARNINGS_NOTE}</p><ul>"
150
+ + "".join(f"<li>{e(_warning(*g))}" for g in grouped(loaded.warnings)) + "</ul>")
137
151
  if loaded.skipped:
138
152
  parts.append("<h2>Skipped</h2><ul>" + "".join(f"<li>{e(w)}: {e(r)}" for w, r in loaded.skipped) + "</ul>")
153
+ if loaded.copies:
154
+ parts.append(f"<p class=muted>{e(copies_line(loaded.copies))}</p>")
139
155
  parts.append(f"<p class=muted>Read by aprf-reader {e(__version__)} against {DRAFT}. Nothing left this computer.</p>")
140
156
  parts.append("</body></html>")
141
157
  return "\n".join(parts) + "\n"
@@ -232,6 +248,14 @@ def to_terminal(loaded: Loaded, color: bool = True, width: int = 100) -> str:
232
248
  for n in s.notes:
233
249
  out += _wrap(n, width - 4, " · ")
234
250
  out.append("")
251
+ if loaded.warnings:
252
+ out.append(st.warn(f"Warnings {len(grouped(loaded.warnings))}"))
253
+ out += [st.dim(line) for line in _wrap(WARNINGS_NOTE, width - 2, " ")]
254
+ for g in grouped(loaded.warnings):
255
+ out += _wrap(_warning(*g), width - 2, " ")
256
+ out.append("")
257
+ if loaded.copies:
258
+ out += [st.dim(line) for line in _wrap(copies_line(loaded.copies), width)] + [""]
235
259
  if loaded.skipped:
236
260
  out.append(st.warn(f"Skipped {len(loaded.skipped)}"))
237
261
  for w, r in loaded.skipped:
@@ -7,6 +7,7 @@ edit to this file.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import re
10
11
  from dataclasses import dataclass, field
11
12
  from datetime import UTC, date, datetime
12
13
 
@@ -25,6 +26,14 @@ def uses_sdi(value: str) -> bool:
25
26
  return first not in NO_SDI
26
27
 
27
28
 
29
+ DAY = 86400
30
+ ONE_DAY = "not one UTC day from 00:00:00 to 23:59:59, which draft section 5.1 requires"
31
+ # Placement names section 6 gives as examples; engagement names section 5.2.2 lists
32
+ NAMED_CLASSIFICATION = ("inbox", "unwanted", "forwarded", "promotional")
33
+ # sdi as published in DNS (section 4): a header field name, a comma, one separator character
34
+ SDI_FORMAT = re.compile(r"[!-9;-~]+,\S")
35
+
36
+
28
37
  class ReportError(ValueError):
29
38
  """The data is not an APRF report this module can read."""
30
39
 
@@ -50,6 +59,8 @@ class Report:
50
59
  contact_info: str
51
60
  extra_info: str
52
61
  segments: tuple[Segment, ...]
62
+ # (warning, detail) where the report departs from the draft but can still be read
63
+ warnings: tuple[tuple[str, str], ...] = ()
53
64
 
54
65
  @property
55
66
  def day(self) -> date:
@@ -95,23 +106,23 @@ def _int(v: object) -> int | None:
95
106
  return v if isinstance(v, int) else None
96
107
 
97
108
 
98
- def _counts(value: object, where: str) -> dict[str, int]:
109
+ def _counts(value: object, where: str, section: str) -> dict[str, int]:
99
110
  if value is None:
100
111
  return {}
101
112
  if not isinstance(value, dict):
102
- raise ReportError(f"{where} is not an object")
113
+ raise ReportError(f"{where} is not an object ({section})")
103
114
  out = {}
104
115
  for name, n in value.items():
105
116
  k = _int(n)
106
117
  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)}")
118
+ raise ReportError(f"{where}.{name} is not a count ({section}): {json.dumps(n, ensure_ascii=False, separators=(',', ':'))}")
108
119
  out[str(name)] = k
109
120
  return out
110
121
 
111
122
 
112
123
  def _segment(item: object, i: int) -> Segment:
113
124
  if not isinstance(item, dict):
114
- raise ReportError(f"body[{i}] is not an object")
125
+ raise ReportError(f"body[{i}] is not an object (section 5.2.2)")
115
126
  seg = item.get("segment")
116
127
  # Section 5.2.2 shows a string, the samples a list
117
128
  if seg is None:
@@ -121,11 +132,11 @@ def _segment(item: object, i: int) -> Segment:
121
132
  elif isinstance(seg, list) and all(isinstance(s, str) for s in seg):
122
133
  path = tuple(seg)
123
134
  else:
124
- raise ReportError(f"body[{i}].segment is not a string or list of strings")
135
+ raise ReportError(f"body[{i}].segment is not a string or list of strings (section 5.2.2)")
125
136
  return Segment(
126
137
  path,
127
- _counts(item.get("classification"), f"body[{i}].classification"),
128
- _counts(item.get("engagement"), f"body[{i}].engagement"),
138
+ _counts(item.get("classification"), f"body[{i}].classification", "section 6"),
139
+ _counts(item.get("engagement"), f"body[{i}].engagement", "section 7"),
129
140
  )
130
141
 
131
142
 
@@ -135,27 +146,30 @@ def _text(value: object) -> str:
135
146
 
136
147
  def _report(obj: object) -> Report:
137
148
  if not isinstance(obj, dict) or not isinstance(obj.get("header"), dict):
138
- raise ReportError("no header object")
149
+ raise ReportError("no header object (section 5.2.1)")
139
150
  h = obj["header"]
140
151
  for name in ("dkim_domain", "dkim_selector"):
141
152
  if not isinstance(h.get(name), str) or not h[name]:
142
- raise ReportError(f"header.{name} is missing")
153
+ raise ReportError(f"header.{name} is missing (section 5.2.1)")
143
154
  start, end = _int(h.get("report_start")), _int(h.get("report_end"))
144
155
  for name, v in (("report_start", start), ("report_end", end)):
145
156
  if v is None:
146
- raise ReportError(f"header.{name} is not an epoch time")
157
+ raise ReportError(f"header.{name} is not an epoch time (section 5.2.1)")
147
158
  # Seconds past 1e11 are beyond the year 5138; up to 1e14 that is milliseconds
148
159
  if 10**11 <= abs(v) < 10**14:
149
- raise ReportError(f"header.{name} looks like milliseconds, not seconds: {v}")
160
+ raise ReportError(f"header.{name} looks like milliseconds, not epoch seconds (section 5.2.1): {v}")
150
161
  if end < start:
151
- raise ReportError("header.report_end is before report_start")
162
+ raise ReportError("header.report_end is before report_start (section 5.1)")
152
163
  try:
153
164
  _day(end)
154
165
  except (OverflowError, OSError, ValueError):
155
- raise ReportError(f"header.report_end is out of range: {end}") from None
166
+ raise ReportError(f"header.report_end is out of range (section 5.2.1): {end}") from None
167
+ if start % DAY or end != start + DAY - 1:
168
+ raise ReportError(ONE_DAY)
156
169
  body = obj.get("body", [])
157
170
  if not isinstance(body, list):
158
- raise ReportError("body is not a list")
171
+ raise ReportError("body is not a list of segments (section 5.2.2)")
172
+ segments = tuple(_segment(s, i) for i, s in enumerate(body))
159
173
  return Report(
160
174
  source=_text(h.get("source")) or "unknown",
161
175
  dkim_domain=h["dkim_domain"],
@@ -167,17 +181,44 @@ def _report(obj: object) -> Report:
167
181
  sdi_used=_text(h.get("sdi_used")).strip(" -"),
168
182
  contact_info=_text(h.get("contact_info")),
169
183
  extra_info=_text(h.get("extra_info")),
170
- segments=tuple(_segment(s, i) for i, s in enumerate(body)),
184
+ segments=segments,
185
+ warnings=_warnings(h, segments),
171
186
  )
172
187
 
173
188
 
189
+ def _warnings(h: dict, segments: tuple[Segment, ...]) -> tuple[tuple[str, str], ...]:
190
+ out = []
191
+ if "sdi_used" not in h:
192
+ out.append(("no sdi_used field; section 5.2.1 requires N/A when no SDI is used", ""))
193
+ else:
194
+ raw = h["sdi_used"]
195
+ if not (raw in ("N/A", "N/F") or (isinstance(raw, str) and SDI_FORMAT.fullmatch(raw))):
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=(',', ':'))))
198
+ keys = lambda attr: sorted({k for s in segments for k in getattr(s, attr)})
199
+ for k in keys("classification"):
200
+ if k not in NAMED_CLASSIFICATION:
201
+ out.append((f"classification key {json.dumps(k, ensure_ascii=False)} is not one section 6 names "
202
+ "(inbox, unwanted, forwarded, promotional); read as given", ""))
203
+ for k in keys("engagement"):
204
+ if k not in ENGAGEMENT_NAMES:
205
+ out.append((f"engagement key {json.dumps(k, ensure_ascii=False)} is not positive, neutral or "
206
+ "negative (section 5.2.2); read as given", ""))
207
+ return tuple(out)
208
+
209
+
210
+ def _no_constant(name: str) -> None:
211
+ # Python's json takes NaN and Infinity; RFC 8259 and browsers do not
212
+ raise json.JSONDecodeError(f"{name} is not valid JSON", name, 0)
213
+
214
+
174
215
  def parse_items(data: bytes) -> list[Report | ReportError]:
175
216
  """Each report in a document, or the reason it cannot be read, so one bad
176
217
  report does not cost the rest of the file."""
177
218
  try:
178
- doc = json.loads(data)
219
+ doc = json.loads(data, parse_constant=_no_constant)
179
220
  except (UnicodeDecodeError, json.JSONDecodeError) as e:
180
- raise ReportError(f"not JSON: {e}") from None
221
+ raise ReportError(f"not valid JSON, which section 5 requires: {e}") from None
181
222
  except RecursionError:
182
223
  raise ReportError("JSON nested too deeply") from None
183
224
  items = doc if isinstance(doc, list) else [doc]
@@ -119,8 +119,25 @@ def streams(reports: list[Report]) -> list[Stream]:
119
119
  return [Stream(src, dom, sel, days) for (src, dom, sel), days in sorted(groups.items())]
120
120
 
121
121
 
122
- def to_dict(reports: list[Report], skipped: list[tuple[str, str]]) -> dict:
122
+ def grouped(warnings: list[tuple[str, str, str]]) -> list[tuple[str, int, str, str]]:
123
+ """(warning, times, first where, first detail), in the order first seen."""
124
+ out: dict[str, list] = {}
125
+ for w, d, where in warnings:
126
+ if w in out:
127
+ out[w][1] += 1
128
+ else:
129
+ out[w] = [w, 1, where, d]
130
+ return [tuple(v) for v in out.values()]
131
+
132
+
133
+ def copies_line(n: int) -> str:
134
+ return (f"{n} file{'s' if n != 1 else ''} repeated a report already read, such as the same report "
135
+ "saved as .json and as .eml, and counted once.")
136
+
137
+
138
+ def to_dict(loaded) -> dict:
123
139
  """The summary as plain data, for --json and for checking the web page against."""
140
+ reports, skipped = loaded.reports, loaded.skipped
124
141
  return {
125
142
  "reports": len(reports),
126
143
  "streams": [{
@@ -133,4 +150,6 @@ def to_dict(reports: list[Report], skipped: list[tuple[str, str]]) -> dict:
133
150
  "changes": s.changes, "notes": s.notes,
134
151
  } for s in streams(reports)],
135
152
  "skipped": [{"where": w, "reason": r} for w, r in skipped],
153
+ "warnings": [{"warning": w, "times": n, "first": where, "detail": d} for w, n, where, d in grouped(loaded.warnings)],
154
+ "copies": loaded.copies,
136
155
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.3.1
3
+ Version: 0.4.1
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.3, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
27
+ **Status: 0.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
28
28
 
29
29
  ## What APRF is
30
30
 
@@ -88,8 +88,62 @@ to mark a change in what a category counts.
88
88
  or share. Nothing is uploaded anywhere.
89
89
  - Follow the draft as it changes. The report format lives in one module
90
90
  (`src/aprf_reader/report.py`), so a new draft revision is a small update.
91
- - When the same day arrives twice, keep the first copy and say so. The draft
92
- has not yet decided whether a resend replaces the original.
91
+ - Check each report against the draft and skip, with the reason, any it
92
+ cannot trust. One bad report in a file does not cost the rest of the file.
93
+
94
+ ## Checking against the draft
95
+
96
+ Every report is checked against draft-ietf-mailmaint-aprf-00, and every
97
+ message names the section, so a list of them can go straight into a review of
98
+ the draft. On the web page, Copy for a draft review copies them as text.
99
+
100
+ **Skipped**, because the report cannot be trusted:
101
+
102
+ | Problem | Section |
103
+ |---|---|
104
+ | Not valid JSON | 5 |
105
+ | A required header field missing or of the wrong type; times not in epoch seconds | 5.2.1 |
106
+ | Not one UTC day from 00:00:00 to 23:59:59, or ending before it starts | 5.1 |
107
+ | Body not a list of segments; a segment not an object | 5.2.2 |
108
+ | A placement or engagement count that is not a whole number | 6, 7 |
109
+ | A saved email with no report attached | 8 |
110
+
111
+ **Warnings**, where the report is still read:
112
+
113
+ | Warning | Section |
114
+ |---|---|
115
+ | `sdi_used` is not N/A, N/F, or the `sdi` value as published in DNS, or is missing | 5.2.1 |
116
+ | Attachment name or subject not `<yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>` | 8 |
117
+ | Message not `multipart/report`; attachment not `application/json` (or `application/gzip` for `.json.gz`) | 8, 8.1 |
118
+ | One attachment holding several reports | 8 |
119
+ | A placement key other than the four section 6 names, or an engagement key other than positive, neutral and negative | 6, 5.2.2 |
120
+ | A name or subject that writes `/` or `*` some other way, as a file name must | 8 is silent |
121
+ | The same day twice from one provider | left open, note in 8.1 |
122
+
123
+ Comcast's beta sends `sdi_used` as ` - N/A - Beta - Not in use` and a source
124
+ of `Comcast/Xfinity`, so its reports carry the `sdi_used` warning, and a
125
+ correct file name for them cannot follow section 8 exactly. A file that
126
+ repeats a report already read, such as the same report as `.json` and as
127
+ `.eml`, is counted once and summed up in one line.
128
+
129
+ ## How it decides
130
+
131
+ - **The same day twice, from the same provider:** it keeps the first copy it
132
+ reads and skips the other, saying whether the skipped copy had a different
133
+ `version` or different counts. The draft leaves resends open (a note in
134
+ section 8.1 asks whether a later report should overwrite or be discarded),
135
+ and `version` is a free string with no order, so there is no defined way
136
+ to pick the newer one. Files are read in name order, so the first copy is
137
+ the first by file name, then by position in the file.
138
+ - **The same day from two providers:** both are kept, as separate streams.
139
+ - **Names:** signing domains and selectors compare without case, and an
140
+ international domain matches its `xn--` form, so `bücher.example` and
141
+ `xn--bcher-kva.example` are one stream.
142
+ - **The reporting window:** section 5.1 says a report MUST cover one UTC day,
143
+ from 00:00:00 to 23:59:59. A report that covers more, or is shifted off the
144
+ day boundary, is skipped with a reason that names section 5.1.
145
+ - **Counts:** whole numbers only. `100.0` reads as 100; `"100"` and negative
146
+ numbers are refused.
93
147
 
94
148
  ## How the web page is built
95
149
 
@@ -88,9 +88,12 @@ def test_load_gz_eml_folder_and_skips(tmp_path):
88
88
 
89
89
  loaded = load([tmp_path])
90
90
  assert sorted(r.day.day for r in loaded.reports) == [1, 2, 3]
91
- reasons = dict((Path(w.split(" ")[0]).name, r) for w, r in loaded.skipped)
92
- assert set(reasons) == {"copy.json", "resend.json", "broken.json"}
93
- assert "duplicate" in reasons["copy.json"] and "same day" in reasons["resend.json"]
91
+ assert [Path(w).name for w, _ in loaded.skipped] == ["broken.json"]
92
+ assert loaded.copies == 1 # copy.json has a.json's bytes
93
+ warned = {(w.split(";")[0].split(".")[0], Path(where.split(" ")[0]).name) for w, _, where in loaded.warnings}
94
+ assert ("the same day came twice from one provider", "resend.json") in warned
95
+ # The test email is multipart/mixed; its name and subject follow section 8
96
+ assert {w for w, _, where in loaded.warnings if "c.eml" in where} == {"message Content-Type is not multipart/report (section 8)"}
94
97
 
95
98
 
96
99
  def report_email(day: int, payload: bytes, subtype: str, filename: str | None) -> EmailMessage:
@@ -238,14 +241,25 @@ def test_cli_terminal_when_interactive(monkeypatch, capsys):
238
241
 
239
242
  def test_stress_set_one_bad_report_does_not_cost_the_file():
240
243
  loaded = load([FIX / "stress_100.json"])
241
- assert len(loaded.reports) == 88
244
+ assert len(loaded.reports) == 86
242
245
  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"
246
+ # s18 spans two days and s19 is shifted an hour: draft section 5.1 requires one UTC day
247
+ assert "section 5.1" in reasons["77"] and "section 5.1" in reasons["78"]
248
+ # Same day from the same provider: keep the first, warn, and say how the copy differs
249
+ dup = {where.rsplit(" ", 1)[-1]: d for w, d, where in loaded.warnings if w.startswith("the same day came twice")}
250
+ assert dup["64"] == f"{FIX}/stress_100.json report 63 kept, version 1 kept, version 2 dropped"
251
+ assert dup["69"].endswith("report 68 kept, with different counts") # bücher.example
252
+ assert "report 3 kept" in dup["70"] # DNS-AUDIT.COM / SELECTOR1 is dns-audit.com / selector1
253
+ assert not {"64", "69", "70"} & set(reasons)
254
+ warned = {w for w, _, _ in loaded.warnings}
255
+ assert "sdi_used is not N/A, N/F, or the sdi value as published in DNS (section 5.2.1)" in warned
256
+ assert any(w.startswith('classification key "spam"') for w in warned)
257
+ assert not any('"promotional"' in w or '"forwarded"' in w for w in warned) # section 6 names them
258
+ assert reasons["73"] == 'body[0].engagement.positive is not a count (section 7): "100"'
259
+ assert reasons["79"] == "header.report_end is before report_start (section 5.1)"
245
260
  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
261
  ss = {(s.source, s.domain, s.selector): s for s in streams(loaded.reports)}
262
+ assert ("Big MBP Co", "dns-audit.com", "s11") in ss # another provider, same day: its own stream
249
263
  obj = report(0)
250
264
  obj["body"][0]["classification"]["unwanted"] = 100.0
251
265
  assert parse(json.dumps(obj).encode())[0].segments[0].classification["unwanted"] == 100
@@ -29,7 +29,7 @@ def web(mode: str, paths: list[Path]) -> str:
29
29
 
30
30
  def same(paths: list[Path]) -> None:
31
31
  loaded = load(paths)
32
- assert json.loads(web("--json", paths)) == to_dict(loaded.reports, loaded.skipped)
32
+ assert json.loads(web("--json", paths)) == to_dict(loaded)
33
33
  assert web("--md", paths) == to_markdown(loaded)
34
34
  assert web("--html", paths) == to_html(loaded)
35
35
 
@@ -104,3 +104,27 @@ def test_hostile_text_escaped_the_same(tmp_path):
104
104
  obj["body"][0]["classification"][hostile] = 7
105
105
  (tmp_path / "h.json").write_text(json.dumps(obj))
106
106
  same([tmp_path / "h.json"])
107
+
108
+
109
+ def test_section_8_checks_the_same(tmp_path):
110
+ raw = json.dumps(report(9)).encode() # 2026-10-10, Example.com, s1, Comcast/Xfinity
111
+ good = email(raw, "json", "20261010_Example.com_s1_Comcast/Xfinity.json")
112
+ good.replace_header("Subject", "APRF: 20261010_Example.com_s1_Comcast/Xfinity")
113
+ good.set_type("multipart/report")
114
+ safe = email(gzip.compress(json.dumps(report(10)).encode()), "gzip", "20261011_example.com_s1_Comcast-Xfinity.json.gz")
115
+ safe.replace_header("Subject", "APRF: 20261011_example.com_s1_Comcast-Xfinity")
116
+ wrong = email(json.dumps(report(11)).encode(), "octet-stream", "report.json")
117
+ paths = []
118
+ for name, msg in (("good.eml", good), ("safe.eml", safe), ("wrong.eml", wrong)):
119
+ (tmp_path / name).write_bytes(msg.as_bytes())
120
+ paths.append(tmp_path / name)
121
+ same(paths)
122
+ loaded = load(paths)
123
+ by_file = {}
124
+ for w, _, where in loaded.warnings:
125
+ by_file.setdefault(Path(where.split(" ")[0]).name, set()).add(w.split(";")[0].split(":")[0].split(" (")[0])
126
+ # Name, subject and both Content-Types follow section 8; only Comcast's beta sdi_used remains
127
+ assert by_file["good.eml"] == {"sdi_used is not N/A, N/F, or the sdi value as published in DNS"}
128
+ assert "attachment name stands in for a character a file name cannot hold, such as / or *" in by_file["safe.eml"]
129
+ assert {"attachment name does not follow section 8", "subject does not follow section 8",
130
+ "attachment Content-Type is not application/json, or application/gzip when compressed"} <= by_file["wrong.eml"]
@@ -1,143 +0,0 @@
1
- """Find report files, open them, and drop duplicates."""
2
-
3
- from __future__ import annotations
4
-
5
- import gzip
6
- import hashlib
7
- import io
8
- import mailbox
9
- import zlib
10
- from collections.abc import Iterable, Iterator
11
- from dataclasses import dataclass, field
12
- from email import policy
13
- from email.message import Message
14
- from email.parser import BytesParser
15
- from pathlib import Path
16
-
17
- from .report import Report, ReportError, parse_items
18
-
19
- SUFFIXES = (".json", ".json.gz", ".eml", ".mbox")
20
- # A daily report is a few KB; this stops a stray huge file or gzip bomb
21
- MAX_BYTES = 10 * 1024 * 1024
22
-
23
-
24
- @dataclass
25
- class Loaded:
26
- reports: list[Report] = field(default_factory=list)
27
- skipped: list[tuple[str, str]] = field(default_factory=list) # (where, reason)
28
-
29
-
30
- def _files(paths: Iterable[Path]) -> Iterator[Path]:
31
- for p in paths:
32
- if p.is_dir():
33
- yield from sorted(f for f in p.rglob("*") if f.is_file() and (f.name.lower().endswith(SUFFIXES) or _is_mbox(f)))
34
- else:
35
- yield p
36
-
37
-
38
- def _is_mbox(path: Path) -> bool:
39
- # Apple Mail exports a folder "Name.mbox" holding a file named just "mbox"
40
- return path.name.lower() == "mbox" or path.name.lower().endswith(".mbox")
41
-
42
-
43
- def _gunzip(data: bytes) -> bytes:
44
- with gzip.GzipFile(fileobj=io.BytesIO(data)) as g:
45
- out = g.read(MAX_BYTES + 1)
46
- if len(out) > MAX_BYTES:
47
- raise ReportError("larger than 10 MB unpacked")
48
- return out
49
-
50
-
51
- def _attachments(msg: Message, label: str) -> Iterator[tuple[str, bytes]]:
52
- """Yield (label, report JSON bytes) for each report attached to one email."""
53
- for part in msg.walk():
54
- if part.is_multipart():
55
- continue
56
- fname = (part.get_filename() or "").lower()
57
- ctype = part.get_content_type()
58
- data = part.get_payload(decode=True) or b""
59
- where = f"{label} ({fname or ctype})"
60
- if len(data) > MAX_BYTES:
61
- raise ReportError(f"{where} is larger than 10 MB")
62
- if fname.endswith(".gz") or ctype in ("application/gzip", "application/x-gzip") or data[:2] == b"\x1f\x8b":
63
- yield where, _gunzip(data)
64
- # By name or type, or an attachment of unhelpful type that is a JSON array or object
65
- elif fname.endswith(".json") or ctype == "application/json" or (
66
- part.get_content_disposition() == "attachment" and data.lstrip()[:1] in (b"[", b"{")):
67
- yield where, data
68
-
69
-
70
- def _payloads(path: Path) -> Iterator[tuple[str, bytes]]:
71
- """Yield (label, report JSON bytes) for each report in a file."""
72
- data = path.read_bytes()
73
- if len(data) > MAX_BYTES:
74
- raise ReportError("larger than 10 MB")
75
- if path.name.lower().endswith(".eml"):
76
- found = list(_attachments(BytesParser(policy=policy.default).parsebytes(data), str(path)))
77
- if not found:
78
- raise ReportError("no report attachment")
79
- yield from found
80
- elif data[:2] == b"\x1f\x8b":
81
- yield str(path), _gunzip(data)
82
- else:
83
- yield str(path), data
84
-
85
-
86
- def _mbox(path: Path, out: Loaded) -> Iterator[tuple[str, bytes]]:
87
- """Yield reports from a mailbox export; one skip line for all mail with none attached."""
88
- box = mailbox.mbox(path, factory=lambda f: BytesParser(policy=policy.default).parse(f), create=False)
89
- without = 0
90
- try:
91
- for i, msg in enumerate(box, 1):
92
- label = f"{path} message {i}"
93
- try:
94
- found = list(_attachments(msg, label))
95
- # OSError covers gzip.BadGzipFile; one bad message must not end the mailbox
96
- except (OSError, EOFError, zlib.error, ReportError) as e:
97
- out.skipped.append((label, str(e)))
98
- continue
99
- without += not found
100
- yield from found
101
- finally:
102
- box.close()
103
- if without:
104
- out.skipped.append((str(path), f"{without} message{'s' if without != 1 else ''} with no report attached"))
105
-
106
-
107
- def load(paths: Iterable[Path]) -> Loaded:
108
- out = Loaded()
109
- seen_bytes: set[str] = set()
110
- seen_keys: dict[tuple, str] = {}
111
-
112
- def add(where: str, data: bytes) -> None:
113
- digest = hashlib.sha256(data).hexdigest()
114
- if digest in seen_bytes:
115
- out.skipped.append((where, "duplicate of a file already read"))
116
- return
117
- seen_bytes.add(digest)
118
- try:
119
- items = parse_items(data)
120
- except ReportError as e:
121
- out.skipped.append((where, str(e)))
122
- return
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
128
- # The draft does not yet say whether a resend replaces the first; keep the first
129
- if r.key in seen_keys:
130
- out.skipped.append((label, f"same day already reported in {seen_keys[r.key]}; kept that one"))
131
- continue
132
- seen_keys[r.key] = label
133
- out.reports.append(r)
134
-
135
- for path in _files(paths):
136
- try:
137
- # An mbox can be large, so it is read one message at a time
138
- payloads = _mbox(path, out) if _is_mbox(path) else list(_payloads(path))
139
- for where, data in payloads:
140
- add(where, data)
141
- except (OSError, EOFError, zlib.error, ReportError) as e:
142
- out.skipped.append((str(path), str(e)))
143
- return out
File without changes
File without changes