aprf-reader 0.6.1__tar.gz → 0.6.2__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.6.1/src/aprf_reader.egg-info → aprf_reader-0.6.2}/PKG-INFO +1 -1
  2. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/pyproject.toml +1 -1
  3. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/__init__.py +1 -1
  4. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/cli.py +12 -2
  5. aprf_reader-0.6.2/src/aprf_reader/imap.py +141 -0
  6. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/load.py +5 -4
  7. {aprf_reader-0.6.1 → aprf_reader-0.6.2/src/aprf_reader.egg-info}/PKG-INFO +1 -1
  8. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/tests/test_imap.py +58 -2
  9. aprf_reader-0.6.1/src/aprf_reader/imap.py +0 -85
  10. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/LICENSE +0 -0
  11. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/README.md +0 -0
  12. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/setup.cfg +0 -0
  13. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/render.py +0 -0
  14. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/report.py +0 -0
  15. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader/summary.py +0 -0
  16. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/SOURCES.txt +0 -0
  17. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
  18. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/entry_points.txt +0 -0
  19. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/requires.txt +0 -0
  20. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/src/aprf_reader.egg-info/top_level.txt +0 -0
  21. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/tests/test_reader.py +0 -0
  22. {aprf_reader-0.6.1 → aprf_reader-0.6.2}/tests/test_web_parity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.6.1"
7
+ version = "0.6.2"
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.6.1"
3
+ __version__ = "0.6.2"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import getpass
7
+ import imaplib
7
8
  import json
8
9
  import os
9
10
  import shutil
@@ -49,11 +50,20 @@ def main(argv: list[str] | None = None) -> int:
49
50
  host, _, port = args.imap.partition(":")
50
51
  if port and not port.isdigit():
51
52
  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
+ password = os.environ.get("APRF_IMAP_PASSWORD")
54
+ if not password:
55
+ if pasted is not None:
56
+ p.error("stdin is the pasted report, so the password must come from APRF_IMAP_PASSWORD")
57
+ try:
58
+ password = getpass.getpass(f"IMAP password for {args.user}: ")
59
+ except (EOFError, KeyboardInterrupt):
60
+ print(file=sys.stderr)
61
+ p.error("no password: set APRF_IMAP_PASSWORD or run it in a terminal")
53
62
  try:
54
63
  # Fetched in full before reading, so a dropped connection leaves no half summary
55
64
  messages = list(fetch(host, args.user, password, args.folder, int(port or 993), args.since, args.all_mail))
56
- except (ImapError, OSError) as e:
65
+ # imaplib.IMAP4.error and UnicodeError are a backstop; fetch() turns what it can into ImapError
66
+ except (ImapError, imaplib.IMAP4.error, OSError, UnicodeError) as e:
57
67
  print(f"aprf-reader: {args.imap}: {e}", file=sys.stderr)
58
68
  return 2
59
69
  print(f"read {len(messages)} message{'s' if len(messages) != 1 else ''} from {args.folder} on {host}", file=sys.stderr)
