Convert from Homebrew Formulae to Casks for font installation
Formulae can't install to ~/Library/Fonts/ due to Homebrew sandboxing. Casks have a built-in `font` artifact that handles this automatically. - Replace Formula/ with Casks/ directory - Rewrite generator to produce cask files instead of formulae - Add .ttc (TrueType Collection) support - Update all tests for cask format - Update CLI and documentation - Fonts with no installable files (TTF/OTF/TTC) are skipped Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cb1918c30f
commit
76743cdc4d
273 changed files with 2509 additions and 10048 deletions
|
|
@ -3,10 +3,10 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
# Repo root: directory containing font_files/ and Formula/
|
||||
# Repo root: directory containing font_files/ and Casks/
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
FONT_FILES_DIR = REPO_ROOT / "font_files"
|
||||
FORMULA_DIR = REPO_ROOT / "Formula"
|
||||
CASKS_DIR = REPO_ROOT / "Casks"
|
||||
|
||||
REQUIRED_SUBDIRS = ("ttf", "otf", "web", "other_files")
|
||||
WEB_EXTENSIONS = (".woff", ".woff2", ".eot", ".svg")
|
||||
|
|
@ -35,8 +35,8 @@ def font_dir_names():
|
|||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def formula_paths():
|
||||
"""All Formula/font-*.rb paths that exist."""
|
||||
if not FORMULA_DIR.exists():
|
||||
def cask_paths():
|
||||
"""All Casks/font-*.rb paths that exist."""
|
||||
if not CASKS_DIR.exists():
|
||||
return []
|
||||
return sorted(FORMULA_DIR.glob("font-*.rb"))
|
||||
return sorted(CASKS_DIR.glob("font-*.rb"))
|
||||
|
|
|
|||
|
|
@ -1,42 +1,61 @@
|
|||
"""Tests for formula content: correct paths and valid Ruby class name."""
|
||||
"""Tests for cask content: correct font paths and valid cask identifier."""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import FONT_FILES_DIR, FORMULA_DIR, get_font_dir_names
|
||||
from tests.conftest import CASKS_DIR, FONT_FILES_DIR, get_font_dir_names
|
||||
|
||||
|
||||
def formula_name_to_class(formula_name: str) -> str:
|
||||
"""Same logic as generator: formula name to PascalCase (no hyphens)."""
|
||||
parts = re.split(r"[-_]+", formula_name)
|
||||
return "".join(p.capitalize() for p in parts if p)
|
||||
def _skip_if_no_cask(font_name):
|
||||
cask_path = CASKS_DIR / f"{font_name}.rb"
|
||||
if not cask_path.exists():
|
||||
pytest.skip(f"No cask for {font_name} (no installable font files)")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("font_name", get_font_dir_names())
|
||||
def test_formula_references_correct_font_path(font_name):
|
||||
"""Generated formula contains the correct font_files/font-<name>/ path."""
|
||||
formula_path = FORMULA_DIR / f"{font_name}.rb"
|
||||
content = formula_path.read_text()
|
||||
# Install block should reference this font path
|
||||
def test_cask_references_correct_font_path(font_name):
|
||||
"""Generated cask contains the correct font_files/font-<name>/ path."""
|
||||
_skip_if_no_cask(font_name)
|
||||
cask_path = CASKS_DIR / f"{font_name}.rb"
|
||||
content = cask_path.read_text()
|
||||
assert f"font_files/{font_name}/" in content, (
|
||||
f"{font_name}: formula does not reference font_files/{font_name}/"
|
||||
f"{font_name}: cask does not reference font_files/{font_name}/"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("font_name", get_font_dir_names())
|
||||
def test_formula_class_name_valid_and_matches(font_name):
|
||||
"""Formula defines class Font<PascalCase> with no hyphens (valid Ruby)."""
|
||||
formula_path = FORMULA_DIR / f"{font_name}.rb"
|
||||
content = formula_path.read_text()
|
||||
formula_name = font_name.replace("font-", "", 1)
|
||||
expected_class = "Font" + formula_name_to_class(formula_name)
|
||||
# Class line: class FontSomething < Formula
|
||||
match = re.search(r"class\s+(Font\w+)\s+<\s+Formula", content)
|
||||
assert match, f"{font_name}: no 'class Font... < Formula' found"
|
||||
actual_class = match.group(1)
|
||||
assert "-" not in actual_class, (
|
||||
f"{font_name}: Ruby class name must not contain hyphens (got {actual_class})"
|
||||
)
|
||||
assert actual_class == expected_class, (
|
||||
f"{font_name}: expected class {expected_class}, got {actual_class}"
|
||||
def test_cask_identifier_matches(font_name):
|
||||
"""Cask declares the correct identifier matching the file name."""
|
||||
_skip_if_no_cask(font_name)
|
||||
cask_path = CASKS_DIR / f"{font_name}.rb"
|
||||
content = cask_path.read_text()
|
||||
match = re.search(r'cask\s+"([^"]+)"\s+do', content)
|
||||
assert match, f"{font_name}: no 'cask \"...\" do' found"
|
||||
assert match.group(1) == font_name, (
|
||||
f"{font_name}: expected cask \"{font_name}\", got \"{match.group(1)}\""
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("font_name", get_font_dir_names())
|
||||
def test_cask_has_font_artifacts(font_name):
|
||||
"""Cask declares at least one font artifact."""
|
||||
_skip_if_no_cask(font_name)
|
||||
cask_path = CASKS_DIR / f"{font_name}.rb"
|
||||
content = cask_path.read_text()
|
||||
font_lines = re.findall(r'^\s*font\s+"[^"]+"', content, re.MULTILINE)
|
||||
assert font_lines, f"{font_name}: cask has no font artifacts"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("font_name", get_font_dir_names())
|
||||
def test_cask_font_files_exist_on_disk(font_name):
|
||||
"""Every font artifact declared in the cask corresponds to a real file in font_files/."""
|
||||
_skip_if_no_cask(font_name)
|
||||
cask_path = CASKS_DIR / f"{font_name}.rb"
|
||||
content = cask_path.read_text()
|
||||
# Extract paths from font "..." lines
|
||||
paths = re.findall(r'^\s*font\s+"([^"]+)"', content, re.MULTILINE)
|
||||
for rel_path in paths:
|
||||
full_path = FONT_FILES_DIR.parent / rel_path
|
||||
assert full_path.is_file(), (
|
||||
f"{font_name}: font artifact references missing file: {rel_path}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,26 @@
|
|||
"""Tests that a Formula file exists for every font."""
|
||||
"""Tests that a Cask file exists for every font with installable files."""
|
||||
import pytest
|
||||
|
||||
from tests.conftest import FORMULA_DIR, get_font_dir_names
|
||||
from tests.conftest import CASKS_DIR, FONT_FILES_DIR, get_font_dir_names
|
||||
|
||||
INSTALLABLE_EXTS = (".ttf", ".otf", ".ttc")
|
||||
|
||||
|
||||
def _has_installable_fonts(font_name: str) -> bool:
|
||||
font_dir = FONT_FILES_DIR / font_name
|
||||
for ext in INSTALLABLE_EXTS:
|
||||
pattern = f"*{ext}"
|
||||
if any((font_dir / "ttf").glob(pattern)) or any((font_dir / "otf").glob(pattern)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("font_name", get_font_dir_names())
|
||||
def test_formula_file_exists(font_name):
|
||||
"""Formula/font-<name>.rb exists for each font folder."""
|
||||
# font_name is e.g. "font-acrylic-hand"; formula file is font-acrylic-hand.rb
|
||||
formula_path = FORMULA_DIR / f"{font_name}.rb"
|
||||
assert formula_path.is_file(), (
|
||||
f"Missing formula for {font_name}: expected {formula_path}"
|
||||
def test_cask_file_exists(font_name):
|
||||
"""Casks/font-<name>.rb exists for each font folder with installable fonts."""
|
||||
if not _has_installable_fonts(font_name):
|
||||
pytest.skip(f"{font_name} has no installable font files (TTF/OTF/TTC)")
|
||||
cask_path = CASKS_DIR / f"{font_name}.rb"
|
||||
assert cask_path.is_file(), (
|
||||
f"Missing cask for {font_name}: expected {cask_path}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,105 +1,25 @@
|
|||
"""Verify that each font's formula install logic would copy the right files (simulated install)."""
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
"""Verify that each font's cask references valid font files that exist."""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import (
|
||||
FONT_FILES_DIR,
|
||||
FORMULA_DIR,
|
||||
WEB_EXTENSIONS,
|
||||
get_font_dir_names,
|
||||
)
|
||||
|
||||
|
||||
def _run_install_simulation(font_name: str, font_dir: Path, prefix: Path, formula_name: str) -> None:
|
||||
"""
|
||||
Simulate the formula's install: copy ttf/otf/web/other_files to prefix
|
||||
as the formula would (same layout as homebrew-fonts/font_files/<font_name>/).
|
||||
"""
|
||||
# Layout formula expects: homebrew-fonts/font_files/<font_name>/{ttf,otf,web,other_files}
|
||||
# We use font_dir directly (same layout).
|
||||
(prefix / "fonts").mkdir(parents=True)
|
||||
(prefix / "fonts/truetype").mkdir(parents=True)
|
||||
(prefix / "fonts/opentype").mkdir(parents=True)
|
||||
(prefix / "fonts/webfonts").mkdir(parents=True)
|
||||
(prefix / formula_name).mkdir(parents=True)
|
||||
|
||||
for f in (font_dir / "ttf").glob("*.ttf"):
|
||||
shutil.copy2(f, prefix / "fonts/truetype" / f.name)
|
||||
for f in (font_dir / "otf").glob("*.otf"):
|
||||
shutil.copy2(f, prefix / "fonts/opentype" / f.name)
|
||||
web_dir = font_dir / "web"
|
||||
for f in web_dir.iterdir():
|
||||
if f.is_file() and f.suffix.lower() in WEB_EXTENSIONS:
|
||||
shutil.copy2(f, prefix / "fonts/webfonts" / f.name)
|
||||
other_src = font_dir / "other_files"
|
||||
if other_src.exists():
|
||||
for f in other_src.iterdir():
|
||||
dest = prefix / formula_name / f.name
|
||||
if f.is_dir():
|
||||
shutil.copytree(f, dest, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(f, dest)
|
||||
from tests.conftest import CASKS_DIR, FONT_FILES_DIR, get_font_dir_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("font_name", get_font_dir_names())
|
||||
def test_install_simulation_places_files(font_name):
|
||||
"""Simulate install for this font and assert expected files are present under prefix."""
|
||||
font_dir = FONT_FILES_DIR / font_name
|
||||
formula_name = font_name.replace("font-", "", 1)
|
||||
def test_cask_font_artifacts_are_installable(font_name):
|
||||
"""Each font artifact in the cask points to a real TTF or OTF file."""
|
||||
cask_path = CASKS_DIR / f"{font_name}.rb"
|
||||
if not cask_path.exists():
|
||||
pytest.skip(f"No cask for {font_name} (no TTF/OTF files)")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
prefix = Path(tmp) / "prefix"
|
||||
prefix.mkdir()
|
||||
_run_install_simulation(font_name, font_dir, prefix, formula_name)
|
||||
content = cask_path.read_text()
|
||||
paths = re.findall(r'^\s*font\s+"([^"]+)"', content, re.MULTILINE)
|
||||
assert paths, f"{font_name}: cask has no font artifacts"
|
||||
|
||||
# Same assertions as the formula's test block: at least one expected path has content
|
||||
ttf_files = list((prefix / "fonts/truetype").glob("*.ttf"))
|
||||
otf_files = list((prefix / "fonts/opentype").glob("*.otf"))
|
||||
web_files = list((prefix / "fonts/webfonts").iterdir()) if (prefix / "fonts/webfonts").exists() else []
|
||||
other_dir = prefix / formula_name
|
||||
has_other = other_dir.exists() and any(other_dir.iterdir())
|
||||
|
||||
has_ttf = len(ttf_files) > 0
|
||||
has_otf = len(otf_files) > 0
|
||||
has_web = len(web_files) > 0
|
||||
|
||||
assert has_ttf or has_otf or has_web or has_other, (
|
||||
f"{font_name}: after simulated install, no files in share/fonts/truetype, "
|
||||
f"share/fonts/opentype, share/fonts/webfonts, or share/{formula_name}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("font_name", get_font_dir_names())
|
||||
def test_install_simulation_file_counts_match_source(font_name):
|
||||
"""After simulated install, number of installed font files matches font_files/."""
|
||||
font_dir = FONT_FILES_DIR / font_name
|
||||
formula_name = font_name.replace("font-", "", 1)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
prefix = Path(tmp) / "prefix"
|
||||
prefix.mkdir()
|
||||
_run_install_simulation(font_name, font_dir, prefix, formula_name)
|
||||
|
||||
ttf_src = len(list((font_dir / "ttf").glob("*.ttf")))
|
||||
otf_src = len(list((font_dir / "otf").glob("*.otf")))
|
||||
web_src = sum(
|
||||
1 for f in (font_dir / "web").iterdir()
|
||||
if f.is_file() and f.suffix.lower() in WEB_EXTENSIONS
|
||||
)
|
||||
|
||||
ttf_installed = len(list((prefix / "fonts/truetype").glob("*.ttf")))
|
||||
otf_installed = len(list((prefix / "fonts/opentype").glob("*.otf")))
|
||||
web_installed = len(list((prefix / "fonts/webfonts").iterdir()))
|
||||
|
||||
assert ttf_src == ttf_installed, (
|
||||
f"{font_name}: TTF count mismatch: source={ttf_src}, installed={ttf_installed}"
|
||||
)
|
||||
assert otf_src == otf_installed, (
|
||||
f"{font_name}: OTF count mismatch: source={otf_src}, installed={otf_installed}"
|
||||
)
|
||||
assert web_src == web_installed, (
|
||||
f"{font_name}: web font count mismatch: source={web_src}, installed={web_installed}"
|
||||
for rel_path in paths:
|
||||
full_path = FONT_FILES_DIR.parent / rel_path
|
||||
assert full_path.is_file(), f"{font_name}: missing font file: {rel_path}"
|
||||
assert full_path.suffix.lower() in (".ttf", ".otf", ".ttc"), (
|
||||
f"{font_name}: unexpected font type: {full_path.suffix}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,34 +1,40 @@
|
|||
"""Global tests: no orphan formulae, no duplicate formula names."""
|
||||
import pytest
|
||||
|
||||
from tests.conftest import FONT_FILES_DIR, FORMULA_DIR, get_font_dir_names
|
||||
"""Global tests: no orphan casks, no duplicate cask names."""
|
||||
from tests.conftest import CASKS_DIR, FONT_FILES_DIR, get_font_dir_names
|
||||
|
||||
|
||||
def test_every_formula_has_matching_font_folder():
|
||||
"""Every Formula/font-*.rb has a matching font_files/font-<name>/ directory."""
|
||||
formula_files = sorted(FORMULA_DIR.glob("font-*.rb"))
|
||||
for formula_path in formula_files:
|
||||
name = formula_path.stem # e.g. font-acrylic-hand
|
||||
def test_every_cask_has_matching_font_folder():
|
||||
"""Every Casks/font-*.rb has a matching font_files/font-<name>/ directory."""
|
||||
cask_files = sorted(CASKS_DIR.glob("font-*.rb"))
|
||||
for cask_path in cask_files:
|
||||
name = cask_path.stem # e.g. font-acrylic-hand
|
||||
font_dir = FONT_FILES_DIR / name
|
||||
assert font_dir.is_dir(), (
|
||||
f"Orphan formula: {formula_path.name} has no matching font folder {font_dir}"
|
||||
f"Orphan cask: {cask_path.name} has no matching font folder {font_dir}"
|
||||
)
|
||||
|
||||
|
||||
def test_no_duplicate_formula_names():
|
||||
"""Only one formula file per font-<name> (no duplicate basenames)."""
|
||||
formula_files = list(FORMULA_DIR.glob("font-*.rb"))
|
||||
names = [p.stem for p in formula_files]
|
||||
def test_no_duplicate_cask_names():
|
||||
"""Only one cask file per font-<name> (no duplicate basenames)."""
|
||||
cask_files = list(CASKS_DIR.glob("font-*.rb"))
|
||||
names = [p.stem for p in cask_files]
|
||||
seen = set()
|
||||
for n in names:
|
||||
assert n not in seen, f"Duplicate formula name: {n}.rb"
|
||||
assert n not in seen, f"Duplicate cask name: {n}.rb"
|
||||
seen.add(n)
|
||||
|
||||
|
||||
def test_formula_count_matches_font_count():
|
||||
"""Number of font-*.rb formulae equals number of font-* directories."""
|
||||
def test_cask_count_matches_font_count():
|
||||
"""Number of font-*.rb casks equals number of font-* directories with TTF/OTF files."""
|
||||
font_names = get_font_dir_names()
|
||||
formula_count = len(list(FORMULA_DIR.glob("font-*.rb")))
|
||||
assert formula_count == len(font_names), (
|
||||
f"Formula count ({formula_count}) != font dir count ({len(font_names)})"
|
||||
# Only count fonts that have TTF or OTF files (casks skip fonts with no installable files)
|
||||
fonts_with_files = []
|
||||
for name in font_names:
|
||||
font_dir = FONT_FILES_DIR / name
|
||||
has_ttf = any((font_dir / "ttf").glob("*.ttf")) or any((font_dir / "ttf").glob("*.ttc"))
|
||||
has_otf = any((font_dir / "otf").glob("*.otf"))
|
||||
if has_ttf or has_otf:
|
||||
fonts_with_files.append(name)
|
||||
cask_count = len(list(CASKS_DIR.glob("font-*.rb")))
|
||||
assert cask_count == len(fonts_with_files), (
|
||||
f"Cask count ({cask_count}) != font dir count with TTF/OTF ({len(fonts_with_files)})"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue