offerprinter 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.
- offerprinter/__init__.py +7 -0
- offerprinter/cli.py +523 -0
- offerprinter/config.py +183 -0
- offerprinter/controllers/__init__.py +5 -0
- offerprinter/controllers/pipeline.py +135 -0
- offerprinter/llm/__init__.py +11 -0
- offerprinter/llm/anthropic_provider.py +41 -0
- offerprinter/llm/base.py +125 -0
- offerprinter/llm/factory.py +33 -0
- offerprinter/llm/gemini_provider.py +38 -0
- offerprinter/llm/kimi_provider.py +16 -0
- offerprinter/llm/ollama_provider.py +24 -0
- offerprinter/llm/openai_provider.py +42 -0
- offerprinter/models/__init__.py +21 -0
- offerprinter/models/schemas.py +202 -0
- offerprinter/pricing.py +98 -0
- offerprinter/prompts/__init__.py +32 -0
- offerprinter/prompts/templates.py +339 -0
- offerprinter/services/__init__.py +14 -0
- offerprinter/services/cv_parser.py +84 -0
- offerprinter/services/generator.py +169 -0
- offerprinter/services/jd_fetcher.py +86 -0
- offerprinter/services/pdf_writer.py +329 -0
- offerprinter/services/tracker.py +210 -0
- offerprinter/services/writer.py +142 -0
- offerprinter/ui/__init__.py +9 -0
- offerprinter/ui/printer.py +165 -0
- offerprinter-0.2.0.dist-info/METADATA +542 -0
- offerprinter-0.2.0.dist-info/RECORD +32 -0
- offerprinter-0.2.0.dist-info/WHEEL +4 -0
- offerprinter-0.2.0.dist-info/entry_points.txt +3 -0
- offerprinter-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""A local record of every application you have printed.
|
|
2
|
+
|
|
3
|
+
Job hunting is a long, demoralising process with no scoreboard. This module is
|
|
4
|
+
the scoreboard. Every run appends a row to a plain JSON file in
|
|
5
|
+
``~/.offerprinter/applications.json`` — no server, no account, no sync, just a
|
|
6
|
+
file you own and can delete, grep, or check into your own private repo.
|
|
7
|
+
|
|
8
|
+
It powers three things:
|
|
9
|
+
* ``offerprinter list`` — everything you have applied for
|
|
10
|
+
* ``offerprinter stats`` — totals, spend, average fit, outcomes
|
|
11
|
+
* achievements — the small, silly nudges that keep you going
|
|
12
|
+
|
|
13
|
+
Nothing here ever leaves your machine.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
|
|
25
|
+
#: Where applications are recorded. Override with OFFERPRINTER_HOME.
|
|
26
|
+
DEFAULT_HOME = Path.home() / ".offerprinter"
|
|
27
|
+
|
|
28
|
+
#: Valid values for a record's `status` field, in pipeline order.
|
|
29
|
+
STATUSES = ("printed", "applied", "screening", "interview", "offer", "rejected")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApplicationRecord(BaseModel):
|
|
33
|
+
"""One printed application package."""
|
|
34
|
+
|
|
35
|
+
slug: str
|
|
36
|
+
company: str
|
|
37
|
+
role: str
|
|
38
|
+
printed_at: str # ISO-8601 UTC
|
|
39
|
+
provider: str = ""
|
|
40
|
+
model: str = ""
|
|
41
|
+
fit_score: int | None = None
|
|
42
|
+
fit_band: str = ""
|
|
43
|
+
cost_usd: float = 0.0
|
|
44
|
+
total_tokens: int = 0
|
|
45
|
+
output_dir: str = ""
|
|
46
|
+
status: str = "printed"
|
|
47
|
+
notes: str = ""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def printed_date(self) -> str:
|
|
51
|
+
return self.printed_at[:10]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Tracker:
|
|
55
|
+
"""Read/write access to the local application history."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, home: Path | None = None) -> None:
|
|
58
|
+
self.home = Path(home or os.environ.get("OFFERPRINTER_HOME") or DEFAULT_HOME)
|
|
59
|
+
self.path = self.home / "applications.json"
|
|
60
|
+
|
|
61
|
+
# -- persistence --------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def load(self) -> list[ApplicationRecord]:
|
|
64
|
+
"""Return every record, oldest first. A corrupt file is never fatal."""
|
|
65
|
+
if not self.path.is_file():
|
|
66
|
+
return []
|
|
67
|
+
try:
|
|
68
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
69
|
+
except (json.JSONDecodeError, OSError):
|
|
70
|
+
return []
|
|
71
|
+
records = []
|
|
72
|
+
for item in raw if isinstance(raw, list) else []:
|
|
73
|
+
try:
|
|
74
|
+
records.append(ApplicationRecord(**item))
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
continue # skip a bad row rather than losing the whole history
|
|
77
|
+
return records
|
|
78
|
+
|
|
79
|
+
def save(self, records: list[ApplicationRecord]) -> None:
|
|
80
|
+
self.home.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
payload = [r.model_dump() for r in records]
|
|
82
|
+
self.path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
83
|
+
|
|
84
|
+
# -- mutations ----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def record(self, record: ApplicationRecord) -> list[str]:
|
|
87
|
+
"""Append a run and return any achievements newly unlocked by it."""
|
|
88
|
+
records = self.load()
|
|
89
|
+
before = unlocked(records)
|
|
90
|
+
records.append(record)
|
|
91
|
+
self.save(records)
|
|
92
|
+
return sorted(unlocked(records) - before)
|
|
93
|
+
|
|
94
|
+
def set_status(self, slug: str, status: str) -> ApplicationRecord | None:
|
|
95
|
+
"""Update the most recent record for a slug. Returns it, or None."""
|
|
96
|
+
if status not in STATUSES:
|
|
97
|
+
raise ValueError(f"Unknown status '{status}'. Use one of: {', '.join(STATUSES)}")
|
|
98
|
+
records = self.load()
|
|
99
|
+
for record in reversed(records):
|
|
100
|
+
if record.slug == slug:
|
|
101
|
+
record.status = status
|
|
102
|
+
self.save(records)
|
|
103
|
+
return record
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Stats(BaseModel):
|
|
108
|
+
"""Aggregate view of the whole history."""
|
|
109
|
+
|
|
110
|
+
total: int = 0
|
|
111
|
+
companies: int = 0
|
|
112
|
+
average_fit: float = 0.0
|
|
113
|
+
best_fit: int = 0
|
|
114
|
+
best_fit_role: str = ""
|
|
115
|
+
total_cost_usd: float = 0.0
|
|
116
|
+
total_tokens: int = 0
|
|
117
|
+
by_status: dict[str, int] = Field(default_factory=dict)
|
|
118
|
+
first_printed: str = ""
|
|
119
|
+
last_printed: str = ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def summarise(records: list[ApplicationRecord]) -> Stats:
|
|
123
|
+
"""Roll a list of records up into headline numbers."""
|
|
124
|
+
if not records:
|
|
125
|
+
return Stats()
|
|
126
|
+
|
|
127
|
+
scored = [r for r in records if r.fit_score is not None]
|
|
128
|
+
best = max(scored, key=lambda r: r.fit_score or 0, default=None)
|
|
129
|
+
by_status: dict[str, int] = {}
|
|
130
|
+
for record in records:
|
|
131
|
+
by_status[record.status] = by_status.get(record.status, 0) + 1
|
|
132
|
+
|
|
133
|
+
return Stats(
|
|
134
|
+
total=len(records),
|
|
135
|
+
companies=len({r.company.lower() for r in records if r.company}),
|
|
136
|
+
average_fit=round(sum(r.fit_score or 0 for r in scored) / len(scored), 1)
|
|
137
|
+
if scored
|
|
138
|
+
else 0.0,
|
|
139
|
+
best_fit=best.fit_score if best and best.fit_score is not None else 0,
|
|
140
|
+
best_fit_role=f"{best.role} at {best.company}" if best else "",
|
|
141
|
+
total_cost_usd=round(sum(r.cost_usd for r in records), 4),
|
|
142
|
+
total_tokens=sum(r.total_tokens for r in records),
|
|
143
|
+
by_status=by_status,
|
|
144
|
+
first_printed=min(r.printed_at for r in records)[:10],
|
|
145
|
+
last_printed=max(r.printed_at for r in records)[:10],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- achievements ------------------------------------------------------------
|
|
150
|
+
#
|
|
151
|
+
# Deliberately small and a bit silly. Job hunting is a grind with almost no
|
|
152
|
+
# feedback loop; a line that says "that's ten applications this month" is a
|
|
153
|
+
# cheap way to make the grind visible.
|
|
154
|
+
|
|
155
|
+
#: id -> (emoji, title, description, predicate over the full record list)
|
|
156
|
+
ACHIEVEMENTS: dict[str, tuple[str, str, str]] = {
|
|
157
|
+
"first_print": ("🖨", "First Print", "You printed your first application package."),
|
|
158
|
+
"five_printed": ("🖐", "Warmed Up", "Five applications printed."),
|
|
159
|
+
"ten_printed": ("🔟", "Double Digits", "Ten applications printed."),
|
|
160
|
+
"fifty_printed": ("💯", "Machine", "Fifty applications printed."),
|
|
161
|
+
"strong_fit": ("🎯", "Bullseye", "Scored 85+ on a role — apply to that one today."),
|
|
162
|
+
"honest_stretch": ("🧗", "Honest Stretch", "Applied to a role you scored under 50 on. Brave."),
|
|
163
|
+
"five_companies": ("🌍", "Spread Bet", "Applications to five different companies."),
|
|
164
|
+
"thrifty": ("🪙", "Thrifty", "Ten packages printed for under one dollar total."),
|
|
165
|
+
"local_hero": ("🏠", "Local Hero", "Printed a package on a locally-hosted model."),
|
|
166
|
+
"interview": ("🤝", "In The Room", "Marked an application as reaching interview."),
|
|
167
|
+
"offer": ("🏆", "Offer Printed", "Marked an application as an offer. Congratulations."),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def unlocked(records: list[ApplicationRecord]) -> set[str]:
|
|
172
|
+
"""Return the ids of every achievement earned by this history."""
|
|
173
|
+
if not records:
|
|
174
|
+
return set()
|
|
175
|
+
|
|
176
|
+
earned: set[str] = {"first_print"}
|
|
177
|
+
count = len(records)
|
|
178
|
+
if count >= 5:
|
|
179
|
+
earned.add("five_printed")
|
|
180
|
+
if count >= 10:
|
|
181
|
+
earned.add("ten_printed")
|
|
182
|
+
if count >= 50:
|
|
183
|
+
earned.add("fifty_printed")
|
|
184
|
+
if len({r.company.lower() for r in records if r.company}) >= 5:
|
|
185
|
+
earned.add("five_companies")
|
|
186
|
+
if any((r.fit_score or 0) >= 85 for r in records):
|
|
187
|
+
earned.add("strong_fit")
|
|
188
|
+
if any(r.fit_score is not None and r.fit_score < 50 for r in records):
|
|
189
|
+
earned.add("honest_stretch")
|
|
190
|
+
if count >= 10 and sum(r.cost_usd for r in records) < 1.0:
|
|
191
|
+
earned.add("thrifty")
|
|
192
|
+
if any(r.provider == "ollama" for r in records):
|
|
193
|
+
earned.add("local_hero")
|
|
194
|
+
statuses = {r.status for r in records}
|
|
195
|
+
if statuses & {"interview", "offer"}:
|
|
196
|
+
earned.add("interview")
|
|
197
|
+
if "offer" in statuses:
|
|
198
|
+
earned.add("offer")
|
|
199
|
+
return earned
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def describe(achievement_id: str) -> str:
|
|
203
|
+
"""One-line human description of an achievement, for the CLI."""
|
|
204
|
+
emoji, title, description = ACHIEVEMENTS.get(achievement_id, ("🎖", achievement_id, ""))
|
|
205
|
+
return f"{emoji} {title} — {description}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def utc_now() -> str:
|
|
209
|
+
"""Current time as an ISO-8601 UTC string (stable, sortable, timezone-safe)."""
|
|
210
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Write a generated package to disk as .md, .docx and/or .pdf, plus a combined file.
|
|
2
|
+
|
|
3
|
+
Kept separate from generation so the format concerns live in one place. The
|
|
4
|
+
.docx writer does a lightweight Markdown-to-Word rendering (headings, bullets,
|
|
5
|
+
bold) — enough to produce clean, ATS-friendly documents without a heavy
|
|
6
|
+
dependency. The .pdf writer is our own (see `pdf_writer.py`), for the same
|
|
7
|
+
reason: recruiters ask for PDFs and ATS parsers want plain Helvetica text.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from offerprinter.models.schemas import ApplicationPackage, Artifact
|
|
16
|
+
from offerprinter.services.pdf_writer import write_pdf
|
|
17
|
+
|
|
18
|
+
#: Output formats `write_package` knows how to produce.
|
|
19
|
+
SUPPORTED_FORMATS = ("md", "docx", "pdf")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write_md(artifact: Artifact, folder: Path) -> Path:
|
|
23
|
+
path = folder / f"{artifact.filename}.md"
|
|
24
|
+
path.write_text(artifact.content.rstrip() + "\n", encoding="utf-8")
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_pdf(artifact: Artifact, folder: Path) -> Path:
|
|
29
|
+
return write_pdf(artifact.content, folder / f"{artifact.filename}.pdf")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _add_markdown_runs(paragraph, text: str) -> None:
|
|
36
|
+
"""Add text to a python-docx paragraph, honouring **bold** spans."""
|
|
37
|
+
pos = 0
|
|
38
|
+
for m in _BOLD_RE.finditer(text):
|
|
39
|
+
if m.start() > pos:
|
|
40
|
+
paragraph.add_run(text[pos : m.start()])
|
|
41
|
+
run = paragraph.add_run(m.group(1))
|
|
42
|
+
run.bold = True
|
|
43
|
+
pos = m.end()
|
|
44
|
+
if pos < len(text):
|
|
45
|
+
paragraph.add_run(text[pos:])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _write_docx(artifact: Artifact, folder: Path) -> Path:
|
|
49
|
+
from docx import Document
|
|
50
|
+
|
|
51
|
+
doc = Document()
|
|
52
|
+
for raw_line in artifact.content.splitlines():
|
|
53
|
+
line = raw_line.rstrip()
|
|
54
|
+
if not line:
|
|
55
|
+
doc.add_paragraph()
|
|
56
|
+
continue
|
|
57
|
+
if line.startswith("### "):
|
|
58
|
+
doc.add_heading(line[4:], level=3)
|
|
59
|
+
elif line.startswith("## "):
|
|
60
|
+
doc.add_heading(line[3:], level=2)
|
|
61
|
+
elif line.startswith("# "):
|
|
62
|
+
doc.add_heading(line[2:], level=1)
|
|
63
|
+
elif line.lstrip().startswith(("- ", "* ")):
|
|
64
|
+
stripped = line.lstrip()[2:]
|
|
65
|
+
p = doc.add_paragraph(style="List Bullet")
|
|
66
|
+
_add_markdown_runs(p, stripped)
|
|
67
|
+
elif re.match(r"^\d+\.\s", line.lstrip()):
|
|
68
|
+
stripped = re.sub(r"^\d+\.\s", "", line.lstrip())
|
|
69
|
+
p = doc.add_paragraph(style="List Number")
|
|
70
|
+
_add_markdown_runs(p, stripped)
|
|
71
|
+
else:
|
|
72
|
+
p = doc.add_paragraph()
|
|
73
|
+
_add_markdown_runs(p, line)
|
|
74
|
+
|
|
75
|
+
path = folder / f"{artifact.filename}.docx"
|
|
76
|
+
doc.save(str(path))
|
|
77
|
+
return path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _combined_markdown(package: ApplicationPackage) -> str:
|
|
81
|
+
parts = [f"# Application Package — {package.role} at {package.company}\n"]
|
|
82
|
+
parts.append(
|
|
83
|
+
"> Generated by OfferPrinter. Every statement is drawn from your real CV — "
|
|
84
|
+
"nothing here is fabricated. Review before sending.\n"
|
|
85
|
+
)
|
|
86
|
+
if package.fit:
|
|
87
|
+
parts.append(f"\n**Fit score: {package.fit.score}/100 — {package.fit.band}.** ")
|
|
88
|
+
parts.append(f"{package.fit.verdict}\n")
|
|
89
|
+
for artifact in package.artifacts:
|
|
90
|
+
parts.append("\n---\n")
|
|
91
|
+
parts.append(artifact.content.rstrip())
|
|
92
|
+
return "\n".join(parts) + "\n"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_package(
|
|
96
|
+
package: ApplicationPackage, output_dir: str, formats: list[str]
|
|
97
|
+
) -> dict[str, list[Path]]:
|
|
98
|
+
"""Write every artifact in the requested formats. Returns paths written.
|
|
99
|
+
|
|
100
|
+
Returns a dict mapping artifact key (plus "_combined") to the list of files
|
|
101
|
+
written for it.
|
|
102
|
+
"""
|
|
103
|
+
folder = Path(output_dir) / package.slug
|
|
104
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
|
|
106
|
+
def write_all(artifact: Artifact) -> list[Path]:
|
|
107
|
+
paths: list[Path] = []
|
|
108
|
+
if "md" in formats:
|
|
109
|
+
paths.append(_write_md(artifact, folder))
|
|
110
|
+
if "docx" in formats:
|
|
111
|
+
paths.append(_write_docx(artifact, folder))
|
|
112
|
+
if "pdf" in formats:
|
|
113
|
+
paths.append(_write_pdf(artifact, folder))
|
|
114
|
+
return paths
|
|
115
|
+
|
|
116
|
+
written: dict[str, list[Path]] = {}
|
|
117
|
+
for artifact in package.artifacts:
|
|
118
|
+
written[artifact.key] = write_all(artifact)
|
|
119
|
+
|
|
120
|
+
# The fit score is structured data rather than a generated document, so it
|
|
121
|
+
# gets rendered here rather than coming back from the model as Markdown.
|
|
122
|
+
if package.fit:
|
|
123
|
+
written["fit_score"] = write_all(
|
|
124
|
+
Artifact(
|
|
125
|
+
key="fit_score",
|
|
126
|
+
title="Fit Score",
|
|
127
|
+
filename="fit-score",
|
|
128
|
+
content=package.fit.as_markdown(),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Combined package — a single file with everything.
|
|
133
|
+
written["_combined"] = write_all(
|
|
134
|
+
Artifact(
|
|
135
|
+
key="_combined",
|
|
136
|
+
title="Full Package",
|
|
137
|
+
filename="full-package",
|
|
138
|
+
content=_combined_markdown(package),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return written
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Terminal presentation helpers.
|
|
2
|
+
|
|
3
|
+
Kept apart from the pipeline so that nothing in the core depends on how (or
|
|
4
|
+
whether) progress is drawn. The CLI is the only consumer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from offerprinter.ui.printer import PrinterAnimation, render_fit_bar
|
|
8
|
+
|
|
9
|
+
__all__ = ["PrinterAnimation", "render_fit_bar"]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The printer animation — an ASCII dot-matrix printer feeding paper.
|
|
2
|
+
|
|
3
|
+
Purely cosmetic, and that is the point: a generation run is 30-60 seconds of
|
|
4
|
+
nothing, and watching a little printer chug through five documents is far nicer
|
|
5
|
+
than watching a spinner. It degrades safely — if the output is not a terminal
|
|
6
|
+
(piped, redirected, CI, or `--no-animation`), it prints plain status lines
|
|
7
|
+
instead and nothing is lost.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import itertools
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from rich.console import Console, Group
|
|
19
|
+
from rich.live import Live
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
|
|
22
|
+
#: The printer body. `{roll}` animates the paper feed rollers.
|
|
23
|
+
_PRINTER = r""" ╔═══════════════════════════════════╗
|
|
24
|
+
║ ● OfferPrinter {status} ║
|
|
25
|
+
╟───────────────────────────────────╢
|
|
26
|
+
║ {roll} ║
|
|
27
|
+
╚═══════════════════════════════════╝"""
|
|
28
|
+
|
|
29
|
+
#: Frames for the paper-feed rollers, cycled while a document is generating.
|
|
30
|
+
_ROLLERS = [
|
|
31
|
+
"▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚▚",
|
|
32
|
+
"▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞▞",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
#: How a sheet of paper looks as it emerges, one line at a time.
|
|
36
|
+
_SHEET_TOP = " ┌─────────────────────────────────┐"
|
|
37
|
+
_SHEET_ROW = " │ {line:<31} │"
|
|
38
|
+
_SHEET_BOTTOM = " └─────────────────────────────────┘"
|
|
39
|
+
|
|
40
|
+
_STATUS_WIDTH = 10
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _supports_animation(console: Console) -> bool:
|
|
44
|
+
"""Animate only when there's a human watching a real terminal."""
|
|
45
|
+
if os.environ.get("OFFERPRINTER_NO_ANIM"):
|
|
46
|
+
return False
|
|
47
|
+
if os.environ.get("CI"):
|
|
48
|
+
return False
|
|
49
|
+
return console.is_terminal and not console.is_jupyter
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PrinterAnimation:
|
|
53
|
+
"""Draws the printer while artifacts generate, feeding a sheet per artifact.
|
|
54
|
+
|
|
55
|
+
Use as a context manager:
|
|
56
|
+
|
|
57
|
+
with PrinterAnimation(console, total=5) as anim:
|
|
58
|
+
anim.status("Reading the job description")
|
|
59
|
+
anim.sheet("Tailored CV")
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, console: Console, total: int, enabled: bool = True) -> None:
|
|
63
|
+
self.console = console
|
|
64
|
+
self.total = max(total, 1)
|
|
65
|
+
self.enabled = enabled and _supports_animation(console)
|
|
66
|
+
self.done = 0
|
|
67
|
+
self._status = "warming up"
|
|
68
|
+
self._sheets: list[str] = []
|
|
69
|
+
self._live: Live | None = None
|
|
70
|
+
self._stop = threading.Event()
|
|
71
|
+
self._thread: threading.Thread | None = None
|
|
72
|
+
self._frames = itertools.cycle(_ROLLERS)
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
# -- rendering ----------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _render(self) -> Group:
|
|
78
|
+
with self._lock:
|
|
79
|
+
status = self._status[:_STATUS_WIDTH].ljust(_STATUS_WIDTH)
|
|
80
|
+
sheets = list(self._sheets)
|
|
81
|
+
|
|
82
|
+
body = Text(
|
|
83
|
+
_PRINTER.format(status=status, roll=next(self._frames)),
|
|
84
|
+
style="bright_cyan",
|
|
85
|
+
)
|
|
86
|
+
lines: list[Text | str] = [body]
|
|
87
|
+
if sheets:
|
|
88
|
+
lines.append(Text(_SHEET_TOP, style="grey58"))
|
|
89
|
+
for name in sheets:
|
|
90
|
+
lines.append(Text(_SHEET_ROW.format(line=f"✓ {name}"), style="green"))
|
|
91
|
+
lines.append(Text(_SHEET_BOTTOM, style="grey58"))
|
|
92
|
+
return Group(*lines)
|
|
93
|
+
|
|
94
|
+
def _spin(self) -> None:
|
|
95
|
+
while not self._stop.wait(0.18):
|
|
96
|
+
if self._live is not None:
|
|
97
|
+
self._live.update(self._render())
|
|
98
|
+
|
|
99
|
+
# -- public API ---------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def status(self, message: str) -> None:
|
|
102
|
+
"""Update the little status readout on the printer's front panel."""
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._status = message
|
|
105
|
+
if not self.enabled:
|
|
106
|
+
self.console.print(f"[cyan]…[/cyan] {message}")
|
|
107
|
+
elif self._live is not None:
|
|
108
|
+
self._live.update(self._render())
|
|
109
|
+
|
|
110
|
+
def sheet(self, title: str) -> None:
|
|
111
|
+
"""Feed one finished sheet out of the printer."""
|
|
112
|
+
self.done += 1
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._sheets.append(title)
|
|
115
|
+
self._status = f"{self.done}/{self.total}"
|
|
116
|
+
if not self.enabled:
|
|
117
|
+
self.console.print(f" [green]✓[/green] {title}")
|
|
118
|
+
elif self._live is not None:
|
|
119
|
+
self._live.update(self._render())
|
|
120
|
+
|
|
121
|
+
def __enter__(self) -> PrinterAnimation:
|
|
122
|
+
if self.enabled:
|
|
123
|
+
self._live = Live(
|
|
124
|
+
self._render(),
|
|
125
|
+
console=self.console,
|
|
126
|
+
refresh_per_second=12,
|
|
127
|
+
transient=False,
|
|
128
|
+
)
|
|
129
|
+
self._live.__enter__()
|
|
130
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
131
|
+
self._thread.start()
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
135
|
+
self._stop.set()
|
|
136
|
+
if self._thread is not None:
|
|
137
|
+
self._thread.join(timeout=1.0)
|
|
138
|
+
if self._live is not None:
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._status = "done".ljust(_STATUS_WIDTH)
|
|
141
|
+
self._live.update(self._render())
|
|
142
|
+
self._live.__exit__(*exc_info) # type: ignore[arg-type]
|
|
143
|
+
self._live = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_fit_bar(score: int, width: int = 24) -> Text:
|
|
147
|
+
"""A coloured 0-100 bar for the fit score, for the end-of-run summary."""
|
|
148
|
+
filled = round(score / 100 * width)
|
|
149
|
+
colour = "green" if score >= 70 else "yellow" if score >= 50 else "red"
|
|
150
|
+
bar = Text()
|
|
151
|
+
bar.append("█" * filled, style=colour)
|
|
152
|
+
bar.append("░" * (width - filled), style="grey37")
|
|
153
|
+
return bar
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def type_out(console: Console, text: str, delay: float = 0.012, style: str = "") -> None:
|
|
157
|
+
"""Print text a character at a time, for the one line that deserves it."""
|
|
158
|
+
if not _supports_animation(console) or delay <= 0:
|
|
159
|
+
console.print(text, style=style)
|
|
160
|
+
return
|
|
161
|
+
for char in text:
|
|
162
|
+
console.print(char, end="", style=style, highlight=False, markup=False)
|
|
163
|
+
sys.stdout.flush()
|
|
164
|
+
time.sleep(delay)
|
|
165
|
+
console.print()
|