Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
169 changes: 169 additions & 0 deletions .github/workflows/python-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: Python release

# Publishes the Python bindings to PyPI: a source distribution, and a
# wheel for Linux, macOS and Windows.
#
# This is a file of its own rather than jobs in python.yml, and that is
# the point. python.yml runs on every push and every pull request; this
# one has a single trigger, `push: tags`, with no `branches` key and no
# `pull_request` key at all. A branch push cannot reach a macOS runner
# from here -- not because a condition forbids it and could be edited
# away, but because no event is wired to it. macOS bills at ten times the
# ubuntu rate and Windows at twice, so once per release is a fair price
# and once per pull request is not.
#
# The `if:` on each job is redundant with that trigger, deliberately: it
# is there so that anyone who later adds a second event to this file has
# to walk past it before these runners start answering to it.
#
# ── How the upload is authorised ─────────────────────────────────────
#
# PyPI Trusted Publishing, so there is no API token in this repository's
# secrets and nothing to leak or rotate. The publisher registered on PyPI
# for `numeria` names three things, and all three have to keep matching or
# the upload is refused:
#
# repository Magic-Man-us/RustPhysicsEngine
# workflow python-release.yml <- this file's name
# environment pypi <- the publish job's environment
#
# So renaming this file, moving the publish step into another one, or
# changing that environment name breaks the release until PyPI is told.
#
# The environment is worth having for its own sake as well: a required
# reviewer on it, set in the repository's settings, makes every publish
# wait for a human. A version can never be re-uploaded to PyPI, even after
# deleting it, so the one irreversible step in this file is the one worth
# putting a person in front of.
#
# Every `run:` is quoted, for the reason given in verify.yml: an unquoted
# value containing a colon followed by a space parses as a nested mapping
# and invalidates the file.

on:
push:
tags: ["v*"]

# Nothing here needs write access to the repository. The publish job adds
# the one permission it does need, and nothing else inherits it.
permissions: {}

env:
CARGO_TERM_COLOR: always

jobs:
# Cheap, and first: if the tag and Cargo.toml disagree, say so before
# spending three platforms' worth of runner minutes finding out.
version:
name: "Version matches the tag"
if: "startsWith(github.ref, 'refs/tags/v')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: The tag, the crate and the bindings agree
run: "python3 bindings/python/check_version.py '${{ github.ref }}'"

sdist:
name: "Source distribution"
if: "startsWith(github.ref, 'refs/tags/v')"
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The bindings depend on the library by path, so the archive has to
# carry the library too. maturin vendors it: the sdist contains the
# whole crate, and `pip install` of it builds against that copy.
# This is what makes the package installable on a platform none of
# the wheels below covers.
- uses: PyO3/maturin-action@v1
with:
command: sdist
args: "--out dist --manifest-path bindings/python/Cargo.toml"
- uses: actions/upload-artifact@v4
with:
name: dist-sdist
path: dist

linux:
name: "Wheel (Linux x86_64)"
if: "startsWith(github.ref, 'refs/tags/v')"
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# `manylinux: auto` builds inside a manylinux container, so the
# wheel is not pinned to whatever glibc this runner happens to
# carry. Plain `maturin build` here produces a manylinux_2_35 tag
# that will not install on an older distribution.
- uses: PyO3/maturin-action@v1
with:
target: x86_64
manylinux: auto
args: "--release --out dist --manifest-path bindings/python/Cargo.toml"
- uses: actions/upload-artifact@v4
with:
name: dist-linux-x86_64
path: dist

macos:
name: "Wheel (macOS universal2)"
if: "startsWith(github.ref, 'refs/tags/v')"
needs: version
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
# One universal2 wheel covers both Apple Silicon and Intel, so this
# is a single mac runner rather than two. At ten times the ubuntu
# rate that halving is the whole reason for the target choice.
- uses: PyO3/maturin-action@v1
with:
target: universal2-apple-darwin
args: "--release --out dist --manifest-path bindings/python/Cargo.toml"
- uses: actions/upload-artifact@v4
with:
name: dist-macos-universal2
path: dist

windows:
name: "Wheel (Windows x64)"
if: "startsWith(github.ref, 'refs/tags/v')"
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: PyO3/maturin-action@v1
with:
target: x64
args: "--release --out dist --manifest-path bindings/python/Cargo.toml"
- uses: actions/upload-artifact@v4
with:
name: dist-windows-x64
path: dist

