Skip to content

Commit 140b4c0

Browse files
etjonesclaude
andcommitted
Add installable-font catalog and fontpkg search command
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 16e0f76 commit 140b4c0

13 files changed

Lines changed: 457 additions & 20 deletions

File tree

.github/workflows/generate.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,12 @@ jobs:
5151
exit 0
5252
fi
5353
uv publish "${wheels[@]}"
54-
- name: Commit updated state manifest
54+
- name: Commit updated state manifest and catalog
5555
run: |
56-
if ! git diff --quiet state.json 2>/dev/null || [ -n "$(git ls-files --others state.json)" ]; then
56+
if ! git diff --quiet state.json catalog.json 2>/dev/null || [ -n "$(git ls-files --others state.json catalog.json)" ]; then
5757
git config user.name "github-actions[bot]"
5858
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
59-
git add state.json
60-
git commit -m "Update font state manifest"
59+
git add state.json catalog.json
60+
git commit -m "Update font state manifest and catalog"
6161
git push
6262
fi

CONVERSATION.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,12 @@ Open decisions: project/package naming (`fontgrab` vs `pyfontsource`), PyPI scal
115115
**User:** Put PYPI_TOKEN in .env; asked Claude to publish the first 10 MVP font packages.
116116

117117
**Claude:** Added .env to .gitignore first (it wasn't ignored). Built wheels+sdists for all 10, published with the token via UV_PUBLISH_TOKEN. fontpkg-fira-code and fontpkg-inter landed immediately; PyPI's new-project creation throttle (429) hit on the third, so the remaining 8 are publishing via a background retry loop (idempotent with --check-url, 120s backoff).
118+
119+
---
120+
121+
**User:** Asked about discoverability (fontsource-style search/specimens), requested a CLI query for installable (not just installed) fonts, a README usage update, and further suggestions.
122+
123+
**Claude:** Built the catalog layer:
124+
- catalog.json at repo root: index of installable families (family/slug/package/version/license/category/styles/variable/weights), generated by sync alongside state.json, committed by the weekly workflow; seeded from a fresh sync of the 10 families. build.py now records category+subsets in metadata.json.
125+
- `fontpkg search <query>` in the core CLI: fetches catalog from GitHub raw (--catalog-url / FONTPKG_CATALOG_URL override, local paths supported), matches family/slug/category, marks [installed], prints uv add hints. Only networked CLI command; library API stays offline.
126+
- READMEs updated with normal usage (uv add fontpkg-inter; fontpkg.path("inter")) and CLI examples. 45 tests passing.

DESIGN.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,14 @@ Recorded while implementing the MVP; deviations from or refinements of the secti
252252
runs) because **each new `fontpkg-<slug>` name needs a pending trusted publisher added
253253
on PyPI first** — PyPI has no bulk API for this, so the MVP names are a one-time manual
254254
step (or a first local `uv publish` with a token per package).
255+
- **Discoverability (Phase 3 started):** `catalog.json` at the repo root is the
256+
machine-readable index of installable families (family, slug, package, version,
257+
license, category, styles, variable/weights), updated by the same sync that builds
258+
packages and committed by CI alongside `state.json`. The core CLI's `fontpkg search
259+
<query>` fetches it from GitHub raw (override with `--catalog-url` or
260+
`FONTPKG_CATALOG_URL`; accepts a local path) and marks installed families — the only
261+
CLI command that touches the network; the library API stays offline. The catalog is
262+
the intended data source for a future docs site with rendered specimens.
255263
- **Tests:** no network anywhere; fixture fonts are built in-memory with fontTools
256264
`FontBuilder`; generator↔core schema compatibility is covered by a roundtrip test.
257265
Live verification (fetch → build → wheel → clean-venv install → resolve → PIL render)

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,30 @@
33
[![CI](https://github.com/Fontpkg/fontpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/Fontpkg/fontpkg/actions/workflows/ci.yml)
44

55
Fonts as ordinary Python project dependencies — a [Fontsource](https://fontsource.org)
6-
analog for PyPI. `uv add fontpkg-roboto` and the font is pinned, offline, and
7-
resolvable at runtime.
6+
analog for PyPI.
7+
8+
## Usage
9+
10+
```bash
11+
uv add fontpkg fontpkg-inter
12+
```
813

914
```python
1015
import fontpkg
1116

