aprf-reader 0.4.1__tar.gz → 0.6.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.
Files changed (22) hide show
  1. {aprf_reader-0.4.1/src/aprf_reader.egg-info → aprf_reader-0.6.0}/PKG-INFO +27 -10
  2. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/README.md +26 -9
  3. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/pyproject.toml +1 -1
  4. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/__init__.py +1 -1
  5. aprf_reader-0.6.0/src/aprf_reader/cli.py +84 -0
  6. aprf_reader-0.6.0/src/aprf_reader/imap.py +77 -0
  7. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/load.py +88 -11
  8. {aprf_reader-0.4.1 → aprf_reader-0.6.0/src/aprf_reader.egg-info}/PKG-INFO +27 -10
  9. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader.egg-info/SOURCES.txt +2 -0
  10. aprf_reader-0.6.0/tests/test_imap.py +105 -0
  11. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/tests/test_reader.py +5 -5
  12. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/tests/test_web_parity.py +58 -6
  13. aprf_reader-0.4.1/src/aprf_reader/cli.py +0 -55
  14. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/LICENSE +0 -0
  15. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/setup.cfg +0 -0
  16. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/render.py +0 -0
  17. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/report.py +0 -0
  18. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader/summary.py +0 -0
  19. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
  20. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader.egg-info/entry_points.txt +0 -0
  21. {aprf_reader-0.4.1 → aprf_reader-0.6.0}/src/aprf_reader.egg-info/requires.txt +0 -0
  22. {aprf_reader-0.4.1 → aprf_reader-0.6.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.4.1
3
+ Version: 0.6.0
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -20,11 +20,11 @@ points out the days worth a look, such as a jump in unwanted mail.
20
20
 
21
21
  Two ways to use it, with the same results:
22
22
 
23
- - **In your browser:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop
24
- your reports on the page. They are read on your computer and never uploaded.
23
+ - **On the web:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop or
24
+ paste your reports on the page.
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.6, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
28
28
 
29
29
  ## What APRF is
30
30
 
@@ -51,6 +51,7 @@ aprf-reader APRF.mbox # a mail folder exported as mbox
51
51
  aprf-reader reports/ --markdown # Markdown with daily tables
52
52
  aprf-reader reports/ --html summary.html # and a page you can open or share
53
53
  aprf-reader reports/ --json # the summary as data, for scripts
54
+ aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF
54
55
  ```
55
56
 
56
57
  In a terminal you get an overview: for each stream, each count's latest value,
@@ -59,12 +60,26 @@ the changes worth a look. Piped or written to a file with `--md`, the output
59
60
  is Markdown with the full daily tables.
60
61
 
61
62
  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
63
+ mailbox exports (`.mbox`), `.zip` files of any of those, or folders of them. The easiest way to collect reports
63
64
  is a mail filter that moves them into one folder, then an export of that folder:
64
65
  Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
65
66
  label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
66
67
  Mailbox > Export Mailbox. Comcast's reports come from
67
- `aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively. Files it skips are listed
68
+ `aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively.
69
+
70
+ Or skip the export and read the folder straight from your mailbox over IMAP:
71
+
72
+ ```bash
73
+ aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF --since 2026-10-01
74
+ ```
75
+
76
+ It opens the folder read-only and fetches with `BODY.PEEK`, so nothing is
77
+ marked read, moved or deleted. It reads messages whose subject contains
78
+ `APRF:`; `--all-mail` reads every message in the folder. The password comes
79
+ from the `APRF_IMAP_PASSWORD` environment variable, or it asks. Gmail and
80
+ Outlook.com need an app password, not your account password, and a Gmail
81
+ label is read as a folder of the same name. The connection uses TLS on port
82
+ 993; give another port as `--imap host:port`. Files it skips are listed
68
83
  on stderr and at the end of the summary, each with a reason.
69
84
 
70
85
  Each stream (provider, signing domain, selector) gets a table with one row per
@@ -78,14 +93,16 @@ to mark a change in what a category counts.
78
93
 
79
94
  ## What it does
80
95
 
81
- - Read report files (`.json`, `.json.gz`, `.eml` and `.mbox`) or a folder of them, and skip
96
+ - Read report files (`.json`, `.json.gz`, `.eml`, `.mbox` and `.zip`), a folder of them, or a
97
+ mailbox folder over IMAP, and skip
82
98
  duplicates and files it cannot read, with a reason for each.
83
99
  - Summarise placement per signing domain and selector: inbox, unwanted and
84
100
  recipient reactions, by day and as a trend.
85
101
  - Treat the counts as what the draft says they are: rounded into buckets, so
86
102
  they are a signal, not exact rates.
87
103
  - Write a Markdown summary and a single self-contained HTML page you can open
88
- or share. Nothing is uploaded anywhere.
104
+ or share. The command line tool sends nothing anywhere; with `--imap` it
105
+ only talks to the mail server you name.
89
106
  - Follow the draft as it changes. The report format lives in one module
90
107
  (`src/aprf_reader/report.py`), so a new draft revision is a small update.
91
108
  - Check each report against the draft and skip, with the reason, any it
@@ -152,8 +169,8 @@ repeats a report already read, such as the same report as `.json` and as
152
169
  emails, forwarded emails, mailbox exports, duplicates and hostile text) and
153
170
  requires the same JSON, Markdown and HTML output, so the page and the command
154
171
  line cannot drift apart. `node web/build.js` inlines everything into one
155
- `web/dist/index.html` whose Content Security Policy blocks every network
156
- request, so it also works saved to disk. A merge to main that passes the
172
+ `web/dist/index.html` whose Content Security Policy allows one connection, the
173
+ usage note to dns-audit.com, and it also works saved to disk. A merge to main that passes the
157
174
  tests deploys it to Cloudflare.
158
175
 
159
176
  ## Related
@@ -7,11 +7,11 @@ points out the days worth a look, such as a jump in unwanted mail.
7
7
 
8
8
  Two ways to use it, with the same results:
9
9
 
10
- - **In your browser:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop
11
- your reports on the page. They are read on your computer and never uploaded.
10
+ - **On the web:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop or
11
+ paste your reports on the page.
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.6, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
15
15
 
16
16
  ## What APRF is
17
17
 
@@ -38,6 +38,7 @@ aprf-reader APRF.mbox # a mail folder exported as mbox
38
38
  aprf-reader reports/ --markdown # Markdown with daily tables
39
39
  aprf-reader reports/ --html summary.html # and a page you can open or share
40
40
  aprf-reader reports/ --json # the summary as data, for scripts
41
+ aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF
41
42
  ```
42
43
 
43
44
  In a terminal you get an overview: for each stream, each count's latest value,
@@ -46,12 +47,26 @@ the changes worth a look. Piped or written to a file with `--md`, the output
46
47
  is Markdown with the full daily tables.
47
48
 
48
49
  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
50
+ mailbox exports (`.mbox`), `.zip` files of any of those, or folders of them. The easiest way to collect reports
50
51
  is a mail filter that moves them into one folder, then an export of that folder:
51
52
  Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
52
53
  label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
53
54
  Mailbox > Export Mailbox. Comcast's reports come from
54
- `aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively. Files it skips are listed
55
+ `aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively.
56
+
57
+ Or skip the export and read the folder straight from your mailbox over IMAP:
58
+
59
+ ```bash
60
+ aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF --since 2026-10-01
61
+ ```
62
+
63
+ It opens the folder read-only and fetches with `BODY.PEEK`, so nothing is
64
+ marked read, moved or deleted. It reads messages whose subject contains
65
+ `APRF:`; `--all-mail` reads every message in the folder. The password comes
66
+ from the `APRF_IMAP_PASSWORD` environment variable, or it asks. Gmail and
67
+ Outlook.com need an app password, not your account password, and a Gmail
68
+ label is read as a folder of the same name. The connection uses TLS on port
69
+ 993; give another port as `--imap host:port`. Files it skips are listed
55
70
  on stderr and at the end of the summary, each with a reason.
56
71
 
57
72
  Each stream (provider, signing domain, selector) gets a table with one row per
@@ -65,14 +80,16 @@ to mark a change in what a category counts.
65
80
 
66
81
  ## What it does
67
82
 
68
- - Read report files (`.json`, `.json.gz`, `.eml` and `.mbox`) or a folder of them, and skip
83
+ - Read report files (`.json`, `.json.gz`, `.eml`, `.mbox` and `.zip`), a folder of them, or a
84
+ mailbox folder over IMAP, and skip
69
85
  duplicates and files it cannot read, with a reason for each.
70
86
  - Summarise placement per signing domain and selector: inbox, unwanted and
71
87
  recipient reactions, by day and as a trend.
72
88
  - Treat the counts as what the draft says they are: rounded into buckets, so
73
89
  they are a signal, not exact rates.
74
90
  - Write a Markdown summary and a single self-contained HTML page you can open
75
- or share. Nothing is uploaded anywhere.
91
+ or share. The command line tool sends nothing anywhere; with `--imap` it
92
+ only talks to the mail server you name.
76
93
  - Follow the draft as it changes. The report format lives in one module
77
94
  (`src/aprf_reader/report.py`), so a new draft revision is a small update.
78
95
  - Check each report against the draft and skip, with the reason, any it
@@ -139,8 +156,8 @@ repeats a report already read, such as the same report as `.json` and as
139
156
  emails, forwarded emails, mailbox exports, duplicates and hostile text) and