publish:
name: "Publish to PyPI"
if: "startsWith(github.ref, 'refs/tags/v')"
needs: [sdist, linux, macos, windows]
runs-on: ubuntu-latest
# Named so PyPI can be told to trust exactly this job, and so that a
# required reviewer on the environment gates the one step here that
# cannot be undone.
environment:
name: pypi
url: "https://pypi.org/p/numeria"
permissions:
# The OIDC token Trusted Publishing exchanges for an upload. This is
# the only elevated permission in the file, and it is scoped to this
# job.
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
pattern: dist-*
path: dist
merge-multiple: true
- name: What is about to be published
run: "ls -l dist"
- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
77 changes: 77 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Python

# The Python bindings under bindings/python. They are a separate Cargo
# workspace -- the library's own Cargo.lock stays a single package -- so
# they need a job of their own; ci.yml never builds them.
#
# One job, on ubuntu-latest. There is deliberately no wheel-building
# matrix: macOS runners bill at ten times the ubuntu rate and Windows at
# twice, and building the same crate on three platforms establishes
# nothing this job has not. When there is a release to cut, build the
# wheels then -- locally, or in a workflow added for that purpose.
#
# Every `run:` is quoted, for the reason given in verify.yml: an unquoted
# value containing a colon followed by a space parses as a nested mapping
# and invalidates the file.

on:
push:
branches: [main]
pull_request:
branches: [main]

env:
CARGO_TERM_COLOR: always

jobs:
bindings:
name: "Bindings"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2
with:
workspaces: "bindings/python"

# 3.9 is the floor the abi3 wheel targets, and abi3 means one binary
# serves every version above it -- so a matrix here would compile
# the same crate twice to learn nothing.
- uses: actions/setup-python@v5
with:
python-version: "3.9"

- name: Install the build and test tools
run: "python -m pip install --upgrade pip maturin pytest"

# The bindings are generated from the library's source. If a commit
# changes the library and does not re-run the generator, what is
# committed here describes a library that no longer exists -- and
# nothing else would notice, because stale bindings still compile.
# This is the check that notices.
- name: The committed bindings match the source
run: "python3 bindings/python/generate.py --check"

# Checked here as well as at release time, because a mismatch
# introduced now is cheapest to fix now. At release time the same
# check is the last thing standing between a wrong version number
# and a PyPI upload that cannot be taken back.
- name: The crate and the bindings claim one version
run: "python3 bindings/python/check_version.py"

# `pip install` rather than `maturin develop`: develop wants a
# virtualenv to install into, and setup-python does not make one.
- name: Build and install
run: "python -m pip install --no-build-isolation ./bindings/python"

- name: Test
run: "python -m pytest bindings/python/tests -q"

# The stubs are generated too, and a stub that disagrees with the
# module it describes is worse than no stub: it type-checks code
# that will fail at run time.
- name: Stubs describe the module that was built
run: "python3 bindings/python/check_stubs.py"

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
/target
*.profraw
/bindings/python/target
/bindings/python/**/__pycache__
# Where `maturin develop` drops the built extension.
/bindings/python/python/rust_physics_engine/*.so
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,39 @@ assert_eq!(one, Rational::one()); // not 0.9999999999999999

---

## From Python

Published to PyPI as **numeria**: 4,086 of this crate's 4,149 free
functions, 2,254 of its 2,277 methods, 416 of its 426 types and every
constant. The bindings live in [`bindings/python`](bindings/python) and
are generated from this crate's source, so they cannot fall behind it.

```console
$ pip install numeria
```

```python
import math
import numeria as nm

nm.classical.projectile_range(50.0, math.pi / 4, 9.81) # 254.841997961...
nm.linalg.lu.solve([[2, 1], [1, 3]], [5, 10]) # [1.0, 3.0]
nm.numerical.integrate.simpson(math.sin, 0.0, math.pi, 1000)
nm.exact.bigint.factorial(100) # a Python int
```

Every Python module mirrors a Rust module of the same name beneath
`numeria`. `Result` errors
become exceptions under one `PhysicsError` root; a `Vec3` argument accepts
`(x, y, z)`; `Complex`, `BigInt` and `Rational` cross over as Python's own
`complex`, `int` and `fractions.Fraction`; and anywhere the library takes a
`&dyn Fn`, a Python callable will do. See
[`bindings/python/README.md`](bindings/python/README.md), and
[`bindings/python/COVERAGE.md`](bindings/python/COVERAGE.md) for the list of
what is not bound and why.

---

# What's in it

Equations below are the ones the code actually implements, not a
Expand Down
Loading
Loading