Building the client¶
The client ships as a single executable — no Python on the user's machine, no virtualenv, no install step.
uv sync
uv run --with pyinstaller pyinstaller --noconfirm client/packaging/resumix.spec
./dist/resumix --help
./dist/resumix version
That produces dist/resumix (or dist/resumix.exe on Windows).
What the spec does¶
client/packaging/resumix.spec:
- Entry point is
client/packaging/entrypoint.py, notresumix_client/__main__.py. PyInstaller runs its entry script as a top-level module, where__main__.py's relative import has no package to resolve against. - Bundled data:
applications.xlsx, the empty spreadsheet copied on first use, and the package's own.dist-infoviacopy_metadata.resumix versionreadsimportlib.metadata, and a frozen application has no metadata unless it is copied in — without it the binary printsversion unknown. Everything else the client needs — your profile, your template, your images — it reads from the folder you run it in at runtime, which is the whole point. - Excludes:
fastapi,uvicorn,starlette,openai,jinja2,pypdf,resumix_server,tkinter,pytest. None of them belong in a client; excluding them keeps the binary small if one is ever pulled in transitively.tests/test_architecture.pyenforces the same rule at the source level, so a bad import fails the test suite before it reaches the build.
PyInstaller does not cross-compile. Build each binary on the OS it is for.
Why PyInstaller and not Cython: Cython compiles modules to C extensions but still needs an interpreter and a launcher around them, so it does not produce the single file this is for. Nuitka would, and is the fallback if start-up time or source opacity ever matters more than build simplicity.
In CI¶
flowchart TB
subgraph matrix["client job — one runner per OS"]
direction TB
U["uv sync --frozen"] --> CACHE["restore the PyInstaller<br/>analysis cache<br/><i>key: uv.lock + the spec</i>"]
CACHE --> BUILD["pyinstaller resumix.spec"]
BUILD --> HELP["./dist/resumix --help<br/>./dist/resumix version"]
HELP --> SMOKE["Linux only:<br/>start resumix-api from source,<br/>render smoke.tex through it,<br/>assert the file starts with %PDF"]
SMOKE --> ART["upload-artifact<br/>the bare binary"]
end
The smoke test is the part worth keeping: --help proves the binary starts,
but the real question is whether it talks to a server. render is the one
call that needs no model, so CI starts the server from the same checkout with
an invalid provider endpoint, renders a two-line .tex through the real
multipart → envelope → pdflatex → base64 path, and checks the bytes on disk.
The analysis cache is keyed on uv.lock and the spec, because either one
invalidates it; without it PyInstaller re-analyses every dependency from
scratch on each run.
What a user downloads¶
A release is one flat .tar.gz holding both binaries and the files the client
looks for by name. How it is assembled, and how to cut one, is in
Cutting a release.
Running from source instead¶
uv sync
uv run resumix --help
uv run resumix --server http://localhost:8080 clipboard --out ~/applications
uv run pytest client/tests # no server, no terminal, no network
The client's tests fake at the boundary: ResumixApi is a protocol, so the
modes run against a stub with no HTTP at all, and tests/test_integration.py
drives the real client against the real app in-process through an
httpx.Client — both halves, no socket.
Adding a new way to feed it postings¶
A source of job descriptions is a JDSource
(client/src/resumix_client/stages/listener.py): one method, poll(), that
returns the next new text or None. The clipboard is one; a second — an IMAP
folder, a browser extension, a queue — is a new file under
client/src/resumix_client/sources/, a thin mode that hands it to a
Listener, and no change to the stages at all. watch and submit are not
sources: watch reads its folder with its own inbox stage, and submit takes
one file and runs the same stages once.