140
157
  requires the same JSON, Markdown and HTML output, so the page and the command
141
158
  line cannot drift apart. `node web/build.js` inlines everything into one
142
- `web/dist/index.html` whose Content Security Policy blocks every network
143
- request, so it also works saved to disk. A merge to main that passes the
159
+ `web/dist/index.html` whose Content Security Policy allows one connection, the
160
+ usage note to dns-audit.com, and it also works saved to disk. A merge to main that passes the
144
161
  tests deploys it to Cloudflare.
145
162
 
146
163
  ## Related
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.4.1"
7
+ version = "0.6.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.1"
3
+ __version__ = "0.6.0"
@@ -0,0 +1,84 @@
1
+ """Command line entry point: aprf-reader <paths...>"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import json
8
+ import os
9
+ import shutil
10
+ import sys
11
+ from datetime import date
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+ from .imap import ImapError, fetch
16
+ from .load import load
17
+ from .render import to_html, to_markdown, to_terminal
18
+ from .summary import to_dict
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ p = argparse.ArgumentParser(prog="aprf-reader", description="Summarise APRF placement reports.")
23
+ p.add_argument("paths", nargs="*", type=Path,
24
+ help="report files (.json, .json.gz, .eml, .mbox, .zip) or folders; - reads a pasted report or email from stdin")
25
+ p.add_argument("--md", type=Path, help="write the summary here instead of printing it")
26
+ p.add_argument("--html", type=Path, help="also write a self-contained HTML page here")
27
+ p.add_argument("--markdown", action="store_true", help="print Markdown with daily tables, even in a terminal")
28
+ p.add_argument("--json", action="store_true", help="print the summary as JSON")
29
+ imap = p.add_argument_group("reading a mailbox over IMAP (read-only: nothing is marked read, moved or deleted)")
30
+ imap.add_argument("--imap", metavar="HOST[:PORT]", help="IMAP server, such as imap.gmail.com; TLS on port 993 unless given")
31
+ imap.add_argument("--user", help="IMAP login name; the password is read from APRF_IMAP_PASSWORD or asked for")
32
+ imap.add_argument("--folder", default="INBOX", help="folder to read (default INBOX)")
33
+ imap.add_argument("--since", type=date.fromisoformat, metavar="YYYY-MM-DD", help="only mail received on or after this day")
34
+ imap.add_argument("--all-mail", action="store_true", help='read every message in the folder, not only subjects with "APRF:"')
35
+ p.add_argument("--version", action="version", version=f"aprf-reader {__version__}")
36
+ args = p.parse_args(argv)
37
+ if not args.paths and not args.imap:
38
+ p.error("give report files or folders, or --imap")
39
+ if args.imap and not args.user:
40
+ p.error("--imap needs --user")
41
+
42
+ paths = [x for x in args.paths if str(x) != "-"]
43
+ for path in paths:
44
+ if not path.exists():
45
+ p.error(f"no such file or folder: {path}")
46
+ pasted = sys.stdin.buffer.read() if len(paths) < len(args.paths) else None
47
+ messages = []
48
+ if args.imap:
49
+ host, _, port = args.imap.partition(":")
50
+ if port and not port.isdigit():
51
+ p.error(f"--imap port is not a number: {port}")
52
+ password = os.environ.get("APRF_IMAP_PASSWORD") or getpass.getpass(f"IMAP password for {args.user}: ")
53
+ try:
54
+ # Fetched in full before reading, so a dropped connection leaves no half summary
55
+ messages = list(fetch(host, args.user, password, args.folder, int(port or 993), args.since, args.all_mail))
56
+ except (ImapError, OSError) as e:
57
+ print(f"aprf-reader: {args.imap}: {e}", file=sys.stderr)
58
+ return 2
59
+ print(f"read {len(messages)} message{'s' if len(messages) != 1 else ''} from {args.folder} on {host}", file=sys.stderr)
60
+ loaded = load(paths, pasted, messages, f"imap://{args.imap}/{args.folder}" if args.imap else "mailbox")
61
+ for where, reason in loaded.skipped:
62
+ print(f"skipped {where}: {reason}", file=sys.stderr)
63
+ if not loaded.reports:
64
+ print("no APRF reports found", file=sys.stderr)
65
+ return 1
66
+
67
+ if args.json:
68
+ text = json.dumps(to_dict(loaded), indent=2) + "\n"
69
+ elif args.md or args.markdown or not sys.stdout.isatty():
70
+ text = to_markdown(loaded) # files and pipes get Markdown, which other tools read
71
+ else:
72
+ color = "NO_COLOR" not in os.environ and os.environ.get("TERM") != "dumb"
73
+ text = to_terminal(loaded, color=color, width=shutil.get_terminal_size().columns)
74
+ if args.md:
75
+ args.md.write_text(text, encoding="utf-8")
76
+ else:
77
+ sys.stdout.write(text)
78
+ if args.html:
79
+ args.html.write_text(to_html(loaded), encoding="utf-8")
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
@@ -0,0 +1,77 @@
1
+ """Read report emails straight from a mailbox over IMAP, without changing anything there."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import imaplib
6
+ import re
7
+ from collections.abc import Iterator
8
+ from datetime import date
9
+
10
+ # Comcast's subjects start "APRF:"; IMAP SUBJECT matches a substring, without case
11
+ SUBJECT = "APRF:"
12
+ # Messages fetched per round trip
13
+ BATCH = 50
14
+ # A few years of daily reports for a few selectors; more is a wrong folder
15
+ MAX_MESSAGES = 20_000
16
+
17
+
18
+ class ImapError(Exception):
19
+ pass
20
+
21
+
22
+ def _check(typ: str, data: list, what: str) -> list:
23
+ if typ != "OK":
24
+ detail = b" ".join(d for d in data if isinstance(d, bytes)).decode(errors="replace")
25
+ raise ImapError(f"{what} failed: {detail or typ}")
26
+ return data
27
+
28
+
29
+ def _quote(s: str) -> str:
30
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
31
+
32
+
33
+ def criteria(since: date | None = None, every: bool = False) -> list[str]:
34
+ out = [] if every else ["SUBJECT", _quote(SUBJECT)]
35
+ if since:
36
+ out += ["SINCE", since.strftime("%d-%b-%Y")]
37
+ return out or ["ALL"]
38
+
39
+
40
+ def fetch(host: str, user: str, password: str, folder: str = "INBOX", port: int = 993,
41
+ since: date | None = None, every: bool = False, conn=None) -> Iterator[tuple[str, bytes]]:
42
+ """Yield (label, raw message bytes) for each matching message in one folder.
43
+
44
+ The folder is opened read-only and bodies are fetched with BODY.PEEK, so no
45
+ message is marked read, moved or deleted."""
46
+ m = conn or imaplib.IMAP4_SSL(host, port, timeout=60)
47
+ try:
48
+ try:
49
+ _check(*m.login(user, password), "login")
50
+ except imaplib.IMAP4.error as e:
51
+ raise ImapError(f"login failed: {e}") from None
52
+ try:
53
+ _check(*m.select(_quote(folder), readonly=True), f"opening folder {folder}")
54
+ except imaplib.IMAP4.error as e:
55
+ raise ImapError(f"opening folder {folder} failed: {e}") from None
56
+ (ids,) = _check(*m.uid("SEARCH", *criteria(since, every)), "search")
57
+ uids = (ids or b"").split()
58
+ if len(uids) > MAX_MESSAGES:
59
+ raise ImapError(f"{len(uids):,} messages match, more than {MAX_MESSAGES:,}; "
60
+ "pick the folder the reports are filed in, or use --since")
61
+ for i in range(0, len(uids), BATCH):
62
+ chunk = b",".join(uids[i:i + BATCH]).decode()
63
+ data = _check(*m.uid("FETCH", chunk, "(UID RFC822.SIZE BODY.PEEK[])"), "fetch")
64
+ for item in data:
65
+ if not isinstance(item, tuple):
66
+ continue # the closing ")" of each response
67
+ head, body = item
68
+ # Most servers put UID before the body; one that puts it after still has a sequence number
69
+ uid = re.search(rb"UID (\d+)", head)
70
+ which = f"uid {uid.group(1).decode()}" if uid else f"message {head.split(b' ', 1)[0].decode(errors='replace')}"
71
+ label = f"imap://{host}/{folder} {which}"
72
+ yield label, body
73
+ finally:
74
+ try:
75
+ m.logout()
76
+ except (imaplib.IMAP4.error, OSError):
77
+ pass
@@ -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]]:
@@ -190,9 +223,25 @@ def _mail_warnings(mail: Mail, items: list) -> list[tuple[str, str]]:
190
223
  return out
