aprf-reader 0.3.0__tar.gz → 0.4.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.3.0/src/aprf_reader.egg-info → aprf_reader-0.4.0}/PKG-INFO +58 -4
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/README.md +57 -3
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/pyproject.toml +1 -1
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader/__init__.py +1 -1
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader/cli.py +1 -1
- aprf_reader-0.4.0/src/aprf_reader/load.py +241 -0
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader/render.py +46 -3
- aprf_reader-0.4.0/src/aprf_reader/report.py +242 -0
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader/summary.py +24 -6
- {aprf_reader-0.3.0 → aprf_reader-0.4.0/src/aprf_reader.egg-info}/PKG-INFO +58 -4
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/tests/test_reader.py +38 -3
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/tests/test_web_parity.py +27 -2
- aprf_reader-0.3.0/src/aprf_reader/load.py +0 -139
- aprf_reader-0.3.0/src/aprf_reader/report.py +0 -142
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/LICENSE +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/setup.cfg +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader.egg-info/SOURCES.txt +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader.egg-info/entry_points.txt +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader.egg-info/requires.txt +0 -0
- {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aprf-reader
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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.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
|
-
-
|
|
92
|
-
|
|
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.
|
|
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
|
-
-
|
|
79
|
-
|
|
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
|
|
|
@@ -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
|
|
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,16 @@ 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
|
+
|
|
20
|
+
|
|
21
|
+
NO_COUNTS = "These reports carry no counts."
|
|
13
22
|
|
|
14
23
|
CAVEAT = (
|
|
15
24
|
"Counts are bucket upper bounds chosen by each provider: a ceiling, not an exact figure. "
|
|
@@ -50,6 +59,9 @@ def to_markdown(loaded: Loaded) -> str:
|
|
|
50
59
|
for s in ss:
|
|
51
60
|
lines += [f"## {_md(s.source)}: {_md(s.domain)}, selector {_md(s.selector)}", "", _span(s), ""]
|
|
52
61
|
cols = [k for _, k in s.columns]
|
|
62
|
+
if not cols:
|
|
63
|
+
lines += [NO_COUNTS, ""]
|
|
64
|
+
continue
|
|
53
65
|
lines.append("| Day | Segment | " + " | ".join(_md(c) for c in cols) + " |")
|
|
54
66
|
lines.append("|---|---|" + "---:|" * len(cols))
|
|
55
67
|
for row in s.rows:
|
|
@@ -62,8 +74,12 @@ def to_markdown(loaded: Loaded) -> str:
|
|
|
62
74
|
lines += ["Changes worth a look:", ""] + [f"- {_md(c)}" for c in s.changes] + [""]
|
|
63
75
|
if s.notes:
|
|
64
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)] + [""]
|
|
65
79
|
if loaded.skipped:
|
|
66
80
|
lines += ["## Skipped", ""] + [f"- {_md(w)}: {_md(r)}" for w, r in loaded.skipped] + [""]
|
|
81
|
+
if loaded.copies:
|
|
82
|
+
lines += [copies_line(loaded.copies), ""]
|
|
67
83
|
lines.append(f"Read by aprf-reader {__version__} against {DRAFT}.")
|
|
68
84
|
return "\n".join(lines) + "\n"
|
|
69
85
|
|
|
@@ -112,6 +128,9 @@ def to_html(loaded: Loaded) -> str:
|
|
|
112
128
|
for s in ss:
|
|
113
129
|
cols = [k for _, k in s.columns]
|
|
114
130
|
parts.append(f"<h2>{e(s.source)}: {e(s.domain)}, selector {e(s.selector)}</h2>")
|
|
131
|
+
if not cols:
|
|
132
|
+
parts.append(f"<p class=muted>{e(_span(s))}</p><p>{NO_COUNTS}</p>")
|
|
133
|
+
continue
|
|
115
134
|
parts.append(f"<p class=muted>{e(_span(s))}</p><div class=wrap><table><thead><tr><th>Day<th>Segment")
|
|
116
135
|
parts.append("".join(f"<th>{e(c)}" for c in cols) + "</thead><tbody>")
|
|
117
136
|
parts.append("<tr class=trend><td>Trend<td class=muted>sum per day")
|
|
@@ -126,8 +145,13 @@ def to_html(loaded: Loaded) -> str:
|
|
|
126
145
|
parts.append("<p>Changes worth a look:</p><ul>" + "".join(f"<li>{e(c)}" for c in s.changes) + "</ul>")
|
|
127
146
|
if s.notes:
|
|
128
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>")
|
|
129
151
|
if loaded.skipped:
|
|
130
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>")
|
|
131
155
|
parts.append(f"<p class=muted>Read by aprf-reader {e(__version__)} against {DRAFT}. Nothing left this computer.</p>")
|
|
132
156
|
parts.append("</body></html>")
|
|
133
157
|
return "\n".join(parts) + "\n"
|
|
@@ -166,6 +190,13 @@ def _bars(points: list[tuple[int, int]], width: int) -> str:
|
|
|
166
190
|
return "".join(" " if v is None else BARS[min(7, v * 7 // top)] if v else BARS[0] for v in cells)
|
|
167
191
|
|
|
168
192
|
|
|
193
|
+
def _spread(points: list[tuple[int, int]]) -> str:
|
|
194
|
+
lo, hi = min(v for _, v in points), max(v for _, v in points)
|
|
195
|
+
if lo == hi:
|
|
196
|
+
return f"{lo:,}" if len(points) == 1 else f"{lo:,} every day"
|
|
197
|
+
return f"{lo:,} to {hi:,}"
|
|
198
|
+
|
|
199
|
+
|
|
169
200
|
def _date(d) -> str:
|
|
170
201
|
return f"{d:%b} {d.day}"
|
|
171
202
|
|
|
@@ -195,10 +226,13 @@ def to_terminal(loaded: Loaded, color: bool = True, width: int = 100) -> str:
|
|
|
195
226
|
line += st.dim(" · ") + st.warn(f"{_plural(s.missing, 'day')} with no report")
|
|
196
227
|
out += [line, ""]
|
|
197
228
|
names = [k for _, k in s.columns]
|
|
229
|
+
if not names:
|
|
230
|
+
out += [" " + st.dim(NO_COUNTS), ""]
|
|
231
|
+
continue
|
|
198
232
|
nw = max([len(n) for n in names] + [5])
|
|
199
233
|
trends = [s.trend(i) for i in range(len(names))]
|
|
200
234
|
latest = [f"{t[-1][1]:,}" for t in trends]
|
|
201
|
-
ranges = [
|
|
235
|
+
ranges = [_spread(t) for t in trends]
|
|
202
236
|
lw, rw = max(len(x) for x in latest + ["latest"]), max(len(x) for x in ranges + ["range"])
|
|
203
237
|
bw = max(10, min(60, width - (2 + nw + 2 + lw + 2 + rw + 2)))
|
|
204
238
|
out.append(st.dim(f" {'':<{nw}} {'latest':>{lw}} {'range':<{rw}} trend"))
|
|
@@ -214,9 +248,18 @@ def to_terminal(loaded: Loaded, color: bool = True, width: int = 100) -> str:
|
|
|
214
248
|
for n in s.notes:
|
|
215
249
|
out += _wrap(n, width - 4, " · ")
|
|
216
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)] + [""]
|
|
217
259
|
if loaded.skipped:
|
|
218
260
|
out.append(st.warn(f"Skipped {len(loaded.skipped)}"))
|
|
219
|
-
|
|
261
|
+
for w, r in loaded.skipped:
|
|
262
|
+
out += [st.dim(line) for line in _wrap(f"{w}: {r}", width - 2, " ")]
|
|
220
263
|
out.append("")
|
|
221
264
|
out.append(st.dim(f"aprf-reader {__version__}, {DRAFT}. Daily tables: --markdown, or --html for a page."))
|
|
222
265
|
return "\n".join(out) + "\n"
|