Skip to content

Release v0.1.0 - #19

Merged
hasanzakeri merged 35 commits into
releasefrom
main
Apr 30, 2026
Merged

hasanzakeri merged 35 commits into
releasefrom
main

Conversation

@hasanzakeri

@hasanzakeri hasanzakeri commented Apr 30, 2026 •

Copy link
Copy Markdown
Owner

Changes

This is the first release of pyharfrust. It provides a complete, IDE-typed Python binding for the harfrust shaping
engine.

Core API

  • High-level shape(font_path, text, options) and run_from_args(argv) matching the hb-shape CLI.
  • Object API: Font (file or bytes), Buffer, GlyphBuffer, GlyphInfo, GlyphPosition. Buffers are consumed by
    shape() and recyclable via GlyphBuffer.clear().
  • Variable-font support via Font.set_variations() and Font.set_point_size().

Value types

  • Direction, Script, Language, Feature, Variation with string-based construction matching hb-shape syntax.

Packaging

  • PEP 561 type stubs (__init__.pyi, py.typed).
  • Wheels for Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x64 across CPython 3.11/3.12/3.13.
  • Tip-of-release tag gate, release-source-gate PR check, OIDC trusted publishing — see RELEASING.md.

Known limitations (deferred)

  • GlyphBuffer.__getitem__ doesn't accept slices.
  • GlyphInfo and GlyphPosition don't support value equality.
  • GlyphBuffer.serialize doesn't accept SerializeFlags.
  • No Font.set_named_instance(index) for variable fonts.
  • FontRef is re-parsed inside every Font.shape() / Font.units_per_em call (perf, not correctness).

Test plan

  • CI on this PR is green (Tier 1 + Tier 2 + release-source-gate).
  • After merge, tag the tip of release per RELEASING.md to trigger wheel build + PyPI publish.
  • Verify the published pyharfrust==0.1.0 installs and imports on a clean venv.

Minimal buildable PyO3 extension that imports successfully:
  - src/lib.rs: PyO3 module exposing __version__ from Cargo.toml
  - python/pyharfrust/__init__.py: re-exports __version__
  - tests/test_import.py: verifies import and version attribute
  - pyproject.toml: maturin backend, dev deps in dependency-groups,
    pyright config for native module resolution
  - Cargo.toml: renamed to pyharfrust/_pyharfrust, harfrust 0.5.2 + pyo3 0.28.3
  - .pre-commit-config.yaml: formatting on commit, clippy/tests on push
  - .gitignore: trimmed to project-relevant patterns
  - uv.lock and Cargo.lock committed for reproducible builds
  - github workflow checks for rust and python
Full Phase 1 foundation for the HarfRust Python
bindings: a buildable PyO3/maturin package that can be installed,
imported, and validated in CI. It sets up the Rust crate, Python
packaging metadata, initial extension module wiring, import smoke test,
lockfiles, and developer automation (pre-commit + CI) so future shaping
APIs can be added on a stable base.

### What’s included
- Rust crate scaffold for the extension module (`Cargo.toml`,
`src/lib.rs`, `Cargo.lock`)
- Python package scaffold and import surface
(`python/pyharfrust/__init__.py`, `pyproject.toml`)
- Initial test coverage for import/version smoke check
(`tests/test_import.py`)
- Project ignore and local dev configuration updates (`.gitignore`,
`.cargo/config.toml`)
- CI pipeline for formatting, linting, build, and tests
(`.github/workflows/ci.yml`)
  - Pre-commit hooks for local quality gates (`.pre-commit-config.yaml`)
  - Dependency lockfile for reproducible Python tooling (`uv.lock`)

### Validation
  - Extension builds with `maturin develop`
  - Import test passes with `pytest`
- Rust formatting/lint/test checks and Python lint/format/test checks
are wired in CI
  Expose harfrust's configuration types as Python classes with string
  parsing, properties, equality, and hashing. Script validates input
  as 4-letter ISO 15924 tags rather than inheriting harfrust's
  permissive parsing. All error messages include the offending input.
## Summary

Gives Python users the building blocks needed to configure text shaping:
Direction, Script, Language, Feature, and Variation. These are the types
that will flow into Buffer and Font in later phases.

Each type is constructed from a string using the same syntax as
harfbuzz/harfrust (e.g. `Feature("+kern")`, `Variation("wght=700")`,
`Direction("rtl")`), making the API familiar to anyone coming from the
harfbuzz ecosystem. Script enforces valid 4-letter ISO 15924 tags rather
than silently accepting garbage input.

This is an intentional Python API divergence from core harfrust's
lenient script parsing, so invalid script tags fail fast with a clear
ValueError.


## Test plan

- 46 passing tests covering construction, string round-tripping,
equality, hashing, error messages, and edge cases for all five types
- `maturin develop && pytest tests/ -v`
  Wrap hr_shape::shape() and hr_shape::run_from_args() as Python functions,
  stripping trailing newlines from hr-shape output. Add .tests file regression
  infrastructure with bundled test fonts and 6 verified test cases covering
  Latin, RTL, Indic, variable fonts, and mark attachment.
  - Drop redundant closures in shape.rs to satisfy clippy -D warnings.
  - Use a context manager for .tests file reading. Scaffold a pytest
  'external' marker so Tier 2 cases (from HARFRUST_SOURCE) can be
  filtered via -m external / -m "not external" once the Tier 2 parser
  lands.
Python can now shape text end-to-end through pyharfrust, and the project
has a regression-test foundation built around the same `.tests` file
format that harfbuzz and harfrust use upstream.

## What's conceptually done

- **Shaping is callable from Python.** `pyharfrust.shape(font, text,
options)` and `pyharfrust.run_from_args([...])` expose hr-shape's
high-level entry points as ordinary Python functions. This is
deliberately the *thin* path — the textual shape report, not a
structured buffer API — so we have a known-good oracle to test richer
bindings against as they get added.

- **A regression harness that speaks HarfBuzz's native test format.**
Tests are driven by `.tests` files, parametrized through a pytest
fixture. Each line becomes one test case, so expanding coverage is a
data change, not a code change.

- **Hermetic bundled suite in-repo.** A small set of fonts plus a
curated `bundled.tests` file lives alongside the tests so CI always has
something to run without reaching outside the repo. Cases cover Latin,
RTL reordering, Indic reordering, variable-font axis selection, and mark
attachment — a deliberately diverse set chosen to catch regressions in
the major shaping paths.

- **Hook for a future external suite.** The conftest already branches on
a `HARFRUST_SOURCE` env var and registers a pytest `external` marker, so
when the parser for the upstream harfrust suite lands it can be wired in
without restructuring the harness.

## Why this shape

The binding is intentionally minimal here. The goal wasn't to design the
Pythonic shaping API — that comes later — it was to prove the build/link
path works end-to-end from Python down to hr-shape, and to stand up the
regression scaffolding before adding surface area, so future work lands
against a real test suite instead of ad-hoc scripts.
  Introduces an opt-in regression mode that shapes every test case in a
  local harfrust checkout through pyharfrust.shape and compares against
  the expected output embedded in harfrust's own tests. Enabled by
  setting HARFRUST_SOURCE; default-denied in pytest config so local
  dev and the main-branch CI stay fast.

  CI runs Tier 2 only on push/PR targeting the release branch, against
  harfrust pinned at commit efdae31 (0.5.2) to match the hr-shape dep.
The `_collect_external_cases()` function was incorrectly parsing `.tests`
files from harfrust's `tests/custom/` directory. These files are source
inputs for harfrust's test generator (`gen-shaping-tests.py`), not actual
test cases to run. They contain expected values that may intentionally
differ from harfrust's current behavior, as noted in the files themselves:
"the expected values for the shaping process will be ignored and sometimes
wrong."

This caused false test failures, such as the `--language=pl` BigCaslon test
which expects `cacute.polish` but harfrust outputs `cacute` (confirmed by
running hr-shape CLI directly).

Now we only parse the generated `.rs` files in `tests/shaping/`, which
represent the actual tests that `cargo test` runs in harfrust.

Test count: 6145 → 6139 external cases (removed 6 invalid cases from .tests)
Get a high-confidence signal that `pyharfrust.shape` stays faithful to
upstream harfrust output — not just on the handful of cases we bundle,
but on the full shaping corpus harfrust itself uses to guard against
regressions.

Bundled Tier 1 tests are fast and self-contained, but they cover a tiny
slice of scripts and features. Every harfrust version bump is a chance
for subtle output drift that our ~6 bundled cases would never catch.
Tier 2 closes that gap.

## Approach  

**Opt-in, not always-on.** A harfrust checkout is a heavy dependency
(fonts, generated test files, a specific commit pin), and the suite
parametrizes into ~6 k cases. Making it the default would punish every
local `pytest` run and every `main`-branch CI job for a signal that only
matters at release time.

  The mechanism:  

  - Set `HARFRUST_SOURCE=/path/to/harfrust` to enable.
  - Cases are marked `@pytest.mark.external` at collection time.
- `pyproject.toml` default-denies via `addopts = "-m 'not external'"`,
so Tier 2 only runs when the marker filter is explicitly overridden
(`pytest -m external` or `-m ""`).
- A `MIN_EXTERNAL_CASES` floor ensures a silently-broken parser can't
pass by collecting zero cases.

**Parse, don't re-run.** harfrust's corpus lives in generated
`tests/shaping/*.rs` files containing literal `shape(...)` assertions.
We extract the `(font, text, options, expected)` tuples with a targeted
regex (handles `\u{XXXX}` escapes and line continuations), then compare
against `pyharfrust.shape` output. We intentionally skip the
`tests/custom/*.tests` source files — those are generator inputs that
may contain stale expected values.

**CI pinned to the shipped version.** Tier 2 runs on push/PR targeting
`release`, against harfrust checked out at commit `efdae31` (the 0.5.2
tag, matching the `hr-shape` version in `Cargo.toml`). Bumping the dep
means bumping the SHA in the same PR — corpus and shaper stay in
lockstep. Main-branch CI is unchanged and still fast.

  ## Why regex over an AST 

The `.rs` files are machine-generated with a rigid, uniform
`shape("...", "...", "..."), "..."` shape. A full Rust parser
(tree-sitter-rust, syn) would be overkill, add a heavy dep, and gain
nothing on content this regular. If harfrust's generator format ever
changes materially, the regex will fail loudly (via the min-case guard)
rather than silently drift.

  ## Out of scope 

- Vendoring the harfrust corpus into this repo (too large, and would
defeat the point of testing against upstream).
- Automating the harfrust SHA bump alongside `hr-shape` — manual step,
intentional coupling.
Update ROADMAP.md to use the pyharfrust package name (python/pyharfrust/
and pyharfrust.shape()) and point README.md at ROADMAP.md instead of
the renamed PLAN.md.
Update ROADMAP.md to use the pyharfrust package name (python/pyharfrust/
and pyharfrust.shape()) and point README.md at ROADMAP.md instead of the
renamed PLAN.md.
  Pythonic Buffer class backed by Option<UnicodeBuffer>, which
  establishes the consumption pattern for Phase 5 shape() without
  yet exposing a consumer. Covers the operations needed to drive
  shaping end-to-end: text accumulation, direction/script/language
  properties, guess_segment_properties, context setters, and clear.
  Adds a Buffer test covering set_not_found_variation_selector_glyph,
  which was exposed but previously unexercised. Marks the Direction
  label match inside Buffer.__repr__ with a comment pointing at the
  equivalent (private) match in PyDirection::__repr__, so the next
  edit to either keeps them in sync. Expands the TestContext header
  to spell out why pre/post context tests only assert that the call
  doesn't raise.
