Skip to content

Packaging your code

Publishing your core rust code to crates.io is pretty straightforward, ensuring that your python code can be used by people on different systems and with multiple versions of python needs more work.

I'll assume you are happy publishing via github actions in general and may add an excursion with more detail later.

Note:

I've used azure devops in the place of PyPi and crates.io to host the final packages (the world really doesn't need another fizzbuzz implementation spamming public package repositories!), publishing to the main package stores is easier.

Crates.io

Packaging for crates.io

You can follow the normal process for publishing to crates.io:

/projectroot$ cargo build --release -p fizzbuzz
/projectroot$ cargo package -p fizzbuzz
/projectroot$ cargo publish --package fizzbuzz

Just remember to add -p fizzbuzz so you don't accidentally also try to publish your wrapped code!

Publishing from github actions

Here is the workflow I used to publish (to ADO):

.github/workflows/deploy-rust.yml

name: Deploy Rust
concurrency: ADO-Cargo

on:
  workflow_dispatch:
  push: 
    tags: "rust-v*"

jobs:
  quality-check:
    uses: ./.github/workflows/rust.yml

  publish:
    environment: ADO-Packages
    needs: quality-check
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
      with:
        submodules: true
    - name: Setup Rust
      uses: actions-rs/toolchain@v1
      with:
        profile: minimal
        toolchain: stable
    - name: Build and package
      run: |
        cargo build --release -p fizzbuzz
        cargo package -p fizzbuzz
    - name: Login to ADO
      env:
        PAT: ${{ secrets.ADO_TOKEN }}
      run: echo -n Basic $(echo -n PAT:$PAT | base64) | cargo login --registry FizzBuzz
    - name: Publish to ADO
      run: cargo publish --package fizzbuzz --registry FizzBuzz

TODO - publishing to ADO

.cargo/config.toml

[registries]
FizzBuzz = { index = "sparse+https://pkgs.dev.azure.com/MusicalNinjas/FizzBuzz/_packaging/FizzBuzz/Cargo/index/" }

# Priority given to wincred if running on WSL (later entries have higher precedence)
[registry]
global-credential-providers = ["cargo:token"]

Pypi

The python distribution is significantly more tricky than the rust one in this situation.

Distribution formats - pre-built vs build from source

Python offers two distribution formats for packages:

  • Source distributions which require the user to have a rust toolchain in place and to compile your code themselves when they install it.
  • Binary distributions which require you to compile your code for a specific combination of operating system, architecture and python version.

Here's how to successfully provide both and support the widest range of users possible:

Supporting many linux distros

Each version of each linux distro has different standard libraries available. To ensure compatibility with as many as possible PyPa defined manylinux with clear restrictions on build environments. cibuildhweel (see below)makes it easy to meet these requirements.

Use cibuildwheel from PyPa

PyPA provide cibuildwheel to make the process of building wheels for different versions of python and different OS & architectures reasonably simple.

  1. Configure cibuildwheel to install rust and clean any existing build artefacts before beginning builds, via ./pyproject.toml:
    ...
    [tool.cibuildwheel.linux]
        before-all = "just clean"
        archs = "all"
    
    [tool.cibuildwheel.macos]
        before-all = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && cargo install just && just clean"
    
    [tool.cibuildwheel.windows]
        before-all = "rustup target add aarch64-pc-windows-msvc i586-pc-windows-msvc i686-pc-windows-msvc x86_64-pc-windows-msvc && cargo install just && just clean"
    ...
    
  2. Test each wheel after building, you'll also need delvewheel on windows builds (./pyproject.toml):
    ...
    [tool.cibuildwheel]
        # `test-command` will FAIL if only passed `pytest {package}`
        # as this tries to resolve imports based on source files (where there is no `.so` for rust libraries), not installed wheel
        test-command = "pytest {package}/tests"
        test-extras = "test"
        ...
        [tool.cibuildwheel.windows]
            ...
            before-build = "pip install delvewheel"
            repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}"
    ...
    
  3. Skip linux architecture / library combinations with no rust version available (./pyproject.toml):
    ...
    [tool.cibuildwheel]
        skip = [
            "*-musllinux_i686",    # No musllinux-i686 rust compiler available.
            "*-musllinux_ppc64le", # no rust target available.
            "*-musllinux_s390x",   # no rust target available.
        ]
        ...
    
  4. Set up a build pipeline with a job to run cibuildwheel for you on Windows, Macos & Linux and to create a source distribution. Run the various linux builds using a matrix strategy to reduce your build time significantly.