12-
fontpkg.path("Roboto", weight=700, style="italic") # -> Path to the .ttf
17+
font_path = fontpkg.path("inter") # Path to Inter 400/normal
18+
fontpkg.path("Inter", weight=700, style="italic") # names are case/space-insensitive
1319
fontpkg.families() # everything installed
1420
```
1521

22+
The font is pinned in your lockfile, downloaded at install time (never at runtime),
23+
and works offline forever.
24+
1625
There is also a small CLI:
1726

1827
```bash
19-
fontpkg list
28+
fontpkg list # installed families
29+
fontpkg search mono # installable families (fetches the catalog)
2030
fontpkg path "Open Sans" --weight bold
2131
```
2232

catalog.json

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
{
2+
"fira-code": {
3+
"category": [
4+
"MONOSPACE"
5+
],
6+
"family": "Fira Code",
7+
"license": "OFL-1.1",
8+
"package": "fontpkg-fira-code",
9+
"slug": "fira-code",
10+
"styles": [
11+
"normal"
12+
],
13+
"variable": true,
14+
"version": "5.002",
15+
"weight_range": [
16+
300,
17+
700
18+
],
19+
"weights": []
20+
},
21+
"inter": {
22+
"category": [
23+
"SANS_SERIF"
24+
],
25+
"family": "Inter",
26+
"license": "OFL-1.1",
27+
"package": "fontpkg-inter",
28+
"slug": "inter",
29+
"styles": [
30+
"italic",
31+
"normal"
32+
],
33+
"variable": true,
34+
"version": "4.001",
35+
"weight_range": [
36+
100,
37+
900
38+
],
39+
"weights": []
40+
},
41+
"jetbrains-mono": {
42+
"category": [
43+
"MONOSPACE"
44+
],
45+
"family": "JetBrains Mono",
46+
"license": "OFL-1.1",
47+
"package": "fontpkg-jetbrains-mono",
48+
"slug": "jetbrains-mono",
49+
"styles": [
50+
"italic",
51+
"normal"
52+
],
53+
"variable": true,
54+
"version": "2.211",
55+
"weight_range": [
56+
100,
57+
800
58+
],
59+
"weights": []
60+
},
61+
"lato": {
62+
"category": [
63+
"SANS_SERIF"
64+
],
65+
"family": "Lato",
66+
"license": "OFL-1.1",
67+
"package": "fontpkg-lato",
68+
"slug": "lato",
69+
"styles": [
70+
"italic",
71+
"normal"
72+
],
73+
"variable": false,
74+
"version": "2.015",
75+
"weight_range": [
76+
100,
77+
900
78+
],
79+
"weights": [
80+
100,
81+
200,
82+
300,
83+
400,
84+
500,
85+
600,
86+
700,
87+
800,
88+
900
89+
]
90+
},
91+
"merriweather": {
92+
"category": [
93+
"SERIF"
94+
],
95+
"family": "Merriweather",
96+
"license": "OFL-1.1",
97+
"package": "fontpkg-merriweather",
98+
"slug": "merriweather",
99+
"styles": [
100+
"italic",
101+
"normal"
102+
],
103+
"variable": true,
104+
"version": "2.100",
105+
"weight_range": [
106+
300,
107+
900
108+
],
109+
"weights": []
110+
},
111+
"noto-sans": {
112+
"category": [
113+
"SANS_SERIF"
114+
],
115+
"family": "Noto Sans",
116+
"license": "OFL-1.1",
117+
"package": "fontpkg-noto-sans",
118+
"slug": "noto-sans",
119+
"styles": [
120+
"italic",
121+
"normal"
122+
],
123+
"variable": true,
124+
"version": "2.015",
125+
"weight_range": [
126+
100,
127+
900
128+
],
129+
"weights": []
130+
},
131+
"open-sans": {
132+
"category": [
133+
"SANS_SERIF"
134+
],
135+
"family": "Open Sans",
136+
"license": "OFL-1.1",
137+
"package": "fontpkg-open-sans",
138+
"slug": "open-sans",
139+
"styles": [
140+
"italic",
141+
"normal"
142+
],
143+
"variable": true,
144+
"version": "3.003",
145+
"weight_range": [
146+
300,
147+
800
148+
],
149+
"weights": []
150+
},
151+
"playfair-display": {
152+
"category": [
153+
"SERIF"
154+
],
155+
"family": "Playfair Display",
156+
"license": "OFL-1.1",
157+
"package": "fontpkg-playfair-display",
158+
"slug": "playfair-display",
159+
"styles": [
160+
"italic",
161+
"normal"
162+
],
163+
"variable": true,
164+
"version": "1.203",
165+
"weight_range": [
166+
400,
167+
900
168+
],
169+
"weights": []
170+
},
171+
"roboto": {
172+
"category": [
173+
"SANS_SERIF"
174+
],
175+
"family": "Roboto",
176+
"license": "OFL-1.1",
177+
"package": "fontpkg-roboto",
178+
"slug": "roboto",
179+
"styles": [
180+
"italic",
181+
"normal"
182+
],
183+
"variable": true,
184+
"version": "3.015",
185+
"weight_range": [
186+
100,
187+
900
188+
],
189+
"weights": []
190+
},
191+
"source-sans-3": {
192+
"category": [
193+
"SANS_SERIF"
194+
],
195+
"family": "Source Sans 3",
196+
"license": "OFL-1.1",
197+
"package": "fontpkg-source-sans-3",
198+
"slug": "source-sans-3",
199+
"styles": [
200+
"italic",
201+
"normal"
202+
],
203+
"variable": true,
204+
"version": "3.052",
205+
"weight_range": [
206+
200,
207+
900
208+
],
209+
"weights": []
210+
}
211+
}

core/README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,24 @@
33
Fonts as ordinary Python project dependencies. Inspired by [Fontsource](https://fontsource.org).
44

55
```bash
6-
uv add fontpkg fontpkg-roboto
6+
uv add fontpkg fontpkg-inter
77
```
88

99
```python
1010
import fontpkg
1111