- Adds `Buffer` — the Pythonic entry point for feeding text into the
shaping pipeline.
- Wraps harfrust's `UnicodeBuffer` behind `Option<UnicodeBuffer>` so the
consumption pattern (`ValueError` on use-after-shape) is wired in before
any consumer exists. Phase 5's `Font.shape()` will call the `pub(crate)
take_inner` / `restore` hooks without further refactor.
- Exposes the operations Phase 5 will need: `add_str`, `add(codepoint,
cluster=0)`, `clear`, `reset_clusters`, `guess_segment_properties`,
`reserve`,`set_pre_context` / `set_post_context`, and `direction` /
`script` / `language` properties. `language` returns `None` when unset,
matching upstream.

  ## Out of scope 
- The actual buffer-consumption code path — has to wait for
`Font.shape()` in Phase 5.

  ## Test plan 
  - [x] `cargo fmt --all --check`
  - [x] `cargo clippy --all -- -D warnings`  
  - [x] `cargo test --all`
  - [x] `uv run maturin develop` 
  - [x] `uv run ruff check && uv run ruff format --check`
  - [x] `uv run pytest` — 81 passed (25 new, 56 pre-existing)
- Added `Font` class for loading and managing font data, including methods for creating instances from file paths and byte arrays.
- Introduced `GlyphBuffer`, `GlyphInfo`, and `GlyphPosition` classes to handle glyph shaping and positioning, providing a structured way to access glyph information.
- Updated `__init__.py` to include new classes in the module's public API.
- Enhanced `Buffer` class with methods for handling glyph data and serialization.
- Added comprehensive tests for the new font and glyph functionalities, ensuring correct behavior and integration with existing components.
- Updated `test_glyph_info_fields` to assert additional properties of glyph information, including cluster values for multiple glyphs.
- Introduced `test_from_bytes_matches_from_path` to verify that font data loaded from bytes matches that loaded from a file path.
- Adjusted error handling in `test_features_wrong_type` to ensure proper feature validation.
This PR adds the object-oriented shaping API: load fonts from path or
bytes, configure axis variations and point size, shape a `Buffer`, and
work with the resulting `GlyphInfo` / `GlyphPosition` data. Buffers can
be recycled through `GlyphBuffer.clear()`, and serialized output can be
matched to the high-level `shape()` helper for the cases we cover in
tests.

## What’s included

- **`Font`** — load via path (`Font(path)`) or `Font.from_bytes`,
optional `face_index`, `units_per_em`, `set_variations` (string or
`Variation` list), `set_point_size`, and `shape(buffer, features=…)`
with string or `Feature` list.
- **`GlyphBuffer`**, **`GlyphInfo`**, **`GlyphPosition`** — length,
index access, iteration as `(info, pos)` pairs, `glyph_infos` /
`glyph_positions` getters, and `serialize(font)` for HarfRust-style
glyph strings.
- **Safety / UX** — clear errors when the buffer’s direction is still
invalid before shaping, and explicit consumption semantics for `Buffer`
(after `shape`) and `GlyphBuffer` (after `clear()`) to match the
owned-container design.
- **Tests** — construction, shaping, iteration, feature and variation
options, point size, serialize parity with `pyharfrust.shape`, and
buffer consumption / recycling.
  Ship `__init__.pyi` covering every public type and function with property
  signatures, exception notes, and `Sequence[Feature] | str | None` unions
  that match the runtime accepted shapes. Add an empty `py.typed` marker so
  type checkers pick up the stubs from an installed wheel — maturin already
  includes both files from `python-source` without further config.

  Suppress pyright on the five negative-type test lines that intentionally
  pass wrong types to verify TypeError.

  Rewrite the README around installation, the two-tier API (high-level
  `shape()` and the `Font` / `Buffer` / `GlyphBuffer` object API), variable
  fonts, buffer recycling, and the error taxonomy.
…Buffer behavior. Added example for `run_from_args(argv)` to demonstrate full CLI parity with `hb-shape`. Updated description of `GlyphBuffer.clear()` to specify its consumption behavior and error handling.
This pull request introduces comprehensive improvements and
clarifications to the Python API and documentation for the `pyharfrust`
package. The main changes include the addition of a detailed
installation and usage guide in the `README.md`, the introduction of a
complete type stub file (`__init__.pyi`) for type checking and editor
support, and minor test adjustments to suppress type checker warnings.

**Documentation and API improvements:**

* Added a detailed installation, quick start, and advanced usage guide
to `README.md`, covering building from source, high-level and
object-oriented shaping APIs, configuration types, variable fonts,
buffer recycling, and error handling.
* Added a complete PEP 561 type stub file
`python/pyharfrust/__init__.pyi`, describing all public classes,
methods, properties, and exceptions, enabling type checkers and editors
to offer full API support.
  New `release.yml`: tag-push (`v*`) builds wheels for Linux x86_64/aarch64,
  macOS x86_64/aarch64, and Windows x64 across CPython 3.11/3.12/3.13, plus
  an sdist. The `publish` job uploads to PyPI via OIDC trusted publishing
  and uses `skip-existing` so re-runs are idempotent.

  The `gate` job rejects any tag whose SHA isn't the current tip of
  `release`. Combined with branch protection requiring `ci` to pass on
  `release`, that turns "tip of release" into a hard guarantee that full
  CI (Tier 1 + Tier 2) ran on the exact SHA being released — so the
  release workflow doesn't re-run tests itself.

  Add a `release-source-gate` job to `ci.yml` that fails any PR targeting
  `release` whose head ref isn't `main`. GitHub branch protection has no
  "restrict source branch" setting, so we enforce it as a required check.

  `RELEASING.md` documents guarantees (branch protection rules,
  PyPI trusted publisher) and the per-release procedure, plus recovery
  from a lost tag race.
This pull request introduces a robust and secure release process by
adding new workflows and documentation to enforce stricter controls over
how releases are created and published. The changes ensure that releases
are only made from well-tested code, prevent accidental or unauthorized
modifications to the release branch, and provide clear instructions for
maintainers.

Key changes include:

**Release Process Enforcement:**
- Added a `release-source-gate` job to `.github/workflows/ci.yml` to
ensure that pull requests to the `release` branch can only come from the
`main` branch, blocking all other sources.
- Introduced a new `.github/workflows/release.yml` workflow, which:
- Ensures release tags only point to the tip of the `release` branch via
a gating job.
- Builds and uploads Python wheels for Linux, macOS, and Windows (across
multiple architectures and Python versions), as well as a source
distribution.
- Publishes artifacts to PyPI using trusted publishing with OIDC, and
only after all builds succeed.

**Documentation:**
- Added a comprehensive `RELEASING.md` file detailing the release
process, the guarantees provided by the new checks and workflows, and
troubleshooting steps for common issues.
- Modified `.pre-commit-config.yaml` to enforce checks on both `main` and `release` branches.
- Updated `.github/workflows/ci.yml` to pin the `harfrust` repository reference to a specific commit for consistency in CI testing.
- Modified `.pre-commit-config.yaml` to enforce checks on both `main`
and `release` branches.
- Updated `.github/workflows/ci.yml` to pin the `harfrust` repository
reference to a specific commit for consistency in CI testing.
@hasanzakeri
hasanzakeri merged commit 598f101 into release Apr 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant