diff --git a/.gitignore b/.gitignore index 9230175..83074c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ _build +build *.swp _output node_modules @@ -8,6 +9,12 @@ node_modules CLAUDE.md .claude/ +# Agent-local tool state +.serena/ + # pixi environments .pixi/* !.pixi/config.toml + +# Python virtual environments +.venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9122071..0d70059 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: rev: "06507ab9500d4a9919b5ebd76d9d5b7074f15c1f" # frozen: v3.8.4 hooks: - id: prettier - exclude: '\.md$' # Markdown is handled by rumdl + exclude: '(\.md$|(^|/)pixi\.lock$)' # Markdown is handled by rumdl; pixi.lock is generated by pixi (see .gitattributes) - repo: https://github.com/codespell-project/codespell rev: "2ccb47ff45ad361a21071a7eedda4c37e6ae8c5a" # frozen: v2.4.2 diff --git a/AGENTS.md b/AGENTS.md index 048455f..385f7bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## What this is -SIMPLE-Py is a workshop that teaches simple compiled Python packaging (C++, Rust, publishing). It is **content, not code**: a [MyST](https://mystmd.org/) book plus [Marp](https://marp.app/) slides, deployed to GitHub Pages. There is no Python package to build or test here — edits are to Markdown. +SIMPLE-Py is a workshop that teaches simple compiled Python packaging (C++, Rust, publishing). It is **content, not code**: a [MyST](https://mystmd.org/) book plus [Marp](https://marp.app/) slides, deployed to GitHub Pages. The book itself has no package to build or test (edits are to Markdown), though some chapters have runnable companion projects under `examples/`. ## Commands @@ -19,11 +19,13 @@ prek -a --quiet # lint/format everything (ruff-format, blacken-docs, rumdl ## Structure -- **`content/`** — book chapters as Markdown, grouped by section directory (`basic-packaging/`, `compiled/`, `scikit-build/`, `other-tools/`, `interesting/`). Files are prefixed with an order number (`01_`, `02_`, …). Images live alongside their chapter, prefixed with the chapter stem (e.g. `04_distros-shipping.jpg`). -- **`myst.yml`** — book config and the **table of contents**. Adding or reordering a chapter file requires editing the `toc` here; the filesystem order is not authoritative. -- **`slides/`** — Marp decks (`marp: true` frontmatter, `theme: simplepy` → `slides/simplepy.css`). Named `
__.md` where the leading digit ties the deck to its content section. +- **`content/`** - book chapters as Markdown, grouped by section directory (`basic-packaging/`, `compiled/`, `scikit-build/`, `other-tools/`, `interesting/`). Files are prefixed with an order number (`01_`, `02_`, …). Images live alongside their chapter, prefixed with the chapter stem (e.g. `04_distros-shipping.jpg`). +- **`myst.yml`** - book config and the **table of contents**. Adding or reordering a chapter file requires editing the `toc` here; the filesystem order is not authoritative. +- **`slides/`** - Marp decks (`marp: true` frontmatter, `theme: simplepy` → `slides/simplepy.css`). Named `
__.md` where the leading digit ties the deck to its content section. +- **`examples/`** - runnable companion projects for chapters, named `
__` like the slides (e.g. `2_04_rust_pyo3/` is a pixi-managed PyO3 extension). Each is self-contained: `pixi install`, then tasks like `pixi run test` or `pixi run bench` from inside the example directory. +- **`instructor-notes/`** - instructor-facing research notes and deep dives, with Obsidian-style frontmatter and `[[wiki-links]]`. Deliberately absent from the `myst.yml` toc, so they are never published into the book. ## Conventions -- `blacken-docs` formats Python code blocks inside Markdown, and `ruff-format` runs on code — keep embedded code snippets valid and formatted. +- `blacken-docs` formats Python code blocks inside Markdown, and `ruff-format` runs on code - keep embedded code snippets valid and formatted. - CI (`.github/workflows/cd.yml`) builds with `BASE_URL: /SIMPLE-Py` and deploys to Pages on push to `main`. diff --git a/content/basic-packaging/03_package.md b/content/basic-packaging/03_package.md index 2805056..e4d58b5 100644 --- a/content/basic-packaging/03_package.md +++ b/content/basic-packaging/03_package.md @@ -1 +1,690 @@ # Making a basic package + +{button}`Slides ` + +In the last chapters you learned how environments and dependencies work, and +you saw a minimal `pyproject.toml`. Now you'll build a real package from +scratch, by hand, so that every file in it is something you understand. By the +end of this hour you will have a package that: + +- lives in a standard `src` layout, +- installs (editably) into an environment, +- has complete, standards-based metadata, +- provides a command-line script, +- gets its version from git, and +- builds into an SDist and a wheel you can inspect. + +We'll build everything manually this time; the next chapter shows the +template that generates all of this (and more) for you. + +## From script to package + +Much code starts life in a notebook or a script. Ours is a single function: + +```python +import numpy as np + + +def rescale(input_array): + """Rescale an array so its values span [0, 1].""" + low = np.min(input_array) + high = np.max(input_array) + return (input_array - low) / (high - low) +``` + +It works (`rescale(np.linspace(0, 100, 5))` gives `[0, 0.25, 0.5, 0.75, 1]`), +but you can't `pip install` it, can't `import` it from another project, and +can't share it. Let's fix that. + +### The layout + +Make a new directory with a git repo, and lay out the files like this: + +```text +rescale +├── pyproject.toml +└── src + └── rescale + ├── __init__.py + └── core.py +``` + +- `core.py` holds the function above. +- `__init__.py` marks the directory as a package, and defines your public API: + +```{code} python +:filename: src/rescale/__init__.py +from rescale.core import rescale + +__all__ = ["rescale"] +``` + +> [!NOTE] +> Why the extra `src` level? If your package sits in the project root, `python` +> and `pytest` will happily import the local folder instead of the installed +> package, hiding packaging bugs (like forgetting to include a file) until a +> user hits them. The `src` layout forces everything through a real install. +> It also matches how compiled projects are laid out, which will pay off later +> in this workshop. + +### The pyproject.toml + +The only other required file is `pyproject.toml`. Two tables matter: + +```{code} toml +:filename: pyproject.toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "rescale" +version = "0.1.0" +dependencies = ["numpy"] +``` + +`[build-system]` selects a **build backend**: the tool that turns your source +tree into something installable. The frontend (pip, uv, ...) installs +`requires` into an isolated environment and asks the `build-backend` to do the +build. All backends read the same standard `[project]` table, so switching is +easy; they differ in file selection, dynamic versioning, and extras: + +::::{tab-set} + +:::{tab-item} Hatchling + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +``` + +A great default: fast, extendable, good file-inclusion defaults (it reads +your `.gitignore`). We'll use it in this chapter. + +::: + +:::{tab-item} uv_build + +```toml +[build-system] +requires = ["uv_build>=0.7.19"] +build-backend = "uv_build" +``` + +uv's own backend; very fast, intentionally minimal, pairs well with `uv init`. + +::: + +:::{tab-item} Flit-core + +```toml +[build-system] +requires = ["flit_core>=3.12"] +build-backend = "flit_core.buildapi" +``` + +Tiny and dependency-free; the backend many core PyPA tools use themselves. + +::: + +:::{tab-item} Setuptools + +```toml +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" +``` + +The classic. Modern setuptools reads `[project]` too; you only need +`setup.py`/`MANIFEST.in` for legacy features. + +::: + +:::: + +> [!NOTE] +> `name` is the install name (`pip install rescale`), and the folder under +> `src/` is the import name (`import rescale`). Keep them identical if you can +> (normalizing `-` to `_`); backends auto-detect the package when they match. + +### Install it and use it + +Now install your package into an environment. You have a low-level and a +high-level option: + +::::{tab-set} + +:::{tab-item} uv run (high level) + +```bash +uv run python -c "import rescale, numpy; print(rescale.rescale(numpy.linspace(0, 100, 5)))" +``` + +Remember from the setup chapter: `uv run` makes the venv, installs your +package editably along with its dependencies, and runs the command. There is +no step two. + +::: + +:::{tab-item} pip install -e (low level) + +```bash +uv venv +uv pip install -e . +.venv/bin/python -c "import rescale, numpy; print(rescale.rescale(numpy.linspace(0, 100, 5)))" +``` + +The `-e` makes it an editable install (covered in the setup chapter): edits +to `src/rescale/` are visible on the next `import`, no reinstall needed. + +::: + +:::: + +:::{exercise} Build the package +:label: pkg-minimal + +Create the `rescale` package exactly as above: the `src` layout, the two +Python files, and the minimal `pyproject.toml`. Initialize a git repo and +commit. Then prove it works by importing it and rescaling +`numpy.linspace(0, 100, 5)`. + +Bonus: temporarily delete `dependencies = ["numpy"]`, remove `.venv` and +`uv.lock`, and see what happens when you try again. + +::: + +:::{solution} pkg-minimal +:class: dropdown + +```bash +mkdir rescale && cd rescale +git init +mkdir -p src/rescale +# create core.py, __init__.py, and pyproject.toml as above +git add -A && git commit -m "feat: initial package" +uv run python +>>> import numpy as np +>>> from rescale import rescale +>>> rescale(np.linspace(0, 100, 5)) +array([0. , 0.25, 0.5 , 0.75, 1. ]) +``` + +Without the `dependencies` line, the import fails with +`ModuleNotFoundError: No module named 'numpy'`: your package installed fine, +but nothing told the installer that NumPy must come along. + +::: + +### A first test + +Let's lock in that manual check as a real test, in a `tests/` folder next to +`src/` (not inside it): + +```{code} python +:filename: tests/test_core.py +import numpy as np + +from rescale import rescale + + +def test_rescale(): + np.testing.assert_allclose( + rescale(np.linspace(0, 100, 5)), + np.array([0.0, 0.25, 0.5, 0.75, 1.0]), + ) +``` + +pytest is a development-only dependency, so it goes in a dependency group, +not in `dependencies`: + +```{code} toml +:filename: pyproject.toml +[dependency-groups] +dev = ["pytest"] +``` + +Since uv installs the `dev` group by default, running the tests is just: + +```bash +uv run pytest +``` + +The next chapter covers testing properly; for now, one passing test is enough +to tell us we haven't broken anything while we work on the metadata. + +## Filling out the metadata + +`name` and `version` are the only required fields, but a real package should +tell its users (and PyPI) much more. Everything goes in the same standard +`[project]` table, regardless of backend. + +### Informational metadata + +These fields describe your package to humans and search engines: + +```{code} toml +:filename: pyproject.toml +[project] +name = "rescale" +version = "0.1.0" +description = "Rescale NumPy arrays to span [0, 1]." +readme = "README.md" +authors = [{ name = "My Name", email = "me@email.com" }] +license = "BSD-3-Clause" +license-files = ["LICENSE"] +keywords = ["arrays", "normalization"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering", + "Private :: Do Not Upload", +] + +[project.urls] +Homepage = "https://github.com/me/rescale" +"Bug Tracker" = "https://github.com/me/rescale/issues" +Changelog = "https://github.com/me/rescale/releases" +``` + +A few of these deserve a closer look: + +license +: The modern form is an [SPDX identifier expression][spdx] like +`"BSD-3-Clause"` or `"MIT AND (Apache-2.0 OR BSD-2-Clause)"`, plus +`license-files` globs pointing at the license text. You'll see older packages +use a `License ::` classifier or `license = { file = "..." }` table instead; +don't mix the old and new styles. And never write your own license text; pick +a standard one from [choosealicense.com][]. + +classifiers +: Free-form-ish tags from a [fixed list][classifiers]. They help searching +and communicate stability ("Development Status"). The special +`Private :: Do Not Upload` classifier isn't real, which means PyPI will +_reject_ the package; perfect for tutorials and internal code. Remove it when +you mean to publish. + +readme +: Points at your `README.md`; its contents become the package's long +description, rendered on the PyPI page. + +### Functional metadata + +These fields change how installers treat your package: + +```{code} toml +:filename: pyproject.toml +[project] +# ... +requires-python = ">=3.10" +dependencies = ["numpy>=1.24"] + +[project.optional-dependencies] +plot = ["matplotlib"] +``` + +requires-python +: Installers use this to _back-solve_: `pip install rescale` on an old Python +walks back through your releases until it finds one whose `requires-python` +passes. That's what lets you drop old Pythons safely. It only works in that +direction; **never add an upper cap** like `<4` here, it breaks the +back-solving logic and helps nobody. + +dependencies +: Version specifiers support lower bounds (`>=1.24`), caps (`<3`), and pins +(`==2.1.0`). For a library: set lower bounds you actually test, and avoid +upper caps unless you _know_ the next major version breaks you (see +[bound version constraints][] for why). Exact pins belong in lockfiles, not +here; remember from the setup chapter that your users must be able to share +an environment with other packages. + +optional-dependencies +: The extras from the setup chapter: users opt in with +`pip install 'rescale[plot]'`. Unlike dependency groups, extras are published +metadata and available to your users from PyPI. + +:::{exercise} project.dependencies vs. build-system.requires +:label: pkg-deps-quiz + +You've now seen both. What's the difference between putting `numpy` in +`build-system.requires` versus `project.dependencies`? + +::: + +:::{solution} pkg-deps-quiz +:class: dropdown + +`build-system.requires` is installed into a temporary, isolated environment +_while building_ your package, then thrown away; it never reaches your users +(a wheel doesn't even contain `pyproject.toml`). `project.dependencies` +becomes wheel metadata, and installers pull those packages in whenever someone +installs yours. A pure Python package almost never needs anything besides the +backend in `build-system.requires`; compiled packages will use it more. + +::: + +### Entry points: adding a command line script + +To give your package a shell command, add a `scripts` entry point mapping a +command name to `package.module:function`: + +```{code} toml +:filename: pyproject.toml +[project.scripts] +rescale = "rescale.__main__:main" +``` + +with a matching function: + +```{code} python +:filename: src/rescale/__main__.py +import sys + +import numpy as np + +from rescale.core import rescale + + +def main() -> None: + values = np.array([float(x) for x in sys.argv[1:]]) + print(rescale(values)) + + +if __name__ == "__main__": + main() +``` + +On install, the installer generates a real `rescale` executable in the +environment's `bin/`. Using `__main__.py` as the file also makes +`python -m rescale` work for free. + +:::{exercise} Metadata and a CLI +:label: pkg-metadata + +Give your package the full metadata above (adjust the author!), plus the +`rescale` script. Then: + +1. Check the metadata with `uv run --refresh-package rescale uv pip show -v rescale` + (or `uv pip show -v rescale` if you installed manually). +2. Run your new command: it's an app in your venv, so `uv run rescale 1 2 3`. + +::: + +:::{solution} pkg-metadata +:class: dropdown + +```bash +uv run rescale 1 2 3 +[0. 0.5 1. ] +uv pip show -v rescale +Name: rescale +Version: 0.1.0 +Summary: Rescale NumPy arrays to span [0, 1]. +... +Entry-points: + [console_scripts] + rescale = rescale.__main__:main +``` + +If the metadata looks stale, uv cached the previous build; `uv sync --reinstall-package rescale` +or the `--refresh-package` flag forces a rebuild. + +::: + +## Versioning + +A quick word on choosing version numbers, then the fun part: never writing +them by hand again. + +The common schemes are **SemVer** (`major.minor.patch`) and **CalVer** +(date-based, like pip's `25.1`). Treat SemVer as an abbreviated changelog +expressing author intent — patch: "nothing to see", minor: "new stuff +available", major: "you should look before upgrading" — not as a promise that +nothing will ever break; with enough users, _every_ change breaks someone. +That's also why you shouldn't preemptively pin `package<2` in your +dependencies. + +### Single-sourcing the version + +Right now the version lives in `pyproject.toml`, and users may also expect +`rescale.__version__`. Two copies drift. Backends can compute the version for +you; you declare it `dynamic` and configure the backend: + +::::{tab-set} + +:::{tab-item} From git tags (hatch-vcs) + +```{code} toml +:filename: pyproject.toml +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "rescale" +dynamic = ["version"] + +[tool.hatch] +version.source = "vcs" +build.hooks.vcs.version-file = "src/rescale/_version.py" +``` + +Now `git tag v0.2.0` _is_ the release process; commits after a tag get dev +versions like `0.2.1.dev3`, so every commit is uniquely identifiable. The +generated `_version.py` is a build artifact: add it to `.gitignore`, and +re-export it: + +```{code} python +:filename: src/rescale/__init__.py +from rescale._version import __version__ +``` + +::: + +:::{tab-item} From a file (hatchling) + +```{code} toml +:filename: pyproject.toml +[project] +name = "rescale" +dynamic = ["version"] + +[tool.hatch] +version.path = "src/rescale/__init__.py" +``` + +Hatchling reads `__version__ = "0.1.0"` out of your source, so the source is +the single copy and the metadata follows it. + +::: + +:::: + +:::{dropdown} Versions in GitHub's tarballs + +Git tags aren't included in `git archive` output, which is what GitHub's +"Download source" tarballs are. Two small files fix that. `.git_archival.txt`: + +```text +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ +``` + +and a line in `.gitattributes`: + +```text +.git_archival.txt export-subst +``` + +Git substitutes the real values when creating the archive, and hatch-vcs +knows to read them. + +::: + +:::{exercise} Version from git +:label: pkg-version + +Switch your package to hatch-vcs versioning. Commit everything, tag `v0.2.0`, +and confirm the installed package reports it: + +```bash +uv run python -c "import rescale; print(rescale.__version__)" +``` + +Then make one more commit and check the version again. + +::: + +:::{solution} pkg-version +:class: dropdown + +```bash +git add -A && git commit -m "build: version from VCS" +git tag v0.2.0 +uv sync --reinstall-package rescale +uv run python -c "import rescale; print(rescale.__version__)" +0.2.0 +``` + +After another commit, you'll see something like `0.2.1.dev1+g1a2b3c4`: one +commit past the tag, at that git hash. (You need to reinstall to see version +changes; the version is computed at build time, and editable installs don't +rebuild on their own.) + +::: + +## The supporting files + +Your repository needs a few files that aren't code but make it a project +someone else can use: + +| File | What it's for | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `README.md` | Name, one-paragraph description, install command, tiny usage example. Keep it brief; details belong in docs. Rendered on GitHub and PyPI. | +| `LICENSE` | The exact text of the license you declared in `pyproject.toml`. Without one, default copyright applies and nobody may legally use your code. | +| `.gitignore` | Start from [GitHub's Python template][gitignore]. Doubly important with hatchling, which uses it to decide what _doesn't_ go into your package. | +| `CHANGELOG.md` | Human-readable list of changes per version ([keep a changelog][]). GitHub Releases can also fill this role. | + +:::{exercise} Round out the repo +:label: pkg-files + +Add a `README.md` with a description, install instructions, and a usage +example, a BSD-3-Clause `LICENSE` (from [choosealicense.com][]), and GitHub's +Python `.gitignore`. Make sure `readme` and `license-files` in your +`pyproject.toml` point at the right names, then commit. + +::: + +:::{solution} pkg-files +:class: dropdown + +```bash +curl -o .gitignore https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore +echo "src/rescale/_version.py" >> .gitignore +# write README.md, paste LICENSE text (update year and name) +git add -A && git commit -m "docs: add README, LICENSE, gitignore" +``` + +`uv pip show -v rescale` should now show your readme-derived description +metadata, and `git status` should be quiet even after running tests. + +::: + +## Building your first distributions + +Installing directly from the source tree is for development. To _distribute_ +a package, you build it into two standard artifacts: + +SDist +: The **source distribution**: a `.tar.gz` of your source tree plus metadata. +Installing from it runs the build backend on the user's machine. + +wheel +: A **built distribution**: a `.whl` zip file that is simply unpacked into +`site-packages`. No code runs at install time, making installs fast and safe. +For pure Python, one wheel (`py3-none-any`) works everywhere; compiled +packages need one per platform, which is most of the rest of this workshop. + +Build both with: + +```bash +uv build +``` + +(or `pipx run build`; both call your build backend per the standard +interface). The results land in `dist/`, and you can (and should!) look +inside: + +```bash +tar -tf dist/*.tar.gz # SDist contents +unzip -l dist/*.whl # wheel contents +``` + +:::{exercise} Inspect your distributions +:label: pkg-build + +Build your package and examine both artifacts. Find: + +1. Where the `[project]` table ended up in the wheel. +2. Whether `tests/` made it into each artifact, and whether that's a problem. +3. What `RECORD` is. + +::: + +:::{solution} pkg-build +:class: dropdown + +```bash +uv build +unzip -p dist/*.whl '*/METADATA' | head -20 +``` + +1. The wheel's `rescale-0.2.0.dist-info/METADATA` file holds your metadata, + rendered from `[project]` into the core-metadata format; your readme is its + body. Note there's no `pyproject.toml` in the wheel at all. +2. Hatchling includes `tests/` and `README.md` in the SDist (anything + tracked by git), but only `src/rescale/` ends up importable in the wheel. + That's the right default: the SDist should be able to rebuild everything, + including running tests, while the wheel ships only what users import. +3. `RECORD` lists every installed file with a hash, so uninstalls and + upgrades are exact. + +::: + +## Summary + +:::{glossary} +build backend +: The tool (hatchling here) that turns your source tree into installable +artifacts. Selected in `[build-system]`, configured under `[tool.*]`. + +src layout +: Keeping code in `src//` so nothing works without a real install. + +`[project]` table +: The standardized metadata: name, version, dependencies, entry points, and +everything PyPI displays. Identical across backends. + +entry point +: Metadata mapping a command name to a Python function; installers generate +the executable. + +SDist / wheel +: Source vs. built distribution. Ship both. +::: + +You built all of this by hand once so it holds no mysteries. You will +probably never do it by hand again: the next chapter introduces the +[Scientific Python Development Guide][] and its template, which generates a +package like this, plus tests, linting, typing, and CI, in one command. + +[spdx]: https://spdx.org/licenses +[classifiers]: https://pypi.org/classifiers/ +[choosealicense.com]: https://choosealicense.com +[bound version constraints]: https://iscinumpy.dev/post/bound-version-constraints/ +[gitignore]: https://github.com/github/gitignore/blob/main/Python.gitignore +[keep a changelog]: https://keepachangelog.com +[scientific python development guide]: https://learn.scientific-python.org/development/ diff --git a/content/basic-packaging/05_publishing_ci.md b/content/basic-packaging/05_publishing_ci.md index 8795458..baec344 100644 --- a/content/basic-packaging/05_publishing_ci.md +++ b/content/basic-packaging/05_publishing_ci.md @@ -7,7 +7,7 @@ chapter automates both: run your tests and linters on every pull request with [GitHub Actions][], then publish to PyPI with one click using [Trusted Publishing][]. This page covers pure Python packages; compiled packages add one more tool ([cibuildwheel][]) that we'll meet in the -[compiled section](../compiled/03_cibuildwheel.md). +[compiled section](../compiled/04_cibuildwheel.md). ## GitHub Actions in a nutshell @@ -391,7 +391,7 @@ Everything above still applies, except one wheel is no longer enough: a compiled package needs a wheel _per platform, per architecture_ (and without the stable ABI, per Python version). That's a build matrix problem, and [cibuildwheel][] solves it in a few lines of CI. That's the -[compiled section's publishing chapter](../compiled/03_cibuildwheel.md). +[compiled section's publishing chapter](../compiled/04_cibuildwheel.md). [github actions]: https://docs.github.com/en/actions [trusted publishing]: https://docs.pypi.org/trusted-publishers/ diff --git a/content/compiled/01_compiled.md b/content/compiled/01_compiled.md new file mode 100644 index 0000000..6860a8c --- /dev/null +++ b/content/compiled/01_compiled.md @@ -0,0 +1,605 @@ +# A minimal compiled package with scikit-build + +{button}`Slides ` + +You've seen how to build a package already. Let's try a compiled package now! + +## First simple package + +First, you'll need a file to compile. C++, C, Fortran, Rust, etc. You also need +to pick a binding tool; if you are in C, you can use the C-API, but don't -- +it's verbose, and you'll have to manage reference counting; binding tools +handle this for you. + +Let's use a trivial pybind11 extension. Name the source file `collatz.cpp`: + +```c++ +#include + +// Even -> n/2, odd -> 3n+1. Conjectured to always terminate. +int collatz_steps(long long n) { + int steps = 0; + while (n != 1) { + n = (n % 2 == 0) ? n / 2 : 3 * n + 1; + ++steps; + } + return steps; +} + +PYBIND11_MODULE(collatz, m) { + m.def("collatz_steps", &collatz_steps, "Steps to reach 1 in the Collatz sequence"); +} +``` + +It's a tight integer loop, something that's fast in a compiled language. +Hopefully you noticed that we set a module name, `collatz`, in +`PYBIND11_MODULE`. Every CPython module needs to know the name it will be +compiled to, since that's also how you look up the main entry point into the +code. + +pybind11 isn't the only choice. [nanobind](https://nanobind.readthedocs.io) is +a lighter, faster tool from the same author, and it also builds with +CMake, so everything below works with a one-line swap. The binding itself looks +almost identical: + +::::{tab-set} + +:::{tab-item} pybind11 + +```c++ +#include + +PYBIND11_MODULE(collatz, m) { + m.def("collatz_steps", &collatz_steps); +} +``` + +::: + +:::{tab-item} nanobind + +```c++ +#include + +NB_MODULE(collatz, m) { + m.def("collatz_steps", &collatz_steps); +} +``` + +::: + +:::: + +We'll stick with pybind11 for this chapter. The [next chapter](./02_binding.md) +compares the binding tools (including Rust via PyO3) in depth; here we care +about the _packaging_ around them, which is the same either way. + +Now, you need a `pyproject.toml`. We use `scikit-build-core` as the build +backend, and list `pybind11` so CMake can find it while building: + +```toml +[build-system] +requires = ["scikit-build-core", "pybind11"] +build-backend = "scikit_build_core.build" + +[project] +name = "collatz" +version = "0.1.0" +``` + +And, a `CMakeLists.txt`: + +```cmake +cmake_minimum_required(VERSION 3.15...4.3) +project(collatz LANGUAGES CXX) + +find_package(pybind11 CONFIG REQUIRED) + +pybind11_add_module(collatz collatz.cpp) +install(TARGETS collatz DESTINATION .) +``` + +And that should be it! Before you run it, take a guess: starting from `27`, how +many steps do you think the sequence takes to reach `1`? (The number climbs to +`9232` on the way, so don't feel bad if you're off.) Now try it: + +```console +$ uv run python +>>> from collatz import collatz_steps +>>> collatz_steps(27) +111 +``` + +:::{exercise} Build the collatz package +:label: pkg-build + +Put the three files above (`collatz.cpp`, `pyproject.toml`, `CMakeLists.txt`) +in an empty directory, then get a working import. + +1. Run the module and confirm `collatz_steps(27)` returns `111`. +2. What is the largest number of steps you can find for a starting value + under 100? + +::: + +:::{solution} pkg-build +:class: dropdown + +```console +$ uv run python -c "from collatz import collatz_steps; print(collatz_steps(27))" +111 +``` + +`uv run` sees the `pyproject.toml`, builds the extension with +scikit-build-core, and installs it into a temporary environment before running +your command — no manual compile step. For the second part, `97` takes 118 +steps, the most under 100. + +::: + +## Was it worth it? + +We claimed a tight integer loop is "fast in a compiled language." Let's check. +Here's the same function in pure Python: + +```python +def collatz_steps(n): + steps = 0 + while n != 1: + n = n // 2 if n % 2 == 0 else 3 * n + 1 + steps += 1 + return steps +``` + +`timeit` makes the comparison easy — point it at each version and let it pick a +sensible number of loops: + +```console +$ python -m timeit -s "from collatz import collatz_steps" "collatz_steps(97)" +1000000 loops, best of 5: 210 nsec per loop + +$ python -m timeit -s "from mymodule import collatz_steps" "collatz_steps(97)" +100000 loops, best of 5: 5.8 usec per loop +``` + +Exact numbers depend on your machine, but the shape holds: the compiled version +is roughly **20–30× faster** here. That gap is the whole reason to reach for a +compiled extension — and it's why the loop lives in C++ while the packaging, +which runs once, stays in Python. + +:::{exercise} Benchmark it yourself +:label: pkg-bench + +Save the pure-Python version as `mymodule.py`, then run both `timeit` lines +above. How does the ratio change if you benchmark a _cheaper_ input like +`collatz_steps(1)` instead of `collatz_steps(97)`? Why? + +::: + +:::{solution} pkg-bench +:class: dropdown + +With `collatz_steps(1)` the loop body never runs, so you're mostly timing the +call overhead — crossing the Python↔C boundary. The compiled version still +wins, but by much less, because there's no hot loop for it to speed up. The +lesson: compiled extensions pay off when there's real work _inside_ the call, +not for tiny wrappers around trivial operations. + +::: + +## Make the package better + +You don't have to add anything more, but there are config settings that +really help, let's look at a few. + +### Minimum version + +How do you ensure new users get good defaults, but old users don't break when defaults change? CMake handles this elegantly: + +```cmake +cmake_minimum_required(VERSION 3.15) +``` + +That 3.15 is special: it's the _minimum version_ of CMake supported. It doesn't +just add an error if CMake is too old, though; it actually changes the defaults +(called Policies). It even handles removals; you can still use +`FindPythonInterp`/`FindPythonLibs` if it's set below 3.26, but if it's 3.27 or +higher, those modules are no longer available. + +(There's also an optional upper number: that lets the minimum version float up +to the upper number, based on the CMake that's running. Using it extends the +lifespan of your code, but does mean you should test once on either end of the +range. Update it as you test on newer versions.) + +Scikit-build-core adopts a similar mechanism: + +```toml +[tool.scikit-build] +minimum-version = "1.0" +``` + +Any changes we make are gated behind this version; if you set it, then we will +continue to behave identically. But if you increase it (or leave it unset), the +behavior will change to the latest recommendations. For example, we improved +the SDist inclusion mode to make it more useful and faster, but if you set +something older than `"0.12"` here, you'll get the old mode. + +If we have to change something due to PyPI or some other tool we don't control +(like non-normalized SDist names no longer being uploadable on PyPI), then that +won't be gated by this value. + +Since you should also provide a minimum in your build system requirements, +there's a great trick you can use to avoid repeating yourself: + +```text +[build-system] +requires = ["scikit-build-core>=1.0"] +build-backend = "scikit_build_core.build" + +[tool.scikit-build] +minimum-version = "build-system.requires" +``` + +Setting it to this special string will read the value from the `requires` list. + +### Structuring the files + +Just like pure Python packages (though probably even more important), you can use `src` layout: + +```text +example +├── pyproject.toml +├── CMakeLists.txt +└── src + └── example + ├── __init__.py + └── _core.cpp +``` + +This ensures that Python doesn't pick up the local folder if you run `import +example` inside the project directory. This is especially important for +compiled code, since you (and your tests, etc) can't run the uncompiled version! + +Just like hatchling (which we took a lot of inspiration from!), the name matters! Your +package name must match the project name, and be in `/`, `/src`, or `/python`. If not, +you need to tell it where to discover it: + +```toml +[tool.scikit-build] +wheel.packages = ["some/path/to/package"] +``` + +The final directory name is the package. If you need more complex structure, a +table is supported here as well. + +:::{exercise} Move collatz into src layout +:label: pkg-src + +Restructure the package from the first exercise so the source lives in +`src/collatz/`. Add a `src/collatz/__init__.py` that re-exports +`collatz_steps`, and compile the extension as a submodule so both +`import collatz` and `from collatz import collatz_steps` still work. + +::: + +:::{solution} pkg-src +:class: dropdown + +```text +example +├── pyproject.toml +├── CMakeLists.txt +└── src + └── collatz + ├── __init__.py + └── _core.cpp +``` + +Rename the module to `_core` in the C++ file, so it builds as +`collatz._core`: + +```c++ +PYBIND11_MODULE(_core, m) { + m.def("collatz_steps", &collatz_steps, "Steps to reach 1 in the Collatz sequence"); +} +``` + +Re-export it from `__init__.py`: + +```python +from ._core import collatz_steps + +__all__ = ["collatz_steps"] +``` + +And install the target into the package directory instead of the root: + +```cmake +find_package(pybind11 CONFIG REQUIRED) + +pybind11_add_module(_core src/collatz/_core.cpp) +install(TARGETS _core DESTINATION collatz) +``` + +scikit-build-core discovers `src/collatz/` automatically because the package +name matches the project name. + +::: + +:::{caution} Three names that must line up + +The most common beginner error is a mismatch between three separate names. +Getting any one wrong gives an unhelpful `ImportError` at runtime, not a build +failure: + +1. The name in `PYBIND11_MODULE(name, m)` (or `NB_MODULE`) — this is what the + compiled `.so`/`.pyd` is called. +2. The `install(TARGETS ... DESTINATION ...)` path — where CMake puts it in the + wheel. +3. The name you `import` in Python. + +In the src-layout above, the module is `_core`, it installs to `collatz/`, and +you import it as `collatz._core`. If you rename the C++ module but forget the +`__init__.py` re-export, `from collatz import collatz_steps` breaks even though +the build succeeded. When something imports oddly, check these three first. + +::: + +### The two distributions + +A package ships as two artifacts, and it's worth seeing how they relate before +we build each one. The {term}`SDist` is the source you build _from_; the +{term}`wheel` is the pre-built result you _install_: + +```{mermaid} +flowchart LR + src["source tree
collatz.cpp
CMakeLists.txt
pyproject.toml"] + sdist["SDist
collatz-0.1.0.tar.gz"] + wheel["wheel
collatz-0.1.0-*.whl"] + site["site-packages
(installed)"] + src -->|"uv build --sdist"| sdist + src -->|"uv build --wheel"| wheel + sdist -.->|"build from sdist"| wheel + wheel -->|"pip install"| site +``` + +The two differ in what they contain: an SDist keeps build inputs (source, +`CMakeLists.txt`, tests), while a wheel keeps only the runtime package plus the +compiled extension. Deciding what lands in each is the tricky part, so let's +take them one at a time. + +### Building the SDist + +An SDist is the source of your package, and it needs to contain everything +required to build a wheel — but _everything required_ is surprisingly hard to +pin down automatically. This is one of the main differences between backends. + +Scikit-build-core and hatchling start by including anything not `.gitignored`, +and they include your `.gitignore`. That's a good baseline, doesn't require +git, and enables sdists to recreate themselves if unpacked. Then there are other +ways to add/remove files (and scikit-build-core includes other modes to pick from). + +flit-core includes a few common files and your package, and otherwise requires explicit +includes/excludes. Setuptools is similar, but uses `MANIFEST.in`. + +To build an sdist: + +```bash +uv build --sdist +``` + +To see what's in your SDist, use `tar -tf dist/*.tar.gz`. To measure what's in +your SDist against git as a source of truth, you can use `uvx check-dist`. +It's really useful for flit-core/setuptools, less so for scikit-build-core and +hatchling, due to the fact they tend to map to git by default. + +If you need files from somewhere else, you can use `sdist.force-include` +(spelling slightly different in hatchling). + +:::{note} + +* Do your tests go in the SDist? Yes. +* Do your docs go in the SDist? Ehh. Depends. Kindof. +* Do your CI files go in the SDist? No, but who cares, they are small. +::: + +:::{exercise} Build and inspect the SDist +:label: pkg-sdist + +Build an SDist for your collatz package and list its contents: + +```bash +uv build --sdist +tar -tf dist/*.tar.gz +``` + +Which files ended up inside? Was anything included that surprised you — or +missing that you expected? + +::: + +:::{solution} pkg-sdist +:class: dropdown + +You should see your source (`collatz.cpp` or `src/collatz/`), `CMakeLists.txt`, +`pyproject.toml`, and a generated `PKG-INFO`. Because scikit-build-core starts +from "everything not `.gitignore`d," a stray file in your working tree (a scratch +script, a `.venv` you forgot to ignore) can sneak in — which is exactly why the +`.gitignore` baseline matters. Notably, your build artifacts (`dist/`, +`build/`) should _not_ appear, since they're git-ignored. + +::: + +### Building the wheel + +Figuring out what files go in the wheel is also a hard problem, though at least +it's better defined; only your package goes in, tests, docs, etc. do not. + +Scikit-build-core includes everything in the auto-discovered or explicitly +named `wheel.packages`. It also contains anything CMake installs. + +You can adjust quite a bit, though. For example, `wheel.install-dir` sets where +CMake's install tree is grafted into the wheel, so you can keep `DESTINATION .` +in CMake and redirect everything from one place: + +```toml +[tool.scikit-build] +wheel.install-dir = "collatz" +``` + +```cmake +install(TARGETS _core DESTINATION .) +``` + +Now the target installed to `.` lands in `collatz/` inside the wheel. Be +careful: paths here are relative to the wheel root, and an absolute path is an +error. + +You can also use `wheel.force-include` to move things around, and +`wheel.exclude` to strip out items you don't want. There are controls over what +"components" CMake installs, which can allow you to pick a subset from CMake. + +:::{note} +Wheels have multiple directories that are handled differently, and are all +available using CMake (style) variables: + +* `${SKBUILD_PLATLIB_DIR}`: The original platlib directory. Anything here goes directly to site-packages when a wheel is installed. +* `${SKBUILD_DATA_DIR}`: The data directory. Anything here goes to the root of the environment when a wheel is installed (use with care). +* `${SKBUILD_HEADERS_DIR}`: The header directory. Anything in here gets installed to Python’s header directory. +* `${SKBUILD_SCRIPTS_DIR}`: The scripts directory. Anything placed in here will go to bin (Unix) or Scripts (Windows). +* `${SKBUILD_METADATA_DIR}`: The dist-info directory. Licenses go in the licenses subdirectory. +* `${SKBUILD_NULL_DIR}`: Anything installed here will not be placed in the wheel. + +::: + +To build a wheel: + +```bash +uv build --wheel +``` + +If you leave off the flags, it builds both, by the way. Unlike `python -m +build` — which builds the SDist and then builds the wheel _from_ that SDist — +`uv build` builds the two independently, both directly from the source +directory. If you want to build a wheel from an SDist (a good check that your +SDist is complete), pass the archive, such as: `uv build dist/collatz-0.1.0.tar.gz`. + +To see what's in your wheel, use `unzip -l dist/*.whl`. + +:::{exercise} Build and inspect the wheel +:label: pkg-wheel + +Build a wheel and list its contents: + +```bash +uv build --wheel +unzip -l dist/*.whl +``` + +Compare it to the SDist from the previous exercise. What's _in_ the wheel that +wasn't obvious from your source tree, and what's _missing_ that was in the SDist? + +::: + +:::{solution} pkg-wheel +:class: dropdown + +The wheel contains your compiled extension (a platform-tagged `.so` or `.pyd` — +note the wheel filename encodes your platform and Python version), the package's +`__init__.py`, and a `collatz-0.1.0.dist-info/` metadata directory. It does +_not_ contain `CMakeLists.txt`, `collatz.cpp`, or any tests — those are build +inputs, not runtime files. That's the core difference: the SDist is what you +build _from_, the wheel is what you install. + +::: + +### Iterating: the full compiled edit loop + +So far each change meant a fresh build. For real development you want an +_editable_ install so Python picks up your package in place — and for compiled +code, scikit-build-core can even rebuild the extension automatically when you +re-import it. That's covered in [its own chapter](../scikit-build/04_editable_installs.md); +the short version is: + +```bash +uv pip install --no-build-isolation -e . +``` + +When a build goes wrong, two knobs help you see what happened: + +```toml +[tool.scikit-build] +build.verbose = true # show the full compiler command lines +cmake.build-type = "Debug" +``` + +You can also pass CMake defines through the build without editing files, which +is handy for one-off experiments: + +```bash +uv build --wheel -C cmake.define.CMAKE_CXX_STANDARD=20 +``` + +:::{exercise} Add a second function +:label: pkg-extend + +Extend the extension with `collatz_max(n)` that returns the _largest_ value the +sequence reaches (the peak, not the number of steps). Rebuild, re-import, and +confirm `collatz_max(27)` returns `9232`. Don't forget to re-export it from +`__init__.py` if you're using the src layout. + +::: + +:::{solution} pkg-extend +:class: dropdown + +Add the function and a second binding in the C++ file: + +```c++ +long long collatz_max(long long n) { + long long peak = n; + while (n != 1) { + n = (n % 2 == 0) ? n / 2 : 3 * n + 1; + peak = n > peak ? n : peak; + } + return peak; +} + +PYBIND11_MODULE(_core, m) { + m.def("collatz_steps", &collatz_steps); + m.def("collatz_max", &collatz_max); +} +``` + +If you're on the src layout, add `collatz_max` to the `__init__.py` re-export +and its `__all__`. Then rebuild — `collatz_max(27)` returns `9232`, the peak we +mentioned back when you first ran `collatz_steps(27)`. + +::: + +## Glossary + +:::{glossary} + +SDist +: Source distribution — a `.tar.gz` containing everything needed to build the +package. You build a wheel _from_ it. + +wheel +: A pre-built, installable archive. For compiled packages it's platform- and +Python-specific, which is encoded in the filename tags. + +platlib +: The platform-specific library directory inside a wheel. Its contents install +straight into site-packages; it's where your compiled extension goes. + +policy +: A CMake versioned behavior switch. `cmake_minimum_required` selects a policy +set, so raising the minimum version opts into newer defaults all at once. + +editable install +: An install that points at your working tree instead of copying files, so +source edits take effect without reinstalling. scikit-build-core can also +rebuild the compiled part on import. + +::: diff --git a/content/compiled/01_package.md b/content/compiled/01_package.md deleted file mode 100644 index cd04274..0000000 --- a/content/compiled/01_package.md +++ /dev/null @@ -1 +0,0 @@ -# A minimal compiled package with scikit-build diff --git a/content/compiled/02_binding.md b/content/compiled/02_binding.md index faf92ec..ca73a3d 100644 --- a/content/compiled/02_binding.md +++ b/content/compiled/02_binding.md @@ -1,7 +1,433 @@ # Binding tools -## Pybind11 +Let's take a deeper look at binding tools. We'll focus on pybind11 and nanobind. These are: -## Nanobind +* No dependencies required +* No pre-process step +* Not a custom language -- just (advanced) C++ +* Easy to get started with +* Built up one piece at a time +* Great CMake support -## Maturin, PyO3, and Rust +:::{note} + +Other choices include: + +* **Cython**: Custom language, preprocessor -- better for making fast Python than binding + * See `cython-cmake` for CMake support +* **F2Py**: Fortran, preprocessor + * See `f2py-cmake` for CMake support +* **SWIG**: Preprocessor, all-at-once wrapping + * CMake built-in helper + +::: + +## Intro to pybind11 + +Before we tackle a real library, let's see the binding patterns on a couple of +toy classes. + +pybind11 is similar to Boost::Python, but much easier to build: it's pure C++11 +with no dependencies, no preprocessing step, and no new language to learn. It's +used in projects like SciPy, PyTorch, boost-histogram, and GooFit (including for +CUDA). The main downside is that it's a little verbose -- but that verbosity buys +you a highly customizable interface. + +### A simple class + +Here's a minimal C++ class: + +```{literalinclude} ../../examples/2_02_binding/simpleclass/SimpleClass.hpp +:language: cpp +``` + +Binding it takes just a few lines: + +```{literalinclude} ../../examples/2_02_binding/simpleclass/simpleclass.cpp +:language: cpp +``` + +The `PYBIND11_MODULE` macro defines the module -- its name must match the +compiled file. `py::class_` exposes the class, `py::init()` binds +the constructor, and each `.def(...)` binds a method. + +### More binding situations + +A slightly richer class shows off more features -- properties and operators: + +```{literalinclude} ../../examples/2_02_binding/vectorclass/VectorClass.hpp +:language: cpp +``` + +And the binding code: + +```{literalinclude} ../../examples/2_02_binding/vectorclass/vectorclass.cpp +:language: cpp +``` + +A few new points: + +* Constructors are easy, even when overloaded: just use `py::init<...>()`. +* You can name arguments with `"..."_a` and `using namespace pybind11::literals`. +* `def_property` takes a getter and a setter. +* The `pybind11/operators.h` header lets you bind operators with `py::self`. +* You can bind a lambda instead of a real method -- handy for `__repr__`. +* pybind11 provides Python types like `py::str` (with methods like `.format`), + and `.attr` reaches any Python attribute. + +:::{note} + +pybind11 has excellent documentation and handles a lot of situations, including +smart pointers, `std::variant`, NumPy (without needing NumPy at compile time!), +and Eigen. It focuses on making _small_ extensions -- there's a little call +overhead per function to handle overloads, so if faster means bigger, it may not +be the right fit. +::: + +## Intro to nanobind + +[nanobind](https://nanobind.readthedocs.io) is a newer library from the same +original author, Wenzel Jakob. It keeps pybind11's design but expects the code +to conform to nanobind, rather than trying to support all of C++, and in +exchange it compiles quicker, produces much smaller binaries, and has lower +call overhead. + +The API is deliberately close to pybind11, so most of what you just learned +carries straight over -- `nb::` instead of `py::`, `NB_MODULE` instead of +`PYBIND11_MODULE`. The main differences you'll hit when we wrap Minuit2: + +* STL casters are opt-in per type (``, + ``) instead of one catch-all ``. +* Trampolines for overriding virtuals in Python use the `NB_TRAMPOLINE` and + `NB_OVERRIDE_*` macros. +* A factory constructor (a lambda rather than `nb::init<>`) binds through + `__init__` with a placement `new`, instead of pybind11's `py::init([]{ ... })`. + +We are going to try a non-trivial project! Let's wrap Minuit2 -- and since the +two libraries are so similar, we'll show both side by side. + +## Intro to Minuit2 + +Before we see it in Python, let's see what Minuit2 is. + +You should know what the C++ looks like, and know what you want the Python to look like. For now, let's replicate the C++ experience. + +For example: a simple minimizer for $f(x) = x^2$ (should quickly find 0 as minimum), the procedure should be: + +* Define FCN +* Setup parameters +* Minimize +* Print result + +### Define the FCN + +We define the function to minimize by subclassing `FCNBase`: + +```{literalinclude} ../../examples/2_02_binding/cpponly/SimpleFCN.hpp +:language: cpp +``` + +### Run the minimizer + +Then we set up the parameters, minimize, and print the result: + +```{literalinclude} ../../examples/2_02_binding/cpponly/simpleminuit.cpp +:language: cpp +``` + +### Build configuration + +We use CMake with `FetchContent` to grab Minuit2: + +```{literalinclude} ../../examples/2_02_binding/cpponly/CMakeLists.txt +:language: cmake +``` + +### Build and run + +To build it: + +```bash +cmake -S . -B build +cmake --build build +``` + +And run: + +```bash +./build/simpleminuit +``` + +You should see something like this: + +```text +val = 1 +val = 1.001 +val = 0.999 +val = 1.0006 +val = 0.999402 +val = -8.23008e-11 +val = 0.000345267 +val = -0.000345267 +val = -8.23008e-11 +val = 0.000345267 +val = -0.000345267 +val = 6.90533e-05 +val = -6.90535e-05 + + Valid : yes + Function calls: 13 + Minimum value : 6.773427082e-21 + Edm : 6.773427082e-21 + Internal parameters: [ -8.230083282e-11] + Internal covariance matrix: +[[ 1]]] + External parameters: + Pos | Name | type | Value | Error +/- + 0 | x | free | -8.230083282e-11 | 0.7071067812 +``` + +:::{note} + +We are getting Minuit2 from the GooFit hosted standalone copy. It's an exact +standalone output of the contents of [ROOT](https://root.cern), at +`root-project/root` in the `math/minuit2` directory. You can also include it +from there, but the repo is _much_ bigger, so it's just a bit slower. + +You can use either repo as a submodule and `add_subdirectory(...)` instead if +you prefer. +::: + +## Binding Minuit2 + +The great thing about pybind11 and nanobind is that we can just bind the parts +we need. There's a lot more to Minuit2, but we don't care. If we used an +auto-binding tool (like SWIG), we'd have to work out issues for all the parts we +aren't using first. + +We'll build the same module both ways -- pick a tab to see each tool. + +### The main module + +These programs are best split into a main module, which allows you to build the +parts separately, with minimal header overlap, and then link it all at the end. + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +```{literalinclude} ../../examples/2_02_binding/pybind11/pyminuit2/pyminuit2.cpp +:language: cpp +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +```{literalinclude} ../../examples/2_02_binding/nanobind/pyminuit2/pyminuit2.cpp +:language: cpp +``` + +::: + +:::: + +Each `init_*` function fills in one piece of the module. We forward-declare them +here and call them in the module macro (`PYBIND11_MODULE` / `NB_MODULE`); the +definitions live in their own files. + +### Binding the FCN + +The FCN is an abstract base class in C++. To let Python subclass it, we use a +"trampoline" class that routes the virtual calls back into Python: + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +```{literalinclude} ../../examples/2_02_binding/pybind11/pyminuit2/FCNBase.cpp +:language: cpp +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +```{literalinclude} ../../examples/2_02_binding/nanobind/pyminuit2/FCNBase.cpp +:language: cpp +``` + +::: + +:::: + +`PYBIND11_OVERLOAD_PURE_NAME` maps C++'s `operator()` to Python's `__call__`; +nanobind's equivalent is `NB_OVERRIDE_PURE_NAME`, after declaring the trampoline +with `NB_TRAMPOLINE`. Including `` -- or, for nanobind, the +per-type `` -- gives us automatic `std::vector` ↔ +list conversion. + +### Binding the parameters + +`MnUserParameters` holds the parameters to minimize. We bind the constructor and +the two `Add` overloads (fixed and with an error) using `py::overload_cast` +(`nb::overload_cast` for nanobind, which also needs `` +for the parameter name): + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +```{literalinclude} ../../examples/2_02_binding/pybind11/pyminuit2/MnUserParameters.cpp +:language: cpp +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +```{literalinclude} ../../examples/2_02_binding/nanobind/pyminuit2/MnUserParameters.cpp +:language: cpp +``` + +::: + +:::: + +### Binding the minimizer + +`MnMigrad` runs the minimization. It inherits from `MnApplication`, so we bind +both and declare the relationship (`py::class_`, or the +`nb::` equivalent). We use a lambda for the constructor so we can take a plain +`unsigned int` strategy instead of an `MnStrategy` object: pybind11 wraps it in +`py::init(...)`, while nanobind binds `__init__` directly and placement-`new`s +into the object. The same `_a` literals give named arguments with defaults, as +we saw with `Vector2D`: + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +```{literalinclude} ../../examples/2_02_binding/pybind11/pyminuit2/MnApplication.cpp +:language: cpp +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +```{literalinclude} ../../examples/2_02_binding/nanobind/pyminuit2/MnApplication.cpp +:language: cpp +``` + +::: + +:::: + +### Binding the result + +Finally, `FunctionMinimum` is the result. We only need to print it, so we bind +`__str__` to stream the C++ object into a string (nanobind needs +`` to return it): + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +```{literalinclude} ../../examples/2_02_binding/pybind11/pyminuit2/FunctionMinimum.cpp +:language: cpp +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +```{literalinclude} ../../examples/2_02_binding/nanobind/pyminuit2/FunctionMinimum.cpp +:language: cpp +``` + +::: + +:::: + +### Build configuration + +The CMake is much like before, but we use `pybind11_add_module` (or +`nanobind_add_module`) instead of a plain executable, glob the source files +together, and `install` the resulting module. nanobind also wants an explicit +`find_package(Python ...)`: + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +```{literalinclude} ../../examples/2_02_binding/pybind11/CMakeLists.txt +:language: cmake +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +```{literalinclude} ../../examples/2_02_binding/nanobind/CMakeLists.txt +:language: cmake +``` + +::: + +:::: + +We drive the build with scikit-build-core, so we need a `pyproject.toml` -- just +list the right binding tool in `build-system.requires`: + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +```{literalinclude} ../../examples/2_02_binding/pybind11/pyproject.toml +:language: toml +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +```{literalinclude} ../../examples/2_02_binding/nanobind/pyproject.toml +:language: toml +``` + +::: + +:::: + +### Build and run + +Install and run it in one step with uv: + +```bash +uv run sample.py +``` + +The Python API is identical either way, so the same sample script -- a mirror of +the C++ program -- runs against both modules: + +```{literalinclude} ../../examples/2_02_binding/pybind11/sample.py +:language: python +``` + +You should see the same minimization output as the C++ version, ending with the +printed `FunctionMinimum`. diff --git a/content/compiled/03_rust.md b/content/compiled/03_rust.md new file mode 100644 index 0000000..024559c --- /dev/null +++ b/content/compiled/03_rust.md @@ -0,0 +1,529 @@ +--- +authors: [Matt McCormick] +--- + +# Rust, PyO3, and Maturin + +{button}`Slides ` + +In this chapter we build a real, importable Python extension module in **Rust**, with **PyO3** providing the bindings and **maturin** as the build backend — and the entire toolchain, Rust compiler included, installed by pixi. +No prior Rust is assumed: every Rust construct we meet is translated into a Python concept you already know. +The complete project lives in [`examples/2_04_rust_pyo3/`](https://github.com/scikit-build/SIMPLE-Py/tree/main/examples/2_04_rust_pyo3) in the workshop repository. + +## Why Rust? + +You have almost certainly run Rust today, even if you have never written a line of it. +[ruff](https://astral.sh/ruff) lints and formats your Python, [uv](https://docs.astral.sh/uv/) resolves and installs your packages, [Polars](https://pola.rs/) crunches your dataframes, [pydantic-core](https://github.com/pydantic/pydantic-core) validates your models, and [cryptography](https://cryptography.io/) guards your TLS connections. +All of them are Python-facing tools with a Rust core, shipped to PyPI as ordinary wheels. + +Why do so many extension authors reach for Rust instead of C or C++? +Two reasons stand out. +First, **memory safety without a garbage collector**: the Rust compiler proves at compile time that memory is used correctly, so the classic native-extension crashes — use-after-free, double-free, a forgotten reference count — become compile errors instead of segfault whack-a-mole, while performance stays in C territory because there is no garbage collector pausing your hot loop. +Second, **a real package manager**: cargo resolves dependencies, builds, and tests with one tool, so adding a library is one line in a manifest rather than a CMake scavenger hunt. + +Rust has a reputation for a steep learning curve, and the borrow checker earns some of it. +But for the kind of code that belongs in an extension module — small numeric kernels, tight loops, parsing — the subset of Rust you need is small, and PyO3 hides most of the sharp edges. + +## The stack, translated + +The Rust ecosystem maps almost one-to-one onto tools this book has already covered: + +| Rust | Python analog | Role | +| -------------- | ------------------- | ------------------------------------------------------------- | +| cargo | pip | resolves and installs dependencies, drives builds | +| crates.io | PyPI | the public package index | +| `Cargo.toml` | `pyproject.toml` | project metadata and dependencies | +| PyO3 | pybind11 | the binding layer (PyO3 is to Rust what pybind11 is to C++) | +| maturin | scikit-build-core | the PEP 517 build backend that turns source into a wheel | + +The last row is the important one: **maturin is just another build backend**. +The same `pyproject.toml` plumbing you have used throughout this book applies unchanged, and any frontend — pip, uv, pixi — can build and install the project like any other Python package. + +## Setup with pixi + +Nearly every published PyO3 tutorial begins with "install Rust with rustup." +We won't. +conda-forge packages the Rust toolchain, so pixi installs it into the project environment exactly the way it installs Python, maturin, and pytest. +Here is the example's complete `pixi.toml`: + +```{code} toml +:filename: pixi.toml + +[workspace] +channels = ["conda-forge"] +name = "pyo3-example" +platforms = ["linux-64", "osx-arm64"] +version = "0.1.0" + +[tasks.develop] +description = "Build the Rust extension and install it into the pixi environment" +cmd = "maturin develop" + +[tasks.develop-release] +description = "Build the optimized Rust extension and install it" +cmd = "maturin develop --release" + +[tasks.test] +description = "Run the pytest suite" +cmd = "pytest -v" +depends-on = ["develop"] + +[tasks.bench] +description = "Benchmark the Rust extension against pure Python" +cmd = "python bench.py" +depends-on = ["develop-release"] + +[dependencies] +rust = ">=1.85" +maturin = ">=1.9" +python = ">=3.12" +pytest = ">=8" +``` + +The `[dependencies]` table is the entire toolchain — the Rust compiler is the one-line `rust = ">=1.85"`. +The `[tasks]` table wraps the commands we will use for the rest of the chapter, and `depends-on` chains a build in front of the tests and the benchmark so they can never run against a stale extension. + +::: {tip} No rustup, no system Rust +:class: dropdown + +A single `pixi install` provisions `rustc`, `cargo`, and even Rust's linter and formatter (`clippy` and `rustfmt` — the `ruff check` and `ruff format` of Rust) from conda-forge, entirely inside the project environment. +Nothing touches your system: no rustup, no shell-profile edits, no admin rights. + +::: + +Reproducibility follows the same pattern as everywhere else in the book, just doubled: `pixi.lock` pins the toolchain (rustc, maturin, Python, pytest), while `Cargo.lock` pins the Rust libraries the extension depends on. +Commit both. + +## A first extension + +maturin can scaffold a fresh project for you — `maturin new -b pyo3` generates a PyO3 project skeleton — and the result is where our example started. +The layout is compact: + +```text +2_04_rust_pyo3/ +├── pixi.toml # toolchain and tasks (shown above) +├── pyproject.toml # Python package metadata +├── Cargo.toml # Rust package metadata +├── src/ +│ └── lib.rs # the extension module itself +├── tests/ +│ └── test_pyo3_example.py +└── bench.py # the benchmark, later in this chapter +``` + +The Rust side is just two manifests and one source file. +Start with the file you know best: + +```{code} toml +:filename: pyproject.toml + +[build-system] +requires = ["maturin>=1.14,<2.0"] +build-backend = "maturin" + +[project] +name = "pyo3_example" +version = "0.1.0" +requires-python = ">=3.9" + +[tool.maturin] +features = ["pyo3/extension-module"] +``` + +This should look completely familiar — it has the same shape as every `pyproject.toml` in this book, with only the backend name changed. +The one new line is `[tool.maturin] features`, which compiles PyO3 in *extension module* mode: build a module to be loaded by an existing interpreter, rather than embedding an interpreter inside a Rust program. + +```{code} toml +:filename: Cargo.toml + +[package] +name = "pyo3_example" +version = "0.1.0" +edition = "2024" + +[lib] +name = "pyo3_example" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = "0.29.0" +``` + +`Cargo.toml` is `pyproject.toml`'s Rust twin. +A **crate** is Rust's unit of packaging — this project is one crate with one dependency, PyO3, which cargo fetches from crates.io on the first build. +`edition = "2024"` opts into a snapshot of language rules — think `from __future__ import ...` applied project-wide, not a compiler version. +The line doing the real work is `crate-type = ["cdylib"]`: build a *C dynamic library*, a shared library exposing C symbols — which is precisely what a CPython extension module is. + +Finally, the module itself. +Here is the top of `src/lib.rs`: + +```{code} rust +:filename: src/lib.rs + +use pyo3::prelude::*; + +/// A Python module implemented in Rust. +#[pymodule] +mod pyo3_example { + use pyo3::prelude::*; + + /// Formats the sum of two numbers as string. + #[pyfunction] + fn sum_as_string(a: usize, b: usize) -> PyResult { + Ok((a + b).to_string()) + } + + // ... +} +``` + +Reading it as a Pythonista: + +* `use pyo3::prelude::*;` is an import — `from pyo3.prelude import *`. Glob imports are frowned upon in Python; Rust *preludes* are curated for exactly this use. +* `///` comments are docstrings: PyO3 turns them into the `__doc__` of the module and function. +* `#[pymodule] mod pyo3_example { ... }` — `mod` declares a namespace, like a Python module but spelled out explicitly. The `#[pymodule]` **attribute** looks like a decorator and reads like one, but runs at *compile time*, generating the entry point CPython looks for when it executes `import pyo3_example`. This block is the Rust analog of "the code that runs at import." +* `#[pyfunction]` is the decorator-alike that exposes a Rust `fn` (a `def`) to Python. +* `fn sum_as_string(a: usize, b: usize) -> PyResult` — type annotations that are actually *enforced*. `usize` is a machine-sized unsigned integer; Python's `int` is arbitrary-precision, so PyO3 converts at the boundary and raises `OverflowError` if a value is negative or too large. The `String` comes back out as an ordinary `str`. +* `Ok((a + b).to_string())` — Rust returns errors as values rather than raising. `PyResult` means "a `str`, or a Python exception"; `Ok` wraps the success case, and returning an `Err` raises a genuine exception on the Python side — we will use that later in this chapter. +* There is no `return`: the last expression in a block is its value. + +The `// ...` marks the rest of the file — a class and two more functions we will meet in the coming sections. + +## Build and iterate + +One command builds the extension and installs it into the environment: + +```console +$ pixi run develop +✨ Pixi task (develop): maturin develop: (Build the Rust extension and install it into the pixi environment) +🐍 Found CPython 3.14 at /home/matt/src/SIMPLE-Py-worktrees/rust/examples/2_04_rust_pyo3/.pixi/envs/default/bin/python +🔗 Found pyo3 bindings +📡 Using build options features from pyproject.toml + Compiling pyo3_example v0.1.0 (/home/matt/src/SIMPLE-Py-worktrees/rust/examples/2_04_rust_pyo3) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s +📦 Built wheel for CPython 3.14 to /tmp/.tmpxvrtja/pyo3_example-0.1.0-cp314-cp314-linux_x86_64.whl +✏️ Setting installed package as editable +🛠 Installed pyo3_example-0.1.0 +``` + +Note what you did *not* have to configure: maturin found the environment's interpreter on its own. +A pixi environment is a conda environment, so `maturin develop` detects it exactly as it would a virtualenv — compile, wrap in a wheel, install, done. +The very first build takes a minute or two while cargo compiles PyO3 itself; after that, rebuilds take seconds. + +Start a REPL inside the environment with `pixi run python`: + +```{code} python +>>> import pyo3_example +>>> pyo3_example.sum_as_string(2, 3) +'5' +``` + +That is the whole loop: Rust code, called from Python, with type conversion handled at the boundary. + +::: {note} What did we just install? +:class: dropdown + +Look inside the environment's `site-packages` and you will find `pyo3_example/pyo3_example.cpython-314-x86_64-linux-gnu.so`, plus a small generated `__init__.py` that re-exports it. +An extension module is nothing more exotic than that shared library. +Its long filename suffix encodes the interpreter version, ABI, and platform it was built for — this one only loads on CPython 3.14 on x86-64 Linux — a contract that resurfaces when we ship wheels at the end of the chapter. + +::: + +This is the same editable workflow the book covers in [Editable installs](../scikit-build/04_editable_installs.md): maturin is a full PEP 660 backend, so `pip install -e .` works here too, and `maturin develop` is the convenient shorthand for it. +The one Rust-specific rule of the edit–rebuild loop: Python cannot re-import its way to your new Rust code. +After every change to `src/lib.rs`, rerun `pixi run develop`; if an edit ever seems to do nothing, the installed extension is stale — the fix is the same command. + +## How fast is it? + +Time to cash the performance check. +The example module also exports `count_primes(limit)`, which counts the primes below `limit` by trial division — a deliberately branchy, CPU-bound loop, exactly the kind of code CPython executes slowly and a compiler loves. +We will read its Rust source a little later in the chapter; first, let's measure it. +`bench.py` implements the identical algorithm in pure Python and times both: + +```{code} python +:filename: bench.py + +"""Benchmark pyo3_example.count_primes against the same algorithm in pure Python.""" + +import timeit + +import pyo3_example + +LIMIT = 1_000_000 + + +def count_primes(limit): + """Count primes below `limit` by trial division (same algorithm as src/lib.rs).""" + count = 0 + for n in range(2, limit): + is_prime = True + d = 2 + while d * d <= n: + if n % d == 0: + is_prime = False + break + d += 1 + if is_prime: + count += 1 + return count + + +if __name__ == "__main__": + assert count_primes(10_000) == pyo3_example.count_primes(10_000) + + print(f"count_primes({LIMIT:_}), best of 3 runs:") + python_seconds = min(timeit.repeat(lambda: count_primes(LIMIT), number=1, repeat=3)) + rust_seconds = min( + timeit.repeat(lambda: pyo3_example.count_primes(LIMIT), number=1, repeat=3) + ) + print(f" pure Python: {python_seconds:.3f} s") + print(f" Rust (PyO3): {rust_seconds:.3f} s") + print(f" speedup: {python_seconds / rust_seconds:.0f}x") +``` + +The `assert` keeps the comparison honest: both implementations must agree on an answer before either is timed. +One task runs the benchmark, and its `depends-on` chain (look back at `pixi.toml`) rebuilds the extension in *release* mode first: + +```console +$ pixi run bench +✨ Pixi task (develop-release): maturin develop --release: (Build the optimized Rust extension and install it) +🐍 Found CPython 3.14 at /home/matt/src/SIMPLE-Py-worktrees/rust/examples/2_04_rust_pyo3/.pixi/envs/default/bin/python +🔗 Found pyo3 bindings +📡 Using build options features from pyproject.toml + Compiling pyo3_example v0.1.0 (/home/matt/src/SIMPLE-Py-worktrees/rust/examples/2_04_rust_pyo3) + Finished `release` profile [optimized] target(s) in 0.19s +📦 Built wheel for CPython 3.14 to /tmp/.tmpfn9edB/pyo3_example-0.1.0-cp314-cp314-linux_x86_64.whl +✏️ Setting installed package as editable +🛠 Installed pyo3_example-0.1.0 + +✨ Pixi task (bench): python bench.py: (Benchmark the Rust extension against pure Python) +count_primes(1_000_000), best of 3 runs: + pure Python: 1.601 s + Rust (PyO3): 0.084 s + speedup: 19x +``` + +Nineteen times faster, from a line-for-line transcription of the Python loop — no algorithm change, no tuning. +The exact digit varies by machine and by run; call it an order of 20×. + +::: {warning} Benchmark release builds only + +`maturin develop` compiles an *unoptimized* debug build by default — the fast-compile, slow-run profile meant for the edit–rebuild loop, the same trade-off as `cmake -DCMAKE_BUILD_TYPE=Debug`. +Depending on the workload, debug Rust can run 10–50× slower than release and can even lose to pure Python — which is where most "I rewrote it in Rust and it got slower!" stories come from. +Benchmark only `--release` builds. +The example's `pixi.toml` encodes the rule so nobody has to remember it: `bench` depends on `develop-release`, so the benchmark can never see a debug build. + +::: + +An honest coda: a 19× speedup is representative for moving a branchy scalar loop wholesale into a compiled language, not a promise. +Code that is I/O-bound, or that already spends its time inside NumPy's vectorized C loops, gains little from Rust; and a Python loop that calls into Rust once per element instead of once per loop can end up *slower* than pure Python, because every crossing pays the conversion toll at the boundary. +The design rule that covers all of these: **move the loop into Rust, not the loop body**. + +## Classes + +Speed is one half of the story; ergonomics is the other. +`#[pyclass]` exposes a Rust `struct` to Python as a class. +Here is the next piece of `src/lib.rs` (still inside the `mod pyo3_example` block): + +```{code} rust +:filename: src/lib.rs + + /// A 2D point, exposed to Python as a class. + #[pyclass] + struct Point { + #[pyo3(get)] + x: f64, + #[pyo3(get)] + y: f64, + } + + #[pymethods] + impl Point { + #[new] + fn new(x: f64, y: f64) -> Self { + Point { x, y } + } + + /// Distance from the origin. + fn magnitude(&self) -> f64 { + (self.x * self.x + self.y * self.y).sqrt() + } + + fn __repr__(&self) -> String { + // {:?} keeps the decimal point on whole floats: 1.0, not 1. + format!("Point(x={:?}, y={:?})", self.x, self.y) + } + } +``` + +Again as a Pythonista: + +* A `struct` is pure data with named, typed fields; Rust keeps behavior in a separate `impl` block, where the methods live. `#[pyclass]` and `#[pymethods]` publish the pair to Python as one ordinary class. +* `#[new]` marks the constructor — `__init__`. Its body `Point { x, y }` builds the struct, using shorthand for `Point { x: x, y: y }`. +* `#[pyo3(get)]` generates a read-only attribute for a field — a `@property` without the boilerplate. There is no matching `set`, so assigning to `p.x` raises `AttributeError`. +* `&self` is `self`; the `&` means the method *borrows* the instance to read it rather than taking ownership. +* `__repr__` is exactly what you think: give a method a dunder name and PyO3 wires it into the matching Python protocol. + +```{code} python +>>> p = pyo3_example.Point(3.0, 4.0) +>>> p.magnitude() +5.0 +>>> p +Point(x=3.0, y=4.0) +>>> p.x +3.0 +``` + +## Errors that feel native + +A Rust extension should not make its callers learn new failure modes: when things go wrong, Python code expects a Python exception. +`PyResult` is the bridge. +The first half of the chapter promised we would return an `Err`; here it is: + +```{code} rust +:filename: src/lib.rs + + /// Divides `a` by `b`, raising ZeroDivisionError like Python's `/`. + #[pyfunction] + fn checked_div(a: f64, b: f64) -> PyResult { + use pyo3::exceptions::PyZeroDivisionError; + + if b == 0.0 { + return Err(PyZeroDivisionError::new_err("division by zero")); + } + Ok(a / b) + } +``` + +The `use` inside the function body is a scoped import, like an `import` inside a `def`: it keeps the exception type next to its only use. +`pyo3::exceptions` mirrors the builtins — `PyValueError`, `PyTypeError`, `PyKeyError`, and friends — and `new_err` constructs one with a message. +Returning it as an `Err` (note that `return` exists in Rust; it is simply optional on the last expression) raises it on the Python side: + +```{code} python +>>> pyo3_example.checked_div(1.0, 4.0) +0.25 +>>> pyo3_example.checked_div(1.0, 0.0) +Traceback (most recent call last): + File "", line 1, in + pyo3_example.checked_div(1.0, 0.0) + ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^ +ZeroDivisionError: division by zero +``` + +That is a genuine `ZeroDivisionError` — `except ZeroDivisionError:` catches it, and nothing about it betrays that the raiser was Rust. +Even a Rust *panic* (its equivalent of an unrecoverable error) does not take down the interpreter: PyO3 catches it at the boundary and raises a `PanicException` instead. + +## The GIL (and life without it) + +One promise is still outstanding: the Rust source of `count_primes`, which hides the chapter's most Python-flavored topic — the Global Interpreter Lock. + +```{code} rust +:filename: src/lib.rs + + /// Counts primes below `limit` by trial division. + #[pyfunction] + fn count_primes(py: Python<'_>, limit: u64) -> u64 { + // Release the GIL so other Python threads can run during the hot loop. + py.detach(|| { + let mut count = 0; + for n in 2..limit { + let mut is_prime = true; + let mut d = 2; + while d * d <= n { + if n % d == 0 { + is_prime = false; + break; + } + d += 1; + } + if is_prime { + count += 1; + } + } + count + }) + } +``` + +The loop itself should read fine by now — `let mut` declares a variable that is allowed to change (Rust variables are immutable unless you say otherwise), and `2..limit` is `range(2, limit)`. +The interesting part is the frame around it: + +* `py: Python<'_>` is a *token* proving this thread currently holds the GIL. PyO3 recognizes the parameter type and supplies it automatically — from Python, `count_primes` still takes a single argument. +* `py.detach(|| { ... })` runs the closure (`|| { ... }` is Rust's lambda) with the GIL released. Other Python threads get to run for the entire duration of the hot loop — the same courtesy NumPy extends around its C loops. +* While detached, the code must not touch Python objects. In a C extension that is a rule you memorize; here it is a rule the compiler enforces. A closure that tries to capture anything Python-side does not compile. + +::: {tip} Life without the GIL +:class: dropdown + +The GIL itself is on the way out: [free-threaded CPython](../interesting/free_threading.md) removes it entirely, and PyO3 is ahead of the curve — recent releases support free-threaded builds, where `py.detach` keeps its meaning (the thread detaches from the interpreter, so it no longer blocks stop-the-world operations like garbage collection). +Free-threading raises the thread-safety bar for every extension author, and this is where Rust earns its keep a second time: the compiler's `Send`/`Sync` checks audit your extension for data races at compile time. +Our `Point`, immutable after construction, needs no changes at all. + +::: + +## Shipping wheels + +`maturin develop` installs into your own environment; sharing with anyone else means building a wheel: + +```console +$ pixi run maturin build --release +🐍 Found CPython 3.14 at /home/matt/src/SIMPLE-Py-worktrees/rust/examples/2_04_rust_pyo3/.pixi/envs/default/bin/python3 +🔗 Found pyo3 bindings +📡 Using build options features from pyproject.toml + Compiling pyo3-ffi v0.29.0 + Compiling pyo3 v0.29.0 + Compiling pyo3_example v0.1.0 (/home/matt/src/SIMPLE-Py-worktrees/rust/examples/2_04_rust_pyo3) + Finished `release` profile [optimized] target(s) in 2.47s +📦 Built wheel for CPython 3.14 to /home/matt/src/SIMPLE-Py-worktrees/rust/examples/2_04_rust_pyo3/target/wheels/pyo3_example-0.1.0-cp314-cp314-manylinux_2_28_x86_64.whl +``` + +Read the wheel's filename like a shipping label. +`cp314-cp314` says it was built for exactly CPython 3.14, and `manylinux_2_28_x86_64` says it runs on any x86-64 Linux with glibc 2.28 or newer. +That second tag is earned, not asserted: maturin bundles a reimplementation of `auditwheel` and audits every `maturin build` wheel automatically — work that is a separate tool and a separate CI step in the C++ chapters. +(Compare the throwaway wheels `maturin develop` dropped in `/tmp` earlier: those are tagged plain `linux_x86_64`, fine for installing into the local environment, but PyPI would reject them.) + +The `cp314-cp314` half is the expensive one: one wheel per Python version per platform, so supporting five CPython versions on three platforms means fifteen builds. +The escape hatch is the **stable ABI**, nicknamed abi3: a restricted subset of the C API that CPython promises to keep compatible across versions. +Add the `abi3-py310` feature (or another version floor) to the `pyo3` dependency in `Cargo.toml`, and maturin emits a single `cp310-abi3` wheel per platform that installs on every CPython from 3.10 onward, at the cost of a slightly restricted API and some conversion fast paths. +[cryptography](https://cryptography.io/) ships exactly such wheels to millions of users a day. + +Nobody builds a wheel matrix by hand. +`maturin generate-ci github` prints a complete GitHub Actions release workflow — platform matrix, manylinux-compliant build containers, an sdist, and PyPI upload via trusted publishing — ready to drop into `.github/workflows/`. +Alternatively, [cibuildwheel](./03_cibuildwheel.md) supports maturin projects too: if your CI already revolves around it, it will happily build your Rust wheels alongside everything else. + +## Try it yourself + +:::{exercise} Extend the extension +:label: rust-pyo3-extend + +Clone the workshop repository and open `examples/2_04_rust_pyo3/`; `pixi run test` should pass before you change anything. +Then: + +1. Add an `is_prime(n)` `#[pyfunction]` that reports whether a single number is prime — the trial-division test inside `count_primes` is the algorithm. +2. Add a `distance_to(other)` method to `Point` returning the distance between two points, so that `Point(0.0, 0.0).distance_to(Point(3.0, 4.0))` gives `5.0`. +3. Extend `tests/test_pyo3_example.py` to cover both, and make `pixi run test` pass again. + +::: + +:::{solution} rust-pyo3-extend +:class: dropdown + +Some hints in place of full code: + +* `is_prime` needs neither `PyResult` nor a `py` token: the signature `fn is_prime(n: u64) -> bool` is enough, and the `bool` comes back to Python as `True`/`False`. Put it inside the `mod pyo3_example` block with `#[pyfunction]` on top. +* `distance_to` goes inside the existing `#[pymethods] impl Point` block with the signature `fn distance_to(&self, other: &Point) -> f64` — PyO3 handles borrowing `other` when Python passes in a second `Point`. The body is two subtractions away from `magnitude`. +* Remember the rule from *Build and iterate*: Rust edits need a rebuild. `pixi run test` runs one for you via its `depends-on: ["develop"]`. + +::: + +## Where to go next + +Four pointers, in the order worth reading them: + +* The [PyO3 user guide](https://pyo3.rs/) — start with the getting-started walkthrough, then the chapters on classes and modules; everything this chapter glossed over is there. +* The [maturin user guide](https://www.maturin.rs/) — project layouts (including mixed Rust/Python packages), configuration, and the full CLI reference. +* [rust-numpy](https://github.com/PyO3/rust-numpy) — zero-copy NumPy arrays in Rust. The moment your hot data is an array, reach for this instead of converting element by element. +* [The Rust Programming Language](https://doc.rust-lang.org/book/) — "the Book", the canonical way to actually learn Rust; its first ten chapters cover every construct this example uses. + +The stack you used today does not change as the project grows: the same `pixi.toml`, the same two manifests, the same `pixi run develop` loop carry a module from these eighty lines to ruff- and Polars-sized codebases. +Only `src/lib.rs` gets bigger. diff --git a/content/compiled/03_cibuildwheel.md b/content/compiled/04_cibuildwheel.md similarity index 99% rename from content/compiled/03_cibuildwheel.md rename to content/compiled/04_cibuildwheel.md index 208c9b6..e1144cb 100644 --- a/content/compiled/03_cibuildwheel.md +++ b/content/compiled/04_cibuildwheel.md @@ -1,6 +1,6 @@ # Publishing with cibuildwheel -{button}`Slides ` +{button}`Slides ` In the [basic publishing chapter](../basic-packaging/05_publishing_ci.md), one job built an SDist and a wheel, and one job uploaded them. If you have diff --git a/content/interesting/free_threading.md b/content/interesting/free_threading.md index 0e2dbd7..ebe1fde 100644 --- a/content/interesting/free_threading.md +++ b/content/interesting/free_threading.md @@ -1 +1,216 @@ # Modern Python: Free-threading + +{button}`Slides ` + +For most of Python's history, the **Global Interpreter Lock** (GIL) has meant +that only one thread runs Python bytecode at a time. Threads are great for +overlapping I/O, but they can't use more than one core for computation --- for +that you reached for multiprocessing, with its pickling and process-startup +costs. + +[PEP 703](https://peps.python.org/pep-0703/) changed that. Starting with Python +3.13 there is an official **free-threaded** build (sometimes written +`python3.14t`) where the GIL can be turned off, and threads run Python in +parallel on every core. 3.13 was the experimental debut; 3.14 is where it got +fast, so that's what we require here. It's the most interesting thing to happen +to CPython in years --- and it has real consequences for how we package compiled +extensions. + +You can check whether the GIL is active at runtime: + +```pycon +>>> import sys +>>> sys._is_gil_enabled() +False +``` + +## An embarrassingly parallel example + +To see it work, we need a CPU-bound task that splits cleanly across threads. +Estimating $\pi$ by throwing darts is perfect: throw random points into the +square $[-1, 1]^2$ and count how many land inside the unit circle. The fraction +inside approaches $\pi/4$. Each dart is independent, so we can run a batch per +thread and average the results. + +## Pure Python + +The pure-Python version is a plain loop, run across a thread pool: + +```{literalinclude} ../../examples/6_01_free_threading/pure/freecomputepi/pi.py +:language: python +``` + +On a normal (GIL-enabled) interpreter, adding threads doesn't help --- the GIL +serializes them, so you get one core's worth of work no matter what. On a +free-threaded build, the same code speeds up with each core. + +Run it with a free-threaded interpreter (uv will fetch one for the `t` suffix): + +```bash +uv run --python 3.14t sample.py +``` + +```{literalinclude} ../../examples/6_01_free_threading/pure/sample.py +:language: python +``` + +```text +Python 3.14.6, GIL disabled + 1 threads: pi = 3.14159 (2.22 s) + 2 threads: pi = 3.14158 (1.13 s) + 4 threads: pi = 3.14150 (0.60 s) + 8 threads: pi = 3.14201 (0.43 s) +``` + +Drop the `t` (`uv run --python 3.14`) and the times stay flat no matter how many +threads you add --- that's the GIL. + +## Compiled: releasing the GIL for real + +Pure Python is now parallel, but it's still Python-slow. The real win is a +compiled inner loop that runs in parallel _and_ fast. There's a catch: **an +extension has to declare that it doesn't need the GIL.** If you import any +extension that hasn't opted in, CPython silently switches the GIL back on (with +a warning) to keep that extension safe --- so every extension in your process +has to be free-threading-aware, or nobody gets the speedup. + +The compute is identical to the pure version, just in C++. The interesting part +is the one line that marks the module as GIL-free --- and it differs between the +two tools: + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +pybind11 marks the module in the `PYBIND11_MODULE` macro with +`py::mod_gil_not_used()` (available since pybind11 2.13): + +```{literalinclude} ../../examples/6_01_free_threading/pybind11/freecomputepi/_core.cpp +:language: cpp +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +nanobind opts in from CMake instead, via the `FREE_THREADED` flag on +`nanobind_add_module` (see the build config below) --- the module code stays +unchanged: + +```{literalinclude} ../../examples/6_01_free_threading/nanobind/freecomputepi/_core.cpp +:language: cpp +``` + +::: + +:::: + +:::{note} + +Declaring the module GIL-free is a _promise_, not a shield. You're telling +CPython your extension has no unguarded shared state. Our `pi` only uses local +variables, so it's safe --- but a function with global caches or shared buffers +would need real locking (`std::mutex`, atomics, or nanobind's `nb::ft_mutex`) +before making that promise. + +With the raw C API you make the same promise with a module slot: +`{Py_mod_gil, Py_MOD_GIL_NOT_USED}`. +::: + +A thin Python wrapper spreads the work over a thread pool, exactly as the pure +version did --- it just imports `pi` from the compiled `_core` instead: + +```{literalinclude} ../../examples/6_01_free_threading/pybind11/freecomputepi/pi.py +:language: python +``` + +### Build configuration + +The CMake is a standard scikit-build-core extension build. nanobind is where the +free-threading opt-in lives (`FREE_THREADED`); pybind11 needs nothing special +here: + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +```{literalinclude} ../../examples/6_01_free_threading/pybind11/CMakeLists.txt +:language: cmake +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +```{literalinclude} ../../examples/6_01_free_threading/nanobind/CMakeLists.txt +:language: cmake +``` + +::: + +:::: + +The `pyproject.toml` differs only in the binding tool it requires: + +::::{tab-set} + +:::{tab-item} pybind11 +:sync: pybind11 + +```{literalinclude} ../../examples/6_01_free_threading/pybind11/pyproject.toml +:language: toml +``` + +::: + +:::{tab-item} nanobind +:sync: nanobind + +```{literalinclude} ../../examples/6_01_free_threading/nanobind/pyproject.toml +:language: toml +``` + +::: + +:::: + +### Build and run + +`uv run` builds the extension and runs the same benchmark: + +```bash +uv run --python 3.14t sample.py +``` + +```text +Python 3.14.6, GIL disabled + 1 threads: pi = 3.14141 (0.26 s) + 2 threads: pi = 3.14199 (0.13 s) + 4 threads: pi = 3.14148 (0.07 s) + 8 threads: pi = 3.14149 (0.07 s) +``` + +Same near-linear scaling as pure Python, but an order of magnitude faster per +thread. pybind11 and nanobind produce identical timings --- the choice is about +the binding style, not the parallelism. + +## Building wheels + +Free-threaded wheels use a distinct ABI tag (`cp314t`), so they're separate +artifacts from the regular `cp314` wheels. [cibuildwheel](../compiled/04_cibuildwheel.md) +builds them for you --- as of 3.14 free-threading is no longer experimental, so +they're on by default with no `enable` needed: + +```toml +[tool.cibuildwheel] +build = "cp314*" +``` + +The `cp314*` pattern matches both the `cp314` and `cp314t` identifiers, so each +job emits both a normal and a free-threaded wheel. Users on a free-threaded +interpreter automatically get the `t` wheel; the GIL stays off, and their +threads finally use every core. diff --git a/content/interesting/lazy_imports.md b/content/interesting/lazy_imports.md index b27aec4..9726f8e 100644 --- a/content/interesting/lazy_imports.md +++ b/content/interesting/lazy_imports.md @@ -1 +1,335 @@ # Modern Python: lazy imports + +{button}`Slides ` + +Importing a module runs it. Every `import numpy` at the top of a file pays the +full cost of loading that module --- even if the code path that needs it never +runs. For a library that's a one-time cost; for a CLI tool it's paid on _every_ +invocation, including `--help`. + +[PEP 810](https://peps.python.org/pep-0810/) adds **lazy imports** to Python +3.15. A lazy import does nothing when the `import` statement runs; the module is +loaded the first time you actually touch it. Unlike the earlier, rejected +attempt at implicit laziness, this one is explicit and opt-in --- libraries mark +which imports are safe to defer. + +:::{note} +Python 3.15 is still in alpha, but you can try lazy imports today: +`uv python install 3.15` fetches it on every major platform, and +`uv run --python 3.15 ...` runs against it. +::: + +## The problem + +Here is a standard argparse CLI: + +```python +import argparse +import numpy + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--foo", action="store_true") + args = parser.parse_args() + if args.foo: + print(numpy.array([1, 2, 3])) +``` + +Run this with `--help` and `numpy` is imported anyway, even though it is never +used. With modern tooling this hurts more than it used to: `uv` doesn't +pre-compile bytecode unless you ask it to, so the first import is slower. + +The same waste shows up in the classic re-export `__init__.py`: + +```python +# __init__.py +from . import a +from . import b + +__all__ = ["a", "b"] +``` + +This lets a user write `lib.a.stuff` after just `import lib`, but they pay to +import `b` even if they only ever touch `a`. Careful libraries like `rich` avoid +this and ask for explicit imports; many older ones don't. Subcommand CLIs have +the same shape --- each subcommand needs different dependencies, but importing +the top-level package drags in all of them. + +## Using lazy imports + +In Python 3.15 you add the `lazy` keyword: + +```python +lazy import argparse +lazy import numpy + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--foo", action="store_true") + args = parser.parse_args() + if args.foo: + print(numpy.array([1, 2, 3])) +``` + +Now nothing happens when these imports run --- the modules might not even be +installed. The first time you access an attribute, the module becomes a real, +imported object. Run `--help` and `numpy` is never touched, so it's never +imported. + +There is also a backward-compatible spelling that works on _any_ Python version +(it's just not lazy before 3.15): + +```python +__lazy_modules__ = ["argparse", "numpy"] + +import argparse +import numpy +``` + +`__lazy_modules__` is a plain list of absolute module names, so you can generate +or manipulate it dynamically. Linters like Ruff already know to allow it above +your imports without complaining about import order. + +:::{note} +Two escape hatches force the mode globally, mostly for testing: the `-X +lazy_imports=all` flag and the `PYTHON_LAZY_IMPORTS=all` environment variable +(both also accept `normal` and `none`). Turning laziness `all` on before you +start is a quick way to estimate how much time you might save. +::: + +## When _not_ to be lazy + +You don't have to mark everything lazy, and sometimes you shouldn't. + +**Import side effects.** If a module needs to run at the import site, it can't +be lazy. The most common case is a guarded optional dependency: + +```python +try: + import numpy +except ModuleNotFoundError: + ... +``` + +Make this lazy and the `ModuleNotFoundError` moves to the first _use_ of numpy, +which is not what you want. The semi-lazy alternative checks for the module +without importing it: + +```python +import importlib.util + +if importlib.util.find_spec("numpy") is None: + ... # whatever you wanted to do if numpy is missing + +lazy import numpy +``` + +This costs a little more than doing nothing (which is why laziness doesn't do it +for you), and it imports parent packages to reach subpackages (`a.b` imports +`a`), but it's a good way to detect an installed package. + +**Top-level use.** If you use the module while the file is being imported, +laziness buys nothing: + +```python +lazy import re + +REGEX = re.compile(...) # re is loaded right here anyway +``` + +You can defer this by moving the work behind a cache: + +::::{tab-set} + +:::{tab-item} Python 3.15+ + +```python +import functools + +lazy import re + + +@functools.cache +def regex() -> re.Pattern: + return re.compile(...) +``` + +::: + +:::{tab-item} Older + +```python +__lazy_modules__ = ["re"] + +import functools +import re + + +@functools.cache +def regex() -> re.Pattern: + return re.compile(...) +``` + +::: + +:::: + +Note there's no `from __future__ import annotations` needed for the `re.Pattern` +return annotation --- annotations became lazy by default in Python 3.14, so they +don't trigger the import. + +You _can_ make top-level imports lazy, but you're just relocating the eventual +import for no benefit, so it's cleaner to leave them eager. + +## flake8-lazy + +Deciding what belongs in `__lazy_modules__` by hand is fiddly --- you have to +find every module that isn't used at top level, keep the list sorted, and avoid +listing things twice. [flake8-lazy](https://github.com/henryiii/flake8-lazy) +does that for you. It's a flake8 plugin with a standalone runner, which is +likely how you'll use it early in the 3.15 lifecycle: + +::::{tab-set} + +:::{tab-item} uv + +```bash +# Show flake8-style errors +uvx flake8-lazy +# Show the lines you need to add +uvx flake8-lazy --format=lazy-modules +# Just add them! +uvx flake8-lazy --apply=list +``` + +::: + +:::{tab-item} pipx + +```bash +# Show flake8-style errors +pipx run flake8-lazy +# Show the lines you need to add +pipx run flake8-lazy --format=lazy-modules +# Just add them! +pipx run flake8-lazy --apply=list +``` + +::: + +:::: + +`--apply` rewrites files in place; it supports `list`, `set`, `native` (the +`lazy` keyword), and `dynamic` output formats. + +The checks fall into four groups. The 1xx checks find imports that _should_ be +lazy: + +| Code | Missing lazy declarations | +| -------- | ------------------------------------------------------------------ | +| `LZY101` | stdlib module should be listed in `__lazy_modules__` | +| `LZY102` | third-party or local module should be listed in `__lazy_modules__` | + +The 2xx checks keep an existing `__lazy_modules__` tidy and correct: + +| Code | `__lazy_modules__` validation | +| -------- | --------------------------------------------------------------- | +| `LZY201` | `__lazy_modules__` is not sorted | +| `LZY202` | module listed in `__lazy_modules__` is never imported | +| `LZY203` | module listed in `__lazy_modules__` is duplicated | +| `LZY204` | `__lazy_modules__` is assigned after importing modules it names | +| `LZY205` | module listed in `__lazy_modules__` must be an absolute name | + +The 3xx checks cover the native `lazy` keyword and only run on a 3.15+ host: + +| Code | Native `lazy` keyword (Python 3.15+) | +| -------- | ------------------------------------------------------------------ | +| `LZY301` | lazy import inside `suppress(ImportError)` is misleading | +| `LZY302` | module declared lazy by both `lazy` keyword and `__lazy_modules__` | +| `LZY303` | module imported both eagerly and lazily | + +The 4xx checks are the inverse of 1xx --- laziness that isn't buying anything: + +| Code | Lazy import safety and semantics | +| -------- | ------------------------------------------------------------------- | +| `LZY401` | module is declared lazy but accessed at the top level | +| `LZY402` | module is an enclosing package for this file and should not be lazy | + +:::{tip} +Don't run this on your test suite --- tests generally use everything they +import, so there's nothing to defer. +::: + +## Tips + +**Look beyond what the tool finds.** The `re` cache above is a case the checker +can't spot on its own. Watch the _actual_ imports too, since one library often +pulls in another --- lots of things import `re` (including `typing`), and `re` +isn't cheap. Profile with `python -X importtime`; anything successfully made +lazy drops off the list. + +**Skip `typing` at runtime.** Type checkers always treat `TYPE_CHECKING` as +`True`, so you can gate type-only imports behind a local that's `False` at +runtime: + +```python +TYPE_CHECKING = False +if TYPE_CHECKING: + import numpy +``` + +Ruff can even enforce this with `flake8-tidy-imports`: + +```toml +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"typing.TYPE_CHECKING".msg = "Use TYPE_CHECKING=False instead" +``` + +**Relative imports stay static.** `__lazy_modules__` needs absolute names, so +build them from the package name: + +```python +__lazy_modules__ = [f"{__spec__.parent}.thing"] +from . import thing +``` + +(`__package__` is the older spelling of `__spec__.parent`.) Don't do this in +`__main__.py` --- use absolute imports there, since `__spec__` can be `None`. + +## Results + +Making imports lazy pays off most for CLI startup. Running `flake8-lazy` on real +projects and timing `--help` on Python 3.15: + +| Package | Before | After | Speedup | +| ------------ | ------- | ------- | ------- | +| flake8-lazy | 100+ ms | 50 ms | 2x | +| repo-review | 113 ms | 35 ms | 3x | +| cibuildwheel | 179 ms | 61 ms | 3x | +| check-sdist | 45 ms | 34 ms | 30% | + +Python itself takes around 15 ms to start (on an M1), so that's the floor --- you +still need a few things like argparse. The `uv`-doesn't-precompile effect means +the first-run savings can be larger than these warm numbers show. To measure a +change yourself, [hyperfine](https://github.com/sharkdp/hyperfine) makes an +easy before/after: + +```bash +hyperfine --warmup 10 \ + -n main --prepare "git checkout main" "python3.15 -m --help" \ + -n PR --prepare "git checkout some-branch" "python3.15 -m --help" +``` + +Add `-X lazy_imports=none` or `all` to bound the best and worst case without +touching the code. + +:::{note} +There are edge cases where laziness silently breaks: dataclasses resolve their +annotations to look for `typing.ClassVar`, which forces those imports. And the +`from a import b` form is ambiguous --- only a type checker knows whether `b` is +a module or an attribute --- so flake8-lazy assumes it isn't and may miss it. Use +`import a.b as b` to be unambiguous. When in doubt, err toward marking too much +lazy rather than too little. +::: diff --git a/content/scikit-build/03_dynamic_metadata.md b/content/scikit-build/03_dynamic_metadata.md index df7bc52..c6cd04c 100644 --- a/content/scikit-build/03_dynamic_metadata.md +++ b/content/scikit-build/03_dynamic_metadata.md @@ -1 +1,362 @@ +--- +authors: [Cristian Le] +--- + # Dynamic Metadata + +A method to handle `project.dynamic` fields generically. +The [specification] is generic, but currently, it is a feature used only in scikit-build-core. +I.e. for now you must use + +```{code} toml +:filename: pyproject.toml + +[build-system] +requires = ["scikit-build-core"] +build-backend = "scikit_build_core.build" +``` + +[specification]: https://github.com/scikit-build/dynamic-metadata + +## Example + +::::{tab-set} + +:::{tab-item} Generic (recommended) + +```{code} toml +:filename: pyproject.toml + +[project] +name = "mypackage" +dynamic = ["version"] + +[tool.scikit-build] +sdist.include = ["src/package/_version.py"] + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.setuptools_scm] # Section required +write_to = "src/package/_version.py" +``` + +::: + +:::{tab-item} scikit-build + +```{code} toml +:filename: pyproject.toml + +[project] +name = "mypackage" +dynamic = ["version"] + +[tool.scikit-build] +sdist.include = ["src/package/_version.py"] +metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.setuptools_scm] # Section required +write_to = "src/package/_version.py" +``` + +::: + +:::: + +## From the user side + +- Add a field that you want to be resolved dynamically in `project.dynamic` [^1] +- Add a `[[tool.dynamic-metadata]]` section with a `provider` that can provide that field +- Configure all other (arbitrary) keys that the provider consumes in `tool.dynamic-metadata` or other `tool.*` +- Profit? + +[^1]: With PEP808 you can have mixed static and dynamic fields for table and array fields like `dependencies`, `scripts` + +## Pre-defined plugins + +There are some pre-defined plugins to get you started both in `scikit-build-core` or `dynamic-metadata`. +To use the latter, make sure `dynamic-metadata` is in the `build-system.requires`. +For an updated documentation check the relevant documentation section in [scikit-build-core] and [dynamic-metadata]. + +[scikit-build-core]: https://scikit-build-core.readthedocs.io/en/latest/configuration/dynamic.html#built-in-plugins +[dynamic-metadata]: https://dynamic-metadata.readthedocs.io/en/latest/plugins.html#bundled-plugins + +::::{tab-set} + +:::{tab-item} `scikit_build_core.metadata.setuptools_scm` + +Get version from git metadata + +```toml +[project] +name = "mypackage" +dynamic = ["version"] + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.setuptools_scm] +``` + +- dynamic field: `version` +- inputs: `tool.setuptools_scm` + +See [setuptools-scm] + +[setuptools-scm]: https://setuptools-scm.readthedocs.io/en/latest/ + +::: + +:::{tab-item} `(scikit_build_core.metadata|dynamic_metadata.plugins).regex` + +Format a field from a regex search of a file + +```toml +[project] +name = "mypackage" +dynamic = ["version"] + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.regex" +field = "version" +input = "src/package/_version.py" +``` + +- dynamic field: any, specify in `field` +- inputs: + - `field`: The metadata field to set + - `input`: The file to read + - `regex`: The pattern to search for (default: match for `__version__`, `VERSION`) + - `result`: `str.format` template to generate with the named regex groups (default: `{value}`) + - `remove`: A regex to be stripped after `result` is rendered + +::: + +:::{tab-item} `(scikit_build_core.metadata|dynamic_metadata.plugins).template` + +Format a field from other values in `project` table + +```toml +[project] +name = "mypackage" +dynamic = ["version"] + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.template" +field = "dependencies" +result = ["mypackage-core == {project['version']}"] +``` + +- dynamic field: any, specify in `field` +- inputs: + - `field`: The metadata field to set + - `result`: `str.format` template to generate with the `project` table + +::: + +:::{tab-item} `scikit_build_core.metadata.fancy_pypi_readme` + +Generate a comprehensive readme from snippets or with substitution + +```toml +[project] +name = "mypackage" +dynamic = ["readme"] + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.fancy_pypi_readme" + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +``` + +- dynamic field: `readme` +- inputs: `[tool.hatch.metadata.hooks.fancy-pypi-readme]` + +See [hatch-fancy-pypi-readme] + +[hatch-fancy-pypi-readme]: https://github.com/hynek/hatch-fancy-pypi-readme + +::: + +:::{tab-item} `dynamic_metadata.plugins.ast` + +Parse a Python file and use a variable + +```toml +[project] +name = "mypackage" +dynamic = ["version"] + +[[tool.dynamic-metadata]] +provider = "dynamic_metadata.plugins.ast" +field = "version" +input = "src/my_package/__init__.py" +name = "__version__" +``` + +- dynamic field: any, specify in `field` +- inputs: + - `field`: The metadata field to set + - `input` : The python file to parse + - `name`: The global variable to read + +::: + +:::{tab-item} `dynamic_metadata.plugins.from_file` + +Read a file and pass it to the field + +```toml +[project] +name = "mypackage" +dynamic = ["dependencies"] + +[[tool.dynamic-metadata]] +provider = "dynamic_metadata.plugins.from_file" +field = "dependencies" +path = "requirements.txt" +``` + +- dynamic field: any, specify in `field` +- inputs: + - `field`: The metadata field to set + - `path` : The file to read + +::: + +:::{tab-item} `dynamic_metadata.plugins.static` + +Set the field statically + +```toml +[project] +name = "mypackage" +dynamic = ["version"] + +[[tool.dynamic-metadata]] +provider = "dynamic_metadata.plugins.static" +version = "1.2.3" +``` + +- dynamic field: any +- inputs: any `project` fields + +::: + +:::{tab-item} `dynamic_metadata.plugins.readme_fragment` + +Build a readme from multiple fragments + +```toml +[project] +name = "mypackage" +dynamic = ["readme"] + +[[tool.dynamic-metadata]] +provider = "dynamic_metadata.plugins.readme_fragment" +path = "README.md" +``` + +- dynamic field: `readme` +- inputs: + - `text`: A literal text + - `path`: A file to read + - `content-type`: The readme content (default: `text/markdown`) + - `start-after`, `end-before`: Markers indicating the content (exclusive) + - `start-at`, `end-at`: Markers indicating the content (inclusive) + - `pattern`: A regex pattern to capture the fragment + +::: + +:::{tab-item} `dynamic_metadata.plugins.pin_installed` + +Pin the runtime dependencies to the versions in `build-system.requires` + +```toml +[project] +name = "mypackage" +dynamic = ["dependencies"] + +[[tool.dynamic-metadata]] +provider = "dynamic_metadata.plugins.pin_installed" +packages = ["torch==x.x.*"] +``` + +- dynamic field: `dependencies` +- inputs: + - `packages`: A list of requirement templates + +::: + +:::{tab-item} `dynamic_metadata.plugins.substitute` + +Apply a regex substitution on the field previously generated + +```toml +[[tool.dynamic-metadata]] +provider = "dynamic_metadata.plugins.substitute" +field = "readme" +pattern = "#(\\d+)" +replacement = "[#\\1](https://github.com/org/repo/issues/\\1)" +``` + +- dynamic field: any, specify in `field` +- inputs: + - `field`: The metadata field to transform + - `pattern`: The regex pattern to search + - `replacement`: The regex replacement + - `ignore-case`: Whether to match case-insensitively + - `format`: Whether to resolve `project` + +::: + +:::: + +## Order matters + +The `tool.dynamic-metadata` are processed in the order they appear in `pyproject.toml`. +If a provider expects a dynamic field to have been resolved, make sure you order them appropriately. + +``` toml +:filename: pyproject.toml +:emphasize-lines: 5-11 +:linenos: + +[project] +name = "mypackage" +dynamic = ["version", "dependencies"] + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.setuptools_scm" + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.template" +field = "dependencies" +result = ["mypackage-core == {project['version']}"] + +[tool.setuptools_scm] +``` + +## Want more? Custom plugins + +You can write your own plugin and use it locally or distribute it via `dynamic_metadata.provider` entry-point. + +```toml +[[tool.dynamic-metadata]] +provider = {path = "helpers/plugins", module = "my_plugin"} +``` + +The referenced module/class must have at least the function + +```python +def dynamic_metadata( + settings: Mapping[str, Any], + project: Mapping[str, Any], +) -> dict[str, Any]: + """ + :param settings: all the additional fields defined in the ``[[tool.dynamic-metadata]]`` + :param project: current view of the ``[project]`` + :return: a ``[project]`` snippet to be merged + """ +``` diff --git a/content/scikit-build/05_overrides.md b/content/scikit-build/05_overrides.md index fde431b..ccb088d 100644 --- a/content/scikit-build/05_overrides.md +++ b/content/scikit-build/05_overrides.md @@ -1 +1,161 @@ +--- +authors: [Cristian Le] +--- + # The override system + +## The basics + +This allows you to _override_ the settings in `[tool.scikit-build]` based on the environment that is building the package. + +A simple example is + +```toml +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +cmake.define.BUILD_MPI = true +``` + +which checks for an environment variable `WITH_MPI` and then populates the `-DBUILD_MPI` CMake define. + +The latest documentation of this feature can be found at the [Configuration/Overrides] page + +[Configuration/Overrides]: https://scikit-build-core.readthedocs.io/en/latest/configuration/overrides.html + +### Example situations + +Here are some scenarios of where and how you could use this to get you inspired: + +- Specifying different CMake defines in the CI +- Providing platform specific default settings +- Change the build flags based on Python and/or CMake version +- Fail early on unsupported environments +- Download CMake dependencies when building from sdist, but not when building locally +- Adjust SPDX license if using bundled dependencies + +## How it works + +All `if.*` conditionals are `and` glued. +If you need `or` glued conditionals instead, use `if.any.*` as needed. +The current `if.*` conditionals available are: + +- `scikit-build-version` (version): scikit-build-core version currently used +- `python-version` (version): the builder's python version +- `system-cmake` (version): if any suitable CMake is available on the system +- `implementation-version` (version): `sys.implementation.name` +- `platform-system`, `platform-machine`, `platform-node`, `implementation-name` (regex): + equivalent `sys.*` variable +- `abi-flags` (regex): abi flags such as `t` for free-threading +- `state` (regex): current build state, one of `sdist`, `wheel`, `editable`, `metadata_wheel`, `metadata_editable` +- `env.*` (regex or bool): environment variables +- `from-sdist` (bool): whether the build comes from an sdist source +- `wheel.cmake` (bool): if there are known CMake wheels available for the system +- `failed` (bool): whether a build has failed, used to try again with other options + +where the `regex`, `version`, `bool` conditionals check if the variable match the provided regex pattern, +version specifier, boolean state respectively. + +Then you provide any variables you would provide for `tool.scikit-build` to override. +By default, a table (e.g. `cmake.define`) is completely replaced. +`inherit.*` can be used to merge the table with overrides on top of original (`append`) or vice-versa (`prepend`). +For example + +::::{tab-set} + +:::{tab-item} if does not match + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false +cmake.define.BUILD_TESTS = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +cmake.define.BUILD_MPI = true +cmake.define.MPI_PROC = "2" +``` + +If environment variable `WITH_MPI` is not defined is equivalent to + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false +cmake.define.BUILD_TESTS = false +``` + +::: + +:::{tab-item} no inheirt + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false +cmake.define.BUILD_TESTS = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +cmake.define.BUILD_MPI = true +cmake.define.MPI_PROC = "2" +``` + +If environment variable `WITH_MPI=ON` is equivalent to + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = true +cmake.define.MPI_PROC = "2" +``` + +::: + +:::{tab-item} inherit append + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false +cmake.define.BUILD_TESTS = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +inherit.cmake.define = "append" +cmake.define.BUILD_MPI = true +cmake.define.MPI_PROC = "2" +``` + +If environment variable `WITH_MPI=ON` is equivalent to + +```toml +[tool.scikit-build] +cmake.define.BUILD_TESTS = false +cmake.define.BUILD_MPI = true +cmake.define.MPI_PROC = "2" +``` + +::: + +:::{tab-item} inherit prepend + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false +cmake.define.BUILD_TESTS = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +inherit.cmake.define = "prepend" +cmake.define.BUILD_MPI = true +cmake.define.MPI_PROC = "2" +``` + +If environment variable `WITH_MPI=ON` is equivalent to + +```toml +[tool.scikit-build] +cmake.define.BUILD_TESTS = false +cmake.define.BUILD_MPI = false +cmake.define.MPI_PROC = "2" +``` + +::: + +:::: diff --git a/examples/2_01_package/collatz/.gitignore b/examples/2_01_package/collatz/.gitignore new file mode 100644 index 0000000..5d24ff1 --- /dev/null +++ b/examples/2_01_package/collatz/.gitignore @@ -0,0 +1,220 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/examples/2_01_package/collatz/CMakeLists.txt b/examples/2_01_package/collatz/CMakeLists.txt new file mode 100644 index 0000000..4aff989 --- /dev/null +++ b/examples/2_01_package/collatz/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.15...4.3) +project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX) + +find_package(pybind11 CONFIG REQUIRED) + +pybind11_add_module(collatz collatz.cpp) + +install(TARGETS collatz DESTINATION .) diff --git a/examples/2_01_package/collatz/collatz.cpp b/examples/2_01_package/collatz/collatz.cpp new file mode 100644 index 0000000..bc2c015 --- /dev/null +++ b/examples/2_01_package/collatz/collatz.cpp @@ -0,0 +1,15 @@ +#include + +// Even -> n/2, odd -> 3n+1. Conjectured to always terminate. +int collatz_steps(long long n) { + int steps = 0; + while (n != 1) { + n = (n % 2 == 0) ? n / 2 : 3 * n + 1; + ++steps; + } + return steps; +} + +PYBIND11_MODULE(collatz, m) { + m.def("collatz_steps", &collatz_steps, "Steps to reach 1 in the Collatz sequence"); +} diff --git a/examples/2_01_package/collatz/pyproject.toml b/examples/2_01_package/collatz/pyproject.toml new file mode 100644 index 0000000..2bc3228 --- /dev/null +++ b/examples/2_01_package/collatz/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["scikit-build-core", "pybind11"] +build-backend = "scikit_build_core.build" + +[project] +name = "collatz" +version = "0.1.0" diff --git a/examples/2_02_binding/cpponly/CMakeLists.txt b/examples/2_02_binding/cpponly/CMakeLists.txt new file mode 100644 index 0000000..8b9e4fb --- /dev/null +++ b/examples/2_02_binding/cpponly/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.26...4.4) +project(Minuit2SimpleExamle LANGUAGES CXX) + +include(FetchContent) +FetchContent_Declare( + Minuit2 + GIT_REPOSITORY https://github.com/GooFit/Minuit2.git + GIT_TAG v6-40-02 + GIT_SHALLOW TRUE + FIND_PACKAGE_ARGS +) +FetchContent_MakeAvailable(Minuit2) + +add_executable(simpleminuit simpleminuit.cpp SimpleFCN.hpp) +target_link_libraries(simpleminuit PRIVATE Minuit2::Minuit2) diff --git a/examples/2_02_binding/cpponly/SimpleFCN.hpp b/examples/2_02_binding/cpponly/SimpleFCN.hpp new file mode 100644 index 0000000..ef537f9 --- /dev/null +++ b/examples/2_02_binding/cpponly/SimpleFCN.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +using namespace ROOT::Minuit2; + +class SimpleFCN : public FCNBase { + // Always 0.5 for these sorts of fits + double Up() const override {return 0.5;} + + // This computes whatever you are going to minimize + double operator()(const std::vector &v) const override { + std::cout << "val = " << v.at(0) << std::endl; + return v.at(0)*v.at(0); + } +}; diff --git a/examples/2_02_binding/cpponly/simpleminuit.cpp b/examples/2_02_binding/cpponly/simpleminuit.cpp new file mode 100644 index 0000000..43bbd52 --- /dev/null +++ b/examples/2_02_binding/cpponly/simpleminuit.cpp @@ -0,0 +1,10 @@ +#include "SimpleFCN.hpp" + +int main() { + SimpleFCN fcn; + MnUserParameters upar; + upar.Add("x", 1., 0.1); + MnMigrad migrad(fcn, upar); + FunctionMinimum min = migrad(); + std::cout << min << std::endl; +} diff --git a/examples/2_02_binding/nanobind/CMakeLists.txt b/examples/2_02_binding/nanobind/CMakeLists.txt new file mode 100644 index 0000000..b1196a2 --- /dev/null +++ b/examples/2_02_binding/nanobind/CMakeLists.txt @@ -0,0 +1,25 @@ +cmake_minimum_required(VERSION 3.26...4.4) +project(pyminuit2 LANGUAGES CXX) + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to use") +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(FetchContent) +FetchContent_Declare( + Minuit2 + GIT_REPOSITORY https://github.com/GooFit/Minuit2.git + GIT_TAG v6-40-02 + GIT_SHALLOW TRUE + FIND_PACKAGE_ARGS +) +FetchContent_MakeAvailable(Minuit2) + +find_package(Python 3.8 REQUIRED COMPONENTS Interpreter Development.Module) +find_package(nanobind CONFIG REQUIRED) + +file(GLOB OUTPUT pyminuit2/*.cpp) +nanobind_add_module(minuit2 ${OUTPUT}) +target_link_libraries(minuit2 PUBLIC Minuit2::Minuit2) +install(TARGETS minuit2 DESTINATION .) diff --git a/examples/2_02_binding/nanobind/pyminuit2/FCNBase.cpp b/examples/2_02_binding/nanobind/pyminuit2/FCNBase.cpp new file mode 100644 index 0000000..44f9cf8 --- /dev/null +++ b/examples/2_02_binding/nanobind/pyminuit2/FCNBase.cpp @@ -0,0 +1,25 @@ +#include +#include // std::vector <-> Python list conversion +#include + +#include + +namespace nb = nanobind; +using namespace ROOT::Minuit2; + +class PyFCNBase : public FCNBase { + public: + NB_TRAMPOLINE(FCNBase, 2); + + double operator()(const std::vector &v) const override { + NB_OVERRIDE_PURE_NAME("__call__", operator(), v);} + + double Up() const override { + NB_OVERRIDE_PURE(Up);} + }; +void init_FCNBase(nb::module_ &m) { + nb::class_(m, "FCNBase") + .def(nb::init<>()) + .def("__call__", &FCNBase::operator()) + .def("Up", &FCNBase::Up); +} diff --git a/examples/2_02_binding/nanobind/pyminuit2/FunctionMinimum.cpp b/examples/2_02_binding/nanobind/pyminuit2/FunctionMinimum.cpp new file mode 100644 index 0000000..312ab1d --- /dev/null +++ b/examples/2_02_binding/nanobind/pyminuit2/FunctionMinimum.cpp @@ -0,0 +1,21 @@ +#pragma once +#include +#include // std::string return value -> Python str + +#include + +#include +#include + +namespace nb = nanobind; +using namespace ROOT::Minuit2; + +void init_FunctionMinimum(nb::module_ &m) { + nb::class_(m, "FunctionMinimum") + .def("__str__", [](const FunctionMinimum &self) { + std::stringstream os; + os << self; + return os.str(); + }) + ; +} diff --git a/examples/2_02_binding/nanobind/pyminuit2/MnApplication.cpp b/examples/2_02_binding/nanobind/pyminuit2/MnApplication.cpp new file mode 100644 index 0000000..9836f1e --- /dev/null +++ b/examples/2_02_binding/nanobind/pyminuit2/MnApplication.cpp @@ -0,0 +1,27 @@ +#include + +#include +#include +#include +#include +#include + +namespace nb = nanobind; +using namespace nanobind::literals; +using namespace ROOT::Minuit2; + +void init_MnMigrad(nb::module_ &m) { + nb::class_(m, "MnApplication") + .def("__call__", + &MnApplication::operator(), + "Minimize the function, returns a function minimum", + nb::arg("maxfcn") = 0, + "tolerance"_a = 0.1); + + nb::class_(m, "MnMigrad") + .def("__init__", [](MnMigrad *self, const FCNBase &fcn, const MnUserParameters &par, unsigned int stra) { + new (self) MnMigrad(fcn, par, MnStrategy(stra)); + }, + "fcn"_a, "par"_a, "stra"_a = 1) + ; +} diff --git a/examples/2_02_binding/nanobind/pyminuit2/MnUserParameters.cpp b/examples/2_02_binding/nanobind/pyminuit2/MnUserParameters.cpp new file mode 100644 index 0000000..f5a252c --- /dev/null +++ b/examples/2_02_binding/nanobind/pyminuit2/MnUserParameters.cpp @@ -0,0 +1,15 @@ +#include +#include // std::string <-> Python str conversion + +#include + +namespace nb = nanobind; +using namespace ROOT::Minuit2; + +void init_MnUserParameters(nb::module_ &m) { + nb::class_(m, "MnUserParameters") + .def(nb::init<>()) + .def("Add", nb::overload_cast(&MnUserParameters::Add)) + .def("Add", nb::overload_cast(&MnUserParameters::Add)) + ; +} diff --git a/examples/2_02_binding/nanobind/pyminuit2/pyminuit2.cpp b/examples/2_02_binding/nanobind/pyminuit2/pyminuit2.cpp new file mode 100644 index 0000000..7165233 --- /dev/null +++ b/examples/2_02_binding/nanobind/pyminuit2/pyminuit2.cpp @@ -0,0 +1,15 @@ +#include + +namespace nb = nanobind; + +void init_FCNBase(nb::module_ &); +void init_MnUserParameters(nb::module_ &); +void init_MnMigrad(nb::module_ &); +void init_FunctionMinimum(nb::module_ &); + +NB_MODULE(minuit2, m) { + init_FCNBase(m); + init_MnUserParameters(m); + init_MnMigrad(m); + init_FunctionMinimum(m); +} diff --git a/examples/2_02_binding/nanobind/pyproject.toml b/examples/2_02_binding/nanobind/pyproject.toml new file mode 100644 index 0000000..f22d34b --- /dev/null +++ b/examples/2_02_binding/nanobind/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["scikit-build-core>=1", "nanobind>=2"] +build-backend = "scikit_build_core.build" + +[project] +name = "pyminuit2" +version = "0.0.1" + +# Rebuild the editable install when the build config or C++ sources change +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "CMakeLists.txt" }, + { file = "pyminuit2/**/*.cpp" }, + { file = "pyminuit2/**/*.hpp" }, +] diff --git a/examples/2_02_binding/nanobind/sample.py b/examples/2_02_binding/nanobind/sample.py new file mode 100644 index 0000000..3eb7e1e --- /dev/null +++ b/examples/2_02_binding/nanobind/sample.py @@ -0,0 +1,18 @@ +import minuit2 + + +class SimpleFCN(minuit2.FCNBase): + def Up(self): + return 0.5 + + def __call__(self, v): + print("val =", v[0]) + return v[0] ** 2 + + +fcn = SimpleFCN() +upar = minuit2.MnUserParameters() +upar.Add("x", 1.0, 0.1) +migrad = minuit2.MnMigrad(fcn, upar) +minimum = migrad() +print(minimum) diff --git a/examples/2_02_binding/nanobind/uv.lock b/examples/2_02_binding/nanobind/uv.lock new file mode 100644 index 0000000..022f5a9 --- /dev/null +++ b/examples/2_02_binding/nanobind/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "pyminuit2" +version = "0.0.1" +source = { editable = "." } diff --git a/examples/2_02_binding/pybind11/CMakeLists.txt b/examples/2_02_binding/pybind11/CMakeLists.txt new file mode 100644 index 0000000..cf87a2a --- /dev/null +++ b/examples/2_02_binding/pybind11/CMakeLists.txt @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.26...4.4) +project(pyminuit2 LANGUAGES CXX) + +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to use") +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(FetchContent) +FetchContent_Declare( + Minuit2 + GIT_REPOSITORY https://github.com/GooFit/Minuit2.git + GIT_TAG v6-40-02 + GIT_SHALLOW TRUE + FIND_PACKAGE_ARGS +) +FetchContent_MakeAvailable(Minuit2) + +find_package(pybind11) + +file(GLOB OUTPUT pyminuit2/*.cpp) +pybind11_add_module(minuit2 ${OUTPUT}) +target_link_libraries(minuit2 PUBLIC Minuit2::Minuit2) +install(TARGETS minuit2 DESTINATION .) diff --git a/examples/2_02_binding/pybind11/pyminuit2/FCNBase.cpp b/examples/2_02_binding/pybind11/pyminuit2/FCNBase.cpp new file mode 100644 index 0000000..088e70d --- /dev/null +++ b/examples/2_02_binding/pybind11/pyminuit2/FCNBase.cpp @@ -0,0 +1,25 @@ +#include +#include // std::vector <-> Python list conversion + +#include + +namespace py = pybind11; +using namespace ROOT::Minuit2; + +class PyFCNBase : public FCNBase { + public: + using FCNBase::FCNBase; + + double operator()(const std::vector &v) const override { + PYBIND11_OVERLOAD_PURE_NAME( + double, FCNBase, "__call__", operator(), v);} + + double Up() const override { + PYBIND11_OVERLOAD_PURE(double, FCNBase, Up, );} + }; +void init_FCNBase(py::module &m) { + py::class_(m, "FCNBase") + .def(py::init<>()) + .def("__call__", &FCNBase::operator()) + .def("Up", &FCNBase::Up); +} diff --git a/examples/2_02_binding/pybind11/pyminuit2/FunctionMinimum.cpp b/examples/2_02_binding/pybind11/pyminuit2/FunctionMinimum.cpp new file mode 100644 index 0000000..b11dd11 --- /dev/null +++ b/examples/2_02_binding/pybind11/pyminuit2/FunctionMinimum.cpp @@ -0,0 +1,20 @@ +#pragma once +#include + +#include + +#include +#include + +namespace py = pybind11; +using namespace ROOT::Minuit2; + +void init_FunctionMinimum(py::module &m) { + py::class_(m, "FunctionMinimum") + .def("__str__", [](const FunctionMinimum &self) { + std::stringstream os; + os << self; + return os.str(); + }) + ; +} diff --git a/examples/2_02_binding/pybind11/pyminuit2/MnApplication.cpp b/examples/2_02_binding/pybind11/pyminuit2/MnApplication.cpp new file mode 100644 index 0000000..4f38a9f --- /dev/null +++ b/examples/2_02_binding/pybind11/pyminuit2/MnApplication.cpp @@ -0,0 +1,27 @@ +#include + +#include +#include +#include +#include +#include + +namespace py = pybind11; +using namespace pybind11::literals; +using namespace ROOT::Minuit2; + +void init_MnMigrad(py::module &m) { + py::class_(m, "MnApplication") + .def("__call__", + &MnApplication::operator(), + "Minimize the function, returns a function minimum", + py::arg("maxfcn") = 0, + "tolerance"_a = 0.1); + + py::class_(m, "MnMigrad") + .def(py::init([](const FCNBase &fcn, const MnUserParameters &par, unsigned int stra) { + return MnMigrad(fcn, par, MnStrategy(stra)); + }), + "fcn"_a, "par"_a, "stra"_a = 1) + ; +} diff --git a/examples/2_02_binding/pybind11/pyminuit2/MnUserParameters.cpp b/examples/2_02_binding/pybind11/pyminuit2/MnUserParameters.cpp new file mode 100644 index 0000000..66c7bbd --- /dev/null +++ b/examples/2_02_binding/pybind11/pyminuit2/MnUserParameters.cpp @@ -0,0 +1,14 @@ +#include + +#include + +namespace py = pybind11; +using namespace ROOT::Minuit2; + +void init_MnUserParameters(py::module &m) { + py::class_(m, "MnUserParameters") + .def(py::init<>()) + .def("Add", py::overload_cast(&MnUserParameters::Add)) + .def("Add", py::overload_cast(&MnUserParameters::Add)) + ; +} diff --git a/examples/2_02_binding/pybind11/pyminuit2/pyminuit2.cpp b/examples/2_02_binding/pybind11/pyminuit2/pyminuit2.cpp new file mode 100644 index 0000000..80b2dcd --- /dev/null +++ b/examples/2_02_binding/pybind11/pyminuit2/pyminuit2.cpp @@ -0,0 +1,15 @@ +#include + +namespace py = pybind11; + +void init_FCNBase(py::module &); +void init_MnUserParameters(py::module &); +void init_MnMigrad(py::module &); +void init_FunctionMinimum(py::module &); + +PYBIND11_MODULE(minuit2, m) { + init_FCNBase(m); + init_MnUserParameters(m); + init_MnMigrad(m); + init_FunctionMinimum(m); +} diff --git a/examples/2_02_binding/pybind11/pyproject.toml b/examples/2_02_binding/pybind11/pyproject.toml new file mode 100644 index 0000000..5b4505b --- /dev/null +++ b/examples/2_02_binding/pybind11/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["scikit-build-core>=1", "pybind11>=3"] +build-backend = "scikit_build_core.build" + +[project] +name = "pyminuit2" +version = "0.0.1" + +# Rebuild the editable install when the build config or C++ sources change +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "CMakeLists.txt" }, + { file = "pyminuit2/**/*.cpp" }, + { file = "pyminuit2/**/*.hpp" }, +] diff --git a/examples/2_02_binding/pybind11/sample.py b/examples/2_02_binding/pybind11/sample.py new file mode 100644 index 0000000..3eb7e1e --- /dev/null +++ b/examples/2_02_binding/pybind11/sample.py @@ -0,0 +1,18 @@ +import minuit2 + + +class SimpleFCN(minuit2.FCNBase): + def Up(self): + return 0.5 + + def __call__(self, v): + print("val =", v[0]) + return v[0] ** 2 + + +fcn = SimpleFCN() +upar = minuit2.MnUserParameters() +upar.Add("x", 1.0, 0.1) +migrad = minuit2.MnMigrad(fcn, upar) +minimum = migrad() +print(minimum) diff --git a/examples/2_02_binding/pybind11/uv.lock b/examples/2_02_binding/pybind11/uv.lock new file mode 100644 index 0000000..022f5a9 --- /dev/null +++ b/examples/2_02_binding/pybind11/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "pyminuit2" +version = "0.0.1" +source = { editable = "." } diff --git a/examples/2_02_binding/simpleclass/CMakeLists.txt b/examples/2_02_binding/simpleclass/CMakeLists.txt new file mode 100644 index 0000000..d47578b --- /dev/null +++ b/examples/2_02_binding/simpleclass/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.26...4.4) +project(simpleclass LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to use") +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(pybind11) + +pybind11_add_module(simpleclass simpleclass.cpp) +install(TARGETS simpleclass DESTINATION .) diff --git a/examples/2_02_binding/simpleclass/SimpleClass.hpp b/examples/2_02_binding/simpleclass/SimpleClass.hpp new file mode 100644 index 0000000..9bd4611 --- /dev/null +++ b/examples/2_02_binding/simpleclass/SimpleClass.hpp @@ -0,0 +1,10 @@ +#pragma once + +class Simple { + int x; + + public: + Simple(int x) : x(x) {} + + int get() const { return x; } +}; diff --git a/examples/2_02_binding/simpleclass/pyproject.toml b/examples/2_02_binding/simpleclass/pyproject.toml new file mode 100644 index 0000000..8415a9a --- /dev/null +++ b/examples/2_02_binding/simpleclass/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["scikit-build-core>=1", "pybind11>=3"] +build-backend = "scikit_build_core.build" + +[project] +name = "simpleclass" +version = "0.0.1" + +# Rebuild the editable install when the build config or C++ sources change +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "CMakeLists.txt" }, + { file = "*.cpp" }, + { file = "*.hpp" }, +] diff --git a/examples/2_02_binding/simpleclass/sample.py b/examples/2_02_binding/simpleclass/sample.py new file mode 100644 index 0000000..b0e2f40 --- /dev/null +++ b/examples/2_02_binding/simpleclass/sample.py @@ -0,0 +1,4 @@ +import simpleclass + +x = simpleclass.Simple(4) +print(x.get()) diff --git a/examples/2_02_binding/simpleclass/simpleclass.cpp b/examples/2_02_binding/simpleclass/simpleclass.cpp new file mode 100644 index 0000000..cf669b7 --- /dev/null +++ b/examples/2_02_binding/simpleclass/simpleclass.cpp @@ -0,0 +1,11 @@ +#include + +#include "SimpleClass.hpp" + +namespace py = pybind11; + +PYBIND11_MODULE(simpleclass, m) { + py::class_(m, "Simple") + .def(py::init()) + .def("get", &Simple::get); +} diff --git a/examples/2_02_binding/simpleclass/uv.lock b/examples/2_02_binding/simpleclass/uv.lock new file mode 100644 index 0000000..6342a86 --- /dev/null +++ b/examples/2_02_binding/simpleclass/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "simpleclass" +version = "0.0.1" +source = { editable = "." } diff --git a/examples/2_02_binding/vectorclass/CMakeLists.txt b/examples/2_02_binding/vectorclass/CMakeLists.txt new file mode 100644 index 0000000..6e78928 --- /dev/null +++ b/examples/2_02_binding/vectorclass/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.26...4.4) +project(vectorclass LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to use") +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(pybind11) + +pybind11_add_module(vectorclass vectorclass.cpp) +install(TARGETS vectorclass DESTINATION .) diff --git a/examples/2_02_binding/vectorclass/VectorClass.hpp b/examples/2_02_binding/vectorclass/VectorClass.hpp new file mode 100644 index 0000000..baa008d --- /dev/null +++ b/examples/2_02_binding/vectorclass/VectorClass.hpp @@ -0,0 +1,25 @@ +#pragma once + +class Vector2D { + double x; + double y; + + public: + Vector2D(double x, double y) : x(x), y(y) {} + + float get_x() const { return x; } + float get_y() const { return y; } + + void set_x(float val) { x = val; } + void set_y(float val) { y = val; } + + Vector2D &operator+=(const Vector2D &other) { + x += other.x; + y += other.y; + return *this; + } + + Vector2D operator+(const Vector2D &other) const { + return Vector2D(x + other.x, y + other.y); + } +}; diff --git a/examples/2_02_binding/vectorclass/pyproject.toml b/examples/2_02_binding/vectorclass/pyproject.toml new file mode 100644 index 0000000..da02b7f --- /dev/null +++ b/examples/2_02_binding/vectorclass/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["scikit-build-core>=1", "pybind11>=3"] +build-backend = "scikit_build_core.build" + +[project] +name = "vectorclass" +version = "0.0.1" + +# Rebuild the editable install when the build config or C++ sources change +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "CMakeLists.txt" }, + { file = "*.cpp" }, + { file = "*.hpp" }, +] diff --git a/examples/2_02_binding/vectorclass/sample.py b/examples/2_02_binding/vectorclass/sample.py new file mode 100644 index 0000000..2d7d7a3 --- /dev/null +++ b/examples/2_02_binding/vectorclass/sample.py @@ -0,0 +1,7 @@ +import vectorclass + +v = vectorclass.Vector2D(1, 2) +print(f"{v.x = }, {v.y = }") +print(v) +print(v + v) +print(vectorclass.Vector2D(x=2, y=4)) diff --git a/examples/2_02_binding/vectorclass/uv.lock b/examples/2_02_binding/vectorclass/uv.lock new file mode 100644 index 0000000..ac811a8 --- /dev/null +++ b/examples/2_02_binding/vectorclass/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "vectorclass" +version = "0.0.1" +source = { editable = "." } diff --git a/examples/2_02_binding/vectorclass/vectorclass.cpp b/examples/2_02_binding/vectorclass/vectorclass.cpp new file mode 100644 index 0000000..8e8062f --- /dev/null +++ b/examples/2_02_binding/vectorclass/vectorclass.cpp @@ -0,0 +1,19 @@ +#include +#include + +#include "VectorClass.hpp" + +namespace py = pybind11; +using namespace pybind11::literals; + +PYBIND11_MODULE(vectorclass, m) { + py::class_(m, "Vector2D") + .def(py::init(), "x"_a, "y"_a) + .def_property("x", &Vector2D::get_x, &Vector2D::set_x) + .def_property("y", &Vector2D::get_y, &Vector2D::set_y) + .def(py::self += py::self) + .def(py::self + py::self) + .def("__repr__", [](py::object self) { + return py::str("{0.__class__.__name__}({0.x}, {0.y})").format(self); + }); +} diff --git a/examples/2_04_rust_pyo3/.gitignore b/examples/2_04_rust_pyo3/.gitignore new file mode 100644 index 0000000..f36238f --- /dev/null +++ b/examples/2_04_rust_pyo3/.gitignore @@ -0,0 +1,3 @@ +/target +__pycache__/ +.pytest_cache/ diff --git a/examples/2_04_rust_pyo3/Cargo.lock b/examples/2_04_rust_pyo3/Cargo.lock new file mode 100644 index 0000000..b91ef3f --- /dev/null +++ b/examples/2_04_rust_pyo3/Cargo.lock @@ -0,0 +1,132 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "pyo3" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pyo3_example" +version = "0.1.0" +dependencies = [ + "pyo3", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +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" diff --git a/examples/2_04_rust_pyo3/Cargo.toml b/examples/2_04_rust_pyo3/Cargo.toml new file mode 100644 index 0000000..4a794c0 --- /dev/null +++ b/examples/2_04_rust_pyo3/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pyo3_example" +version = "0.1.0" +edition = "2024" + +[lib] +name = "pyo3_example" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = "0.29.0" diff --git a/examples/2_04_rust_pyo3/README.md b/examples/2_04_rust_pyo3/README.md new file mode 100644 index 0000000..c2e2022 --- /dev/null +++ b/examples/2_04_rust_pyo3/README.md @@ -0,0 +1,24 @@ +# PyO3 example: a Rust extension module built with pixi + +A minimal Rust extension module for Python, built with [PyO3](https://pyo3.rs) and [maturin](https://www.maturin.rs). Every tool it needs - the Rust compiler, maturin, Python, and pytest - is installed from conda-forge by [pixi](https://pixi.sh), so no rustup or system Rust is required. The module (`src/lib.rs`) exposes a function that releases the GIL (`count_primes`), a class (`Point`), and a function that raises a Python exception (`checked_div`); `bench.py` times the Rust `count_primes` against the identical trial-division algorithm in pure Python. + +## Commands + +```sh +pixi install # solve and install the toolchain from conda-forge +pixi run test # build the extension (debug) and run the pytest suite +pixi run bench # build the extension (release) and run the benchmark +``` + +## Benchmark + +Output of `pixi run bench` on an AMD Ryzen AI 9 HX 370 (Linux, Python 3.14, rustc 1.97): + +```text +count_primes(1_000_000), best of 3 runs: + pure Python: 1.601 s + Rust (PyO3): 0.084 s + speedup: 19x +``` + +**Benchmark release builds only.** `maturin develop` produces an unoptimized debug build - often many times slower than `--release`, sometimes slow enough to lose to pure Python. The `bench` task therefore depends on `develop-release` (`maturin develop --release`), so it always measures the optimized build. diff --git a/examples/2_04_rust_pyo3/bench.py b/examples/2_04_rust_pyo3/bench.py new file mode 100644 index 0000000..cfba37e --- /dev/null +++ b/examples/2_04_rust_pyo3/bench.py @@ -0,0 +1,36 @@ +"""Benchmark pyo3_example.count_primes against the same algorithm in pure Python.""" + +import timeit + +import pyo3_example + +LIMIT = 1_000_000 + + +def count_primes(limit): + """Count primes below `limit` by trial division (same algorithm as src/lib.rs).""" + count = 0 + for n in range(2, limit): + is_prime = True + d = 2 + while d * d <= n: + if n % d == 0: + is_prime = False + break + d += 1 + if is_prime: + count += 1 + return count + + +if __name__ == "__main__": + assert count_primes(10_000) == pyo3_example.count_primes(10_000) + + print(f"count_primes({LIMIT:_}), best of 3 runs:") + python_seconds = min(timeit.repeat(lambda: count_primes(LIMIT), number=1, repeat=3)) + rust_seconds = min( + timeit.repeat(lambda: pyo3_example.count_primes(LIMIT), number=1, repeat=3) + ) + print(f" pure Python: {python_seconds:.3f} s") + print(f" Rust (PyO3): {rust_seconds:.3f} s") + print(f" speedup: {python_seconds / rust_seconds:.0f}x") diff --git a/examples/2_04_rust_pyo3/pixi.lock b/examples/2_04_rust_pyo3/pixi.lock new file mode 100644 index 0000000..22d2b3b --- /dev/null +++ b/examples/2_04_rust_pyo3/pixi.lock @@ -0,0 +1,829 @@ +version: 7 +platforms: +- name: linux-64 +- name: osx-arm64 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-15.2.0-ha6850e4_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-15.2.0-h90f66d4_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/maturin-1.14.1-py310h2b5ca13_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.97.0-h53717f1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-15.2.0-hcc6f6b0_119.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-15.2.0-hd446a21_119.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.97.0-h2c6d0dc_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.97.0-hf6ec828_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.3-h1b79a29_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/maturin-1.14.1-py310hc7c2786_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.97.0-h4ff7c5d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.45.1-default_hfdba357_102.conda + sha256: 0a7d405064f53b9d91d92515f1460f7906ee5e8523f3cd8973430e81219f4917 + md5: 8165352fdce2d2025bf884dc0ee85700 + depends: + - ld_impl_linux-64 2.45.1 default_hbd61a6d_102 + - sysroot_linux-64 + - zstd >=1.5.7,<1.6.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 3661455 + timestamp: 1774197460085 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 + md5: d2ffd7602c02f2b316fd921d39876885 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 260182 + timestamp: 1771350215188 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-15.2.0-ha6850e4_19.conda + sha256: 2c0b2870210cfc7e8561676844f4bd3884b345c19100e1da84cbfb4994d8f104 + md5: 1c2eddf7501ef92050d3fbb11df61ee3 + depends: + - binutils_impl_linux-64 >=2.45 + - libgcc >=15.2.0 + - libgcc-devel_linux-64 15.2.0 hcc6f6b0_119 + - libgomp >=15.2.0 + - libsanitizer 15.2.0 h90f66d4_19 + - libstdcxx >=15.2.0 + - libstdcxx-devel_linux-64 15.2.0 hd446a21_119 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 81043749 + timestamp: 1778860073982 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c + md5: 18335a698559cdbcd86150a48bf54ba6 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 728002 + timestamp: 1774197446916 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 77856 + timestamp: 1781203599810 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + sha256: 8e0a3b5e41272e5678499b5dfc4cddb673f9e935de01eb0767ce857001229f46 + md5: 57736f29cc2b0ec0b6c2952d3f101b6a + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_19 + - libgomp 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 1041084 + timestamp: 1778269013026 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + sha256: 5abe4ab9d93f6c9757d654f1969ae2267d4505315c1f2f8fe705fd60af084f1b + md5: faac990cb7aedc7f3a2224f2c9b0c26c + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 603817 + timestamp: 1778268942614 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d + md5: b88d90cad08e6bc8ad540cb310a761fb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 113478 + timestamp: 1775825492909 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 92400 + timestamp: 1769482286018 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-15.2.0-h90f66d4_19.conda + sha256: 7a58892a52739ce4c0f7109de9e91b4353104748eb04fc6441d88e8af444ba99 + md5: 67eef12ce33f7ff99900c212d7076fc2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=15.2.0 + - libstdcxx >=15.2.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + weak: + - libsanitizer 15.2.0 + size: 7930689 + timestamp: 1778269054623 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda + sha256: 365376f4815e5e80def2b3462a2419708b7c292da0da85278386c2618621fff4 + md5: 4aed8e657e9ff156bdbe849b4df44389 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.3,<4.0a0 + size: 962119 + timestamp: 1782519076616 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + sha256: dff1058c76ec6b8759e41cefa2508162d00e4a5e6721aa68ec3fd10094e702dc + md5: 5794b3bdc38177caf969dabd3af08549 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_19 + constrains: + - libstdcxx-ng ==15.2.0=*_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 5852044 + timestamp: 1778269036376 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f + md5: 01bb81d12c957de066ea7362007df642 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libuuid >=2.42.2,<3.0a0 + size: 40017 + timestamp: 1781625522462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 + md5: d87ff7921124eccd67248aa483c23fec + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 63629 + timestamp: 1774072609062 +- conda: https://conda.anaconda.org/conda-forge/linux-64/maturin-1.14.1-py310h2b5ca13_0.conda + noarch: python + sha256: 33604a07d4d10f440fcc1e38d7d33462b3726565c1ad704ccfa189c7934912c6 + md5: 532b0a5340d4431bea91efe4792ceb13 + depends: + - python + - tomli >=1.1.0 + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - openssl >=3.5.7,<4.0a0 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + run_exports: {} + size: 9808111 + timestamp: 1781871513283 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 + md5: fc21868a1a5aacc937e7a18747acb8a5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 918956 + timestamp: 1777422145199 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + sha256: d48f5c22b9897c01e4dff3680f1f57ceb02711ab9c62f74339b080419dfad34b + md5: 79dd2074b5cd5c5c6b2930514a11e22d + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3159683 + timestamp: 1781069855778 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_100_cp314.conda + build_number: 100 + sha256: 6d28ac2b061179deb434d3d57afa98ffd20ec3c5d44ab8048a1ca33424b22d38 + md5: 0b9b2f83b5b600e1ac38becde8d0dd44 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libuuid >=2.42.1,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 36717183 + timestamp: 1781255094700 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.97.0-h53717f1_0.conda + sha256: 2ffb498d537f5d555956d27003f8d922504c295e260c9e5360caf1344771ba51 + md5: 3719d40bc183aafc8cfc539f0f9f8cdf + depends: + - __glibc >=2.17,<3.0.a0 + - gcc_impl_linux-64 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + - rust-std-x86_64-unknown-linux-gnu 1.97.0 h2c6d0dc_0 + - sysroot_linux-64 >=2.17 + license: MIT + run_exports: + strong_constrains: + - __glibc >=2.17 + size: 173622696 + timestamp: 1783711533417 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3301196 + timestamp: 1769460227866 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + sha256: f8e3c730fa14ee3f170493779f06522c4acf89169f43db4f039727709b6419cf + md5: a9965dd99f683c5f444428f896635716 + depends: + - __unix + license: ISC + run_exports: {} + size: 128866 + timestamp: 1781708962055 +- conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 + md5: 962b9857ee8e7018c22f2776ffa0b2d7 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 27011 + timestamp: 1733218222191 +- conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + sha256: ee6cf346d017d954255bbcbdb424cddea4d14e4ed7e9813e429db1d795d01144 + md5: 8e662bd460bda79b1ea39194e3c4c9ab + depends: + - python >=3.10 + - typing_extensions >=4.6.0 + license: MIT and PSF-2.0 + run_exports: {} + size: 21333 + timestamp: 1763918099466 +- conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + sha256: e1a9e3b1c8fe62dc3932a616c284b5d8cbe3124bbfbedcf4ce5c828cb166ee19 + md5: 9614359868482abba1bd15ce465e3c42 + depends: + - python >=3.10 + license: MIT + license_family: MIT + run_exports: {} + size: 13387 + timestamp: 1760831448842 +- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + sha256: 41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a + md5: 86d9cba083cd041bfbf242a01a7a1999 + constrains: + - sysroot_linux-64 ==2.28 + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 1278712 + timestamp: 1765578681495 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-15.2.0-hcc6f6b0_119.conda + sha256: 38a557eba305468ac1f90ac85e50d8defd76141cb0b8a43b2fc1aca71dd5d5f2 + md5: 683fcb168e1df9a21fa80d5aa2d9330b + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 3095909 + timestamp: 1778268932148 +- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-15.2.0-hd446a21_119.conda + sha256: a2385f3611d5cd25378f9cf2367183320731709c067ddd08d43330d3170f15b8 + md5: bcfe7eae40158c3e355d2f9d3ed41230 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 20765069 + timestamp: 1778268963689 +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + sha256: 3906abfb6511a3bb309e39b9b1b7bc38f50a723971de2395489fd1f379255890 + md5: 4c06a92e74452cfa53623a81592e8934 + depends: + - python >=3.8 + - python + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 91574 + timestamp: 1777103621679 +- conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e + md5: d7585b6550ad04c8c5e21097ada2888e + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 25877 + timestamp: 1764896838868 +- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + sha256: cf70b2f5ad9ae472b71235e5c8a736c9316df3705746de419b59d442e8348e86 + md5: 16c18772b340887160c79a6acc022db0 + depends: + - python >=3.10 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 893031 + timestamp: 1774796815820 +- conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda + sha256: 430051d80765207a7d782b2b188230ba1489d35c6e75fd9903f76cb9fda4af16 + md5: 64c98a12c4e23eb238bf66bbecafdf3c + depends: + - colorama + - pygments >=2.7.2 + - python >=3.10 + - iniconfig >=1.0.1 + - packaging >=22 + - pluggy >=1.5,<2 + - tomli >=1 + - exceptiongroup >=1 + - python + constrains: + - pytest-faulthandler >=2 + license: MIT + license_family: MIT + run_exports: {} + size: 306724 + timestamp: 1782127176429 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + build_number: 8 + sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 + md5: 0539938c55b6b1a59b560e843ad864a4 + constrains: + - python 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6989 + timestamp: 1752805904792 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.97.0-hf6ec828_0.conda + sha256: 0b27009b2d02aeda8ea4b778a32cc004742f35d100ed2d1fb160a6c88d25faf9 + md5: 02930754aeb66acf30f7594c5e587fb8 + depends: + - __unix + constrains: + - rust >=1.97.0,<1.97.1.0a0 + license: MIT + run_exports: {} + size: 34693143 + timestamp: 1783710384433 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.97.0-h2c6d0dc_0.conda + sha256: 9b7d75e7466f5651cb78c871b85318368769761657cb29c98c2d22aa578764b7 + md5: 1b62b82641a649fa440d492fa083a069 + depends: + - __unix + constrains: + - rust >=1.97.0,<1.97.1.0a0 + license: MIT + run_exports: {} + size: 36746381 + timestamp: 1783711449395 +- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + sha256: c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b851 + md5: 13dc3adbc692664cd3beabd216434749 + depends: + - __glibc >=2.28 + - kernel-headers_linux-64 4.18.0 he073ed8_9 + - tzdata + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: + strong: + - __glibc >=2.28,<3.0.a0 + size: 24008591 + timestamp: 1765578833462 +- conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + sha256: 91cafdb64268e43e0e10d30bd1bef5af392e69f00edd34dfaf909f69ab2da6bd + md5: b5325cf06a000c5b14970462ff5e4d58 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 21561 + timestamp: 1774492402955 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + sha256: 2d888f90af0686044882c74193ec80a90ec1943145d94a7b1b048958acda1848 + md5: c70ad746c22219b9700931707482992c + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 52631 + timestamp: 1783002732887 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + run_exports: {} + size: 119135 + timestamp: 1767016325805 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + sha256: 540fe54be35fac0c17feefbdc3e29725cce05d7367ffedfaaa1bdda234b019df + md5: 620b85a3f45526a8bc4d23fd78fc22f0 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 124834 + timestamp: 1771350416561 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f + md5: a915151d5d3c5bf039f5ccc8402a436f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 69362 + timestamp: 1781203631990 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 + md5: 43c04d9cb46ef176bb2a4c77e324d599 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 40979 + timestamp: 1769456747661 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + sha256: 34878d87275c298f1a732c6806349125cebbf340d24c6c23727268184bba051e + md5: b1fd823b5ae54fbec272cea0811bd8a9 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 92472 + timestamp: 1775825802659 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 + md5: 57c4be259f5e0b99a5983799a228ae55 + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 73690 + timestamp: 1769482560514 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.3-h1b79a29_0.conda + sha256: a73a8acd97a6599fd6e561514db9f101ca7fd984cdc0cfd91ba74c8aa9dbe067 + md5: 7184d95871a58b8258a8ea124ed5aabc + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.3,<4.0a0 + size: 924912 + timestamp: 1782519136322 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + sha256: 361415a698514b19a852f5d1123c5da746d4642139904156ddfca7c922d23a05 + md5: bc5a5721b6439f2f62a84f2548136082 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 47759 + timestamp: 1774072956767 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/maturin-1.14.1-py310hc7c2786_0.conda + noarch: python + sha256: e7ad10951487ba06dda703dc58da2beea3678b89bad4a06b396323de600ba8f8 + md5: bbd550abd21eb0919cb212d519ef022d + depends: + - python + - tomli >=1.1.0 + - __osx >=11.0 + - openssl >=3.5.7,<4.0a0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: {} + size: 8813293 + timestamp: 1781871768111 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + sha256: 4ea6c620b87bd1d42bb2ccc2c87cd2483fa2d7f9e905b14c223f11ff3f4c455d + md5: 343d10ed5b44030a2f67193905aea159 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 805509 + timestamp: 1777423252320 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + sha256: b3e3ca895c336d4eb91c5d2f244a312bdb59a0de8cfa0cc4c179225ab2f6bbfb + md5: 8187a86242741725bfa74785fe812979 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3102584 + timestamp: 1781069820667 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_100_cp314.conda + build_number: 100 + sha256: 984081c9fae3a3944c6f2707bbbbc70e8b961f02cdb7c640d9745e2636235632 + md5: 4841be3d0cf616a860efc6e60af66f8b + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 14059371 + timestamp: 1781254578985 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 + depends: + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 313930 + timestamp: 1765813902568 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.97.0-h4ff7c5d_0.conda + sha256: 478e9108b61721a52c34617a714b297afc63b2bd5ee3899d1e78c6895123c1d9 + md5: f273a7c331f87847178f453f217eea12 + depends: + - rust-std-aarch64-apple-darwin 1.97.0 hf6ec828_0 + license: MIT + run_exports: {} + size: 179646209 + timestamp: 1783710468658 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 + md5: a9d86bc62f39b94c4661716624eb21b0 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3127137 + timestamp: 1769460817696 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 + md5: ab136e4c34e97f34fb621d2592a393d8 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 433413 + timestamp: 1764777166076 diff --git a/examples/2_04_rust_pyo3/pixi.toml b/examples/2_04_rust_pyo3/pixi.toml new file mode 100644 index 0000000..942a95f --- /dev/null +++ b/examples/2_04_rust_pyo3/pixi.toml @@ -0,0 +1,29 @@ +[workspace] +channels = ["conda-forge"] +name = "pyo3-example" +platforms = ["linux-64", "osx-arm64"] +version = "0.1.0" + +[tasks.develop] +description = "Build the Rust extension and install it into the pixi environment" +cmd = "maturin develop" + +[tasks.develop-release] +description = "Build the optimized Rust extension and install it" +cmd = "maturin develop --release" + +[tasks.test] +description = "Run the pytest suite" +cmd = "pytest -v" +depends-on = ["develop"] + +[tasks.bench] +description = "Benchmark the Rust extension against pure Python" +cmd = "python bench.py" +depends-on = ["develop-release"] + +[dependencies] +rust = ">=1.85" +maturin = ">=1.9" +python = ">=3.12" +pytest = ">=8" diff --git a/examples/2_04_rust_pyo3/pyproject.toml b/examples/2_04_rust_pyo3/pyproject.toml new file mode 100644 index 0000000..fa67449 --- /dev/null +++ b/examples/2_04_rust_pyo3/pyproject.toml @@ -0,0 +1,11 @@ +[build-system] +requires = ["maturin>=1.14,<2.0"] +build-backend = "maturin" + +[project] +name = "pyo3_example" +version = "0.1.0" +requires-python = ">=3.9" + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/examples/2_04_rust_pyo3/src/lib.rs b/examples/2_04_rust_pyo3/src/lib.rs new file mode 100644 index 0000000..4837643 --- /dev/null +++ b/examples/2_04_rust_pyo3/src/lib.rs @@ -0,0 +1,75 @@ +use pyo3::prelude::*; + +/// A Python module implemented in Rust. +#[pymodule] +mod pyo3_example { + use pyo3::prelude::*; + + /// Formats the sum of two numbers as string. + #[pyfunction] + fn sum_as_string(a: usize, b: usize) -> PyResult { + Ok((a + b).to_string()) + } + + /// Counts primes below `limit` by trial division. + #[pyfunction] + fn count_primes(py: Python<'_>, limit: u64) -> u64 { + // Release the GIL so other Python threads can run during the hot loop. + py.detach(|| { + let mut count = 0; + for n in 2..limit { + let mut is_prime = true; + let mut d = 2; + while d * d <= n { + if n % d == 0 { + is_prime = false; + break; + } + d += 1; + } + if is_prime { + count += 1; + } + } + count + }) + } + + /// A 2D point, exposed to Python as a class. + #[pyclass] + struct Point { + #[pyo3(get)] + x: f64, + #[pyo3(get)] + y: f64, + } + + #[pymethods] + impl Point { + #[new] + fn new(x: f64, y: f64) -> Self { + Point { x, y } + } + + /// Distance from the origin. + fn magnitude(&self) -> f64 { + (self.x * self.x + self.y * self.y).sqrt() + } + + fn __repr__(&self) -> String { + // {:?} keeps the decimal point on whole floats: 1.0, not 1. + format!("Point(x={:?}, y={:?})", self.x, self.y) + } + } + + /// Divides `a` by `b`, raising ZeroDivisionError like Python's `/`. + #[pyfunction] + fn checked_div(a: f64, b: f64) -> PyResult { + use pyo3::exceptions::PyZeroDivisionError; + + if b == 0.0 { + return Err(PyZeroDivisionError::new_err("division by zero")); + } + Ok(a / b) + } +} diff --git a/examples/2_04_rust_pyo3/tests/test_pyo3_example.py b/examples/2_04_rust_pyo3/tests/test_pyo3_example.py new file mode 100644 index 0000000..9ed0534 --- /dev/null +++ b/examples/2_04_rust_pyo3/tests/test_pyo3_example.py @@ -0,0 +1,44 @@ +"""Tests for the pyo3_example extension module built with PyO3 and maturin.""" + +import pytest + +import pyo3_example + + +def test_sum_as_string(): + assert pyo3_example.sum_as_string(2, 40) == "42" + + +def test_count_primes(): + assert pyo3_example.count_primes(10) == 4 + assert pyo3_example.count_primes(100) == 25 + + +def test_count_primes_edge_cases(): + assert pyo3_example.count_primes(0) == 0 + assert pyo3_example.count_primes(2) == 0 + + +def test_point_construction_and_attributes(): + point = pyo3_example.Point(1.0, 2.0) + assert point.x == 1.0 + assert point.y == 2.0 + + +def test_point_magnitude(): + point = pyo3_example.Point(3.0, 4.0) + assert point.magnitude() == 5.0 + + +def test_point_repr(): + point = pyo3_example.Point(1.0, 2.0) + assert repr(point) == "Point(x=1.0, y=2.0)" + + +def test_checked_div(): + assert pyo3_example.checked_div(1.0, 2.0) == 0.5 + + +def test_checked_div_by_zero(): + with pytest.raises(ZeroDivisionError): + pyo3_example.checked_div(1.0, 0.0) diff --git a/examples/6_01_free_threading/.gitignore b/examples/6_01_free_threading/.gitignore new file mode 100644 index 0000000..5d24ff1 --- /dev/null +++ b/examples/6_01_free_threading/.gitignore @@ -0,0 +1,220 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/examples/6_01_free_threading/nanobind/CMakeLists.txt b/examples/6_01_free_threading/nanobind/CMakeLists.txt new file mode 100644 index 0000000..eaa05e1 --- /dev/null +++ b/examples/6_01_free_threading/nanobind/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.26...4.4) +project(freecomputepi LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to use") +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(Python 3.14 REQUIRED COMPONENTS Interpreter Development.Module) +find_package(nanobind CONFIG REQUIRED) + +nanobind_add_module(_core FREE_THREADED freecomputepi/_core.cpp) +install(TARGETS _core DESTINATION freecomputepi) diff --git a/examples/6_01_free_threading/nanobind/freecomputepi/__init__.py b/examples/6_01_free_threading/nanobind/freecomputepi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/6_01_free_threading/nanobind/freecomputepi/_core.cpp b/examples/6_01_free_threading/nanobind/freecomputepi/_core.cpp new file mode 100644 index 0000000..08c7638 --- /dev/null +++ b/examples/6_01_free_threading/nanobind/freecomputepi/_core.cpp @@ -0,0 +1,27 @@ +#include + +#include + +namespace nb = nanobind; + +// Monte Carlo estimate of pi. The loop touches no Python objects, so nothing is +// shared between threads -- it scales cleanly once the GIL is out of the way. +double pi(int trials) { + std::random_device rd; + std::default_random_engine engine(rd()); + std::uniform_real_distribution dist(-1, 1); + + int inside = 0; + for (int i = 0; i < trials; ++i) { + double x = dist(engine); + double y = dist(engine); + if (x * x + y * y <= 1.0) { + ++inside; + } + } + return 4.0 * inside / trials; +} + +NB_MODULE(_core, m) { + m.def("pi", &pi, "Estimate pi with a Monte Carlo dart throw"); +} diff --git a/examples/6_01_free_threading/nanobind/freecomputepi/pi.py b/examples/6_01_free_threading/nanobind/freecomputepi/pi.py new file mode 100644 index 0000000..9fd00b0 --- /dev/null +++ b/examples/6_01_free_threading/nanobind/freecomputepi/pi.py @@ -0,0 +1,12 @@ +import statistics +from concurrent.futures import ThreadPoolExecutor + +from ._core import pi + + +def pi_in_threads(threads: int, trials: int) -> float: + if threads == 0: + return pi(trials) + chunks = [trials // threads] * threads + with ThreadPoolExecutor(max_workers=threads) as executor: + return statistics.mean(executor.map(pi, chunks)) diff --git a/examples/6_01_free_threading/nanobind/pyproject.toml b/examples/6_01_free_threading/nanobind/pyproject.toml new file mode 100644 index 0000000..df38d9b --- /dev/null +++ b/examples/6_01_free_threading/nanobind/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["scikit-build-core>=1", "nanobind>=2"] +build-backend = "scikit_build_core.build" + +[project] +name = "freecomputepi" +version = "0.0.1" +requires-python = ">=3.14" + +[tool.cibuildwheel] +build = "cp314*" + +# Rebuild the editable install when the build config or C++ sources change +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "CMakeLists.txt" }, + { file = "freecomputepi/**/*.cpp" }, +] diff --git a/examples/6_01_free_threading/nanobind/sample.py b/examples/6_01_free_threading/nanobind/sample.py new file mode 100644 index 0000000..99d5146 --- /dev/null +++ b/examples/6_01_free_threading/nanobind/sample.py @@ -0,0 +1,15 @@ +import sys +import time + +from freecomputepi.pi import pi_in_threads + +TRIALS = 20_000_000 + +gil = sys._is_gil_enabled() +print(f"Python {sys.version.split()[0]}, GIL {'enabled' if gil else 'disabled'}") + +for threads in [1, 2, 4, 8]: + start = time.monotonic() + result = pi_in_threads(threads, TRIALS) + elapsed = time.monotonic() - start + print(f"{threads:>2} threads: pi = {result:.5f} ({elapsed:.2f} s)") diff --git a/examples/6_01_free_threading/nanobind/uv.lock b/examples/6_01_free_threading/nanobind/uv.lock new file mode 100644 index 0000000..247a1e2 --- /dev/null +++ b/examples/6_01_free_threading/nanobind/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "freecomputepi" +version = "0.0.1" +source = { editable = "." } diff --git a/examples/6_01_free_threading/pure/freecomputepi/__init__.py b/examples/6_01_free_threading/pure/freecomputepi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/6_01_free_threading/pure/freecomputepi/pi.py b/examples/6_01_free_threading/pure/freecomputepi/pi.py new file mode 100644 index 0000000..0335b5c --- /dev/null +++ b/examples/6_01_free_threading/pure/freecomputepi/pi.py @@ -0,0 +1,22 @@ +import random +import statistics +from concurrent.futures import ThreadPoolExecutor + + +def pi(trials: int) -> float: + ran = random.Random() + inside = 0 + for _ in range(trials): + x = ran.uniform(-1, 1) + y = ran.uniform(-1, 1) + if x * x + y * y <= 1: + inside += 1 + return 4.0 * inside / trials + + +def pi_in_threads(threads: int, trials: int) -> float: + if threads == 0: + return pi(trials) + chunks = [trials // threads] * threads + with ThreadPoolExecutor(max_workers=threads) as executor: + return statistics.mean(executor.map(pi, chunks)) diff --git a/examples/6_01_free_threading/pure/pyproject.toml b/examples/6_01_free_threading/pure/pyproject.toml new file mode 100644 index 0000000..f903a4f --- /dev/null +++ b/examples/6_01_free_threading/pure/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "freecomputepi" +version = "0.0.1" +requires-python = ">=3.14" diff --git a/examples/6_01_free_threading/pure/sample.py b/examples/6_01_free_threading/pure/sample.py new file mode 100644 index 0000000..99d5146 --- /dev/null +++ b/examples/6_01_free_threading/pure/sample.py @@ -0,0 +1,15 @@ +import sys +import time + +from freecomputepi.pi import pi_in_threads + +TRIALS = 20_000_000 + +gil = sys._is_gil_enabled() +print(f"Python {sys.version.split()[0]}, GIL {'enabled' if gil else 'disabled'}") + +for threads in [1, 2, 4, 8]: + start = time.monotonic() + result = pi_in_threads(threads, TRIALS) + elapsed = time.monotonic() - start + print(f"{threads:>2} threads: pi = {result:.5f} ({elapsed:.2f} s)") diff --git a/examples/6_01_free_threading/pure/uv.lock b/examples/6_01_free_threading/pure/uv.lock new file mode 100644 index 0000000..247a1e2 --- /dev/null +++ b/examples/6_01_free_threading/pure/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "freecomputepi" +version = "0.0.1" +source = { editable = "." } diff --git a/examples/6_01_free_threading/pybind11/CMakeLists.txt b/examples/6_01_free_threading/pybind11/CMakeLists.txt new file mode 100644 index 0000000..481fd5b --- /dev/null +++ b/examples/6_01_free_threading/pybind11/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.26...4.4) +project(freecomputepi LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to use") +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(pybind11 CONFIG REQUIRED) + +pybind11_add_module(_core freecomputepi/_core.cpp) +install(TARGETS _core DESTINATION freecomputepi) diff --git a/examples/6_01_free_threading/pybind11/freecomputepi/__init__.py b/examples/6_01_free_threading/pybind11/freecomputepi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/6_01_free_threading/pybind11/freecomputepi/_core.cpp b/examples/6_01_free_threading/pybind11/freecomputepi/_core.cpp new file mode 100644 index 0000000..de99587 --- /dev/null +++ b/examples/6_01_free_threading/pybind11/freecomputepi/_core.cpp @@ -0,0 +1,27 @@ +#include + +#include + +namespace py = pybind11; + +// Monte Carlo estimate of pi. The loop touches no Python objects, so nothing is +// shared between threads -- it scales cleanly once the GIL is out of the way. +double pi(int trials) { + std::random_device rd; + std::default_random_engine engine(rd()); + std::uniform_real_distribution dist(-1, 1); + + int inside = 0; + for (int i = 0; i < trials; ++i) { + double x = dist(engine); + double y = dist(engine); + if (x * x + y * y <= 1.0) { + ++inside; + } + } + return 4.0 * inside / trials; +} + +PYBIND11_MODULE(_core, m, py::mod_gil_not_used()) { + m.def("pi", &pi, "Estimate pi with a Monte Carlo dart throw"); +} diff --git a/examples/6_01_free_threading/pybind11/freecomputepi/pi.py b/examples/6_01_free_threading/pybind11/freecomputepi/pi.py new file mode 100644 index 0000000..9fd00b0 --- /dev/null +++ b/examples/6_01_free_threading/pybind11/freecomputepi/pi.py @@ -0,0 +1,12 @@ +import statistics +from concurrent.futures import ThreadPoolExecutor + +from ._core import pi + + +def pi_in_threads(threads: int, trials: int) -> float: + if threads == 0: + return pi(trials) + chunks = [trials // threads] * threads + with ThreadPoolExecutor(max_workers=threads) as executor: + return statistics.mean(executor.map(pi, chunks)) diff --git a/examples/6_01_free_threading/pybind11/pyproject.toml b/examples/6_01_free_threading/pybind11/pyproject.toml new file mode 100644 index 0000000..608fa24 --- /dev/null +++ b/examples/6_01_free_threading/pybind11/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["scikit-build-core>=1", "pybind11>=3"] +build-backend = "scikit_build_core.build" + +[project] +name = "freecomputepi" +version = "0.0.1" +requires-python = ">=3.14" + +[tool.cibuildwheel] +build = "cp314*" + +# Rebuild the editable install when the build config or C++ sources change +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "CMakeLists.txt" }, + { file = "freecomputepi/**/*.cpp" }, +] diff --git a/examples/6_01_free_threading/pybind11/sample.py b/examples/6_01_free_threading/pybind11/sample.py new file mode 100644 index 0000000..99d5146 --- /dev/null +++ b/examples/6_01_free_threading/pybind11/sample.py @@ -0,0 +1,15 @@ +import sys +import time + +from freecomputepi.pi import pi_in_threads + +TRIALS = 20_000_000 + +gil = sys._is_gil_enabled() +print(f"Python {sys.version.split()[0]}, GIL {'enabled' if gil else 'disabled'}") + +for threads in [1, 2, 4, 8]: + start = time.monotonic() + result = pi_in_threads(threads, TRIALS) + elapsed = time.monotonic() - start + print(f"{threads:>2} threads: pi = {result:.5f} ({elapsed:.2f} s)") diff --git a/examples/6_01_free_threading/pybind11/uv.lock b/examples/6_01_free_threading/pybind11/uv.lock new file mode 100644 index 0000000..247a1e2 --- /dev/null +++ b/examples/6_01_free_threading/pybind11/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "freecomputepi" +version = "0.0.1" +source = { editable = "." } diff --git a/instructor-notes/research/maturin-workflows.md b/instructor-notes/research/maturin-workflows.md new file mode 100644 index 0000000..0589a9a --- /dev/null +++ b/instructor-notes/research/maturin-workflows.md @@ -0,0 +1,325 @@ +--- +type: research +title: maturin - Workflows and Distribution (July 2026) +created: 2026-07-10 +tags: + - rust + - maturin + - packaging + - python-bindings +related: + - '[[pyo3-current-state]]' + - '[[pixi-rust-toolchain]]' + - '[[rust-python-landscape]]' + - '[[rust-pyo3-maturin-deep-dive]]' +--- + +Research note for the Rust/PyO3/maturin section (`examples/2_04_rust_pyo3/`). +All claims were checked on 2026-07-10 against maturin.rs, PyPI, and the maturin +GitHub README, or verified locally by running **maturin 1.14.1** inside the Phase 01 +pixi environment; anything not re-verifiable today is marked "(unverified)". +maturin's self-description: "Build and publish crates with pyo3, cffi and uniffi +bindings as well as rust binaries as python packages" +(). + +## Version and command surface + +- **Latest release: 1.14.1** on PyPI - exactly the version conda-forge resolved into + the Phase 01 pixi env, so the workshop demos current maturin. + Sources: ; local `maturin --version`. +- Full command list from local `maturin --help` (1.14.1): `build`, `publish`, + `list-python`, `develop`, `sdist`, `init`, `new`, `generate-ci`, `upload`, + `generate-stubs`. `generate-stubs` (autogenerate `.pyi` type stubs) is a recent + addition worth a mention when attendees ask about typing. + +## The four workhorse commands and when each is used + +Verified against and local `--help` output: + +- **`maturin new `** - scaffold a fresh project (`maturin init` does the same in + an existing directory). Key flags: `-b pyo3` selects bindings (`pyo3`, `pyo3-ffi`, + `cffi`, `uniffi`, `bin`), `--mixed` adds a Python package directory alongside the + crate, `--src` uses a Python-first `src/` layout for mixed projects. Phase 01 used + the pure-Rust template. Ground-truth caveat: the maturin 1.14 template **no longer + emits `features = ["pyo3/extension-module"]`** under `[tool.maturin]` - Phase 01 + restored it by hand (see audit hooks below). +- **`maturin develop`** - compile and install straight into the active environment; + the inner-loop command. Builds **debug by default**; `--release` opts into optimized + builds. Useful flags seen in local `--help`: `--uv` ("Use `uv` to install packages + instead of `pip`"), `--skip-install` ("only build the extension module inplace… + Only works with mixed Rust/Python project layout"), `-E/--extras` for optional + dependencies, and `-G/--group` for PEP 735 dependency groups. The README notes the + trade-off: "while `maturin develop` is faster, it doesn't support all the features + that running `pip install` after `maturin build` supports." + Sources: , + . +- **`maturin build`** - build wheels into `target/wheels/` without installing or + uploading. `-i/--interpreter python3.x` selects interpreters ("The python versions + to build wheels for"), `-F/--features` forwards cargo features, `--sdist` also + builds a source distribution and "verifies that the source distribution is complete + and can be used to build the project from source" (local `--help`). +- **`maturin sdist`** - source distribution only, no compilation. An sdist ships the + crate sources plus `pyproject.toml`; installing from it requires a Rust toolchain on + the target machine, which is why real projects publish binary wheels and treat the + sdist as the fallback. Source: . + +`maturin publish` (build + upload) and `maturin upload` round out the release story; +both support classic API tokens (`MATURIN_PYPI_TOKEN`) and PyPI **trusted publishing** +via OpenID Connect in CI. Sources: , +. + +## `--release` semantics + +`--release` simply forwards to `cargo build --release` (optimized, no debug +assertions); without it, `maturin develop` produces a debug build and even keeps +debug symbols unless `--strip` is passed. `--profile` selects custom cargo profiles +for finer control. This is the mechanism behind the workshop's two pixi tasks +(`develop` vs `develop-release`) and the "debug builds lose to CPython" teaching +moment - though per the Phase 01 bench, state that warning generally, not for this +u64-division-bound example (debug 0.102 s vs release 0.085 s; see +[[rust-pyo3-maturin-deep-dive]]). +Source: ; local ground truth in +`Working/phase-01-ground-truth.md`. + +## Environment detection: virtualenvs, conda, and pixi + +- maturin discovers the target Python from **`VIRTUAL_ENV`** ("Specifies the path to + a Python virtual environment") or **`CONDA_PREFIX`** ("Indicates the path to a conda + environment"). Source: . +- **pixi needs no special support:** pixi environments are conda environments, and + `pixi run` sets `CONDA_PREFIX`. Observed in Phase 01: `maturin develop` printed + "🐍 Found CPython 3.14 at …/.pixi/envs/default/bin/python" and installed the wheel + into the pixi env - zero configuration. +- Running `maturin develop` with neither variable set fails with an error asking for + an activated virtualenv/conda env (unverified exact wording); `--pip-path` exists + for envs that don't ship their own pip. +- PEP 517 knobs worth knowing for later chapters: + `MATURIN_PEP517_ARGS` - "Extra arguments passed to `maturin` during PEP 517 builds + (e.g. `pip install .`)", with pip's `--config-settings` taking priority; + `MATURIN_PEP517_USE_BASE_PYTHON` to avoid unnecessary rebuilds across venvs; and + `MATURIN_NO_INSTALL_RUST`, which disables maturin's fallback of **auto-installing a + Rust toolchain (via puccinialin) when cargo is missing** during a PEP 517 build - + a surprising default worth mentioning when someone pip-installs an sdist without + Rust. Source: . + +## Editable installs (`pip install -e .`) and the book's editable chapter + +maturin is a full PEP 517/660 build backend: + +```toml +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" +``` + +- With that in place, `pip install -e .` produces an editable install using maturin + under the hood; `maturin develop` is the equivalent direct command. The guide's + motivation: "Editable installs can be used with mixed Rust/Python projects so you + don't have to recompile and reinstall when only Python source code changes." + Python edits take effect immediately; **Rust edits always require a rebuild** + (rerun `maturin develop` - or use the import hook below). + Source: . +- To get an optimized editable install through pip, pass build args via + `MATURIN_PEP517_ARGS="--release" pip install -e .` (or pip `--config-settings`, + which takes priority). Source: . +- **Relation to the book:** the editable-installs story is already a chapter theme - + `content/basic-packaging/01_setup.md` introduces editable installs, and + `content/scikit-build/04_editable_installs.md` covers scikit-build-core's + `redirect`/`inplace` modes and its `editable.rebuild` auto-rebuild option. The + Rust section can slot in as "same PEP 660 mechanism, third backend": maturin's + backend has **no built-in auto-rebuild setting** - the analogue of + scikit-build-core's `editable.rebuild` is the separate `maturin-import-hook` + package (next section). That symmetry (backend-specific rebuild ergonomics on top + of the same standard) is a nice callback for attendees who saw the earlier chapter. + +## `maturin-import-hook` - auto-rebuild on import + +Source: ; + (latest **0.3.0**, requires +Python ≥3.9). + +- Install and activate once per environment: + + ```bash + pip install maturin_import_hook + python -m maturin_import_hook site install # installs into sitecustomize.py + ``` + +- Effect: the hook "automatically rebuilds Maturin projects when imported", which + "reduces friction when developing mixed Python/Rust codebases because edits made to + Rust components take effect automatically like edits to Python components do", and + "eliminates the possibility of Python code using outdated rust components, which + often leads to confusing behaviour". +- Caveats: it only manages packages installed in editable mode ("Only Maturin + packages installed in editable mode (maturin develop or pip install -e) are + considered"); it is "intended for use in development environments and not for + production environments"; disable per-run with `MATURIN_IMPORT_HOOK_ENABLED=0`. +- Workshop stance: demo `maturin develop` explicitly (the rebuild step *is* the + lesson), then name-drop the hook as the quality-of-life upgrade for daily work. + +## Mixed Rust/Python layouts (`python-source`, `module-name`) + +Source: . + +- **Pure Rust** (the Phase 01 example): just `Cargo.toml` + `pyproject.toml` + + `src/lib.rs`; maturin generates the package so `import pyo3_example` works + directly. +- **Mixed:** add a directory named after the package next to `src/`; the compiled + module then sits inside the Python package and must be re-exported explicitly + (`from my_project import my_project`). +- **`python-source`:** relocate Python code, e.g. `python-source = "python"` under + `[tool.maturin]`, giving the common `python/my_project/…` + `src/…` split. +- **`module-name`:** rename the compiled module into a private submodule - + `module-name = "my_project._my_project"` - the widely used "private native core, + public Python API" convention (pydantic-core-style; see + [[rust-python-landscape]]). The `#[pymodule]` name in `lib.rs` must match the + last path segment. +- Teaching recommendation: start pure-Rust (least moving parts), show the mixed + layout on a slide as "what real projects grow into". + +## `maturin generate-ci` + +- `maturin generate-ci github` emits a complete GitHub Actions workflow (GitHub is + the only provider listed in local `--help`). Platform selection now lives in + `pyproject.toml` under `[tool.maturin.generate-ci.github."PLATFORM NAME"]`; the + deprecated `--platform` flag enumerates the choices: `all`, `manylinux`, + `musllinux`, `windows`, `macos`, `emscripten` (local `--help`; the guide also + mentions Android). Options cover per-platform builds, optional pytest runs, zig, + and artifact attestation. The release job **defaults to API-token publishing**: + the generated workflow runs `uv publish` with + `UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}` (verified locally against + maturin 1.14.1 output; the `id-token: write` permission it requests is for + attestation signing, not OIDC upload). **PyPI trusted publishing is opt-in** via + `[tool.maturin.generate-ci.github] trusted-publishing = true`, which switches the + job to `uv publish --trusted-publishing always` and drops the token. + Sources: local `maturin generate-ci github` output; + . +- Positioning vs cibuildwheel: `generate-ci` is maturin-native and Rust-aware out of + the box; cibuildwheel (which the book's C++ chapters reference) also supports Rust + extensions but needs the toolchain provisioned per build image (unverified detail + of current cibuildwheel Rust ergonomics). For a maturin-only project, + `generate-ci` is the lower-friction default. + +## manylinux/musllinux wheels and the container images + +Source: ; +. + +- Linux wheels for PyPI must be **manylinux** or **musllinux** tagged. Because "the + Rust compiler requires at least glibc 2.17", **manylinux2014 (`manylinux_2_17`) is + the practical floor** for Rust wheels. +- maturin "contains a reimplementation of auditwheel" that "automatically checks the + generated library and gives the wheel the proper platform tag" - no separate + auditwheel pass needed; `--auditwheel repair` can also bundle external shared + libraries into the wheel (requires patchelf). `--compatibility` pins a specific + policy (e.g. `manylinux_2_28`) or opts into plain `linux` tags. +- **Official container image:** builds run in a manylinux2014-based image with + toolchains preinstalled: + + ```bash + docker run --rm -v $(pwd):/io ghcr.io/pyo3/maturin build --release + ``` + +- **Ground-truth teaching hook:** the wheel maturin built inside the Phase 01 pixi + env was tagged `pyo3_example-0.1.0-cp314-cp314-linux_x86_64.whl` - a plain + `linux_x86_64` tag, **not** manylinux (a conda env's glibc/linked libs don't + satisfy the policy, and `maturin develop` doesn't need it to). Perfect for + contrasting "local dev install" vs "publishable wheel": PyPI would reject this + tag; CI or the container image produces the manylinux one. + +## Cross-compilation (including zig) + +Sources: ; local `maturin build --help`; +. + +- Three supported routes: (1) **manylinux-cross Docker images** for Linux targets; + (2) **zig** - `maturin build --zig --target ` uses zig as the C + compiler/linker to "ensure compliance for the chosen manylinux version" + (defaulting to manylinux2014/`manylinux_2_17`), giving glibc-version-pinned + cross-builds from any host; (3) **cargo-xwin** integration for Windows MSVC + targets, with automatic CRT/SDK header and library download. +- Cross-interpreter configuration flows through PyO3's env vars + (`PYO3_CROSS_PYTHON_VERSION`, `PYO3_CROSS_LIB_DIR`, `PYO3_CONFIG_FILE`), which + maturin sets or honors. +- Workshop scope: out of scope for the 30-minute segment; keep as a Q&A answer + ("yes, one Linux box can emit wheels for other platforms - maturin + zig or + Docker"). + +## abi3 wheels + +Sources: ; see [[pyo3-current-state]] for the +PyO3 side (feature flags, limited-API costs, `abi3t`). + +- maturin auto-detects PyO3's `abi3`/`abi3-py3X` cargo features from `Cargo.toml` + and tags the wheel accordingly (e.g. `cp310-abi3-manylinux_2_17_x86_64` for an + `abi3-py310` build): **one wheel per platform covers every CPython ≥ the floor**, + instead of one wheel per Python version. +- New in the 0.29-era stack: PEP 803 free-threaded stable ABI. "A single maturin + build selects one stable ABI family. If you want to publish both a GIL-enabled + `abi3` wheel and an `abi3t` wheel, run separate wheel builds explicitly, with + compatible interpreters" - e.g. `maturin build -i python3.10` and + `maturin build -i python3.15t`. +- The Phase 01 example intentionally builds version-specific (no abi3 feature) - + hence the `cp314-cp314` tag; abi3 stays a distribution-discussion hook, matching + the decision recorded in [[pyo3-current-state]]. + +## Comparison: maturin vs setuptools-rust vs when scikit-build-core wins + +- **maturin** - purpose-built for Rust: its own PEP 517 backend, near-zero config, + built-in manylinux auditing, `develop` inner loop, `generate-ci`, publishing. + The right default for new PyO3 projects, and what this workshop teaches. +- **setuptools-rust** () - a + setuptools plugin: "Compile and distribute Python extensions written in Rust as + easily as if they were written in C." Choose it when a project is **already on + setuptools** (existing `setup.py` machinery, other setuptools plugins, custom + build steps) and Rust is being added incrementally, or when one package must + bundle **multiple Rust extension modules** - its docs note "If you require + multiple extension modules you will need to write multiple `Cargo.toml` files" + (or expose PyO3 submodules from one crate), a shape maturin's one-crate-per-wheel + model doesn't target. Wheel building is delegated to external tooling + (typically cibuildwheel) rather than built in. +- **scikit-build-core** () - + "the build backend for making Python modules with CMake", aimed at C, C++, and + Fortran. It **remains the better fit whenever CMake is the source of truth**: + existing C/C++ codebases, mixed C++-plus-Rust builds, or teams standardized on + CMake - exactly the territory of the book's `content/scikit-build/` chapters. + maturin does not drive CMake; scikit-build-core does not know about cargo. Rule + of thumb for the slide: *new pure-Rust extension → maturin; legacy setuptools + + Rust bolt-on → setuptools-rust; CMake/C/C++ world → scikit-build-core.* + +## Implications for the Phase 01 example (audit hooks) + +- The pixi tasks already model the canonical workflow: `develop` (debug inner + loop), `develop-release` (bench-worthy builds), `test` depends-on `develop`, + `bench` depends-on `develop-release`. ✓ Matches current guidance; nothing to + change. +- `[tool.maturin] features = ["pyo3/extension-module"]` (hand-restored in Phase 01): + harmless but redundant with this stack - maturin sets + `PYO3_BUILD_EXTENSION_MODULE=1` when building, and PyO3 0.26+ honors that env var + with the same don't-link-libpython effect as the feature (PyO3 0.26.0 changelog; + maturin `src/compile.rs`; maturin's tutorial now scopes the feature to "pyo3 0.26 + or earlier"). The 1.14 template's omission is therefore deliberate, not an + oversight. Keep the feature only for older PyO3 or for plain `cargo build` outside + maturin. +- pixi pin `maturin = ">=1.9"` resolves to 1.14.1 today - floor is fine; no action. +- The `linux_x86_64` wheel-tag observation above should feed the deep-dive's + distribution section rather than change the example. +- No deprecated maturin usage found in the example; commands and flags all match + 1.14.1 help output. + +## Sources + +- maturin user guide (overview/commands): +- Local development, develop, editable installs: +- Import hook guide: +- Project layouts (`python-source`, `module-name`): +- Distribution (manylinux, Docker, zig, generate-ci, publish): +- Bindings incl. abi3/abi3t wheel selection: +- Environment variables (`VIRTUAL_ENV`, `CONDA_PREFIX`, PEP 517 args): +- README (Docker image, auditwheel reimplementation): +- PyPI metadata: , +- setuptools-rust docs: +- scikit-build-core docs: +- Local ground truth: `maturin 1.14.1 --help` output in `examples/2_04_rust_pyo3/`; + `Working/phase-01-ground-truth.md`; book chapters + `content/basic-packaging/01_setup.md`, `content/scikit-build/04_editable_installs.md` diff --git a/instructor-notes/research/pixi-rust-toolchain.md b/instructor-notes/research/pixi-rust-toolchain.md new file mode 100644 index 0000000..c23e317 --- /dev/null +++ b/instructor-notes/research/pixi-rust-toolchain.md @@ -0,0 +1,284 @@ +--- +type: research +title: The pixi/conda-forge Rust Toolchain (July 2026) +created: 2026-07-10 +tags: + - rust + - pixi + - conda-forge + - toolchain +related: + - '[[pyo3-current-state]]' + - '[[maturin-workflows]]' + - '[[rust-python-landscape]]' + - '[[rust-pyo3-maturin-deep-dive]]' +--- + +Research note for the Rust/PyO3/maturin section (`examples/2_04_rust_pyo3/`). +Checked 2026-07-10 against the conda-forge `rust` feedstock, the anaconda.org package +index, the rustup book, conda-forge/conda-build docs, and the pixi documentation - +plus a lot of local ground truth, because the Phase 01 pixi environment on linux-64 *is* +a conda-forge Rust toolchain we can dissect directly. Claims that could not be verified +today are marked "(unverified)". + +## What the conda-forge `rust` package ships + +conda-forge repackages the official upstream Rust binary distribution; it does not +build rustc from source patches. The feedstock produces several outputs +(source: ): + +- **`rust`** - the toolchain itself. Verified from the Phase 01 env's `bin/`: + `rustc`, `cargo`, `rustdoc`, **`rustfmt`/`cargo-fmt`**, **`clippy-driver`/`cargo-clippy`**, + and the `rust-gdb`/`rust-gdbgui`/`rust-lldb` debugger wrappers. So the two components + Pythonistas would `rustup component add` (formatter, linter) are already there. +- **`rust-std-`** - the standard library for one target, split out per + architecture and pinned **exactly** by `rust` (`pin_subpackage(..., exact=True)`; + the lockfile shows `rust-std-x86_64-unknown-linux-gnu 1.97.0 h2c6d0dc_0`). Extra + `rust-std-` packages exist for cross-compilation targets, the conda analogue + of `rustup target add`. +- **`rust-src`** and **`rust-docs`** - separate optional packages, not installed by + default (the feedstock builds them only for the `x86_64-unknown-linux-gnu` and + `x86_64-pc-windows-msvc` targets). + +Details observed in the Phase 01 env worth teaching: + +- `rustc --print sysroot` returns the **environment prefix itself** + (`…/.pixi/envs/default`) - the conda env *is* the Rust sysroot; the upstream installer + manifests (`manifest-rustc`, `manifest-cargo`, `manifest-clippy-preview`, + `manifest-rustfmt-preview`, `manifest-llvm-tools-preview`, …) sit right in the prefix. +- There is **no `rust-analyzer` LSP binary** in the env - only the proc-macro server + (`libexec/rust-analyzer-proc-macro-srv`). Attendees who want IDE support should let + their editor install rust-analyzer; it happily drives the conda-provided toolchain. +- It is a **big download**: the `rust` package alone is ~174 MB compressed on linux-64 + and ~180 MB on osx-arm64 (`size:` fields in `pixi.lock`) - pre-solve and pre-install + before a live demo; see the teaching runbook in [[rust-pyo3-maturin-deep-dive]]. +- Available for linux-64, linux-aarch64, linux-ppc64le, osx-64, osx-arm64, win-64, and + win-arm64; latest version on all of them today is **1.97.0**, matching the Phase 01 + env exactly. Source: . +- conda-forge tracks stable closely: the env's `rustc 1.97.0 (2d8144b78 2026-07-07)` + was packaged with a linux-64 timestamp of 2026-07-10 (`timestamp:` in `pixi.lock`) - + days, not months, behind upstream. + +## How it differs from rustup + +rustup is a *toolchain multiplexer*: it "installs and manages many Rust toolchains and +presents them all through a single set of tools installed to `~/.cargo/bin`", with +stable/beta/nightly channels, `rustup component add`, `rustup target add`, and per-project +overrides via `rust-toolchain.toml`. +Source: . + +The conda-forge model is deliberately different: + +| Concern | rustup | conda-forge `rust` via pixi | +| ------- | ------ | --------------------------- | +| Install | `curl \| sh`, global `~/.cargo`/`~/.rustup` | one dependency in `pixi.toml`, per-project env | +| Versions | any channel incl. beta/nightly, switchable | **stable releases only** (package index has no nightly/beta builds) | +| Version selection | `rustup default`/`rust-toolchain.toml` | conda solver + version spec, frozen by `pixi.lock` | +| Components | `rustup component add clippy rustfmt` | clippy + rustfmt already in the package | +| Cross targets | `rustup target add ` | install `rust-std-` as another conda dep | +| Binaries | proxy shims that dispatch per toolchain | real binaries on the env `PATH`, no shims | +| Co-installed stack | Rust only | same solve also pins Python, maturin, pytest | + +Why the workshop teaches the pixi route: it is the same install-and-lock machinery the +book already uses for every other tool, it needs no admin rights or shell-profile +surgery, and one `pixi install` gives every attendee an identical Rust+Python stack. +The honest trade-offs to state: no nightly (irrelevant here - PyO3 targets stable, MSRV +1.83, see [[pyo3-current-state]]), and you get conda-forge's packaging cadence instead +of `rustup update` the moment upstream releases. + +## Linker requirements per platform + +rustc compiles Rust to objects itself, but final linking of an executable or cdylib is +delegated to a platform linker driver (normally `cc`). Who provides that linker is the +per-platform story - and the practical difference between the three OSes. + +### Linux: batteries included (verified in Phase 01) + +The linux-64 `rust` package **directly depends on a C toolchain**. From `pixi.lock`: + +```yaml +- conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.97.0-h53717f1_0.conda + depends: + - __glibc >=2.17,<3.0.a0 + - gcc_impl_linux-64 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + - rust-std-x86_64-unknown-linux-gnu 1.97.0 h2c6d0dc_0 + - sysroot_linux-64 >=2.17 +``` + +`gcc_impl_linux-64` (15.2.0 in the env) transitively brings `binutils_impl_linux-64`/ +`ld_impl_linux-64` 2.45.1, and `sysroot_linux-64` resolved to 2.28. The package also +installs a conda activation script - the env's +`etc/conda/activate.d/rust.sh` contains exactly one line: + +```bash +export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=".../.pixi/envs/default/bin/x86_64-conda-linux-gnu-cc" +``` + +so cargo links with the env's own gcc wrapper, not whatever `/usr/bin/cc` happens to be. +The feedstock's `install-rust.sh` generates this hook **only on Linux**. +Sources: local env inspection; +. + +**Observed Phase 01 behavior, worth stating as the headline:** no `compilers` or +`c-compiler` package was ever added on linux-64, and no link step failed - `maturin +develop` worked out of the box. A side effect to be aware of: binaries are linked +against the conda sysroot's glibc (2.28 here), which sets the glibc floor of anything +you build (inference from the dependency data - not separately verified; the wheel +portability story lives in [[maturin-workflows]]). + +### macOS: bring Xcode Command Line Tools (not locally verified) + +The osx-arm64 `rust` package depends on **nothing but its standard library** - from +`pixi.lock`: + +```yaml +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.97.0-h4ff7c5d_0.conda + depends: + - rust-std-aarch64-apple-darwin 1.97.0 hf6ec828_0 +``` + +No compiler, no activation script (the feedstock hook is Linux-only, above). rustc on +macOS invokes the system `cc` as its linker driver, so attendees need **Xcode Command +Line Tools** (`xcode-select --install`) - which nearly every developer Mac already has. +Phase 01 ran only on linux-64, so this is inference from package metadata plus standard +rustc behavior, not an observed run - verify on an osx-arm64 machine before the workshop +(the example's `pixi.toml` already lists `osx-arm64` in `platforms`, so the lockfile side +is ready). + +The macOS SDK caveat mostly concerns conda-forge's *C/C++* compilers, not pure Rust: +Apple's SDK license prevents bundling it in conda packages, so conda-forge `clang` +users must obtain an SDK separately (`CONDA_BUILD_SYSROOT`); conda-forge currently +builds its stack against SDK 11.0. With system Command Line Tools linking a PyO3 +cdylib, none of that machinery is needed. +Sources: , +. + +### Windows: MSVC Build Tools are on you (unverified locally) + +conda-forge's win-64 `rust` targets `x86_64-pc-windows-msvc`, and - like macOS - its +run dependency is only `rust-std`. The MSVC linker (`link.exe`) and Windows import +libraries **cannot be shipped by conda-forge** (proprietary); they come from +"Build Tools for Visual Studio 2022" with the "MSVC v143 … build tools" and +"Windows 11 SDK" components - the same prerequisites rustup documents for its own +msvc toolchain. conda-forge itself moved its default Windows toolchain to VS2022 in +June 2025. Practical workshop guidance: Windows attendees should either have VS Build +Tools preinstalled or use WSL2 and follow the Linux path (where everything is in the +lockfile). +Sources: , +, +. + +### Where `compilers`/`c-compiler` *do* come in + +The conda-forge metapackages `c-compiler`/`cxx-compiler`/`compilers` install the +ABI-consistent toolchain conda-forge builds its own packages with (gcc on Linux, clang +on macOS, MSVC activation on Windows). For this section's pure-PyO3 extension they are +unnecessary (verified on Linux, above). You *do* want them the moment the crate graph +grows a C/C++ build step: `*-sys` crates using the `cc` crate, `bindgen` (needs +libclang), `cxx`, or vendored C libraries. Rule of thumb for the deep-dive: "pure Rust + +PyO3 → the `rust` package is enough on Linux/macOS; add `c-compiler` when a C-building +`-sys` crate enters `Cargo.lock`, `cxx-compiler` when the build step is C++, and +libclang alongside the compiler when `bindgen` is in the graph." +Sources: , +. + +## Reproducibility: pixi.lock for a Rust+Python stack + +The example's `pixi.lock` (lockfile `version: 7`) records, for **both** declared +platforms (`linux-64`, `osx-arm64`), every package in the solve - full download URL, +sha256 and md5, and dependency list; 57 package records for this small env, rustc and +CPython pinned side by side in one file. The pixi docs' framing: "A lock file lists the +exact dependencies that were resolved during this resolution process", and installers +"can create exactly the same environment without needing to manage the actual package +contents itself". It is designed to be committed to git. Lockfile v7 additionally locks +build dependencies and avoids churn from unrelated manifest edits. +Sources: , +; local `pixi.lock`. + +For CI and classroom use, two flags matter (same source): + +- `pixi install --frozen` - "install the environment as defined in the lock file, + doesn't update `pixi.lock`". +- `pixi install --locked` - "only install if the `pixi.lock` is up-to-date with the + manifest file" (fail instead of silently re-solving). + +The full-stack picture has **two lockfiles**, which is itself a teaching moment: + +| Lockfile | Pins | Written by | +| -------- | ---- | ---------- | +| `pixi.lock` | rustc/cargo 1.97.0, maturin 1.14.1, Python 3.14.6, pytest 9.1.1 | pixi (conda solve) | +| `Cargo.lock` | the crate graph - pyo3 0.29.0 and its deps | cargo | + +Commit both and a fresh `pixi install && pixi run test` rebuilds the identical +extension anywhere (modulo the non-conda seam: the system linker on macOS/Windows is +outside both lockfiles). The pyo3 pin story is in [[pyo3-current-state]]; how maturin +finds the env (`CONDA_PREFIX`) is in [[maturin-workflows]]. + +## Pinning strategy + +What the example ships (Phase 01 ground truth): + +```toml +[dependencies] +rust = ">=1.85" +maturin = ">=1.9" +python = ">=3.12" +pytest = ">=8" +``` + +The pattern: **manifest floors encode real requirements; the lockfile does the exact +pinning.** Each floor is meaningful - `rust >=1.85` is the minimum for the crate's +`edition = "2024"` (and comfortably above PyO3 0.29's MSRV of 1.83, see +[[pyo3-current-state]]); `maturin >=1.9` predates every feature the example uses; +`python >=3.12` matches the book's baseline. Because `pixi.lock` freezes the solve at +exact builds (rust 1.97.0 `h53717f1_0`, …), tighter manifest specs would add no +reproducibility - they would only fight future `pixi update` runs. + +Recommendation for the workshop repo: keep permissive floors, commit `pixi.lock`, and +have instructors provision with `pixi install --locked` so the classroom env is +bit-identical to the one this research was validated against - unlike `--frozen`, +`--locked` aborts when the lockfile has drifted from the manifest instead of silently +installing a stale solve (local `pixi install --help`). Reach for an exact +manifest pin (e.g. `rust = "1.97.*"`) only if you must protect against someone running +`pixi update` the night before the session. + +## Observed Phase 01 behaviors (the record) + +All from the Phase 01 run on linux-64 (see `Working/phase-01-ground-truth.md`): + +- Resolved stack: rust 1.97.0 (`h53717f1_0`) + rust-std-x86_64-unknown-linux-gnu + 1.97.0, maturin 1.14.1, Python 3.14.6 (cp314), pytest 9.1.1 - conda-forge channel only. +- **No linker package needed**: `gcc_impl_linux-64` 15.2.0, `binutils_impl_linux-64`/ + `ld_impl_linux-64` 2.45.1, `sysroot_linux-64` 2.28, and `kernel-headers_linux-64` + 4.18.0 all arrived as dependencies of `rust`; `maturin develop` linked on the first try. +- `CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER` was set by the env activation to the + conda gcc wrapper (contents of `etc/conda/activate.d/rust.sh`, quoted above). +- maturin auto-detected the pixi env via `CONDA_PREFIX` ("🐍 Found CPython 3.14 at + …/.pixi/envs/default/bin/python") - no `VIRTUAL_ENV`, no flags; details in + [[maturin-workflows]]. +- `pixi run test`: 8 tests green; `pixi run bench`: 19× speedup over pure Python for + `count_primes(1_000_000)` on a release build. + +## Sources + +- Local ground truth: `examples/2_04_rust_pyo3/` - `pixi.lock`, `pixi list`, env + `bin/`/`libexec/`/activation scripts, `rustc --print sysroot`; `Working/phase-01-ground-truth.md` +- rust-feedstock recipe (outputs, per-platform run deps): + +- rust-feedstock install script (Linux-only linker activation hook): + +- conda-forge package index for `rust` (versions, platforms): + +- rustup concepts (toolchains, channels, components, targets): + +- rustup MSVC prerequisites: +- conda-forge knowledge base (SDK, compilers): +- conda-forge FAQ (compiler metapackages): +- conda-build "Anaconda compiler tools" (SDK licensing, activation model): + +- conda-forge news - VS2022 default: +- pixi lock file docs (contents, `--frozen`/`--locked`): + +- pixi lockfile v7 announcement: diff --git a/instructor-notes/research/pyo3-current-state.md b/instructor-notes/research/pyo3-current-state.md new file mode 100644 index 0000000..d8b4025 --- /dev/null +++ b/instructor-notes/research/pyo3-current-state.md @@ -0,0 +1,239 @@ +--- +type: research +title: PyO3 - Current State (July 2026) +created: 2026-07-10 +tags: + - rust + - pyo3 + - python-bindings +related: + - '[[maturin-workflows]]' + - '[[pixi-rust-toolchain]]' + - '[[rust-python-landscape]]' + - '[[rust-pyo3-maturin-deep-dive]]' +--- + +Research note for the Rust/PyO3/maturin section (`examples/2_04_rust_pyo3/`). +All claims were checked on 2026-07-10 against pyo3.rs, docs.rs, crates.io, and the +PyO3 GitHub changelog; anything that could not be re-verified today is marked +"(unverified)". Local ground truth: the Phase 01 example builds with **pyo3 0.29.0** +(edition 2024, `cdylib`) on Python 3.14.6 and Rust 1.97.0 from conda-forge. + +## Latest release, MSRV, and support matrix + +- **Latest release: 0.29.0, published 2026-06-11** - also the version in the example's + `Cargo.lock`. + Source: (`max_version: "0.29.0"`, + `created_at: 2026-06-11`); . +- **MSRV: Rust 1.83** ("Requires Rust 1.83 or greater"; bumped in 0.28.0 - "Bump MSRV to + Rust 1.83"). The example's pixi pin `rust = ">=1.85"` clears this comfortably, and + edition 2024 needs ≥1.85 anyway. + Sources: , + . +- **Python support: CPython ≥3.9, PyPy 7.3 (Python 3.11+), GraalPy ≥25.0 (Python 3.12+).** + CPython 3.8 support was dropped in 0.28.0. + Sources: , + . +- **Release hygiene worth knowing:** 0.28.0 and 0.28.1 were later **yanked** on crates.io + (0.28.2/0.28.3 superseded them), and 0.29.0 fixed two security issues (a missing `Sync` + bound on `PyCFunction::new_closure` closures; a possible out-of-bounds read in + `BoundTupleIterator::nth_back`/`BoundListIterator::nth_back`). Practical advice for the + workshop: depend on the latest minor and let patch releases float (`pyo3 = "0.29.0"` + is a caret requirement - it accepts 0.29.x). + Sources: , + . + +### Release cadence (why online snippets rot fast) + +PyO3 ships a breaking minor roughly every quarter. Recent history, from the changelog +(): + +| Version | Date | Headline | +| ------- | ---- | -------- | +| 0.21.0 | 2024-03-25 | `Bound<'py, T>` API introduced alongside old "GIL Refs" | +| 0.23.0 | 2024-11-15 | GIL-Refs era APIs removed; `IntoPyObject`; first free-threaded (3.13t) support | +| 0.25.0 | 2025-05-14 | Python 3.14 support; `#[pyclass(generic)]`; `datetime` with abi3 | +| 0.26.0 | 2025-08-29 | `Python::with_gil`/`allow_threads` renamed to `attach`/`detach` | +| 0.27.0 | 2025-10-19 | `FromPyObject` rework (borrowing); `.downcast()` deprecated for `.cast()` | +| 0.28.0 | 2026-02-01 | MSRV 1.83; Python 3.8 dropped; free-threaded support becomes opt-out; `__init__` | +| 0.29.0 | 2026-06-11 | PEP 803 `abi3t` stable ABI for free-threaded builds; Python 3.15.0b1; 3.13t dropped | + +Teaching takeaway: any tutorial or Stack Overflow answer predating April 2024 (0.21) is +written against a fundamentally different API, and even 2025 snippets may use pre-rename +GIL vocabulary. Check dates before trusting snippets. + +## The `Bound<'py, T>` API - and why older snippets look different + +Since 0.21, the core object handle is `Bound<'py, T>`: an owned smart pointer to a Python +object that both holds a reference count and carries the `'py` lifetime proving the thread +is attached to the interpreter. Methods on Python objects come from traits like +`PyAnyMethods`. Source: . + +History that explains the snippet soup found online: + +- **Pre-0.21 ("GIL Refs" era):** APIs handed out `&'py PyAny` references owned by an + internal pool. Code full of `obj.as_ref(py)`, `&PyAny`, `&PyList` parameters is from + this era. +- **0.21-0.22 (transition):** `Bound` arrived alongside the old API; new constructors got + temporary `_bound` suffixes (`PyTuple::new_bound(py, …)`), and `FromPyObject` gained + `extract_bound`. Snippets with `_bound` names are from this window. +- **0.23 (2024-11-15):** the deprecated GIL-Refs functionality was removed and the plain + names came to mean the `Bound` variants (`PyTuple::new(py, …)`). The `_bound` suffixes + are gone in current code. +- **0.27:** `.downcast()` and `DowncastError` were deprecated in favor of `.cast()` and + `CastError` - another marker for dating snippets. + +Sources: , . + +## The `Python<'py>` token + +`Python<'py>` is a zero-sized token whose possession proves the current thread is attached +to the Python interpreter (on GIL-enabled builds: holds the GIL). It is obtained either by +`Python::attach(|py| { … })` from Rust, or by declaring a `py: Python<'py>` parameter in a +`#[pyfunction]`/`#[pymethods]` method (PyO3 injects it - the example's `count_primes` does +exactly this). The `'py` lifetime is what ties every `Bound<'py, T>` to an attached thread +state, so "you can only touch Python objects while attached" is enforced at compile time - +a nice show-piece for the borrow checker teaching moment. +Sources: , . + +## Conversion traits: `FromPyObject` and `IntoPyObject` + +- **`FromPyObject`** drives argument extraction (`obj.extract::()` and automatic + `#[pyfunction]` argument conversion). Reworked in 0.27.0 with a second lifetime so + implementations can borrow data directly from Python objects (e.g. `&str` from a + `str` without copying); `FromPyObjectOwned` was added for the non-borrowing case. +- **`IntoPyObject`** (added in 0.23) is the single, fallible conversion trait for return + values, replacing the older `IntoPy` and `ToPyObject` (both deprecated in 0.23 - their + presence marks an outdated snippet). A `#[derive(IntoPyObject)]` macro exists. +- Notable 0.23 behavior change: `Vec`, `&[u8]`, and `[u8; N]` now convert to `bytes`, + not `list` - worth a caution slide if byte data comes up. +- 0.28 deprecated the implicit `Clone`-based by-value `FromPyObject` for `#[pyclass]` + types; it is now explicit via `#[pyclass(from_py_object)]` (or silenced with + `#[pyclass(skip_from_py_object)]`). + +For the workshop, the practical story is simpler: standard types (ints, floats, `String`, +`Vec`, `HashMap`, `Option`, tuples) convert automatically in both directions, +and `PyResult` return values map `Err(PyErr)` to a raised Python exception. +Sources: , , +. + +## GIL vocabulary: `attach`/`detach` (formerly `with_gil`/`allow_threads`) + +All the GIL-centric names were renamed in **0.26.0 (2025-08-29)** to attachment-centric +ones, because on free-threaded builds there is no GIL - what a thread actually does is +attach to/detach from an interpreter thread state: + +- `Python::with_gil` → `Python::attach` (PyO3/pyo3#5209) +- `Python::allow_threads` → `Python::detach` (PyO3/pyo3#5221) +- `Python::with_gil_unchecked` → `Python::attach_unchecked` (PyO3/pyo3#5340) +- `Python::assume_gil_acquired` → `Python::assume_attached` (PyO3/pyo3#5354) +- `pyo3::prepare_freethreaded_python` → `Python::initialize` (embedding use case) + +Source: (0.26.0 section); +. + +The old names are deprecated aliases; whether they still compile (with warnings) on 0.29 +was not re-verified (unverified). What is locally verified: `Python::detach` compiles +clean with zero warnings on pyo3 0.29.0 in the Phase 01 example, where `count_primes` +wraps its hot loop in `py.detach(…)` so other Python threads can run - the canonical +"release the GIL around pure-Rust work" pattern. Anything that touches Python objects must +stay outside the `detach` closure (the closure cannot capture `Bound` values - enforced by +the type system via the `Ungil` bound). + +## Free-threaded CPython support + +Status as of 0.29.0 (source: and the +changelog): + +- PyO3 has supported free-threaded builds **since 0.23** (initially Python 3.13t, + opt-in). **0.28 flipped the default**: extension modules now advertise free-threaded + support unless they opt out with `#[pymodule(gil_used = true)]` (or + `PyModule::gil_used(true)`). **0.29 dropped 3.13t** and targets 3.14t+, and added + support for the new **PEP 803 `abi3t` stable ABI** (features `abi3t`, `abi3t-py315`) + so free-threaded wheels can finally be version-independent from CPython 3.15 on. +- Every `#[pyclass]` must be `Sync` (required since 0.23). Mutable access to `#[pyclass]` + data is protected by PyO3's `RefCell`-style runtime borrow checking, which raises or + panics on conflicting borrows - under free threading, real concurrent access becomes + possible, so classes with mutable state may need explicit locking. +- Synchronization helpers: `PyOnceLock` (replaces the deprecated `GILOnceCell`), + `OnceExt`, and `MutexExt::lock_py_attached` (deadlock-safe locking while attached). + `GILProtected` is deprecated. +- Before `abi3t`, the `abi3` feature is simply **ignored (with a build warning)** when + building for a free-threaded interpreter - such builds produce version-specific wheels. +- `cfg(Py_GIL_DISABLED)` is available for conditional compilation. + +Teaching angle: this is a strong "why Rust" argument - `Send`/`Sync` means the compiler +audits your extension's thread safety just as PEP 703 makes Python genuinely concurrent. + +## abi3 (stable ABI) support and its limits + +Sources: , +, . + +- Enabled with the `abi3` feature plus a floor feature; docs.rs lists `abi3-py38` through + `abi3-py315` on 0.29.0, plus the new `abi3t`/`abi3t-py315`. One wheel then runs on every + CPython ≥ the floor (maturin tags it `cp3X-abi3`; see [[maturin-workflows]]). +- Note the mismatch: the `abi3-py38` feature still exists, but PyO3 0.28 dropped Python + 3.8 support (README floor is 3.9) - so 3.9 is the practical minimum floor. +- Costs and limits of the limited API (`Py_LIMITED_API`): some APIs and fast paths are + unavailable (code gated `#[cfg(not(Py_LIMITED_API))]`), so abi3 builds can be somewhat + slower; `datetime` types only became abi3-compatible in 0.25; subclassing native types + with abi3 requires Python 3.12+ (added in 0.28); and abi3 does not apply to + free-threaded builds before `abi3t` (see above). PyPy/GraalPy do not use the stable + ABI - abi3 is a CPython mechanism (unverified for current guide wording). +- The Phase 01 example deliberately builds a **version-specific** extension (no abi3 + feature): simpler story, full API, and it matches the free-threaded reality. abi3 is a + discussion hook for "how do I ship one wheel per platform instead of one per Python?" + +## `#[pyclass]` / `#[pymethods]` capabilities relevant to teaching + +Source: . + +- **Derive-like options on `#[pyclass]`:** `eq` (uses `PartialEq` for `__eq__`), `ord`, + `str` (uses `Display`, or a format string), `hash` (requires `eq` + `frozen`), `frozen` + (immutable - also removes runtime borrow-check overhead), `get_all`/`set_all` (expose + all fields), `subclass`/`extends = Base` (inheritance, both directions), `dict`, + `weakref`, `generic` (0.25+), `mapping`, `sequence`. +- **Constructors:** `#[new]` maps to `__new__`; since 0.28 a real `__init__` can be + written in `#[pymethods]` too. Constructors may return `Self`, `PyResult`, or + `PyClassInitializer` for inheritance chains. +- **Properties:** `#[pyo3(get, set)]` on fields for the simple case; `#[getter]`, + `#[setter]`, and (since 0.28) `#[deleter]` methods for computed properties. +- **Method kinds:** instance methods (`&self`/`&mut self`), `#[staticmethod]`, + `#[classmethod]`, `#[classattr]`. +- **Enums:** simple (unit-variant) enums get `__richcmp__`/`__int__`/`__repr__` (plus + `eq_int`); complex enums with struct/tuple variants support Python 3.10+ pattern + matching and `#[pyo3(constructor = (…))]`. +- **Magic methods** go straight into `#[pymethods]` (`__repr__`, `__len__`, arithmetic, + etc.); `async fn` in `#[pyfunction]`/`#[pymethods]` exists behind `experimental-async`. +- **Modules:** the guide now leads with **declarative modules** - `#[pymodule] mod name` + auto-registers the `#[pyfunction]`s/`#[pyclass]`es declared inside it, with + `#[pymodule_export]` for items defined elsewhere and `#[pymodule_init]` as the escape + hatch. The Phase 01 example already uses this style (no hand-written registration). + Since 0.28, `#[pymodule]` uses PEP 489 multi-phase initialization. + Source: . + +## Implications for the Phase 01 example (audit hooks) + +- `pyo3 = "0.29.0"` **is the current latest** - nothing to bump. +- `Python::detach`, declarative `#[pymodule] mod`, edition 2024: all current idiom. ✓ +- `requires-python = ">=3.8"` in `pyproject.toml` is stale relative to PyO3 0.28+'s + CPython ≥3.9 floor - the audit task should raise it to `>=3.9` (or align with the pixi + pin `python >=3.12`). +- Since 0.28 the module **advertises free-threaded support by default**; the audit should + confirm the `#[pyclass] Point` is safe under that assumption (or opt out with + `gil_used = true` and say why). +- pixi pin `rust >=1.85` ≥ MSRV 1.83. ✓ + +## Sources + +- crates.io API - latest version and dates: +- GitHub releases: +- Changelog (versions, dates, renames, MSRV): +- Migration guide (Bound, IntoPyObject, attach/detach, cast): +- Free-threading guide: +- abi3 / multiple Python versions: +- Feature list incl. abi3/abi3t floors: +- Class guide: ; module guide: +- README (MSRV, interpreter support): +- Local ground truth: `examples/2_04_rust_pyo3/` (`Cargo.lock`, `pixi list`, Phase 01 records) diff --git a/instructor-notes/research/rust-python-landscape.md b/instructor-notes/research/rust-python-landscape.md new file mode 100644 index 0000000..f387517 --- /dev/null +++ b/instructor-notes/research/rust-python-landscape.md @@ -0,0 +1,265 @@ +--- +type: research +title: The Rust-for-Pythonistas Landscape (July 2026) +created: 2026-07-10 +tags: + - rust + - python + - ecosystem + - pyo3 + - motivation +related: + - '[[pyo3-current-state]]' + - '[[maturin-workflows]]' + - '[[pixi-rust-toolchain]]' + - '[[rust-pyo3-maturin-deep-dive]]' +--- + +Research note for the Rust/PyO3/maturin section (`examples/2_04_rust_pyo3/`). This is the +*motivational* note: proof that attendees already run Rust every day, where the +Rust-extension approach genuinely pays off, where it does not, and what other +tutorials/workshops exist so this session can differentiate. Claims were checked on +2026-07-10 against each project's repository (`pyproject.toml`/`Cargo.toml` on the default +branch), PyPI, and crates.io; anything not re-verifiable today is marked "(unverified)". +Download figures are pepy.tech month badges read on 2026-07-10 - treat as order of +magnitude, not precision. + +## Rust tools Pythonistas already use daily + +The single best motivational slide: a typical 2026 Python stack already executes Rust on +every run - usually without the user ever installing a Rust compiler. One line each: + +| Package | Downloads/mo | What it is | How it ships Rust | +| ------- | ------------ | ---------- | ----------------- | +| `cryptography` | ~1 billion | TLS/crypto primitives | PyO3 extension, built with maturin | +| `pydantic-core` | ~863 M | validation core of pydantic v2 (and thus FastAPI) | PyO3 extension (`bindings = 'pyo3'`), maturin | +| `ruff` | ~234 M | linter + formatter | pure Rust binary in a wheel (maturin `bindings = "bin"`, no PyO3) | +| `tokenizers` | ~187 M | Hugging Face tokenizers | PyO3 extension, maturin, abi3 | +| `orjson` | ~176 M | fast JSON | Rust via low-level `pyo3-ffi`, maturin | +| `uv` | ~139 M | package/project manager | pure Rust binary in a wheel (maturin `bindings = "bin"`) | +| `polars` | ~52 M | DataFrame library | pure-Python meta package over PyO3-built `polars-runtime-*` abi3 wheels | + +Sources: monthly badges at `https://static.pepy.tech/badge//month` +(project pages: etc.); per-project build facts +below. The `uv` figure understates real usage - most installs come from its standalone +installer, Homebrew, or CI caches, not PyPI (analysis, not a sourced count). + +Per-tool detail, verified against each repo's default branch on 2026-07-10: + +- **ruff** (Astral): no PyO3 at all - `pyproject.toml` sets `build-backend = "maturin"` + with `bindings = "bin"`, `manifest-path = "crates/ruff/Cargo.toml"`, and + `python-source = "python"`. maturin packages a standalone executable into wheels; the + wheel's job is distribution, not binding. Fun teaching detail: `requires-python = + ">=3.7"` - a binary that never links libpython doesn't care which Python installs it. + Source: . +- **uv** (Astral): same pattern - maturin backend, `bindings = "bin"`, + `manifest-path = "crates/uv/Cargo.toml"`, plus a tiny `python-source = "python"` shim + package. Source: . +- **polars**: since the 1.x runtime split, the PyPI `polars` package (1.42.1 today) is + **pure Python** (`build-backend = "setuptools.build_meta"`) and depends on + `polars-runtime-32 == `, with `polars-runtime-64` and + `polars-runtime-compat` as opt-in extras; a loader picks the best runtime for the + host's CPU flags at import time. The compiled runtimes come from the `polars-python` + crate, which depends on `pyo3` with `features = ["abi3-py310", …]` - the runtime wheels + on PyPI are tagged `cp310-abi3`. (Build backend of the runtime wheels themselves not + verified; the bindings crate is unambiguously PyO3.) + Sources: , + , + , + . +- **pydantic-core**: the canonical PyO3 mixed-layout project - maturin backend + (`maturin>=1.9.4,<2`), `bindings = 'pyo3'`, `python-source = "python"`, + `module-name = "pydantic_core._pydantic_core"`. Every pydantic v2 model validation + (so: every FastAPI request) runs this Rust code. + Source: . +- **orjson**: maturin backend, but deliberately **not** the high-level PyO3 API - it + depends on `pyo3-ffi = { version = "0.28", default-features = false }` (raw C-API + bindings, edition 2024, `rust-version = "1.95"`) to shave every last conversion cost. + Good "you can drop a level when you must, but you lose the safety rails" data point. + Sources: , + . +- **cryptography** (pyca): maturin backend today - and a demanding user of it + (`maturin>=1.14.1,<2` on new Pythons), mixed layout with + `module-name = "cryptography.hazmat.bindings._rust"` and `locked = true`. History for + the "Rust arrives quietly" narrative: Rust entered the build in the 3.4 era (early + 2021) and became mandatory in 35.0.0 (2021-09-29: "Rust is now required for building + cryptography"); it originally built with setuptools-rust and later switched the + backend to maturin - community records place the switch in late 2023/early 2024, but + the exact release is not stated in the changelog (unverified). + Sources: , + . +- **tokenizers** (Hugging Face): maturin backend, `bindings = "pyo3"`, + `features = ["pyo3/extension-module", "abi3"]`, mixed layout + (`python-source = "py_src"`, `module-name = "tokenizers.tokenizers"`). A production + example of the abi3 one-wheel-per-platform strategy discussed in + [[maturin-workflows]]. + Source: . + +### Two integration patterns - name them explicitly + +The table above splits cleanly into two shapes, and naming the split avoids a common +attendee confusion ("is ruff a Python extension?"): + +1. **PyO3 extension modules** (pydantic-core, cryptography, tokenizers, polars runtimes, + orjson via pyo3-ffi): Rust compiled to a `cdylib` that CPython imports; Python calls + into Rust in-process. This is what the workshop example builds. +2. **Rust binaries delivered as wheels** (ruff, uv): maturin's `bin` bindings put a + normal executable on `PATH` via pip/uv/pixi. No PyO3, no libpython, works with any + Python. The wheel is used purely as a package-manager-friendly delivery vehicle. + +Either way, **maturin is the common denominator** - every row of the table builds with +it except the pure-Python polars shim. That is the strongest available argument that the +tool this section teaches is the ecosystem default, not a niche choice. + +### Honorable mentions (one breath, not a slide) + +Also Rust-backed and widely deployed: `jiter` (the JSON parser inside pydantic and the +OpenAI/Anthropic SDKs), `watchfiles` (file watching used by uvicorn's reloader), +`rpds-py` (persistent data structures under `jsonschema`), `granian` (Rust HTTP server +for Python apps), and Astral's `ty` type checker (unverified - listed from ecosystem +familiarity; spot-check any of these before quoting details in the session). + +## NumPy interop: rust-numpy and ndarray + +For numeric attendees the natural question is "what about arrays?" - the answer is the +`numpy` crate (rust-numpy), which is PyO3-based bindings to NumPy's C-API: + +- **Current release 0.29.0 (2026-06-13), tracking pyo3 0.29** - the crate versions its + minors in lockstep with PyO3 (0.28 ↔ pyo3 0.28, etc.), so the pairing for the example + would be `pyo3 = { version = "0.29" }` + `numpy = "0.29"`. MSRV 1.83, same as PyO3. + Sources: , + . +- It exposes `PyArray` (a NumPy array as a Python object) plus safe read/write + guards (`PyReadonlyArray` etc.), and converts to/from the pure-Rust `ndarray` crate, + which it re-exports; it tolerates a range of ndarray versions (currently + `>= 0.15, <= 0.17`). Zero-copy views mean Rust can loop over the same buffer NumPy + sees - no per-element boundary crossing. + Source: (README). +- Teaching placement: the workshop example stays scalar (`count_primes`, `Point`) on + purpose; rust-numpy is the "where to go next" pointer for the "my hot loop is over a + NumPy array" question, covered in depth in [[rust-pyo3-maturin-deep-dive]]. + +## Where Rust does *not* help + +The section should spend two honest minutes here - it buys credibility for the 19× +benchmark (local ground truth: pure Python ~1.63 s vs Rust release ~0.085 s for +`count_primes(1_000_000)`; see `Working/phase-01-ground-truth.md`). The following are +engineering analysis rather than sourced claims, backed by local measurements where +noted: + +- **I/O-bound code.** If the profile is dominated by network, disk, or database waits, + compiled code changes nothing - the CPU was already idle. Concurrency tools + (`asyncio`, threads - increasingly viable on free-threaded CPython, see + [[pyo3-current-state]]) attack waiting; Rust attacks computing. +- **Already-vectorized NumPy.** `a * b + c` on large arrays already runs C loops (or + BLAS) over contiguous buffers; a hand-written Rust loop typically matches rather than + beats it, and a *naive* per-element PyO3 function called from Python is dramatically + slower than either. Rust wins over NumPy only where vectorization fails: per-element + branching, stateful scans, custom reductions - and then via rust-numpy whole-array + calls, never per-element calls. +- **Chatty boundaries.** Every Python→Rust call pays FFI + conversion overhead + (arguments in, results out - see the conversion-traits section of + [[pyo3-current-state]]). A tiny function invoked millions of times from a Python loop + can end up *slower* than pure Python. Design rule for the slides: move the **loop** + into Rust, not the loop **body**. Passing big `list`/`dict` structures converts + element-by-element; prefer buffers/arrays at the boundary. +- **Debug builds.** `maturin develop` compiles unoptimized by default; the section's + workflow encodes the fix (`develop-release` task → `maturin develop --release`). + Ground-truth caveat: on this u64-division-bound example debug was only ~1.2× slower + (0.102 s vs 0.085 s), so state the "debug builds can lose to CPython" warning + generally, not as a claim about this example. See [[maturin-workflows]]. +- **The bottleneck is the algorithm.** An O(n²) → O(n log n) fix in Python routinely + beats a constant-factor 19× from Rust. Profile first (`cProfile`, `py-spy`), rewrite + second. +- **Team cost is real.** A second toolchain, compile times, a wheel-building CI matrix + (mitigated by `maturin generate-ci` - [[maturin-workflows]]), and a smaller + contributor pool. The ecosystem answer is visible in the table above: push Rust into + *narrow, hot, well-tested cores* (pydantic-core, polars runtimes) and keep the API + surface in Python. + +## Existing PyO3 tutorials and workshops - and how this session differs + +What is already out there (all links checked 2026-07-10): + +- **The official PyO3 user guide** () - comprehensive and current; + the getting-started chapter is effectively the canonical tutorial. Assumes the reader + installs Rust themselves (rustup) and manages a virtualenv. +- **The maturin tutorial** () - builds a + guessing-game lib crate and installs it with `maturin develop`; the closest published + analogue to this section's workflow. +- **"PyO3 101 - Writing Python modules in Rust" (Cheuk Ting Ho)** - the most visible + conference workshop: a ~3-hour hands-on tutorial run at PyCon US 2024, PyCon DE & + PyData Berlin 2024 (recording on YouTube), and EuroPython 2025. Seven exercises from + hello-world through iterators and thread-safe decorators; materials MIT-licensed at + . Setup asks attendees to install Rust via + rustup, create a venv with uv, `uv pip install maturin`, and even run + `python -m ensurepip` so `maturin develop` can find pip. + Sources: , + , + . +- **PyO3's in-repo examples** () - + small complete projects (word-count etc.) useful for cribbing patterns. +- **Blog posts and Medium tutorials** - abundant but rot quickly: PyO3 ships a breaking + minor roughly quarterly, and anything predating 0.21 (2024-03) or the 0.26 (2025-08) + `attach`/`detach` renames shows APIs that no longer exist. The rot mechanics are + documented in [[pyo3-current-state]]; teach attendees to check dates before trusting + snippets. + +Differentiation of this session (the honest pitch, not marketing): + +1. **pixi-managed toolchain, zero rustup.** Every published tutorial above starts with + "install Rust" as a separate, per-OS step. Here, `pixi install` provisions rust + 1.97.0, maturin 1.14.1, Python 3.14.6, and pytest from conda-forge in one lockfile - + and on linux-64 the linker comes along transitively, no `compilers` package needed + ([[pixi-rust-toolchain]], ground truth). maturin then auto-detects the pixi env via + `CONDA_PREFIX`. Setup friction is the thing that kills 3-hour workshops; this + session inherits an environment attendees already built in earlier chapters. +2. **Packaging-workshop context.** Attendees arrive knowing PEP 517 backends, editable + installs, and scikit-build-core from earlier sections - so maturin is taught as + *another build backend* with a familiar shape (`pyproject.toml`, `pip install -e .`, + wheels), and the maturin vs setuptools-rust vs scikit-build-core comparison + ([[maturin-workflows]]) lands with an audience primed to care. +3. **30-minute segment, one artifact.** Not a Rust course: one crate (`count_primes` + + a `#[pyclass]`), one benchmark payoff, plus pointers out (rust-numpy, abi3, + generate-ci) for depth - versus the half-day scope of PyO3 101. +4. **Current APIs.** The example is pyo3 0.29 / edition 2024 / declarative modules / + `Python::detach` - ahead of most published material, per the cadence table in + [[pyo3-current-state]]. + +## Teaching hooks recorded for the deep-dive + +- Open with the downloads table: "you already ran Rust today - probably before your + first coffee" (cryptography ~1 B/mo). +- Name the two patterns (extension module vs binary-in-a-wheel) before demoing either. +- Use pydantic-core/tokenizers `pyproject.toml` snippets as "real-world versions of the + file we just wrote" when showing the example's `[tool.maturin]` table. +- Keep the "where Rust does not help" list next to the benchmark slide - the 19× claim + is more persuasive with its boundary conditions attached. +- Point NumPy users at rust-numpy 0.29 (versions pair with pyo3), not at hand-rolled + per-element functions. + +## Sources + +- Build configs (default branches, read 2026-07-10): + , + , + , + , + , + (+ `Cargo.toml`), + , + +- polars runtime split: , + +- cryptography Rust history: (3.4.x and + 35.0.0 entries) +- rust-numpy: , +- Download badges: (`https://static.pepy.tech/badge//month`) +- PyO3 101 workshop: , + , + , + +- Official tutorials: , , + +- Local ground truth: `Working/phase-01-ground-truth.md` (versions, benchmark, linker + and `CONDA_PREFIX` observations) diff --git a/instructor-notes/rust-pyo3-maturin-deep-dive.md b/instructor-notes/rust-pyo3-maturin-deep-dive.md new file mode 100644 index 0000000..6f2b331 --- /dev/null +++ b/instructor-notes/rust-pyo3-maturin-deep-dive.md @@ -0,0 +1,705 @@ +--- +type: reference +title: Rust, PyO3, and Maturin - Instructor Deep Dive +created: 2026-07-10 +tags: + - rust + - pyo3 + - maturin + - pixi + - teaching +related: + - '[[pyo3-current-state]]' + - '[[maturin-workflows]]' + - '[[pixi-rust-toolchain]]' + - '[[rust-python-landscape]]' +--- + +Instructor reference for the 30-minute Rust/PyO3/maturin segment built around +`examples/2_04_rust_pyo3/`. This synthesizes the four Phase 02 research notes - +[[pyo3-current-state]], [[maturin-workflows]], [[pixi-rust-toolchain]], and +[[rust-python-landscape]] - into the background an instructor needs to teach the +segment and field questions beyond it. Detailed sourcing lives in those notes; this +document cites them by wiki-link and adds URLs only for claims introduced here. +Everything marked "verified locally" was observed in the Phase 01/02 environment +(rust 1.97.0, maturin 1.14.1, Python 3.14.6, PyO3 0.29.0, linux-64). + +## How CPython native extensions work + +The single most demystifying fact for attendees: **a compiled extension module is +just a shared library with one well-known entry point.** Nothing about it is +Python-specific until the interpreter loads it. + +### The import machinery finds a shared library + +When Python executes `import pyo3_example`, the finder walks `sys.path` looking for +a match against the extension suffixes the interpreter was built with. Verified +locally in the workshop env: + +```text +>>> importlib.machinery.EXTENSION_SUFFIXES +['.cpython-314-x86_64-linux-gnu.so', '.abi3.so', '.so'] +``` + +- On Linux and macOS the file is a `.so`; on Windows it is a `.pyd` (a DLL with a + different extension). Source: . +- The long first suffix is the interesting one: it encodes interpreter version + (`cpython-314`), architecture, and platform - the filename itself is an ABI + contract. The workshop build produces exactly + `pyo3_example.cpython-314-x86_64-linux-gnu.so` (verified locally in + `site-packages/pyo3_example/`). +- `.abi3.so` is the stable-ABI escape hatch (below); bare `.so` is the legacy + fallback. + +### `PyInit_`: the one required symbol + +Having found the file, CPython `dlopen()`s it and looks up a single exported C +symbol named `PyInit_`. Verified locally on the built extension: + +```text +$ nm -D pyo3_example.cpython-314-x86_64-linux-gnu.so | grep PyInit +000000000001fce0 T PyInit_pyo3_example +``` + +That function hands CPython the module definition. Since PEP 489 (multi-phase +initialization, used by PyO3's `#[pymodule]` as of 0.28 - see +[[pyo3-current-state]]) it returns a definition with initialization *slots* rather +than a fully constructed module, which is part of what makes per-interpreter and +free-threaded support tractable. +Sources: , +. + +Two teaching consequences: + +1. **The name must match.** `PyInit_pyo3_example` is derived from the module name; + if the `#[pymodule]` name, `[lib] name` in `Cargo.toml`, and (in mixed layouts) + the last segment of `[tool.maturin] module-name` disagree, the import fails with + `ImportError: dynamic module does not define module export function` - see the + troubleshooting table. +2. **Any language that speaks the C ABI can play.** The contract is "export one C + symbol, call C API functions". C, C++, Cython-generated C, Fortran wrappers - + and Rust, whose `cdylib` crate type plus `extern "C"` functions produce exactly + such a library. PyO3's `#[pymodule]` macro writes the `PyInit_` glue so nobody + in the room ever types it. + +One more locally verified detail worth showing: even for a pure-Rust project, +maturin installs a real Python *package* wrapping the binary module: + +```python +# site-packages/pyo3_example/__init__.py (generated by maturin) +from .pyo3_example import * + +__doc__ = pyo3_example.__doc__ +if hasattr(pyo3_example, "__all__"): + __all__ = pyo3_example.__all__ +``` + +This is why `import pyo3_example` looks completely ordinary from the REPL. + +### Why ABI compatibility matters + +The CPython C API's structures and function signatures change between minor +versions, so a module compiled against 3.14 headers cannot safely load into 3.13 - +and the suffix/tag machinery makes sure it is never even found. This is exactly +what wheel platform tags encode: the Phase 01 build is +`pyo3_example-0.1.0-cp314-cp314-linux_x86_64.whl` - CPython 3.14 only, this +platform only (see [[maturin-workflows]] for the distribution consequences). + +The escape hatch is the **stable ABI** (PEP 384): a restricted subset of the C API +whose binary interface is guaranteed forward-compatible, giving `abi3` wheels that +run on every CPython at or above a floor version. Its costs and the free-threaded +successor (`abi3t`, PEP 803) are covered in [[pyo3-current-state]]; the wheel-tag +mechanics in [[maturin-workflows]]. Free-threaded interpreters are a separate ABI +family again (`cp314t`) - one reason the workshop example deliberately builds +version-specific and treats abi3 as a discussion topic. +Sources: , . + +## The PyO3 mental model for Pythonistas + +The pitch to attendees: PyO3 maps Python's *runtime* object rules onto Rust's +*compile-time* checking, so whole classes of classic C-extension bugs become +compile errors. Full API details in [[pyo3-current-state]]; this is the conceptual +frame. + +### Ownership and borrowing ↔ reference counting + +Python manages object lifetime by counting references at runtime. Rust tracks +ownership at compile time: every value has exactly one owner, others may *borrow* +it temporarily, and the compiler proves the bookkeeping. PyO3 joins the two: + +- **`Bound<'py, T>`** is an owned smart pointer to a Python object: holding one + means holding one strong reference. `.clone()` increments the refcount; dropping + it decrements. The forgotten-`Py_DECREF` leak and the double-`Py_DECREF` + segfault - the two canonical C extension bugs - are simply not expressible. +- **`Py`** is the lifetime-free sibling for *storing* a Python object inside a + Rust struct; it must be re-bound with a `Python<'py>` token before use. +- The `'py` lifetime on `Bound` is what ties every object handle to an attached + thread (next section), so "touched a Python object while not attached to the + interpreter" is a compile error, not a Tuesday-afternoon segfault. This is the + borrow checker earning its keep in one sentence. + +### The `Python<'py>` token + +`Python<'py>` is a zero-sized value whose existence proves the current thread may +touch the interpreter (on GIL builds: holds the GIL). Inside `#[pyfunction]` / +`#[pymethods]` code you get it by declaring a `py: Python<'_>` parameter - PyO3 +injects it, costing nothing - exactly what the example's `count_primes` does. From +a spawned Rust thread you would use `Python::attach(|py| …)`. See +[[pyo3-current-state]] for the 0.26 renaming story (`with_gil` → `attach`). + +### Conversions at the boundary + +- **Arguments** arrive via the `FromPyObject` trait (`extract()` under the hood): + Python `int` → `u64`/`usize`/`i64` (raising `OverflowError` if it doesn't fit), + `float` → `f64`, `str` → `String`/`&str`, `list` → `Vec`, `dict` → + `HashMap`, `None` → `Option`. +- **Return values** leave via `IntoPyObject` - the same table in reverse. +- Collection conversions **copy element-by-element**; that cost is the root of the + "chatty boundary" performance trap analyzed in [[rust-python-landscape]] and the + performance section below. +- Trait history and the 0.27 borrowing rework are in [[pyo3-current-state]] - + relevant to instructors only for dating stale snippets. + +### `PyResult` → exceptions, and panics + +`PyResult` is just `Result`. Returning `Err(...)` raises a real +Python exception at the call site; the example's `checked_div` returns +`Err(PyZeroDivisionError::new_err("division by zero"))` and Python sees an +ordinary `ZeroDivisionError`. Every built-in exception type has a `Py*` twin, and +custom exception types can be created. + +Rust *panics* (out-of-bounds index, explicit `panic!`, failed `unwrap()`) do not +crash the interpreter: PyO3 catches the unwind at the FFI boundary and raises +`pyo3.PanicException`, which deliberately derives from `BaseException` so a +routine `except Exception:` won't swallow what is, by definition, a bug. +Source: . + +### What `#[pyclass]` generates under the hood + +`#[pyclass]` on the example's `Point` struct expands to a complete CPython type +object - the same artifact a C extension author would hand-build: + +- The instance layout is a normal Python object header with the Rust struct stored + inline, plus a borrow-tracking flag (unless the class is `frozen`). +- `#[pymethods]` fills the method table; dunder methods land in the corresponding + type slots (`__repr__` → `tp_repr`), so they are *real* protocol + implementations, not lookups. +- `#[new]` becomes `__new__`; `#[pyo3(get)]` generates getter descriptors (the + example exposes `x` and `y` read-only this way). +- `&self` vs `&mut self` receivers are mediated by **runtime borrow checking** + (`RefCell`-style): Rust's aliasing rules can't be proven statically across the + FFI boundary, so PyO3 counts borrows at runtime and raises or panics on + conflicts. `#[pyclass(frozen)]` removes both mutability and that overhead. +- Every pyclass must be `Send + Sync` (compile-enforced; `#[pyclass(unsendable)]` + waives `Send`, but such classes panic when touched from another thread - a dead + end for free-threading) - the hook that makes the free-threading story below + credible. + +The full option menu (`eq`, `str`, `hash`, inheritance, enums, …) is cataloged in +[[pyo3-current-state]]; `Point` intentionally uses almost none of it. + +## GIL deep dive + +Vocabulary first: since PyO3 0.26 the API says **attach/detach** rather than +acquire/release-the-GIL, because on free-threaded builds there is no GIL - what a +thread really does is attach to or detach from an interpreter thread state. Older +docs and answers say `with_gil`/`allow_threads`; the rename table is in +[[pyo3-current-state]]. + +### Acquiring (attaching) + +- Code inside `#[pyfunction]`/`#[pymethods]` is *already attached* - Python called + you. Declaring `py: Python<'_>` just materializes the proof. +- Pure Rust threads attach with `Python::attach(|py| …)`. Attaching when already + attached is fine. + +### Releasing (detaching): the example's own pattern + +`count_primes` wraps its hot loop in `py.detach(|| { … })` so other Python threads +keep running during the computation. The closure must not touch Python objects - +and cannot: PyO3 bounds the closure's captures by the `Ungil` marker, so trying to +smuggle a `Bound<'py, T>` inside is a **compile error**. This is the moment to +show attendees that "you must not use Python objects while the GIL is released" - +a runbook rule C extension authors memorize - is a type-system rule in Rust. + +Rules of thumb worth stating verbatim in the session: + +1. Detach around CPU-heavy pure-Rust work (the example). +2. Detach around anything *blocking* - channel receives, `Mutex` waits, network + calls, `JoinHandle::join`. An attached thread that blocks stalls every Python + thread (GIL builds) and stalls stop-the-world operations (free-threaded). + +### Deadlock pitfalls + +The classic shape is lock-ordering between the interpreter and a Rust lock: + +- Thread A, attached, tries to take `Mutex` M. +- Thread B holds M and calls `Python::attach(…)`, which waits for A. +- Nobody progresses. + +Mitigations, in preference order: don't hold Rust locks across Python calls; take +contended locks only while detached; or use PyO3's deadlock-aware helpers - +`MutexExt::lock_py_attached`, `PyOnceLock` (successor of the deprecated +`GILOnceCell`) - cataloged in [[pyo3-current-state]]. For the workshop example +none of this arises (no shared mutable state), which is itself the design lesson. + +### Free-threaded CPython + +Status summary (details and sources in [[pyo3-current-state]]): + +- Supported since PyO3 0.23; **since 0.28 extension modules advertise + free-threaded support by default**, with `#[pymodule(gil_used = true)]` as the + opt-out. 0.29 targets 3.14t+ (3.14t via version-specific `cp314t` builds) and + adds the PEP 803 `abi3t` stable ABI, which CPython supports only from 3.15. +- Every `#[pyclass]` is `Sync` by requirement; classes with mutable state face + real concurrent borrows and may need locking. The Phase 02 audit verdict on the + example: `Point` is immutable after construction (two `f64` fields, copy-out + getters, no `&mut self` methods), so it is trivially safe with no opt-out. +- Teaching angle: PEP 703 makes Python genuinely concurrent at the moment Rust's + `Send`/`Sync` gives you a compiler that audits extension thread-safety. The two + developments are better together - a strong closing "why Rust" argument. + +## maturin in depth + +maturin (see [[maturin-workflows]] for full sourcing) is both the CLI the workshop +drives and the PEP 517 backend named in the example's `pyproject.toml`: + +```toml +[build-system] +requires = ["maturin>=1.14,<2.0"] +build-backend = "maturin" + +[tool.maturin] +features = ["pyo3/extension-module"] +``` + +### Command reference for the workshop + +Verified against maturin 1.14.1 (the env's version): + +| Command | What it does | Workshop relevance | +| ------- | ------------ | ------------------ | +| `maturin new` / `init` | scaffold a project (`-b pyo3`, `--mixed`, `--src`) | how the example was born; the 1.14 template omits `features = ["pyo3/extension-module"]`, restored by hand | +| `maturin develop` | build + install into the active env; **debug by default** | the inner loop; wrapped as `pixi run develop` | +| `maturin develop --release` | optimized inner loop | wrapped as `pixi run develop-release`; feeds the benchmark | +| `maturin build` | build wheels into `target/wheels/` (`-i` per interpreter, `--sdist`) | the distribution demo | +| `maturin sdist` | source distribution only | "the fallback for platforms you didn't build" | +| `maturin publish` / `upload` | build and/or upload to PyPI (tokens or trusted publishing) | mention only | +| `maturin generate-ci github` | emit a complete GitHub Actions release workflow | the answer to "and now how do I ship it?" | +| `maturin generate-stubs` | autogenerate `.pyi` type stubs | the answer to "what about type hints?" | +| `maturin list-python` | show interpreters maturin can find | debugging aid | + +Environment detection is the quiet star of the demo: maturin reads +`VIRTUAL_ENV`/`CONDA_PREFIX`, and because a pixi env *is* a conda env, `pixi run +maturin develop` just works - Phase 01 observed "🐍 Found CPython 3.14 at +…/.pixi/envs/default/bin/python" with zero configuration. + +### Editable installs and the book's through-line + +maturin is a full PEP 660 backend: `pip install -e .` on this project is an +editable install, same standard the book teaches for pure Python and +scikit-build-core. The backend-specific ergonomics differ: Python-side edits in +mixed layouts take effect immediately, **Rust edits always need a rebuild** +(`maturin develop` again), and the auto-rebuild analogue of scikit-build-core's +`editable.rebuild` is the separate `maturin-import-hook` package (dev-only, +sitecustomize-based). Positioning and caveats in [[maturin-workflows]]; the +callback to `content/scikit-build/04_editable_installs.md` lands well with this +audience. + +### Layouts + +The example is the **pure-Rust** layout: `Cargo.toml` + `pyproject.toml` + +`src/lib.rs`, with maturin generating the wrapper package (shown above). Real +projects usually grow into the **mixed** layout - a Python package directory with +the compiled module tucked inside as a private submodule via +`module-name = "pkg._pkg"` - the pydantic-core convention +(`pydantic_core._pydantic_core`; see [[rust-python-landscape]] for real +`pyproject.toml` examples to show). `python-source = "python"` relocates the +Python tree. Teach pure; slide the mixed layout as "what you'll see in the wild." + +### Wheels: manylinux, abi3, and the demo's own wheel + +- Linux wheels for PyPI must be `manylinux*`/`musllinux*` tagged; **manylinux2014 + is the practical floor for Rust** (rustc needs glibc ≥2.17). maturin bundles an + auditwheel reimplementation and tags wheels correctly by itself; the + `ghcr.io/pyo3/maturin` container is the turnkey compliant build environment. +- **The built-in teaching hook (verified locally):** the wheel produced inside the + pixi env is tagged `linux_x86_64`, *not* manylinux - a conda env's glibc doesn't + satisfy the policy. It installs fine locally; PyPI would reject it. One + contrast slide gets attendees from "it works on my machine" to "this is why CI + builds release wheels." +- **abi3:** enabling PyO3's `abi3-py3X` feature makes maturin emit a + `cp3X-abi3` wheel - one wheel per platform for all CPython ≥ the floor, at some + API/performance cost (limits in [[pyo3-current-state]]). One build selects one + stable-ABI family; shipping both `abi3` and free-threaded `abi3t` wheels means + separate builds ([[maturin-workflows]]). + +### `generate-ci` vs cibuildwheel + +`maturin generate-ci github` emits a ready workflow: platform matrix, manylinux +images, sdist, optional pytest, and a PyPI release job. By default that job +publishes with an **API token** (`uv publish` reading +`UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}` - verified locally with maturin +1.14.1); **trusted publishing** (OIDC - no long-lived token in the repo) is opt-in +via `[tool.maturin.generate-ci.github] trusted-publishing = true` +([[maturin-workflows]]). cibuildwheel, which the book's C++ chapters +reference, also handles Rust but needs the toolchain provisioned per image. For a +maturin-only project, `generate-ci` is the lower-friction default; teams already +standardized on cibuildwheel can stay there. Details in [[maturin-workflows]]. + +## Choosing a backend: maturin vs setuptools-rust vs scikit-build-core + +Synthesized from [[maturin-workflows]]; this table is the slide. + +| Criterion | maturin | setuptools-rust | scikit-build-core | +| --------- | ------- | --------------- | ----------------- | +| Source of truth | cargo | setuptools + cargo plugin | CMake | +| PEP 517 backend | its own | setuptools | its own | +| Configuration | near-zero (`[tool.maturin]`) | `setup.py`/`pyproject.toml` + per-extension entries | `CMakeLists.txt` + `[tool.scikit-build]` | +| Editable installs | PEP 660; auto-rebuild via `maturin-import-hook` | setuptools editable machinery | `redirect`/`inplace` modes; built-in `editable.rebuild` | +| Extension modules per wheel | one crate per wheel (PyO3 submodules within it) | several (one `Cargo.toml` each) | whatever CMake builds | +| manylinux story | built-in auditwheel reimplementation + container image | external (cibuildwheel + auditwheel) | external (cibuildwheel + auditwheel) | +| Release CI scaffold | `generate-ci github` | bring your own | bring your own | +| Languages | Rust (+ cffi/uniffi/bin) | Rust into setuptools projects | C/C++/Fortran; Rust only via custom CMake | +| Best when | new pure-Rust extension (this workshop) | legacy setuptools project adding Rust incrementally | CMake is already the build's center of gravity | + +Rule of thumb to say out loud: *new pure-Rust → maturin; setuptools legacy + Rust +bolt-on → setuptools-rust; CMake/C/C++ world (the book's scikit-build chapters) → +scikit-build-core.* + +## The pixi/conda-forge angle + +Why this workshop installs Rust the unusual way (full analysis in +[[pixi-rust-toolchain]], differentiation argument in [[rust-python-landscape]]): + +- Every published PyO3 tutorial starts with per-OS rustup installation. Here, + Rust is **one line in `pixi.toml`**, resolved by the same solver, pinned by the + same lockfile, and installed by the same `pixi install` the book uses + everywhere else. Setup friction is what kills workshop segments. +- conda-forge's `rust` ships rustc, cargo, **and clippy + rustfmt** - the + components a rustup user would add by hand. Stable releases only, packaged + within days of upstream. The conda env prefix literally *is* the Rust sysroot. + +Exact versions the segment was validated against (Phase 01 ground truth; expect +these on a fresh `pixi install --frozen`): + +| Component | Version | Provenance | +| --------- | ------- | ---------- | +| rust | 1.97.0 | conda-forge, `pixi.lock` | +| maturin | 1.14.1 | conda-forge (= latest on PyPI) | +| Python | 3.14.6 (cp314) | conda-forge | +| pytest | 9.1.1 | conda-forge | +| pyo3 crate | 0.29.0 (latest) | crates.io, `Cargo.lock` | + +Platform caveats - the honest slide: + +| Platform | Linker story | Action needed | +| -------- | ------------ | ------------- | +| linux-64 | `rust` package pulls a full GNU toolchain and points cargo at the env's own gcc wrapper via an activation hook (verified in Phase 01 - no `compilers` package, first link succeeded) | none | +| osx-arm64 | conda `rust` ships no linker or hook; rustc drives the system `cc` | Xcode Command Line Tools (`xcode-select --install`) - inference from package metadata, verify on real hardware pre-workshop | +| win-64 | targets MSVC; conda-forge cannot ship `link.exe` | VS 2022 Build Tools + Windows 11 SDK, or WSL2 and the Linux path | + +The `compilers`/`c-compiler` metapackages stay out of scope until a `-sys` crate, +`cc`, or `bindgen` enters the dependency graph - rule and sources in +[[pixi-rust-toolchain]]. + +Reproducibility is a **two-lockfile model** worth naming explicitly: `pixi.lock` +pins the toolchain (rustc, maturin, Python, pytest - both declared platforms, +URL + sha256 per package) while `Cargo.lock` pins the crate graph (PyO3 0.29.0 and +friends). Commit both; the manifest keeps meaningful floors (`rust >=1.85` for +edition 2024 > PyO3's MSRV 1.83; `maturin >=1.9`; `python >=3.12`) and the +lockfiles do the exact pinning. Classroom provisioning: `pixi install --frozen`. + +## Performance guidance + +### Debug builds: the first thing to check, stated carefully + +`maturin develop` compiles **unoptimized by default**; the fix is encoded in the +example's task names (`develop` vs `develop-release`). State the warning +generally - "debug builds can lose to CPython" - but do not claim it for this +example: the ground-truth measurement shows this u64-division-bound loop at +0.102 s debug vs 0.085 s release, only ~1.2× apart, because the hot loop is +hardware-division-bound either way. Other workloads (allocation-heavy, iterator +chains, bounds-check-heavy indexing) can see 10-50× debug/release gaps, which is +where the "Rust is slow!" complaint almost always comes from. See +[[maturin-workflows]] and the troubleshooting table. + +### Release-profile knobs + +For the segment, `--release` is enough. For attendees who ask what production +projects do, the two standard `Cargo.toml` additions are: + +```toml +[profile.release] +lto = true # whole-program optimization across all crates +codegen-units = 1 # trade compile time for better codegen +``` + +(`opt-level = 3` is already the release default.) Source: +. maturin forwards +`--profile` for custom profiles and `--strip` to drop symbols. One caution: +**never set `panic = "abort"` in an extension module** - PyO3's +panic-to-`PanicException` mapping requires unwinding; with abort, any panic kills +the whole interpreter process. + +### Realistic expectations + +- Ground truth for this example: `count_primes(1_000_000)` pure Python ~1.63 s vs + Rust release ~0.085 s → **19-20×** (say "order of 20×"; exact digits vary + per machine and run). +- Branchy scalar Python loops moved wholesale into Rust: one to two orders of + magnitude is typical. Already-vectorized NumPy: expect parity, not victory - + the C loops are already there. Chatty per-element calls from a Python loop: + Rust can be *slower* than pure Python. The design rule that summarizes all + three: **move the loop into Rust, not the loop body.** Full analysis with + boundary conditions in [[rust-python-landscape]] - keep it adjacent to the + benchmark slide; the 20× claim is more persuasive with its limits attached. +- Profile before rewriting (`cProfile`, `py-spy`); an algorithmic fix in Python + routinely beats a constant-factor win in Rust. + +### When to reach for rust-numpy instead of scalar loops + +The moment the hot data is a NumPy array, hand-rolling `Vec` conversions +recreates the chatty boundary. The `numpy` crate (rust-numpy) exposes +`PyArray` with zero-copy views over the same buffer NumPy sees, converting +to/from the pure-Rust `ndarray` crate; versions pair minor-for-minor with PyO3 +(`pyo3 = "0.29"` ↔ `numpy = "0.29"`). Position it as the "where to go next" +pointer - the example stays scalar on purpose. Details in +[[rust-python-landscape]]. + +## Teaching runbook + +### Minute-by-minute plan (30-minute segment) + +Beats follow the page's section order (page section in quotes per row). + +| Time | Beat | Anchors and commands | +| ---- | ---- | -------------------- | +| 0:00-2:00 | Hook - "Why Rust?": "you ran Rust before your first coffee" | downloads table from [[rust-python-landscape]] (cryptography ~1 B/mo); the two-reason pitch: memory safety without a GC, a real package manager | +| 2:00-4:00 | "The stack, translated" | the five-row table; "maturin is just another PEP 517 backend" - callback to earlier chapters | +| 4:00-6:00 | "Setup with pixi" | `pixi.toml`: the compiler is one line in `[dependencies]`; the two-lockfile model (`pixi.lock` + `Cargo.lock`) | +| 6:00-9:00 | "A first extension": project tour + guided read of `sum_as_string` | `Cargo.toml` (11 lines, `cdylib`), `pyproject.toml`; `#[pymodule]`/`#[pyfunction]`, enforced types, `PyResult` | +| 9:00-12:00 | Live demo 1 - "Build and iterate" | `pixi run develop`; REPL: `sum_as_string(2, 3)`; what an extension module is: the installed `.so` filename, `nm -D … \| grep PyInit` flourish, filename-as-ABI-contract sets up wheels later; the edit → stale → rebuild rule | +| 12:00-15:00 | Live demo 2 - "How fast is it?" | `pixi run bench` → ~20×; while the release build runs, explain debug-vs-release | +| 15:00-17:00 | The honesty coda (still "How fast is it?") | where Rust does *not* help (I/O-bound, vectorized NumPy, chatty boundaries); "move the loop, not the loop body"; rust-numpy pointer | +| 17:00-19:00 | "Classes" | `Point`: `#[pyclass]`/`#[pymethods]`, `#[new]`, `#[pyo3(get)]`, `__repr__`; REPL: `Point(3.0, 4.0).magnitude()`, `repr(p)` | +| 19:00-21:00 | "Errors that feel native" | `checked_div` (Rust `Err` → Python `ZeroDivisionError`); REPL: `checked_div(1, 0)` traceback; panics → `PanicException` | +| 21:00-24:00 | "The GIL (and life without it)" | the `count_primes` source; `py.detach` and the compile-checked closure; free-threading + `Send`/`Sync` teaser | +| 24:00-27:00 | "Shipping wheels" | the demo wheel's `linux_x86_64` tag vs manylinux; abi3 one-wheel-per-platform; `maturin generate-ci`; backend-comparison table | +| 27:00-30:00 | "Try it yourself" + Q&A | the `is_prime`/`distance_to` exercise as homework; `maturin-import-hook`, `generate-stubs` pointers; this block is the overrun buffer | + +### Live-demo checklist + +- **Night before, on real network:** `pixi install --frozen` in + `examples/2_04_rust_pyo3/` - the conda-forge `rust` package alone is ~174 MB + compressed ([[pixi-rust-toolchain]]); never download it on venue Wi-Fi. +- **Warm both cargo caches:** run `pixi run test` *and* `pixi run bench` once - + that compiles debug and release trees, so on-stage "builds" are near-instant + no-op rebuilds. +- **Verify the env matches this document:** + `pixi list | grep -E 'rust|maturin|python|pytest'` against the versions table + above. +- **Terminal:** font ≥ 18 pt, short prompt, already `cd`'d into the example; + editor pre-opened on `lib.rs`, `Cargo.toml`, `pyproject.toml`. +- **REPL cheat sheet** (printed or second screen): the exact import/call lines + from the 9:00, 17:00, and 19:00 blocks, including the `checked_div(1, 0)` + traceback. +- **Benchmark hygiene:** AC power, notifications off, browsers closed; announce + "order of 20×," never exact digits. +- **Plan one live edit** (e.g. change the `__repr__` format string) to demo the + edit → stale behavior → `pixi run develop` → fixed cycle; it doubles as + teaching the most common real-world confusion (stale build). +- **Clean git state** in the example dir so any live edit reverts with one + `git checkout -- .`. +- **Disaster fallback:** screenshots of green `pixi run test` and the bench + output; a prebuilt wheel in `target/wheels/`. +- **Prework email:** macOS attendees run `xcode-select --install`; Windows + attendees set up WSL2 (or VS Build Tools); everyone runs `pixi install` + beforehand. + +### Anticipated attendee questions + +1. **"Do I need to install Rust with rustup first?"** No - `pixi install` + provisions rust 1.97.0 (clippy and rustfmt included) from conda-forge into the + project environment; no admin rights, no shell-profile changes + ([[pixi-rust-toolchain]]). +2. **"I rewrote my function in Rust and it's *slower*. How?"** Two usual + suspects: a debug build (run `pixi run develop-release`) or a chatty boundary + (a tiny Rust function called millions of times from a Python loop - move the + loop itself into Rust). +3. **"Can I pass NumPy arrays without copying?"** Yes - the rust-numpy `numpy` + crate gives zero-copy views; pick the version matching your PyO3 minor + (`0.29` ↔ `0.29`) ([[rust-python-landscape]]). +4. **"Do I have to rebuild after every Rust edit?"** Yes: `pixi run develop`. + Python-side files in mixed layouts don't need it. `maturin-import-hook` + automates rebuild-on-import for development environments + ([[maturin-workflows]]). +5. **"Do I really ship one wheel per Python version per platform?"** By default + yes (that's the `cp314` tag). PyO3's `abi3-py3X` features collapse that to one + wheel per platform for all CPython ≥ the floor, at some API/performance cost; + free-threaded builds need the separate `abi3t` family (CPython 3.15+; 3.14t + only gets version-specific `cp314t` wheels) ([[pyo3-current-state]]). +6. **"If the Rust code panics, does Python segfault?"** No - the panic unwinds to + the boundary and raises `pyo3.PanicException` (a `BaseException` subclass, so + `except Exception:` won't hide it). Actual undefined behavior requires + `unsafe`, and this example contains none. +7. **"Does this work with free-threaded (no-GIL) Python?"** Yes. PyO3 has + supported it since 0.23, and since 0.28 modules advertise support by default; + every `#[pyclass]` must be `Sync`, and mutable classes may need real locking + ([[pyo3-current-state]]). +8. **"Why maturin and not setuptools-rust or scikit-build-core?"** New pure-Rust + extension → maturin; legacy setuptools project adding Rust → setuptools-rust; + CMake/C/C++ codebase → scikit-build-core. See the comparison table. +9. **"How do I publish to PyPI - do I need a Mac and a Windows box?"** No: + `maturin generate-ci github` emits a matrix workflow using manylinux images + and trusted publishing; maturin can also cross-compile (zig, cargo-xwin) + ([[maturin-workflows]]). +10. **"The PyO3 tutorial I found online doesn't compile. Why?"** PyO3 ships a + breaking minor roughly quarterly. Snippets predating April 2024 use the + removed GIL-Refs API (`&PyAny`, `_bound` suffixes); 2025 snippets may use the + pre-rename `with_gil`/`allow_threads`. Check the post's date against the + cadence table in [[pyo3-current-state]]. +11. **"How do I get type hints and IDE autocompletion for the Rust module?"** + `maturin generate-stubs` writes `.pyi` stubs; for Rust-side IDE support, let + the editor install rust-analyzer - it drives the conda toolchain fine + ([[pixi-rust-toolchain]]). +12. **"Can I write async functions?"** Experimentally - `async fn` in + `#[pyfunction]`/`#[pymethods]` sits behind PyO3's `experimental-async` + feature. For production async interop today, look at the `pyo3-async-runtimes` + ecosystem (unverified currency - check before recommending). +13. **"Is 20× typical?"** For branchy scalar loops, one-to-two orders of + magnitude is common; against vectorized NumPy expect parity; against I/O + waits expect nothing. Profile first ([[rust-python-landscape]]). + +### Troubleshooting table + +| Symptom | Likely cause | Fix | +| ------- | ------------ | --- | +| `error: linker 'cc' not found` (macOS) | Xcode Command Line Tools missing - conda's `rust` brings its own linker only on Linux | `xcode-select --install`, retry ([[pixi-rust-toolchain]]) | +| `link.exe not found` (Windows) | MSVC Build Tools absent; conda-forge cannot ship them | install VS 2022 Build Tools + Windows 11 SDK, or use WSL2 | +| maturin: "Couldn't find a virtualenv or conda environment" | command run outside the pixi env, so `CONDA_PREFIX` is unset | prefix with `pixi run …` or enter `pixi shell` | +| "Rust is slower than Python!" | debug build - `maturin develop` default | `pixi run develop-release`, re-run the benchmark | +| `ImportError: dynamic module does not define module export function (PyInit_…)` | import name mismatch: `#[pymodule]` name ≠ `[lib] name` ≠ `module-name` last segment | make the names agree; rebuild | +| `ModuleNotFoundError: No module named 'pyo3_example'` | `develop` never ran, or python is not the pixi env's interpreter | `pixi run develop`; always launch python via `pixi run` | +| Rust edits "don't do anything" | stale build - the installed `.so` predates the edit | rerun `pixi run develop` (or install `maturin-import-hook` in dev envs) | +| PyPI rejects the wheel: unsupported tag `linux_x86_64` | wheel built inside the conda env is not manylinux-compliant | build in the `ghcr.io/pyo3/maturin` container or via `generate-ci` ([[maturin-workflows]]) | +| First build takes minutes | cold cargo cache in a fresh env | expected once; warm the cache before demos (checklist above) | + +## Glossary: Rust terms for Python developers + +Every Rust term that appears in the example code or this section, translated. + +### Language and syntax + +- **crate** - Rust's unit of compilation and distribution; roughly a Python + package. The example is one crate named `pyo3_example`. +- **edition** (`edition = "2024"`) - an opt-in language-rules snapshot a crate + declares once; think `from __future__ import …`, project-wide. Not the compiler + version. +- **`mod`** - a namespace inside a crate; like a Python module but declared + explicitly rather than implied by a file (`#[pymodule] mod pyo3_example`). +- **`use`** - brings names into scope; `use pyo3::prelude::*` is the idiomatic + `from pyo3.prelude import *` (preludes are designed for glob import). +- **`fn`** - `def`. +- **`let` / `mut`** - variable binding; **immutable by default**, `mut` opts into + mutation - the inverse of Python's default. +- **`struct`** - a record type with named fields and no inheritance; closest to a + `@dataclass`. +- **`impl`** - the block that attaches methods to a type; data (`struct`) and + behavior (`impl`) are declared separately. +- **`&self` / `Self`** - method receiver borrowed read-only (`self` without the + right to mutate); `Self` is "the current type," like writing the class name. +- **closure** (`|| { … }`) - anonymous function; like `lambda` but allowing full + statement bodies. `count_primes` passes one to `py.detach`. +- **attribute** (`#[pyfunction]`, `#[pyclass]`) - looks like a decorator, but + runs at *compile time*, generating code before the program exists. +- **macro** (`format!`, `panic!` - note the `!`) - compile-time code expansion; + `format!` is the f-string equivalent. +- **implicit return** - blocks are expressions; the last expression without a + trailing `;` is the value. That is how `count` leaves the `detach` closure with + no `return` in sight. +- **range** (`2..limit`) - `range(2, limit)`, half-open just like Python's. + +### Types and error handling + +- **`u64` / `usize` / `f64`** - unsigned 64-bit integer; pointer-sized unsigned + integer (indexing/sizes); 64-bit float (exactly Python's `float`). Python's + `int` is arbitrary-precision - extraction into a fixed-width type raises + `OverflowError` when it doesn't fit. +- **`String` / `&str`** - owned string / borrowed string view; both surface as + `str` across the boundary. +- **`Result` / `Ok` / `Err`** - errors as return values instead of raised + exceptions; the compiler forces callers to handle them. `PyResult` is + `Result`, and returning `Err` raises in Python. +- **`Option`** - a value or `None`; `Optional[T]` with compiler enforcement. +- **`Bound<'py, T>` / `Py`** - PyO3's smart pointers to Python objects: the + attached, lifetime-carrying handle and the storable, lifetime-free one. +- **panic** - Rust's "this is a bug" failure (failed `unwrap()`, out-of-bounds + index); unwinds the stack like an exception but is not meant to be caught in + normal flow. PyO3 converts it to `pyo3.PanicException`. +- **`unsafe`** - keyword marking a block where the programmer, not the compiler, + guarantees memory safety. Grep-able, auditable, and absent from this example. + +### Concepts + +- **ownership / move** - every value has exactly one owner; assignment transfers + ownership by default. The compile-time analogue of "there is one refcount and + I hold it." +- **borrowing / borrow checker** - temporary references: many readers (`&T`) or + one writer (`&mut T`), never both; the compiler proves it. The reason data + races are compile errors. +- **lifetime** (`'py`, `'_`) - a named "valid for this long" annotation on + references; `'py` ties Python-object handles to an attached thread state. +- **trait** - an interface a type implements; between an ABC and a + `typing.Protocol`. `FromPyObject`, `Send`, and `Sync` are traits. +- **`Send` / `Sync`** - compiler-verified thread-safety markers: safe to move to + / share between threads. Why "is this extension free-threading safe?" is a + question the Rust compiler helps answer. +- **smart pointer** - a value that behaves like a reference and runs logic on + drop (e.g. decrementing a refcount); `Bound` is one. +- **FFI / `extern "C"`** - the foreign-function interface; declaring the C + calling convention so CPython (a C program) can call into the Rust library. + +### Toolchain and build + +- **Cargo / `Cargo.toml` / `Cargo.lock`** - Rust's package manager and build + tool; the manifest (role of `pyproject.toml`) and the exact-version lockfile + (role of `pixi.lock`) - see the two-lockfile model above. +- **rustc** - the compiler itself; normally driven by cargo, not invoked by hand. +- **cdylib** - "C dynamic library" crate type: a shared library exposing C-ABI + symbols - precisely what a CPython extension must be. +- **debug / release profile** - unoptimized-fast-compile vs optimized build; + selected by `--release` (see performance section). +- **LTO / codegen-units** - release-profile optimization knobs (whole-program + optimization; codegen parallelism traded for quality). +- **clippy / rustfmt** - linter and formatter (`ruff check` / `ruff format` + equivalents); included in the conda-forge `rust` package. +- **MSRV** - minimum supported Rust version of a library (PyO3 0.29: 1.83). +- **target triple** (`x86_64-unknown-linux-gnu`) - the platform identifier, + cousin of a wheel's platform tag. +- **sysroot** - where the compiler finds its standard libraries; in a pixi env, + the env prefix itself ([[pixi-rust-toolchain]]). + +## Further reading + +Curated, in the order an interested attendee should read them: + +- The PyO3 user guide - (start with getting-started, then the + class and module chapters; the free-threading chapter when relevant). +- The maturin user guide and tutorial - . +- *The Rust Programming Language* ("the Book") - + (chapters 1-10 cover everything the example uses). +- Rustlings, hands-on exercises - . +- rust-numpy - (the NumPy interop path). +- The PyO3 changelog - (indispensable for + dating stale snippets; see the cadence table in [[pyo3-current-state]]). +- Cargo profiles reference - . +- "PyO3 101" workshop materials (Cheuk Ting Ho, MIT-licensed) - + (the half-day companion to this + 30-minute segment; landscape review in [[rust-python-landscape]]). +- CPython extending/embedding docs - . +- PEPs behind this section: 384 (stable ABI), 425 (wheel tags), 489 (multi-phase + init), 517/660 (build backends, editable installs), 703 (free-threading), 803 + (`abi3t`) - all at . +- The four sibling research notes: [[pyo3-current-state]], + [[maturin-workflows]], [[pixi-rust-toolchain]], [[rust-python-landscape]]. diff --git a/myst.yml b/myst.yml index b68c171..6e893a8 100644 --- a/myst.yml +++ b/myst.yml @@ -19,9 +19,10 @@ project: - file: content/basic-packaging/05_publishing_ci.md - title: Compiled children: - - file: content/compiled/01_package.md + - file: content/compiled/01_compiled.md - file: content/compiled/02_binding.md - - file: content/compiled/03_cibuildwheel.md + - file: content/compiled/03_rust.md + - file: content/compiled/04_cibuildwheel.md - title: Scikit Build children: - file: content/scikit-build/01_custom.md diff --git a/pixi.lock b/pixi.lock index 4b44e2f..8fed59b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,1300 +1,1300 @@ version: 7 platforms: -- name: linux-64 - virtual-packages: - - __unix=0=0 - - __linux=4.18 - - __glibc=2.28 - - __archspec=0=x86_64 -- name: osx-arm64 - virtual-packages: - - __unix=0=0 - - __osx=13.0 - - __archspec=0=m1 + - name: linux-64 + virtual-packages: + - __unix=0=0 + - __linux=4.18 + - __glibc=2.28 + - __archspec=0=x86_64 + - name: osx-arm64 + virtual-packages: + - __unix=0=0 + - __osx=13.0 + - __archspec=0=m1 environments: default: channels: - - url: https://conda.anaconda.org/conda-forge/ + - url: https://conda.anaconda.org/conda-forge/ packages: linux-64: - - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/bun-1.3.11-h560442b_4.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/hdrhistogram-c-0.11.9-h421ea60_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libarchive-3.8.8-gpl_hc2c16d8_100.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/lol-html-3.0.0-hb17b654_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ls-hpack-2.3.5-h280c20c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/lzo-2.10-h280c20c_1002.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.2.1-he2c55a7_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_100_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mystmd-1.10.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bun-1.3.11-h560442b_4.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/hdrhistogram-c-0.11.9-h421ea60_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarchive-3.8.8-gpl_hc2c16d8_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lol-html-3.0.0-hb17b654_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ls-hpack-2.3.5-h280c20c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lzo-2.10-h280c20c_1002.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.2.1-he2c55a7_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mystmd-1.10.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mystmd-1.10.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bun-1.3.11-h5389c0c_4.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdrhistogram-c-0.11.9-h132b30e_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h2062a1b_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarchive-3.8.8-gpl_h6fbacd7_100.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-ha332bbd_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.3-h1b79a29_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lol-html-3.0.0-h6fdd925_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ls-hpack-2.3.5-h1a92334_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h925e9cb_1002.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.5.0-h00e74ec_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_100_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mystmd-1.10.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bun-1.3.11-h5389c0c_4.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdrhistogram-c-0.11.9-h132b30e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h2062a1b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarchive-3.8.8-gpl_h6fbacd7_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-ha332bbd_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.3-h1b79a29_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lol-html-3.0.0-h6fdd925_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ls-hpack-2.3.5-h1a92334_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h925e9cb_1002.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.5.0-h00e74ec_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_100_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda packages: -- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - build_number: 20 - sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 - md5: a9f577daf3de00bca7c3c76c0ecbd1de - depends: - - __glibc >=2.17,<3.0.a0 - - libgomp >=7.5.0 - constrains: - - openmp_impl <0.0a0 - license: BSD-3-Clause - license_family: BSD - run_exports: - strong: - - _openmp_mutex >=4.5 - size: 28948 - timestamp: 1770939786096 -- conda: https://conda.anaconda.org/conda-forge/linux-64/bun-1.3.11-h560442b_4.conda - sha256: 93467a42c74e34738d771ae518189ac4db1c8cfbd6ea30276a86ae7062f057ad - md5: b31a44395580c7cbf2de971faf23d7f7 - depends: - - libstdcxx >=15 - - libgcc >=15 - - __glibc >=2.28,<3.0.a0 - - libsqlite >=3.53.2,<4.0a0 - - ls-hpack >=2.3.5,<2.3.6.0a0 - - zstd >=1.5.7,<1.6.0a0 - - libdeflate >=1.25,<1.26.0a0 - - libzlib >=1.3.2,<2.0a0 - - libarchive >=3.8.7,<3.9.0a0 - - hdrhistogram-c >=0.11.9,<0.11.10.0a0 - - c-ares >=1.34.6,<2.0a0 - - icu >=75.1,<76.0a0 - - lol-html >=3.0.0,<3.0.1.0a0 - - libbrotlicommon >=1.2.0,<1.3.0a0 - - libbrotlienc >=1.2.0,<1.3.0a0 - - libbrotlidec >=1.2.0,<1.3.0a0 - - libhwy >=1.4.0,<1.5.0a0 - license: MIT - license_family: MIT - run_exports: {} - size: 17940522 - timestamp: 1780924533437 -- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda - sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 - md5: d2ffd7602c02f2b316fd921d39876885 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: bzip2-1.0.6 - license_family: BSD - run_exports: - weak: - - bzip2 >=1.0.8,<2.0a0 - size: 260182 - timestamp: 1771350215188 -- conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda - sha256: cc9accf72fa028d31c2a038460787751127317dcfa991f8d1f1babf216bb454e - md5: 920bb03579f15389b9e512095ad995b7 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: MIT - license_family: MIT - run_exports: - weak: - - c-ares >=1.34.6,<2.0a0 - size: 207882 - timestamp: 1765214722852 -- conda: https://conda.anaconda.org/conda-forge/linux-64/hdrhistogram-c-0.11.9-h421ea60_1.conda - sha256: 04363c6cd15b7d9e48b622e196471b725630c84e82bfa7a4adbf36f5e94a2c06 - md5: 5160b2491c798ef0f3e883485c526e42 - depends: - - libgcc >=14 - - __glibc >=2.17,<3.0.a0 - - libzlib >=1.3.1,<2.0a0 - license: BSD-2-Clause OR CC0-1.0 - run_exports: - weak: - - hdrhistogram-c >=0.11.9,<0.11.10.0a0 - size: 38736 - timestamp: 1769816263111 -- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda - sha256: 71e750d509f5fa3421087ba88ef9a7b9be11c53174af3aa4d06aff4c18b38e8e - md5: 8b189310083baabfb622af68fd9d3ae3 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc-ng >=12 - - libstdcxx-ng >=12 - license: MIT - license_family: MIT - run_exports: - weak: - - icu >=75.1,<76.0a0 - size: 12129203 - timestamp: 1720853576813 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda - sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c - md5: 18335a698559cdbcd86150a48bf54ba6 - depends: - - __glibc >=2.17,<3.0.a0 - - zstd >=1.5.7,<1.6.0a0 - constrains: - - binutils_impl_linux-64 2.45.1 - license: GPL-3.0-only - license_family: GPL - run_exports: {} - size: 728002 - timestamp: 1774197446916 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda - sha256: dcd1429a1782864c452057a6c5bc1860f2b637dc20a2b7e6eacd57395bbceff8 - md5: 83b160d4da3e1e847bf044997621ed63 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - - libstdcxx >=13 - constrains: - - libabseil-static =20250512.1=cxx17* - - abseil-cpp =20250512.1 - license: Apache-2.0 - license_family: Apache - run_exports: - weak: - - libabseil >=20250512.1,<20250513.0a0 - - libabseil =*=cxx17* - size: 1310612 - timestamp: 1750194198254 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libarchive-3.8.8-gpl_hc2c16d8_100.conda - sha256: f916b51f55f51a9bb2d902e0a5f029490f7b745aee549c349751c1787ddc26b8 - md5: 44652e646cb623f486ea72e7e7479222 - depends: - - __glibc >=2.17,<3.0.a0 - - bzip2 >=1.0.8,<2.0a0 - - libgcc >=14 - - liblzma >=5.8.3,<6.0a0 - - libxml2 - - libxml2-16 >=2.14.6 - - libzlib >=1.3.2,<2.0a0 - - lz4-c >=1.10.0,<1.11.0a0 - - lzo >=2.10,<3.0a0 - - openssl >=3.5.7,<4.0a0 - - zstd >=1.5.7,<1.6.0a0 - license: BSD-2-Clause - license_family: BSD - run_exports: - weak: - - libarchive >=3.8.8,<3.9.0a0 - size: 867280 - timestamp: 1782289011634 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda - sha256: 318f36bd49ca8ad85e6478bd8506c88d82454cc008c1ac1c6bf00a3c42fa610e - md5: 72c8fd1af66bd67bf580645b426513ed - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: MIT - license_family: MIT - run_exports: - weak: - - libbrotlicommon >=1.2.0,<1.3.0a0 - size: 79965 - timestamp: 1764017188531 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda - sha256: 12fff21d38f98bc446d82baa890e01fd82e3b750378fedc720ff93522ffb752b - md5: 366b40a69f0ad6072561c1d09301c886 - depends: - - __glibc >=2.17,<3.0.a0 - - libbrotlicommon 1.2.0 hb03c661_1 - - libgcc >=14 - license: MIT - license_family: MIT - run_exports: - weak: - - libbrotlidec >=1.2.0,<1.3.0a0 - size: 34632 - timestamp: 1764017199083 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda - sha256: a0c15c79997820bbd3fbc8ecf146f4fe0eca36cc60b62b63ac6cf78857f1dd0d - md5: 4ffbb341c8b616aa2494b6afb26a0c5f - depends: - - __glibc >=2.17,<3.0.a0 - - libbrotlicommon 1.2.0 hb03c661_1 - - libgcc >=14 - license: MIT - license_family: MIT - run_exports: - weak: - - libbrotlienc >=1.2.0,<1.3.0a0 - size: 298378 - timestamp: 1764017210931 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda - sha256: aa8e8c4be9a2e81610ddf574e05b64ee131fab5e0e3693210c9d6d2fba32c680 - md5: 6c77a605a7a689d17d4819c0f8ac9a00 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: MIT - license_family: MIT - run_exports: - weak: - - libdeflate >=1.25,<1.26.0a0 - size: 73490 - timestamp: 1761979956660 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda - sha256: 1cd6048169fa0395af74ed5d8f1716e22c19a81a8a36f934c110ca3ad4dd27b4 - md5: 172bf1cd1ff8629f2b1179945ed45055 - depends: - - libgcc-ng >=12 - license: BSD-2-Clause - license_family: BSD - run_exports: - weak: - - libev >=4.33,<4.34.0a0 - size: 112766 - timestamp: 1702146165126 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda - sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 - md5: b24d3c612f71e7aa74158d92106318b2 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - constrains: - - expat 2.8.1.* - license: MIT - license_family: MIT - run_exports: {} - size: 77856 - timestamp: 1781203599810 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda - sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 - md5: a360c33a5abe61c07959e449fa1453eb - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: MIT - license_family: MIT - run_exports: - weak: - - libffi >=3.5.2,<3.6.0a0 - size: 58592 - timestamp: 1769456073053 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda - sha256: 8e0a3b5e41272e5678499b5dfc4cddb673f9e935de01eb0767ce857001229f46 - md5: 57736f29cc2b0ec0b6c2952d3f101b6a - depends: - - __glibc >=2.17,<3.0.a0 - - _openmp_mutex >=4.5 - constrains: - - libgcc-ng ==15.2.0=*_19 - - libgomp 15.2.0 he0feb66_19 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - run_exports: {} - size: 1041084 - timestamp: 1778269013026 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda - sha256: 9dcf54adfaa5e861123c2da4f2f0451a685464ea7e5a41ad91cf67b31d658d98 - md5: 331ee9b72b9dff570d56b1302c5ab37d - depends: - - libgcc 15.2.0 he0feb66_19 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - run_exports: - strong: - - libgcc - size: 27694 - timestamp: 1778269016987 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda - sha256: 5abe4ab9d93f6c9757d654f1969ae2267d4505315c1f2f8fe705fd60af084f1b - md5: faac990cb7aedc7f3a2224f2c9b0c26c - depends: - - __glibc >=2.17,<3.0.a0 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - run_exports: - strong: - - _openmp_mutex >=4.5 - size: 603817 - timestamp: 1778268942614 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda - sha256: 8b70955d5e9a49d08945d4f8e2eab855b2efa5fce9cb9bc5e75d86764e6f2f38 - md5: 3a9428b74c403c71048104d38437b48c - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libstdcxx >=14 - license: Apache-2.0 OR BSD-3-Clause - run_exports: - weak: - - libhwy >=1.4.0,<1.5.0a0 - size: 1435782 - timestamp: 1776989559668 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda - sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f - md5: 915f5995e94f60e9a4826e0b0920ee88 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: LGPL-2.1-only - run_exports: - weak: - - libiconv >=1.18,<2.0a0 - size: 790176 - timestamp: 1754908768807 -- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda - sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d - md5: b88d90cad08e6bc8ad540cb310a761fb - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - constrains: - - xz 5.8.3.* - license: 0BSD - run_exports: - weak: - - liblzma >=5.8.3,<6.0a0 - size: 113478 - timestamp: 1775825492909 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda - sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 - md5: 2c21e66f50753a083cbe6b80f38268fa - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: BSD-2-Clause - license_family: BSD - run_exports: {} - size: 92400 - timestamp: 1769482286018 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda - sha256: 663444d77a42f2265f54fb8b48c5450bfff4388d9c0f8253dd7855f0d993153f - md5: 2a45e7f8af083626f009645a6481f12d - depends: - - __glibc >=2.17,<3.0.a0 - - c-ares >=1.34.6,<2.0a0 - - libev >=4.33,<4.34.0a0 - - libev >=4.33,<5.0a0 - - libgcc >=14 - - libstdcxx >=14 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.5,<4.0a0 - license: MIT - license_family: MIT - run_exports: - weak: - - libnghttp2 >=1.68.1,<2.0a0 - size: 663344 - timestamp: 1773854035739 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda - sha256: 365376f4815e5e80def2b3462a2419708b7c292da0da85278386c2618621fff4 - md5: 4aed8e657e9ff156bdbe849b4df44389 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libzlib >=1.3.2,<2.0a0 - license: blessing - run_exports: - weak: - - libsqlite >=3.53.3,<4.0a0 - size: 962119 - timestamp: 1782519076616 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda - sha256: dff1058c76ec6b8759e41cefa2508162d00e4a5e6721aa68ec3fd10094e702dc - md5: 5794b3bdc38177caf969dabd3af08549 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc 15.2.0 he0feb66_19 - constrains: - - libstdcxx-ng ==15.2.0=*_19 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - run_exports: {} - size: 5852044 - timestamp: 1778269036376 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda - sha256: 0672b6b6e1791c92e8eccad58081a99d614fcf82bca5841f9dfa3c3e658f83b9 - md5: e5ce228e579726c07255dbf90dc62101 - depends: - - libstdcxx 15.2.0 h934c35e_19 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - run_exports: - strong: - - libstdcxx - size: 27776 - timestamp: 1778269074600 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda - sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f - md5: 01bb81d12c957de066ea7362007df642 - depends: - - libgcc >=14 - - __glibc >=2.17,<3.0.a0 - license: BSD-3-Clause - license_family: BSD - run_exports: - weak: - - libuuid >=2.42.2,<3.0a0 - size: 40017 - timestamp: 1781625522462 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda - sha256: e28e4519223f78b3163599ca89c3f2d80bfb53e907e7fc74e806e60d1efa578b - md5: 4e33d49bf4fc853855a3b00643aa5484 - depends: - - libgcc >=14 - - __glibc >=2.17,<3.0.a0 - license: MIT - license_family: MIT - run_exports: - weak: - - libuv >=1.52.1,<2.0a0 - size: 419935 - timestamp: 1779396012261 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda - sha256: 71436e72a286ef8b57d6f4287626ff91991eb03c7bdbe835280521791efd1434 - md5: e7733bc6785ec009e47a224a71917e84 - depends: - - __glibc >=2.17,<3.0.a0 - - icu >=75.1,<76.0a0 - - libgcc >=14 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.1,<6.0a0 - - libzlib >=1.3.1,<2.0a0 - constrains: - - libxml2 2.15.1 - license: MIT - license_family: MIT - run_exports: {} - size: 556302 - timestamp: 1761015637262 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda - sha256: ec0735ae56c3549149eebd7dc22c0bed91fd50c02eaa77ff418613ddda190aa8 - md5: e512be7dc1f84966d50959e900ca121f - depends: - - __glibc >=2.17,<3.0.a0 - - icu >=75.1,<76.0a0 - - libgcc >=14 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.1,<6.0a0 - - libxml2-16 2.15.1 ha9997c6_0 - - libzlib >=1.3.1,<2.0a0 - license: MIT - license_family: MIT - run_exports: - weak: - - libxml2 - - libxml2-16 >=2.15.1 - size: 45283 - timestamp: 1761015644057 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda - sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 - md5: d87ff7921124eccd67248aa483c23fec - depends: - - __glibc >=2.17,<3.0.a0 - constrains: - - zlib 1.3.2 *_2 - license: Zlib - license_family: Other - run_exports: - weak: - - libzlib >=1.3.2,<2.0a0 - size: 63629 - timestamp: 1774072609062 -- conda: https://conda.anaconda.org/conda-forge/linux-64/lol-html-3.0.0-hb17b654_0.conda - sha256: 852ae2072f6c8cfaf32db8a7f3d33d3d70b28f91af3137d5da0f12ba63a00749 - md5: f67d6bd8ed2470a0198dc7765d595bb2 - depends: - - libgcc >=14 - - __glibc >=2.17,<3.0.a0 - constrains: - - __glibc >=2.17 - license: BSD-3-Clause - license_family: BSD - run_exports: - weak: - - lol-html >=3.0.0,<3.0.1.0a0 - size: 482549 - timestamp: 1780399715936 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ls-hpack-2.3.5-h280c20c_0.conda - sha256: fe57537d4fd99845c9d94870e46fe78e0e7ace0343991dac765d55777da1e500 - md5: 5f8ace4f3cf7857ab7b80c328dc07622 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: MIT - license_family: MIT - run_exports: - weak: - - ls-hpack >=2.3.5,<2.3.6.0a0 - size: 99800 - timestamp: 1779602199229 -- conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda - sha256: 47326f811392a5fd3055f0f773036c392d26fdb32e4d8e7a8197eed951489346 - md5: 9de5350a85c4a20c685259b889aa6393 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - - libstdcxx >=13 - license: BSD-2-Clause - license_family: BSD - run_exports: - weak: - - lz4-c >=1.10.0,<1.11.0a0 - size: 167055 - timestamp: 1733741040117 -- conda: https://conda.anaconda.org/conda-forge/linux-64/lzo-2.10-h280c20c_1002.conda - sha256: 5c6bbeec116e29f08e3dad3d0524e9bc5527098e12fc432c0e5ca53ea16337d4 - md5: 45161d96307e3a447cc3eb5896cf6f8c - depends: - - libgcc >=14 - - __glibc >=2.17,<3.0.a0 - license: GPL-2.0-or-later - license_family: GPL - run_exports: - weak: - - lzo >=2.10,<3.0a0 - size: 191060 - timestamp: 1753889274283 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 - md5: fc21868a1a5aacc937e7a18747acb8a5 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: X11 AND BSD-3-Clause - run_exports: - weak: - - ncurses >=6.6,<7.0a0 - size: 918956 - timestamp: 1777422145199 -- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.2.1-he2c55a7_1.conda - sha256: 6516f99fe400181ebe27cba29180ca0c7425c15d7392f74220a028ad0e0064a2 - md5: d8005b3a90515c952b51026f6b7d005d - depends: - - __glibc >=2.28,<3.0.a0 - - libstdcxx >=14 - - libgcc >=14 - - zstd >=1.5.7,<1.6.0a0 - - c-ares >=1.34.6,<2.0a0 - - libuv >=1.51.0,<2.0a0 - - libsqlite >=3.51.1,<4.0a0 - - libnghttp2 >=1.67.0,<2.0a0 - - openssl >=3.5.4,<4.0a0 - - libabseil >=20250512.1,<20250513.0a0 - - libabseil * cxx17* - - libzlib >=1.3.1,<2.0a0 - - libbrotlicommon >=1.2.0,<1.3.0a0 - - libbrotlienc >=1.2.0,<1.3.0a0 - - libbrotlidec >=1.2.0,<1.3.0a0 - - icu >=75.1,<76.0a0 - license: MIT - license_family: MIT - run_exports: - weak: - - nodejs >=25.2.1,<26.0a0 - size: 17246248 - timestamp: 1765444698486 -- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda - sha256: d48f5c22b9897c01e4dff3680f1f57ceb02711ab9c62f74339b080419dfad34b - md5: 79dd2074b5cd5c5c6b2930514a11e22d - depends: - - __glibc >=2.17,<3.0.a0 - - ca-certificates - - libgcc >=14 - license: Apache-2.0 - license_family: Apache - run_exports: - weak: - - openssl >=3.6.3,<4.0a0 - size: 3159683 - timestamp: 1781069855778 -- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_100_cp314.conda - build_number: 100 - sha256: 6d28ac2b061179deb434d3d57afa98ffd20ec3c5d44ab8048a1ca33424b22d38 - md5: 0b9b2f83b5b600e1ac38becde8d0dd44 - depends: - - __glibc >=2.17,<3.0.a0 - - bzip2 >=1.0.8,<2.0a0 - - ld_impl_linux-64 >=2.36.1 - - libexpat >=2.8.1,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 - - liblzma >=5.8.3,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.53.2,<4.0a0 - - libuuid >=2.42.1,<3.0a0 - - libzlib >=1.3.2,<2.0a0 - - ncurses >=6.6,<7.0a0 - - openssl >=3.5.7,<4.0a0 - - python_abi 3.14.* *_cp314 - - readline >=8.3,<9.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - - zstd >=1.5.7,<1.6.0a0 - license: Python-2.0 - run_exports: - weak: - - python_abi 3.14.* *_cp314 - noarch: - - python - size: 36717183 - timestamp: 1781255094700 - python_site_packages_path: lib/python3.14/site-packages -- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda - sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 - md5: d7d95fc8287ea7bf33e0e7116d2b95ec - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - ncurses >=6.5,<7.0a0 - license: GPL-3.0-only - license_family: GPL - run_exports: - weak: - - readline >=8.3,<9.0a0 - size: 345073 - timestamp: 1765813471974 -- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac - md5: cffd3bdd58090148f4cfcd831f4b26ab - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libzlib >=1.3.1,<2.0a0 - constrains: - - xorg-libx11 >=1.8.12,<2.0a0 - license: TCL - license_family: BSD - run_exports: - weak: - - tk >=8.6.13,<8.7.0a0 - size: 3301196 - timestamp: 1769460227866 -- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 - md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 - depends: - - __glibc >=2.17,<3.0.a0 - - libzlib >=1.3.1,<2.0a0 - license: BSD-3-Clause - license_family: BSD - run_exports: - weak: - - zstd >=1.5.7,<1.6.0a0 - size: 601375 - timestamp: 1764777111296 -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - sha256: f8e3c730fa14ee3f170493779f06522c4acf89169f43db4f039727709b6419cf - md5: a9965dd99f683c5f444428f896635716 - depends: - - __unix - license: ISC - run_exports: {} - size: 128866 - timestamp: 1781708962055 -- conda: https://conda.anaconda.org/conda-forge/noarch/mystmd-1.10.1-pyhcf101f3_0.conda - sha256: b4f7643a7c33f062f9ade71411e66d6fe30a70c9e93631c5091e553e15a1a63b - md5: 21d74fa7f9fcc516065990fb22ae4d3c - depends: - - python >=3.10 - - nodejs >=18 - - python - license: MIT - license_family: MIT - run_exports: {} - size: 2189657 - timestamp: 1780749480811 -- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - build_number: 8 - sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 - md5: 0539938c55b6b1a59b560e843ad864a4 - constrains: - - python 3.14.* *_cp314 - license: BSD-3-Clause - license_family: BSD - run_exports: {} - size: 6989 - timestamp: 1752805904792 -- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c - md5: ad659d0a2b3e47e38d829aa8cad2d610 - license: LicenseRef-Public-Domain - run_exports: {} - size: 119135 - timestamp: 1767016325805 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bun-1.3.11-h5389c0c_4.conda - sha256: ddea3ef56c01e357b167c5e761a56ba1a8af6c69188234199cfe5a0065eec015 - md5: 018d2e896104b7e3f0462322885e4e0b - depends: - - libcxx >=21 - - __osx >=13.0 - - lol-html >=3.0.0,<3.0.1.0a0 - - libdeflate >=1.25,<1.26.0a0 - - libbrotlicommon >=1.2.0,<1.3.0a0 - - libbrotlienc >=1.2.0,<1.3.0a0 - - libbrotlidec >=1.2.0,<1.3.0a0 - - ls-hpack >=2.3.5,<2.3.6.0a0 - - libhwy >=1.4.0,<1.5.0a0 - - libzlib >=1.3.2,<2.0a0 - - libsqlite >=3.53.2,<4.0a0 - - hdrhistogram-c >=0.11.9,<0.11.10.0a0 - - libarchive >=3.8.7,<3.9.0a0 - - c-ares >=1.34.6,<2.0a0 - - zstd >=1.5.7,<1.6.0a0 - license: MIT - license_family: MIT - run_exports: {} - size: 17012905 - timestamp: 1780924565717 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda - sha256: 540fe54be35fac0c17feefbdc3e29725cce05d7367ffedfaaa1bdda234b019df - md5: 620b85a3f45526a8bc4d23fd78fc22f0 - depends: - - __osx >=11.0 - license: bzip2-1.0.6 - license_family: BSD - run_exports: - weak: - - bzip2 >=1.0.8,<2.0a0 - size: 124834 - timestamp: 1771350416561 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda - sha256: 2995f2aed4e53725e5efbc28199b46bf311c3cab2648fc4f10c2227d6d5fa196 - md5: bcb3cba70cf1eec964a03b4ba7775f01 - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - run_exports: - weak: - - c-ares >=1.34.6,<2.0a0 - size: 180327 - timestamp: 1765215064054 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdrhistogram-c-0.11.9-h132b30e_1.conda - sha256: bde94a98894bc8f9bd735aef0a1b8b805a4cc623bbbd1c53580c1acb24c22c7c - md5: 7938c4e113486aea6258375a41a5c1dd - depends: - - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 - license: BSD-2-Clause OR CC0-1.0 - run_exports: - weak: - - hdrhistogram-c >=0.11.9,<0.11.10.0a0 - size: 37701 - timestamp: 1769816279348 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda - sha256: 3a7907a17e9937d3a46dfd41cffaf815abad59a569440d1e25177c15fd0684e5 - md5: f1182c91c0de31a7abd40cedf6a5ebef - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - run_exports: - weak: - - icu >=78.3,<79.0a0 - size: 12361647 - timestamp: 1773822915649 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h2062a1b_1.conda - sha256: 450026eb01a52acd0ff122e331ec9b8546c93790143214b73e1c14bc2b075b22 - md5: 8adfdc0215e979a0ce31be676883e0b3 - depends: - - __osx >=11.0 - - libcxx >=19 - constrains: - - libabseil-static =20260526.0=cxx17* - - abseil-cpp =20260526.0 - license: Apache-2.0 - license_family: Apache - run_exports: - weak: - - libabseil >=20260526.0,<20260527.0a0 - - libabseil =*=cxx17* - size: 1273408 - timestamp: 1780524599788 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarchive-3.8.8-gpl_h6fbacd7_100.conda - sha256: 05c370fae4f2a5fd7baf59c15d75caec718d785ec47e813dbf7bff68355e4bb7 - md5: cfa10f3c4b14c13f676dc08e2ea29023 - depends: - - __osx >=11.0 - - bzip2 >=1.0.8,<2.0a0 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.3,<6.0a0 - - libxml2 - - libxml2-16 >=2.14.6 - - libzlib >=1.3.2,<2.0a0 - - lz4-c >=1.10.0,<1.11.0a0 - - lzo >=2.10,<3.0a0 - - openssl >=3.5.7,<4.0a0 - - zstd >=1.5.7,<1.6.0a0 - license: BSD-2-Clause - license_family: BSD - run_exports: - weak: - - libarchive >=3.8.8,<3.9.0a0 - size: 796153 - timestamp: 1782289667690 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda - sha256: a7cb9e660531cf6fbd4148cff608c85738d0b76f0975c5fc3e7d5e92840b7229 - md5: 006e7ddd8a110771134fcc4e1e3a6ffa - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - run_exports: - weak: - - libbrotlicommon >=1.2.0,<1.3.0a0 - size: 79443 - timestamp: 1764017945924 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda - sha256: 2eae444039826db0454b19b52a3390f63bfe24f6b3e63089778dd5a5bf48b6bf - md5: 079e88933963f3f149054eec2c487bc2 - depends: - - __osx >=11.0 - - libbrotlicommon 1.2.0 hc919400_1 - license: MIT - license_family: MIT - run_exports: - weak: - - libbrotlidec >=1.2.0,<1.3.0a0 - size: 29452 - timestamp: 1764017979099 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda - sha256: 01436c32bb41f9cb4bcf07dda647ce4e5deb8307abfc3abdc8da5317db8189d1 - md5: b2b7c8288ca1a2d71ff97a8e6a1e8883 - depends: - - __osx >=11.0 - - libbrotlicommon 1.2.0 hc919400_1 - license: MIT - license_family: MIT - run_exports: - weak: - - libbrotlienc >=1.2.0,<1.3.0a0 - size: 290754 - timestamp: 1764018009077 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef - md5: 89f76a2a21a3ec3ec983b5eb237c4113 - depends: - - __osx >=11.0 - license: Apache-2.0 WITH LLVM-exception - license_family: Apache - run_exports: {} - size: 569349 - timestamp: 1781670209146 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda - sha256: 5e0b6961be3304a5f027a8c00bd0967fc46ae162cffb7553ff45c70f51b8314c - md5: a6130c709305cd9828b4e1bd9ba0000c - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - run_exports: - weak: - - libdeflate >=1.25,<1.26.0a0 - size: 55420 - timestamp: 1761980066242 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda - sha256: 95cecb3902fbe0399c3a7e67a5bed1db813e5ab0e22f4023a5e0f722f2cc214f - md5: 36d33e440c31857372a72137f78bacf5 - license: BSD-2-Clause - license_family: BSD - run_exports: - weak: - - libev >=4.33,<4.34.0a0 - size: 107458 - timestamp: 1702146414478 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda - sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f - md5: a915151d5d3c5bf039f5ccc8402a436f - depends: - - __osx >=11.0 - constrains: - - expat 2.8.1.* - license: MIT - license_family: MIT - run_exports: {} - size: 69362 - timestamp: 1781203631990 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda - sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 - md5: 43c04d9cb46ef176bb2a4c77e324d599 - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - run_exports: - weak: - - libffi >=3.5.2,<3.6.0a0 - size: 40979 - timestamp: 1769456747661 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-ha332bbd_0.conda - sha256: 4fcad3cbec60da940312e883b7866816517acc5f9baecfe9a778de57327a1b1b - md5: 7394850583ca88325244b68b532c7a39 - depends: - - __osx >=11.0 - - libcxx >=19 - license: Apache-2.0 OR BSD-3-Clause - run_exports: - weak: - - libhwy >=1.4.0,<1.5.0a0 - size: 609931 - timestamp: 1776990524407 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda - sha256: de0336e800b2af9a40bdd694b03870ac4a848161b35c8a2325704f123f185f03 - md5: 4d5a7445f0b25b6a3ddbb56e790f5251 - depends: - - __osx >=11.0 - license: LGPL-2.1-only - run_exports: - weak: - - libiconv >=1.18,<2.0a0 - size: 750379 - timestamp: 1754909073836 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda - sha256: 34878d87275c298f1a732c6806349125cebbf340d24c6c23727268184bba051e - md5: b1fd823b5ae54fbec272cea0811bd8a9 - depends: - - __osx >=11.0 - constrains: - - xz 5.8.3.* - license: 0BSD - run_exports: - weak: - - liblzma >=5.8.3,<6.0a0 - size: 92472 - timestamp: 1775825802659 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda - sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 - md5: 57c4be259f5e0b99a5983799a228ae55 - depends: - - __osx >=11.0 - license: BSD-2-Clause - license_family: BSD - run_exports: {} - size: 73690 - timestamp: 1769482560514 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda - sha256: 2bc7bc3978066f2c274ebcbf711850cc9ab92e023e433b9631958a098d11e10a - md5: 6ea18834adbc3b33df9bd9fb45eaf95b - depends: - - __osx >=11.0 - - c-ares >=1.34.6,<2.0a0 - - libcxx >=19 - - libev >=4.33,<4.34.0a0 - - libev >=4.33,<5.0a0 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.5,<4.0a0 - license: MIT - license_family: MIT - run_exports: - weak: - - libnghttp2 >=1.68.1,<2.0a0 - size: 576526 - timestamp: 1773854624224 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.3-h1b79a29_0.conda - sha256: a73a8acd97a6599fd6e561514db9f101ca7fd984cdc0cfd91ba74c8aa9dbe067 - md5: 7184d95871a58b8258a8ea124ed5aabc - depends: - - __osx >=11.0 - - libzlib >=1.3.2,<2.0a0 - license: blessing - run_exports: - weak: - - libsqlite >=3.53.3,<4.0a0 - size: 924912 - timestamp: 1782519136322 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda - sha256: e23176af832f637693ebbb9bbe7d29c0f4cba662dabd001081d2aa6fc9f7f661 - md5: fa9fef7d9f33724b7c3899c883c25a3e - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - run_exports: - weak: - - libuv >=1.52.1,<2.0a0 - size: 122732 - timestamp: 1779396113397 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda - sha256: ff75b84cdb9e8d123db2fa694a8ac2c2059516b6cbc98ac21fb68e235d0fd354 - md5: 19edaa53885fc8205614b03da2482282 - depends: - - __osx >=11.0 - - icu >=78.3,<79.0a0 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.3,<6.0a0 - - libzlib >=1.3.2,<2.0a0 - constrains: - - libxml2 2.15.3 - license: MIT - license_family: MIT - run_exports: {} - size: 466360 - timestamp: 1776377102261 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda - sha256: 2fe1d8de0854342ae9cabe408b476935f82f5636e153b3b497456264dc8ff3a1 - md5: 8e037d73747d6fe34e12d7bcac10cf21 - depends: - - __osx >=11.0 - - icu >=78.3,<79.0a0 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.3,<6.0a0 - - libxml2-16 2.15.3 h5ef1a60_0 - - libzlib >=1.3.2,<2.0a0 - license: MIT - license_family: MIT - run_exports: - weak: - - libxml2 - - libxml2-16 >=2.15.3 - size: 41102 - timestamp: 1776377119495 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda - sha256: 361415a698514b19a852f5d1123c5da746d4642139904156ddfca7c922d23a05 - md5: bc5a5721b6439f2f62a84f2548136082 - depends: - - __osx >=11.0 - constrains: - - zlib 1.3.2 *_2 - license: Zlib - license_family: Other - run_exports: - weak: - - libzlib >=1.3.2,<2.0a0 - size: 47759 - timestamp: 1774072956767 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lol-html-3.0.0-h6fdd925_0.conda - sha256: 39e0889760cf9b991415277721e54a43b419f7f9ef74c9e6143cbfa7fd678959 - md5: 7fb36aa5e3c0bfcb90888ca5c5715660 - depends: - - __osx >=11.0 - constrains: - - __osx >=11.0 - license: BSD-3-Clause - license_family: BSD - run_exports: - weak: - - lol-html >=3.0.0,<3.0.1.0a0 - size: 447576 - timestamp: 1780399792530 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ls-hpack-2.3.5-h1a92334_0.conda - sha256: cdd6b81d14ed7fb271a74e9ff056144c88ec703a8baa676bc27f05169a293857 - md5: 72a3080021b46d7733adfca14ddd69ed - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - run_exports: - weak: - - ls-hpack >=2.3.5,<2.3.6.0a0 - size: 103549 - timestamp: 1779602259051 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda - sha256: 94d3e2a485dab8bdfdd4837880bde3dd0d701e2b97d6134b8806b7c8e69c8652 - md5: 01511afc6cc1909c5303cf31be17b44f - depends: - - __osx >=11.0 - - libcxx >=18 - license: BSD-2-Clause - license_family: BSD - run_exports: - weak: - - lz4-c >=1.10.0,<1.11.0a0 - size: 148824 - timestamp: 1733741047892 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h925e9cb_1002.conda - sha256: db40fd25c6306bfda469f84cddd8b5ebb9aa08d509cecb49dfd0bb8228466d0c - md5: e56eaa1beab0e7fed559ae9c0264dd88 - depends: - - __osx >=11.0 - license: GPL-2.0-or-later - license_family: GPL - run_exports: - weak: - - lzo >=2.10,<3.0a0 - size: 152755 - timestamp: 1753889267953 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - sha256: 4ea6c620b87bd1d42bb2ccc2c87cd2483fa2d7f9e905b14c223f11ff3f4c455d - md5: 343d10ed5b44030a2f67193905aea159 - depends: - - __osx >=11.0 - license: X11 AND BSD-3-Clause - run_exports: - weak: - - ncurses >=6.6,<7.0a0 - size: 805509 - timestamp: 1777423252320 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.5.0-h00e74ec_0.conda - sha256: 676de2e97763f31f04e82fa73fdc2852fe4a02b47ef984eca85e75c2fc0b5941 - md5: d8db0c638f8b006913b57c3af36e5064 - depends: - - libcxx >=19 - - __osx >=12.0 - - libzlib >=1.3.2,<2.0a0 - - libbrotlicommon >=1.2.0,<1.3.0a0 - - libbrotlienc >=1.2.0,<1.3.0a0 - - libbrotlidec >=1.2.0,<1.3.0a0 - - libnghttp2 >=1.68.1,<2.0a0 - - libsqlite >=3.53.3,<4.0a0 - - libuv >=1.52.1,<2.0a0 - - c-ares >=1.34.6,<2.0a0 - - openssl >=3.5.7,<4.0a0 - - icu >=78.3,<79.0a0 - - zstd >=1.5.7,<1.6.0a0 - - libabseil >=20260526.0,<20260527.0a0 - - libabseil * cxx17* - license: MIT - run_exports: - weak: - - nodejs >=26.5.0,<27.0a0 - size: 18185560 - timestamp: 1783543392996 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda - sha256: b3e3ca895c336d4eb91c5d2f244a312bdb59a0de8cfa0cc4c179225ab2f6bbfb - md5: 8187a86242741725bfa74785fe812979 - depends: - - __osx >=11.0 - - ca-certificates - license: Apache-2.0 - license_family: Apache - run_exports: - weak: - - openssl >=3.6.3,<4.0a0 - size: 3102584 - timestamp: 1781069820667 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_100_cp314.conda - build_number: 100 - sha256: 984081c9fae3a3944c6f2707bbbbc70e8b961f02cdb7c640d9745e2636235632 - md5: 4841be3d0cf616a860efc6e60af66f8b - depends: - - __osx >=11.0 - - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.8.1,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.3,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.53.2,<4.0a0 - - libzlib >=1.3.2,<2.0a0 - - ncurses >=6.6,<7.0a0 - - openssl >=3.5.7,<4.0a0 - - python_abi 3.14.* *_cp314 - - readline >=8.3,<9.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - - zstd >=1.5.7,<1.6.0a0 - license: Python-2.0 - run_exports: - weak: - - python_abi 3.14.* *_cp314 - noarch: - - python - size: 14059371 - timestamp: 1781254578985 - python_site_packages_path: lib/python3.14/site-packages -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 - md5: f8381319127120ce51e081dce4865cf4 - depends: - - __osx >=11.0 - - ncurses >=6.5,<7.0a0 - license: GPL-3.0-only - license_family: GPL - run_exports: - weak: - - readline >=8.3,<9.0a0 - size: 313930 - timestamp: 1765813902568 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 - md5: a9d86bc62f39b94c4661716624eb21b0 - depends: - - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 - license: TCL - license_family: BSD - run_exports: - weak: - - tk >=8.6.13,<8.7.0a0 - size: 3127137 - timestamp: 1769460817696 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 - md5: ab136e4c34e97f34fb621d2592a393d8 - depends: - - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 - license: BSD-3-Clause - license_family: BSD - run_exports: - weak: - - zstd >=1.5.7,<1.6.0a0 - size: 433413 - timestamp: 1764777166076 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 28948 + timestamp: 1770939786096 + - conda: https://conda.anaconda.org/conda-forge/linux-64/bun-1.3.11-h560442b_4.conda + sha256: 93467a42c74e34738d771ae518189ac4db1c8cfbd6ea30276a86ae7062f057ad + md5: b31a44395580c7cbf2de971faf23d7f7 + depends: + - libstdcxx >=15 + - libgcc >=15 + - __glibc >=2.28,<3.0.a0 + - libsqlite >=3.53.2,<4.0a0 + - ls-hpack >=2.3.5,<2.3.6.0a0 + - zstd >=1.5.7,<1.6.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libzlib >=1.3.2,<2.0a0 + - libarchive >=3.8.7,<3.9.0a0 + - hdrhistogram-c >=0.11.9,<0.11.10.0a0 + - c-ares >=1.34.6,<2.0a0 + - icu >=75.1,<76.0a0 + - lol-html >=3.0.0,<3.0.1.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libhwy >=1.4.0,<1.5.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 17940522 + timestamp: 1780924533437 + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 + md5: d2ffd7602c02f2b316fd921d39876885 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 260182 + timestamp: 1771350215188 + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda + sha256: cc9accf72fa028d31c2a038460787751127317dcfa991f8d1f1babf216bb454e + md5: 920bb03579f15389b9e512095ad995b7 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - c-ares >=1.34.6,<2.0a0 + size: 207882 + timestamp: 1765214722852 + - conda: https://conda.anaconda.org/conda-forge/linux-64/hdrhistogram-c-0.11.9-h421ea60_1.conda + sha256: 04363c6cd15b7d9e48b622e196471b725630c84e82bfa7a4adbf36f5e94a2c06 + md5: 5160b2491c798ef0f3e883485c526e42 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-2-Clause OR CC0-1.0 + run_exports: + weak: + - hdrhistogram-c >=0.11.9,<0.11.10.0a0 + size: 38736 + timestamp: 1769816263111 + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda + sha256: 71e750d509f5fa3421087ba88ef9a7b9be11c53174af3aa4d06aff4c18b38e8e + md5: 8b189310083baabfb622af68fd9d3ae3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=75.1,<76.0a0 + size: 12129203 + timestamp: 1720853576813 + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c + md5: 18335a698559cdbcd86150a48bf54ba6 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 728002 + timestamp: 1774197446916 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20250512.1-cxx17_hba17884_0.conda + sha256: dcd1429a1782864c452057a6c5bc1860f2b637dc20a2b7e6eacd57395bbceff8 + md5: 83b160d4da3e1e847bf044997621ed63 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + constrains: + - libabseil-static =20250512.1=cxx17* + - abseil-cpp =20250512.1 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - libabseil >=20250512.1,<20250513.0a0 + - libabseil =*=cxx17* + size: 1310612 + timestamp: 1750194198254 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libarchive-3.8.8-gpl_hc2c16d8_100.conda + sha256: f916b51f55f51a9bb2d902e0a5f029490f7b745aee549c349751c1787ddc26b8 + md5: 44652e646cb623f486ea72e7e7479222 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - lzo >=2.10,<3.0a0 + - openssl >=3.5.7,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libarchive >=3.8.8,<3.9.0a0 + size: 867280 + timestamp: 1782289011634 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda + sha256: 318f36bd49ca8ad85e6478bd8506c88d82454cc008c1ac1c6bf00a3c42fa610e + md5: 72c8fd1af66bd67bf580645b426513ed + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlicommon >=1.2.0,<1.3.0a0 + size: 79965 + timestamp: 1764017188531 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda + sha256: 12fff21d38f98bc446d82baa890e01fd82e3b750378fedc720ff93522ffb752b + md5: 366b40a69f0ad6072561c1d09301c886 + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlidec >=1.2.0,<1.3.0a0 + size: 34632 + timestamp: 1764017199083 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda + sha256: a0c15c79997820bbd3fbc8ecf146f4fe0eca36cc60b62b63ac6cf78857f1dd0d + md5: 4ffbb341c8b616aa2494b6afb26a0c5f + depends: + - __glibc >=2.17,<3.0.a0 + - libbrotlicommon 1.2.0 hb03c661_1 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlienc >=1.2.0,<1.3.0a0 + size: 298378 + timestamp: 1764017210931 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda + sha256: aa8e8c4be9a2e81610ddf574e05b64ee131fab5e0e3693210c9d6d2fba32c680 + md5: 6c77a605a7a689d17d4819c0f8ac9a00 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libdeflate >=1.25,<1.26.0a0 + size: 73490 + timestamp: 1761979956660 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda + sha256: 1cd6048169fa0395af74ed5d8f1716e22c19a81a8a36f934c110ca3ad4dd27b4 + md5: 172bf1cd1ff8629f2b1179945ed45055 + depends: + - libgcc-ng >=12 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libev >=4.33,<4.34.0a0 + size: 112766 + timestamp: 1702146165126 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 77856 + timestamp: 1781203599810 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 58592 + timestamp: 1769456073053 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + sha256: 8e0a3b5e41272e5678499b5dfc4cddb673f9e935de01eb0767ce857001229f46 + md5: 57736f29cc2b0ec0b6c2952d3f101b6a + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_19 + - libgomp 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 1041084 + timestamp: 1778269013026 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda + sha256: 9dcf54adfaa5e861123c2da4f2f0451a685464ea7e5a41ad91cf67b31d658d98 + md5: 331ee9b72b9dff570d56b1302c5ab37d + depends: + - libgcc 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - libgcc + size: 27694 + timestamp: 1778269016987 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + sha256: 5abe4ab9d93f6c9757d654f1969ae2267d4505315c1f2f8fe705fd60af084f1b + md5: faac990cb7aedc7f3a2224f2c9b0c26c + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 603817 + timestamp: 1778268942614 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libhwy-1.4.0-h10be129_0.conda + sha256: 8b70955d5e9a49d08945d4f8e2eab855b2efa5fce9cb9bc5e75d86764e6f2f38 + md5: 3a9428b74c403c71048104d38437b48c + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 OR BSD-3-Clause + run_exports: + weak: + - libhwy >=1.4.0,<1.5.0a0 + size: 1435782 + timestamp: 1776989559668 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f + md5: 915f5995e94f60e9a4826e0b0920ee88 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 790176 + timestamp: 1754908768807 + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d + md5: b88d90cad08e6bc8ad540cb310a761fb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 113478 + timestamp: 1775825492909 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 92400 + timestamp: 1769482286018 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda + sha256: 663444d77a42f2265f54fb8b48c5450bfff4388d9c0f8253dd7855f0d993153f + md5: 2a45e7f8af083626f009645a6481f12d + depends: + - __glibc >=2.17,<3.0.a0 + - c-ares >=1.34.6,<2.0a0 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libgcc >=14 + - libstdcxx >=14 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.5,<4.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libnghttp2 >=1.68.1,<2.0a0 + size: 663344 + timestamp: 1773854035739 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.3-h0c1763c_0.conda + sha256: 365376f4815e5e80def2b3462a2419708b7c292da0da85278386c2618621fff4 + md5: 4aed8e657e9ff156bdbe849b4df44389 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.3,<4.0a0 + size: 962119 + timestamp: 1782519076616 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + sha256: dff1058c76ec6b8759e41cefa2508162d00e4a5e6721aa68ec3fd10094e702dc + md5: 5794b3bdc38177caf969dabd3af08549 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_19 + constrains: + - libstdcxx-ng ==15.2.0=*_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 5852044 + timestamp: 1778269036376 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda + sha256: 0672b6b6e1791c92e8eccad58081a99d614fcf82bca5841f9dfa3c3e658f83b9 + md5: e5ce228e579726c07255dbf90dc62101 + depends: + - libstdcxx 15.2.0 h934c35e_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - libstdcxx + size: 27776 + timestamp: 1778269074600 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f + md5: 01bb81d12c957de066ea7362007df642 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libuuid >=2.42.2,<3.0a0 + size: 40017 + timestamp: 1781625522462 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_0.conda + sha256: e28e4519223f78b3163599ca89c3f2d80bfb53e907e7fc74e806e60d1efa578b + md5: 4e33d49bf4fc853855a3b00643aa5484 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libuv >=1.52.1,<2.0a0 + size: 419935 + timestamp: 1779396012261 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.1-ha9997c6_0.conda + sha256: 71436e72a286ef8b57d6f4287626ff91991eb03c7bdbe835280521791efd1434 + md5: e7733bc6785ec009e47a224a71917e84 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=75.1,<76.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libzlib >=1.3.1,<2.0a0 + constrains: + - libxml2 2.15.1 + license: MIT + license_family: MIT + run_exports: {} + size: 556302 + timestamp: 1761015637262 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.1-h26afc86_0.conda + sha256: ec0735ae56c3549149eebd7dc22c0bed91fd50c02eaa77ff418613ddda190aa8 + md5: e512be7dc1f84966d50959e900ca121f + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=75.1,<76.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.1,<6.0a0 + - libxml2-16 2.15.1 ha9997c6_0 + - libzlib >=1.3.1,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.1 + size: 45283 + timestamp: 1761015644057 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 + md5: d87ff7921124eccd67248aa483c23fec + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 63629 + timestamp: 1774072609062 + - conda: https://conda.anaconda.org/conda-forge/linux-64/lol-html-3.0.0-hb17b654_0.conda + sha256: 852ae2072f6c8cfaf32db8a7f3d33d3d70b28f91af3137d5da0f12ba63a00749 + md5: f67d6bd8ed2470a0198dc7765d595bb2 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + constrains: + - __glibc >=2.17 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - lol-html >=3.0.0,<3.0.1.0a0 + size: 482549 + timestamp: 1780399715936 + - conda: https://conda.anaconda.org/conda-forge/linux-64/ls-hpack-2.3.5-h280c20c_0.conda + sha256: fe57537d4fd99845c9d94870e46fe78e0e7ace0343991dac765d55777da1e500 + md5: 5f8ace4f3cf7857ab7b80c328dc07622 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - ls-hpack >=2.3.5,<2.3.6.0a0 + size: 99800 + timestamp: 1779602199229 + - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda + sha256: 47326f811392a5fd3055f0f773036c392d26fdb32e4d8e7a8197eed951489346 + md5: 9de5350a85c4a20c685259b889aa6393 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - libstdcxx >=13 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - lz4-c >=1.10.0,<1.11.0a0 + size: 167055 + timestamp: 1733741040117 + - conda: https://conda.anaconda.org/conda-forge/linux-64/lzo-2.10-h280c20c_1002.conda + sha256: 5c6bbeec116e29f08e3dad3d0524e9bc5527098e12fc432c0e5ca53ea16337d4 + md5: 45161d96307e3a447cc3eb5896cf6f8c + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: GPL-2.0-or-later + license_family: GPL + run_exports: + weak: + - lzo >=2.10,<3.0a0 + size: 191060 + timestamp: 1753889274283 + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 + md5: fc21868a1a5aacc937e7a18747acb8a5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 918956 + timestamp: 1777422145199 + - conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-25.2.1-he2c55a7_1.conda + sha256: 6516f99fe400181ebe27cba29180ca0c7425c15d7392f74220a028ad0e0064a2 + md5: d8005b3a90515c952b51026f6b7d005d + depends: + - __glibc >=2.28,<3.0.a0 + - libstdcxx >=14 + - libgcc >=14 + - zstd >=1.5.7,<1.6.0a0 + - c-ares >=1.34.6,<2.0a0 + - libuv >=1.51.0,<2.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libnghttp2 >=1.67.0,<2.0a0 + - openssl >=3.5.4,<4.0a0 + - libabseil >=20250512.1,<20250513.0a0 + - libabseil * cxx17* + - libzlib >=1.3.1,<2.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - icu >=75.1,<76.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - nodejs >=25.2.1,<26.0a0 + size: 17246248 + timestamp: 1765444698486 + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + sha256: d48f5c22b9897c01e4dff3680f1f57ceb02711ab9c62f74339b080419dfad34b + md5: 79dd2074b5cd5c5c6b2930514a11e22d + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3159683 + timestamp: 1781069855778 + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_100_cp314.conda + build_number: 100 + sha256: 6d28ac2b061179deb434d3d57afa98ffd20ec3c5d44ab8048a1ca33424b22d38 + md5: 0b9b2f83b5b600e1ac38becde8d0dd44 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libuuid >=2.42.1,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 36717183 + timestamp: 1781255094700 + python_site_packages_path: lib/python3.14/site-packages + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 345073 + timestamp: 1765813471974 + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3301196 + timestamp: 1769460227866 + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 601375 + timestamp: 1764777111296 + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + sha256: f8e3c730fa14ee3f170493779f06522c4acf89169f43db4f039727709b6419cf + md5: a9965dd99f683c5f444428f896635716 + depends: + - __unix + license: ISC + run_exports: {} + size: 128866 + timestamp: 1781708962055 + - conda: https://conda.anaconda.org/conda-forge/noarch/mystmd-1.10.1-pyhcf101f3_0.conda + sha256: b4f7643a7c33f062f9ade71411e66d6fe30a70c9e93631c5091e553e15a1a63b + md5: 21d74fa7f9fcc516065990fb22ae4d3c + depends: + - python >=3.10 + - nodejs >=18 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 2189657 + timestamp: 1780749480811 + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + build_number: 8 + sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 + md5: 0539938c55b6b1a59b560e843ad864a4 + constrains: + - python 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6989 + timestamp: 1752805904792 + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + run_exports: {} + size: 119135 + timestamp: 1767016325805 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bun-1.3.11-h5389c0c_4.conda + sha256: ddea3ef56c01e357b167c5e761a56ba1a8af6c69188234199cfe5a0065eec015 + md5: 018d2e896104b7e3f0462322885e4e0b + depends: + - libcxx >=21 + - __osx >=13.0 + - lol-html >=3.0.0,<3.0.1.0a0 + - libdeflate >=1.25,<1.26.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - ls-hpack >=2.3.5,<2.3.6.0a0 + - libhwy >=1.4.0,<1.5.0a0 + - libzlib >=1.3.2,<2.0a0 + - libsqlite >=3.53.2,<4.0a0 + - hdrhistogram-c >=0.11.9,<0.11.10.0a0 + - libarchive >=3.8.7,<3.9.0a0 + - c-ares >=1.34.6,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 17012905 + timestamp: 1780924565717 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + sha256: 540fe54be35fac0c17feefbdc3e29725cce05d7367ffedfaaa1bdda234b019df + md5: 620b85a3f45526a8bc4d23fd78fc22f0 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 124834 + timestamp: 1771350416561 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-ares-1.34.6-hc919400_0.conda + sha256: 2995f2aed4e53725e5efbc28199b46bf311c3cab2648fc4f10c2227d6d5fa196 + md5: bcb3cba70cf1eec964a03b4ba7775f01 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - c-ares >=1.34.6,<2.0a0 + size: 180327 + timestamp: 1765215064054 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hdrhistogram-c-0.11.9-h132b30e_1.conda + sha256: bde94a98894bc8f9bd735aef0a1b8b805a4cc623bbbd1c53580c1acb24c22c7c + md5: 7938c4e113486aea6258375a41a5c1dd + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-2-Clause OR CC0-1.0 + run_exports: + weak: + - hdrhistogram-c >=0.11.9,<0.11.10.0a0 + size: 37701 + timestamp: 1769816279348 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda + sha256: 3a7907a17e9937d3a46dfd41cffaf815abad59a569440d1e25177c15fd0684e5 + md5: f1182c91c0de31a7abd40cedf6a5ebef + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 12361647 + timestamp: 1773822915649 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h2062a1b_1.conda + sha256: 450026eb01a52acd0ff122e331ec9b8546c93790143214b73e1c14bc2b075b22 + md5: 8adfdc0215e979a0ce31be676883e0b3 + depends: + - __osx >=11.0 + - libcxx >=19 + constrains: + - libabseil-static =20260526.0=cxx17* + - abseil-cpp =20260526.0 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - libabseil >=20260526.0,<20260527.0a0 + - libabseil =*=cxx17* + size: 1273408 + timestamp: 1780524599788 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libarchive-3.8.8-gpl_h6fbacd7_100.conda + sha256: 05c370fae4f2a5fd7baf59c15d75caec718d785ec47e813dbf7bff68355e4bb7 + md5: cfa10f3c4b14c13f676dc08e2ea29023 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - lz4-c >=1.10.0,<1.11.0a0 + - lzo >=2.10,<3.0a0 + - openssl >=3.5.7,<4.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libarchive >=3.8.8,<3.9.0a0 + size: 796153 + timestamp: 1782289667690 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-hc919400_1.conda + sha256: a7cb9e660531cf6fbd4148cff608c85738d0b76f0975c5fc3e7d5e92840b7229 + md5: 006e7ddd8a110771134fcc4e1e3a6ffa + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlicommon >=1.2.0,<1.3.0a0 + size: 79443 + timestamp: 1764017945924 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-hc919400_1.conda + sha256: 2eae444039826db0454b19b52a3390f63bfe24f6b3e63089778dd5a5bf48b6bf + md5: 079e88933963f3f149054eec2c487bc2 + depends: + - __osx >=11.0 + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlidec >=1.2.0,<1.3.0a0 + size: 29452 + timestamp: 1764017979099 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-hc919400_1.conda + sha256: 01436c32bb41f9cb4bcf07dda647ce4e5deb8307abfc3abdc8da5317db8189d1 + md5: b2b7c8288ca1a2d71ff97a8e6a1e8883 + depends: + - __osx >=11.0 + - libbrotlicommon 1.2.0 hc919400_1 + license: MIT + license_family: MIT + run_exports: + weak: + - libbrotlienc >=1.2.0,<1.3.0a0 + size: 290754 + timestamp: 1764018009077 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef + md5: 89f76a2a21a3ec3ec983b5eb237c4113 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 569349 + timestamp: 1781670209146 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.25-hc11a715_0.conda + sha256: 5e0b6961be3304a5f027a8c00bd0967fc46ae162cffb7553ff45c70f51b8314c + md5: a6130c709305cd9828b4e1bd9ba0000c + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - libdeflate >=1.25,<1.26.0a0 + size: 55420 + timestamp: 1761980066242 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h93a5062_2.conda + sha256: 95cecb3902fbe0399c3a7e67a5bed1db813e5ab0e22f4023a5e0f722f2cc214f + md5: 36d33e440c31857372a72137f78bacf5 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libev >=4.33,<4.34.0a0 + size: 107458 + timestamp: 1702146414478 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f + md5: a915151d5d3c5bf039f5ccc8402a436f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 69362 + timestamp: 1781203631990 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 + md5: 43c04d9cb46ef176bb2a4c77e324d599 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 40979 + timestamp: 1769456747661 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libhwy-1.4.0-ha332bbd_0.conda + sha256: 4fcad3cbec60da940312e883b7866816517acc5f9baecfe9a778de57327a1b1b + md5: 7394850583ca88325244b68b532c7a39 + depends: + - __osx >=11.0 + - libcxx >=19 + license: Apache-2.0 OR BSD-3-Clause + run_exports: + weak: + - libhwy >=1.4.0,<1.5.0a0 + size: 609931 + timestamp: 1776990524407 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + sha256: de0336e800b2af9a40bdd694b03870ac4a848161b35c8a2325704f123f185f03 + md5: 4d5a7445f0b25b6a3ddbb56e790f5251 + depends: + - __osx >=11.0 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 750379 + timestamp: 1754909073836 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + sha256: 34878d87275c298f1a732c6806349125cebbf340d24c6c23727268184bba051e + md5: b1fd823b5ae54fbec272cea0811bd8a9 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 92472 + timestamp: 1775825802659 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 + md5: 57c4be259f5e0b99a5983799a228ae55 + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 73690 + timestamp: 1769482560514 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h8f3e76b_0.conda + sha256: 2bc7bc3978066f2c274ebcbf711850cc9ab92e023e433b9631958a098d11e10a + md5: 6ea18834adbc3b33df9bd9fb45eaf95b + depends: + - __osx >=11.0 + - c-ares >=1.34.6,<2.0a0 + - libcxx >=19 + - libev >=4.33,<4.34.0a0 + - libev >=4.33,<5.0a0 + - libzlib >=1.3.1,<2.0a0 + - openssl >=3.5.5,<4.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libnghttp2 >=1.68.1,<2.0a0 + size: 576526 + timestamp: 1773854624224 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.3-h1b79a29_0.conda + sha256: a73a8acd97a6599fd6e561514db9f101ca7fd984cdc0cfd91ba74c8aa9dbe067 + md5: 7184d95871a58b8258a8ea124ed5aabc + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.3,<4.0a0 + size: 924912 + timestamp: 1782519136322 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_0.conda + sha256: e23176af832f637693ebbb9bbe7d29c0f4cba662dabd001081d2aa6fc9f7f661 + md5: fa9fef7d9f33724b7c3899c883c25a3e + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - libuv >=1.52.1,<2.0a0 + size: 122732 + timestamp: 1779396113397 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda + sha256: ff75b84cdb9e8d123db2fa694a8ac2c2059516b6cbc98ac21fb68e235d0fd354 + md5: 19edaa53885fc8205614b03da2482282 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.3 + license: MIT + license_family: MIT + run_exports: {} + size: 466360 + timestamp: 1776377102261 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda + sha256: 2fe1d8de0854342ae9cabe408b476935f82f5636e153b3b497456264dc8ff3a1 + md5: 8e037d73747d6fe34e12d7bcac10cf21 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 h5ef1a60_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.3 + size: 41102 + timestamp: 1776377119495 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + sha256: 361415a698514b19a852f5d1123c5da746d4642139904156ddfca7c922d23a05 + md5: bc5a5721b6439f2f62a84f2548136082 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 47759 + timestamp: 1774072956767 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lol-html-3.0.0-h6fdd925_0.conda + sha256: 39e0889760cf9b991415277721e54a43b419f7f9ef74c9e6143cbfa7fd678959 + md5: 7fb36aa5e3c0bfcb90888ca5c5715660 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - lol-html >=3.0.0,<3.0.1.0a0 + size: 447576 + timestamp: 1780399792530 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ls-hpack-2.3.5-h1a92334_0.conda + sha256: cdd6b81d14ed7fb271a74e9ff056144c88ec703a8baa676bc27f05169a293857 + md5: 72a3080021b46d7733adfca14ddd69ed + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - ls-hpack >=2.3.5,<2.3.6.0a0 + size: 103549 + timestamp: 1779602259051 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lz4-c-1.10.0-h286801f_1.conda + sha256: 94d3e2a485dab8bdfdd4837880bde3dd0d701e2b97d6134b8806b7c8e69c8652 + md5: 01511afc6cc1909c5303cf31be17b44f + depends: + - __osx >=11.0 + - libcxx >=18 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - lz4-c >=1.10.0,<1.11.0a0 + size: 148824 + timestamp: 1733741047892 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lzo-2.10-h925e9cb_1002.conda + sha256: db40fd25c6306bfda469f84cddd8b5ebb9aa08d509cecb49dfd0bb8228466d0c + md5: e56eaa1beab0e7fed559ae9c0264dd88 + depends: + - __osx >=11.0 + license: GPL-2.0-or-later + license_family: GPL + run_exports: + weak: + - lzo >=2.10,<3.0a0 + size: 152755 + timestamp: 1753889267953 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + sha256: 4ea6c620b87bd1d42bb2ccc2c87cd2483fa2d7f9e905b14c223f11ff3f4c455d + md5: 343d10ed5b44030a2f67193905aea159 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 805509 + timestamp: 1777423252320 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.5.0-h00e74ec_0.conda + sha256: 676de2e97763f31f04e82fa73fdc2852fe4a02b47ef984eca85e75c2fc0b5941 + md5: d8db0c638f8b006913b57c3af36e5064 + depends: + - libcxx >=19 + - __osx >=12.0 + - libzlib >=1.3.2,<2.0a0 + - libbrotlicommon >=1.2.0,<1.3.0a0 + - libbrotlienc >=1.2.0,<1.3.0a0 + - libbrotlidec >=1.2.0,<1.3.0a0 + - libnghttp2 >=1.68.1,<2.0a0 + - libsqlite >=3.53.3,<4.0a0 + - libuv >=1.52.1,<2.0a0 + - c-ares >=1.34.6,<2.0a0 + - openssl >=3.5.7,<4.0a0 + - icu >=78.3,<79.0a0 + - zstd >=1.5.7,<1.6.0a0 + - libabseil >=20260526.0,<20260527.0a0 + - libabseil * cxx17* + license: MIT + run_exports: + weak: + - nodejs >=26.5.0,<27.0a0 + size: 18185560 + timestamp: 1783543392996 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + sha256: b3e3ca895c336d4eb91c5d2f244a312bdb59a0de8cfa0cc4c179225ab2f6bbfb + md5: 8187a86242741725bfa74785fe812979 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3102584 + timestamp: 1781069820667 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_100_cp314.conda + build_number: 100 + sha256: 984081c9fae3a3944c6f2707bbbbc70e8b961f02cdb7c640d9745e2636235632 + md5: 4841be3d0cf616a860efc6e60af66f8b + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 14059371 + timestamp: 1781254578985 + python_site_packages_path: lib/python3.14/site-packages + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 + depends: + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 313930 + timestamp: 1765813902568 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 + md5: a9d86bc62f39b94c4661716624eb21b0 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3127137 + timestamp: 1769460817696 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 + md5: ab136e4c34e97f34fb621d2592a393d8 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 433413 + timestamp: 1764777166076 diff --git a/slides/1_03_package.md b/slides/1_03_package.md new file mode 100644 index 0000000..75cff27 --- /dev/null +++ b/slides/1_03_package.md @@ -0,0 +1,442 @@ +--- +marp: true +theme: simplepy +paginate: true +_paginate: skip +--- + +# SIMPLE-Py + +## Making a basic package + +--- + +## The goal for this hour + +Build a package **by hand**, so every file holds no mysteries: + +- Standard `src` layout +- Installs (editably) into an environment +- Complete, standards-based metadata +- A command-line script +- Version from git +- Builds into an SDist and a wheel + +_Next chapter: the template that generates all of this for you._ + +--- + +## The starting point + +A function in a notebook or script: + +```python +import numpy as np + + +def rescale(input_array): + """Rescale an array so its values span [0, 1].""" + low = np.min(input_array) + high = np.max(input_array) + return (input_array - low) / (high - low) +``` + +Works fine — but you can't `pip install` it, `import` it elsewhere, or share it. + +--- + +## The layout + +```text +rescale +├── pyproject.toml +└── src + └── rescale + ├── __init__.py + └── core.py +``` + +`__init__.py` marks the package and defines the public API: + +```python +from rescale.core import rescale + +__all__ = ["rescale"] +``` + +--- + +## Why a `src` layout? + +Without it, `python` and `pytest` import the **local folder**, not the +installed package: + +- Hides packaging bugs (forgotten files) until a _user_ hits them +- `src` forces everything through a real install +- Matches how compiled projects are laid out — pays off later! + +--- + +## The pyproject.toml + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "rescale" +version = "0.1.0" +dependencies = ["numpy"] +``` + +- `[build-system]` selects a **build backend** — the tool that turns source into something installable +- `name` = install name, `src/rescale` = import name — keep them matched + +--- + +## Build backends + +All read the same standard `[project]` table — switching is easy: + +| Backend | Notes | +| -------------- | ------------------------------------------------- | +| **hatchling** | Great default: fast, extendable, respects git | +| **uv_build** | uv's own; very fast, intentionally minimal | +| **flit-core** | Tiny, dependency-free; used by core PyPA tools | +| **setuptools** | The classic; modern versions read `[project]` too | + +They differ in file selection, dynamic versioning, and extras. + +--- + +## Install it and use it + +**High level:** + +```bash +uv run python +>>> from rescale import rescale +``` + +**Low level:** + +```bash +uv venv +uv pip install -e . +``` + +`-e` = editable: edits to `src/` are visible on the next `import`. + +--- + +## 🧑‍💻 Hands on: build the package + +Create the `rescale` package: + +- `src` layout, the two Python files, minimal `pyproject.toml` +- `git init` and commit +- Import it and rescale `numpy.linspace(0, 100, 5)` + +**Bonus:** delete `dependencies = ["numpy"]`, remove `.venv` + `uv.lock`, +and see what breaks. + +--- + +## A first test + +```python +# tests/test_core.py +import numpy as np + +from rescale import rescale + + +def test_rescale(): + np.testing.assert_allclose( + rescale(np.linspace(0, 100, 5)), + np.array([0.0, 0.25, 0.5, 0.75, 1.0]), + ) +``` + +```toml +[dependency-groups] +dev = ["pytest"] +``` + +```bash +uv run pytest +``` + +--- + +## Metadata + +`name` + `version` are required; a real package says much more. + +Same standard `[project]` table for **every** backend: + +- **Informational** — description, readme, authors, license, URLs, classifiers +- **Functional** — `requires-python`, dependencies, extras, entry points + +--- + +## Informational metadata + +```toml +[project] +name = "rescale" +version = "0.1.0" +description = "Rescale NumPy arrays to span [0, 1]." +readme = "README.md" +authors = [{ name = "My Name", email = "me@email.com" }] +license = "BSD-3-Clause" +license-files = ["LICENSE"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Private :: Do Not Upload", +] + +[project.urls] +Homepage = "https://github.com/me/rescale" +``` + +--- + +## License and classifiers + +**License:** modern form is an SPDX expression + +- `"BSD-3-Clause"`, `"MIT AND (Apache-2.0 OR BSD-2-Clause)"` +- Older style: `License ::` classifiers — don't mix old and new +- Never write your own license — pick one at choosealicense.com + +**Classifiers:** tags from a fixed list on PyPI + +- `Private :: Do Not Upload` makes PyPI **reject** the package +- Perfect for tutorials — remove it when you mean to publish + +--- + +## Functional metadata + +```toml +[project] +requires-python = ">=3.10" +dependencies = ["numpy>=1.24"] + +[project.optional-dependencies] +plot = ["matplotlib"] +``` + +- `requires-python`: lets installers **back-solve** for old Pythons — **never upper-cap it** +- `dependencies`: lower bounds you test; avoid upper caps; pins belong in lockfiles +- extras: users opt in with `pip install 'rescale[plot]'` + +--- + +## `project.dependencies` vs. `build-system.requires` + +
+
+ +### `build-system.requires` + +- Installed into a **temporary** isolated env while building +- Thrown away after +- Never reaches your users +- (A wheel doesn't even contain `pyproject.toml`!) + +
+
+ +### `project.dependencies` + +- Becomes wheel **metadata** +- Installers pull these in whenever someone installs you + +
+
+ +--- + +## Entry points: a command line script + +```toml +[project.scripts] +rescale = "rescale.__main__:main" +``` + +```python +# src/rescale/__main__.py +def main() -> None: ... +``` + +- Installer generates a real `rescale` executable in `bin/` +- Using `__main__.py` makes `python -m rescale` work for free + +--- + +## 🧑‍💻 Hands on: metadata and a CLI + +- Add the full metadata (adjust the author!) +- Add the `rescale` script entry point +- Inspect: `uv pip show -v rescale` +- Run it: `uv run rescale 1 2 3` + +Stale metadata? uv cached the old build: +`uv sync --reinstall-package rescale` + +--- + +## Versioning schemes + +**SemVer** (`major.minor.patch`) — read it as author intent: + +- patch: "nothing to see" · minor: "new stuff" · major: "look first" +- _Not_ a promise nothing breaks — with enough users, every change breaks someone +- So don't preemptively pin `package<2` + +**CalVer** — date-based (pip's `25.1`); communicates age and deprecation windows + +--- + +## Single-sourcing the version + +Two copies (`pyproject.toml` + `__version__`) drift. Let the backend compute it: + +```toml +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "rescale" +dynamic = ["version"] + +[tool.hatch] +version.source = "vcs" +build.hooks.vcs.version-file = "src/rescale/_version.py" +``` + +--- + +## Versions from git tags + +```bash +git tag v0.2.0 +``` + +- Tagging **is** the release process +- Commits after a tag get dev versions: `0.2.1.dev3+g1a2b3c4` +- `_version.py` is a build artifact — add it to `.gitignore`, re-export it: + +```python +from rescale._version import __version__ +``` + +_Alternative: `version.path` reads `__version__` out of your source file._ + +--- + +## 🧑‍💻 Hands on: version from git + +- Switch to hatch-vcs versioning +- Commit, then `git tag v0.2.0` +- Check it: + +```bash +uv sync --reinstall-package rescale +uv run python -c "import rescale; print(rescale.__version__)" +``` + +- Make one more commit — check again! + +--- + +## The supporting files + +| File | What it's for | +| -------------- | -------------------------------------------------------- | +| `README.md` | Description, install, usage — rendered on GitHub & PyPI | +| `LICENSE` | Exact license text — without it, nobody may use the code | +| `.gitignore` | GitHub's Python template; hatchling uses it too! | +| `CHANGELOG.md` | Human-readable changes per version | + +--- + +## 🧑‍💻 Hands on: round out the repo + +- `README.md` — description, install instructions, usage example +- `LICENSE` — BSD-3-Clause text from choosealicense.com +- `.gitignore` — GitHub's Python template (+ `_version.py`) +- Make sure `readme` and `license-files` point at the right names +- Commit! + +--- + +## Distributions + +
+
+ +### SDist + +- **Source** distribution: `.tar.gz` of source + metadata +- Installing runs the build backend on the user's machine + +
+
+ +### Wheel + +- **Built** distribution: `.whl` zip, just unpacked into `site-packages` +- No code runs at install — fast and safe +- Pure Python: one wheel works everywhere (`py3-none-any`) + +
+
+ +Compiled packages need a wheel _per platform_ — most of the rest of this workshop! + +--- + +## Building and inspecting + +```bash +uv build +``` + +```bash +tar -tf dist/*.tar.gz # SDist contents +unzip -l dist/*.whl # wheel contents +``` + +- Wheel metadata lives in `rescale-0.2.0.dist-info/METADATA` — your `[project]` table, rendered +- `RECORD` lists every file with a hash — exact uninstalls/upgrades + +--- + +## 🧑‍💻 Hands on: inspect your distributions + +Build, then find: + +1. Where the `[project]` table ended up in the wheel +2. Whether `tests/` made it into each artifact — and whether that's a problem +3. What `RECORD` is + +```bash +unzip -p dist/*.whl '*/METADATA' | head -20 +``` + +--- + +## Summary + +- **src layout** — nothing works without a real install +- **`[project]` table** — standard metadata, identical across backends +- **Entry points** — installers generate your executables +- **Dynamic versioning** — tag once, version everywhere +- **SDist + wheel** — ship both + +You did it by hand once — next chapter: the template that does it for you. diff --git a/slides/2_01_compiled.md b/slides/2_01_compiled.md new file mode 100644 index 0000000..f5ecd08 --- /dev/null +++ b/slides/2_01_compiled.md @@ -0,0 +1,255 @@ +--- +marp: true +theme: simplepy +paginate: true +_paginate: skip +--- + +# SIMPLE-Py + +## A minimal compiled package + +--- + +## A compiled package is three files + +You already built a pure-Python package. A compiled one adds a compiler and a +build backend, but the shape is the same: + +
+
+ +- **source** — `collatz.cpp` (the code to compile) +- **`pyproject.toml`** — declares the build backend +- **`CMakeLists.txt`** — tells CMake how to build + +
+
+ +We use: + +- **pybind11** to bind C++ ↔ Python +- **scikit-build-core** as the build backend +- **CMake** as the build system + +
+
+ +The packaging is the same whichever binding tool you pick. + +--- + +## The source + +A trivial pybind11 extension — a tight integer loop, fast in a compiled language: + +```c++ +#include + +int collatz_steps(long long n) { + int steps = 0; + while (n != 1) { + n = (n % 2 == 0) ? n / 2 : 3 * n + 1; + ++steps; + } + return steps; +} + +PYBIND11_MODULE(collatz, m) { // this name IS the importable module + m.def("collatz_steps", &collatz_steps); +} +``` + +--- + +## The build backend + +`pyproject.toml` picks scikit-build-core and lists `pybind11` so CMake can find it: + +```toml +[build-system] +requires = ["scikit-build-core", "pybind11"] +build-backend = "scikit_build_core.build" + +[project] +name = "collatz" +version = "0.1.0" +``` + +`CMakeLists.txt` does the actual build: + +```cmake +cmake_minimum_required(VERSION 3.15...4.3) +project(collatz LANGUAGES CXX) + +find_package(pybind11 CONFIG REQUIRED) +pybind11_add_module(collatz collatz.cpp) +install(TARGETS collatz DESTINATION .) +``` + +--- + +## And it just works + +No manual compile step — `uv run` sees the `pyproject.toml`, builds the +extension, and installs it into a temporary environment: + +```console +$ uv run python +>>> from collatz import collatz_steps +>>> collatz_steps(27) +111 +``` + +(It climbs to `9232` on the way to `1` — 111 steps.) + +--- + +## Was it worth it? + +The same loop in pure Python, then `timeit` both: + +```console +$ python -m timeit -s "from collatz import collatz_steps" "collatz_steps(97)" +1000000 loops, best of 5: 210 nsec per loop + +$ python -m timeit -s "from mymodule import collatz_steps" "collatz_steps(97)" +100000 loops, best of 5: 5.8 usec per loop +``` + +**~20–30× faster.** That gap is the whole reason to compile — the hot loop +lives in C++, the packaging that runs once stays in Python. + +The win shrinks for trivial inputs: with no work inside the call, you're just +timing the Python↔C boundary. + +--- + +## Minimum version = good defaults, safely + +`cmake_minimum_required` isn't just an error check — it selects CMake +**policies** (versioned defaults), so old projects don't break on new CMake: + +```cmake +cmake_minimum_required(VERSION 3.15...4.3) # floats up to 4.3 +``` + +scikit-build-core has the same idea. Set it once, reuse it: + +```toml +[build-system] +requires = ["scikit-build-core>=1.0"] + +[tool.scikit-build] +minimum-version = "build-system.requires" # reads the pin above +``` + +Raise it (or leave unset) to opt into the latest recommendations all at once. + +--- + +## src layout + +Especially important for compiled code — you can't run the *uncompiled* version, +so don't let Python pick up the source dir: + +```text +example +├── pyproject.toml +├── CMakeLists.txt +└── src + └── collatz + ├── __init__.py + └── _core.cpp # builds as collatz._core +``` + +Auto-discovered when the package name matches the project name (like hatchling). +`__init__.py` re-exports from `._core` so `from collatz import collatz_steps` +still works. + +--- + +## Three names that must line up + +The #1 beginner error — a mismatch gives a runtime `ImportError`, not a build +failure: + +1. `PYBIND11_MODULE(_core, m)` — what the compiled `.so`/`.pyd` is called +2. `install(TARGETS _core DESTINATION collatz)` — where it lands in the wheel +3. `from collatz._core import ...` — what you import + +Rename the module but forget the `__init__.py` re-export → the build succeeds +and the import breaks. When something imports oddly, check these three first. + +--- + +## Two distributions + +
+
+ +### SDist + +- What you build **from** +- Source, `CMakeLists.txt`, tests +- Baseline = everything not `.gitignore`d + +```bash +uv build --sdist +tar -tf dist/*.tar.gz +``` + +
+
+ +### wheel + +- What you **install** +- Package + compiled extension only +- Platform- and Python-tagged filename + +```bash +uv build --wheel +unzip -l dist/*.whl +``` + +
+
+ +`uv build` makes each **independently** from source; pass the archive +(`uv build dist/collatz-0.1.0.tar.gz`) to build a wheel *from* the SDist. + +--- + +## Iterating + +For real development, install editable — scikit-build-core can even rebuild the +extension on import: + +```bash +uv pip install --no-build-isolation -e . +``` + +When a build goes wrong, turn on the details: + +```toml +[tool.scikit-build] +build.verbose = true +cmake.build-type = "Debug" +``` + +```bash +uv build --wheel -C cmake.define.CMAKE_CXX_STANDARD=20 +``` + +--- + +## Summary + +- A compiled package = **source + `pyproject.toml` + `CMakeLists.txt`**; + `uv run` builds it for you +- Compiled pays off when there's **real work inside the call** (~20–30× here) +- `minimum-version` gives new users good defaults without breaking old builds +- Use **src layout**, and keep the **three names** aligned +- **SDist** is what you build from; the **wheel** is what you install +- Develop with an **editable** install; debug with `build.verbose` diff --git a/slides/2_02_binding.md b/slides/2_02_binding.md new file mode 100644 index 0000000..d9d8a92 --- /dev/null +++ b/slides/2_02_binding.md @@ -0,0 +1,343 @@ +--- +marp: true +theme: simplepy +paginate: true +_paginate: skip +--- + +# Binding tools + +## pybind11 & nanobind, up close + +--- + +## Why pybind11 / nanobind? + +Both are header-only C++ binding libraries — you write real (advanced) C++, not a +new language: + +- No dependencies, no pre-process step +- Easy to start; built up one `.def(...)` at a time +- Great CMake support + +Other tools solve different problems: + +- **Cython** — custom language + preprocessor; better for *writing* fast Python +- **F2Py** — Fortran wrapper (`f2py-cmake`) +- **SWIG** — preprocessor, wraps everything at once + +--- + +## pybind11: a simple class + +Start with a minimal C++ class, then bind it: + +
+
+ +```c++ +class Simple { + int x; + +public: + Simple(int x) : x(x) {} + int get() const { return x; } +}; +``` + +
+
+ +```c++ +#include +#include "SimpleClass.hpp" +namespace py = pybind11; + +PYBIND11_MODULE(simpleclass, m) { + py::class_(m, "Simple") + .def(py::init()) + .def("get", &Simple::get); +} +``` + +
+
+ +`py::class_` exposes the class, `py::init()` binds the constructor, +each `.def(...)` binds a method. The module name **must** match the compiled file. + +--- + +## pybind11: properties & operators + +A richer `Vector2D` shows off more of the API: + +```c++ +py::class_(m, "Vector2D") + .def(py::init(), "x"_a, "y"_a) // named args + .def_property("x", &Vector2D::get_x, &Vector2D::set_x) // getter + setter + .def(py::self += py::self) // + .def(py::self + py::self) + .def("__repr__", [](py::object self) { // bind a lambda + return py::str("{0.__class__.__name__}({0.x}, {0.y})").format(self); + }); +``` + +- `"..."_a` names arguments (`using namespace pybind11::literals`) +- `py::self` binds operators; a lambda handles `__repr__` +- `py::str`/`.attr` reach real Python types and attributes + +--- + +## nanobind: same design, tighter + +[nanobind](https://nanobind.readthedocs.io) — newer, same author. It expects code +to conform to *it* rather than supporting all of C++, and in return compiles +faster, makes **much smaller** binaries, and has lower call overhead. + +The API is deliberately close — mostly `nb::` for `py::`, `NB_MODULE` for +`PYBIND11_MODULE`. Differences you'll hit wrapping Minuit2: + +- STL casters are **opt-in per type** (``) vs one + `` +- Virtual overrides use `NB_TRAMPOLINE` + `NB_OVERRIDE_*` +- Factory constructors bind `__init__` with a placement `new` + +Next: a real project — wrap Minuit2, both ways side by side. + +--- + +## Minuit2: the C++ we're wrapping + +Minuit2 is a function minimizer. Subclass `FCNBase`, set params, minimize, print: + +```c++ +class SimpleFCN : public FCNBase { + double Up() const override { return 0.5; } + double operator()(const std::vector &v) const override { + return v.at(0) * v.at(0); // minimize x^2 -> finds 0 + } +}; +``` + +We only bind the handful of classes we actually use. With an auto-binding tool +(SWIG) we'd have to fix up *everything* first — here we don't care about the rest. + +--- + +## The main module + +Split the module into pieces — each `init_*` fills in one part, forward-declared +here and defined in its own file: + +
+
+ +**pybind11** + +```c++ +void init_FCNBase(py::module &); +void init_MnUserParameters(py::module &); +void init_MnMigrad(py::module &); +void init_FunctionMinimum(py::module &); + +PYBIND11_MODULE(minuit2, m) { + init_FCNBase(m); + init_MnUserParameters(m); + init_MnMigrad(m); + init_FunctionMinimum(m); +} +``` + +
+
+ +**nanobind** + +```c++ +void init_FCNBase(nb::module_ &); +void init_MnUserParameters(nb::module_ &); +void init_MnMigrad(nb::module_ &); +void init_FunctionMinimum(nb::module_ &); + +NB_MODULE(minuit2, m) { + init_FCNBase(m); + init_MnUserParameters(m); + init_MnMigrad(m); + init_FunctionMinimum(m); +} +``` + +
+
+ +Building the pieces separately keeps header overlap minimal, then it all links. + +--- + +## Binding the FCN (trampoline) + +`FCNBase` is abstract — a **trampoline** routes virtual calls back into Python: + +
+
+ +**pybind11** + +```c++ +class PyFCNBase : public FCNBase { +public: + using FCNBase::FCNBase; + double operator()( + const std::vector &v) const override { + PYBIND11_OVERLOAD_PURE_NAME( + double, FCNBase, "__call__", operator(), v); + } +}; +``` + +
+
+ +**nanobind** + +```c++ +class PyFCNBase : public FCNBase { +public: + NB_TRAMPOLINE(FCNBase, 2); + double operator()( + const std::vector &v) const override { + NB_OVERRIDE_PURE_NAME( + "__call__", operator(), v); + } +}; +``` + +
+
+ +`*_OVERLOAD/OVERRIDE_PURE_NAME` maps C++'s `operator()` to Python's `__call__`. +The STL header (`` / ``) gives +`std::vector` ↔ list for free. + +--- + +## Parameters & minimizer + +- **Overloads:** `py::overload_cast` / `nb::overload_cast` picks the right `Add` +- **Inheritance:** declare it — `py::class_` +- **Factory ctor:** a lambda takes a plain `unsigned int` strategy + +
+
+ +**pybind11** wraps the lambda: + +```c++ +.def(py::init([](const FCNBase &fcn, + const MnUserParameters &par, + unsigned int stra) { + return MnMigrad(fcn, par, MnStrategy(stra)); + }), "fcn"_a, "par"_a, "stra"_a = 1); +``` + +
+
+ +**nanobind** placement-`new`s into `__init__`: + +```c++ +.def("__init__", [](MnMigrad *self, + const FCNBase &fcn, + const MnUserParameters &par, + unsigned int stra) { + new (self) MnMigrad(fcn, par, MnStrategy(stra)); + }, "fcn"_a, "par"_a, "stra"_a = 1); +``` + +
+
+ +`_a` literals give named arguments with defaults, just like `Vector2D`. + +--- + +## Build it: CMake + scikit-build-core + +`pybind11_add_module` / `nanobind_add_module` build and `install` the module; +scikit-build-core drives it from `pyproject.toml`: + +
+
+ +```toml +[build-system] +requires = [ + "scikit-build-core", + "pybind11", +] +build-backend = "scikit_build_core.build" +``` + +
+
+ +```toml +[build-system] +requires = [ + "scikit-build-core", + "nanobind", +] +build-backend = "scikit_build_core.build" +``` + +nanobind also wants an explicit +`find_package(Python ...)` in CMake. + +
+
+ +The result of `FunctionMinimum` just needs `__str__` — stream the C++ object into +a string (nanobind returns it via ``). + +--- + +## One Python API, either backend + +Install and run in one step — the sample mirrors the C++ program: + +```python +import minuit2 + + +class SimpleFCN(minuit2.FCNBase): + def Up(self): + return 0.5 + + def __call__(self, v): + return v[0] ** 2 + + +upar = minuit2.MnUserParameters() +upar.Add("x", 1.0, 0.1) +minimum = minuit2.MnMigrad(SimpleFCN(), upar)() +print(minimum) # same output as the C++ version +``` + +```bash +uv run sample.py +``` + +--- + +## Summary + +- **pybind11 / nanobind** = header-only C++ binding, no new language, grown one + `.def` at a time +- Bind **only what you use** — no need to wrap the whole library +- **nanobind** mirrors pybind11 but compiles faster and ships smaller binaries; + STL casters are opt-in, trampolines and factory ctors differ slightly +- Real bindings are **split into pieces**, use **trampolines** for virtuals, and + handle overloads with `overload_cast` +- **scikit-build-core + CMake** package it; the **Python API is identical** either + way diff --git a/slides/2_03_rust.md b/slides/2_03_rust.md new file mode 100644 index 0000000..2dcb817 --- /dev/null +++ b/slides/2_03_rust.md @@ -0,0 +1,320 @@ +--- +marp: true +theme: simplepy +paginate: true +_paginate: skip +--- + +# SIMPLE-Py + +## Rust, PyO3, and Maturin + +--- + +## Rust is already in your stack + +You have almost certainly run Rust today, even if you have never written a +line of it: + +- **ruff** lints your Python, **uv** resolves and installs your packages +- **Polars** crunches your dataframes, **pydantic-core** validates your models +- **cryptography** guards your TLS connections + +All Python-facing tools with a Rust core, shipped to PyPI as ordinary wheels. + +
+ +Today: a real extension module in Rust - the whole toolchain, compiler +included, installed by pixi - and a **19× speedup** along the way. + +--- + +## Why Rust (and not C or C++)? + +- **Memory safety without a garbage collector** - use-after-free, + double-free, a forgotten reference count: compile errors instead of + segfault whack-a-mole, with performance still in C territory +- **A real package manager** - cargo resolves, builds, and tests with one + tool; adding a library is one line in a manifest, not a CMake scavenger + hunt + +
+ +The borrow checker earns some of its reputation - but extension-module code +(small numeric kernels, tight loops, parsing) needs only a small subset of +Rust, and PyO3 hides most of the sharp edges. + +--- + +## The stack, translated + +| Rust | Python analog | Role | +| ------------ | ----------------- | -------------------------------- | +| cargo | pip | installs dependencies and builds | +| crates.io | PyPI | the public package index | +| `Cargo.toml` | `pyproject.toml` | metadata and dependencies | +| PyO3 | pybind11 | the binding layer | +| maturin | scikit-build-core | the PEP 517 build backend | + +The last row is the important one: **maturin is just another build +backend**. The same `pyproject.toml` plumbing as ever - pip, uv, and pixi +build and install the project like any other Python package. + +--- + +## Setup: pixi installs the compiler + +Nearly every PyO3 tutorial begins with "install Rust with rustup." We won't: + +```toml +[workspace] +channels = ["conda-forge"] + +[tasks.develop] +cmd = "maturin develop" + +[dependencies] +rust = ">=1.85" +maturin = ">=1.9" +python = ">=3.12" +``` + +- One `pixi install`, nothing global: `rustc`, `cargo`, clippy, `rustfmt` +- Commit **both** lockfiles: `pixi.lock` (toolchain), `Cargo.lock` (crates) + +--- + +## Project layout + +`maturin new -b pyo3` scaffolds it - two manifests and one source file: + +```text +2_04_rust_pyo3/ +├── pixi.toml # toolchain and tasks +├── pyproject.toml # Python package metadata +├── Cargo.toml # Rust package metadata +├── src/ +│ └── lib.rs # the extension module itself +├── tests/ +│ └── test_pyo3_example.py +└── bench.py # the benchmark +``` + +- `pyproject.toml`: the usual shape - only `build-backend = "maturin"` + and `features = ["pyo3/extension-module"]` are new +- `Cargo.toml`: its Rust twin - one dependency (`pyo3`) and + `crate-type = ["cdylib"]`: the C shared library CPython imports + +--- + +## The module: `src/lib.rs` + +```rust +use pyo3::prelude::*; + +/// A Python module implemented in Rust. +#[pymodule] +mod pyo3_example { + use pyo3::prelude::*; + + /// Formats the sum of two numbers as string. + #[pyfunction] + fn sum_as_string(a: usize, b: usize) -> PyResult { + Ok((a + b).to_string()) + } + + // ... a class and two more functions, later in this deck +} +``` + +--- + +## Reading it as a Pythonista + +- `use pyo3::prelude::*;` - `from pyo3.prelude import *`; Rust preludes are + curated for exactly this +- `///` comments become `__doc__` +- `#[pymodule]` and `#[pyfunction]` look like decorators but run at + *compile time*, generating the entry point `import pyo3_example` needs +- `fn` is `def` with type annotations that are *enforced*: `usize` converts + from `int` at the boundary - `OverflowError` if negative or too large +- `PyResult` is "a `str`, or a Python exception" - errors are + values, and returning `Err` raises +- No `return` needed: the last expression in a block is its value + +--- + +## The develop loop + +```console +$ pixi run develop +🐍 Found CPython 3.14 at .../.pixi/envs/default/bin/python + Compiling pyo3_example v0.1.0 +🛠 Installed pyo3_example-0.1.0 +``` + +```pycon +>>> import pyo3_example +>>> pyo3_example.sum_as_string(2, 3) +'5' +``` + +- maturin detects a pixi environment just like a virtualenv +- After each edit, rerun `pixi run develop` - imports won't pick up new Rust + +--- + +## How fast? + +```console +$ pixi run bench +✨ Pixi task (develop-release): maturin develop --release +✨ Pixi task (bench): python bench.py +count_primes(1_000_000), best of 3 runs: + pure Python: 1.601 s + Rust (PyO3): 0.084 s + speedup: 19x +``` + +- A line-for-line transcription of the same trial-division loop - no + algorithm change, no tuning +- **Benchmark release builds only**: `maturin develop` compiles a *debug* + build - 10-50× slower, it can even lose to pure Python. Here `bench` + depends on `develop-release`, so it cannot get this wrong +- The design rule: move the **loop** into Rust, not the loop body + +--- + +## Classes: a `struct` holds the data + +Still inside the `mod pyo3_example` block: + +```rust + /// A 2D point, exposed to Python as a class. + #[pyclass] + struct Point { + #[pyo3(get)] + x: f64, + #[pyo3(get)] + y: f64, + } +``` + +- A `struct` is pure data - named, typed fields; behavior lives in a + separate `impl` block (next slide) +- `#[pyo3(get)]` generates a read-only attribute - a `@property` without + the boilerplate; there is no `set`, so assigning to `p.x` raises + `AttributeError` + +--- + +## ...and `#[pymethods]` holds the behavior + +```rust + #[pymethods] + impl Point { + #[new] + fn new(x: f64, y: f64) -> Self { + Point { x, y } + } + + /// Distance from the origin. + fn magnitude(&self) -> f64 { + (self.x * self.x + self.y * self.y).sqrt() + } + } +``` + +`#[new]` marks `__init__`; `&self` is `self`, *borrowed* rather than owned. +A `__repr__` method (elided here) wires straight into the Python protocol: + +```pycon +>>> p = pyo3_example.Point(3.0, 4.0) +>>> p.magnitude() +5.0 +>>> p +Point(x=3.0, y=4.0) +``` + +--- + +## Errors that feel native + +```rust + /// Divides `a` by `b`, raising ZeroDivisionError like Python's `/`. + #[pyfunction] + fn checked_div(a: f64, b: f64) -> PyResult { + use pyo3::exceptions::PyZeroDivisionError; + + if b == 0.0 { + return Err(PyZeroDivisionError::new_err("division by zero")); + } + Ok(a / b) + } +``` + +```pycon +>>> pyo3_example.checked_div(1.0, 0.0) +Traceback (most recent call last): + ... +ZeroDivisionError: division by zero +``` + +- `pyo3::exceptions` mirrors the builtins - `except ZeroDivisionError:` works +- A Rust *panic* can't take down the interpreter - PyO3 raises `PanicException` + +--- + +## The GIL (and life without it) + +```rust + /// Counts primes below `limit` by trial division. + #[pyfunction] + fn count_primes(py: Python<'_>, limit: u64) -> u64 { + // Release the GIL so other Python threads can run during the hot loop. + py.detach(|| { + let mut count = 0; + for n in 2..limit { + // ... the trial-division loop, straight from bench.py + } + count + }) + } +``` + +- `py: Python<'_>`: a token proving this thread holds the GIL - PyO3 + supplies it, so Python callers still pass just `limit` +- `py.detach(|| ...)` releases the GIL for the hot loop, like NumPy around + its C loops - the *compiler* rejects closures that touch Python objects +- Free-threaded CPython removes the GIL; Rust's `Send`/`Sync` checks audit + your extension for data races + +--- + +## Shipping wheels + +```console +$ pixi run maturin build --release +📦 Built wheel for CPython 3.14 to .../target/wheels/ + pyo3_example-0.1.0-cp314-cp314-manylinux_2_28_x86_64.whl +``` + +- Read the filename like a shipping label: `cp314-cp314` - exactly + CPython 3.14; `manylinux_2_28` - glibc ≥ 2.28, audited by maturin itself +- **abi3**: the `abi3-py310` feature makes one `cp310-abi3` wheel per + platform cover every CPython from 3.10 on - how cryptography ships +- Nobody builds the matrix by hand: `maturin generate-ci github` prints a + GitHub Actions release workflow; cibuildwheel supports maturin too + +--- + +## Try it yourself, then go deeper + +`pixi run test` should pass before you change anything. Then: + +1. Add an `is_prime(n)` `#[pyfunction]` - the trial-division test inside + `count_primes` is the algorithm +2. Add `Point.distance_to(other)` - the origin to `Point(3.0, 4.0)` is `5.0` +3. Extend `tests/test_pyo3_example.py` to cover both - `pixi run test` again + +**Further reading:** the [PyO3 user guide](https://pyo3.rs/) · the [maturin user guide](https://www.maturin.rs/) · [rust-numpy](https://github.com/PyO3/rust-numpy) · [the Rust Book](https://doc.rust-lang.org/book/) diff --git a/slides/2_03_cibuildwheel.md b/slides/2_04_cibuildwheel.md similarity index 100% rename from slides/2_03_cibuildwheel.md rename to slides/2_04_cibuildwheel.md diff --git a/slides/4_03_dynamic_metadata.md b/slides/4_03_dynamic_metadata.md new file mode 100644 index 0000000..defafce --- /dev/null +++ b/slides/4_03_dynamic_metadata.md @@ -0,0 +1,107 @@ +--- +marp: true +theme: simplepy +paginate: true +_paginate: skip +--- + +# SIMPLE-Py + +## Scikit-build-core: Dynamic metadata + +--- + +- `setuptools-scm` is cool +* Wanted even more control? +* Wanted to inject your own helpers? + +--- + +# Example: `pyproject.toml` +```toml +[build-system] +requires = ["scikit-build-core"] +build-backend = "scikit_build_core.build" + +[project] +name = "mypackage" +dynamic = ["version"] + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.setuptools_scm] +``` + +--- + +# You can chain them + +```toml +[project] +name = "mypackage" +dynamic = ["version", "dependencies"] + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.setuptools_scm" + +[[tool.dynamic-metadata]] +provider = "scikit_build_core.metadata.template" +field = "dependencies" +result = ["mypackage-core == {project['version']}"] + +[tool.setuptools_scm] +``` + +--- + +# There are many built-ins + +```toml +[project] +name = "mypackage" +dynamic = ["dependencies"] + +[[tool.dynamic-metadata]] +provider = "dynamic_metadata.plugins.pin_installed" +packages = ["torch==x.x.*"] +``` + +--- + +# And you can write your own + + +
+
+ +```toml +[project] +name = "mypackage" +dynamic = ["version"] + +[[tool.dynamic-metadata]] +provider = {path = "helpers/plugins", module = "my_plugin"} +``` + +
+
+ + +```python +def dynamic_metadata( + settings: Mapping[str, Any], + project: Mapping[str, Any], +) -> dict[str, Any]: + return {"version": "1.2.3"} +``` + +
+
+ +--- + +# Caveats + +- It is only implemented in `scikit-build-core` right now +- Not even as scikit-build-core plugin diff --git a/slides/4_05_overrides.md b/slides/4_05_overrides.md new file mode 100644 index 0000000..214fbce --- /dev/null +++ b/slides/4_05_overrides.md @@ -0,0 +1,145 @@ +--- +marp: true +theme: simplepy +paginate: true +_paginate: skip +--- + +# SIMPLE-Py + +## Scikit-build-core: The override system + +--- + +# An example says it all + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +cmake.define.BUILD_MPI = true +``` +- If `WITH_MPI` is not defined, it provides a `-DBUILD_MPI=OFF` +- If `WITH_MPI` is `ON`, it provides a `-DBUILD_MPI=ON` + +--- + +# Example scenarios + +- Specifying different CMake defines in the CI +- Providing platform specific default settings +- Change the build flags based on Python and/or CMake version +- Fail early on unsupported environments +- Download CMake dependencies when building from sdist, but not when building locally +- Adjust SPDX license if using bundled dependencies + +--- + +# `if` conditional + +- Left side: what to test against e.g. `env`, `python-version` +- Right side: regex pattern, version specifier (`>=4.0`), bool +- Full list in + +--- + +# Things to consider +## Order of tables is important + +
+
+ +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +cmake.define.BUILD_MPI = true + +[[tool.scikit-build.overrides]] +if.platform-system = "win32" +cmake.define.BUILD_MPI = false +``` +Always `-DBUILD_MPI=OFF` on windows + +
+
+ +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false + +[[tool.scikit-build.overrides]] +if.platform-system = "win32" +cmake.define.BUILD_MPI = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +cmake.define.BUILD_MPI = true +``` +Windows override is a no-op + +
+
+ +--- + +# Things to consider +## `inherit` and tables + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false +cmake.define.BUILD_TESTS = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +cmake.define.BUILD_MPI = true +cmake.define.MPI_PROC = "2" +``` +- `-DBUILD_TESTS` is not defined +- `-DBUILD_MPI=ON` +- `-DMPI_PROC=2` + +--- + +# Things to consider +## `inherit` and tables + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false +cmake.define.BUILD_TESTS = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +inherit.cmake.define = "append" +cmake.define.BUILD_MPI = true +cmake.define.MPI_PROC = "2" +``` +- `-DBUILD_TESTS=OFF` +- `-DBUILD_MPI=ON` +- `-DMPI_PROC=2` + +--- + +# Things to consider +## `inherit` and tables + +```toml +[tool.scikit-build] +cmake.define.BUILD_MPI = false +cmake.define.BUILD_TESTS = false + +[[tool.scikit-build.overrides]] +if.env.WITH_MPI = "ON" +inherit.cmake.define = "prepend" +cmake.define.BUILD_MPI = true +cmake.define.MPI_PROC = "2" +``` +- `-DBUILD_TESTS=OFF` +- `-DBUILD_MPI=OFF` +- `-DMPI_PROC=2` diff --git a/slides/6_01_free_threading.md b/slides/6_01_free_threading.md new file mode 100644 index 0000000..c5f27c3 --- /dev/null +++ b/slides/6_01_free_threading.md @@ -0,0 +1,190 @@ +--- +marp: true +theme: simplepy +paginate: true +_paginate: skip +--- + +# SIMPLE-Py + +## Free-threading + +--- + +## The GIL + +For most of Python's history, one lock — the **Global Interpreter Lock** — has +let only **one thread run Python at a time**. + +- Great for overlapping **I/O** +- Useless for **CPU-bound** work across cores +- The workaround was **multiprocessing**: pickling, process startup, no shared memory + +Threads existed, but they couldn't make your computation faster. + +--- + +## Free-threading arrives + +[PEP 703](https://peps.python.org/pep-0703/) makes the GIL **optional**. + +- **3.13** — experimental debut +- **3.14** — it got *fast* (what we require here) +- A separate build, tagged with a `t`: **`python3.14t`** + +```pycon +>>> import sys +>>> sys._is_gil_enabled() +False +``` + +With the GIL off, threads run Python in **parallel on every core**. + +--- + +## An embarrassingly parallel example + +Estimate $\pi$ by throwing darts into $[-1, 1]^2$ and counting hits inside the +unit circle — the fraction approaches $\pi/4$. + +Each dart is **independent**, so we run a batch per thread and average. + +```python +def pi_in_threads(threads: int, trials: int) -> float: + chunks = [trials // threads] * threads + with ThreadPoolExecutor(max_workers=threads) as executor: + return statistics.mean(executor.map(pi, chunks)) +``` + +The same runner drives every version below — only `pi` changes. + +--- + +## Pure Python + +
+
+ +Just a loop over `random`: + +```python +def pi(trials): + ran = random.Random() + inside = 0 + for _ in range(trials): + x = ran.uniform(-1, 1) + y = ran.uniform(-1, 1) + if x * x + y * y <= 1: + inside += 1 + return 4.0 * inside / trials +``` + +
+
+ +On `python3.14t`: + +```text + 1 threads: 2.22 s + 2 threads: 1.13 s + 4 threads: 0.60 s + 8 threads: 0.43 s +``` + +Drop the `t` and the times stay **flat** — that's the GIL serializing threads. + +
+
+ +--- + +## The catch: extensions must opt in + +An extension has to **declare it doesn't need the GIL**. + +Import *any* extension that hasn't opted in, and CPython silently switches the +GIL back **on** (with a warning) to keep that code safe. + +> Every extension in the process must be free-threading-aware, +> or **nobody** gets the speedup. + +The compute moves to C++ — the opt-in is the interesting one line. + +--- + +## Compiled: two ways to opt in + +
+
+ +### pybind11 + +In the module macro: + +```cpp +PYBIND11_MODULE(_core, m, + py::mod_gil_not_used()) { + m.def("pi", &pi); +} +``` + +
+
+ +### nanobind + +In CMake — code unchanged: + +```cmake +nanobind_add_module( + _core FREE_THREADED + freecomputepi/_core.cpp) +``` + +
+
+ +Same Monte Carlo loop, same near-linear scaling — **an order of magnitude +faster per thread** (`0.26 s → 0.07 s`, 1→8 threads). + +--- + +## A promise, not a shield + +Declaring the module GIL-free tells CPython **"I have no unguarded shared +state."** + +- Our `pi` uses only **local variables** → safe +- Global caches or shared buffers → need real locking first + - `std::mutex`, atomics, or nanobind's `nb::ft_mutex` + +Raw C API makes the same promise with a slot: +`{Py_mod_gil, Py_MOD_GIL_NOT_USED}`. + +--- + +## Building wheels + +Free-threaded wheels get their own ABI tag: **`cp314t`**, separate from `cp314`. + +As of 3.14 it's **no longer experimental** — no `enable` needed: + +```toml +[tool.cibuildwheel] +build = "cp314*" +``` + +`cp314*` matches both `cp314` and `cp314t`, so each job emits **both** wheels. +Free-threaded users automatically get the `t` wheel. + +--- + +## Summary + +- The **GIL** let only one thread run Python; free-threading (**PEP 703**) makes + it optional — fast in **3.14t** +- **Pure Python** threads finally scale across cores without the GIL +- **Compiled** extensions must **opt in**, or importing them turns the GIL back on + - pybind11: `py::mod_gil_not_used()` · nanobind: `FREE_THREADED` +- The opt-in is a **promise** — guard any shared state before making it +- Ship it with cibuildwheel's **`cp314*`** for both regular and `cp314t` wheels diff --git a/slides/6_02_lazy_imports.md b/slides/6_02_lazy_imports.md new file mode 100644 index 0000000..e124028 --- /dev/null +++ b/slides/6_02_lazy_imports.md @@ -0,0 +1,219 @@ +--- +marp: true +theme: simplepy +paginate: true +_paginate: skip +--- + +# SIMPLE-Py + +## Lazy imports + +--- + +## Importing costs you + +Every `import` at the top of a file **runs that module** — right then. + +```python +import argparse +import numpy # loaded even for `--help` + + +def main(): + args = argparse.ArgumentParser().parse_args() + if args.foo: + print(numpy.array([1, 2, 3])) +``` + +- A library pays this **once** +- A CLI pays it on **every** invocation, including `--help` +- `uv` doesn't pre-compile bytecode → the first import is slower still + +--- + +## Where it hurts + +
+
+ +The re-export `__init__.py`: + +```python +from . import a +from . import b + +__all__ = ["a", "b"] +``` + +`import lib` drags in **`b`** even if +you only touch `lib.a`. + +
+
+ +Subcommand CLIs: + +- Each subcommand needs + **different** dependencies +- Importing the package pulls in + **all** of them + +
+
+ +Careful libraries (like `rich`) ask for explicit imports — many older ones don't. + +--- + +## Python 3.15: `lazy import` + +[PEP 810](https://peps.python.org/pep-0810/) makes imports **opt-in lazy**. + +```python +lazy import argparse +lazy import numpy +``` + +- The `import` statement does **nothing** — the module might not even be installed +- First **attribute access** turns it into a real, imported object +- Run `--help`, never touch `numpy` → it's **never imported** + +```bash +uv python install 3.15 # alpha, but one command away +``` + +--- + +## Works on older Pythons too + +A back-compat spelling — just not lazy before 3.15: + +```python +__lazy_modules__ = ["argparse", "numpy"] + +import argparse +import numpy +``` + +- A plain list of **absolute** module names → generate it dynamically if you like +- Ruff already allows it above your imports without an import-order complaint + +> Testing knobs: `-X lazy_imports=all` / `PYTHON_LAZY_IMPORTS=all` +> (also `normal`, `none`) — flip `all` on to estimate the payoff. + +--- + +## When *not* to be lazy + +
+
+ +**Import side effects** — a guarded optional dep can't be lazy: + +```python +try: + import numpy +except ModuleNotFoundError: + ... +``` + +Semi-lazy alternative: + +```python +if find_spec("numpy") is None: + ... +lazy import numpy +``` + +
+
+ +**Top-level use** buys nothing: + +```python +lazy import re + +REGEX = re.compile(...) # loaded now +``` + +Defer it behind a cache: + +```python +@functools.cache +def regex() -> re.Pattern: + return re.compile(...) +``` + +
+
+ +Making these lazy just **relocates** the import — leave them eager. + +--- + +## A tool: flake8-lazy + +Deciding what to defer by hand is fiddly. `flake8-lazy` finds it for you. + +```bash +uvx flake8-lazy # flake8-style errors +uvx flake8-lazy --format=lazy-modules # the lines to add +uvx flake8-lazy --apply=list # just add them +``` + +| Group | Checks | +| ----- | ---------------------------------------------------------- | +| `1xx` | module **should** be lazy (`LZY101` stdlib, `LZY102` other) | +| `2xx` | `__lazy_modules__` sorted / unique / absolute / positioned | +| `3xx` | native `lazy` keyword issues (3.15+ host) | +| `4xx` | declared lazy but used at top level — **not** worth it | + +`--apply` writes `list`, `set`, `native`, or `dynamic` — in place. + +--- + +## Tips + +- **Don't** run it on your test suite — tests use what they import +- Profile with **`-X importtime`**; what went lazy drops off the list +- Skip `typing` at runtime: + + ```python + TYPE_CHECKING = False + if TYPE_CHECKING: + import numpy + ``` + +- Relative imports stay static — build absolute names: + + ```python + __lazy_modules__ = [f"{__spec__.parent}.thing"] + from . import thing + ``` + +--- + +## Results + +Timing `--help` on Python 3.15 after running the tool: + +| Package | Before | After | Speedup | +| ------------ | ------- | ----- | ------- | +| flake8-lazy | 100+ ms | 50 ms | 2x | +| repo-review | 113 ms | 35 ms | 3x | +| cibuildwheel | 179 ms | 61 ms | 3x | + +Floor is ~**15 ms** (Python startup) — and `uv`'s no-precompile means the +**first-run** savings are bigger than these warm numbers show. + +--- + +## Summary + +- Imports **run on import** — wasted for code paths you don't hit +- **PEP 810 / 3.15**: `lazy import` defers until first attribute access +- **`__lazy_modules__`** is the back-compat spelling (works everywhere) +- Skip laziness for **side effects** and **top-level use** +- **`flake8-lazy`** finds and applies it — `uvx flake8-lazy --apply=list` +- CLI `--help` gets **2–3x** faster; floor is Python's own startup