diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..226b310 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + push: + branches: [main, release] + pull_request: + branches: [main, release] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + release-source-gate: + # PRs targeting `release` are only allowed from `main`. GitHub branch + # protection has no "restrict source branch" setting, so we enforce it + # here as a required check. Make this a required status check on the + # `release` branch so the PR can't merge without it. + if: github.event_name == 'pull_request' && github.base_ref == 'release' + runs-on: ubuntu-latest + steps: + - name: PRs to release must come from main + run: | + if [ "$GITHUB_HEAD_REF" != "main" ]; then + echo "::error::PRs to release must come from main, got '$GITHUB_HEAD_REF'." + exit 1 + fi + + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Rust formatting + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --all -- -D warnings + + - name: Rust tests + run: cargo test --all + + - name: Install Python deps + run: uv sync --locked + + - name: Build extension + run: uv run maturin develop + + - name: Ruff check + run: uv run ruff check + + - name: Ruff format + run: uv run ruff format --check + + - name: Python tests + run: uv run pytest + + tier2: + # Tier 2: external regression against the full harfrust shaping corpus. + # Only runs on the release branch, where we care about version-drift signal. + if: github.ref == 'refs/heads/release' || github.base_ref == 'release' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check out harfrust at 0.5.2 + uses: actions/checkout@v6 + with: + repository: harfbuzz/harfrust + # Pinned to the 0.5.2 commit so the test corpus matches the hr-shape + # version declared in Cargo.toml. Bump this alongside the dep. + ref: efdae3142ab76a2f1524d72cff9e3dfdc5afd7ca + path: harfrust-external + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install Python deps + run: uv sync --locked + + - name: Build extension + run: uv run maturin develop + + - name: Tier 2 shaping regression + env: + HARFRUST_SOURCE: ${{ github.workspace }}/harfrust-external + run: uv run pytest -m external diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b850f6e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,143 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + gate: + # Enforce that the tag points to the current tip of `release`. The + # release-branch CI (ci.yml) runs Tier 1 + Tier 2 on every push to + # release, so tip-of-release is the SHA on which full CI passed. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Tag must point to the tip of release + run: | + git fetch origin release --depth=1 + expected=$(git rev-parse FETCH_HEAD) + if [ "$GITHUB_SHA" != "$expected" ]; then + echo "::error::Tag $GITHUB_REF_NAME ($GITHUB_SHA) is not the tip of release ($expected). See RELEASING.md." + exit 1 + fi + + linux: + runs-on: ubuntu-latest + needs: gate + strategy: + fail-fast: false + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v6 + + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + manylinux: auto + args: --release --out dist -i python3.11 -i python3.12 -i python3.13 + + - uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.target }} + path: dist + + macos: + needs: gate + strategy: + fail-fast: false + matrix: + target: [x86_64, aarch64] + python-version: ['3.11', '3.12', '3.13'] + include: + - target: x86_64 + runner: macos-13 + - target: aarch64 + runner: macos-14 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist -i python${{ matrix.python-version }} + + - uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.target }}-py${{ matrix.python-version }} + path: dist + + windows: + runs-on: windows-latest + needs: gate + strategy: + fail-fast: false + matrix: + target: [x64] + python-version: ['3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + architecture: ${{ matrix.target }} + + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist + + - uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.target }}-py${{ matrix.python-version }} + path: dist + + sdist: + runs-on: ubuntu-latest + needs: gate + steps: + - uses: actions/checkout@v6 + + - uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + + - uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist + + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: [linux, macos, windows, sdist] + if: startsWith(github.ref, 'refs/tags/v') + environment: + name: pypi + url: https://pypi.org/p/pyharfrust + permissions: + # Required for PyPI trusted publishing (OIDC). Configure the publisher + # at https://pypi.org/manage/account/publishing/ before tagging a release. + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4332ceb --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Local Cargo config (dev overrides for sibling checkouts) +.cargo/config.toml + +# Rust +target/ +**/*.rs.bk + +# Python +__pycache__/ +*.py[cod] +*.so +*.pyd +*.egg-info/ + +# Environments +.venv/ + +# Testing +.pytest_cache/ + +**/*.dSYM diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..818965f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,65 @@ +default_install_hook_types: [pre-commit, pre-push] +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: no-commit-to-branch + args: [--branch, main, --branch, release] + +- repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.11.3 + hooks: + - id: uv-lock + args: [--locked] + +# pre-commit: fast formatting and lint checks only +- repo: local + hooks: + - id: cargo-fmt + name: cargo fmt + entry: cargo fmt --all --check + language: system + types: [rust] + pass_filenames: false + + - id: ruff-check + name: ruff check + entry: uv run ruff check --fix + language: system + types: [python] + + - id: ruff-format + name: ruff format + entry: uv run ruff format --force-exclude + language: system + types: [python] + +# pre-push: compilation, linting, and tests + - id: cargo-clippy + name: cargo clippy + entry: cargo clippy --all -- -D warnings + language: system + types: [rust] + pass_filenames: false + stages: [pre-push] + + - id: cargo-test + name: cargo test + entry: cargo test --all + language: system + types: [rust] + pass_filenames: false + stages: [pre-push] + + - id: pytest + name: pytest + entry: uv run pytest + language: system + types_or: [python, rust] + pass_filenames: false + stages: [pre-push] diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..158ef91 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,360 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "font-types" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9237c6d82152100c691fb77ea18037b402bcc7257d2c876a4ffac81bc22a1c" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags", + "bytemuck", + "core_maths", + "read-fonts", + "smallvec", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hr-shape" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f915467aa5ab450c6d5474df0b35be7609b543b273db540dd41a49a9c21c0576" +dependencies = [ + "clap", + "harfrust", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyharfrust" +version = "0.1.0" +dependencies = [ + "harfrust", + "hr-shape", + "pyo3", +] + +[[package]] +name = "pyo3" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0787ae5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pyharfrust" +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +license = "MIT" + +[lib] +name = "_pyharfrust" +crate-type = ["cdylib"] + +[dependencies] +harfrust = { version = "0.5.2", features = ["std"] } +hr-shape = { version = "0.5.2" } +pyo3 = { version = "0.28.3", features = ["extension-module"] } diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7dbb3f1 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2026 Hasan Zakeri + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d75d5e9 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# harfrust-python + +Python bindings for [HarfRust](https://github.com/nickkuk/harfrust), a pure-Rust port of the [HarfBuzz](https://harfbuzz.github.io/) text shaping engine. + +## What is text shaping? + +Text shaping is the process of converting a sequence of Unicode codepoints into positioned glyphs — selecting the right glyph forms, applying ligatures, kerning, and reordering as required by the script. It is a critical step in any text rendering pipeline, especially for complex scripts like Arabic, Devanagari, and Thai. + +HarfBuzz is the industry-standard text shaping engine used by Firefox, Chrome, Android, and many other platforms. HarfRust is a faithful pure-Rust port of HarfBuzz, and this project aims to make that engine accessible from Python. + +## Goals + +- **Standalone Python package** — a proper, independently usable Python library for text shaping, not just a test utility. +- **Two-tier API** — a high-level `shape()` function for quick one-shot shaping, and a lower-level object API (`Font`, `Buffer`, `GlyphBuffer`) for full control over the shaping pipeline. +- **Pythonic interface** — string-based construction for configuration types (`Direction("rtl")`, `Feature("+kern")`, `Variation("wght=700")`), iteration over glyph results, and clear error messages. +- **HarfBuzz test compatibility** — ability to run against HarfBuzz's `.tests` regression format, enabling direct comparison between the two engines. + +## Installation + +Wheels are not yet published. To build from source you need a Rust toolchain (>= 1.85) and Python (>= 3.11): + +```bash +git clone https://github.com/hasanzakeri/harfrust-python.git +cd harfrust-python +pip install maturin +maturin develop --release +``` + +The package is imported as `pyharfrust`: + +```python +import pyharfrust +print(pyharfrust.__version__) +``` + +PEP 561 type stubs (`__init__.pyi`, `py.typed`) ship with the package, so editors and type checkers see the full API. + +## Quick start + +Two ways to shape a string. Both produce the same output. + +### High-level `shape()` function + +```python +from pyharfrust import shape + +result = shape("path/to/font.ttf", "Hello World", "") +# "[H=0+733|e=1+598|l=2+336|l=3+336|o=4+631|space=5+272|W=6+871|o=7+631|r=8+380|l=9+336|d=10+629]" +``` + +The third argument accepts the same flags as the `hb-shape` CLI: + +```python +shape("font.ttf", "AB", "--features=+kern,-liga --direction=ltr") +``` + +For full CLI parity, `run_from_args(argv)` accepts the same argument list as `hb-shape` — `argv[0]` is the program name, the rest are flags: + +```python +from pyharfrust import run_from_args +run_from_args(["hb-shape", "--font-file=font.ttf", "--features=+kern", "Hello"]) +``` + +### Object API + +For repeated shaping, font configuration, or access to per-glyph metadata, use the object API: + +```python +from pyharfrust import Buffer, Feature, Font + +font = Font("path/to/font.ttf") + +buf = Buffer() +buf.add_str("Hello World") +buf.guess_segment_properties() # infers direction/script/language + +glyphs = font.shape(buf, features=[Feature("+kern")]) + +for info, pos in glyphs: + print(f"glyph={info.glyph_id} cluster={info.cluster} " + f"advance=({pos.x_advance},{pos.y_advance}) " + f"offset=({pos.x_offset},{pos.y_offset})") +``` + +The `serialize()` method produces the same string format as the high-level `shape()` function: + +```python +print(glyphs.serialize(font)) +``` + +## Configuration types + +All configuration types accept either a string or their structured form. Strings parse with the same syntax as `hb-shape`: + +```python +from pyharfrust import Direction, Feature, Language, Script, Variation + +Direction("ltr") # or Direction.LTR +Script("Latn") # 4-letter ISO 15924 tag +Language("en-US") +Feature("+kern") # enable; "-liga" disables; "kern[3:5]=2" applies a range +Variation("wght=700") # variable-font axis setting +``` + +## Variable fonts + +```python +from pyharfrust import Font, Variation + +font = Font("variable.ttf") +font.set_variations([Variation("wght=700"), Variation("wdth=85")]) +# or +font.set_variations("wght=700,wdth=85") + +# Reset to defaults: +font.set_variations([]) +``` + +## Buffer recycling + +Buffers are consumed by `shape()`. Recycle them via `GlyphBuffer.clear()`: + +```python +from pyharfrust import Buffer, Font + +font = Font("font.ttf") +buf = Buffer() +buf.add_str("First") +buf.guess_segment_properties() +glyphs = font.shape(buf) + +# Reuse the same allocation for a new shaping call: +buf = glyphs.clear() +buf.add_str("Second") +buf.guess_segment_properties() +glyphs = font.shape(buf) +``` + +`GlyphBuffer.clear()` consumes the glyph buffer: any further access to the original `glyphs` instance — including a second `clear()` — raises `ValueError`. The same applies to a `Buffer` once it has been passed to `shape()`. + +## Errors + +- `RuntimeError` — font cannot be loaded or parsed. +- `ValueError` — invalid string input (`Direction("xyz")`, `Feature("=")`), unset buffer direction at shape time, or use of an already-consumed buffer. +- `TypeError` — wrong argument types (e.g. assigning a string to `Buffer.direction`). + +## Technical Approach + +- **PyO3 + maturin** — the standard modern toolchain for building Rust extensions for Python. +- **Owned-container pattern** — the Python `Font` object owns all its backing data, with transient Rust borrows scoped to individual method calls. This cleanly bridges Rust's lifetime system and Python's garbage-collected memory model. +- **Standalone project** — not a member of the harfrust Cargo workspace, allowing an independent release cadence and CI configuration. + +## Status + +This project is in early development. See the [development plan](https://github.com/hasanzakeri/harfrust-python/blob/main/ROADMAP.md) for the phased roadmap. + +## License + +MIT diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..3e49cb2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,57 @@ +# Releasing + +Releases are cut from the `release` branch. The `ci.yml` workflow runs full +CI (Tier 1 + Tier 2 against the pinned harfrust corpus) on every push to +`release`. The `release.yml` workflow gates on tag-equals-release-tip, so +the SHA being released has demonstrably passed full CI. + +## Guarantees in place + +- **Branch protection on `release`** — direct pushes blocked, all commits + must come through a PR with the `ci` and `release-source-gate` checks + passing, branch must be up to date with base, no force pushes. +- **`release-source-gate` job (`ci.yml`)** — rejects any PR targeting + `release` whose head ref isn't `main`. Required check. +- **`gate` job (`release.yml`)** — rejects any tag whose SHA isn't the + current tip of `release`. Together with the protections above, this + means tip-of-release = "full CI + Tier 2 passed on this exact SHA." +- **PyPI trusted publishing** — the `publish` job uploads via OIDC, no + API tokens in GitHub secrets. + +## Release procedure + +1. Open a PR from `main` to `release`. Wait for CI (including Tier 2) to + go green. Merge. +2. Tag the tip of `release`: + ```bash + git checkout release + git pull --ff-only + git tag -a vX.Y.Z -m "vX.Y.Z" + git push origin vX.Y.Z + ``` +3. The tag push triggers `release.yml`. The `gate` job verifies + `vX.Y.Z` points to the current tip of `release`. If it doesn't, the + whole workflow fails — fix and re-tag. +4. Wheel jobs build for Linux (x86_64, aarch64), macOS (x86_64, aarch64), + Windows (x64) across CPython 3.11/3.12/3.13. The `sdist` job builds + the source distribution. +5. The `publish` job uploads everything to PyPI via OIDC. `skip-existing` + makes re-runs idempotent. + +## Common issues + +**Gate fails: "tag is not the tip of release."** Someone else merged to +`release` between when you fetched and when you pushed the tag, or you +tagged the wrong branch. Delete the tag locally and on origin, pull +`release`, retag. + +```bash +git tag -d vX.Y.Z +git push origin :refs/tags/vX.Y.Z +git checkout release && git pull --ff-only +git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z +``` + +**Publish fails on PyPI.** Most often the version in `pyproject.toml` +hasn't been bumped (PyPI rejects re-uploads of an existing version). +Bump the version on `main`, merge to `release`, retag. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a568f22 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,43 @@ +# Development Plan + +## Phase 1 — Project Skeleton + +Buildable Python package that imports successfully. Set up `Cargo.toml` (PyO3 cdylib), `pyproject.toml` (maturin backend), a minimal `src/lib.rs` with a version function, and `python/pyharfrust/__init__.py` that re-exports it. Include an MIT `LICENSE` file and a GitHub Actions CI workflow (`maturin develop` + `pytest`). + +**Done when:** `maturin develop && pytest` passes with a single `test_import` test, LICENSE exists, and CI is green. + +## Phase 2 — Value Types + +Expose `Direction`, `Script`, `Language`, `Feature`, and `Variation` as Python classes. Each wraps the corresponding harfrust type and uses its `FromStr` impl for string-based construction (e.g. `Direction("rtl")`, `Feature("+kern")`, `Variation("wght=700")`). + +**Done when:** types can be constructed, compared, stringified, and their properties accessed from Python. + +## Phase 3 — High-Level `shape()` Function + +Wrap `hr_shape::shape()` and `hr_shape::run_from_args()` as top-level Python functions. Set up a `.tests` regression suite with bundled test fonts and data, plus optional discovery of an external harfrust checkout via `HARFRUST_SOURCE`. + +**Done when:** `pyharfrust.shape(font_path, text, options)` returns a serialized glyph string and bundled `.tests` cases pass. + +## Phase 4 — Buffer Class + +Expose `UnicodeBuffer` as a Python `Buffer` class with `add_str()`, direction/script/language properties, `guess_segment_properties()`, and `len()`. Uses `Option` internally to track consumption by `shape()`. + +**Done when:** buffers can be created, populated, configured, and passed to shaping. + +## Phase 5 — Font Class and Object-Level Shaping + +Full object API: `Font` (owned-container pattern), `GlyphBuffer`, `GlyphInfo`, `GlyphPosition`. Supports loading from file or bytes, setting variations and point size, shaping a buffer, iterating results, serializing output, and recycling buffers via `GlyphBuffer.clear()`. + +**Done when:** the object API produces output identical to the `shape()` string function, and buffer consumption/recycling works correctly. + +## Phase 6 — Type Stubs, Polish, and Packaging + +Add PEP 561 type stubs (`__init__.pyi`, `py.typed`) and expand the README with usage examples and installation instructions. + +**Done when:** the package is publishable with full IDE autocomplete support. + +## Phase 7 — shapecmp Test Harness + +Separate pure-Python project (`shapecmp-python/`) that compares harfrust vs harfbuzz shaping output across font/text corpora. Includes a CLI for single comparisons, batch `.tests` runs, corpus sweeps, failure minimization, and `.tests` file emission. + +**Done when:** `shapecmp run-tests` can execute HarfBuzz `.tests` files against both engines and report differences. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a2a7e81 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["maturin>=1.8"] +build-backend = "maturin" + +[project] +name = "pyharfrust" +version = "0.1.0" +description = "Python bindings for the HarfRust text shaping engine" +requires-python = ">=3.11" +license = "MIT" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", +] +dependencies = [] + +[dependency-groups] +dev = [ + "maturin>=1.12.6", + "pre-commit>=4.3.0", + "pyright>=1.1.408", + "pytest>=9.0.2", + "ruff>=0.15.9", +] + +[tool.maturin] +features = ["pyo3/extension-module"] +python-source = "python" +module-name = "pyharfrust._pyharfrust" + +[tool.pyright] +pythonPlatform = "All" +typeCheckingMode = "basic" +reportMissingModuleSource = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-m 'not external'" +markers = [ + "external: tests that require an external harfrust checkout via HARFRUST_SOURCE", +] diff --git a/python/pyharfrust/__init__.py b/python/pyharfrust/__init__.py new file mode 100644 index 0000000..f65e703 --- /dev/null +++ b/python/pyharfrust/__init__.py @@ -0,0 +1,31 @@ +from pyharfrust._pyharfrust import ( + __version__, + Buffer, + Direction, + Feature, + Font, + GlyphBuffer, + GlyphInfo, + GlyphPosition, + Language, + Script, + Variation, + run_from_args, + shape, +) + +__all__ = [ + "__version__", + "Buffer", + "Direction", + "Feature", + "Font", + "GlyphBuffer", + "GlyphInfo", + "GlyphPosition", + "Language", + "Script", + "Variation", + "run_from_args", + "shape", +] diff --git a/python/pyharfrust/__init__.pyi b/python/pyharfrust/__init__.pyi new file mode 100644 index 0000000..a20bebc --- /dev/null +++ b/python/pyharfrust/__init__.pyi @@ -0,0 +1,290 @@ +from collections.abc import Iterator, Sequence +from typing import ClassVar, final + +__version__: str + +# --------------------------------------------------------------------------- +# Value types +# --------------------------------------------------------------------------- + +@final +class Direction: + """Text direction: left-to-right, right-to-left, top-to-bottom, bottom-to-top.""" + + LTR: ClassVar[Direction] + RTL: ClassVar[Direction] + TTB: ClassVar[Direction] + BTT: ClassVar[Direction] + + def __init__(self, s: str) -> None: + """Parse a direction string ("ltr", "rtl", "ttb", "btt"; case-insensitive). + + Raises ``ValueError`` on an unknown string. + """ + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +@final +class Script: + """An ISO 15924 script tag (e.g. "Latn", "Arab", "Deva").""" + + def __init__(self, s: str) -> None: + """Construct from a 4-letter ISO 15924 tag. + + Raises ``ValueError`` if the input is not 4 ASCII letters. + """ + + @property + def tag(self) -> str: ... + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +@final +class Language: + """A BCP 47 language tag (e.g. "en", "ar", "en-US").""" + + def __init__(self, s: str) -> None: + """Parse a BCP 47 language tag. Raises ``ValueError`` on empty input.""" + + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +@final +class Feature: + """An OpenType feature setting (e.g. "+kern", "-liga", "kern[3:5]=2").""" + + def __init__(self, s: str) -> None: + """Parse a feature string. Raises ``ValueError`` on invalid syntax.""" + + @property + def tag(self) -> str: ... + @property + def value(self) -> int: ... + @property + def start(self) -> int: ... + @property + def end(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +@final +class Variation: + """A font variation axis setting (e.g. "wght=700", "wdth=85.5"). + + Not hashable: variation values are floats. + """ + + def __init__(self, s: str) -> None: + """Parse a variation string. Raises ``ValueError`` on invalid syntax.""" + + @property + def tag(self) -> str: ... + @property + def value(self) -> float: ... + def __eq__(self, other: object) -> bool: ... + __hash__: ClassVar[None] # type: ignore[assignment] + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +# --------------------------------------------------------------------------- +# Buffer +# --------------------------------------------------------------------------- + +@final +class Buffer: + """Mutable text buffer used as input to shaping. + + A ``Buffer`` is consumed when passed to :meth:`Font.shape` — any further + use raises ``ValueError``. Reuse a buffer by recycling it through + :meth:`GlyphBuffer.clear`. + """ + + def __init__(self) -> None: ... + def __len__(self) -> int: ... + def __repr__(self) -> str: ... + def add_str(self, s: str) -> None: + """Append a Unicode string to the buffer.""" + + def add(self, codepoint: int, cluster: int = 0) -> None: + """Append a single Unicode codepoint with an explicit cluster value.""" + + def clear(self) -> None: + """Drop all codepoints and reset segment properties.""" + + def reset_clusters(self) -> None: + """Reset cluster values to be sequential (0, 1, 2, ...).""" + + def guess_segment_properties(self) -> None: + """Guess direction, script, and language from the buffer contents.""" + + def reserve(self, size: int) -> bool: + """Reserve capacity for at least ``size`` items.""" + + def set_pre_context(self, s: str) -> None: + """Set context preceding the buffer for shaping decisions.""" + + def set_post_context(self, s: str) -> None: + """Set context following the buffer for shaping decisions.""" + + def set_not_found_variation_selector_glyph(self, glyph: int) -> None: + """Set the glyph emitted for a variation selector with no match.""" + + @property + def direction(self) -> Direction: ... + @direction.setter + def direction(self, value: Direction) -> None: ... + @property + def script(self) -> Script: ... + @script.setter + def script(self, value: Script) -> None: ... + @property + def language(self) -> Language | None: ... + @language.setter + def language(self, value: Language) -> None: ... + +# --------------------------------------------------------------------------- +# Glyphs +# --------------------------------------------------------------------------- + +@final +class GlyphInfo: + """Read-only information about a shaped glyph. + + Snapshot value type: properties reflect the underlying glyph at the time + the info was retrieved from a :class:`GlyphBuffer`. + """ + + @property + def glyph_id(self) -> int: ... + @property + def cluster(self) -> int: ... + @property + def unsafe_to_break(self) -> bool: ... + @property + def unsafe_to_concat(self) -> bool: ... + @property + def safe_to_insert_tatweel(self) -> bool: ... + def __repr__(self) -> str: ... + +@final +class GlyphPosition: + """Read-only positioning information for a shaped glyph (font units).""" + + @property + def x_advance(self) -> int: ... + @property + def y_advance(self) -> int: ... + @property + def x_offset(self) -> int: ... + @property + def y_offset(self) -> int: ... + def __repr__(self) -> str: ... + +@final +class GlyphBuffer: + """Output of :meth:`Font.shape`: a sequence of glyphs and their positions. + + Iterating yields ``(GlyphInfo, GlyphPosition)`` pairs. The buffer is + consumed by :meth:`clear`, which returns a recycled :class:`Buffer`. + """ + + def __len__(self) -> int: ... + def __getitem__(self, index: int) -> tuple[GlyphInfo, GlyphPosition]: ... + def __iter__(self) -> Iterator[tuple[GlyphInfo, GlyphPosition]]: ... + def __repr__(self) -> str: ... + @property + def glyph_infos(self) -> list[GlyphInfo]: ... + @property + def glyph_positions(self) -> list[GlyphPosition]: ... + def clear(self) -> Buffer: + """Consume this glyph buffer and return its underlying buffer for reuse.""" + + def serialize(self, font: Font) -> str: + """Format the shaped glyphs as a string matching ``shape()`` output.""" + +# --------------------------------------------------------------------------- +# Font +# --------------------------------------------------------------------------- + +@final +class Font: + """A loaded font face used for shaping. + + Owns the font bytes and shaping data. Variations and point size can be + adjusted between shape calls. + """ + + def __init__(self, path: str, face_index: int = 0) -> None: + """Load a font from the filesystem. + + Raises ``RuntimeError`` if the file cannot be read or parsed. + """ + + @staticmethod + def from_bytes(data: bytes, face_index: int = 0) -> Font: + """Load a font from in-memory bytes. + + Raises ``RuntimeError`` if the data cannot be parsed as a font. + """ + + @property + def face_index(self) -> int: ... + @property + def units_per_em(self) -> int: ... + def set_variations(self, variations: Sequence[Variation] | str) -> None: + """Set the active variation axes. An empty sequence resets to defaults. + + Raises ``ValueError`` if a string contains an unparseable variation, + or ``TypeError`` if the argument is neither a string nor a sequence + of :class:`Variation` objects. + """ + + def set_point_size(self, size: float | None) -> None: + """Set the active point size, or clear it with ``None``.""" + + def shape( + self, + buffer: Buffer, + features: Sequence[Feature] | str | None = None, + ) -> GlyphBuffer: + """Shape the given buffer. + + Consumes the buffer; subsequent use of the same buffer raises + ``ValueError``. Direction must be set on the buffer (typically via + :meth:`Buffer.guess_segment_properties`) — otherwise raises + ``ValueError``. + + Raises ``ValueError`` on invalid feature syntax, or ``TypeError`` if + ``features`` is not a string, sequence of :class:`Feature`, or ``None``. + """ + + def __repr__(self) -> str: ... + +# --------------------------------------------------------------------------- +# High-level functions +# --------------------------------------------------------------------------- + +def shape(font_path: str, text: str, options: str = "") -> str: + """Shape ``text`` with the font at ``font_path`` and return the serialized output. + + ``options`` accepts the same flags as the ``hb-shape`` CLI (e.g. + ``"--direction=rtl"``, ``"--features=+kern,-liga"``). + + Raises ``RuntimeError`` if the font cannot be loaded or the options are + invalid. + """ + +def run_from_args(args: Sequence[str]) -> str: + """Run the equivalent of the ``hb-shape`` CLI with ``args``. + + Raises ``RuntimeError`` if argument parsing or shaping fails. + """ diff --git a/python/pyharfrust/py.typed b/python/pyharfrust/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/buffer.rs b/src/buffer.rs new file mode 100644 index 0000000..5cfde80 --- /dev/null +++ b/src/buffer.rs @@ -0,0 +1,161 @@ +use harfrust::UnicodeBuffer; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::types::{PyDirection, PyLanguage, PyScript}; + +#[pyclass(name = "Buffer", unsendable)] +pub struct PyBuffer { + pub(crate) inner: Option, +} + +impl PyBuffer { + fn consumed_err() -> PyErr { + PyValueError::new_err("Buffer has been consumed by shape()") + } + + pub(crate) fn as_ref_buf(&self) -> PyResult<&UnicodeBuffer> { + self.inner.as_ref().ok_or_else(Self::consumed_err) + } + + fn as_mut_buf(&mut self) -> PyResult<&mut UnicodeBuffer> { + self.inner.as_mut().ok_or_else(Self::consumed_err) + } + + #[allow(dead_code)] + pub(crate) fn take_inner(&mut self) -> PyResult { + self.inner.take().ok_or_else(Self::consumed_err) + } + + #[allow(dead_code)] + pub(crate) fn restore(&mut self, buf: UnicodeBuffer) { + self.inner = Some(buf); + } +} + +#[pymethods] +impl PyBuffer { + #[new] + fn new() -> Self { + PyBuffer { + inner: Some(UnicodeBuffer::new()), + } + } + + fn __len__(&self) -> PyResult { + Ok(self.as_ref_buf()?.len()) + } + + fn add_str(&mut self, s: &str) -> PyResult<()> { + self.as_mut_buf()?.push_str(s); + Ok(()) + } + + #[pyo3(signature = (codepoint, cluster=0))] + fn add(&mut self, codepoint: u32, cluster: u32) -> PyResult<()> { + let ch = char::from_u32(codepoint).ok_or_else(|| { + PyValueError::new_err(format!("invalid unicode codepoint: U+{codepoint:04X}")) + })?; + self.as_mut_buf()?.add(ch, cluster); + Ok(()) + } + + fn clear(&mut self) -> PyResult<()> { + self.as_mut_buf()?.clear(); + Ok(()) + } + + fn reset_clusters(&mut self) -> PyResult<()> { + self.as_mut_buf()?.reset_clusters(); + Ok(()) + } + + fn guess_segment_properties(&mut self) -> PyResult<()> { + self.as_mut_buf()?.guess_segment_properties(); + Ok(()) + } + + fn reserve(&mut self, size: usize) -> PyResult { + Ok(self.as_mut_buf()?.reserve(size)) + } + + fn set_pre_context(&mut self, s: &str) -> PyResult<()> { + self.as_mut_buf()?.set_pre_context(s); + Ok(()) + } + + fn set_post_context(&mut self, s: &str) -> PyResult<()> { + self.as_mut_buf()?.set_post_context(s); + Ok(()) + } + + fn set_not_found_variation_selector_glyph(&mut self, glyph: u32) -> PyResult<()> { + self.as_mut_buf()? + .set_not_found_variation_selector_glyph(glyph); + Ok(()) + } + + #[getter] + fn direction(&self) -> PyResult { + Ok(PyDirection(self.as_ref_buf()?.direction())) + } + + #[setter] + fn set_direction(&mut self, d: PyDirection) -> PyResult<()> { + self.as_mut_buf()?.set_direction(d.0); + Ok(()) + } + + #[getter] + fn script(&self) -> PyResult { + Ok(PyScript(self.as_ref_buf()?.script())) + } + + #[setter] + fn set_script(&mut self, s: PyScript) -> PyResult<()> { + self.as_mut_buf()?.set_script(s.0); + Ok(()) + } + + #[getter] + fn language(&self) -> PyResult> { + Ok(self.as_ref_buf()?.language().map(PyLanguage)) + } + + #[setter] + fn set_language(&mut self, l: PyLanguage) -> PyResult<()> { + self.as_mut_buf()?.set_language(l.0); + Ok(()) + } + + fn __repr__(&self) -> PyResult { + let buf = self.as_ref_buf()?; + // NOTE: duplicated from PyDirection::__repr__ in types.rs (private there). + // If Direction's repr labels ever change, update both sites — or promote + // this match to a shared helper. + let dir = match buf.direction() { + harfrust::Direction::LeftToRight => "Direction.LTR", + harfrust::Direction::RightToLeft => "Direction.RTL", + harfrust::Direction::TopToBottom => "Direction.TTB", + harfrust::Direction::BottomToTop => "Direction.BTT", + harfrust::Direction::Invalid => "Direction()", + }; + let script = buf.script().tag().to_string(); + let lang = buf + .language() + .map(|l| format!("\"{}\"", l.as_str())) + .unwrap_or_else(|| "None".to_string()); + Ok(format!( + "Buffer(len={}, direction={}, script=\"{}\", language={})", + buf.len(), + dir, + script, + lang, + )) + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/src/font.rs b/src/font.rs new file mode 100644 index 0000000..84bb964 --- /dev/null +++ b/src/font.rs @@ -0,0 +1,178 @@ +use std::str::FromStr; + +use harfrust::{Feature, FontRef, Shaper, ShaperData, ShaperInstance, Variation}; +use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError}; +use pyo3::prelude::*; + +use crate::buffer::PyBuffer; +use crate::glyph::PyGlyphBuffer; +use crate::types::{PyFeature, PyVariation}; + +#[pyclass(name = "Font", unsendable)] +pub struct PyFont { + data: Vec, + face_index: u32, + shaper_data: ShaperData, + instance: Option, + point_size: Option, +} + +impl PyFont { + fn build_from_bytes(data: Vec, face_index: u32) -> PyResult { + // Validate the font and build ShaperData up front so subsequent calls + // don't need to re-parse. ShaperData is 'static — it borrows FontRef + // only during construction. + let shaper_data = { + let font = FontRef::from_index(&data, face_index) + .map_err(|e| PyRuntimeError::new_err(format!("failed to parse font: {e}")))?; + ShaperData::new(&font) + }; + Ok(Self { + data, + face_index, + shaper_data, + instance: None, + point_size: None, + }) + } + + pub(crate) fn font_ref(&self) -> PyResult> { + FontRef::from_index(&self.data, self.face_index) + .map_err(|e| PyRuntimeError::new_err(format!("failed to parse font: {e}"))) + } + + pub(crate) fn build_shaper<'a>(&'a self, font: &FontRef<'a>) -> Shaper<'a> { + self.shaper_data + .shaper(font) + .instance(self.instance.as_ref()) + .point_size(self.point_size) + .build() + } +} + +#[pymethods] +impl PyFont { + #[new] + #[pyo3(signature = (path, face_index=0))] + fn new(path: &str, face_index: u32) -> PyResult { + let data = std::fs::read(path) + .map_err(|e| PyRuntimeError::new_err(format!("failed to read {path:?}: {e}")))?; + Self::build_from_bytes(data, face_index) + } + + #[staticmethod] + #[pyo3(signature = (data, face_index=0))] + fn from_bytes(data: Vec, face_index: u32) -> PyResult { + Self::build_from_bytes(data, face_index) + } + + #[getter] + fn face_index(&self) -> u32 { + self.face_index + } + + #[getter] + fn units_per_em(&self) -> PyResult { + let font = self.font_ref()?; + Ok(self.build_shaper(&font).units_per_em()) + } + + fn set_variations(&mut self, variations: &Bound<'_, PyAny>) -> PyResult<()> { + let vars = parse_variations(variations)?; + if vars.is_empty() { + self.instance = None; + return Ok(()); + } + // Direct field access (rather than self.font_ref()) so the borrow + // checker can split self.data (immutable) from self.instance (mutable). + let font = FontRef::from_index(&self.data, self.face_index) + .map_err(|e| PyRuntimeError::new_err(format!("failed to parse font: {e}")))?; + match &mut self.instance { + Some(inst) => inst.set_variations(&font, vars), + None => { + self.instance = Some(ShaperInstance::from_variations(&font, vars)); + } + } + Ok(()) + } + + #[pyo3(signature = (size))] + fn set_point_size(&mut self, size: Option) { + self.point_size = size; + } + + #[pyo3(signature = (buffer, features=None))] + fn shape( + &self, + buffer: &mut PyBuffer, + features: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + let feats = match features { + Some(any) => parse_features(any)?, + None => Vec::new(), + }; + // Reject Invalid direction up front — harfrust panics on it otherwise, + // surfacing as PanicException in Python instead of a clean error. + if buffer.as_ref_buf()?.direction() == harfrust::Direction::Invalid { + return Err(PyValueError::new_err( + "buffer direction is unset; call buffer.guess_segment_properties() \ + or assign buffer.direction before shaping", + )); + } + let inner = buffer.take_inner()?; + let font = self.font_ref()?; + let shaper = self.build_shaper(&font); + Ok(PyGlyphBuffer::wrap(shaper.shape(inner, &feats))) + } + + fn __repr__(&self) -> String { + format!( + "Font(face_index={}, bytes={})", + self.face_index, + self.data.len() + ) + } +} + +fn parse_features(any: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(s) = any.extract::<&str>() { + return parse_csv(s, "feature", Feature::from_str); + } + if let Ok(seq) = any.extract::>() { + return Ok(seq.into_iter().map(|f| f.0).collect()); + } + Err(PyTypeError::new_err( + "expected a sequence of Feature objects or a string", + )) +} + +fn parse_variations(any: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(s) = any.extract::<&str>() { + return parse_csv(s, "variation", Variation::from_str); + } + if let Ok(seq) = any.extract::>() { + return Ok(seq.into_iter().map(|v| v.0).collect()); + } + Err(PyTypeError::new_err( + "expected a sequence of Variation objects or a string", + )) +} + +fn parse_csv(s: &str, label: &str, parse: impl Fn(&str) -> Result) -> PyResult> { + let mut out = Vec::new(); + for piece in s.split(',') { + let piece = piece.trim(); + if piece.is_empty() { + continue; + } + let value = parse(piece) + .map_err(|_| PyValueError::new_err(format!("invalid {label}: {piece:?}")))?; + out.push(value); + } + Ok(out) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/src/glyph.rs b/src/glyph.rs new file mode 100644 index 0000000..2e2b621 --- /dev/null +++ b/src/glyph.rs @@ -0,0 +1,180 @@ +use harfrust::{GlyphBuffer, GlyphInfo, GlyphPosition, SerializeFlags}; +use pyo3::exceptions::{PyIndexError, PyValueError}; +use pyo3::prelude::*; + +use crate::buffer::PyBuffer; +use crate::font::PyFont; + +// --------------------------------------------------------------------------- +// GlyphInfo +// --------------------------------------------------------------------------- + +#[pyclass(name = "GlyphInfo", frozen, from_py_object)] +#[derive(Clone, Copy)] +pub struct PyGlyphInfo { + #[pyo3(get)] + glyph_id: u32, + #[pyo3(get)] + cluster: u32, + #[pyo3(get)] + unsafe_to_break: bool, + #[pyo3(get)] + unsafe_to_concat: bool, + #[pyo3(get)] + safe_to_insert_tatweel: bool, +} + +impl PyGlyphInfo { + fn from_info(info: &GlyphInfo) -> Self { + Self { + glyph_id: info.glyph_id, + cluster: info.cluster, + unsafe_to_break: info.unsafe_to_break(), + unsafe_to_concat: info.unsafe_to_concat(), + safe_to_insert_tatweel: info.safe_to_insert_tatweel(), + } + } +} + +#[pymethods] +impl PyGlyphInfo { + fn __repr__(&self) -> String { + format!( + "GlyphInfo(glyph_id={}, cluster={})", + self.glyph_id, self.cluster + ) + } +} + +// --------------------------------------------------------------------------- +// GlyphPosition +// --------------------------------------------------------------------------- + +#[pyclass(name = "GlyphPosition", frozen, from_py_object)] +#[derive(Clone, Copy)] +pub struct PyGlyphPosition { + #[pyo3(get)] + x_advance: i32, + #[pyo3(get)] + y_advance: i32, + #[pyo3(get)] + x_offset: i32, + #[pyo3(get)] + y_offset: i32, +} + +impl PyGlyphPosition { + fn from_pos(pos: &GlyphPosition) -> Self { + Self { + x_advance: pos.x_advance, + y_advance: pos.y_advance, + x_offset: pos.x_offset, + y_offset: pos.y_offset, + } + } +} + +#[pymethods] +impl PyGlyphPosition { + fn __repr__(&self) -> String { + format!( + "GlyphPosition(x_advance={}, y_advance={}, x_offset={}, y_offset={})", + self.x_advance, self.y_advance, self.x_offset, self.y_offset + ) + } +} + +// --------------------------------------------------------------------------- +// GlyphBuffer +// --------------------------------------------------------------------------- + +#[pyclass(name = "GlyphBuffer", unsendable)] +pub struct PyGlyphBuffer { + inner: Option, +} + +impl PyGlyphBuffer { + pub(crate) fn wrap(buf: GlyphBuffer) -> Self { + Self { inner: Some(buf) } + } + + fn consumed_err() -> PyErr { + PyValueError::new_err("GlyphBuffer has been consumed by clear()") + } + + fn as_ref_buf(&self) -> PyResult<&GlyphBuffer> { + self.inner.as_ref().ok_or_else(Self::consumed_err) + } +} + +#[pymethods] +impl PyGlyphBuffer { + fn __len__(&self) -> PyResult { + Ok(self.as_ref_buf()?.len()) + } + + #[getter] + fn glyph_infos(&self) -> PyResult> { + Ok(self + .as_ref_buf()? + .glyph_infos() + .iter() + .map(PyGlyphInfo::from_info) + .collect()) + } + + #[getter] + fn glyph_positions(&self) -> PyResult> { + Ok(self + .as_ref_buf()? + .glyph_positions() + .iter() + .map(PyGlyphPosition::from_pos) + .collect()) + } + + // Implementing __getitem__ + __len__ makes the buffer iterable in Python + // via the legacy iteration protocol, so `for info, pos in gbuf:` works + // without a separate iterator class. + fn __getitem__(&self, index: isize) -> PyResult<(PyGlyphInfo, PyGlyphPosition)> { + let buf = self.as_ref_buf()?; + let len = buf.len() as isize; + let idx = if index < 0 { index + len } else { index }; + if idx < 0 || idx >= len { + return Err(PyIndexError::new_err("glyph index out of range")); + } + let i = idx as usize; + Ok(( + PyGlyphInfo::from_info(&buf.glyph_infos()[i]), + PyGlyphPosition::from_pos(&buf.glyph_positions()[i]), + )) + } + + fn clear(&mut self) -> PyResult { + let inner = self.inner.take().ok_or_else(Self::consumed_err)?; + Ok(PyBuffer { + inner: Some(inner.clear()), + }) + } + + fn serialize(&self, font: PyRef<'_, PyFont>) -> PyResult { + let buf = self.as_ref_buf()?; + let font_ref = font.font_ref()?; + let shaper = font.build_shaper(&font_ref); + Ok(buf.serialize(&shaper, SerializeFlags::default())) + } + + fn __repr__(&self) -> String { + match self.inner.as_ref() { + Some(b) => format!("GlyphBuffer(len={})", b.len()), + None => "GlyphBuffer()".to_string(), + } + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ea63651 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,18 @@ +use pyo3::prelude::*; + +mod buffer; +mod font; +mod glyph; +mod shape; +mod types; + +#[pymodule] +fn _pyharfrust(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + types::register(m)?; + buffer::register(m)?; + glyph::register(m)?; + font::register(m)?; + shape::register(m)?; + Ok(()) +} diff --git a/src/shape.rs b/src/shape.rs new file mode 100644 index 0000000..146c742 --- /dev/null +++ b/src/shape.rs @@ -0,0 +1,22 @@ +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (font_path, text, options=""))] +fn shape(font_path: &str, text: &str, options: &str) -> PyResult { + hr_shape::shape(font_path, text, options) + .map(|s| s.trim_end().to_string()) + .map_err(pyo3::exceptions::PyRuntimeError::new_err) +} + +#[pyfunction] +fn run_from_args(args: Vec) -> PyResult { + hr_shape::run_from_args(args) + .map(|s| s.trim_end().to_string()) + .map_err(pyo3::exceptions::PyRuntimeError::new_err) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(shape, m)?)?; + m.add_function(wrap_pyfunction!(run_from_args, m)?)?; + Ok(()) +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..094d052 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,235 @@ +use std::str::FromStr; + +use harfrust::{Direction, Feature, Language, Script, Variation}; +use pyo3::prelude::*; + +// --------------------------------------------------------------------------- +// Direction +// --------------------------------------------------------------------------- + +#[pyclass(name = "Direction", frozen, eq, hash, from_py_object)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct PyDirection(pub(crate) Direction); + +#[pymethods] +impl PyDirection { + #[new] + fn new(s: &str) -> PyResult { + Direction::from_str(s).map(PyDirection).map_err(|_| { + pyo3::exceptions::PyValueError::new_err(format!("invalid direction: {s:?}")) + }) + } + + #[classattr] + const LTR: PyDirection = PyDirection(Direction::LeftToRight); + + #[classattr] + const RTL: PyDirection = PyDirection(Direction::RightToLeft); + + #[classattr] + const TTB: PyDirection = PyDirection(Direction::TopToBottom); + + #[classattr] + const BTT: PyDirection = PyDirection(Direction::BottomToTop); + + fn __repr__(&self) -> &'static str { + match self.0 { + Direction::LeftToRight => "Direction.LTR", + Direction::RightToLeft => "Direction.RTL", + Direction::TopToBottom => "Direction.TTB", + Direction::BottomToTop => "Direction.BTT", + Direction::Invalid => "Direction()", + } + } + + fn __str__(&self) -> &'static str { + match self.0 { + Direction::LeftToRight => "ltr", + Direction::RightToLeft => "rtl", + Direction::TopToBottom => "ttb", + Direction::BottomToTop => "btt", + Direction::Invalid => "invalid", + } + } +} + +// --------------------------------------------------------------------------- +// Script +// --------------------------------------------------------------------------- + +#[pyclass(name = "Script", frozen, eq, hash, from_py_object)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct PyScript(pub(crate) Script); + +#[pymethods] +impl PyScript { + #[new] + fn new(s: &str) -> PyResult { + if s.len() != 4 || !s.bytes().all(|b| b.is_ascii_alphabetic()) { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "expected a 4-letter ISO 15924 script tag, got {s:?}" + ))); + } + Script::from_str(s) + .map(PyScript) + .map_err(|_| pyo3::exceptions::PyValueError::new_err(format!("invalid script: {s:?}"))) + } + + #[getter] + fn tag(&self) -> String { + self.0.tag().to_string() + } + + fn __repr__(&self) -> String { + format!("Script(\"{}\")", self.0.tag()) + } + + fn __str__(&self) -> String { + self.0.tag().to_string() + } +} + +// --------------------------------------------------------------------------- +// Language +// --------------------------------------------------------------------------- + +#[pyclass(name = "Language", frozen, eq, hash, from_py_object)] +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct PyLanguage(pub(crate) Language); + +#[pymethods] +impl PyLanguage { + #[new] + fn new(s: &str) -> PyResult { + Language::from_str(s).map(PyLanguage).map_err(|_| { + pyo3::exceptions::PyValueError::new_err(format!("invalid language: {s:?}")) + }) + } + + fn __repr__(&self) -> String { + format!("Language(\"{}\")", self.0.as_str()) + } + + fn __str__(&self) -> String { + self.0.as_str().to_string() + } +} + +// --------------------------------------------------------------------------- +// Feature +// --------------------------------------------------------------------------- + +#[pyclass(name = "Feature", frozen, eq, hash, from_py_object)] +#[derive(Clone, Copy, PartialEq, Hash)] +pub struct PyFeature(pub(crate) Feature); + +#[pymethods] +impl PyFeature { + #[new] + fn new(s: &str) -> PyResult { + Feature::from_str(s) + .map(PyFeature) + .map_err(|_| pyo3::exceptions::PyValueError::new_err(format!("invalid feature: {s:?}"))) + } + + #[getter] + fn tag(&self) -> String { + self.0.tag.to_string() + } + + #[getter] + fn value(&self) -> u32 { + self.0.value + } + + #[getter] + fn start(&self) -> u32 { + self.0.start + } + + #[getter] + fn end(&self) -> u32 { + self.0.end + } + + fn __repr__(&self) -> String { + let tag = self.0.tag; + let val = self.0.value; + let start = self.0.start; + let end = self.0.end; + if start == 0 && end == u32::MAX { + if val == 1 { + format!("Feature(\"+{tag}\")") + } else if val == 0 { + format!("Feature(\"-{tag}\")") + } else { + format!("Feature(\"{tag}={val}\")") + } + } else { + format!("Feature(\"{tag}[{start}:{end}]={val}\")") + } + } + + fn __str__(&self) -> String { + let tag = self.0.tag; + let val = self.0.value; + let start = self.0.start; + let end = self.0.end; + if start == 0 && end == u32::MAX { + if val == 1 { + format!("+{tag}") + } else if val == 0 { + format!("-{tag}") + } else { + format!("{tag}={val}") + } + } else { + format!("{tag}[{start}:{end}]={val}") + } + } +} + +// --------------------------------------------------------------------------- +// Variation +// --------------------------------------------------------------------------- + +#[pyclass(name = "Variation", frozen, eq, from_py_object)] +#[derive(Clone, Copy, PartialEq)] +pub struct PyVariation(pub(crate) Variation); + +#[pymethods] +impl PyVariation { + #[new] + fn new(s: &str) -> PyResult { + Variation::from_str(s).map(PyVariation).map_err(|_| { + pyo3::exceptions::PyValueError::new_err(format!("invalid variation: {s:?}")) + }) + } + + #[getter] + fn tag(&self) -> String { + self.0.tag.to_string() + } + + #[getter] + fn value(&self) -> f32 { + self.0.value + } + + fn __repr__(&self) -> String { + format!("Variation(\"{}={}\")", self.0.tag, self.0.value) + } + + fn __str__(&self) -> String { + format!("{}={}", self.0.tag, self.0.value) + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e48687a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,120 @@ +import os +import re + +import pytest + +BUNDLED_DATA = os.path.join(os.path.dirname(__file__), "data") +BUNDLED_FONTS = os.path.join(os.path.dirname(__file__), "fonts") +HARFRUST_SOURCE = os.environ.get("HARFRUST_SOURCE") + +MIN_EXTERNAL_CASES = 5000 + +_RS_TEST_RE = re.compile( + r"shape\(\s*" + r'"((?:\\.|[^"\\])*)"\s*,\s*' + r'"((?:\\.|[^"\\])*)"\s*,\s*' + r'"((?:\\.|[^"\\])*)"\s*,?\s*' + r"\)\s*,\s*" + r'"((?:\\.|[^"\\])*)"', + re.DOTALL, +) +_RS_U_ESC = re.compile(r"\\u\{([0-9A-Fa-f]+)\}") +_RS_LINE_CONT = re.compile(r"\\\n\s*") + + +def parse_tests_file(path): + """Parse a harfbuzz-format .tests file into test cases.""" + tests_dir = os.path.dirname(path) + cases = [] + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or line.startswith("@"): + continue + parts = line.split(";") + if len(parts) != 4: + continue + fontfile, options, unicodes, expected = parts + if expected == "*" or not expected: + continue + font_path = os.path.normpath(os.path.join(tests_dir, fontfile)) + if not os.path.exists(font_path): + continue + text = "".join( + chr(int(u.strip()[2:], 16)) for u in unicodes.split(",") if u.strip() + ) + cases.append((font_path, text, options, expected)) + return cases + + +def parse_rs_file(path, font_root): + """Extract test cases from a harfrust-generated tests/shaping/*.rs file. + + Font paths inside the file are relative to the harfrust crate root + (e.g. "tests/fonts/in-house/X.ttf"); ``font_root`` is that root. + """ + cases = [] + with open(path) as f: + content = f.read() + for font_rel, text_lit, options, expected in _RS_TEST_RE.findall(content): + text = _RS_LINE_CONT.sub("", text_lit) + text = _RS_U_ESC.sub(lambda m: chr(int(m.group(1), 16)), text) + font_path = os.path.normpath(os.path.join(font_root, font_rel)) + if not os.path.exists(font_path): + continue + cases.append((font_path, text, options, expected)) + return cases + + +def collect_tests_files(): + """Yield (case, is_external) pairs for every bundled and external test case.""" + cases = [] + if os.path.isdir(BUNDLED_DATA): + for f in sorted(os.listdir(BUNDLED_DATA)): + if f.endswith(".tests"): + for case in parse_tests_file(os.path.join(BUNDLED_DATA, f)): + cases.append((case, False)) + if HARFRUST_SOURCE: + external_cases = _collect_external_cases(HARFRUST_SOURCE) + if len(external_cases) < MIN_EXTERNAL_CASES: + raise RuntimeError( + f"HARFRUST_SOURCE is set to {HARFRUST_SOURCE!r} but only " + f"{len(external_cases)} Tier 2 cases were discovered " + f"(expected at least {MIN_EXTERNAL_CASES}). " + "Either the checkout is missing tests/shaping/*.rs or the parser " + "is out of sync with harfrust's test generator." + ) + cases.extend((c, True) for c in external_cases) + return cases + + +def _collect_external_cases(harfrust_source: str): + """Collect test cases from harfrust's generated .rs test files. + + Note: We only parse .rs files in tests/shaping/, NOT the .tests files in + tests/custom/. The .tests files are source files for harfrust's test + generator (gen-shaping-tests.py) and may contain tests that are + intentionally excluded from the generated .rs files (e.g., macOS-only + tests, tests with known different expected values, etc.). + """ + cases = [] + harfrust_root = os.path.join(harfrust_source, "harfrust") + shaping_dir = os.path.join(harfrust_root, "tests", "shaping") + if os.path.isdir(shaping_dir): + for f in sorted(os.listdir(shaping_dir)): + if f.endswith(".rs") and f != "main.rs": + cases.extend(parse_rs_file(os.path.join(shaping_dir, f), harfrust_root)) + return cases + + +def pytest_generate_tests(metafunc): + if "tests_case" in metafunc.fixturenames: + params = [ + pytest.param( + case, + marks=(pytest.mark.external,) if is_external else (), + id=f"{os.path.basename(case[0])}:{case[1][:20]}", + ) + for case, is_external in collect_tests_files() + ] + metafunc.parametrize("tests_case", params) diff --git a/tests/data/bundled.tests b/tests/data/bundled.tests new file mode 100644 index 0000000..550e201 --- /dev/null +++ b/tests/data/bundled.tests @@ -0,0 +1,7 @@ +# Bundled test cases for pyharfrust — expected output generated by hr-shape 0.5.2 +../fonts/PT_Sans-Caption-Web-Regular.ttf;;U+0048,U+0065,U+006C,U+006C,U+006F;[H=0+733|e=1+598|l=2+336|l=3+336|o=4+631] +../fonts/PT_Sans-Caption-Web-Regular.ttf;--direction rtl;U+0041,U+0042;[B=1+641|A=0+645] +../fonts/PT_Sans-Caption-Web-Regular.ttf;;U+1EA4,U+006E;[Acircumflex=0+645|uniF401=0+0|n=1+641] +../fonts/OpenSans.subset1.ttf;--variations=wght=500,wdth=80;U+0065;[gid0=0+1218] +../fonts/NotoSansMalayalam.subset1.ttf;;U+0D38,U+0D4D,U+0D25;[gid7=0+1891] +../fonts/LaBelleAurore.ttf;;U+006B,U+0065,U+031D;[k=0+479|e=1+343|.notdef=1@-172,-59+0] diff --git a/tests/fonts/LaBelleAurore.ttf b/tests/fonts/LaBelleAurore.ttf new file mode 100644 index 0000000..df0b59e Binary files /dev/null and b/tests/fonts/LaBelleAurore.ttf differ diff --git a/tests/fonts/NotoSansMalayalam.subset1.ttf b/tests/fonts/NotoSansMalayalam.subset1.ttf new file mode 100644 index 0000000..fb867b8 Binary files /dev/null and b/tests/fonts/NotoSansMalayalam.subset1.ttf differ diff --git a/tests/fonts/OpenSans.subset1.ttf b/tests/fonts/OpenSans.subset1.ttf new file mode 100644 index 0000000..e664bef Binary files /dev/null and b/tests/fonts/OpenSans.subset1.ttf differ diff --git a/tests/fonts/PT_Sans-Caption-Web-Regular.ttf b/tests/fonts/PT_Sans-Caption-Web-Regular.ttf new file mode 100644 index 0000000..2ec4360 Binary files /dev/null and b/tests/fonts/PT_Sans-Caption-Web-Regular.ttf differ diff --git a/tests/test_buffer.py b/tests/test_buffer.py new file mode 100644 index 0000000..c8a3a2b --- /dev/null +++ b/tests/test_buffer.py @@ -0,0 +1,199 @@ +import pytest + +from pyharfrust import Buffer, Direction, Language, Script + + +# --------------------------------------------------------------------------- +# Construction / length +# --------------------------------------------------------------------------- + + +class TestCreation: + def test_empty(self): + buf = Buffer() + assert len(buf) == 0 + + def test_add_str_ascii(self): + buf = Buffer() + buf.add_str("Hello") + assert len(buf) == 5 + + def test_add_str_multi_bmp(self): + buf = Buffer() + buf.add_str("العربية") + assert len(buf) == 7 + + def test_add_str_astral(self): + buf = Buffer() + buf.add_str("A\U0001f600B") + assert len(buf) == 3 + + def test_add_str_empty(self): + buf = Buffer() + buf.add_str("") + assert len(buf) == 0 + + def test_add_str_multiple_calls_append(self): + buf = Buffer() + buf.add_str("Hel") + buf.add_str("lo") + assert len(buf) == 5 + + def test_add_codepoint(self): + buf = Buffer() + buf.add(0x0041) + buf.add(0x0042, cluster=7) + assert len(buf) == 2 + + +# --------------------------------------------------------------------------- +# Clear / reset +# --------------------------------------------------------------------------- + + +class TestClear: + def test_clear_empties(self): + buf = Buffer() + buf.add_str("hello") + buf.clear() + assert len(buf) == 0 + + def test_clear_allows_reuse(self): + buf = Buffer() + buf.add_str("one") + buf.clear() + buf.add_str("two") + assert len(buf) == 3 + + def test_reset_clusters(self): + buf = Buffer() + buf.add_str("abc") + buf.reset_clusters() + assert len(buf) == 3 + + +# --------------------------------------------------------------------------- +# Direction / Script / Language properties +# --------------------------------------------------------------------------- + + +class TestProperties: + def test_set_get_direction(self): + buf = Buffer() + buf.direction = Direction.RTL + assert buf.direction == Direction.RTL + buf.direction = Direction.LTR + assert buf.direction == Direction.LTR + + def test_set_get_script(self): + buf = Buffer() + buf.script = Script("Arab") + assert buf.script == Script("Arab") + buf.script = Script("Latn") + assert buf.script == Script("Latn") + + def test_set_get_language(self): + buf = Buffer() + buf.language = Language("en") + assert buf.language == Language("en") + buf.language = Language("ar") + assert buf.language == Language("ar") + + def test_language_none_when_unset(self): + buf = Buffer() + assert buf.language is None + + def test_direction_setter_rejects_nondirection(self): + buf = Buffer() + with pytest.raises(TypeError): + buf.direction = "ltr" # pyright: ignore[reportAttributeAccessIssue] + + def test_script_setter_rejects_nonscript(self): + buf = Buffer() + with pytest.raises(TypeError): + buf.script = "Latn" # pyright: ignore[reportAttributeAccessIssue] + + def test_language_setter_rejects_nonlanguage(self): + buf = Buffer() + with pytest.raises(TypeError): + buf.language = "en" # pyright: ignore[reportAttributeAccessIssue] + + +# --------------------------------------------------------------------------- +# Context setters +# +# harfrust exposes only setters for pre/post context (no getters), so these +# tests can only confirm the calls don't raise. The real regression signal +# will come from Phase 5 shaping tests where context changes affect output. +# --------------------------------------------------------------------------- + + +class TestContext: + def test_set_pre_context(self): + buf = Buffer() + buf.set_pre_context("abc") + + def test_set_post_context(self): + buf = Buffer() + buf.set_post_context("xyz") + + def test_set_not_found_variation_selector_glyph(self): + buf = Buffer() + buf.set_not_found_variation_selector_glyph(0) + buf.set_not_found_variation_selector_glyph(42) + + +# --------------------------------------------------------------------------- +# guess_segment_properties +# --------------------------------------------------------------------------- + + +class TestGuessSegmentProperties: + def test_arabic_guesses_rtl(self): + buf = Buffer() + buf.add_str("العربية") + buf.guess_segment_properties() + assert buf.direction == Direction.RTL + assert buf.script == Script("Arab") + + def test_latin_guesses_ltr(self): + buf = Buffer() + buf.add_str("Hello") + buf.guess_segment_properties() + assert buf.direction == Direction.LTR + assert buf.script == Script("Latn") + + +# --------------------------------------------------------------------------- +# reserve +# --------------------------------------------------------------------------- + + +class TestReserve: + def test_reserve_returns_bool(self): + buf = Buffer() + result = buf.reserve(128) + assert isinstance(result, bool) + assert result is True + + def test_reserve_does_not_change_length(self): + buf = Buffer() + buf.add_str("hi") + buf.reserve(1024) + assert len(buf) == 2 + + +# --------------------------------------------------------------------------- +# repr +# --------------------------------------------------------------------------- + + +class TestRepr: + def test_repr_mentions_class(self): + buf = Buffer() + assert "Buffer" in repr(buf) + + def test_repr_shows_length(self): + buf = Buffer() + buf.add_str("hi") + assert "2" in repr(buf) diff --git a/tests/test_font.py b/tests/test_font.py new file mode 100644 index 0000000..a57cf99 --- /dev/null +++ b/tests/test_font.py @@ -0,0 +1,356 @@ +import os + +import pytest + +from pyharfrust import ( + Buffer, + Feature, + Font, + GlyphBuffer, + GlyphInfo, + GlyphPosition, + Variation, + shape, +) + +FONTS_DIR = os.path.join(os.path.dirname(__file__), "fonts") +PT_SANS = os.path.join(FONTS_DIR, "PT_Sans-Caption-Web-Regular.ttf") +OPEN_SANS = os.path.join(FONTS_DIR, "OpenSans.subset1.ttf") + + +def _shape_str(font, text, features=None): + buf = Buffer() + buf.add_str(text) + buf.guess_segment_properties() + return font.shape(buf, features) if features is not None else font.shape(buf) + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestConstruction: + def test_from_path(self): + font = Font(PT_SANS) + assert font.face_index == 0 + assert font.units_per_em > 0 + + def test_face_index_kw(self): + font = Font(PT_SANS, face_index=0) + assert font.face_index == 0 + + def test_missing_file_raises(self): + with pytest.raises(RuntimeError): + Font("/nonexistent/font.ttf") + + def test_from_bytes(self): + with open(PT_SANS, "rb") as f: + data = f.read() + font = Font.from_bytes(data) + assert font.units_per_em > 0 + + def test_from_bytes_invalid(self): + with pytest.raises(RuntimeError): + Font.from_bytes(b"not a font file") + + +# --------------------------------------------------------------------------- +# Shaping basics +# --------------------------------------------------------------------------- + + +class TestShape: + def test_returns_glyph_buffer(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hello") + assert isinstance(gbuf, GlyphBuffer) + assert len(gbuf) == 5 + + def test_glyph_infos_and_positions(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hi") + infos = gbuf.glyph_infos + positions = gbuf.glyph_positions + assert len(infos) == len(positions) == len(gbuf) + assert all(isinstance(i, GlyphInfo) for i in infos) + assert all(isinstance(p, GlyphPosition) for p in positions) + + def test_glyph_info_fields(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB") + infos = gbuf.glyph_infos + assert isinstance(infos[0].glyph_id, int) and infos[0].glyph_id > 0 + assert infos[0].cluster == 0 + assert infos[1].cluster == 1 + for info in infos: + assert isinstance(info.unsafe_to_break, bool) + assert isinstance(info.unsafe_to_concat, bool) + assert isinstance(info.safe_to_insert_tatweel, bool) + + def test_glyph_position_fields(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "A") + pos = gbuf.glyph_positions[0] + assert pos.x_advance > 0 + assert pos.y_advance == 0 + assert isinstance(pos.x_offset, int) + assert isinstance(pos.y_offset, int) + + +# --------------------------------------------------------------------------- +# Iteration / indexing +# --------------------------------------------------------------------------- + + +class TestIteration: + def test_iter_yields_pairs(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hi") + items = list(gbuf) + assert len(items) == 2 + for info, pos in items: + assert isinstance(info, GlyphInfo) + assert isinstance(pos, GlyphPosition) + + def test_indexing(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB") + first = gbuf[0] + last = gbuf[-1] + assert first[0].cluster == 0 + assert last[0].cluster == 1 + + def test_index_out_of_range(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "A") + with pytest.raises(IndexError): + gbuf[5] + + +# --------------------------------------------------------------------------- +# Serialize parity with shape() string function +# --------------------------------------------------------------------------- + + +class TestSerializeParity: + def test_matches_shape_string(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hello") + obj_result = gbuf.serialize(font).strip() + str_result = shape(PT_SANS, "Hello", "").strip() + assert obj_result == str_result + + def test_matches_shape_with_features(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB", features=[Feature("+kern")]) + obj_result = gbuf.serialize(font).strip() + str_result = shape(PT_SANS, "AB", "--features=+kern").strip() + assert obj_result == str_result + + def test_from_bytes_matches_from_path(self): + font_path = Font(PT_SANS) + with open(PT_SANS, "rb") as f: + font_bytes = Font.from_bytes(f.read()) + a = _shape_str(font_path, "Hello").serialize(font_path).strip() + b = _shape_str(font_bytes, "Hello").serialize(font_bytes).strip() + assert a == b + + +# --------------------------------------------------------------------------- +# Buffer consumption + recycling +# --------------------------------------------------------------------------- + + +class TestBufferConsumption: + def test_buffer_consumed_after_shape(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("Test") + buf.guess_segment_properties() + font.shape(buf) + with pytest.raises(ValueError, match="consumed"): + len(buf) + + def test_shape_twice_raises(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("Test") + buf.guess_segment_properties() + font.shape(buf) + with pytest.raises(ValueError, match="consumed"): + font.shape(buf) + + def test_shape_with_invalid_direction_raises(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("Test") # direction left at default Invalid + with pytest.raises(ValueError, match="direction"): + font.shape(buf) + + +class TestBufferRecycle: + def test_clear_returns_reusable_buffer(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("First") + buf.guess_segment_properties() + gbuf = font.shape(buf) + buf2 = gbuf.clear() + assert isinstance(buf2, Buffer) + assert len(buf2) == 0 + buf2.add_str("Second") + buf2.guess_segment_properties() + gbuf2 = font.shape(buf2) + assert len(gbuf2) == 6 + + def test_glyph_buffer_consumed_after_clear(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("X") + buf.guess_segment_properties() + gbuf = font.shape(buf) + gbuf.clear() + with pytest.raises(ValueError, match="consumed"): + len(gbuf) + + def test_clear_twice_raises(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("X") + buf.guess_segment_properties() + gbuf = font.shape(buf) + gbuf.clear() + with pytest.raises(ValueError, match="consumed"): + gbuf.clear() + + +# --------------------------------------------------------------------------- +# Features +# --------------------------------------------------------------------------- + + +class TestFeatures: + def test_features_list(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB", features=[Feature("+kern")]) + assert len(gbuf) == 2 + + def test_features_string(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB", features="+kern,-liga") + assert len(gbuf) == 2 + + def test_features_empty_list(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "AB", features=[]) + assert len(gbuf) == 2 + + def test_features_invalid_string(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("AB") + with pytest.raises(ValueError, match="invalid feature"): + font.shape(buf, features="=") + + def test_features_wrong_type(self): + font = Font(PT_SANS) + buf = Buffer() + buf.add_str("AB") + with pytest.raises(TypeError): + font.shape(buf, features=42) # pyright: ignore[reportArgumentType] + + +# --------------------------------------------------------------------------- +# Variations +# --------------------------------------------------------------------------- + + +class TestVariations: + def test_set_variations_list(self): + font = Font(OPEN_SANS) + font.set_variations([Variation("wght=700")]) + + def test_set_variations_string(self): + font = Font(OPEN_SANS) + font.set_variations("wght=500,wdth=80") + + def test_variations_affect_output(self): + # OpenSans.subset1 is variable; weight should change x_advance. + baseline = Font(OPEN_SANS) + bold = Font(OPEN_SANS) + bold.set_variations([Variation("wght=900")]) + + a = _shape_str(baseline, "e").glyph_positions[0].x_advance + b = _shape_str(bold, "e").glyph_positions[0].x_advance + assert a != b + + def test_set_variations_empty_resets(self): + font = Font(OPEN_SANS) + font.set_variations([Variation("wght=900")]) + bold_adv = _shape_str(font, "e").glyph_positions[0].x_advance + + font.set_variations([]) + default_adv = _shape_str(font, "e").glyph_positions[0].x_advance + assert default_adv != bold_adv + + def test_set_variations_invalid(self): + font = Font(OPEN_SANS) + with pytest.raises(ValueError, match="invalid variation"): + font.set_variations("garbage~~~") + + def test_set_variations_wrong_type(self): + font = Font(OPEN_SANS) + with pytest.raises(TypeError): + font.set_variations(42) # pyright: ignore[reportArgumentType] + + +# --------------------------------------------------------------------------- +# Point size +# --------------------------------------------------------------------------- + + +class TestPointSize: + def test_set_then_clear(self): + font = Font(PT_SANS) + font.set_point_size(12.0) + font.set_point_size(None) + + def test_shape_runs_with_point_size(self): + font = Font(PT_SANS) + font.set_point_size(24.0) + gbuf = _shape_str(font, "Hi") + assert len(gbuf) == 2 + + +# --------------------------------------------------------------------------- +# Repr +# --------------------------------------------------------------------------- + + +class TestRepr: + def test_font_repr(self): + font = Font(PT_SANS) + assert "Font" in repr(font) + + def test_glyph_buffer_repr(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "Hi") + r = repr(gbuf) + assert "GlyphBuffer" in r and "2" in r + + def test_glyph_buffer_repr_after_clear(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "X") + gbuf.clear() + assert "consumed" in repr(gbuf) + + def test_glyph_info_repr(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "A") + assert "GlyphInfo" in repr(gbuf.glyph_infos[0]) + + def test_glyph_position_repr(self): + font = Font(PT_SANS) + gbuf = _shape_str(font, "A") + assert "GlyphPosition" in repr(gbuf.glyph_positions[0]) diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000..26936d1 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,4 @@ +def test_import(): + import pyharfrust + + assert hasattr(pyharfrust, "__version__") diff --git a/tests/test_shaping.py b/tests/test_shaping.py new file mode 100644 index 0000000..9de0b3e --- /dev/null +++ b/tests/test_shaping.py @@ -0,0 +1,58 @@ +import os + +import pytest + +import pyharfrust + +FONTS = os.path.join(os.path.dirname(__file__), "fonts") +FONT = os.path.join(FONTS, "PT_Sans-Caption-Web-Regular.ttf") + + +class TestShapeBasic: + def test_shape_returns_string(self): + result = pyharfrust.shape(FONT, "AB", "") + assert isinstance(result, str) + assert result.startswith("[") and result.endswith("]") + + def test_shape_hello(self): + result = pyharfrust.shape(FONT, "Hello", "") + assert result == "[H=0+733|e=1+598|l=2+336|l=3+336|o=4+631]" + + def test_shape_direction_rtl(self): + result = pyharfrust.shape(FONT, "AB", "--direction rtl") + assert result == "[B=1+641|A=0+645]" + + def test_shape_empty_options(self): + result = pyharfrust.shape(FONT, "A", "") + assert "A=0" in result + + def test_shape_default_options(self): + result = pyharfrust.shape(FONT, "A") + assert "A=0" in result + + def test_shape_invalid_font(self): + with pytest.raises(RuntimeError): + pyharfrust.shape("/nonexistent/font.ttf", "A", "") + + +class TestRunFromArgs: + def test_basic(self): + result = pyharfrust.run_from_args(["--font-file", FONT, "--text", "Hello"]) + assert result == "[H=0+733|e=1+598|l=2+336|l=3+336|o=4+631]" + + def test_with_unicodes(self): + result = pyharfrust.run_from_args(["--font-file", FONT, "-u", "U+0041,U+0042"]) + assert result == "[A=0+645|B=1+641]" + + def test_invalid_font(self): + with pytest.raises(RuntimeError): + pyharfrust.run_from_args( + ["--font-file", "/nonexistent/font.ttf", "--text", "A"] + ) + + +class TestDotTestsRegression: + def test_shape_matches_tests_file(self, tests_case): + font_path, text, options, expected = tests_case + result = pyharfrust.shape(font_path, text, options) + assert result == expected diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..ab47cb5 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,213 @@ +import pytest + +from pyharfrust import Direction, Feature, Language, Script, Variation + + +# --------------------------------------------------------------------------- +# Direction +# --------------------------------------------------------------------------- + + +class TestDirection: + def test_from_string(self): + assert Direction("ltr") == Direction.LTR + assert Direction("rtl") == Direction.RTL + assert Direction("ttb") == Direction.TTB + assert Direction("btt") == Direction.BTT + + def test_str(self): + assert str(Direction.LTR) == "ltr" + assert str(Direction.RTL) == "rtl" + assert str(Direction.TTB) == "ttb" + assert str(Direction.BTT) == "btt" + + def test_repr(self): + assert repr(Direction.LTR) == "Direction.LTR" + assert repr(Direction.RTL) == "Direction.RTL" + + def test_equality(self): + assert Direction("ltr") == Direction("ltr") + assert Direction("ltr") != Direction("rtl") + + def test_hash(self): + s = {Direction.LTR, Direction.RTL, Direction.LTR} + assert len(s) == 2 + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="invalid direction"): + Direction("xyz") + + def test_case_insensitive(self): + assert Direction("LTR") == Direction.LTR + assert Direction("Rtl") == Direction.RTL + + +# --------------------------------------------------------------------------- +# Script +# --------------------------------------------------------------------------- + + +class TestScript: + def test_common_scripts(self): + s = Script("Latn") + assert s.tag == "Latn" + + def test_arabic(self): + s = Script("Arab") + assert s.tag == "Arab" + + def test_str(self): + s = Script("Latn") + assert str(s) == "Latn" + + def test_repr(self): + s = Script("Latn") + assert repr(s) == 'Script("Latn")' + + def test_equality(self): + assert Script("Latn") == Script("Latn") + assert Script("Latn") != Script("Arab") + + def test_hash(self): + s = {Script("Latn"), Script("Arab"), Script("Latn")} + assert len(s) == 2 + + def test_unknown_script_explicit(self): + s = Script("Zzzz") + assert s.tag == "Zzzz" + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="4-letter ISO 15924"): + Script("XXXXX") + with pytest.raises(ValueError, match="4-letter ISO 15924"): + Script("") + with pytest.raises(ValueError, match="4-letter ISO 15924"): + Script("1234") + + def test_case_normalization(self): + assert Script("latn").tag == "Latn" + + +# --------------------------------------------------------------------------- +# Language +# --------------------------------------------------------------------------- + + +class TestLanguage: + def test_basic(self): + lang = Language("en") + assert str(lang) == "en" + + def test_subtag(self): + lang = Language("en-US") + assert str(lang) == "en-us" + + def test_repr(self): + lang = Language("en") + assert repr(lang) == 'Language("en")' + + def test_equality(self): + assert Language("en") == Language("en") + assert Language("en") != Language("ar") + + def test_hash(self): + s = {Language("en"), Language("ar"), Language("en")} + assert len(s) == 2 + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="invalid language"): + Language("") + + +# --------------------------------------------------------------------------- +# Feature +# --------------------------------------------------------------------------- + + +class TestFeature: + def test_enable(self): + f = Feature("+kern") + assert f.tag == "kern" + assert f.value == 1 + + def test_disable(self): + f = Feature("-liga") + assert f.tag == "liga" + assert f.value == 0 + + def test_with_value(self): + f = Feature("kern=2") + assert f.tag == "kern" + assert f.value == 2 + + def test_with_range(self): + f = Feature("kern[3:5]=2") + assert f.tag == "kern" + assert f.start == 3 + assert f.end == 5 + assert f.value == 2 + + def test_global_range(self): + f = Feature("+kern") + assert f.start == 0 + assert f.end == 2**32 - 1 + + def test_str_enabled(self): + assert str(Feature("+kern")) == "+kern" + + def test_str_disabled(self): + assert str(Feature("-liga")) == "-liga" + + def test_repr(self): + assert repr(Feature("+kern")) == 'Feature("+kern")' + + def test_equality(self): + assert Feature("+kern") == Feature("+kern") + assert Feature("+kern") != Feature("-kern") + + def test_hash(self): + s = {Feature("+kern"), Feature("-liga"), Feature("+kern")} + assert len(s) == 2 + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="invalid feature"): + Feature("") + + +# --------------------------------------------------------------------------- +# Variation +# --------------------------------------------------------------------------- + + +class TestVariation: + def test_basic(self): + v = Variation("wght=700") + assert v.tag == "wght" + assert v.value == 700.0 + + def test_float_value(self): + v = Variation("wdth=85.5") + assert v.tag == "wdth" + assert abs(v.value - 85.5) < 0.01 + + def test_str(self): + v = Variation("wght=700") + assert "wght" in str(v) + assert "700" in str(v) + + def test_repr(self): + v = Variation("wght=700") + assert "wght" in repr(v) + assert "700" in repr(v) + + def test_equality(self): + assert Variation("wght=700") == Variation("wght=700") + assert Variation("wght=700") != Variation("wght=400") + + def test_not_hashable(self): + with pytest.raises(TypeError, match="unhashable"): + hash(Variation("wght=700")) + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="invalid variation"): + Variation("") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7a6a955 --- /dev/null +++ b/uv.lock @@ -0,0 +1,310 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "maturin" +version = "1.12.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/18/8b2eebd3ea086a5ec73d7081f95ec64918ceda1900075902fc296ea3ad55/maturin-1.12.6.tar.gz", hash = "sha256:d37be3a811a7f2ee28a0fa0964187efa50e90f21da0c6135c27787fa0b6a89db", size = 269165, upload-time = "2026-03-01T14:54:04.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/8b/9ddfde8a485489e3ebdc50ee3042ef1c854f00dfea776b951068f6ffe451/maturin-1.12.6-py3-none-linux_armv6l.whl", hash = "sha256:6892b4176992fcc143f9d1c1c874a816e9a041248eef46433db87b0f0aff4278", size = 9789847, upload-time = "2026-03-01T14:54:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e8/5f7fd3763f214a77ac0388dbcc71cc30aec5490016bd0c8e6bd729fc7b0a/maturin-1.12.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c0c742beeeef7fb93b6a81bd53e75507887e396fd1003c45117658d063812dad", size = 19023833, upload-time = "2026-03-01T14:53:46.743Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/706ff3839c8b2046436d4c2bc97596c558728264d18abc298a1ad862a4be/maturin-1.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cb41139295eed6411d3cdafc7430738094c2721f34b7eeb44f33cac516115dc", size = 9821620, upload-time = "2026-03-01T14:54:12.04Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9c/70917fb123c8dd6b595e913616c9c72d730cbf4a2b6cac8077dc02a12586/maturin-1.12.6-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:351f3af1488a7cbdcff3b6d8482c17164273ac981378a13a4a9937a49aec7d71", size = 9849107, upload-time = "2026-03-01T14:53:48.971Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/f1d6ad95c0a12fbe761a7c28a57540341f188564dbe8ad730a4d1788cd32/maturin-1.12.6-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:6dbddfe4dc7ddee60bbac854870bd7cfec660acb54d015d24597d59a1c828f61", size = 10242855, upload-time = "2026-03-01T14:53:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/93/1b/2419843a4f1d2fb4747f3dc3d9c4a2881cd97a3274dd94738fcdf0835e79/maturin-1.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8fdb0f63e77ee3df0f027a120e9af78dbc31edf0eb0f263d55783c250c33b728", size = 9674972, upload-time = "2026-03-01T14:53:52.763Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b60ab2fc996d904b40e55bd475599dcdccd8f7ad3e649bf95e87970df466/maturin-1.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fa84b7493a2e80759cacc2e668fa5b444d55b9994e90707c42904f55d6322c1e", size = 9645755, upload-time = "2026-03-01T14:53:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/a4/96/03f2b55a8c226805115232fc23c4a4f33f0c9d39e11efab8166dc440f80d/maturin-1.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:e90dc12bc6a38e9495692a36c9e231c4d7e0c9bfde60719468ab7d8673db3c45", size = 12737612, upload-time = "2026-03-01T14:54:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c2/648667022c5b53cdccefa67c245e8a984970f3045820f00c2e23bdb2aff4/maturin-1.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06fc8d089f98623ce924c669b70911dfed30f9a29956c362945f727f9abc546b", size = 10455028, upload-time = "2026-03-01T14:54:07.349Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/5b5efe3ca0c043357ed3f8d2b2d556169fdbf1ff75e50e8e597708a359d2/maturin-1.12.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:75133e56274d43b9227fd49dca9a86e32f1fd56a7b55544910c4ce978c2bb5aa", size = 10014531, upload-time = "2026-03-01T14:53:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/68/d5/39c594c27b1a8b32a0cb95fff9ad60b888c4352d1d1c389ac1bd20dc1e16/maturin-1.12.6-py3-none-win32.whl", hash = "sha256:3f32e0a3720b81423c9d35c14e728cb1f954678124749776dc72d533ea1115e8", size = 8553012, upload-time = "2026-03-01T14:53:50.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/66/b262832a91747e04051e21f986bd01a8af81fbffafacc7d66a11e79aab5f/maturin-1.12.6-py3-none-win_amd64.whl", hash = "sha256:977290159d252db946054a0555263c59b3d0c7957135c69e690f4b1558ee9983", size = 9890470, upload-time = "2026-03-01T14:53:56.659Z" }, + { url = "https://files.pythonhosted.org/packages/e3/47/76b8ca470ddc8d7d36aa8c15f5a6aed1841806bb93a0f4ead8ee61e9a088/maturin-1.12.6-py3-none-win_arm64.whl", hash = "sha256:bae91976cdc8148038e13c881e1e844e5c63e58e026e8b9945aa2d19b3b4ae89", size = 8606158, upload-time = "2026-03-01T14:54:02.423Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyharfrust" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "maturin" }, + { name = "pre-commit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "maturin", specifier = ">=1.12.6" }, + { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "ruff", specifier = ">=0.15.9" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +]