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 +8 -0
- flowcvcli/__main__.py +5 -0
- flowcvcli/api.py +29 -0
- flowcvcli/cli.py +335 -0
- flowcvcli/client.py +300 -0
- flowcvcli/config.py +87 -0
- flowcvcli/content.py +168 -0
- flowcvcli/customization.py +85 -0
- flowcvcli/markup.py +53 -0
- flowcvcli/personal.py +66 -0
- flowcvcli/photo.py +95 -0
- flowcvcli/resume.py +122 -0
- flowcvcli-0.2.0.dist-info/METADATA +213 -0
- flowcvcli-0.2.0.dist-info/RECORD +18 -0
- flowcvcli-0.2.0.dist-info/WHEEL +5 -0
- flowcvcli-0.2.0.dist-info/entry_points.txt +2 -0
- flowcvcli-0.2.0.dist-info/licenses/LICENSE +21 -0
- flowcvcli-0.2.0.dist-info/top_level.txt +1 -0
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
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"]
|