.github/workflows/deploy-python.yml - full source
name: Deploy Python
concurrency: ADO-twine

on:
  workflow_dispatch:
  push: 
    tags: "python-v*"

jobs:
  python-checks:
    uses: ./.github/workflows/python.yml

  rust-checks:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - name: Set up Python
      uses: actions/setup-python@v5
      with:
        python-version: 3.9
    - name: Test fizzbuzzo3
      run: cargo test --workspace

  wheels:
    name: Build wheels on ${{ matrix.os }} - ${{ matrix.target }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        # macos-13 is an intel runner, macos-14 is apple silicon
        os: [ubuntu-latest, windows-latest, macos-13, macos-14]
        target: ['*']
        include:
        - os: ubuntu-latest
          target: '*x86*'
        - os: ubuntu-latest
          target: '*i686*'
        - os: ubuntu-latest
          target: '*manylinux_aarch64*'
        - os: ubuntu-latest
          target: '*musllinux_aarch64*'
        - os: ubuntu-latest
          target: '*ppc64le*'
        - os: ubuntu-latest
          target: '*s390x*'
        exclude:
        - os: ubuntu-latest
          target: '*'
    steps:
      - uses: actions/checkout@v4
      - name: Set up QEMU
        if: runner.os == 'Linux'
        uses: docker/setup-qemu-action@v3
        with:
          platforms: all
      - name: Build wheels
        uses: musicalninjadad/cibuildwheel@MusicalNinjaDad/issue1850
        env:
          CIBW_BUILD: ${{ matrix.target }}
        # with:
        #   package-dir: .
        #   output-dir: wheelhouse
        #   config-file: "{package}/pyproject.toml"
      - uses: actions/upload-artifact@v4
        with:
          name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
          path: ./wheelhouse/*.whl

  sdist:
    name: Make SDist
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - name: Build SDist
      run: pipx run build --sdist
    - uses: actions/upload-artifact@v4
      with:
        name: cibw-sdist
        path: dist/*.tar.gz

  publish:
    environment: ADO-Packages
    needs: [python-checks, rust-checks, wheels, sdist]
    runs-on: ubuntu-latest
    steps:
    - uses: actions/download-artifact@v4
      with:
        pattern: cibw-*
        path: dist
        merge-multiple: true
    - name: Install tooling
      run: python -m pip install twine keyring artifacts-keyring
    - name: Upload to ADO
      env: 
        TWINE_REPOSITORY_URL: https://pkgs.dev.azure.com/MusicalNinjas/FizzBuzz/_packaging/FizzBuzz/pypi/upload/
        TWINE_USERNAME: FizzBuzz
        TWINE_PASSWORD: ${{ secrets.ADO_TOKEN }}
      run: twine upload --non-interactive dist/*
./pyproject.toml - full source
[project]
    name = "fizzbuzz"
    description = "An implementation of the traditional fizzbuzz kata"
    # readme = "README.md"
    license = { text = "MIT" }
    authors = [{ name = "Mike Foster" }]
    dynamic = ["version"]
    requires-python = ">=3.8"
    classifiers = [
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
        "Topic :: Software Development :: Libraries :: Python Modules",
        "Intended Audience :: Developers",
        "Natural Language :: English",
    ]

[project.urls]
    # Homepage = ""
    # Documentation = ""
    Repository = "https://github.com/MusicalNinjaDad/fizzbuzz"
    Issues = "https://github.com/MusicalNinjaDad/fizzbuzz/issues"
    # Changelog = ""

[tool.setuptools.dynamic]
    version = { file = "__pyversion__" }


# ===========================
#       Build, package
# ===========================

[build-system]
    requires = ["setuptools", "setuptools-rust"]
    build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
    where = ["python"]

[[tool.setuptools-rust.ext-modules]]
    # The last part of the name (e.g. "_lib") has to match lib.name in Cargo.toml,
    # but you can add a prefix to nest it inside of a Python package.
    target = "fizzbuzz.fizzbuzzo3"
    path = "rust/fizzbuzzo3/Cargo.toml"
    binding = "PyO3"
    features = ["pyo3/extension-module"] # IMPORTANT!!!
    debug = false # Adds `--release` to `pip install -e .` builds, necessary for performance testing

[tool.cibuildwheel]
    skip = [
        "*-musllinux_i686",    # No musllinux-i686 rust compiler available.
        "*-musllinux_ppc64le", # no rust target available.
        "*-musllinux_s390x",   # no rust target available.
    ]
    # Use prebuilt copies of pypa images with rust and just ready compiled (saves up to 25 mins on emulated archs)
    manylinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust"
    manylinux-i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust"
    manylinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust"
    manylinux-ppc64le-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_ppc64le-rust"
    manylinux-s390x-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_s390x-rust"
    manylinux-pypy_x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust"
    manylinux-pypy_i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust"
    manylinux-pypy_aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust"
    musllinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_x86_64-rust"
    musllinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_aarch64-rust"

    # `test-command` will FAIL if only passed `pytest {package}`
    # as this tries to resolve imports based on source files (where there is no `.so` for rust libraries), not installed wheel
    test-command = "pytest {package}/tests"
    test-extras = "test"

    [tool.cibuildwheel.linux]
        before-all = "just clean"
        archs = "all"

    [tool.cibuildwheel.macos]
        before-all = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && cargo install just && just clean"

    [tool.cibuildwheel.windows]
        before-all = "rustup target add aarch64-pc-windows-msvc i586-pc-windows-msvc i686-pc-windows-msvc x86_64-pc-windows-msvc && cargo install just && just clean"
        before-build = "pip install delvewheel"
        repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}"


# ===========================
#  Test, lint, documentation
# ===========================

[project.optional-dependencies]
    stubs = ["pyo3-stubgen"]
    lint = ["ruff"]
    test = ["pytest", "pytest-doctest-mkdocstrings"]
    cov = ["fizzbuzz[test]", "pytest-cov"]
    doc = ["black", "mkdocs", "mkdocstrings[python]", "mkdocs-material", "fizzbuzz[stubs]"]
    dev = ["fizzbuzz[stubs,lint,test,cov,doc]"]

[tool.pytest.ini_options]
    xfail_strict = true
    addopts = [
        "--doctest-modules",
        "--doctest-mdcodeblocks",
        "--doctest-glob=*.pyi",
        "--doctest-glob=*.md",
    ]
    testpaths = ["tests", "python"]

[tool.coverage.run]
    branch = true
    omit = ["tests/*"]
    dynamic_context = "test_function"

[tool.ruff]
    line-length = 120
    format.skip-magic-trailing-comma = false
    format.quote-style = "double"

[tool.ruff.lint]
    select = ["ALL"]
    flake8-pytest-style.fixture-parentheses = false
    flake8-annotations.mypy-init-return = true
    extend-ignore = [
        "E701",   # One-line ifs are not great, one-liners with suppression are worse
        "ANN101", # Type annotation for `self`
        "ANN202", # Return type annotation for private functions
        "ANN401", # Using Any often enough that supressing individually is pointless
        "W291",   # Double space at EOL is linebreak in md-docstring
        "W292",   # Too much typing to add newline at end of each file
        "W293",   # Whitespace only lines are OK for us
        "D401",   # First line of docstring should be in imperative mood ("Returns ..." is OK, for example)
        "D203",   # 1 blank line required before class docstring (No thank you, only after the class docstring)
        "D212",   # Multi-line docstring summary should start at the first line (We start on new line, D209)
        "D215",   # Section underline is over-indented (No underlines)
        "D400",   # First line should end with a period (We allow any punctuation, D415)
        "D406",   # Section name should end with a newline (We use a colon, D416)
        "D407",   # Missing dashed underline after section (No underlines)
        "D408",   # Section underline should be in the line following the section's name (No underlines)
        "D409",   # Section underline should match the length of its name (No underlines)
        "D413",   # Missing blank line after last section (Not required)
    ]

[tool.ruff.lint.per-file-ignores]
    # Additional ignores for tests 
    "**/test_*.py" = [
        "INP001",  # Missing __init__.py
        "ANN",     # Missing type annotations
        "S101",    # Use of `assert`
        "PLR2004", # Magic number comparisons are OK in tests
        "D1",      # Don't REQUIRE docstrings for tests - but they are nice
    ]

    "**/__init__.py" = [
        "D104", # Don't require module docstring in __init__.py
        "F401", # Unused imports are fine: using __init__.py to expose them with implicit __ALL__ 
    ]

Saving hours by using pre-built images

If you are wondering why there is no "install rust" command for the linux builds; I created dedicated container images for of pypa's base images with rust, just etc. pre-installed. This saves a load of time during the build, as installing rust and compiling the various components can be very slow under emulation.

The dockerfile and build process are at MusicalNinjas/cibuildwheel-rust, They get bumped by dependabot every time pypa release a new image for manylinux2014_x86_64 so should be nice and up to date.

The images can be used by including the following in your ./pyproject.toml:

[tool.cibuildwheel]
    ...
    # Use prebuilt copies of pypa images with rust and just ready compiled (saves up to 25 mins on emulated archs)
    manylinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust"
    manylinux-i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust"
    manylinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust"
    manylinux-ppc64le-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_ppc64le-rust"
    manylinux-s390x-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_s390x-rust"
    manylinux-pypy_x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust"
    manylinux-pypy_i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust"
    manylinux-pypy_aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust"
    musllinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_x86_64-rust"
    musllinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_aarch64-rust"
    ...

MusicalNinjas/cibuildwheel-rust/Dockerfile - full source
# Dependabot can't decode the yyyy-mm-dd-sha format used by cibuildwheel, so no point in restricting tags here.
# (See https://github.com/pypa/cibuildwheel/discussions/1858)
ARG baseImage=quay.io/pypa/manylinux2014_x86_64
FROM ${baseImage}

