freemyid 0.2.0.dev1__py3-none-any.whl

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 (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,283 @@
1
+ """CLI setup/edit flows and family bundle export/import."""
2
+ from __future__ import annotations
3
+
4
+ import getpass
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from ..crypto import export_with_passphrase, import_with_passphrase
9
+ from ..models import Profile
10
+ from . import draft as dr
11
+
12
+ BUNDLE_VERSION = 1
13
+
14
+
15
+ def _ask(text: str, default: str = "") -> str:
16
+ suffix = f" [{default}]" if default else ""
17
+ return (input(f"{text}{suffix}: ").strip()) or default
18
+
19
+
20
+ def _yes(text: str, default: bool = False) -> bool:
21
+ ans = input(f"{text} [{'Y/n' if default else 'y/N'}]: ").strip().lower()
22
+ return default if not ans else ans.startswith("y")
23
+
24
+
25
+ def _secret(text: str) -> str:
26
+ return getpass.getpass(f"{text}: ")
27
+
28
+
29
+ def _say(text: str = "") -> None:
30
+ print(text)
31
+
32
+
33
+ class _Form(dict):
34
+ """Adapter so CLI answers go through the same validators as the web form."""
35
+
36
+ def getlist(self, key):
37
+ v = self.get(key)
38
+ return v if isinstance(v, list) else ([v] if v else [])
39
+
40
+
41
+ def _step(d: dr.Draft, step: str, answers: dict) -> bool:
42
+ errs = dr.apply_step(d, step, _Form(answers))
43
+ for e in errs:
44
+ _say(f" ! {e}")
45
+ return not errs
46
+
47
+
48
+ def _loop(d: dr.Draft, step: str, ask) -> None:
49
+ while True:
50
+ if _step(d, step, ask()):
51
+ return
52
+
53
+
54
+ def _lines_default(items: list[str]) -> str:
55
+ return "; ".join(items)
56
+
57
+
58
+ def _split(text: str) -> str:
59
+ return "\n".join(x.strip() for x in text.split(";") if x.strip())
60
+
61
+
62
+ def _consent(d: dr.Draft) -> None:
63
+ _say("\n== Permission ==")
64
+ for t in dr.CONSENT_TEXT:
65
+ _say(f"- {t}")
66
+ _say("\nWho is giving permission?")
67
+ keys = list(dr.BASIS_LABELS)
68
+ for i, k in enumerate(keys, 1):
69
+ _say(f" {i}. {dr.BASIS_LABELS[k]}")
70
+
71
+ def ask():
72
+ n = _ask("Choose 1-3", "1")
73
+ basis = keys[int(n) - 1] if n.isdigit() and 1 <= int(n) <= 3 else ""
74
+ if basis == "authorized_agent":
75
+ _say(" The signed form can be attached later from the dashboard; Free My ID will "
76
+ "not run for this person until it is.")
77
+ return {"basis": basis, "attested_by": _ask("Type your full name to sign", d.attested_by),
78
+ "ack": "1" if _yes("I have read this and I agree") else ""}
79
+ _loop(d, "consent", ask)
80
+
81
+
82
+ def _names(d: dr.Draft) -> None:
83
+ _say("\n== Names ==")
84
+ _loop(d, "names", lambda: {
85
+ "first": _ask("First name", d.first), "middle": _ask("Middle name (optional)", d.middle),
86
+ "last": _ask("Last name", d.last), "suffix": _ask("Suffix (optional)", d.suffix),
87
+ "label": _ask("What to call them on screen", d.label or d.first),
88
+ "nicknames": _split(_ask("Nicknames, separated by ;", _lines_default(d.nicknames))),
89
+ "previous_names": _split(_ask("Previous/maiden full names, separated by ;",
90
+ _lines_default(d.previous_names)))})
91
+
92
+
93
+ def _addresses(d: dr.Draft) -> None:
94
+ _say("\n== Addresses (city and state are enough) ==")
95
+
96
+ def ask():
97
+ a: dict = {}
98
+ cur = "0"
99
+ for i in range(4):
100
+ old = d.addresses[i] if i < len(d.addresses) else {}
101
+ city = _ask(f"Address {i + 1} city (blank to stop)", old.get("city", ""))
102
+ if not city:
103
+ break
104
+ a[f"city{i}"] = city
105
+ a[f"state{i}"] = _ask(" State (2 letters)", old.get("state", ""))
106
+ a[f"street{i}"] = _ask(" Street (optional)", old.get("street", ""))
107
+ a[f"zip{i}"] = _ask(" ZIP (optional)", old.get("zip", ""))
108
+ if _yes(" Is this the current address", i == 0):
109
+ cur = str(i)
110
+ a["current"] = cur
111
+ return a
112
+ _loop(d, "addresses", ask)
113
+
114
+
115
+ def _details(d: dr.Draft) -> None:
116
+ _say("\n== Other details (used to find the right listings; birth year, relatives and "
117
+ "personal emails are never sent) ==")
118
+ _loop(d, "details", lambda: {
119
+ "birth_year": _ask("Year of birth, 4 digits (or blank)", dr.birth_year_of(d)),
120
+ "phones": _split(_ask("Phones, separated by ;", _lines_default(d.phones))),
121
+ "emails": _split(_ask("Personal emails, separated by ;", _lines_default(d.emails))),
122
+ "relatives": _split(_ask("Relatives (full names), separated by ;",
123
+ _lines_default(d.relatives)))})
124
+
125
+
126
+ def _permissions(d: dr.Draft) -> None:
127
+ _say("\n== What Free My ID may do (all off unless you say yes; it never pays) ==")
128
+ _step(d, "permissions", {
129
+ "allow_id_upload": "1" if _yes("Allow uploading an ID when a broker requires it") else "",
130
+ "allow_account_creation": "1" if _yes("Allow creating a broker account if required")
131
+ else "",
132
+ "allow_phone_disclosure": "1" if _yes("Allow giving a phone number if required") else ""})
133
+
134
+
135
+ def _trust_local(d: dr.Draft, a: dict) -> None:
136
+ """Ask the user to approve the local mail app's security code (no password is sent)."""
137
+ from ..engine.mail_tls import TlsPinError, trust_on_first_use
138
+ from ..models import MailboxConfig
139
+ from ..netpolicy import NetPolicy
140
+ pre = dr.PRESETS["proton_bridge"]
141
+ try:
142
+ res = trust_on_first_use(MailboxConfig(
143
+ provider="proton_bridge", address=a.get("address") or "x@example.org",
144
+ username="x", keyring_service="freemyid-mailbox", **pre),
145
+ netpolicy=NetPolicy(None, allow_loopback=True))
146
+ except (TlsPinError, ValueError):
147
+ _say(dr.unreachable_message(dr.PRESETS["proton_bridge"]["imap_host"]))
148
+ return
149
+ _say("\n" + res.message)
150
+ if _yes("Do you trust this code", False):
151
+ d.tls_pin = res.fingerprint
152
+ else:
153
+ _say("Not trusted. Free My ID will not sign in to this mail app.")
154
+
155
+
156
+ def _mailbox(d: dr.Draft) -> None:
157
+ _say("\n== Dedicated removal mailbox ==")
158
+ if not _yes("Set it up now", True):
159
+ d.skip_mailbox = True
160
+ return
161
+ d.skip_mailbox = False
162
+ provs = list(dr.PRESETS) # gmail, yahoo, icloud, proton_bridge, other (Outlook.com: skip)
163
+ old = d.mailbox or {}
164
+
165
+ def ask():
166
+ prov = _ask(f"Provider ({'/'.join(provs)})", old.get("provider", "gmail"))
167
+ a = {"provider": prov, "address": _ask("Mailbox address", old.get("address", "")),
168
+ "password": _secret("Password / app password (hidden; blank keeps saved)")}
169
+ if prov == "proton_bridge":
170
+ _trust_local(d, a)
171
+ if prov == "other":
172
+ a.update(imap_host=_ask("Incoming mail server (IMAP)"), smtp_host=_ask("Outgoing mail server (SMTP)"),
173
+ imap_port=_ask("IMAP port", "993"), smtp_port=_ask("SMTP port", "465"),
174
+ imap_ssl="1" if _yes("IMAP uses SSL", True) else "",
175
+ imap_starttls="1" if _yes("IMAP uses STARTTLS (needed if SSL is off, "
176
+ "or your password is sent unprotected)") else "",
177
+ smtp_starttls="1" if _yes("SMTP uses STARTTLS") else "")
178
+ return a
179
+ _loop(d, "mailbox", ask)
180
+ if d.password and _yes("Test the connection now"):
181
+ _say(dr.test_mailbox_connection(d.mailbox, d.password)[1])
182
+
183
+
184
+ def _email_sched(d: dr.Draft) -> None:
185
+ _say("\n== Email approval ==")
186
+ auto = _yes("Send routine removal emails automatically? (No = ask me each time)")
187
+ _step(d, "email", {"email_mode": "auto" if auto else "approve_each"})
188
+ _say("\n== Schedule ==")
189
+ on = _yes("Re-check regularly", d.weekly_recheck)
190
+ _step(d, "schedule", {"weekly_recheck": "1" if on else "", "weekday": d.weekday, "hour": d.hour})
191
+
192
+
193
+ def _finish(store, d: dr.Draft) -> Profile:
194
+ _say("\nSome brokers need manual steps, listings can reappear, and 'removed' means "
195
+ "removed from that broker only.")
196
+ if not _yes("Save this person", True):
197
+ raise SystemExit("Cancelled; nothing saved.")
198
+ prof = dr.save_draft(store, d)
199
+ _say("Saved. Next: `freemyid run --dry-run`, then `freemyid schedule install`.")
200
+ return prof
201
+
202
+
203
+ def setup_cli(store, prefill: Path | None = None) -> Profile:
204
+ """Interactive terminal setup, same steps as the web wizard."""
205
+ d = dr.Draft()
206
+ if prefill:
207
+ d = load_bundle(Path(prefill).read_bytes(), _secret("Passphrase for the setup file"))
208
+ _say("Loaded. Please review everything and give your own permission.")
209
+ _consent(d)
210
+ _names(d)
211
+ _addresses(d)
212
+ _details(d)
213
+ _permissions(d)
214
+ _mailbox(d)
215
+ _email_sched(d)
216
+ return _finish(store, d)
217
+
218
+
219
+ def edit_cli(store, profile_id: str) -> Profile:
220
+ p = store.get_profile(profile_id)
221
+ if p is None:
222
+ raise ValueError("no such profile")
223
+ d = dr.draft_from_profile(p)
224
+ sections = [("Names", _names), ("Addresses", _addresses), ("Other details", _details),
225
+ ("Permissions", _permissions), ("Mailbox", _mailbox),
226
+ ("Email approval and schedule", _email_sched)]
227
+ for title, fn in sections:
228
+ if _yes(f"Change {title}"):
229
+ fn(d)
230
+ return _finish(store, d)
231
+
232
+
233
+ # --------------------------------------------------------------------------- bundles
234
+
235
+
236
+ def export_family_bundle(store, profile_id: str, dest: Path, passphrase: str) -> Path:
237
+ """Passphrase-encrypted pre-filled profile. No consent, mailbox or password inside."""
238
+ p = store.get_profile(profile_id)
239
+ if p is None:
240
+ raise ValueError("no such profile")
241
+ data = p.model_dump(mode="json", exclude={"id", "consent", "mailbox", "created_at",
242
+ "updated_at", "is_minor"})
243
+ blob = export_with_passphrase(
244
+ json.dumps({"freemyid_bundle": BUNDLE_VERSION, "profile": data}).encode(), passphrase)
245
+ dest = Path(dest)
246
+ if dest.suffix != ".freemyid-profile":
247
+ dest = dest.with_name(dest.name + ".freemyid-profile")
248
+ dest.write_bytes(blob)
249
+ dest.chmod(0o600)
250
+ return dest
251
+
252
+
253
+ def find_family_bundle() -> Path | None:
254
+ """A .freemyid-profile shipped next to the installed app (the "family installer")."""
255
+ import sys
256
+ base = Path(sys.executable).parent if getattr(sys, "frozen", False) else None
257
+ if base is None:
258
+ return None
259
+ return next(iter(sorted(base.glob("*.freemyid-profile"))), None)
260
+
261
+
262
+ def load_bundle(blob: bytes, passphrase: str) -> dr.Draft:
263
+ """Decrypt a bundle into a fresh draft. Consent and mailbox are never imported."""
264
+ obj = json.loads(import_with_passphrase(blob, passphrase))
265
+ if obj.get("freemyid_bundle") != BUNDLE_VERSION:
266
+ raise ValueError("unsupported bundle version")
267
+ pr = obj.get("profile")
268
+ if not isinstance(pr, dict):
269
+ raise TypeError("bad bundle")
270
+ # Never trust identifiers from a file: a fresh id is minted on save (path-traversal guard).
271
+ for k in ("id", "consent", "mailbox", "created_at", "updated_at", "is_minor"):
272
+ pr.pop(k, None)
273
+ from ..models import Consent, ConsentBasis
274
+ tmp = Profile.model_validate({
275
+ **pr, "consent": Consent(basis=ConsentBasis.SELF, attested_by="x",
276
+ text_version="import").model_dump(mode="json")})
277
+ d = dr.draft_from_profile(tmp, keep_consent=False)
278
+ d.editing = False
279
+ d.imported = True
280
+ d.attested_by = ""
281
+ d.basis = ConsentBasis.SELF.value
282
+ d.mailbox, d.skip_mailbox, d.mailbox_answered = {}, False, False
283
+ return d
@@ -0,0 +1,86 @@
1
+ """Plain progress report (HTML, or PDF when weasyprint is installed).
2
+
3
+ Deliberately PII-light: the person's on-screen label, site names, states and dates only.
4
+ No addresses, phone numbers or evidence images, so the file is safe to share with family.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from .. import paths
11
+ from ..models import ListingState, utcnow
12
+ from .ui import CHIP_LABEL, HONESTY, esc
13
+
14
+ _CSS = ("body{font:18px/1.5 system-ui,sans-serif;max-width:46rem;margin:2rem auto;padding:0 1rem}"
15
+ "table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #ccc;"
16
+ "padding:.4rem;text-align:left}td.d{white-space:nowrap}.cov{display:block;font-size:.8em;color:#666}"
17
+ "@page{size:A4;margin:1.5cm}@media print{body{font-size:14px;max-width:none;margin:0}}"
18
+ ".note{background:#f4f4f4;padding:.8rem;border-radius:6px}")
19
+
20
+
21
+ _GOOD = {ListingState.CONFIRMED, ListingState.REMOVED}
22
+ _WAIT = {ListingState.SUBMITTED, ListingState.AWAITING_VERIFICATION}
23
+ _ASKED = _GOOD | _WAIT | {ListingState.REAPPEARED, ListingState.NEEDS_HUMAN}
24
+
25
+
26
+ def build_report(store, registry, profile_id: str, fmt: str = "html") -> Path:
27
+ p = store.get_profile(profile_id)
28
+ if p is None:
29
+ raise ValueError("no such profile")
30
+ if fmt not in ("html", "pdf"):
31
+ raise ValueError("fmt must be 'html' or 'pdf'")
32
+ names = {b.id: b.name for b in registry.brokers} if registry else {}
33
+ members: dict[str, list[str]] = {}
34
+ for b in (registry.brokers if registry else []):
35
+ if getattr(b, "covered_by", None):
36
+ members.setdefault(b.covered_by, []).append(b.name)
37
+
38
+ def site_cell(bid: str) -> str:
39
+ full = names.get(bid, bid)
40
+ short = full.split(" (")[0]
41
+ cov = members.get(bid)
42
+ if cov:
43
+ extra = f'<span class="cov">covers: {esc(", ".join(sorted(cov)))}</span>'
44
+ elif short != full:
45
+ extra = f'<span class="cov">{esc(full[len(short) + 2:].rstrip(")"))}</span>'
46
+ else:
47
+ extra = ""
48
+ return esc(short) + extra
49
+
50
+ sts = sorted(store.list_statuses(profile_id), key=lambda s: names.get(s.broker_id, s.broker_id))
51
+ counts: dict[ListingState, int] = {}
52
+ for s in sts:
53
+ counts[s.state] = counts.get(s.state, 0) + 1
54
+ asked = sum(n for k, n in counts.items() if k in _ASKED)
55
+ good = sum(n for k, n in counts.items() if k in _GOOD)
56
+ wait = sum(n for k, n in counts.items() if k in _WAIT)
57
+ rows = "".join(
58
+ f"<tr><td>{site_cell(s.broker_id)}</td><td>{esc(CHIP_LABEL[s.state])}"
59
+ f"</td><td class='d'>{s.last_scan_at.date() if s.last_scan_at else '-'}</td>"
60
+ f"<td class='d'>{s.last_submitted_at.date() if s.last_submitted_at else '-'}</td></tr>"
61
+ for s in sts)
62
+ todos = store.list_todos(profile_id)
63
+ doc = (f'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Free My ID report'
64
+ f"</title><style>{_CSS}</style></head><body><h1>{esc(p.label)}: what Free My ID has asked, and what came back</h1>"
65
+ f"<p>As of {utcnow().date()}, Free My ID has asked {asked} people-search sites to "
66
+ f"delete this person's details. {good} have confirmed or no longer list them. "
67
+ f"{wait} are still waiting. {len(todos)} need a person to do one step. "
68
+ f"Sites can put details back up, so Free My ID checks again.</p>"
69
+ f'<table><tr><th>Site</th><th>Status</th><th>Last checked</th>'
70
+ f"<th>Last request</th></tr>{rows}</table>"
71
+ f'<p class="note">{esc(HONESTY)}</p></body></html>')
72
+ stem = f"report-{profile_id[:8]}-{utcnow().strftime('%Y%m%d')}"
73
+ out = paths.artifacts_dir() / f"{stem}.html"
74
+ out.write_text(doc, encoding="utf-8")
75
+ out.chmod(0o600)
76
+ if fmt == "pdf":
77
+ try:
78
+ from weasyprint import HTML # optional dependency
79
+ except ImportError:
80
+ return out # caller sees the .html suffix and can tell PDF was unavailable
81
+ pdf = out.with_suffix(".pdf")
82
+ HTML(string=doc).write_pdf(str(pdf))
83
+ pdf.chmod(0o600)
84
+ out.unlink(missing_ok=True)
85
+ return pdf
86
+ return out