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/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """flowcvcli — control a FlowCV resume from Python or the command line."""
2
+ from .api import FlowCV
3
+ from .config import Config
4
+ from .content import SECTION_META, label_of
5
+ from .markup import html_to_text, md_to_html
6
+
7
+ __all__ = ["FlowCV", "Config", "SECTION_META", "label_of", "md_to_html", "html_to_text"]
8
+ __version__ = "0.2.0"
flowcvcli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable `python -m flowcvcli …` (same as the `flowcv` console command)."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
flowcvcli/api.py ADDED
@@ -0,0 +1,29 @@
1
+ """FlowCV — the high-level client, composed from the Client core + feature mixins.
2
+
3
+ LLM / library use:
4
+ from flowcvcli import FlowCV
5
+ fc = FlowCV() # auth + resume id from .env / env vars
6
+ fc = FlowCV(resume_id="...") # target a specific resume
7
+ fc.add_entry("work", sets={"jobTitle": "Engineer", "employer": "Acme",
8
+ "startDateNew": "01/2022", "endDateNew": "Present"},
9
+ md="- Did a measurable thing.")
10
+ fc.set_personal_field("fullName", "Jane Doe")
11
+ fc.set_link("orcid", "ORCID", "https://orcid.org/0000-0000-0000-0000")
12
+ fc.set("font.fontFamily", "Source Sans Pro") # a customization delta
13
+ fc.save_pdf("resume.pdf") # render + view
14
+ """
15
+ from .client import Client
16
+ from .content import ContentMixin
17
+ from .customization import CustomizationMixin
18
+ from .personal import PersonalMixin
19
+ from .photo import PhotoMixin
20
+ from .resume import ResumeMixin
21
+
22
+
23
+ class FlowCV(Client, ResumeMixin, ContentMixin, PersonalMixin,
24
+ CustomizationMixin, PhotoMixin):
25
+ """One object that controls a FlowCV resume end to end."""
26
+
27
+ # convenience: toggle the header photo/avatar on or off (a customization delta)
28
+ def set_avatar_visible(self, visible):
29
+ return self.set("header.photo.show", bool(visible))
flowcvcli/cli.py ADDED
@@ -0,0 +1,335 @@
1
+ """Command-line interface over the FlowCV client.
2
+
3
+ Run `python3 flowcv.py --help`. Auth comes from .env / env vars
4
+ (FLOWCV_COOKIE, or FLOWCV_EMAIL+FLOWCV_PASSWORD). The resume id is optional:
5
+ with a single resume the tool auto-selects it; with several, set FLOWCV_RESUME_ID
6
+ or pass `--resume-id <id>` (any command accepts it).
7
+ """
8
+ import argparse
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ from .api import FlowCV
14
+ from .client import login as do_login, _write_session, _jar_header
15
+ from .content import SECTION_META, label_of
16
+ from .markup import html_to_text, md_to_html
17
+
18
+ ALIASES = {"title": "jobTitle", "company": "employer", "start": "startDateNew",
19
+ "end": "endDateNew", "link": "employerLink"}
20
+ TEXT_FIELDS = ("description", "infoHtml", "text")
21
+
22
+
23
+ def _read(file=None, text=None):
24
+ if file:
25
+ with open(file) as f:
26
+ return f.read().strip()
27
+ return text
28
+
29
+
30
+ def _coerce(s):
31
+ """Parse a CLI value as JSON (true/false/number/quoted), else keep as string."""
32
+ try:
33
+ return json.loads(s)
34
+ except (ValueError, TypeError):
35
+ return s
36
+
37
+
38
+ def _result(env, label):
39
+ ok = env.get("success") if isinstance(env, dict) else env
40
+ print(f"{label} -> success={ok}")
41
+ if isinstance(env, dict) and not ok:
42
+ print(" !", json.dumps(env)[:200])
43
+
44
+
45
+ def _fc(a):
46
+ return FlowCV(resume_id=getattr(a, "resume_id_override", None))
47
+
48
+
49
+ # ------------------------------------------------------------------ commands
50
+ def cmd_login(a):
51
+ fc = _fc(a)
52
+ if not (fc.cfg.email and fc.cfg.password):
53
+ sys.exit("Set FLOWCV_EMAIL and FLOWCV_PASSWORD in .env to use `login`.")
54
+ _write_session(_jar_header(do_login(fc.cfg.email, fc.cfg.password)))
55
+ print("login ok -> session cached to .flowcv_session")
56
+
57
+
58
+ def cmd_resumes(a):
59
+ for r in _fc(a).list_resumes():
60
+ live = "live" if r.get("webResumeLive") else "private"
61
+ print(f" {r.get('id','(no id)')} {(r.get('title') or '(untitled)'):20} web:{r.get('webToken','-')} [{live}]")
62
+
63
+
64
+ def cmd_new(a):
65
+ new_id = _fc(a).create_resume(a.title)
66
+ print(f"created new resume -> {new_id}")
67
+
68
+
69
+ def cmd_duplicate(a):
70
+ new_id = _fc(a).duplicate_resume(a.title)
71
+ print(f"duplicated -> {new_id}")
72
+
73
+
74
+ def cmd_rename(a):
75
+ _result(_fc(a).rename_resume(a.title), f"rename resume -> {a.title!r}")
76
+
77
+
78
+ def cmd_delete_resume(a):
79
+ fc = _fc(a)
80
+ rid = fc.resume_id
81
+ if not a.yes:
82
+ sys.exit(f"refusing to delete resume {rid} without --yes (this is permanent).")
83
+ _result(fc.delete_resume(rid), f"delete resume {rid[:8]}")
84
+
85
+
86
+ def cmd_show(a):
87
+ resume = _fc(a).get_resume()
88
+ for sec, obj in (resume.get("content") or {}).items():
89
+ if a.section and sec != a.section:
90
+ continue
91
+ print(f"[{sec}] '{obj.get('displayName')}' ({len(obj.get('entries') or [])} entries)")
92
+ for e in obj.get("entries") or []:
93
+ d = f" {e.get('startDateNew','')}–{e.get('endDateNew','')}" if e.get("startDateNew") or e.get("endDateNew") else ""
94
+ print(f" {e.get('id','(no id)')} {label_of(e)}{d}")
95
+
96
+
97
+ def cmd_dump(a):
98
+ fc = _fc(a)
99
+ e = fc.find_entry(fc.get_resume(), a.section, a.entry)
100
+ for k, v in e.items():
101
+ if k in TEXT_FIELDS:
102
+ print(f" {k} (text): {html_to_text(v)}")
103
+ print(f" {k} (html): {v}")
104
+ else:
105
+ print(f" {k}: {v!r}")
106
+
107
+
108
+ def cmd_add(a):
109
+ sets = {}
110
+ for kv in a.set or []:
111
+ if "=" not in kv:
112
+ sys.exit(f"--set expects key=value, got {kv!r}")
113
+ k, _, v = kv.partition("=")
114
+ sets[ALIASES.get(k, k)] = v
115
+ new_id = _fc(a).add_entry(a.section, sets=sets, md=_read(a.file, a.text))
116
+ print(f"added {a.section} entry -> {new_id}")
117
+
118
+
119
+ def cmd_rm(a):
120
+ _result(_fc(a).delete_entry(a.section, a.entry), f"rm {a.section}/{a.entry[:8]}")
121
+
122
+
123
+ def cmd_reorder(a):
124
+ _result(_fc(a).reorder_entries(a.section, a.ids), f"reorder {a.section} ({len(a.ids)} entries)")
125
+
126
+
127
+ def cmd_hide(a):
128
+ _result(_fc(a).hide_entry(a.section, a.entry, hidden=True), f"hide {a.section}/{a.entry[:8]}")
129
+
130
+
131
+ def cmd_show_entry(a):
132
+ _result(_fc(a).hide_entry(a.section, a.entry, hidden=False), f"show {a.section}/{a.entry[:8]}")
133
+
134
+
135
+ def cmd_rename_section(a):
136
+ _result(_fc(a).rename_section(a.section, a.name), f"rename-section {a.section}")
137
+
138
+
139
+ def cmd_section_icon(a):
140
+ _result(_fc(a).set_section_icon(a.section, a.icon), f"section-icon {a.section}={a.icon}")
141
+
142
+
143
+ def cmd_rm_section(a):
144
+ fc = _fc(a)
145
+ if not a.yes:
146
+ sys.exit(f"refusing to delete section {a.section!r} and all its entries without --yes.")
147
+ _result(fc.delete_section(a.section), f"rm-section {a.section}")
148
+
149
+
150
+ def cmd_reorder_sections(a):
151
+ _result(_fc(a).reorder_sections(a.ids, layout=a.layout), f"reorder-sections ({a.layout})")
152
+
153
+
154
+ def cmd_field(a):
155
+ _result(_fc(a).set_field(a.section, a.entry, a.field, _read(a.file, a.text)),
156
+ f"{a.section}/{a.entry[:8]}.{a.field}")
157
+
158
+
159
+ def cmd_desc(a):
160
+ _result(_fc(a).set_description(a.section, a.entry, _read(a.file, a.text), field=a.field),
161
+ f"{a.section}/{a.entry[:8]}.{a.field}")
162
+
163
+
164
+ def cmd_pd(a):
165
+ _result(_fc(a).set_personal_field(a.field, _read(a.file, a.text)), f"personalDetails.{a.field}")
166
+
167
+
168
+ def cmd_links(a):
169
+ for k, display, link, shown in _fc(a).list_links():
170
+ print(f" {k}: {display} -> {link} [{'shown' if shown else 'hidden'}]")
171
+
172
+
173
+ def cmd_link(a):
174
+ _result(_fc(a).set_link(a.key, a.display, a.url), f"link {a.key}")
175
+
176
+
177
+ def cmd_unlink(a):
178
+ _result(_fc(a).remove_link(a.key), f"unlink {a.key}")
179
+
180
+
181
+ def cmd_customize(a):
182
+ _result(_fc(a).set(a.path, _coerce(a.value)), f"customize {a.path}={a.value}")
183
+
184
+
185
+ def cmd_avatar(a):
186
+ fc = _fc(a)
187
+ if a.action in ("on", "off"):
188
+ _result(fc.set_avatar_visible(a.action == "on"), f"avatar {a.action}")
189
+ elif a.action == "set":
190
+ if not a.src:
191
+ sys.exit("avatar set needs a URL or file path: `avatar set <url|file>`")
192
+ _result(fc.set_photo(a.src), f"avatar set {a.src[:40]}")
193
+ elif a.action == "remove":
194
+ _result(fc.remove_photo(), "avatar remove")
195
+
196
+
197
+ def _tname(t):
198
+ return t.get("title") or t.get("metaTitle") or t.get("slug") or "(unnamed)"
199
+
200
+
201
+ def cmd_templates(a):
202
+ free = paid = 0
203
+ for t in _fc(a).list_templates():
204
+ if not isinstance(t, dict):
205
+ continue
206
+ premium = bool(t.get("isPremium"))
207
+ paid += premium; free += not premium
208
+ print(f" {t.get('id') or t.get('templateId')} [{'PAID' if premium else 'free'}] {_tname(t)}")
209
+ print(f"\n{free} free, {paid} paid (PAID templates need a FlowCV subscription to apply).")
210
+
211
+
212
+ def cmd_apply_template(a):
213
+ # apply_template refuses a paid template unless --force (it would corrupt a free resume)
214
+ _result(_fc(a).apply_template(a.template_id, force=a.force), f"apply-template {a.template_id[:8]}")
215
+
216
+
217
+ def cmd_download(a):
218
+ fc = _fc(a)
219
+ if a.token: # public download of any shared resume
220
+ data = fc.download_public(a.token)
221
+ out = a.output or f"{a.token}.pdf"
222
+ with open(out, "wb") as f:
223
+ f.write(data)
224
+ print(f"saved {out} ({len(data)} bytes)")
225
+ else:
226
+ path = fc.save_pdf(a.output or "resume.pdf")
227
+ print(f"saved {path} ({os.path.getsize(path)} bytes)")
228
+
229
+
230
+ def cmd_publish(a):
231
+ fc = _fc(a)
232
+ _result(fc.publish(), "publish")
233
+ print(f" {fc.share_url()}")
234
+
235
+
236
+ def cmd_unpublish(a):
237
+ _result(_fc(a).unpublish(), "unpublish")
238
+
239
+
240
+ def cmd_share(a):
241
+ st = _fc(a).web_status()
242
+ print(f"web resume: {'LIVE' if st['live'] else 'disabled'}\nshare url : {st['url'] or '(none)'}")
243
+
244
+
245
+ def cmd_md2html(a):
246
+ print(md_to_html(_read(a.file, a.text)))
247
+
248
+
249
+ # -------------------------------------------------------------------- parser
250
+ def build_parser():
251
+ # --resume-id on a shared parent so it works BEFORE or AFTER the subcommand
252
+ common = argparse.ArgumentParser(add_help=False)
253
+ common.add_argument("--resume-id", dest="resume_id_override", help="target a specific resume")
254
+ p = argparse.ArgumentParser(prog="flowcv", description=__doc__, parents=[common],
255
+ formatter_class=argparse.RawDescriptionHelpFormatter)
256
+ sub = p.add_subparsers(dest="cmd", required=True)
257
+
258
+ def add(name, **kw):
259
+ return sub.add_parser(name, parents=[common], **kw)
260
+
261
+ add("login").set_defaults(fn=cmd_login)
262
+ add("resumes").set_defaults(fn=cmd_resumes)
263
+
264
+ s = add("new"); s.add_argument("title"); s.set_defaults(fn=cmd_new)
265
+ s = add("duplicate"); s.add_argument("title", nargs="?"); s.set_defaults(fn=cmd_duplicate)
266
+ s = add("rename"); s.add_argument("title"); s.set_defaults(fn=cmd_rename)
267
+ s = add("delete-resume"); s.add_argument("--yes", action="store_true", help="confirm permanent deletion")
268
+ s.set_defaults(fn=cmd_delete_resume)
269
+
270
+ s = add("show"); s.add_argument("section", nargs="?"); s.set_defaults(fn=cmd_show)
271
+ s = add("dump"); s.add_argument("section"); s.add_argument("entry"); s.set_defaults(fn=cmd_dump)
272
+
273
+ s = add("add"); s.add_argument("section")
274
+ s.add_argument("--set", action="append", help="field=value (aliases: title,company,start,end,link)")
275
+ g = s.add_mutually_exclusive_group(); g.add_argument("--file"); g.add_argument("--text")
276
+ s.set_defaults(fn=cmd_add)
277
+
278
+ s = add("rm"); s.add_argument("section"); s.add_argument("entry"); s.set_defaults(fn=cmd_rm)
279
+
280
+ s = add("reorder"); s.add_argument("section")
281
+ s.add_argument("ids", nargs="+", help="entry ids in the desired order (all of the section's ids)")
282
+ s.set_defaults(fn=cmd_reorder)
283
+ s = add("hide"); s.add_argument("section"); s.add_argument("entry"); s.set_defaults(fn=cmd_hide)
284
+ s = add("show-entry"); s.add_argument("section"); s.add_argument("entry"); s.set_defaults(fn=cmd_show_entry)
285
+ s = add("rename-section"); s.add_argument("section"); s.add_argument("name"); s.set_defaults(fn=cmd_rename_section)
286
+ s = add("section-icon"); s.add_argument("section"); s.add_argument("icon", help="icon key, e.g. briefcase")
287
+ s.set_defaults(fn=cmd_section_icon)
288
+ s = add("rm-section"); s.add_argument("section")
289
+ s.add_argument("--yes", action="store_true", help="confirm deleting the section + its entries")
290
+ s.set_defaults(fn=cmd_rm_section)
291
+ s = add("reorder-sections"); s.add_argument("ids", nargs="+", help="section ids in the desired order")
292
+ s.add_argument("--layout", default="one", help="column layout to reorder (one|two|mix; default one)")
293
+ s.set_defaults(fn=cmd_reorder_sections)
294
+
295
+ s = add("field"); s.add_argument("section"); s.add_argument("entry"); s.add_argument("field")
296
+ g = s.add_mutually_exclusive_group(required=True); g.add_argument("--text"); g.add_argument("--file")
297
+ s.set_defaults(fn=cmd_field)
298
+
299
+ s = add("desc"); s.add_argument("section"); s.add_argument("entry")
300
+ s.add_argument("--field", default="description")
301
+ g = s.add_mutually_exclusive_group(required=True); g.add_argument("--file"); g.add_argument("--text")
302
+ s.set_defaults(fn=cmd_desc)
303
+
304
+ s = add("pd"); s.add_argument("field")
305
+ g = s.add_mutually_exclusive_group(required=True); g.add_argument("--text"); g.add_argument("--file")
306
+ s.set_defaults(fn=cmd_pd)
307
+
308
+ add("links").set_defaults(fn=cmd_links)
309
+ s = add("link"); s.add_argument("key"); s.add_argument("display"); s.add_argument("url"); s.set_defaults(fn=cmd_link)
310
+ s = add("unlink"); s.add_argument("key"); s.set_defaults(fn=cmd_unlink)
311
+
312
+ s = add("customize"); s.add_argument("path"); s.add_argument("value"); s.set_defaults(fn=cmd_customize)
313
+ s = add("avatar"); s.add_argument("action", choices=["on", "off", "set", "remove"])
314
+ s.add_argument("src", nargs="?", help="URL or file path (for `avatar set`)"); s.set_defaults(fn=cmd_avatar)
315
+ add("templates").set_defaults(fn=cmd_templates)
316
+ s = add("apply-template"); s.add_argument("template_id")
317
+ s.add_argument("--force", action="store_true", help="apply even a paid template (needs a subscription)")
318
+ s.set_defaults(fn=cmd_apply_template)
319
+
320
+ s = add("download"); s.add_argument("-o", "--output")
321
+ s.add_argument("--token", help="download a public resume by its share token (no auth)")
322
+ s.set_defaults(fn=cmd_download)
323
+ add("publish").set_defaults(fn=cmd_publish)
324
+ add("unpublish").set_defaults(fn=cmd_unpublish)
325
+ add("share").set_defaults(fn=cmd_share)
326
+
327
+ s = add("md2html")
328
+ g = s.add_mutually_exclusive_group(required=True); g.add_argument("--file"); g.add_argument("--text")
329
+ s.set_defaults(fn=cmd_md2html)
330
+ return p
331
+
332
+
333
+ def main(argv=None):
334
+ args = build_parser().parse_args(argv)
335
+ args.fn(args)
flowcvcli/client.py ADDED
@@ -0,0 +1,300 @@
1
+ """Core HTTP client: auth, session cookie jar, JSON envelope, re-login retry.
2
+
3
+ Feature mixins build on this interface:
4
+ self.cfg -> Config
5
+ self.resume_id -> str (raises if unset)
6
+ self.request(path, method="GET", body=None, query=None) -> dict envelope
7
+ self.request_raw(path, query=None) -> (status:int, bytes)
8
+ self.get_resume() -> dict (data.resume), raises on failure
9
+ self.now_iso() -> ISO-8601 millisecond UTC timestamp string
10
+
11
+ `path` is relative to https://app.flowcv.com/api (e.g. "resumes/save_entry",
12
+ "auth/login"), or an absolute http(s) URL (e.g. the /pubcache template catalog).
13
+
14
+ Session handling: every request goes through a single `http.cookiejar.CookieJar`
15
+ behind an opener, so the client (a) sends *all* cookies the login set — not just
16
+ `flowcvsidapp` — and (b) automatically captures any `Set-Cookie` the server
17
+ returns mid-session (rotation). The full cookie set is persisted to
18
+ `.flowcv_session` (0o600). FlowCV rate-limits login (~100/day) and returns HTTP
19
+ 429 when hammered; we surface that clearly rather than retrying into the wall.
20
+ """
21
+ import datetime
22
+ import http.cookiejar
23
+ import json
24
+ import os
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ import uuid
29
+
30
+ from .config import Config, SESSION_FILE
31
+
32
+ API = "https://app.flowcv.com/api"
33
+ ORIGIN = "https://app.flowcv.com"
34
+ COOKIE_DOMAIN = "app.flowcv.com"
35
+ UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
36
+ "Chrome/149.0.0.0 Safari/537.36")
37
+
38
+
39
+ def now_iso():
40
+ n = datetime.datetime.now(datetime.timezone.utc)
41
+ return n.strftime("%Y-%m-%dT%H:%M:%S.") + f"{n.microsecond // 1000:03d}Z"
42
+
43
+
44
+ def _multipart(fields):
45
+ boundary = "----flowcvcli" + uuid.uuid4().hex
46
+ out = []
47
+ for k, v in fields.items():
48
+ out += [f"--{boundary}", f'Content-Disposition: form-data; name="{k}"', "", v]
49
+ out += [f"--{boundary}--", ""]
50
+ return boundary, "\r\n".join(out).encode()
51
+
52
+
53
+ def _make_cookie(name, value):
54
+ """Build a session cookie scoped to the FlowCV app domain."""
55
+ return http.cookiejar.Cookie(
56
+ version=0, name=name, value=value, port=None, port_specified=False,
57
+ domain=COOKIE_DOMAIN, domain_specified=True, domain_initial_dot=False,
58
+ path="/", path_specified=True, secure=True, expires=None, discard=False,
59
+ comment=None, comment_url=None, rest={})
60
+
61
+
62
+ def _seed_jar(jar, cookie_str):
63
+ """Load cookies from a 'name=value; name2=value2' header string into `jar`."""
64
+ for part in cookie_str.split(";"):
65
+ part = part.strip()
66
+ if not part or "=" not in part:
67
+ continue
68
+ name, _, value = part.partition("=") # split on first '=' (values may contain '=')
69
+ name, value = name.strip(), value.strip()
70
+ if name:
71
+ jar.set_cookie(_make_cookie(name, value))
72
+
73
+
74
+ def _jar_header(jar):
75
+ """Render a cookie jar as a 'name=value; …' Cookie header string."""
76
+ return "; ".join(f"{c.name}={c.value}" for c in jar)
77
+
78
+
79
+ def _rate_limit_msg(method, path):
80
+ return (f"{method} {path} -> HTTP 429 (rate limited by FlowCV). Too many "
81
+ "requests in a short window — wait a while before retrying, and reuse "
82
+ "the cached session (.flowcv_session / FLOWCV_COOKIE) instead of "
83
+ "logging in repeatedly (login is capped at ~100/day).")
84
+
85
+
86
+ def login(email, password, jar=None):
87
+ """POST /auth/login (multipart email+password) into a cookie jar.
88
+
89
+ Seeds an anonymous session (init_user) then logs in on the *same* jar, so the
90
+ jar ends up holding the full authenticated cookie set. Returns the jar
91
+ (creating a fresh one if not supplied). Raises SystemExit with a clear message
92
+ on a 429 rate-limit or a failed login.
93
+ """
94
+ if jar is None:
95
+ jar = http.cookiejar.CookieJar()
96
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
97
+ base = {"user-agent": UA, "origin": ORIGIN, "accept": "application/json, text/plain, */*"}
98
+
99
+ # Seed an anonymous session like the web app does — but login also works
100
+ # standalone (the browser's curl skips this), so a throttled/failed init_user
101
+ # must NOT abort a login that would otherwise succeed. Best-effort only.
102
+ try:
103
+ opener.open(urllib.request.Request(f"{API}/auth/init_user", headers=base), timeout=30).read()
104
+ except urllib.error.HTTPError:
105
+ pass
106
+ except urllib.error.URLError as e:
107
+ raise SystemExit(f"login (init_user) -> network error: {e.reason}")
108
+
109
+ boundary, body = _multipart({"email": email, "password": password,
110
+ "resumeData": "undefined", "letterData": "undefined",
111
+ "resumeImg": "", "letterImg": ""})
112
+ h = dict(base, **{"content-type": f"multipart/form-data; boundary={boundary}"})
113
+ try:
114
+ resp = opener.open(
115
+ urllib.request.Request(f"{API}/auth/login", data=body, headers=h, method="POST"), timeout=30)
116
+ except urllib.error.HTTPError as e:
117
+ if e.code == 429:
118
+ raise SystemExit(_rate_limit_msg("POST", "auth/login"))
119
+ raise SystemExit(f"login failed: HTTP {e.code} {e.read()[:200]!r}")
120
+ except urllib.error.URLError as e:
121
+ raise SystemExit(f"login -> network error: {e.reason}")
122
+ data = json.loads(resp.read().decode())
123
+ if not data.get("success"):
124
+ # whitelist error/code only — the raw envelope can echo the account email
125
+ raise SystemExit(f"login failed (code {data.get('code')}): {data.get('error') or 'check email/password'}")
126
+ if not any(c.name == "flowcvsidapp" for c in jar):
127
+ raise SystemExit("login succeeded but no session cookie was set")
128
+ return jar
129
+
130
+
131
+ def _write_session(cookie):
132
+ """Persist the session cookie header with owner-only perms (0o600) — it's a credential."""
133
+ parent = os.path.dirname(SESSION_FILE)
134
+ if parent:
135
+ os.makedirs(parent, exist_ok=True)
136
+ fd = os.open(SESSION_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
137
+ with os.fdopen(fd, "w") as f:
138
+ f.write(cookie)
139
+
140
+
141
+ class Client:
142
+ def __init__(self, config=None, resume_id=None):
143
+ self.cfg = config or Config.load()
144
+ if resume_id:
145
+ self.cfg.resume_id = resume_id
146
+ self._jar = http.cookiejar.CookieJar()
147
+ self._opener = urllib.request.build_opener(
148
+ urllib.request.HTTPCookieProcessor(self._jar))
149
+ self._authed = False # has the jar been seeded yet?
150
+ self._persisted = None # last cookie header written to .flowcv_session
151
+
152
+ # ---- auth -------------------------------------------------------------
153
+ def _ensure_auth(self):
154
+ """Seed the cookie jar once, in priority order: FLOWCV_COOKIE (env) ->
155
+ cached .flowcv_session -> a fresh login(). Idempotent."""
156
+ if self._authed:
157
+ return
158
+ if self.cfg.cookie:
159
+ _seed_jar(self._jar, self.cfg.cookie)
160
+ self._authed = True
161
+ return
162
+ if os.path.exists(SESSION_FILE):
163
+ with open(SESSION_FILE) as f:
164
+ cached = f.read().strip()
165
+ if cached:
166
+ _seed_jar(self._jar, cached)
167
+ self._persisted = cached
168
+ self._authed = True
169
+ return
170
+ if self.cfg.email and self.cfg.password:
171
+ login(self.cfg.email, self.cfg.password, self._jar)
172
+ self._persist()
173
+ self._authed = True
174
+ return
175
+ raise SystemExit("No auth. Set FLOWCV_COOKIE, or FLOWCV_EMAIL + "
176
+ "FLOWCV_PASSWORD, in .env.")
177
+
178
+ def cookie(self):
179
+ """Current Cookie header (all session cookies). For multipart uploads that
180
+ build their own request instead of going through `_send`."""
181
+ self._ensure_auth()
182
+ return _jar_header(self._jar)
183
+
184
+ def _persist(self):
185
+ header = _jar_header(self._jar)
186
+ _write_session(header)
187
+ self._persisted = header
188
+
189
+ def _maybe_persist(self):
190
+ """Re-persist if the server rotated the session (Set-Cookie changed the
191
+ jar). Never shadow an authoritative env cookie with a session file."""
192
+ if self.cfg.cookie:
193
+ return
194
+ header = _jar_header(self._jar)
195
+ if header and header != self._persisted:
196
+ _write_session(header)
197
+ self._persisted = header
198
+
199
+ def relogin(self):
200
+ """Discard the current session and log in fresh (only if we have creds).
201
+
202
+ Clears the jar first so a stale/duplicate cookie can't shadow the new
203
+ session — "a fresh login() session" the way the web app starts one.
204
+ """
205
+ if not (self.cfg.email and self.cfg.password):
206
+ return False
207
+ try:
208
+ os.remove(SESSION_FILE)
209
+ except OSError:
210
+ pass
211
+ self._jar.clear()
212
+ login(self.cfg.email, self.cfg.password, self._jar)
213
+ self._persist()
214
+ self._authed = True
215
+ return True
216
+
217
+ @property
218
+ def resume_id(self):
219
+ """The target resume id. If none was configured, auto-use the account's
220
+ only resume; if there are several, require an explicit choice."""
221
+ if self.cfg.resume_id:
222
+ return self.cfg.resume_id
223
+ env = self.request("resumes/all")
224
+ resumes = (env.get("data") or {}).get("resumes") if env.get("success") else None
225
+ if resumes is None:
226
+ raise SystemExit("Could not list resumes to auto-select one — set "
227
+ "FLOWCV_RESUME_ID or pass --resume-id.")
228
+ if not resumes:
229
+ raise SystemExit("This account has no resumes yet.")
230
+ if len(resumes) == 1:
231
+ self.cfg.resume_id = resumes[0].get("id") # cache for the rest of the run
232
+ return self.cfg.resume_id
233
+ listing = "\n".join(f" {r.get('id')} {r.get('title') or '(untitled)'}" for r in resumes)
234
+ raise SystemExit("You have multiple resumes — choose one with --resume-id <id> "
235
+ "or FLOWCV_RESUME_ID:\n" + listing)
236
+
237
+ def now_iso(self):
238
+ return now_iso()
239
+
240
+ # ---- http -------------------------------------------------------------
241
+ def _url(self, path):
242
+ return path if path.startswith("http") else f"{API}/{path}"
243
+
244
+ def _send(self, path, method, body, query, timeout):
245
+ self._ensure_auth() # jar carries the cookies; opener attaches them
246
+ url = self._url(path)
247
+ if query:
248
+ url += "?" + urllib.parse.urlencode(query)
249
+ headers = {"accept": "application/json, text/plain, */*", "user-agent": UA,
250
+ "origin": ORIGIN}
251
+ data = None
252
+ if body is not None:
253
+ data = json.dumps(body).encode()
254
+ headers["content-type"] = "application/json"
255
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
256
+ try:
257
+ with self._opener.open(req, timeout=timeout) as r:
258
+ return r.status, r.read()
259
+ except urllib.error.HTTPError as e:
260
+ return e.code, e.read()
261
+ except urllib.error.URLError as e: # DNS/conn/TLS/timeout
262
+ raise SystemExit(f"{method} {url} -> network error: {e.reason}")
263
+
264
+ def request(self, path, method="GET", body=None, query=None, timeout=30):
265
+ """Return the parsed JSON envelope dict. Retries once after re-login on 401/403."""
266
+ status, raw = self._send(path, method, body, query, timeout)
267
+ if status in (401, 403) and self.relogin():
268
+ status, raw = self._send(path, method, body, query, timeout)
269
+ if status == 429:
270
+ raise SystemExit(_rate_limit_msg(method, path))
271
+ self._maybe_persist()
272
+ try:
273
+ return json.loads(raw.decode())
274
+ except ValueError:
275
+ raise SystemExit(f"{method} {path} -> HTTP {status}: {raw[:200]!r}")
276
+
277
+ def request_raw(self, path, query=None, timeout=120):
278
+ """Return (status, bytes). For binary endpoints (PDF download)."""
279
+ status, raw = self._send(path, "GET", None, query, timeout)
280
+ if status in (401, 403) and self.relogin():
281
+ status, raw = self._send(path, "GET", None, query, timeout)
282
+ if status == 429:
283
+ raise SystemExit(_rate_limit_msg("GET", path))
284
+ self._maybe_persist()
285
+ return status, raw
286
+
287
+ # ---- resume fetch -----------------------------------------------------
288
+ def get_resume(self):
289
+ """Return the full resume object (data.resume). Raises on failure.
290
+
291
+ A freshly minted session can return `400 reloadClient:true` on its first
292
+ heavy read and then succeed; retry once on that signal before giving up.
293
+ """
294
+ env = self.request(f"resumes/{self.resume_id}")
295
+ if not env.get("success") and env.get("reloadClient"):
296
+ env = self.request(f"resumes/{self.resume_id}") # session-warmup retry
297
+ if not env.get("success"):
298
+ raise SystemExit(f"get resume failed (code {env.get('code')}): {env.get('error') or ''} "
299
+ "(session expired? refresh FLOWCV_COOKIE or re-login)")
300
+ return env["data"]["resume"]