191
224
 
192
225
 
193
- def load(paths: Iterable[Path]) -> Loaded:
226
+ def pasted_name(data: bytes) -> str:
227
+ """A name for pasted text, so it is read like a file: a report's JSON, or a whole
228
+ email such as Gmail's Show original, attachment and all."""
229
+ head = data.lstrip()[:1]
230
+ if head in (b"[", b"{"):
231
+ return "pasted.json"
232
+ return "pasted.json.gz" if data[:2] == b"\x1f\x8b" else "pasted.eml"
233
+
234
+
235
+ def _message(label: str, data: bytes) -> list[tuple[str, bytes, Mail]]:
236
+ """Reports in one raw email fetched from a mailbox; mail with none is counted, not skipped."""
237
+ if len(data) > MAX_BYTES:
238
+ raise ReportError("larger than 10 MB")
239
+ return list(_attachments(BytesParser(policy=policy.default).parsebytes(data), label))
240
+
241
+
242
+ def load(paths: Iterable[Path], pasted: bytes | None = None,
243
+ messages: Iterable[tuple[str, bytes]] = (), source: str = "mailbox") -> Loaded:
194
244
  out = Loaded()
195
- seen_bytes: set[str] = set()
196
245
  seen_keys: dict[tuple, tuple[str, Report]] = {}
