flowcvcli 0.2.0__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.
flowcvcli/config.py ADDED
@@ -0,0 +1,87 @@
1
+ """Configuration: resolve resume id + auth from a dotenv file and env vars.
2
+
3
+ Dotenv search order (first match wins; later files fill in missing keys):
4
+ 1. ``$FLOWCV_ENV_FILE`` — an explicit path
5
+ 2. ``./.env`` — in the current working directory (where you run the tool)
6
+ 3. ``<config home>/.env`` — e.g. ``~/.config/flowcvcli/.env``
7
+
8
+ Real environment variables always override the dotenv file. The cached session
9
+ cookie (a credential) is written to ``<config home>/session`` with ``0600``
10
+ permissions, overridable via ``$FLOWCV_SESSION_FILE``. Paths are resolved from
11
+ the user's environment, never from the install location, so the tool behaves the
12
+ same whether it's run from source or ``pip install``-ed.
13
+ """
14
+ import os
15
+
16
+ APP = "flowcvcli"
17
+
18
+
19
+ def _config_home():
20
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
21
+ return os.path.join(base, APP)
22
+
23
+
24
+ # Where the cached session cookie lives (override with $FLOWCV_SESSION_FILE).
25
+ SESSION_FILE = os.environ.get("FLOWCV_SESSION_FILE") or os.path.join(_config_home(), "session")
26
+
27
+
28
+ def _dotenv_files():
29
+ paths = []
30
+ if os.environ.get("FLOWCV_ENV_FILE"):
31
+ paths.append(os.environ["FLOWCV_ENV_FILE"])
32
+ paths.append(os.path.join(os.getcwd(), ".env"))
33
+ paths.append(os.path.join(_config_home(), ".env"))
34
+ return paths
35
+
36
+
37
+ def _load_dotenv():
38
+ fv = {}
39
+ for path in _dotenv_files():
40
+ if not os.path.exists(path):
41
+ continue
42
+ with open(path) as f:
43
+ for line in f:
44
+ line = line.rstrip("\r\n").strip() # tolerate CRLF (.env edited on Windows)
45
+ if line.startswith("export "): # tolerate `export KEY=val`
46
+ line = line[len("export "):]
47
+ if line and not line.startswith("#") and "=" in line:
48
+ k, v = line.split("=", 1) # split first '='; cookie may contain '='
49
+ v = v.strip()
50
+ if len(v) >= 2 and v[0] in "\"'" and v[-1] == v[0]:
51
+ v = v[1:-1] # drop surrounding quotes
52
+ fv.setdefault(k.strip(), v) # first file wins per key
53
+ return fv
54
+
55
+
56
+ def _pick(fv, *keys):
57
+ for k in keys:
58
+ v = os.environ.get(k) or fv.get(k)
59
+ if v:
60
+ return v
61
+ return None
62
+
63
+
64
+ class Config:
65
+ """Resolved auth + target resume. resume_id may be None for resume-list ops."""
66
+
67
+ def __init__(self, resume_id=None, cookie=None, email=None, password=None):
68
+ self.resume_id = resume_id
69
+ self.cookie = cookie
70
+ self.email = email
71
+ self.password = password
72
+
73
+ @classmethod
74
+ def load(cls):
75
+ fv = _load_dotenv()
76
+ return cls(
77
+ resume_id=_pick(fv, "FLOWCV_RESUME_ID", "RESUME_ID"),
78
+ cookie=_pick(fv, "FLOWCV_COOKIE", "COOKIE"),
79
+ email=_pick(fv, "FLOWCV_EMAIL", "EMAIL"),
80
+ password=_pick(fv, "FLOWCV_PASSWORD", "PASSWORD"),
81
+ )
82
+
83
+ def require_resume_id(self):
84
+ if not self.resume_id:
85
+ raise SystemExit("No resume id. Set FLOWCV_RESUME_ID, pass resume_id=, "
86
+ "or use --resume-id. Run `flowcv resumes` to list them.")
87
+ return self.resume_id
flowcvcli/content.py ADDED
@@ -0,0 +1,168 @@
1
+ """Content sections & entries: create / update / delete resume entries.
2
+
3
+ A resume's `content` is a map of `sectionId -> {entries[], sectionType,
4
+ displayName, iconKey}`. Entries are addressed by their `id`. Writes are
5
+ read-modify-write where the API replaces the whole entry; create uses
6
+ `save_entry` with extra section-meta so a missing section is created too.
7
+
8
+ Mixes into Client; uses self.get_resume / self.request / self.resume_id.
9
+ """
10
+ import uuid
11
+
12
+ from .markup import md_to_html
13
+
14
+ # sectionId -> (sectionType, displayName, iconKey) for creating sections.
15
+ SECTION_META = {
16
+ "profile": ("profile", "Summary", "address-card"),
17
+ "work": ("work", "Professional Experience", "briefcase"),
18
+ "education": ("education", "Education", "graduation-cap"),
19
+ "skill": ("skill", "Skills", "head-side-brain"),
20
+ "publication": ("publication", "Publications", "newspaper"),
21
+ "organisation": ("organisation", "Organisations", "house-user"),
22
+ "custom1": ("custom", "Custom", "star"),
23
+ }
24
+
25
+
26
+ def label_of(entry):
27
+ """Best human label for an entry across section shapes."""
28
+ for k in ("jobTitle", "employer", "title", "degree", "skill",
29
+ "position", "publicationTitle", "organisationName"):
30
+ v = entry.get(k)
31
+ if v:
32
+ return v
33
+ return "(empty)"
34
+
35
+
36
+ class ContentMixin:
37
+ # ---- lookups ----------------------------------------------------------
38
+ def find_section(self, resume, section):
39
+ """Return the section object from resume.content (or exit)."""
40
+ sec = (resume.get("content") or {}).get(section)
41
+ if sec is None:
42
+ raise SystemExit(f"section not found: {section}")
43
+ return sec
44
+
45
+ def find_entry(self, resume, section, entry_id):
46
+ """Return the entry dict with id == entry_id in section (or exit)."""
47
+ sec = self.find_section(resume, section)
48
+ for entry in sec.get("entries") or []:
49
+ if entry.get("id") == entry_id:
50
+ return entry
51
+ raise SystemExit(f"entry not found: {section}/{entry_id}")
52
+
53
+ # ---- low-level writes -------------------------------------------------
54
+ def save_entry(self, section, entry, extra=None):
55
+ """PATCH save_entry; updates the entry (or creates it with `extra`)."""
56
+ body = {"resumeId": self.resume_id, "sectionId": section, "entry": entry}
57
+ if extra:
58
+ body.update(extra)
59
+ return self.request("resumes/save_entry", method="PATCH", body=body)
60
+
61
+ def delete_entry(self, section, entry_id):
62
+ """DELETE delete_entry by (resumeId, sectionId, entryId)."""
63
+ query = {"resumeId": self.resume_id, "sectionId": section,
64
+ "entryId": entry_id}
65
+ return self.request("resumes/delete_entry", method="DELETE", query=query)
66
+
67
+ # ---- high-level helpers ----------------------------------------------
68
+ def add_entry(self, section, sets=None, md=None):
69
+ """Create an entry (and the section if needed); return the new id.
70
+
71
+ `sets` are entry fields to populate; `md` becomes a `description`
72
+ HTML field. Creates the entry first (so a missing section is made),
73
+ then fills it with a follow-up update.
74
+ """
75
+ resume = self.get_resume()
76
+ existing = (resume.get("content") or {}).get(section)
77
+ if existing:
78
+ section_type = existing.get("sectionType")
79
+ display_name = existing.get("displayName")
80
+ icon_key = existing.get("iconKey")
81
+ elif section in SECTION_META:
82
+ section_type, display_name, icon_key = SECTION_META[section]
83
+ else:
84
+ raise SystemExit(f"unknown section (no meta): {section}")
85
+
86
+ new_id = str(uuid.uuid4())
87
+ # Create: minimal entry + section meta (also creates the section).
88
+ env = self.save_entry(section, {"id": new_id, "isHidden": False}, extra={
89
+ "sectionType": section_type,
90
+ "sectionDisplayName": display_name,
91
+ "sectionIconKey": icon_key,
92
+ })
93
+ if not env.get("success"):
94
+ raise SystemExit(f"could not create {section} entry: {env}")
95
+
96
+ now = self.now_iso()
97
+ entry = {"id": new_id, "isHidden": False, "showPlaceholder": False,
98
+ "createdAt": now, "updatedAt": now}
99
+ entry.update(sets or {})
100
+ if md:
101
+ # rich text lives in a section-specific field (profile->text, skill->infoHtml)
102
+ field = {"profile": "text", "skill": "infoHtml"}.get(section, "description")
103
+ entry[field] = md_to_html(md)
104
+ env = self.save_entry(section, entry)
105
+ if not env.get("success"):
106
+ raise SystemExit(f"created {section} entry {new_id} but failed to populate it: {env}")
107
+ return new_id
108
+
109
+ def set_field(self, section, entry_id, field, value):
110
+ """Read-modify-write a single entry field; bump updatedAt if present."""
111
+ resume = self.get_resume()
112
+ entry = dict(self.find_entry(resume, section, entry_id))
113
+ entry[field] = value
114
+ if "updatedAt" in entry:
115
+ entry["updatedAt"] = self.now_iso()
116
+ return self.save_entry(section, entry)
117
+
118
+ def set_description(self, section, entry_id, md, field="description"):
119
+ """Set a rich-text field to md_to_html(md) on an entry."""
120
+ return self.set_field(section, entry_id, field, md_to_html(md))
121
+
122
+ def hide_entry(self, section, entry_id, hidden=True):
123
+ """Show/hide an entry (sets its `isHidden`). Hidden entries stay in the
124
+ resume but are omitted from the rendered output."""
125
+ return self.set_field(section, entry_id, "isHidden", bool(hidden))
126
+
127
+ # ---- section-level ops ------------------------------------------------
128
+ def reorder_entries(self, section, order):
129
+ """Set the order of entries in a section. `order` is the list of entry
130
+ ids in the desired order (must be a permutation of the section's ids).
131
+
132
+ PATCH save_entries_order with `disableAutoSort` so the manual order sticks
133
+ (FlowCV otherwise auto-sorts by date)."""
134
+ resume = self.get_resume()
135
+ have = [e.get("id") for e in self.find_section(resume, section).get("entries") or []]
136
+ order = list(order)
137
+ if set(order) != set(have):
138
+ raise SystemExit(f"reorder ids must be exactly the section's entries.\n"
139
+ f" given: {order}\n section: {have}")
140
+ return self.request("resumes/save_entries_order", method="PATCH",
141
+ body={"resumeId": self.resume_id, "sectionId": section,
142
+ "newEntriesIdsOrder": order, "disableAutoSort": True})
143
+
144
+ def rename_section(self, section, display_name):
145
+ """PATCH save_section_name — change a section's heading text."""
146
+ return self.request("resumes/save_section_name", method="PATCH",
147
+ body={"resumeId": self.resume_id, "sectionId": section,
148
+ "displayName": display_name})
149
+
150
+ def set_section_icon(self, section, icon_key):
151
+ """PATCH save_section_icon — change a section's icon (e.g. 'briefcase')."""
152
+ return self.request("resumes/save_section_icon", method="PATCH",
153
+ body={"resumeId": self.resume_id, "sectionId": section,
154
+ "iconKey": icon_key})
155
+
156
+ def delete_section(self, section):
157
+ """DELETE delete_section — remove a whole section and all its entries."""
158
+ return self.request("resumes/delete_section", method="DELETE",
159
+ query={"resumeId": self.resume_id, "sectionId": section})
160
+
161
+ def reorder_sections(self, section_ids, layout="one"):
162
+ """Set the section order for a column layout via the customization field
163
+ `sectionOrder.<layout>.sectionsSorted`. `layout` is 'one' (single column;
164
+ default) — two-column layouts store left/right lists separately.
165
+
166
+ Sections are ordered in `resume.customization.sectionOrder`, not in
167
+ `content`, so this is a `save_customization` delta."""
168
+ return self.set(f"sectionOrder.{layout}.sectionsSorted", list(section_ids))
@@ -0,0 +1,85 @@
1
+ """Customization: styling deltas & template application.
2
+
3
+ FlowCV stores all design in `resume.customization` (font, colors, layout,
4
+ spacing, headings, page format, …). Updates go through a **delta API**: each
5
+ change is a dot-`path` into that object plus a new `value`, sent to
6
+ `save_customization`. Templates are full customization presets pulled from a
7
+ public catalog and applied via `apply_template`.
8
+ """
9
+
10
+ TEMPLATE_CATALOG = "https://app.flowcv.com/pubcache/published-resume-templates"
11
+
12
+
13
+ class CustomizationMixin:
14
+ """Styling & template operations (mixed into Client)."""
15
+
16
+ def customize(self, updates):
17
+ """Apply a batch of customization deltas. Return the envelope dict.
18
+
19
+ `updates` is a list of (path, value) tuples or {"path", "value"} dicts;
20
+ `path` is a dot-path into `resume.customization`
21
+ (e.g. "font.fontFamily", "colors.basic.single").
22
+ """
23
+ deltas = []
24
+ for u in updates:
25
+ if isinstance(u, dict):
26
+ deltas.append({"path": u["path"], "value": u["value"]})
27
+ else:
28
+ path, value = u
29
+ deltas.append({"path": path, "value": value})
30
+ body = {"resumeId": self.resume_id, "customizationUpdates": deltas}
31
+ return self.request("resumes/save_customization", method="PATCH", body=body)
32
+
33
+ def set(self, path, value):
34
+ """Apply a single customization delta. Return the envelope dict."""
35
+ return self.customize([(path, value)])
36
+
37
+ def get_customization(self):
38
+ """Return the current `resume.customization` object."""
39
+ return self.get_resume()["customization"]
40
+
41
+ def list_templates(self):
42
+ """Return the published template catalog as a list of template dicts.
43
+
44
+ Each template has at least an id/name and a `customization`. Defensive
45
+ about the response shape: the catalog may come back as a bare JSON list
46
+ or wrapped in a standard `{success, data}` envelope.
47
+ """
48
+ resp = self.request(TEMPLATE_CATALOG)
49
+ if isinstance(resp, list):
50
+ return resp
51
+ if isinstance(resp, dict):
52
+ data = resp.get("data", resp)
53
+ if isinstance(data, list):
54
+ return data
55
+ if isinstance(data, dict):
56
+ for key in ("templates", "items", "results"):
57
+ if isinstance(data.get(key), list):
58
+ return data[key]
59
+ return []
60
+
61
+ def apply_template(self, template_id, force=False):
62
+ """Apply a published template by id. Return the envelope dict.
63
+
64
+ Looks the template up in the catalog, then PATCHes `apply_template` with
65
+ its full `customization` and the resume's current `personalDetails`.
66
+ Raises SystemExit if no template matches, or if it is a paid (`isPremium`)
67
+ template and `force` is not set — a paid template requires a FlowCV
68
+ subscription (the apply request fails with 400 on a free account).
69
+ """
70
+ tpl = next((t for t in self.list_templates()
71
+ if isinstance(t, dict) and (t.get("id") or t.get("templateId")) == template_id), None)
72
+ if tpl is None:
73
+ raise SystemExit(f"template not found: {template_id!r}")
74
+ if tpl.get("isPremium") and not force:
75
+ raise SystemExit(
76
+ f"'{tpl.get('title') or template_id}' is a PAID template — it requires a FlowCV "
77
+ "subscription (the apply fails with 400 on a free account). Use force=True "
78
+ "(CLI: --force) if your account is subscribed.")
79
+ body = {
80
+ "resumeId": self.resume_id,
81
+ "templateId": template_id,
82
+ "customization": tpl.get("customization"),
83
+ "personalDetails": self.get_resume()["personalDetails"],
84
+ }
85
+ return self.request("resumes/apply_template", method="PATCH", body=body)
flowcvcli/markup.py ADDED
@@ -0,0 +1,53 @@
1
+ """Markdown <-> FlowCV rich-text HTML.
2
+
3
+ FlowCV stores rich text as justified <p> / <ul><li><p> HTML. `md_to_html`
4
+ converts a small markdown dialect into that markup:
5
+
6
+ blank line -> block separator
7
+ "## Heading" -> bold justified paragraph (a subheader)
8
+ "**Whole line bold**" -> bold justified paragraph
9
+ "- item" -> bullet (consecutive lines become one <ul>)
10
+ anything else -> justified paragraph
11
+ inline **bold** -> <strong>bold</strong> (inside paragraphs and bullets)
12
+ """
13
+ import html
14
+ import re
15
+
16
+ J = ' style="text-align: justify"'
17
+
18
+
19
+ def _esc(s):
20
+ """Escape text, then honor inline **bold**."""
21
+ return re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html.escape(s, quote=False))
22
+
23
+
24
+ def md_to_html(md):
25
+ parts, bullets = [], []
26
+
27
+ def flush():
28
+ if bullets:
29
+ lis = "".join(f"<li{J}><p{J}>{_esc(b)}</p></li>" for b in bullets)
30
+ parts.append(f"<ul>{lis}</ul>")
31
+ bullets.clear()
32
+
33
+ for raw in (md or "").splitlines():
34
+ line = raw.strip()
35
+ if not line:
36
+ flush()
37
+ continue
38
+ if line.startswith("- "):
39
+ bullets.append(line[2:].strip())
40
+ continue
41
+ flush()
42
+ if line.startswith("## "):
43
+ parts.append(f"<p{J}><strong>{html.escape(line[3:].strip(), quote=False)}</strong></p>")
44
+ elif len(line) > 4 and line.startswith("**") and line.endswith("**") and line.count("**") == 2:
45
+ parts.append(f"<p{J}><strong>{html.escape(line[2:-2].strip(), quote=False)}</strong></p>")
46
+ else:
47
+ parts.append(f"<p{J}>{_esc(line)}</p>")
48
+ flush()
49
+ return "".join(parts)
50
+
51
+
52
+ def html_to_text(h):
53
+ return html.unescape(re.sub(r"\s+", " ", re.sub("<[^>]+>", " ", h or ""))).strip()
flowcvcli/personal.py ADDED
@@ -0,0 +1,66 @@
1
+ """Header personal details & links.
2
+
3
+ `personalDetails` is a single object saved whole via PATCH
4
+ `resumes/save_personal_details`. The API replaces the object wholesale, so every
5
+ write follows the read-modify-write pattern: fetch the full object, change the
6
+ target field, send it all back.
7
+
8
+ Header links live in `personalDetails.social` as `{key: {display, link}}` and are
9
+ shown in the order given by `personalDetails.detailsOrder`.
10
+ """
11
+ import copy
12
+
13
+
14
+ class PersonalMixin:
15
+ # ---- core read/write --------------------------------------------------
16
+ def _pd(self):
17
+ """Return a deepcopy of the resume's personalDetails object."""
18
+ return copy.deepcopy(self.get_resume()["personalDetails"])
19
+
20
+ def save_personal(self, pd):
21
+ """PATCH the full personalDetails object back. Return the envelope."""
22
+ return self.request("resumes/save_personal_details", method="PATCH",
23
+ body={"resumeId": self.resume_id, "personalDetails": pd})
24
+
25
+ # ---- scalar fields ----------------------------------------------------
26
+ def set_personal_field(self, field, value):
27
+ """Set one scalar field on the full pd and save. Return the envelope."""
28
+ pd = self._pd()
29
+ pd[field] = value
30
+ return self.save_personal(pd)
31
+
32
+ # ---- header links -----------------------------------------------------
33
+ def set_link(self, key, display, url):
34
+ """Add/update a header link (pd.social[key]={display,link}).
35
+
36
+ Ensures `key` is present in pd.detailsOrder (appended if absent). Saves
37
+ and returns the envelope.
38
+ """
39
+ pd = self._pd()
40
+ social = copy.deepcopy(pd.get("social") or {})
41
+ social[key] = {"display": display, "link": url}
42
+ pd["social"] = social
43
+ order = pd.get("detailsOrder") or []
44
+ if key not in order:
45
+ order = order + [key]
46
+ pd["detailsOrder"] = order
47
+ return self.save_personal(pd)
48
+
49
+ def remove_link(self, key):
50
+ """Delete a header link and drop it from detailsOrder. SystemExit if absent."""
51
+ pd = self._pd()
52
+ social = pd.get("social") or {}
53
+ if key not in social:
54
+ raise SystemExit(f"No header link {key!r}.")
55
+ del social[key]
56
+ pd["social"] = social
57
+ pd["detailsOrder"] = [k for k in (pd.get("detailsOrder") or []) if k != key]
58
+ return self.save_personal(pd)
59
+
60
+ def list_links(self):
61
+ """Return [(key, display, link, shown_bool)] from pd.social/detailsOrder."""
62
+ pd = self.get_resume()["personalDetails"]
63
+ social = pd.get("social") or {}
64
+ order = pd.get("detailsOrder") or []
65
+ return [(k, v.get("display", ""), v.get("link", ""), k in order)
66
+ for k, v in social.items()]
flowcvcli/photo.py ADDED
@@ -0,0 +1,95 @@
1
+ """Header photo / avatar: upload from a URL or file, set, remove, toggle.
2
+
3
+ Two-step flow:
4
+ 1. POST resumes/upload_profile_pic (multipart: resumeId + `file`) -> {imageId}
5
+ 2. save the imageId into personalDetails.photo (whole-image crop).
6
+ Display is toggled via the customization delta `header.photo.show`.
7
+
8
+ Depends on PersonalMixin (_pd / save_personal) and CustomizationMixin (set).
9
+ """
10
+ import json
11
+ import os
12
+ import struct
13
+ import urllib.error
14
+ import urllib.request
15
+ import uuid
16
+
17
+ MAX_IMAGE_BYTES = 10 * 1024 * 1024
18
+
19
+ from .client import API, ORIGIN, UA
20
+
21
+ # whole-image crop (matches what FlowCV writes for an un-cropped photo)
22
+ FULL_CROP = {"xPct": 0.0004995004995004271, "yPct": 0.0004995004995004271,
23
+ "widthPct": 0.9990009990009991, "heightPct": 0.9990009990009991}
24
+
25
+
26
+ def image_size(data):
27
+ """Best-effort (width, height) from PNG/JPEG bytes; (0, 0) if unknown."""
28
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
29
+ return struct.unpack(">II", data[16:24]) if len(data) >= 24 else (0, 0)
30
+ if data[:2] == b"\xff\xd8": # JPEG: find a start-of-frame marker
31
+ i = 2
32
+ while i + 9 < len(data):
33
+ if data[i] != 0xFF:
34
+ i += 1
35
+ continue
36
+ marker = data[i + 1]
37
+ if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
38
+ h, w = struct.unpack(">HH", data[i + 5:i + 9])
39
+ return w, h
40
+ i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0]
41
+ return 0, 0
42
+
43
+
44
+ class PhotoMixin:
45
+ def upload_photo(self, data):
46
+ """Upload image bytes to upload_profile_pic; return the imageId string."""
47
+ self._ensure_auth() # opener attaches the session cookies
48
+ b = "----flowcvcli" + uuid.uuid4().hex
49
+ body = (
50
+ f"--{b}\r\nContent-Disposition: form-data; name=\"resumeId\"\r\n\r\n{self.resume_id}\r\n"
51
+ f"--{b}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"photo\"\r\n"
52
+ f"Content-Type: application/octet-stream\r\n\r\n"
53
+ ).encode() + data + f"\r\n--{b}--\r\n".encode()
54
+ req = urllib.request.Request(
55
+ f"{API}/resumes/upload_profile_pic", data=body, method="POST",
56
+ headers={"accept": "application/json", "user-agent": UA, "origin": ORIGIN,
57
+ "content-type": f"multipart/form-data; boundary={b}"})
58
+ try:
59
+ with self._opener.open(req, timeout=60) as r:
60
+ env = json.loads(r.read().decode())
61
+ except urllib.error.HTTPError as e:
62
+ env = json.loads(e.read().decode())
63
+ if not env.get("success"):
64
+ raise SystemExit(f"photo upload failed: {json.dumps(env)[:200]}")
65
+ return env["data"]["imageId"]
66
+
67
+ def set_photo(self, src, shape="round"):
68
+ """Set the header photo from a local file path or an http(s) URL. Returns env."""
69
+ if os.path.exists(src): # a real file wins (e.g. http_avatar.png)
70
+ with open(src, "rb") as f:
71
+ data = f.read(MAX_IMAGE_BYTES + 1)
72
+ elif src.startswith(("http://", "https://")):
73
+ with urllib.request.urlopen(
74
+ urllib.request.Request(src, headers={"user-agent": UA}), timeout=60) as r:
75
+ data = r.read(MAX_IMAGE_BYTES + 1)
76
+ else:
77
+ raise SystemExit(f"photo source not found (not a file or http(s) URL): {src!r}")
78
+ if len(data) > MAX_IMAGE_BYTES:
79
+ raise SystemExit("photo too large (> 10 MB)")
80
+ image_id = self.upload_photo(data)
81
+ w, h = image_size(data)
82
+ pd = self._pd()
83
+ pd["photo"] = dict(FULL_CROP, imageId=image_id, shape=shape,
84
+ originalWidth=w, originalHeight=h)
85
+ env = self.save_personal(pd)
86
+ self.set("header.photo.show", True) # make sure it displays
87
+ return env
88
+
89
+ def remove_photo(self):
90
+ """Clear the header photo and hide the photo slot."""
91
+ pd = self._pd()
92
+ pd["photo"] = {}
93
+ env = self.save_personal(pd)
94
+ self.set("header.photo.show", False)
95
+ return env