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.
Files changed (21) hide show
  1. {aprf_reader-0.3.0/src/aprf_reader.egg-info → aprf_reader-0.4.0}/PKG-INFO +58 -4
  2. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/README.md +57 -3
  3. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/pyproject.toml +1 -1
  4. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader/__init__.py +1 -1
  5. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader/cli.py +1 -1
  6. aprf_reader-0.4.0/src/aprf_reader/load.py +241 -0
  7. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader/render.py +46 -3
  8. aprf_reader-0.4.0/src/aprf_reader/report.py +242 -0
  9. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader/summary.py +24 -6
  10. {aprf_reader-0.3.0 → aprf_reader-0.4.0/src/aprf_reader.egg-info}/PKG-INFO +58 -4
  11. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/tests/test_reader.py +38 -3
  12. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/tests/test_web_parity.py +27 -2
  13. aprf_reader-0.3.0/src/aprf_reader/load.py +0 -139
  14. aprf_reader-0.3.0/src/aprf_reader/report.py +0 -142
  15. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/LICENSE +0 -0
  16. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/setup.cfg +0 -0
  17. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader.egg-info/SOURCES.txt +0 -0
  18. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader.egg-info/dependency_links.txt +0 -0
  19. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader.egg-info/entry_points.txt +0 -0
  20. {aprf_reader-0.3.0 → aprf_reader-0.4.0}/src/aprf_reader.egg-info/requires.txt +0 -0
  21. {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.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.3, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
27
+ **Status: 0.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
28
28
 
29
29
  ## What APRF is
30
30
 
@@ -88,8 +88,62 @@ to mark a change in what a category counts.
88
88
  or share. Nothing is uploaded anywhere.
89
89
  - Follow the draft as it changes. The report format lives in one module
90
90
  (`src/aprf_reader/report.py`), so a new draft revision is a small update.
91
- - When the same day arrives twice, keep the first copy and say so. The draft
92
- has not yet decided whether a resend replaces the original.
91
+ - Check each report against the draft and skip, with the reason, any it
92
+ cannot trust. One bad report in a file does not cost the rest of the file.
93
+
94
+ ## Checking against the draft
95
+
96
+ Every report is checked against draft-ietf-mailmaint-aprf-00, and every
97
+ message names the section, so a list of them can go straight into a review of
98
+ the draft. On the web page, Copy for a draft review copies them as text.
99
+
100
+ **Skipped**, because the report cannot be trusted:
101
+
102
+ | Problem | Section |
103
+ |---|---|
104
+ | Not valid JSON | 5 |
105
+ | A required header field missing or of the wrong type; times not in epoch seconds | 5.2.1 |
106
+ | Not one UTC day from 00:00:00 to 23:59:59, or ending before it starts | 5.1 |
107
+ | Body not a list of segments; a segment not an object | 5.2.2 |
108
+ | A placement or engagement count that is not a whole number | 6, 7 |
109
+ | A saved email with no report attached | 8 |
110
+
111
+ **Warnings**, where the report is still read:
112
+
113
+ | Warning | Section |
114
+ |---|---|
115
+ | `sdi_used` is not N/A, N/F, or the `sdi` value as published in DNS, or is missing | 5.2.1 |
116
+ | Attachment name or subject not `<yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>` | 8 |
117
+ | Message not `multipart/report`; attachment not `application/json` (or `application/gzip` for `.json.gz`) | 8, 8.1 |
118
+ | One attachment holding several reports | 8 |
119
+ | A placement key other than the four section 6 names, or an engagement key other than positive, neutral and negative | 6, 5.2.2 |
120
+ | A name or subject that writes `/` or `*` some other way, as a file name must | 8 is silent |
121
+ | The same day twice from one provider | left open, note in 8.1 |
122
+
123
+ Comcast's beta sends `sdi_used` as ` - N/A - Beta - Not in use` and a source
124
+ of `Comcast/Xfinity`, so its reports carry the `sdi_used` warning, and a
125
+ correct file name for them cannot follow section 8 exactly. A file that
126
+ repeats a report already read, such as the same report as `.json` and as
127
+ `.eml`, is counted once and summed up in one line.
128
+
129
+ ## How it decides
130
+
131
+ - **The same day twice, from the same provider:** it keeps the first copy it
132
+ reads and skips the other, saying whether the skipped copy had a different
133
+ `version` or different counts. The draft leaves resends open (a note in
134
+ section 8.1 asks whether a later report should overwrite or be discarded),
135
+ and `version` is a free string with no order, so there is no defined way
136
+ to pick the newer one. Files are read in name order, so the first copy is
137
+ the first by file name, then by position in the file.
138
+ - **The same day from two providers:** both are kept, as separate streams.
139
+ - **Names:** signing domains and selectors compare without case, and an
140
+ international domain matches its `xn--` form, so `bücher.example` and
141
+ `xn--bcher-kva.example` are one stream.
142
+ - **The reporting window:** section 5.1 says a report MUST cover one UTC day,
143
+ from 00:00:00 to 23:59:59. A report that covers more, or is shifted off the
144
+ day boundary, is skipped with a reason that names section 5.1.
145
+ - **Counts:** whole numbers only. `100.0` reads as 100; `"100"` and negative
146
+ numbers are refused.
93
147
 
94
148
  ## How the web page is built
95
149
 
@@ -11,7 +11,7 @@ Two ways to use it, with the same results:
11
11
  your reports on the page. They are read on your computer and never uploaded.
12
12
  - **On the command line:** `pipx install aprf-reader`.
13
13
 
14
- **Status: 0.3, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
14
+ **Status: 0.4, beta.** Reads reports in the format of draft-ietf-mailmaint-aprf-00.
15
15
 
16
16
  ## What APRF is
17
17
 
@@ -75,8 +75,62 @@ to mark a change in what a category counts.
75
75
  or share. Nothing is uploaded anywhere.
76
76
  - Follow the draft as it changes. The report format lives in one module
77
77
  (`src/aprf_reader/report.py`), so a new draft revision is a small update.
78
- - When the same day arrives twice, keep the first copy and say so. The draft
79
- has not yet decided whether a resend replaces the original.
78
+ - Check each report against the draft and skip, with the reason, any it
79
+ cannot trust. One bad report in a file does not cost the rest of the file.
80
+
81
+ ## Checking against the draft
82
+
83
+ Every report is checked against draft-ietf-mailmaint-aprf-00, and every
84
+ message names the section, so a list of them can go straight into a review of
85
+ the draft. On the web page, Copy for a draft review copies them as text.
86
+
87
+ **Skipped**, because the report cannot be trusted:
88
+
89
+ | Problem | Section |
90
+ |---|---|
91
+ | Not valid JSON | 5 |
92
+ | A required header field missing or of the wrong type; times not in epoch seconds | 5.2.1 |
93
+ | Not one UTC day from 00:00:00 to 23:59:59, or ending before it starts | 5.1 |
94
+ | Body not a list of segments; a segment not an object | 5.2.2 |
95
+ | A placement or engagement count that is not a whole number | 6, 7 |
96
+ | A saved email with no report attached | 8 |
97
+
98
+ **Warnings**, where the report is still read:
99
+
100
+ | Warning | Section |
101
+ |---|---|
102
+ | `sdi_used` is not N/A, N/F, or the `sdi` value as published in DNS, or is missing | 5.2.1 |
103
+ | Attachment name or subject not `<yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>` | 8 |
104
+ | Message not `multipart/report`; attachment not `application/json` (or `application/gzip` for `.json.gz`) | 8, 8.1 |
105
+ | One attachment holding several reports | 8 |
106
+ | A placement key other than the four section 6 names, or an engagement key other than positive, neutral and negative | 6, 5.2.2 |
107
+ | A name or subject that writes `/` or `*` some other way, as a file name must | 8 is silent |
108
+ | The same day twice from one provider | left open, note in 8.1 |
109
+
110
+ Comcast's beta sends `sdi_used` as ` - N/A - Beta - Not in use` and a source
111
+ of `Comcast/Xfinity`, so its reports carry the `sdi_used` warning, and a
112
+ correct file name for them cannot follow section 8 exactly. A file that
113
+ repeats a report already read, such as the same report as `.json` and as
114
+ `.eml`, is counted once and summed up in one line.
115
+
116
+ ## How it decides
117
+
118
+ - **The same day twice, from the same provider:** it keeps the first copy it
119
+ reads and skips the other, saying whether the skipped copy had a different
120
+ `version` or different counts. The draft leaves resends open (a note in
121
+ section 8.1 asks whether a later report should overwrite or be discarded),
122
+ and `version` is a free string with no order, so there is no defined way
123
+ to pick the newer one. Files are read in name order, so the first copy is
124
+ the first by file name, then by position in the file.
125
+ - **The same day from two providers:** both are kept, as separate streams.
126
+ - **Names:** signing domains and selectors compare without case, and an
127
+ international domain matches its `xn--` form, so `bücher.example` and
128
+ `xn--bcher-kva.example` are one stream.
129
+ - **The reporting window:** section 5.1 says a report MUST cover one UTC day,
130
+ from 00:00:00 to 23:59:59. A report that covers more, or is shifted off the
131
+ day boundary, is skipped with a reason that names section 5.1.
132
+ - **Counts:** whole numbers only. `100.0` reads as 100; `"100"` and negative
133
+ numbers are refused.
80
134
 
81
135
  ## How the web page is built
82
136
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aprf-reader"
7
- version = "0.3.0"
7
+ version = "0.4.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.3.0"
3
+ __version__ = "0.4.0"
@@ -36,7 +36,7 @@ def main(argv: list[str] | None = None) -> int:
36
36
  return 1
37
37
 
38
38
  if args.json:
39
- text = json.dumps(to_dict(loaded.reports, loaded.skipped), indent=2) + "\n"
39
+ text = json.dumps(to_dict(loaded), indent=2) + "\n"
40
40
  elif args.md or args.markdown or not sys.stdout.isatty():
41
41
  text = to_markdown(loaded) # files and pipes get Markdown, which other tools read
42
42
  else:
@@ -0,0 +1,241 @@
1
+ """Find report files, open them, and drop duplicates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import hashlib
7
+ import io
8
+ import mailbox
9
+ import re
10
+ import zlib
11
+ from collections.abc import Iterable, Iterator
12
+ from dataclasses import dataclass, field
13
+ from email import policy
14
+ from email.message import Message
15
+ from email.parser import BytesParser
16
+ from pathlib import Path
17
+
18
+ from .report import Report, ReportError, parse_items
19
+
20
+ SUFFIXES = (".json", ".json.gz", ".eml", ".mbox")
21
+ # A daily report is a few KB; this stops a stray huge file or gzip bomb
22
+ MAX_BYTES = 10 * 1024 * 1024
23
+
24
+
25
+ @dataclass
26
+ class Loaded:
27
+ reports: list[Report] = field(default_factory=list)
28
+ skipped: list[tuple[str, str]] = field(default_factory=list) # (where, reason)
29
+ # (warning, detail, where): read, but departs from the draft or meets a case it leaves open
30
+ warnings: list[tuple[str, str, str]] = field(default_factory=list)
31
+ # Files whose report bytes were already read, often the same report as .json and .eml
32
+ copies: int = 0
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Mail:
37
+ """How a report arrived by email, for the section 8 checks."""
38
+ name: str # attachment file name as sent
39
+ ctype: str # attachment Content-Type
40
+ gz: bool
41
+ subject: str
42
+ msg_ctype: str # Content-Type of the message that carries it
43
+
44
+ DUPLICATE_DAY = ("the same day came twice from one provider; the first copy read was kept. "
45
+ "The draft leaves this open (a note in section 8.1)")
46
+
47
+
48
+ def _files(paths: Iterable[Path]) -> Iterator[Path]:
49
+ for p in paths:
50
+ if p.is_dir():
51
+ yield from sorted(f for f in p.rglob("*") if f.is_file() and (f.name.lower().endswith(SUFFIXES) or _is_mbox(f)))
52
+ else:
53
+ yield p
54
+
55
+
56
+ def _is_mbox(path: Path) -> bool:
57
+ # Apple Mail exports a folder "Name.mbox" holding a file named just "mbox"
58
+ return path.name.lower() == "mbox" or path.name.lower().endswith(".mbox")
59
+
60
+
61
+ def _gunzip(data: bytes) -> bytes:
62
+ with gzip.GzipFile(fileobj=io.BytesIO(data)) as g:
63
+ out = g.read(MAX_BYTES + 1)
64
+ if len(out) > MAX_BYTES:
65
+ raise ReportError("larger than 10 MB unpacked")
66
+ return out
67
+
68
+
69
+ def _leaves(m: Message, owner: Message) -> Iterator[tuple[Message, Message]]:
70
+ """Each non-multipart part with the email it belongs to; an attached email owns its own parts."""
71
+ if m.get_content_type() == "message/rfc822":
72
+ for inner in m.get_payload():
73
+ yield from _leaves(inner, inner)
74
+ elif m.is_multipart():
75
+ for p in m.get_payload():
76
+ yield from _leaves(p, owner)
77
+ else:
78
+ yield m, owner
79
+
80
+
81
+ def _attachments(msg: Message, label: str) -> Iterator[tuple[str, bytes, Mail]]:
82
+ """Yield (label, report JSON bytes, how it arrived) for each report attached to one email."""
83
+ for part, owner in _leaves(msg, msg):
84
+ name = part.get_filename() or ""
85
+ fname = name.lower()
86
+ ctype = part.get_content_type()
87
+ data = part.get_payload(decode=True) or b""
88
+ where = f"{label} ({fname or ctype})"
89
+ if len(data) > MAX_BYTES:
90
+ raise ReportError(f"{where} is larger than 10 MB")
91
+ mail = lambda gz: Mail(name, ctype, gz, " ".join(str(owner.get("subject", "")).split()), owner.get_content_type())
92
+ if fname.endswith(".gz") or ctype in ("application/gzip", "application/x-gzip") or data[:2] == b"\x1f\x8b":
93
+ yield where, _gunzip(data), mail(True)
94
+ # By name or type, or an attachment of unhelpful type that is a JSON array or object
95
+ elif fname.endswith(".json") or ctype == "application/json" or (
96
+ part.get_content_disposition() == "attachment" and data.lstrip()[:1] in (b"[", b"{")):
97
+ yield where, data, mail(False)
98
+
99
+
100
+ def _payloads(path: Path) -> Iterator[tuple[str, bytes, Mail | None]]:
101
+ """Yield (label, report JSON bytes, how it arrived) for each report in a file."""
102
+ data = path.read_bytes()
103
+ if len(data) > MAX_BYTES:
104
+ raise ReportError("larger than 10 MB")
105
+ if path.name.lower().endswith(".eml"):
106
+ found = list(_attachments(BytesParser(policy=policy.default).parsebytes(data), str(path)))
107
+ if not found:
108
+ raise ReportError("no report attachment, which section 8 calls for")
109
+ yield from found
110
+ elif data[:2] == b"\x1f\x8b":
111
+ yield str(path), _gunzip(data), None
112
+ else:
113
+ yield str(path), data, None
114
+
115
+
116
+ def _mbox(path: Path, out: Loaded) -> Iterator[tuple[str, bytes, Mail | None]]:
117
+ """Yield reports from a mailbox export; one skip line for all mail with none attached."""
118
+ box = mailbox.mbox(path, factory=lambda f: BytesParser(policy=policy.default).parse(f), create=False)
119
+ without = 0
120
+ try:
121
+ for i, msg in enumerate(box, 1):
122
+ label = f"{path} message {i}"
123
+ try:
124
+ found = list(_attachments(msg, label))
125
+ # OSError covers gzip.BadGzipFile; one bad message must not end the mailbox
126
+ except (OSError, EOFError, zlib.error, ReportError) as e:
127
+ out.skipped.append((label, str(e)))
128
+ continue
129
+ without += not found
130
+ yield from found
131
+ finally:
132
+ box.close()
133
+ if without:
134
+ out.skipped.append((str(path), f"{without} message{'s' if without != 1 else ''} with no report attached"))
135
+
136
+
137
+ def _differs(kept: Report, dropped: Report) -> str:
138
+ if kept.version != dropped.version:
139
+ return f", version {kept.version or 'none'} kept, version {dropped.version or 'none'} dropped"
140
+ return ", with different counts" if kept.segments != dropped.segments else ", same counts"
141
+
142
+
143
+ def _name(r: Report) -> str:
144
+ # Section 8: the ending date, then domain, selector and source with spaces removed
145
+ return f"{r.day:%Y%m%d}_{r.dkim_domain}_{r.dkim_selector}_{r.source.replace(' ', '')}"
146
+
147
+
148
+ # Characters a file name cannot hold on common systems; "Comcast/Xfinity" and a "*" roll-up
149
+ # selector both carry one, and section 8 only says to remove spaces
150
+ UNSAFE = '/\\:*?"<>|'
151
+ NAME_UNSAFE = ("attachment name stands in for a character a file name cannot hold, such as / or *; "
152
+ "section 8 does not say how to write those")
153
+ SUBJECT_UNSAFE = ("subject stands in for / or * as a file name must, though section 8 gives the source "
154
+ "and selector as they are")
155
+
156
+
157
+ def _loose(expected: str, actual: str) -> bool:
158
+ """Equal, ignoring case, if each file-name-unsafe character in expected may be any character."""
159
+ pattern = "".join("." if c in UNSAFE else re.escape(c) for c in expected.lower())
160
+ return re.fullmatch(pattern, actual.lower()) is not None
161
+
162
+
163
+ def _mail_warnings(mail: Mail, items: list) -> list[tuple[str, str]]:
164
+ out = []
165
+ if mail.msg_ctype != "multipart/report":
166
+ out.append(("message Content-Type is not multipart/report (section 8)", mail.msg_ctype))
167
+ want = "application/gzip" if mail.gz else "application/json"
168
+ if mail.ctype != want:
169
+ out.append(("attachment Content-Type is not application/json, or application/gzip when "
170
+ "compressed (sections 8 and 8.1)", mail.ctype))
171
+ if len(items) > 1:
172
+ out.append(("one attachment holds several reports; the section 8 name and subject fit one", f"{len(items)} reports"))
173
+ elif isinstance(items[0], Report):
174
+ base = _name(items[0])
175
+ file = base + (".json.gz" if mail.gz else ".json")
176
+ if mail.name.lower() == file.lower():
177
+ pass
178
+ elif _loose(file, mail.name):
179
+ out.append((NAME_UNSAFE, f"{mail.name}; section 8 gives {file}"))
180
+ else:
181
+ out.append(("attachment name does not follow section 8: <yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>.json",
182
+ f"{mail.name or '(none)'}; expected {file}"))
183
+ if mail.subject.lower() == f"APRF: {base}".lower():
184
+ pass
185
+ elif _loose(f"APRF: {base}", mail.subject):
186
+ out.append((SUBJECT_UNSAFE, f"{mail.subject}; section 8 gives APRF: {base}"))
187
+ else:
188
+ out.append(("subject does not follow section 8: APRF: <yyyymmdd>_<DKIM domain>_<DKIM selector>_<source>",
189
+ f"{mail.subject or '(none)'}; expected APRF: {base}"))
190
+ return out
191
+
192
+
193
+ def load(paths: Iterable[Path]) -> Loaded:
194
+ out = Loaded()
195
+ seen_bytes: set[str] = set()
196
+ seen_keys: dict[tuple, tuple[str, Report]] = {}
197
+
198
+ parsed: dict[str, list | ReportError] = {}
199
+
200
+ def add(where: str, data: bytes, mail: Mail | None) -> None:
201
+ digest = hashlib.sha256(data).hexdigest()
202
+ copy = digest in parsed
203
+ if not copy:
204
+ try:
205
+ parsed[digest] = parse_items(data)
206
+ except ReportError as e:
207
+ parsed[digest] = e
208
+ items = parsed[digest]
209
+ # An email is checked even when its report was already read from another file
210
+ if mail is not None and not isinstance(items, ReportError):
211
+ out.warnings += [(w, d, where) for w, d in _mail_warnings(mail, items)]
212
+ if copy:
213
+ out.copies += 1
214
+ return
215
+ if isinstance(items, ReportError):
216
+ out.skipped.append((where, str(items)))
217
+ return
218
+ for i, r in enumerate(items, 1):
219
+ label = f"{where} report {i}" if len(items) > 1 else where
220
+ if isinstance(r, ReportError):
221
+ out.skipped.append((label, str(r)))
222
+ continue
223
+ out.warnings += [(w, d, label) for w, d in r.warnings]
224
+ # The draft leaves open whether a resend replaces the first (an open note in
225
+ # section 8.1), and its version is a free string with no order; keep the first
226
+ if r.key in seen_keys:
227
+ first_label, first = seen_keys[r.key]
228
+ out.warnings.append((DUPLICATE_DAY, f"{first_label} kept{_differs(first, r)}", label))
229
+ continue
230
+ seen_keys[r.key] = (label, r)
231
+ out.reports.append(r)
232
+
233
+ for path in _files(paths):
234
+ try:
235
+ # An mbox can be large, so it is read one message at a time
236
+ payloads = _mbox(path, out) if _is_mbox(path) else list(_payloads(path))
237
+ for where, data, mail in payloads:
238
+ add(where, data, mail)
239
+ except (OSError, EOFError, zlib.error, ReportError) as e:
240
+ out.skipped.append((str(path), str(e)))
241
+ return out
@@ -9,7 +9,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 = [f"{min(v for _, v in t):,} to {max(v for _, v in t):,}" for t in trends]
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
- out += [st.dim(f" {w}: {r}") for w, r in loaded.skipped]
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"