197
246
 
198
247
  parsed: dict[str, list | ReportError] = {}
@@ -232,10 +281,38 @@ def load(paths: Iterable[Path]) -> Loaded:
232
281
 
233
282
  for path in _files(paths):
234
283
  try:
284
+ if path.name.lower().endswith(".zip"):
285
+ for label, data in _zip(path, out):
286
+ try:
287
+ for where, d, mail in list(_payloads(label, data)):
288
+ add(where, d, mail)
289
+ except (OSError, EOFError, zlib.error, ReportError) as e:
290
+ out.skipped.append((label, str(e)))
291
+ continue
235
292
  # 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))
293
+ payloads = _mbox(path, out) if _is_mbox(path) else list(_payloads(str(path), path.read_bytes()))
237
294
  for where, data, mail in payloads:
238
295
  add(where, data, mail)
239
296
  except (OSError, EOFError, zlib.error, ReportError) as e:
240
297
  out.skipped.append((str(path), str(e)))
298
+ # Raw emails from IMAP, read like an mbox: one line for all mail with no report
299
+ without = 0
300
+ for label, data in messages:
301
+ try:
302
+ found = _message(label, data)
303
+ except (OSError, EOFError, zlib.error, ReportError) as e:
304
+ out.skipped.append((label, str(e)))
305
+ continue
306
+ without += not found
307
+ for where, d, mail in found:
308
+ add(where, d, mail)
309
+ if without:
310
+ out.skipped.append((source, f"{without} message{'s' if without != 1 else ''} with no report attached"))
311
+ if pasted is not None:
312
+ name = pasted_name(pasted)
313
+ try:
314
+ for where, data, mail in list(_payloads(name, pasted)):
315
+ add(where, data, mail)
316
+ except (OSError, EOFError, zlib.error, ReportError) as e:
317
+ out.skipped.append((name, str(e)))
241
318
  return out
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -20,11 +20,11 @@ points out the days worth a look, such as a jump in unwanted mail.
20
20
 