12-
fontpkg.path("Roboto") # Path to 400/normal
13-
fontpkg.path("Roboto", weight=700, style="italic")
14-
fontpkg.path("Roboto", weight=650, nearest=True)
12+
font_path = fontpkg.path("inter") # Path to 400/normal
13+
fontpkg.path("Inter", weight=700, style="italic") # names are case/space-insensitive
14+
fontpkg.path("Inter", weight=650, nearest=True)
1515

16-
fam = fontpkg.family("Roboto") # .weights, .styles, .axes, .is_variable, .license
16+
fam = fontpkg.family("Inter") # .weights, .styles, .axes, .is_variable, .license
1717
fontpkg.families() # everything installed in the environment
1818
```
1919

20+
Find installable families with `fontpkg search <query>` (matches family, slug, or
21+
category; the only CLI command that touches the network) or `fontpkg list` for what
22+
is already installed.
23+
2024
Integrations (extras `fontpkg[pil]`, `fontpkg[matplotlib]`):
2125

2226
```python

core/src/fontpkg/__main__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import argparse
22
import sys
3+
import urllib.error
34

45
from fontpkg import FontpkgError, families
56
from fontpkg import path as font_path
7+
from fontpkg._catalog import fetch_catalog, search_catalog
68

79

810
def main(argv: list[str] | None = None) -> int:
@@ -14,10 +16,17 @@ def main(argv: list[str] | None = None) -> int:
1416
path_cmd.add_argument("--weight", default="400")
1517
path_cmd.add_argument("--style", default="normal")
1618
path_cmd.add_argument("--nearest", action="store_true")
19+
search_cmd = sub.add_parser("search", help="search installable font families (network)")
20+
search_cmd.add_argument("query", nargs="?", help="match against family, slug, or category")
21+
search_cmd.add_argument(
22+
"--catalog-url", default=None, help="catalog URL or file path (default: fontpkg repo)"
23+
)
1724
args = parser.parse_args(argv)
1825

1926
if args.command in (None, "list"):
2027
return _list()
28+
if args.command == "search":
29+
return _search(args)
2130
return _path(args)
2231

2332

@@ -37,6 +46,31 @@ def _list() -> int:
3746
return 0
3847

3948

49+
def _search(args: argparse.Namespace) -> int:
50+
try:
51+
catalog = fetch_catalog(args.catalog_url)
52+
except (urllib.error.URLError, OSError) as err:
53+
print(f"error: could not fetch catalog: {err}", file=sys.stderr)
54+
return 1
55+
matches = search_catalog(catalog, args.query)
56+
if not matches:
57+
print(f"no installable families match {args.query!r}")
58+
return 0
59+
installed = set(families())
60+
width = max(len(e["slug"]) for e in matches)
61+
for e in matches:
62+
if e["variable"] and e["weight_range"]:
63+
weights = f"wght {e['weight_range'][0]}-{e['weight_range'][1]}"
64+
else:
65+
weights = ",".join(map(str, e["weights"]))
66+
mark = " [installed]" if e["slug"] in installed else ""
67+
print(
68+
f"{e['slug']:{width}} {e['license']:10} styles={'/'.join(e['styles'])} "
69+
f"{weights} uv add {e['package']}{mark}"
70+
)
71+
return 0
72+
73+
4074
def _path(args: argparse.Namespace) -> int:
4175
weight: int | str = int(args.weight) if args.weight.isdigit() else args.weight
4276
try:

0 commit comments

Comments
 (0)