@@ -0,0 +1,141 @@
1
+ """Read report emails straight from a mailbox over IMAP, without changing anything there."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import imaplib
7
+ import re
8
+ import ssl
9
+ from collections.abc import Iterator
10
+ from datetime import date
11
+
12
+ from .load import MAX_BYTES
13
+
14
+ # Comcast's subjects start "APRF:"; IMAP SUBJECT matches a substring, without case
15
+ SUBJECT = "APRF:"
16
+ # Messages fetched per round trip
17
+ BATCH = 50
18
+ # A few years of daily reports for a few selectors; more is a wrong folder
19
+ MAX_MESSAGES = 20_000
20
+
21
+
22
+ class ImapError(Exception):
23
+ pass
24
+
25
+
26
+ def _check(typ: str, data: list, what: str) -> list:
27
+ if typ != "OK":
28
+ detail = b" ".join(d for d in data if isinstance(d, bytes)).decode(errors="replace")
29
+ raise ImapError(f"{what} failed: {detail or typ}")
30
+ return data
31
+
32
+
33
+ def _text(e: Exception) -> str:
34
+ # imaplib puts the server's reply bytes in the exception as they came
35
+ a = e.args[0] if e.args else ""
36
+ return a.decode(errors="replace") if isinstance(a, bytes) else str(e)
37
+
38
+
39
+ def utf7(name: str) -> str:
40
+ """A folder name in IMAP's modified UTF-7 (RFC 3501 5.1.3), as Exchange and others name
41
+ folders such as "Gelöschte Elemente"; plain ASCII is unchanged."""
42
+ out, run = [], []
43
+
44
+ def flush():
45
+ if run:
46
+ b = base64.b64encode("".join(run).encode("utf-16-be")).decode().rstrip("=").replace("/", ",")
47
+ out.append(f"&{b}-")
48
+ run.clear()
49
+
50
+ for ch in name:
51
+ if 0x20 <= ord(ch) <= 0x7E:
52
+ flush()
53
+ out.append("&-" if ch == "&" else ch)
54
+ else:
55
+ run.append(ch)
56
+ flush()
57
+ return "".join(out)
58
+
59
+
60
+ def _login(m, user: str, password: str) -> None:
61
+ if user.isascii() and password.isascii():
62
+ _check(*m.login(user, password), "login")
63
+ else:
64
+ # LOGIN only carries ASCII; SASL PLAIN carries UTF-8 (RFC 4616)
65
+ if "AUTH=PLAIN" not in getattr(m, "capabilities", ("AUTH=PLAIN",)):
66
+ raise ImapError("login failed: the user name or password has non-ASCII characters, "
67
+ "and this server does not offer AUTH=PLAIN, which is needed to send them")
68
+ creds = b"\0" + user.encode() + b"\0" + password.encode()
69
+ _check(*m.authenticate("PLAIN", lambda _: creds), "login")
70
+
71
+
72
+ def _quote(s: str) -> str:
73
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
74
+
75
+
76
+ def criteria(since: date | None = None, every: bool = False) -> list[str]:
77
+ out = [] if every else ["SUBJECT", _quote(SUBJECT)]
78
+ if since:
79
+ out += ["SINCE", since.strftime("%d-%b-%Y")]
80
+ return out or ["ALL"]
81
+
82
+
83
+ def _messages(m, host: str, folder: str, since: date | None, every: bool) -> Iterator[tuple[str, bytes | None]]:
84
+ (ids,) = _check(*m.uid("SEARCH", *criteria(since, every)), "search")
85
+ uids = (ids or b"").split()
86
+ if len(uids) > MAX_MESSAGES:
87
+ raise ImapError(f"{len(uids):,} messages match, more than {MAX_MESSAGES:,}; "
88
+ "pick the folder the reports are filed in, or use --since")
89
+ for i in range(0, len(uids), BATCH):
90
+ chunk = b",".join(uids[i:i + BATCH]).decode()
91
+ # Sizes first, so a huge message is never downloaded
92
+ sizes = {}
93
+ for item in _check(*m.uid("FETCH", chunk, "(UID RFC822.SIZE)"), "fetch"):
94
+ line = item[0] if isinstance(item, tuple) else item
95
+ u, n = re.search(rb"UID (\d+)", line or b""), re.search(rb"RFC822\.SIZE (\d+)", line or b"")
96
+ if u and n:
97
+ sizes[u.group(1)] = int(n.group(1))
98
+ big = [u for u in uids[i:i + BATCH] if sizes.get(u, 0) > MAX_BYTES]
99
+ for u in big:
100
+ yield f"imap://{host}/{folder} uid {u.decode()}", None
101
+ small = [u for u in uids[i:i + BATCH] if u not in big]
102
+ if not small:
103
+ continue
104
+ data = _check(*m.uid("FETCH", b",".join(small).decode(), "(UID BODY.PEEK[])"), "fetch")
105
+ for item in data:
106
+ if not isinstance(item, tuple):
107
+ continue # the closing ")" of each response
108
+ head, body = item
109
+ # Most servers put UID before the body; one that puts it after still has a sequence number
110
+ uid = re.search(rb"UID (\d+)", head)
111
+ which = f"uid {uid.group(1).decode()}" if uid else f"message {head.split(b' ', 1)[0].decode(errors='replace')}"
112
+ yield f"imap://{host}/{folder} {which}", body
113
+
114
+
115
+ def fetch(host: str, user: str, password: str, folder: str = "INBOX", port: int = 993,
116
+ since: date | None = None, every: bool = False, conn=None) -> Iterator[tuple[str, bytes | None]]:
117
+ """Yield (label, raw message bytes) for each matching message in one folder; the
118
+ bytes are None for a message over MAX_BYTES, which is not downloaded.
119
+
120
+ The folder is opened read-only and bodies are fetched with BODY.PEEK, so no
121
+ message is marked read, moved or deleted."""
122
+ # imaplib's own default context checks neither the certificate nor the host name
123
+ m = conn or imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=60)
124
+ try:
125
+ try:
126
+ _login(m, user, password)
127
+ except imaplib.IMAP4.error as e:
128
+ raise ImapError(f"login failed: {_text(e)}") from None
129
+ try:
130
+ _check(*m.select(_quote(utf7(folder)), readonly=True), f"opening folder {folder}")
131
+ except imaplib.IMAP4.error as e:
132
+ raise ImapError(f"opening folder {folder} failed: {_text(e)}") from None
133
+ try:
134
+ yield from _messages(m, host, folder, since, every)
135
+ except imaplib.IMAP4.error as e: # includes abort: the server hung up mid-run
136
+ raise ImapError(f"connection lost: {_text(e)}") from None
137
+ finally:
138
+ try:
139
+ m.logout()
140
+ except (imaplib.IMAP4.error, OSError):
141
+ pass
@@ -232,15 +232,16 @@ def pasted_name(data: bytes) -> str:
232
232
  return "pasted.json.gz" if data[:2] == b"\x1f\x8b" else "pasted.eml"
233
233
 
234
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:
235
+ def _message(label: str, data: bytes | None) -> list[tuple[str, bytes, Mail]]:
236
+ """Reports in one raw email fetched from a mailbox; mail with none is counted, not skipped.
237
+ None is a message too large to have been downloaded."""
238
+ if data is None or len(data) > MAX_BYTES:
238
239
  raise ReportError("larger than 10 MB")
239
240
  return list(_attachments(BytesParser(policy=policy.default).parsebytes(data), label))
240
241
 
241
242
 
242
243
  def load(paths: Iterable[Path], pasted: bytes | None = None,
243
- messages: Iterable[tuple[str, bytes]] = (), source: str = "mailbox") -> Loaded:
244
+ messages: Iterable[tuple[str, bytes | None]] = (), source: str = "mailbox") -> Loaded:
244
245
  out = Loaded()
245
246
  seen_keys: dict[tuple, tuple[str, Report]] = {}
246
247
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aprf-reader
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Summarise APRF (Aggregate Performance Reporting) inbox placement reports.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -32,6 +32,10 @@ class FakeImap:
32
32
  return "NO", [b"[NONEXISTENT] Unknown Mailbox"]
33
33
  return "OK", [str(len(self.messages)).encode()]
34
34
 
35
+ def authenticate(self, mech, cb):
36
+ self.calls.append(("authenticate", mech, cb(b"")))
37
+ return "OK", [b"logged in"]
38
+
35
39
  def uid(self, command, *args):
36
40
  self.calls.append((command, *args))
37
41
  if command == "SEARCH":
@@ -39,7 +43,10 @@ class FakeImap:
39
43
  out = []
40
44
  for u in args[0].split(","):
41
45
  body = self.messages[int(u)]
42
- out += [(f"{u} (UID {u} RFC822.SIZE {len(body)} BODY[] {{{len(body)}}}".encode(), body), b")"]
46
+ if "BODY" not in args[1]:
47
+ out.append(f"{u} (UID {u} RFC822.SIZE {len(body)})".encode())
48
+ else:
49
+ out += [(f"{u} (UID {u} BODY[] {{{len(body)}}}".encode(), body), b")"]
43
50
  return "OK", out
44
51
 
45
52
  def logout(self):
@@ -60,7 +67,7 @@ def test_fetch_is_read_only_and_batched(monkeypatch):
60
67
  got = list(fetch("imap.example.com", "u", "right", "APRF", conn=fake))
61
68
  assert [label for label, _ in got] == [f"imap://imap.example.com/APRF uid {u}" for u in range(100, 105)]
62
69
  assert ("select", '"APRF"', True) in fake.calls # EXAMINE, not SELECT
63
- fetches = [c for c in fake.calls if c[0] == "FETCH"]
70
+ fetches = [c for c in fake.calls if c[0] == "FETCH" and "BODY" in c[2]]
64
71
  assert len(fetches) == 3 and all("BODY.PEEK[]" in c[2] for c in fetches) # PEEK leaves \\Seen alone
65
72
  assert fake.calls[-1] == ("logout",)
66
73
 
@@ -116,3 +123,52 @@ def test_certificate_is_checked(monkeypatch):
116
123
  monkeypatch.setattr(imaplib, "IMAP4_SSL", connect)
117
124
  list(fetch("h", "u", "right"))
118
125
  assert seen["ctx"].verify_mode == ssl.CERT_REQUIRED and seen["ctx"].check_hostname
126
+
127
+
128
+ def test_huge_message_not_downloaded(monkeypatch):
129
+ monkeypatch.setattr(imap, "MAX_BYTES", 1000)
130
+ msgs = mails(1)
131
+ msgs[200] = b"x" * 2000
132
+ fake = FakeImap(msgs)
133
+ got = dict(fetch("h", "u", "right", conn=fake))
134
+ assert got["imap://h/INBOX uid 200"] is None and got["imap://h/INBOX uid 100"]
135
+ assert all("200" not in c[1] for c in fake.calls if c[0] == "FETCH" and "BODY" in c[2])
136
+ loaded = load([], None, got.items())
137
+ assert loaded.skipped == [("imap://h/INBOX uid 200", "larger than 10 MB")]
138
+
139
+
140
+ def test_connection_lost_mid_run_is_a_clean_error(monkeypatch, capsys):
141
+ fake = FakeImap(mails(2))
142
+
143
+ def hang_up(*a):
144
+ raise imaplib.IMAP4.abort("socket error: EOF")
145
+
146
+ fake.uid = hang_up
147
+ with pytest.raises(ImapError, match="connection lost: socket error: EOF"):
148
+ list(fetch("h", "u", "right", conn=fake))
149
+ monkeypatch.setattr(imaplib, "IMAP4_SSL", lambda host, port, ssl_context, timeout: fake)
150
+ monkeypatch.setenv("APRF_IMAP_PASSWORD", "right")
151
+ assert cli.main(["--imap", "h", "--user", "u"]) == 2
152
+ assert "connection lost" in capsys.readouterr().err
153
+
154
+
155
+ def test_non_ascii_folder_and_password():
156
+ assert imap.utf7("Gelöschte Elemente") == "Gel&APY-schte Elemente"
157
+ assert imap.utf7("日本語") == "&ZeVnLIqe-" # the RFC 3501 example
158
+ assert imap.utf7("A&B") == "A&-B"
159
+ fake = FakeImap({}, folders=("Gel&APY-schte Elemente",))
160
+ list(fetch("h", "u", "sécret", "Gelöschte Elemente", conn=fake))
161
+ assert ("authenticate", "PLAIN", "\0u\0sécret".encode()) in fake.calls
162
+ assert ("select", '"Gel&APY-schte Elemente"', True) in fake.calls
163
+
164
+
165
+ def test_no_terminal_for_the_password(monkeypatch, capsys):
166
+ monkeypatch.delenv("APRF_IMAP_PASSWORD", raising=False)
167
+
168
+ def no_tty(prompt):
169
+ raise EOFError
170
+
171
+ monkeypatch.setattr(cli.getpass, "getpass", no_tty)
172
+ with pytest.raises(SystemExit):
173
+ cli.main(["--imap", "h", "--user", "u"])
174
+ assert "set APRF_IMAP_PASSWORD" in capsys.readouterr().err
@@ -1,85 +0,0 @@
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
- import ssl
8
- from collections.abc import Iterator
9
- from datetime import date
10
-
11
- # Comcast's subjects start "APRF:"; IMAP SUBJECT matches a substring, without case
12
- SUBJECT = "APRF:"
13
- # Messages fetched per round trip
14
- BATCH = 50
15
- # A few years of daily reports for a few selectors; more is a wrong folder
16
- MAX_MESSAGES = 20_000
17
-
18
-
19
- class ImapError(Exception):
20
- pass
21
-
22
-
23
- def _check(typ: str, data: list, what: str) -> list:
24
- if typ != "OK":
25
- detail = b" ".join(d for d in data if isinstance(d, bytes)).decode(errors="replace")
26
- raise ImapError(f"{what} failed: {detail or typ}")
27
- return data
28
-
29
-
30
- def _text(e: Exception) -> str:
31
- # imaplib puts the server's reply bytes in the exception as they came
32
- a = e.args[0] if e.args else ""
33
- return a.decode(errors="replace") if isinstance(a, bytes) else str(e)
34
-
35
-
36
- def _quote(s: str) -> str:
37
- return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
38
-
39
-
40
- def criteria(since: date | None = None, every: bool = False) -> list[str]:
41
- out = [] if every else ["SUBJECT", _quote(SUBJECT)]
42
- if since:
43
- out += ["SINCE", since.strftime("%d-%b-%Y")]
44
- return out or ["ALL"]
45
-
46
-
47
- def fetch(host: str, user: str, password: str, folder: str = "INBOX", port: int = 993,
48
- since: date | None = None, every: bool = False, conn=None) -> Iterator[tuple[str, bytes]]:
49
- """Yield (label, raw message bytes) for each matching message in one folder.
50
-
51
- The folder is opened read-only and bodies are fetched with BODY.PEEK, so no
52
- message is marked read, moved or deleted."""
53
- # imaplib's own default context checks neither the certificate nor the host name
54
- m = conn or imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=60)
55
- try:
56
- try:
57
- _check(*m.login(user, password), "login")
58
- except imaplib.IMAP4.error as e:
59
- raise ImapError(f"login failed: {_text(e)}") from None
60
- try:
61
- _check(*m.select(_quote(folder), readonly=True), f"opening folder {folder}")
62
- except imaplib.IMAP4.error as e:
63
- raise ImapError(f"opening folder {folder} failed: {_text(e)}") from None
64
- (ids,) = _check(*m.uid("SEARCH", *criteria(since, every)), "search")
65
- uids = (ids or b"").split()
66
- if len(uids) > MAX_MESSAGES:
67
- raise ImapError(f"{len(uids):,} messages match, more than {MAX_MESSAGES:,}; "
68
- "pick the folder the reports are filed in, or use --since")
69
- for i in range(0, len(uids), BATCH):
70
- chunk = b",".join(uids[i:i + BATCH]).decode()
71
- data = _check(*m.uid("FETCH", chunk, "(UID RFC822.SIZE BODY.PEEK[])"), "fetch")
72
- for item in data:
73
- if not isinstance(item, tuple):
74
- continue # the closing ")" of each response
75
- head, body = item
76
- # Most servers put UID before the body; one that puts it after still has a sequence number
77
- uid = re.search(rb"UID (\d+)", head)
78
- which = f"uid {uid.group(1).decode()}" if uid else f"message {head.split(b' ', 1)[0].decode(errors='replace')}"
79
- label = f"imap://{host}/{folder} {which}"
80
- yield label, body
81
- finally:
82
- try:
83
- m.logout()
84
- except (imaplib.IMAP4.error, OSError):
85
- pass
File without changes
File without changes
File without changes