21
21
  Two ways to use it, with the same results:
22
22
 
23
- - **In your browser:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop
24
- your reports on the page. They are read on your computer and never uploaded.
23
+ - **On the web:** [aprf.dns-audit.com](https://aprf.dns-audit.com). Drop or
24
+ paste your reports on the page.
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.6, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
28
28
 
29
29
  ## What APRF is
30
30
 
@@ -51,6 +51,7 @@ aprf-reader APRF.mbox # a mail folder exported as mbox
51
51
  aprf-reader reports/ --markdown # Markdown with daily tables
52
52
  aprf-reader reports/ --html summary.html # and a page you can open or share
53
53
  aprf-reader reports/ --json # the summary as data, for scripts
54
+ aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF
54
55
  ```
55
56
 
56
57
  In a terminal you get an overview: for each stream, each count's latest value,
@@ -59,12 +60,26 @@ the changes worth a look. Piped or written to a file with `--md`, the output
59
60
  is Markdown with the full daily tables.
60
61
 
61
62
  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
63
+ mailbox exports (`.mbox`), `.zip` files of any of those, or folders of them. The easiest way to collect reports
63
64
  is a mail filter that moves them into one folder, then an export of that folder:
64
65
  Gmail through [Google Takeout](https://takeout.google.com/) (pick Mail and the
65
66
  label), Thunderbird with the ImportExportTools NG add-on, or Apple Mail's
66
67
  Mailbox > Export Mailbox. Comcast's reports come from
67
- `aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively. Files it skips are listed
68
+ `aprf-noreply@alerts.xfinity.com` with subjects starting `APRF:`. Folders are searched recursively.
69
+
70
+ Or skip the export and read the folder straight from your mailbox over IMAP:
71
+
72
+ ```bash
73
+ aprf-reader --imap imap.gmail.com --user you@gmail.com --folder APRF --since 2026-10-01
74
+ ```
75
+
76
+ It opens the folder read-only and fetches with `BODY.PEEK`, so nothing is
77
+ marked read, moved or deleted. It reads messages whose subject contains
78
+ `APRF:`; `--all-mail` reads every message in the folder. The password comes
79
+ from the `APRF_IMAP_PASSWORD` environment variable, or it asks. Gmail and
80
+ Outlook.com need an app password, not your account password, and a Gmail
81
+ label is read as a folder of the same name. The connection uses TLS on port
82
+ 993; give another port as `--imap host:port`. Files it skips are listed
68
83
  on stderr and at the end of the summary, each with a reason.
69
84
 
70
85
  Each stream (provider, signing domain, selector) gets a table with one row per
@@ -78,14 +93,16 @@ to mark a change in what a category counts.
78
93
 
79
94
  ## What it does
80
95
 
81
- - Read report files (`.json`, `.json.gz`, `.eml` and `.mbox`) or a folder of them, and skip
96
+ - Read report files (`.json`, `.json.gz`, `.eml`, `.mbox` and `.zip`), a folder of them, or a
97
+ mailbox folder over IMAP, and skip
82
98
  duplicates and files it cannot read, with a reason for each.
83
99
  - Summarise placement per signing domain and selector: inbox, unwanted and
84
100
  recipient reactions, by day and as a trend.
85
101
  - Treat the counts as what the draft says they are: rounded into buckets, so
86
102
  they are a signal, not exact rates.
87
103
  - Write a Markdown summary and a single self-contained HTML page you can open
88
- or share. Nothing is uploaded anywhere.
104
+ or share. The command line tool sends nothing anywhere; with `--imap` it
105
+ only talks to the mail server you name.
89
106
  - Follow the draft as it changes. The report format lives in one module
90
107
  (`src/aprf_reader/report.py`), so a new draft revision is a small update.
91
108
  - Check each report against the draft and skip, with the reason, any it
@@ -152,8 +169,8 @@ repeats a report already read, such as the same report as `.json` and as
152
169
  emails, forwarded emails, mailbox exports, duplicates and hostile text) and
153
170
  requires the same JSON, Markdown and HTML output, so the page and the command
154
171
  line cannot drift apart. `node web/build.js` inlines everything into one
155
- `web/dist/index.html` whose Content Security Policy blocks every network
156
- request, so it also works saved to disk. A merge to main that passes the
172
+ `web/dist/index.html` whose Content Security Policy allows one connection, the
173
+ usage note to dns-audit.com, and it also works saved to disk. A merge to main that passes the
157
174
  tests deploys it to Cloudflare.
158
175
 
159
176
  ## Related
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/aprf_reader/__init__.py
5
5
  src/aprf_reader/cli.py
6
+ src/aprf_reader/imap.py
6
7
  src/aprf_reader/load.py
7
8
  src/aprf_reader/render.py
8
9
  src/aprf_reader/report.py
@@ -13,5 +14,6 @@ src/aprf_reader.egg-info/dependency_links.txt
13
14
  src/aprf_reader.egg-info/entry_points.txt
