This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
fontconfig-py is a Python package providing Cython-based bindings to the fontconfig library. The project builds statically-linked binary wheels that bundle fontconfig and freetype, eliminating runtime dependencies for users.
Key characteristics:
- Cython wrapper around fontconfig C library
- Statically linked binaries (bundles fontconfig and freetype)
- Currently supports Linux and macOS
- MIT license (with bundled dependencies under FTL and fontconfig licenses)
Cython Layer (src/fontconfig/)
fontconfig.pyx: Main Cython implementation exposing Python API_fontconfig.pxd: Cython declarations for fontconfig C API
Key Python Classes:
Config: Holds fontconfig configuration (default or custom)Pattern: Represents font patterns for matching/queryingFontSet: Container for lists of font patternsObjectSet: Defines which properties to return from queriesBlanks: Legacy Unicode blank character handling (deprecated)CharSet: Set of Unicode characters
High-level API (v0.3.0+):
Three main functions aligned with fontconfig core operations:
match(pattern, properties, select, config): Find the best matching font (wrapsFcFontMatch)sort(pattern, properties, select, trim, config): Get fonts sorted by match quality (wrapsFcFontSort)list(pattern, properties, select, config): List all matching fonts (wrapsFcFontList)
All functions support both pattern strings (:family=Arial:weight=200) and properties dicts ({"family": "Arial", "weight": 200}).
Deprecated:
query(where, select): Deprecated in v0.3.0, uselist()instead (ormatch()/sort()depending on use case)
The build process is more complex than typical Python packages due to static linking:
-
Third-party libraries (
third_party/as git submodules):freetype: Font rendering enginefontconfig: Font configuration library
-
Build script (
scripts/build_third_party.sh):- Builds freetype statically with minimal dependencies
- Builds fontconfig statically on top of freetype
- Installs to
/usr/local/(macOS) or system paths (Linux) - Handles platform-specific flags (universal2 for macOS)
-
Python build (
setup.py):- Uses Cython to compile
.pyxto C - Links against static fontconfig, freetype, expat, and zlib
- Package discovery via setuptools
- Uses Cython to compile
-
CI/CD (
.github/workflows/wheels.yaml):- Uses cibuildwheel for multi-platform wheel building
- Runs
build_third_party.shbefore Python build - Tests with pytest before uploading wheels
- Auto-publishes to PyPI on releases
Starting with v1.0.0, fontconfig-py is built using Python's Limited API (PEP 384), providing Stable ABI wheels.
Benefits:
- Forward compatibility: Single wheel supports Python 3.10, 3.11, 3.12, 3.13, 3.14+
- Reduced distribution: ~75% smaller total package size (3 wheels instead of 12+)
- Future-proof: Works with future Python versions without rebuilding
- Minimal overhead: < 5% performance impact for typical font queries
Technical Details:
- Uses
Py_LIMITED_API=0x030A0000(Python 3.10) in Cython build - Wheels tagged with
.abi3suffix for Stable ABI guarantee - Requires Cython ≥3.0.0 and setuptools ≥61.0
- Can be disabled with
FONTCONFIG_USE_LIMITED_API=0environment variable if needed
Build Configuration:
The Limited API is enabled by default in setup.py:
# Enable Limited API (can be disabled with env var for troubleshooting)
USE_LIMITED_API = os.getenv("FONTCONFIG_USE_LIMITED_API", "1") == "1"
PY_LIMITED_API_VERSION = 0x030A0000 # Python 3.10+
define_macros = [("Py_LIMITED_API", PY_LIMITED_API_VERSION)] if USE_LIMITED_API else []
py_limited_api = USE_LIMITED_APITo build without Limited API (for maximum performance or troubleshooting):
FONTCONFIG_USE_LIMITED_API=0 pip install --no-binary fontconfig-py fontconfig-py# Install with development dependencies
uv sync --dev
# Or install docs dependencies
uv sync --group docsImportant: Building requires system dependencies. You have two options:
Option 1: Use system packages (recommended for local development):
# macOS
brew install fontconfig freetype pkg-config
# Ubuntu/Debian
sudo apt-get install libfontconfig1-dev libfreetype6-dev pkg-config
# Then build the Python package
uv build --wheelOption 2: Build third-party libraries from source (CI environment):
# This script builds and installs fontconfig and freetype from submodules
# Primarily intended for CI environments
bash scripts/build_third_party.sh
# Then build the Python package
uv build --wheelThe build script installs: gperf, gettext, uuid libraries, automake, libtool (platform-dependent).
# Run all tests
uv run pytest tests/
# Run a single test file
uv run pytest tests/test_fontconfig.py
# Run a specific test
uv run tests/test_fontconfig.py::test_query -vTest fixtures:
- Tests use module-scoped
configfixture (current fontconfig config) - Tests use
patternfixture (":lang=en" pattern) - Tests use
object_setfixture with common properties
# Lint with ruff
uvx ruff check .
# Format with ruff
uvx ruff format .
# Check type hints
uv run mypy src/ tests/# Build documentation
cd docs/
make html
# View docs
open build/html/index.html # macOS
xdg-open build/html/index.html # LinuxKey patterns when editing .pyx files:
-
Memory management: C pointers must be manually freed
- Most wrapper classes store a
_ptrand use__dealloc__to free - Some objects have
_ownerflag to prevent double-free
- Most wrapper classes store a
-
Type conversions:
_ObjectToFcValue(): Python → fontconfig value_FcValueToObject(): fontconfig value → Python- String encoding: Python str ↔ UTF-8 bytes ↔
FcChar8*
-
C API patterns:
- Most fontconfig functions start with
Fc - Result codes:
FcResultMatch,FcResultNoMatch,FcResultOutOfMemory - Boolean type is
FcBool(C int, cast to Python bool)
- Most fontconfig functions start with
-
After editing
.pyxor.pxdfiles:# Rebuild extension uv sync --reinstall
# High-level API (recommended) - wraps FcFontMatch
font = fontconfig.match(":family=Arial:weight=200")
if font:
print(font["file"])
# Using properties dict
font = fontconfig.match(properties={"family": "Arial", "weight": 200})
# Custom properties to return
font = fontconfig.match(":family=Arial", select=("family", "file", "weight"))# High-level API (recommended) - wraps FcFontSort
fonts = fontconfig.sort(":family=Arial")
for font in fonts[:5]: # Top 5 matches
print(font["family"], font["file"])
# Using properties dict
fonts = fontconfig.sort(properties={"family": "Arial"})# High-level API (recommended) - wraps FcFontList
fonts = fontconfig.list(":lang=en", select=("family", "file"))
# Using properties dict
fonts = fontconfig.list(properties={"lang": ["en"]})
# List all fonts
fonts = fontconfig.list()# Direct access to Config/Pattern/ObjectSet for more control
config = fontconfig.Config.get_current()
pattern = fontconfig.Pattern.parse(":lang=en")
object_set = fontconfig.ObjectSet.create()
object_set.add("family")
fonts = config.font_list(pattern, object_set)
# Manual font matching with substitutions
pattern = fontconfig.Pattern.parse(":family=Arial")
pattern.default_substitute()
config.substitute(pattern)
matched = config.font_match(pattern)Build failures:
-
Ensure git submodules are initialized:
git submodule update --init --recursive -
Check system dependencies are installed (see
build_third_party.sh) -
On macOS, ensure Xcode command line tools are installed. The compiler cannot find fontconfig/freetype headers unless the flags are passed explicitly. Use
pkg-config(installed withbrew install pkg-config):CFLAGS=$(pkg-config --cflags fontconfig freetype2) \ LDFLAGS=$(pkg-config --libs fontconfig freetype2) \ uv sync
Test failures on "family=Arial":
- These tests may fail if Arial isn't installed on the system
- Consider using more universal test fonts or system defaults
Version mismatches:
- Package version is in
pyproject.toml __version__in__init__.pyshould match
Prerequisites: gh CLI (authenticated) and uv installed.
Every pull request should have an appropriate label so release-drafter can categorise it automatically. Available labels (defined in .github/release-drafter.yml):
| Label | CHANGELOG section |
|---|---|
breaking-change |
Breaking Changes |
feature, enhancement |
Added |
changed |
Changed |
bug |
Fixed |
documentation |
Documentation |
infrastructure, dependencies |
Infrastructure |
technical |
Technical |
skip-changelog |
(excluded — use for release PRs and internal changes) |
Release-drafter accumulates entries from merged PRs into a rolling draft release tagged draft-next on GitHub.
Check that the draft-next draft at https://github.com/CyberAgent/fontconfig-py/releases covers everything that will ship. Edit it directly on GitHub if any entry needs rewording before the release script runs.
bash scripts/release.sh vX.Y.ZThe script validates preconditions (clean tree, draft-next draft exists, no existing branch or tag), then:
- Creates branch
release/vX.Y.Zfrom an up-to-datemain - Bumps
__version__insrc/fontconfig/__init__.py - Writes the
draft-nextcontent into a new[X.Y.Z] - YYYY-MM-DDsection inCHANGELOG.md - Runs
uv sync - Commits, pushes, and opens a GitHub PR titled "Release vX.Y.Z" (labeled
skip-changelog)
- Wait for CI checks to pass
- Get code review approval
- Merge to
main— NEVER commit directly to main
Everything after the merge is automatic: the auto-release workflow (.github/workflows/auto-release.yaml) creates the git tag and GitHub Release from CHANGELOG.md, then deletes the stale draft-next draft. This triggers wheels.yaml to build wheels and publish to PyPI.
Important notes:
- Use
release/vX.Y.Zbranch naming (theauto-releaseworkflow keys on this prefix) - Just pushing a tag does NOT trigger PyPI upload — a GitHub Release must be published
- The
releaseenvironment in GitHub Actions may require manual approval before PyPI upload