ARG rustup_options=
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal ${rustup_options}
ENV PATH=/root/.cargo/bin:$PATH
RUN cargo install just
MusicalNinjas/cibuildwheel-rust/.github/workflows/publish_docker.yml - full source
name: Build and publish Docker images for devcontainer

on: 
  push:
    branches: 
    - "main"
    paths: 
    - "Dockerfile"
    - ".github/workflows/publish_docker.yml"

jobs:
  build-and-publish:
    name: build ${{ matrix.baseImage }}
    permissions:
      packages: write
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
        - baseImage: "manylinux2014_x86_64"
          platform: "linux/amd64"
        - baseImage: "manylinux2014_i686"
          rustup_options: "--default-host i686-unknown-linux-gnu" # not correctly identified by rustup under QEMU on x64
          platform: "linux/386"
        - baseImage: "manylinux2014_aarch64"
          platform: "linux/arm64/v8"
        - baseImage: "manylinux2014_ppc64le"
          platform: "linux/ppc64le"
        - baseImage: "manylinux2014_s390x"
          platform: "linux/s390x"
        - baseImage: "musllinux_1_2_x86_64"
          platform: "linux/amd64"
        - baseImage: "musllinux_1_2_aarch64"
          platform: "linux/arm64/v8"

    steps:
    - name: checkout
      uses: actions/checkout@v4

    - name: Set up QEMU
      uses: docker/setup-qemu-action@v3
      with:
        platforms: all

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    - name: Login to ghcr.io
      uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

    - name: Build and push
      uses: docker/build-push-action@v5
      with:
        context: .
        push: true
        cache-from: type=gha
        cache-to: type=gha,mode=max
        platforms: ${{ matrix.platform }}
        build-args: |
          baseImage=quay.io/pypa/${{ matrix.baseImage }}
          rustup_options=${{ matrix.rustup_options }}
        tags: ghcr.io/musicalninjas/${{ matrix.baseImage }}-rust

avoiding a 6 hour build

At one point I had a build run for nearly 6 hours before I cancelled it. The problem was that I use ruff for linting, and ruff doesn't provide pre-built wheels for some manylinux architectures. That meant that I was building ruff from scratch, under emulation for each wheel I built on these architectures!

Separating the [test], [lint], [doc] and [cov]erage dependencies solved this; after a lot of searching I also found the way to provide a single [dev] set of dependencies combing them all.

In ./pyproject.toml:

...
[tool.cibuildwheel]
    ...
    test-extras = "test"
...
[project.optional-dependencies]
    lint = ["ruff"]
    test = ["pytest", "pytest-doctest-mkdocstrings"]
    cov = ["fizzbuzz[test]", "pytest-cov"]
    doc = ["black", "mkdocs", "mkdocstrings[python]", "mkdocs-material"]
    dev = ["fizzbuzz[lint,test,cov,doc]"]
...

failing wheel tests due to namespace collisions

I lost over half a day at one point trying to track down the reason that my wheel builds were failing final testing. There were two problems:

  1. Make sure you clean any pre-compiled .so files before building a wheel, otherwise pip wheel may decide that you already have a valid .so and use that, with the wrong versions of the C-libraries. This is only a problem when doing local wheel builds for debugging, because you're not checking the .so files into git are you? (I created a just clean command for this)
  2. Make sure you directly specify the test path for wheel tests in ./pyproject.toml:
    ...
    [tool.cibuildwheel]
        # `test-command` will FAIL if only passed `pytest {package}`
        # as this tries to resolve imports based on source files (where there is no `.so` for rust libraries), not installed wheel
        test-command = "pytest {package}/tests"
    ...
    
    otherwise the directive tool.pytest.ini_options.testpaths = ["tests", "python"] will cause pytest to look in python, find your namespace, and ignore the installed wheel!
./pyproject.toml - full source
[project]
    name = "fizzbuzz"
    description = "An implementation of the traditional fizzbuzz kata"
    # readme = "README.md"
    license = { text = "MIT" }
    authors = [{ name = "Mike Foster" }]
    dynamic = ["version"]
    requires-python = ">=3.8"
    classifiers = [
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
        "Topic :: Software Development :: Libraries :: Python Modules",
        "Intended Audience :: Developers",
        "Natural Language :: English",
    ]

[project.urls]
    # Homepage = ""
    # Documentation = ""
    Repository = "https://github.com/MusicalNinjaDad/fizzbuzz"
    Issues = "https://github.com/MusicalNinjaDad/fizzbuzz/issues"
    # Changelog = ""

[tool.setuptools.dynamic]
    version = { file = "__pyversion__" }


# ===========================
#       Build, package
# ===========================

[build-system]
    requires = ["setuptools", "setuptools-rust"]
    build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
    where = ["python"]

[[tool.setuptools-rust.ext-modules]]
    # The last part of the name (e.g. "_lib") has to match lib.name in Cargo.toml,
    # but you can add a prefix to nest it inside of a Python package.
    target = "fizzbuzz.fizzbuzzo3"
    path = "rust/fizzbuzzo3/Cargo.toml"
    binding = "PyO3"
    features = ["pyo3/extension-module"] # IMPORTANT!!!
    debug = false # Adds `--release` to `pip install -e .` builds, necessary for performance testing

[tool.cibuildwheel]
    skip = [
        "*-musllinux_i686",    # No musllinux-i686 rust compiler available.
        "*-musllinux_ppc64le", # no rust target available.
        "*-musllinux_s390x",   # no rust target available.
    ]
    # Use prebuilt copies of pypa images with rust and just ready compiled (saves up to 25 mins on emulated archs)
    manylinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust"
    manylinux-i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust"
    manylinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust"
    manylinux-ppc64le-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_ppc64le-rust"
    manylinux-s390x-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_s390x-rust"
    manylinux-pypy_x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust"
    manylinux-pypy_i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust"
    manylinux-pypy_aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust"
    musllinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_x86_64-rust"
    musllinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_aarch64-rust"

    # `test-command` will FAIL if only passed `pytest {package}`
    # as this tries to resolve imports based on source files (where there is no `.so` for rust libraries), not installed wheel
    test-command = "pytest {package}/tests"
    test-extras = "test"

    [tool.cibuildwheel.linux]
        before-all = "just clean"
        archs = "all"

    [tool.cibuildwheel.macos]
        before-all = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && cargo install just && just clean"

    [tool.cibuildwheel.windows]
        before-all = "rustup target add aarch64-pc-windows-msvc i586-pc-windows-msvc i686-pc-windows-msvc x86_64-pc-windows-msvc && cargo install just && just clean"
        before-build = "pip install delvewheel"
        repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}"


# ===========================
#  Test, lint, documentation
# ===========================

[project.optional-dependencies]
    stubs = ["pyo3-stubgen"]
    lint = ["ruff"]
    test = ["pytest", "pytest-doctest-mkdocstrings"]
    cov = ["fizzbuzz[test]", "pytest-cov"]
    doc = ["black", "mkdocs", "mkdocstrings[python]", "mkdocs-material", "fizzbuzz[stubs]"]
    dev = ["fizzbuzz[stubs,lint,test,cov,doc]"]

[tool.pytest.ini_options]
    xfail_strict = true
    addopts = [
        "--doctest-modules",
        "--doctest-mdcodeblocks",
        "--doctest-glob=*.pyi",
        "--doctest-glob=*.md",
    ]
    testpaths = ["tests", "python"]

[tool.coverage.run]
    branch = true
    omit = ["tests/*"]
    dynamic_context = "test_function"

[tool.ruff]
    line-length = 120
    format.skip-magic-trailing-comma = false
    format.quote-style = "double"

[tool.ruff.lint]
    select = ["ALL"]
    flake8-pytest-style.fixture-parentheses = false
    flake8-annotations.mypy-init-return = true
    extend-ignore = [
        "E701",   # One-line ifs are not great, one-liners with suppression are worse
        "ANN101", # Type annotation for `self`
        "ANN202", # Return type annotation for private functions
        "ANN401", # Using Any often enough that supressing individually is pointless
        "W291",   # Double space at EOL is linebreak in md-docstring
        "W292",   # Too much typing to add newline at end of each file
        "W293",   # Whitespace only lines are OK for us
        "D401",   # First line of docstring should be in imperative mood ("Returns ..." is OK, for example)
        "D203",   # 1 blank line required before class docstring (No thank you, only after the class docstring)
        "D212",   # Multi-line docstring summary should start at the first line (We start on new line, D209)
        "D215",   # Section underline is over-indented (No underlines)
        "D400",   # First line should end with a period (We allow any punctuation, D415)
        "D406",   # Section name should end with a newline (We use a colon, D416)
        "D407",   # Missing dashed underline after section (No underlines)
        "D408",   # Section underline should be in the line following the section's name (No underlines)
        "D409",   # Section underline should match the length of its name (No underlines)
        "D413",   # Missing blank line after last section (Not required)
    ]

[tool.ruff.lint.per-file-ignores]
    # Additional ignores for tests 
    "**/test_*.py" = [
        "INP001",  # Missing __init__.py
        "ANN",     # Missing type annotations
        "S101",    # Use of `assert`
        "PLR2004", # Magic number comparisons are OK in tests
        "D1",      # Don't REQUIRE docstrings for tests - but they are nice
    ]

    "**/__init__.py" = [
        "D104", # Don't require module docstring in __init__.py
        "F401", # Unused imports are fine: using __init__.py to expose them with implicit __ALL__ 
    ]
./justfile - full source
# list available recipes
list:
  @just --list --justfile {{justfile()}}

# remove pre-built rust and python libraries (excluding .venv)
clean:
    - cargo clean
    rm -rf .pytest_cache
    rm -rf build
    rm -rf dist
    rm -rf wheelhouse
    rm -rf .ruff_cache
    find . -depth -type d -not -path "./.venv/*" -name "__pycache__" -exec rm -rf "{}" \;
    find . -depth -type d -path "*.egg-info" -exec rm -rf "{}" \;
    find . -type f -name "*.egg" -delete
    find . -type f -name "*.so" -delete

# clean out coverage files
clean-cov:
    find . -type f -name "*.profraw" -delete
    rm -rf pycov
    rm -rf rustcov

# clean, remove existing .venv and rebuild the venv with pip install -e .[dev]
reset: clean clean-cov
    rm -rf .venv
    python -m venv .venv
    .venv/bin/python -m pip install --upgrade pip 
    .venv/bin/pip install -e .[dev]

# lint rust files with fmt & clippy
lint-rust:
  - cargo fmt --check
  - cargo clippy --workspace

# test rust workspace
test-rust:
  - cargo test --quiet --workspace

# lint and test rust
check-rust: lint-rust test-rust

# lint python with ruff
lint-python:
  - .venv/bin/ruff check .

# test python
test-python:
  - .venv/bin/pytest

# lint and test python
check-python: lint-python test-python

# lint and test both rust and python
check: check-rust check-python

#run coverage analysis on rust & python code
cov:
  RUSTFLAGS="-Cinstrument-coverage" cargo build
  RUSTFLAGS="-Cinstrument-coverage" LLVM_PROFILE_FILE="tests-%p-%m.profraw" cargo test
  -mkdir rustcov
  grcov . -s . --binary-path ./target/debug/ -t html,lcov --branch --ignore-not-existing --excl-line GRCOV_EXCL_LINE --output-path ./rustcov
  pytest --cov --cov-report html:pycov --cov-report term

# serve rust coverage results on localhost:8002 (doesn't run coverage analysis)
rust-cov:
  python -m http.server -d ./rustcov/html 8002

# serve python coverage results on localhost:8003 (doesn't run coverage analysis)
py-cov:
  python -m http.server -d ./pycov 8003

# serve python docs on localhost:8000
py-docs:
  mkdocs serve

#build and serve rust API docs on localhost:8001
rust-docs:
  cargo doc
  python -m http.server -d target/doc 8001

# build and test a wheel (a suitable venv must already by active!)
test-wheel: clean
  cibuildwheel --only cp312-manylinux_x86_64