14
15
  src/aprf_reader.egg-info/requires.txt
15
16
  src/aprf_reader.egg-info/top_level.txt
17
+ tests/test_imap.py
16
18
  tests/test_reader.py
17
19
  tests/test_web_parity.py
@@ -0,0 +1,105 @@
1
+ import gzip
2
+ import imaplib
3
+ import json
4
+ from datetime import date
5
+
6
+ import pytest
7
+
8
+ from aprf_reader import cli, imap
9
+ from aprf_reader.imap import ImapError, criteria, fetch
10
+ from aprf_reader.load import load
11
+
12
+ from test_reader import report, report_email
13
+
14
+
15
+ class FakeImap:
16
+ """Answers the calls fetch() makes the way imaplib returns a server's replies."""
17
+
18
+ def __init__(self, messages, password="right", folders=("INBOX", "APRF")):
19
+ self.messages = messages # {uid: raw bytes}
20
+ self.password, self.folders = password, folders
21
+ self.calls = []
22
+
23
+ def login(self, user, password):
24
+ if password != self.password:
25
+ raise imaplib.IMAP4.error("[AUTHENTICATIONFAILED] Invalid credentials")
26
+ return "OK", [b"logged in"]
27
+
28
+ def select(self, folder, readonly=False):
29
+ self.calls.append(("select", folder, readonly))
30
+ if folder.strip('"') not in self.folders:
31
+ return "NO", [b"[NONEXISTENT] Unknown Mailbox"]
32
+ return "OK", [str(len(self.messages)).encode()]
33
+
34
+ def uid(self, command, *args):
35
+ self.calls.append((command, *args))
36
+ if command == "SEARCH":
37
+ return "OK", [b" ".join(str(u).encode() for u in self.messages)]
38
+ out = []
39
+ for u in args[0].split(","):
40
+ body = self.messages[int(u)]
41
+ out += [(f"{u} (UID {u} RFC822.SIZE {len(body)} BODY[] {{{len(body)}}}".encode(), body), b")"]
42
+ return "OK", out
43
+
44
+ def logout(self):
45
+ self.calls.append(("logout",))
46
+ return "BYE", [b""]
47
+
48
+
49
+ def mails(n=3):
50
+ out = {}
51
+ for d in range(n):
52
+ out[100 + d] = report_email(d, gzip.compress(json.dumps(report(d)).encode()), "gzip", f"{d}.json.gz").as_bytes()
53
+ return out
54
+
55
+
56
+ def test_fetch_is_read_only_and_batched(monkeypatch):
57
+ monkeypatch.setattr(imap, "BATCH", 2)
58
+ fake = FakeImap(mails(5))
59
+ got = list(fetch("imap.example.com", "u", "right", "APRF", conn=fake))
60
+ assert [label for label, _ in got] == [f"imap://imap.example.com/APRF uid {u}" for u in range(100, 105)]
61
+ assert ("select", '"APRF"', True) in fake.calls # EXAMINE, not SELECT
62
+ fetches = [c for c in fake.calls if c[0] == "FETCH"]
63
+ assert len(fetches) == 3 and all("BODY.PEEK[]" in c[2] for c in fetches) # PEEK leaves \\Seen alone
64
+ assert fake.calls[-1] == ("logout",)
65
+
66
+
67
+ def test_criteria():
68
+ assert criteria() == ["SUBJECT", '"APRF:"']
69
+ assert criteria(date(2026, 10, 1)) == ["SUBJECT", '"APRF:"', "SINCE", "01-Oct-2026"]
70
+ assert criteria(every=True) == ["ALL"]
71
+
72
+
73
+ def test_login_and_folder_errors_are_plain():
74
+ with pytest.raises(ImapError, match="login failed: .*Invalid credentials"):
75
+ list(fetch("h", "u", "wrong", conn=FakeImap({})))
76
+ with pytest.raises(ImapError, match="opening folder Nope failed: .*Unknown Mailbox"):
77
+ list(fetch("h", "u", "right", "Nope", conn=FakeImap({})))
78
+
79
+
80
+ def test_fetched_mail_read_like_an_mbox():
81
+ msgs = list(mails(2).items())
82
+ note = b"Subject: APRF: hello\r\n\r\nno attachment\r\n"
83
+ loaded = load([], None, [(f"m{u}", b) for u, b in msgs] + [("m9", note), ("m10", note)], "imap://h/APRF")
84
+ assert sorted(r.day.day for r in loaded.reports) == [1, 2]
85
+ assert loaded.skipped == [("imap://h/APRF", "2 messages with no report attached")]
86
+
87
+
88
+ def test_cli_imap(monkeypatch, capsys):
89
+ fake = FakeImap(mails(2))
90
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, timeout: fake)
91
+ monkeypatch.setenv("APRF_IMAP_PASSWORD", "right")
92
+ assert cli.main(["--imap", "imap.example.com", "--user", "u", "--folder", "APRF", "--json"]) == 0
93
+ out, err = capsys.readouterr()
94
+ assert json.loads(out)["reports"] == 2
95
+ assert "read 2 messages from APRF on imap.example.com" in err
96
+
97
+
98
+ def test_cli_imap_failure_and_usage(monkeypatch, capsys):
99
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, timeout: FakeImap({}))
100
+ monkeypatch.setenv("APRF_IMAP_PASSWORD", "wrong")
101
+ assert cli.main(["--imap", "h", "--user", "u"]) == 2
102
+ assert "login failed" in capsys.readouterr().err
103
+ for argv in ([], ["--imap", "h"], ["--imap", "h:x", "--user", "u"]):
104
+ with pytest.raises(SystemExit):
105
+ cli.main(argv)
@@ -249,7 +249,7 @@ def test_stress_set_one_bad_report_does_not_cost_the_file():
249
249
  dup = {where.rsplit(" ", 1)[-1]: d for w, d, where in loaded.warnings if w.startswith("the same day came twice")}
250
250
  assert dup["64"] == f"{FIX}/stress_100.json report 63 kept, version 1 kept, version 2 dropped"
251
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
252
+ assert "report 3 kept" in dup["70"] # SENDER.EXAMPLE / SELECTOR1 is sender.example / selector1
253
253
  assert not {"64", "69", "70"} & set(reasons)
254
254
  warned = {w for w, _, _ in loaded.warnings}
255
255
  assert "sdi_used is not N/A, N/F, or the sdi value as published in DNS (section 5.2.1)" in warned
@@ -259,13 +259,13 @@ def test_stress_set_one_bad_report_does_not_cost_the_file():
259
259
  assert reasons["79"] == "header.report_end is before report_start (section 5.1)"
260
260
  assert reasons["81"].startswith("header.report_start looks like milliseconds")
261
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
262
+ assert ("Big MBP Co", "sender.example", "s11") in ss # another provider, same day: its own stream
263
263
  obj = report(0)
264
264
  obj["body"][0]["classification"]["unwanted"] = 100.0
265
265
  assert parse(json.dumps(obj).encode())[0].segments[0].classification["unwanted"] == 100
266
266
  for sel in ("sdi0", "sdi1", "sdi2", "sdi3", "sdi4"): # n/a, "N/A ", "", null, N/F;
267
- assert not ss[("Comcast/Xfinity", "dns-audit.com", sel)].notes, sel
268
- blank = ss[("Comcast/Xfinity", "dns-audit.com", "s13")]
267
+ assert not ss[("Comcast/Xfinity", "sender.example", sel)].notes, sel
268
+ blank = ss[("Comcast/Xfinity", "sender.example", "s13")]
269
269
  assert "(blank)" in [r.segment for r in blank.rows]
270
- assert not ss[("Comcast/Xfinity", "dns-audit.com", "s8")].columns
270
+ assert not ss[("Comcast/Xfinity", "sender.example", "s8")].columns
271
271
  assert "These reports carry no counts." in to_markdown(loaded)
@@ -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
@@ -22,16 +23,24 @@ NODE = shutil.which("node")
22
23
  pytestmark = pytest.mark.skipif(NODE is None, reason="node is not installed")
23
24
 
24
25
 
25
- def web(mode: str, paths: list[Path]) -> str:
26
- return subprocess.run([NODE, str(ROOT / "web/test/run.js"), mode, *map(str, paths)], check=True,
27
- capture_output=True, text=True, env={"APRF_VERSION": __version__}).stdout
26
+ def web(mode: str, paths: list, paste: bytes | None = None) -> str:
27
+ args = [*map(str, paths)] + (["-"] if paste is not None else [])
28
+ return subprocess.run([NODE, str(ROOT / "web/test/run.js"), mode, *args], check=True, input=paste or b"",
29
+ capture_output=True, env={"APRF_VERSION": __version__}).stdout.decode()
30
+
31
+
32
+ def _engine(doc: dict) -> dict:
33
+ """Python and browsers word JSON parse errors differently; compare only our part."""
34
+ cut = lambda r: r.split(": ", 1)[0] if r.startswith("not valid JSON") else r
35
+ return {**doc, "skipped": [{**k, "reason": cut(k["reason"])} for k in doc["skipped"]]}
28
36
 
29
37
 
30
38
  def same(paths: list[Path]) -> None:
31
39
  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)
40
+ assert _engine(json.loads(web("--json", paths))) == _engine(to_dict(loaded))
41
+ text = lambda t: re.sub(r"(not valid JSON, which section 5 requires): [^\n<]*", r"\1", t)
42
+ assert text(web("--md", paths)) == text(to_markdown(loaded))
43
+ assert text(web("--html", paths)) == text(to_html(loaded))
35
44
 
36
45
 
37
46
  def report(day: int, selector: str = "s1", inbox: int = 100) -> dict:
@@ -128,3 +137,46 @@ def test_section_8_checks_the_same(tmp_path):
128
137
  assert "attachment name stands in for a character a file name cannot hold, such as / or *" in by_file["safe.eml"]
129
138
  assert {"attachment name does not follow section 8", "subject does not follow section 8",
130
139
  "attachment Content-Type is not application/json, or application/gzip when compressed"} <= by_file["wrong.eml"]
