Make fonts expressible as ordinary Python project dependencies (uv add, pip install),
with correct licensing, discoverable at runtime, and maintained by an automated pipeline —
what Fontsource does for npm, for PyPI.
| Option | Approach | Status / gap |
|---|---|---|
fonts + font-* (pimoroni) |
Per-font PyPI packages exposing TTFs via fonts_ttf entry points; from fonts.ttf import AmaticSC |
Closest analog. Only ~10 hand-made fonts, effectively dormant, no pipeline, no weight/style model |
pyfonts |
Runtime download from Google Fonts / Bunny Fonts, registers with matplotlib | Not a dependency: needs network at plot time, matplotlib-only, nothing pinned in pyproject.toml |
One-off font packages (ttf-opensans, py-open-fonts, fontawesome_freefile, font-source-sans-pro) |
Vendored font files as package data | Ad-hoc, inconsistent APIs, single fonts, mostly unmaintained |
Installer CLIs (googlefonts-installer, font-installer) |
Download + install into OS font dirs | Imperative side effects, not per-project, not declarative |
| Fontist (Ruby) | YAML manifest + community formulas, 3000+ fonts, license gating | Best licensing model, but Ruby CLI — not pip-resolvable, not importable |
| DIY vendoring | Ship TTF as package data, importlib.resources |
The de-facto standard; every project reinvents it and often ignores license obligations |
Gap: nothing combines (a) pip/uv-resolvable dependencies, (b) broad coverage with automation, (c) a uniform runtime API, (d) license compliance. That's the project.
Goals
uv add fontpkg-roboto→ font available, pinned, offline, reproducible.- Uniform lookup: family → file path(s) by weight/style, framework helpers (PIL, matplotlib).
- Every package legally redistributable, with license text shipped and copyright/RFN respected.
- Fully automated generation from upstream (Google Fonts repo) — no hand-curation at scale.
Non-goals
- Installing fonts into the OS (that's Fontist's job; we stay inside site-packages).
- Web formats (woff2 subsets, CSS) — fontsource covers the web; Python consumers want TTF/OTF. (A
[web]extra with woff2 for Django/static-site use is a possible v2 item, not core.) - Proprietary fonts (Arial etc.) — never redistributable; out of scope.
Three parts, mirroring fontsource:
Small, zero-dependency (stdlib importlib.metadata / importlib.resources). Font packages
are dumb data; all resolution logic lives here, so fixes ship without republishing 1,500
font packages.
import fontpkg
fontpkg.path("Roboto") # Path to 400/normal (or the variable font)
fontpkg.path("Roboto", weight=700, style="italic")
fontpkg.path("Roboto", weight=650, nearest=True) # opt-in nearest-match; exact-or-raise default
fam = fontpkg.family("Roboto") # Family: .weights, .styles, .axes,
# .is_variable, .files, .license, .version
fontpkg.families() # {slug: Family} for everything pip-installedResolution semantics:
- Family lookup is by slug (case/space-insensitive:
"Open Sans"=="open-sans"). - Weights are CSS-numeric (100–900) with named aliases (
"bold"→ 700). - Exact match by default; missing family raises
FamilyNotInstalled("open-sans — add it with: uv add fontpkg-open-sans")— the error message is the install instruction. - If the family ships a variable font,
path()returns the VF file for any weight in its axis range; the renderer sets the axis. Integration helpers do this correctly:
# extras: fontpkg[pil], fontpkg[matplotlib]
from fontpkg.pil import truetype
f = truetype("Roboto", size=24, weight=500) # handles set_variation_by_axes for VFs
from fontpkg.mpl import use
prop = use("Roboto") # registers files with font_manager,
# returns FontPropertiesDiscovery via an entry-point group, so the core needs no registry of its own:
[project.entry-points."fontpkg.family"]
roboto = "fontpkg_roboto"Core loads entry points, reads each package's metadata.json, resolves files with
importlib.resources.files() (zip-safe). Also reads pimoroni's fonts_ttf group for
backward compatibility.
Installed vs. system fonts. Three availability tiers, kept deliberately distinct:
- pip-installed (
fontpkg.families()) — enumerable, versioned, reproducible. Core answers only this; that's the point of the project. - OS-installed — queryable but machine-dependent. Optional
fontpkg[system]extra (wrapping fontconfig / DirectWrite / CoreText enumeration, e.g. viafind-system-fonts-filename) providesfontpkg.system.families()andfontpkg.resolve(name, allow_system=True)for apps that want graceful fallback. - Absent — the exception message tells the user which package to add.
One PyPI package per family (fontpkg-roboto, fontpkg-source-sans-3, …), containing:
fontpkg_roboto/
__init__.py # FAMILY, PATH constants only
metadata.json # family, version, license id, weights, styles, axes, upstream commit
files/*.ttf # unmodified upstream binaries
LICENSE # verbatim OFL/Apache/UFL text with original copyright notice
- Variable font preferred when upstream ships one (one small file, all weights);
static instances behind an extra:
fontpkg-roboto[static]— needed for renderers without VF axis support. - Related families are separate packages (
fontpkg-roboto-condensed,fontpkg-roboto-slab), mirroring upstream's family boundaries. No superfamily metapackages in v1. - Never modify the binaries. No subsetting/instancing in v1 — OFL Reserved Font Names make modified fonts a renaming obligation (the compliance trap fontsource engineered around; shipping upstream bytes verbatim sidesteps it entirely).
- Package version = upstream font version, PEP 440-mapped, with
.postNfor packaging fixes. - Wheel metadata:
License-Expression: OFL-1.1(or Apache-2.0 / Ubuntu font licence),Classifier: Topic :: Text Processing :: Fonts.
A repo (this one) with CI that:
- Clones/updates google/fonts (~1,800 families).
- Parses each family's
METADATA.pb→ allowlist by license: OFL, Apache-2.0, UFL only. - Emits a
pyproject.toml+ package tree per family from a template; copies binaries, license, provenance (upstream commit hash) intometadata.json. - Builds wheels; publishes changed/new families via PyPI Trusted Publishing.
- Runs on a schedule (weekly) — diff against last published upstream commit, so steady state publishes only deltas.
Update detection. A state.json manifest in this repo maps slug → {upstream_commit, published_version}. The weekly run diffs each family's latest
upstream commit against the manifest (via the GitHub compare/commits API), rebuilds and
publishes only the deltas, then commits the updated manifest. Families newly entering
the allowlist are "in upstream, not in state" → built; families that disappear or
change license are flagged for human review, never auto-yanked. Consumers need no
mechanism of their own: font updates arrive through normal dependency tooling
(uv lock --upgrade, Dependabot/Renovate) as reviewable version bumps.
Publishing phases. (1) MVP 10 families, manual; (2) top ~200 by popularity; (3) full allowlist once the pipeline has run unattended reliably. Fonts download at install time only — the bytes are in the wheel, cached by uv/pip; import and resolution are offline.
Data sources. Primary is the google/fonts repo directly: it is the authoritative
origin for binaries, METADATA.pb, axis definitions, and license files — provenance we
can cite. Fontsource's own catalog is mostly Google Fonts plus a small "other" set
(League of Moveable Type, icon sets, direct submissions), normalized into a convenient
JSON API (api.fontsource.org) but serving subsetted woff2 — the wrong artifacts for us,
with second-hand license provenance. Use the fontsource API only as (a) a discovery list
for worthwhile non-Google families, (b) a cross-check on metadata normalization — never
in the build pipeline's critical path. Later sources (Bunny, Fontshare,
league-of-moveable-type) plug in as additional adapters.
- Redistribute only OFL-1.1, Apache-2.0, UFL-1.0 families (same allowlist logic as Fontist/Google Fonts).
- Ship the full license text + original copyright line inside every wheel (OFL §condition 2 requires the license accompany the fonts).
- OFL "no selling fonts by themselves" clause: satisfied — packages are free; PyPI hosting is fine (same position fontsource/npm take).
- Preserve all name-table records by shipping unmodified files; never claim RFNs.
metadata.jsonrecords upstream source URL + commit for provenance/takedown handling.
Decision: fontpkg — core package fontpkg, families fontpkg-<slug>, entry-point
group fontpkg.family. Short, self-describing, and the core name doubles as the family
prefix. Verified unclaimed on PyPI 2026-08-14 (as were fontdep, fontset, typeface,
fontsource-py, pyfontsource). Register fontpkg + a few flagship fontpkg-* names
early to prevent squatting.
On fontsource_py: credit Fontsource prominently as inspiration (factual credit needs no
permission), but don't take the name — it trades on their goodwill, implies API
compatibility we don't intend, and creates an ongoing obligation to track their design.
Precedent: fontsource itself is a renamed continuation of the earlier typefaces npm
project; independent names for ecosystem ports are the norm. Optionally email the
Fontsource maintainers a heads-up — good citizenship, possible future collaboration.
- PyPI at scale: ~1,500+ packages and font-sized wheels will need a PyPI org account,
possibly per-project size-limit requests (default 100 MB/file is fine for almost all;
Noto CJK will need care or exclusion in v1). Mitigation if PyPI objects to the flood:
publish the long tail on a self-hosted PEP 503 simple index (
--extra-index-url), keep the top ~200 families on PyPI proper. - Icon fonts (Font Awesome Free, Material Symbols): same machinery works; separate adapter, v2.
- Web consumers (Django static assets): a
fontpkg.djangostaticfiles finder +@font-facegeneration would want woff2 — candidate[web]extra, v2. Server-side renderers (PIL, matplotlib, ReportLab, WeasyPrint, CAD) are the v1 sweet spot; all consume TTF/OTF paths directly.
- MVP:
fontpkgcore + generator producing ~10 popular OFL families (Roboto, Inter, Open Sans, Lato, Source Sans 3, JetBrains Mono, Fira Code, Noto Sans, Merriweather, Playfair Display); PIL + matplotlib helpers; publish to TestPyPI. - Automation: scheduled CI over full google/fonts allowlist, trusted publishing, deltas.
- Ecosystem: docs site with searchable family index (fontsource-style),
fontpkg searchCLI, bunny/fontshare adapters, static-instance extras,[system]fallback module.
Recorded while implementing the MVP; deviations from or refinements of the sections above.
- Layout: uv workspace monorepo —
core/(thefontpkgruntime) +generator/(fontpkg-genCLI).just sync / test / build <families>. - Python ≥3.10 (not 3.9):
importlib.metadata.entry_points(group=...)and modern union hints; 3.9 is EOL anyway. - metadata.json schema v1:
{schema, family, slug, version, license, copyright, axes: [{tag,min,max}], files: [{path, style, weight|null, variable}], source: {repo, path, commit}}. Variable files carryweight: null; their wght range comes from the family-levelwghtaxis. - Licenses in v1: OFL-1.1 and Apache-2.0 only. UFL dropped for now — its SPDX id is unclear and PEP 639 backends validate license expressions, so Ubuntu's family is excluded until resolved (only affects a handful of families).
- Fetching: GitHub contents API (no multi-GB clone), unauthenticated with UA header,
top-level
.ttf/.otf+ license +METADATA.pbonly;static/subdirs skipped ([static]extras deferred). Provenance commit from the commits API. Unauthenticated rate limit is 60 req/hr — fine for a handful of families; CI at scale needs a token. - Versioning:
head.fontRevisionformatted%.3f→ package version; PEP 440 normalizes leading zeros (Roboto3.015becomes wheel version3.15) whilemetadata.jsonpreserves the exact font version. Theoretical collision (3.015 vs 3.15) accepted for MVP; revisit with a post-release scheme if it ever bites. - Resolution details: nearest-match tie-break prefers the lighter weight; VF wins over
static when both cover the requested weight;
nearest=Trueclamps into a VF's range when the weight falls outside it. - Legacy compat: pimoroni's
fonts_ttfentry points are read best-effort (path-valued entries become single-file 400/normal families); nativefontpkg.familyentries win on slug collision. - Zip-installed wheels not supported:
path()materializesPath(str(traversable)); real-world installs are unpacked. Documented limitation rather than anas_fileExitStack held for the process lifetime. - matplotlib helper registers all family files and returns
FontProperties; matplotlib cannot set VF axes, so non-400 weights of VF-only families need the future[static]extra there (PIL helper handles VF axes properly). fontpkg.systemdeferred to v2 (design §3.1 tier 2).- CLI added to core:
fontpkg list/fontpkg path <family> [--weight] [--style] [--nearest](console script +python -m fontpkg). Roadmap'sfontpkg search(querying the not-yet-installed catalog) remains future work. - Batch validation: all 10 MVP families generated and verified in a clean venv (Roboto, Inter, Open Sans, Lato, Source Sans 3, JetBrains Mono, Fira Code, Noto Sans, Merriweather, Playfair Display). Lato exercises the static-only path (18 files); Fira Code (no italic) and Playfair Display (min weight 400) exercise the error paths.
- CI: GitHub Actions test matrix (3.10/3.12/3.13) + a manual
workflow_dispatchgenerate workflow that uploads wheels as artifacts. PyPI trusted publishing deferred until the names are registered. - Delta pipeline implemented (Phase 2):
fontpkg-gen sync+state.json(keyed by google/fonts dirname →{path, commit, version, package}) +families.txtas the tracked-family list. Weekly scheduled workflow (generate.yml, Mondays 06:17 UTC) syncs deltas, uploads wheels, optionally publishes, and commits the updated manifest. GitHub API calls are authenticated viaGITHUB_TOKENwhen present (5,000 req/hr vs 60). - Publishing wiring:
release.ymlpublishes corefontpkgonv*tags via trusted publishing. Family-package publishing ingenerate.ymlis gated behind aworkflow_dispatchpublishinput (or repo variableAUTO_PUBLISH=truefor scheduled runs) because each newfontpkg-<slug>name needs a pending trusted publisher added on PyPI first — PyPI has no bulk API for this, so the MVP names are a one-time manual step (or a first localuv publishwith a token per package). - Discoverability (Phase 3 started):
catalog.jsonat the repo root is the machine-readable index of installable families (family, slug, package, version, license, category, styles, variable/weights), updated by the same sync that builds packages and committed by CI alongsidestate.json. The core CLI'sfontpkg search <query>fetches it from GitHub raw (override with--catalog-urlorFONTPKG_CATALOG_URL; accepts a local path) and marks installed families — the only CLI command that touches the network; the library API stays offline. The catalog is the intended data source for a future docs site with rendered specimens. - Tests: no network anywhere; fixture fonts are built in-memory with fontTools
FontBuilder; generator↔core schema compatibility is covered by a roundtrip test. Live verification (fetch → build → wheel → clean-venv install → resolve → PIL render) was run manually for Roboto + Inter and passed.
GitHub Pages limits: 1 GB published site, ~100 GB/month bandwidth. At ~3 MB/family of TTFs: 10 families = 30 MB (today), top-200 ≈ 600 MB (fits, tight), full ~1,500 ≈ 3-4.5 GB (exceeds Pages). Client-side scale is already solved by lazy loading; hosting size is the only constraint.
Plan: buy fontpkg.org now and make it the Pages custom domain, so published URLs are hosting-independent. Stay on Pages through Phase 2. For Phase 3, move font binaries to object storage + CDN (Cloudflare R2: 10 GB free, free egress) behind fonts.fontpkg.org, leaving the HTML wherever is convenient. woff2 would halve asset sizes but is a format conversion — the OFL FAQ treats conversion as modification (RFN implications), so adopting it for site specimens is a deliberate decision to revisit, not a default; packages always ship unmodified TTF/OTF regardless.