140
+
141
+
142
+ def test_zips_read_the_same(tmp_path):
143
+ import zipfile
144
+ raw = lambda day, **kw: json.dumps(report(day, **kw)).encode()
145
+ z = tmp_path / "reports.zip"
146
+ with zipfile.ZipFile(z, "w") as f:
147
+ f.writestr("b/20261002.json", raw(1), compress_type=zipfile.ZIP_DEFLATED)
148
+ f.writestr("a/20261001.json", raw(0), compress_type=zipfile.ZIP_STORED)
149
+ f.writestr("c/20261003.json.gz", gzip.compress(raw(2)))
150
+ f.writestr("d/20261004.eml", email(raw(3), "json", "x.json").as_bytes(), compress_type=zipfile.ZIP_DEFLATED)
151
+ f.writestr("__MACOSX/a/._20261001.json", b"\x00\x05\x16\x07 Finder junk")
152
+ f.writestr("e/inner.zip", b"PK nested")
153
+ f.writestr("e/box.mbox", b"From x\n\nhi\n")
154
+ f.writestr("f/huge.json", b" " * (11 * 1024 * 1024), compress_type=zipfile.ZIP_DEFLATED)
155
+ f.writestr("g/notes.txt", b"not a report suffix")
156
+ f.writestr("h/broken.json", b"{oops", compress_type=zipfile.ZIP_DEFLATED)
157
+ f.writestr("i/crc.json", raw(5), compress_type=zipfile.ZIP_STORED)
158
+ # Flip one byte of the stored crc.json so its CRC no longer matches
159
+ data = bytearray(z.read_bytes())
160
+ at = data.index(b'"dkim_selector"', data.index(b"i/crc.json") + 10)
161
+ data[at + 2] ^= 0x01
162
+ z.write_bytes(bytes(data))
163
+ (tmp_path / "fake.zip").write_bytes(b"not a zip at all")
164
+ same([z, tmp_path / "fake.zip"])
165
+ loaded = load([z, tmp_path / "fake.zip"])
166
+ assert sorted(r.day.day for r in loaded.reports) == [1, 2, 3, 4]
167
+ reasons = {w.rsplit("/", 1)[-1]: r for w, r in loaded.skipped}
168
+ assert reasons["crc.json"] == "could not be unpacked from the zip"
169
+ assert reasons["inner.zip"] == reasons["box.mbox"] == "a zip or mailbox inside a zip is not read; unzip it first"
170
+ assert reasons["huge.json"] == "larger than 10 MB" and reasons["fake.zip"] == "not a readable zip file"
171
+ assert "broken.json" in reasons and not any("MACOSX" in w for w, _ in loaded.skipped)
172
+
173
+
174
+ @pytest.mark.parametrize("kind", ["json", "email", "gzip", "junk"])
175
+ def test_pastes_read_the_same(kind):
176
+ raw = json.dumps(report(0)).encode()
177
+ paste = {"json": b" \n" + raw, "email": email(gzip.compress(raw), "gzip", "r.json.gz").as_bytes(),
178
+ "gzip": gzip.compress(raw), "junk": b"hello, not a report"}[kind]
179
+ loaded = load([], paste)
180
+ assert _engine(json.loads(web("--json", [], paste))) == _engine(to_dict(loaded))
181
+ assert (len(loaded.reports), loaded.skipped[0][0] if loaded.skipped else None) == (
182
+ {"json": (1, None), "email": (1, None), "gzip": (1, None), "junk": (0, "pasted.eml")}[kind])
@@ -1,55 +0,0 @@
1
- """Command line entry point: aprf-reader <paths...>"""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import json
7
- import os
8
- import shutil
9
- import sys
10
- from pathlib import Path
11
-
12
- from . import __version__
13
- from .load import load
14
- from .render import to_html, to_markdown, to_terminal
15
- from .summary import to_dict
16
-
17
-
18
- def main(argv: list[str] | None = None) -> int:
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")
21
- p.add_argument("--md", type=Path, help="write the summary here instead of printing it")
22
- p.add_argument("--html", type=Path, help="also write a self-contained HTML page here")
23
- p.add_argument("--markdown", action="store_true", help="print Markdown with daily tables, even in a terminal")
24
- p.add_argument("--json", action="store_true", help="print the summary as JSON")
25
- p.add_argument("--version", action="version", version=f"aprf-reader {__version__}")
26
- args = p.parse_args(argv)
27
-
28
- for path in args.paths:
29
- if not path.exists():
30
- p.error(f"no such file or folder: {path}")
31
- loaded = load(args.paths)
32
- for where, reason in loaded.skipped:
33
- print(f"skipped {where}: {reason}", file=sys.stderr)
34
- if not loaded.reports:
35
- print("no APRF reports found", file=sys.stderr)
36
- return 1
37
-
38
- if args.json:
39
- text = json.dumps(to_dict(loaded), indent=2) + "\n"
40
- elif args.md or args.markdown or not sys.stdout.isatty():
41
- text = to_markdown(loaded) # files and pipes get Markdown, which other tools read
42
- else:
43
- color = "NO_COLOR" not in os.environ and os.environ.get("TERM") != "dumb"
44
- text = to_terminal(loaded, color=color, width=shutil.get_terminal_size().columns)
45
- if args.md:
46
- args.md.write_text(text, encoding="utf-8")
47
- else:
48
- sys.stdout.write(text)
49
- if args.html:
50
- args.html.write_text(to_html(loaded), encoding="utf-8")
51
- return 0
52
-
53
-
54
- if __name__ == "__main__":
55
- sys.exit(main())
File without changes
File without changes