From 4b954d48403127384940af25b7ccc0b8561fe205 Mon Sep 17 00:00:00 2001 From: MusicalNinjaDad <102677655+MusicalNinjaDad@users.noreply.github.com> Date: Wed, 22 May 2024 17:57:58 +0200 Subject: [PATCH] Tips & tricks documentation (#37) --- .devcontainer/devcontainer.json | 27 ++- .github/workflows/docs.yml | 62 +++++++ .gitmodules | 0 docs/coverage.md | 3 + docs/dev-build.md | 295 +++++++++++++++++++++++++++++++ docs/dev-environment.md | 108 +++++++++++ docs/documentation.md | 3 + docs/index.md | 19 ++ docs/packaging.md | 182 +++++++++++++++++++ docs/structure.md | 133 ++++++++++++++ docs/stylesheets/admonitions.css | 102 +++++++++++ docs/testing.md | 247 ++++++++++++++++++++++++++ justfile | 17 +- mkdocs.yml | 79 +++++++++ pyproject.toml | 250 +++++++++++++------------- rust/fizzbuzzo3/src/lib.rs | 22 +-- tests/test_fizzbuzzo3.py | 26 ++- 17 files changed, 1420 insertions(+), 155 deletions(-) create mode 100644 .github/workflows/docs.yml delete mode 100644 .gitmodules create mode 100644 docs/coverage.md create mode 100644 docs/dev-build.md create mode 100644 docs/dev-environment.md create mode 100644 docs/documentation.md create mode 100644 docs/index.md create mode 100644 docs/packaging.md create mode 100644 docs/structure.md create mode 100644 docs/stylesheets/admonitions.css create mode 100644 docs/testing.md create mode 100644 mkdocs.yml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 17cfaa7..9a1ef2a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,6 @@ "extensions": [ // rust "rust-lang.rust-analyzer", - "andrewbrey.rust-test-highlight", // python "ms-python.python", "ms-python.vscode-pylance", @@ -13,6 +12,9 @@ // configs, docs, etc. "DavidAnson.vscode-markdownlint", "tamasfe.even-better-toml" + // DISABLE spell checking for now as exclusions not working ... TODO + // "streetsidesoftware.code-spell-checker", + // "streetsidesoftware.code-spell-checker-british-english" ], "settings": { // rust @@ -31,6 +33,25 @@ 120 ] }, + // docs + "markdownlint.config": { + "MD013": false, // let the editor wrap lines not the author + // multi-paragraph admonitions in mkdocs-material are considered indented code blocks + // see also ... for possible improvements via a plugin: + // - https://github.com/DavidAnson/vscode-markdownlint/issues/180 + // - https://github.com/DavidAnson/vscode-markdownlint/issues/302 + // - https://github.com/DavidAnson/markdownlint/issues/209 + "MD046": false // {"style": "fenced"} leads to errors on codeblocks in admonitions + }, + "[markdown]": { + "editor.tabSize": 2, + "editor.detectIndentation": false + }, + // DISABLE spell checking for now as exclusions not working ... TODO + // "cSpell.language": "en", + // "cSpell.checkOnlyEnabledFileTypes": true, + // "cSpell.enabledFileTypes": {"markdown": true, "json": false}, + // shell "terminal.integrated.defaultProfile.linux": "bash", "terminal.integrated.profiles.linux": { @@ -41,7 +62,5 @@ } } }, - "updateContentCommand": { - "reset-check": "just reset check" - } + "updateContentCommand": "just reset check" } \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..29d0fa6 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,62 @@ +name: Deploy mkdocs site to Pages + +on: + push: + branches: + - "main" + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Setup Pages + id: pages + uses: actions/configure-pages@v4 + - name: Build with mkdocs + run: mkdocs build + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./site + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29..0000000 diff --git a/docs/coverage.md b/docs/coverage.md new file mode 100644 index 0000000..f2b3a7d --- /dev/null +++ b/docs/coverage.md @@ -0,0 +1,3 @@ +# Code coverage + +... :material-traffic-cone: coming soon :material-traffic-cone: ... diff --git a/docs/dev-build.md b/docs/dev-build.md new file mode 100644 index 0000000..13a5a22 --- /dev/null +++ b/docs/dev-build.md @@ -0,0 +1,295 @@ +# Developing and Building + +This section won't go into the actual coding of your core code in rust - see the excursions for that. Assuming that you have your core code written (in rust): + +## Wrapping with pyo3 + +The [relevant section of the pyo3 book](https://pyo3.rs/latest/rust-from-python) does a great job of explaining how to wrap your code, so I'll just touch the highlights here: + +!!! pyo3 "rust/fizzbuzzo3/Cargo.toml" + 1. Name your library the way you want the module to appear in python. For example to import using `from fizzbuzz import fizzbuzzo3` + 1. Use the `cdylib` library type + 1. Add a dependency to `pyo3` + + Add the following to **`./rust/fizzbuzzo3/Cargo.toml`** + ```toml + ... + [lib] + name = "fizzbuzzo3" + path = "src/lib.rs" + crate-type = ["cdylib"] # cdylib required for python import, rlib required for rust tests. + + [dependencies] + pyo3 = { git = "https://github.com/MusicalNinjaDad/pyo3.git", branch = "pyo3-testing" } + ... + ``` + + Note: for now this uses a git dependency to a branch on my fork - until either [PR pyo3/#4099](https://github.com/PyO3/pyo3/pull/4099) lands or I pull the testing support out into an independent crate + +!!! warning "Use the same name for the library and exported module" + I have not spent much time trying but I couldn't get the import to work if you have different names for the library and imported module. Trying to rename the library to `fizzbuzzo3lib` leads to a file like `python/fizzbuzz/fizzbuzzo3lib.cpython-312-x86_64-linux-gnu.so` being generated but unusable: + + ```pycon + >>> from fizzbuzz import fizzbuzzo3 + Traceback (most recent call last): + File "", line 1, in + ImportError: cannot import name 'fizzbuzzo3' from 'fizzbuzz' (/workspaces/FizzBuzz/python/fizzbuzz/__init__.py) + >>> from fizzbuzz import fizzbuzzo3lib + Traceback (most recent call last): + File "", line 1, in + ImportError: dynamic module does not define module export function (PyInit_fizzbuzzo3lib) + ``` + + ??? pyo3 "**`rust/fizzbuzzo3/Cargo.toml`** - full source" + ```toml + --8<-- "rust/fizzbuzzo3/Cargo.toml" + ``` + +!!! python "Adding the wrapped module to your project" + I chose to use `setuptools` & `setuptools-rust` as my build backend. Pyo3 offer two backends [`setuptools-rust`](https://github.com/PyO3/setuptools-rust) and [`maturin`](https://github.com/PyO3/maturin). I preferred to try the first because: + + - I am already used to using `setuptools` and didn't want to change out a working system for something else + - I found `setuptools-rust`to be very easy to use + - [The docs](https://pyo3.rs/v0.21.2/building-and-distribution#packaging-tools) point out that it offers more flexibility and fits better to a use case where you may also have independent python code + + Add the following to **`./pyproject.toml`** + ```toml + ... + [build-system] + requires = ["setuptools", "setuptools-rust"] + build-backend = "setuptools.build_meta" + ... + [[tool.setuptools-rust.ext-modules]] + # The last part of the name (e.g. "_lib") has to match lib.name in Cargo.toml, + # but you can add a prefix to nest it inside of a Python package. + target = "fizzbuzz.fizzbuzzo3" + path = "rust/fizzbuzzo3/Cargo.toml" + binding = "PyO3" + features = ["pyo3/extension-module"] # IMPORTANT!!! + ... + ``` + +!!! warning "Avoid errors packaging for linux" + It is important to specify `features = ["pyo3/extension-module"]` in `./pyproject.toml` to avoid linking the python interpreter into your library and failing quality checks when trying to package for linux. + + Background is available by combining the [pyo3 FAQ](https://pyo3.rs/latest/faq#i-cant-run-cargo-test-or-i-cant-build-in-a-cargo-workspace-im-having-linker-issues-like-symbol-not-found-or-undefined-reference-to-_pyexc_systemerror) and [manylinux specification](https://github.com/pypa/manylinux) + + ??? python "**`./pyproject.toml`** - full source" + ```toml + --8<-- "./pyproject.toml" + ``` + +## Python virtual environment & build + +I like to keep things as simple as possible. Python has many virtual environment managers, `venv` is part of the core library and does everything we need while leaving us in control of the entire build and integration process. + +!!! abstract "Quick start with justfile" + The justfile `./justfile` handles all of this for you. Feel free to copy it. + + ??? abstract "**`./justfile`** - full source" + ```justfile + --8<-- "./justfile" + ``` + +??? python "Creating a virtual environment with venv" + If you are unfamiliar with venv here are the [docs](https://docs.python.org/3/library/venv.html) + + Depending on your distro you may need to install venv as a separate package + + Creating a virtual environment is as simple as: + + ```sh + /projectroot$ python -m venv .venv + ``` + +!!! python "Sourcing development dependencies from `./pyproject.toml`" + To provide a single line python build for local development you will need to source your development dependencies from `./pyproject.toml`. These can be split into multiple groups to give more control during automated processes where you don't need everything. + + Add the following to **`./pyproject.toml`** + ```toml + ... + [project.optional-dependencies] + lint = ["ruff"] + test = ["pytest", "pytest-doctest-mkdocstrings"] + cov = ["fizzbuzz[test]", "pytest-cov"] + doc = ["black", "mkdocs", "mkdocstrings[python]", "mkdocs-material"] + dev = ["fizzbuzz[lint,test,cov,doc]"] + ... + ``` + + ??? python "**`./pyproject.toml`** - full source" + ```toml + --8<-- "./pyproject.toml" + ``` + +!!! python "Building and installing the wrapped rust code for use in python development" + Before you can use the wrapped rust code you need to build the equivalent of `python/fizzbuzz/fizzbuzzo3.cpython-312-x86_64-linux-gnu.so`: + + 1. Make sure your virtual environment is active. If not run `. .venv/bin/activate` (note the leading dot, which is easier than typing `source` all the time) + 1. Then simply use `pip` to create an editable installation of your codebase: + ```sh + (.venv)/projectroot$ pip -e.[dev] + ``` + +### cleaning + +!!! abstract "Cleaning up old build artefacts" + As with any built language it is a good idea to clean up old build artefacts before generating new ones, or at least before finalising a change. Cargo offers a simple `cargo clean` for this, but you will also have the python library and various python caches in place which can sometimes cause problems. + + To clean both languages: + ```sh + (.venv)/projectroot$ cargo clean || true + (.venv)/projectroot$ rm -rf .pytest_cache + (.venv)/projectroot$ rm -rf build + (.venv)/projectroot$ rm -rf dist + (.venv)/projectroot$ rm -rf wheelhouse + (.venv)/projectroot$ rm -rf .ruff_cache + (.venv)/projectroot$ find . -depth -type d -not -path "./.venv/*" -name "__pycache__" -exec rm -rf "{}" \; + (.venv)/projectroot$ find . -depth -type d -path "*.egg-info" -exec rm -rf "{}" \; + (.venv)/projectroot$ find . -type f -name "*.egg" -delete + (.venv)/projectroot$ find . -type f -name "*.so" -delete + ``` + + **Or just use `just clean` from the `./justfile`** + + ??? abstract "**`./justfile`** - full source" + ```justfile + --8<-- "./justfile" + ``` + +## API design + +There are a few things to consider when designing your API for python users. + +### Performance + +Assuming part of the reason you are doing this is to provide a performance over native python, you will want to consider the (small but noticeable) performance cost each time you cross the python-rust boundary. [Discussion pyo3/#4085](https://github.com/PyO3/pyo3/discussions/4085) covers this topic, further improvements are promised for the next versions of pyo3. + +!!! pyo3 "Pass `Container`s, don't make multiple calls" + One simple way to avoid crossing the boundary often is to pass a `list` or similar rather than making multiple individual calls. The performance difference can be seen below: + + ```text + Rust: [1 calls of 10 runs fizzbuzzing up to 1_000_000] + [12.454665303001093] + Python: [1 calls of 10 runs fizzbuzzing up to 1_000_000] + [39.32552230800138] + Rust vector: [1 calls of 10 runs fizzbuzzing a list of numbers up to 1_000_000] + [6.319926773001498] + ``` + +!!! rust "Use the `rayon` crate to break the GIL and run parallel calculations" + Adding parallel processing to a rust iterator is _insanely simple_. My impression of rust is "easy things are hard, hard things are easy!" + + I simply added the following to my core rust code **`/rust/fizzbuzz/src/lib.rs`**: + + ```rust hl_lines="2-3 7 10 12-14" + ... + use rayon::prelude::*; + static BIG_VECTOR: usize = 300_000; // Size from which parallelisation makes sense + ... + impl MultiFizzBuzz for Vec + where + Num: FizzBuzz + Sync, + { + fn fizzbuzz(&self) -> FizzBuzzAnswer { + if self.len() < BIG_VECTOR { + FizzBuzzAnswer::Many(self.iter().map(|n| n.fizzbuzz().into()).collect()) + } else { + FizzBuzzAnswer::Many(self.par_iter().map(|n| n.fizzbuzz().into()).collect()) + } + } + } + + ``` + + !!! tip "Check it makes sense" + Adding parallel processing doesn't always make sense as it adds overhead ramping and managing a threadpool. You will want to do some benchmarking to find the sweet-spot. Benchmarking and performance testing is a topic for itself, so I'll add a dedicated section ... + +??? pyo3 "**`rust/fizzbuzzo3/src/lib.rs`** - full source" + ```rust + --8<-- "rust/fizzbuzzo3/src/lib.rs" + ``` + +??? rust "`rust/fizzbuzz/src/lib.rs` - full source" + ```rust + --8<-- "rust/fizzbuzz/src/lib.rs" + ``` + +### Ducktyping & `Union` types + +Remember your primary users are python coders who are used to duck typing :duck:. They will expect `fizzbuzz(3.0)`to return `'3.0'` and `fizzbuzz(3.1)` to return `'3.1'` unless something is documented regarding rounding to the nearest integer or similar. (Leaving aside any discussion on [why floats are inaccurate](https://0.30000000000000004.com/)). + +Python also often provides single functions which can receive multiple significantly different types for a single argument: e.g. `fizzbuzz([1,2,3])` _and_ `fizzbuzz(3)` could easily both work. The function signature would be `#!python def fizzbuzz(n: int | list[int]) -> str:`. + +!!! pyo3 "Use a custom enum and match to allow multiple types" + This is best done directly in your wrapping library as it is part of the rust-python interface not the core functionality. + + In **`rust/fizzbuzzo3/src/lib.rs`** I used this pattern: + ```rust + ... + #[derive(FromPyObject)] + enum FizzBuzzable { + Int(isize), + Float(f64), + Vec(Vec), + } + ... + #[pyfunction] + #[pyo3(name = "fizzbuzz", text_signature = "(n)")] + fn py_fizzbuzz(num: FizzBuzzable) -> String { + match num { + FizzBuzzable::Int(n) => n.fizzbuzz().into(), + FizzBuzzable::Float(n) => n.fizzbuzz().into(), + FizzBuzzable::Vec(v) => v.fizzbuzz().into(), + } + } + ``` + + ??? pyo3 "**`rust/fizzbuzzo3/src/lib.rs`** - full source" + ```rust + --8<-- "rust/fizzbuzzo3/src/lib.rs" + ``` + +!!! warning "`Union` type returns" + If you want to create something like: `#!python def fizzbuzz(n: int | list[int]) -> str | list[str]:` [Issue pyo3/#1637](https://github.com/PyO3/pyo3/issues/1637) suggests you may be able to do something with the `IntoPy` trait but I haven't tried (yet) + +## IDE type & doc hinting + +Pyo3 does a great job automatically exporting inline rust documentation (using `/// ...`) as python docstrings. It also creates [a simple attribute](https://pyo3.rs/latest/function/signature#making-the-function-signature-available-to-python) detailling the function signature, which you can manually adjust. + +IDEs, linters, etc. don't actually import your code to read the docstrings and signatures, they parse the source-code; and with the source in rust, they can't do this directly for your wrapped modules. + +!!! rocket "Autogenerating hints" + Because I hate copy-pasting stuff I created [pyo3-stubgen](https://github.com/MusicalNinjas/pyo3-stubgen) to auto-generate the information. It is available on pypi: `pip install pyo3-stubgen`, has a simple command line interface and can also be called from python if you prefer. + +!!! python "Create a `.pyi` file" + 1. Create a [stub file](https://peps.python.org/pep-0484/#stub-files) with: + - the same name as your exported module + - the extension `.pyi` + - in the location you would otherwise have placed the `module.py` file + 1. Add function definitions with type hints and docstrings but no code + - For functions with no docstrings enter `...` as the function body + 1. Add the `.pyi` extension to the files checked by doctest: + **`./pyproject.toml`**: + ```toml + [tool.pytest.ini_options] + ... + addopts = [ + "--doctest-modules", + ... + "--doctest-glob=*.pyi", + ... + ] + ``` + + ??? python "**`python/fizzbuzz/fizzbuzzo3.pyi`** - full source" + ```python + --8<-- "python/fizzbuzz/fizzbuzzo3.pyi" + ``` + + ??? python "**`./pyproject.toml`** - full source" + ```toml + --8<-- "pyproject.toml" + ``` + +Pyo3 discusses this topic in [Appendix C](https://pyo3.rs/latest/python-typing-hints). diff --git a/docs/dev-environment.md b/docs/dev-environment.md new file mode 100644 index 0000000..eca67d5 --- /dev/null +++ b/docs/dev-environment.md @@ -0,0 +1,108 @@ +# Development Environment + +To work with both python and rust you're going to need a toolchain in place for both eco-systems and be on a Linux(-like) system. I've done all this on Windows with WSL and it's simply awesome! + +I, personally, like to keep my working machine free of the range of different packages and tools that I need for all the different projects I look at. I also like to be able to rebuild a reproducible working environment easily, and let others know which tools they need if they want to contribute. + +[Dev Containers](https://containers.dev/) make this easy, they consist of: + +1. a Dockerfile with the entire set of tools needed for development +1. a json file with IDE settings, extensions and project specific build scripts + +Think of it like this: you wouldn't develop a python project without a virtual environment ... + +## Quick-Start + +!!! abstract "Getting up and running fast" + 1. Install docker (any set up will do but docker desktop works easiest in my experience if you're starting from scratch) + 1. Install the `ms-vscode-remote.remote-containers` extension in VSCode + 1. Copy `.devcontainer/devcontainer.json` from [MusicalNinjaDad/FizzBuzz](https://github.com/MusicalNinjaDad/FizzBuzz) into your project (keeping the location and filename) + 1. Select `Reopen in container`from the toastie or the command bar + +## Packages and tools (Dockerfile) + +!!! rocket "Pre-Build Docker Image" + You can grab a pre-build image to use in your devcontainer for either linux/amd64 or linux/arm64 + + Add to **`.devcontainer/devcontainer.json`**: + ```json + "image": "ghcr.io/musicalninjas/pyo3-devcontainer" + ``` + + If you want to run it directly `docker run -it ghcr.io/musicalninjas/pyo3-devcontainer:latest`. The source is at [MusicalNinjas/devcontainers-pyo3](https://github.com/MusicalNinjas/devcontainers-pyo3) + + I like to use fedora for my dev environment base as it provides the most up-to-date versions of tools via the package manager dnf, I also like to keep the environment as clean as possible, with only the tools that the project needs. You'll find similarly named packages in most distros. + + ??? abstract "**`MusicalNinjas/devcontainers-pyo3/Dockerfile`** - full source" + ```Docker + --8<-- "https://github.com/MusicalNinjas/devcontainers-pyo3/raw/main/Dockerfile" + ``` + + If you are putting together an environment for yourself the important things to note are the packages in the `# Python` and `# Rust (and python headers)` sections. As well as the usual tools for developing in each language you will also need the python headers for pyo3 to use - in the case of fedora that's `python3-devel`. + +!!! python + Most distributions need you to install the python package manager `pip` with a dedicated package. Python is so embedded into the OS that you really don't wan't to go installing a load of packages and a different version of python at the system level. + + There is no point installing a range of other python packages as you will be re-downloading these in a virtual environment anyway to keep your project set up and your system setup separate. + + ```sh + dnf -y install python-pip + ``` + +!!! rust + Most distributions don't bundle a recent version of rust-up, so you need to install rust by `curl`-ing a shell-script as described at [rustup.rs](https://rustup.rs). Fedora is nice. + + To get rust working you'll also need `clang` as a C-compiler and at least `rust-src` so you have the sources of the core libraries + + ```sh + dnf -y install \ + clang \ + python3-devel \ + rustup \ + && rustup-init -v -y \ + && rustup component add rust-src + ``` + + If you're installing rust inside a docker container which will not be run as `root` you need to use `RUSTUP_HOME` and `CARGO_HOME` environment variables to select where to install, make sure they are accessible to all users, add cargo to the `PATH` and then `chown` or `chmod` the entire contents to make them usable by a non-root user. Otherwise the installation goes under `/root` and the files are `rw-rw---- root root` + + Unlike python installing tools up-front via cargo can save quite some time when rebuilding / creating the dev environment as rust tools are compiled from source when you install them. Getting this done once when you build the container rather than every time you create an environment saves a significant number of minutes. + +!!! abstract "Other useful tools" + In addition to the core language components I also found the following tools useful (they're in the `Dockerfile`): + + - [`just`](https://github.com/casey/just): You're going to be running a few multi-step commands. I came across it for the first time during this project and liked the simple approach. I guess you could use `nox`, but that means mentally translating from shell to python and context switching from rust to python if you're in the middle of rust-ing. `make` would be another option, but again, overly complicated for this use case, in my view + - `llvm-tools-preview` and [`grcov`](https://github.com/mozilla/grcov) for getting test coverage of your rust code. There are a few different ways to get rust coverage, after much research and a few unsuccessful starts I found grcov, from mozilla, which pretty much just worked, gave the best quality results and sounds to me like it works in a way that makes most sense + - [`mdbook`](https://github.com/rust-lang/mdBook) is what to use if you want to create rust-style manuals rather than python-style manuals + - [`cargo-expand`](https://github.com/dtolnay/cargo-expand) is invaluable if you end up writing rust proc-macros + +## IDE extensions and settings (json) + +I use VSCode and love language-agnostic approach it takes. Devcontainers will run in IntelliJ as well (I've tried it with a colleague) and in neoVIM (I've read), many of these tips will probably translate to those IDEs too without too much google-ing. + +??? abstract "**`.devcontainer/devcontainer.json`** - full source" + ```json + --8<-- ".devcontainer/devcontainer.json" + ``` + +!!! python + Extensions: + + - `ms-python.python` & `ms-python.vscode-pylance` give you language services, hints and intellisense + - `charliermarsh.ruff` integrates `ruff` as your linter (It's written in rust!) + + Settings: + + - `"python.defaultInterpreterPath": "./.venv/bin/python3"` sets VSCode to use the interpreter your virtual environment + - `"python.testing.pytestEnabled": true` sets VSCode to use [`pytest`](https://https://pytest.org/) and integrates the tests into the Test Explorer + +!!! rust + Extensions: + + - `rust-lang.rust-analyzer` the only extension you need is the official LSP implementation for rust. This handles everything including testing + + Settings: + + - `"rust-analyzer.interpret.tests": true` & `"rust-analyzer.testExplorer": true` enable integrating tests into VSCode + - `"[rust]": {"editor.rulers": [100]}`: rust has a _standard_ formatter which is as opinionated as `black`! You're going to want a ruler at 100 chars! + +## TODO - .gitignore diff --git a/docs/documentation.md b/docs/documentation.md new file mode 100644 index 0000000..668b57f --- /dev/null +++ b/docs/documentation.md @@ -0,0 +1,3 @@ +# Documentation + +... :material-traffic-cone: coming soon :material-traffic-cone: ... diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..2b36198 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,19 @@ +# Combining rust & python + +This aims to be a fully worked example showing how to build python libraries in rust. It is written from the perspective of a rust newbie coming from a python background, which also explains why this documentation in presented in a python mkdocs-style rather than as a rust mdbook. + +I will walk you through the whole process from an empty repo to having packaged, published code in both pypi and crates.io with documentation and tests across both eco-systems.To keep things simple I've chose a well-known kata _fizzbuzz_ so that the focus is not on the algorithm but the ecosystem and "glue". + +!!! abstract "Following this guide" + You should be able to follow through step by step, although if like me you practice TDD you'll want to jump between the Testing and Developing & Building sections. + + Alternatively you should also be able to dive directly into whichever topic you're looking for guidance on right now, without needing to read everything else first. + +I've used github for hosting the repo, CI pipeline and documentation and azure devops in the place of PyPi and crates.io to host the final packages (the world _really_ doesn't need another fizzbuzz implementation spamming public package repositories!). You can view, fork or clone the repo from [github: MusicalNinjaDad/FizzBuzz](https://github.com/MusicalNinjaDad/FizzBuzz) and get the packages from [ADO: MusicalNinjas/FizzBuzz](https://dev.azure.com/MusicalNinjas/FizzBuzz/_artifacts/feed/FizzBuzz). + +The whole process took me a few months from start to finish, working on this in my spare time, with a few excursions along the way - there is a separate section on those (above). See the excursions section at the top (once I start writing them) for info ... + +!!! abstract "Feedback and contributions" + I'd love your feedback and contributions via Issues, Discussions and PRs in [github: MusicalNinjaDad/FizzBuzz](https://github.com/MusicalNinjaDad/FizzBuzz) + + Have fun and I hope this is useful! diff --git a/docs/packaging.md b/docs/packaging.md new file mode 100644 index 0000000..a9f5984 --- /dev/null +++ b/docs/packaging.md @@ -0,0 +1,182 @@ +# Packaging your code + +Publishing your core rust code to crates.io is pretty straightforward, ensuring that your python code can be used by people on different systems and with multiple versions of python needs more work. + +I'll assume you are happy publishing via github actions _in general_ and may add an excursion with more detail later. + +!!! tip "Note:" + I've used azure devops in the place of PyPi and crates.io to host the final packages (the world _really_ doesn't need another fizzbuzz implementation spamming public package repositories!), publishing to the main package stores is easier. + +## Crates.io + +??? rust "Packaging for crates.io" + You can follow the [normal process](https://doc.rust-lang.org/cargo/reference/publishing.html) for publishing to crates.io: + ```sh + /projectroot$ cargo build --release -p fizzbuzz + /projectroot$ cargo package -p fizzbuzz + /projectroot$ cargo publish --package fizzbuzz + ``` + + **Just remember to add `-p fizzbuzz` so you don't accidentally also try to publish your wrapped code!** + +??? rust "Publishing from github actions" + Here is the workflow I used to publish (to ADO): + + **`.github/workflows/deploy-rust.yml`** + ```yaml + --8<-- ".github/workflows/deploy-rust.yml" + ``` + +??? rust "TODO - publishing to ADO" + **`.cargo/config.toml`** + ```yaml + --8<-- ".cargo/config.toml" + ``` + +## Pypi + +The python distribution is significantly more tricky than the rust one in this situation. + +!!! python "Distribution formats - pre-built vs build from source" + Python offers two distribution formats for packages: + + - [Source distributions](https://packaging.python.org/en/latest/specifications/source-distribution-format/) which require the user to have a rust toolchain in place and to compile your code themselves when they install it. + - [Binary distributions](https://packaging.python.org/en/latest/specifications/binary-distribution-format/) which require you to compile your code for a specific combination of operating system, architecture and python version. + +Here's how to successfully provide both and support the widest range of users possible: + +!!! warning "Supporting many linux distros" + Each version of each linux distro has different standard libraries available. To ensure compatibility with as many as possible PyPa defined [`manylinux`](https://github.com/pypa/manylinux) with clear restrictions on build environments. `cibuildhweel` (see below)makes it easy to meet these requirements. + +!!! python "Use `cibuildwheel` from PyPa" + PyPA provide [`cibuildwheel`](https://github.com/pypa/cibuildwheel) to make the process of building wheels for different versions of python and different OS & architectures reasonably simple. + + 1. Configure cibuildwheel to install rust and clean any existing build artefacts before beginning builds, via **`./pyproject.toml`**: + ```toml + ... + [tool.cibuildwheel.linux] + before-all = "just clean" + archs = "all" + + [tool.cibuildwheel.macos] + before-all = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && cargo install just && just clean" + + [tool.cibuildwheel.windows] + before-all = "rustup target add aarch64-pc-windows-msvc i586-pc-windows-msvc i686-pc-windows-msvc x86_64-pc-windows-msvc && cargo install just && just clean" + ... + ``` + 1. Test each wheel after building, you'll also need [`delvewheel`](https://github.com/adang1345/delvewheel) on windows builds (**`./pyproject.toml`**): + ```toml + ... + [tool.cibuildwheel] + # `test-command` will FAIL if only passed `pytest {package}` + # as this tries to resolve imports based on source files (where there is no `.so` for rust libraries), not installed wheel + test-command = "pytest {package}/tests" + test-extras = "test" + ... + [tool.cibuildwheel.windows] + ... + before-build = "pip install delvewheel" + repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" + ... + ``` + 1. Skip linux architecture / library combinations with no rust version available (**`./pyproject.toml`**): + ```toml + ... + [tool.cibuildwheel] + skip = [ + "*-musllinux_i686", # No musllinux-i686 rust compiler available. + "*-musllinux_ppc64le", # no rust target available. + "*-musllinux_s390x", # no rust target available. + ] + ... + ``` + 1. Set up a build pipeline with a job to run cibuildwheel for you on Windows, Macos & Linux and to create a source distribution. Run the various linux builds using a matrix strategy to reduce your build time significantly. + + ??? python "**`.github/workflows/deploy-python.yml`** - full source" + ```yaml + --8<-- ".github/workflows/deploy-python.yml" + ``` + + ??? python "**`./pyproject.toml`** - full source" + ```toml + --8<-- "./pyproject.toml" + ``` + +!!! rocket "Saving hours by using pre-built images" + If you are wondering why there is no "install rust" command for the linux builds; I created dedicated container images for of pypa's base images with rust, just etc. pre-installed. This saves a load of time during the build, as installing rust and compiling the various components can be very slow under emulation. + + The dockerfile and build process are at [MusicalNinjas/cibuildwheel-rust](https://github.com/MusicalNinjas/cibuildwheel-rust), They get bumped by dependabot every time pypa release a new image for `manylinux2014_x86_64` so should be nice and up to date. + + The images can be used by including the following in your **`./pyproject.toml`**: + ```toml + [tool.cibuildwheel] + ... + # Use prebuilt copies of pypa images with rust and just ready compiled (saves up to 25 mins on emulated archs) + manylinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust" + manylinux-i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust" + manylinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust" + manylinux-ppc64le-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_ppc64le-rust" + manylinux-s390x-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_s390x-rust" + manylinux-pypy_x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust" + manylinux-pypy_i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust" + manylinux-pypy_aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust" + musllinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_x86_64-rust" + musllinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_aarch64-rust" + ... + ``` + + ??? abstract "**`MusicalNinjas/cibuildwheel-rust/Dockerfile`** - full source" + ```Docker + --8<-- "https://raw.githubusercontent.com/MusicalNinjas/cibuildwheel-rust/main/Dockerfile" + ``` + + ??? abstract "**`MusicalNinjas/cibuildwheel-rust/.github/workflows/publish_docker.yml`** - full source" + ```yaml + --8<-- "https://raw.githubusercontent.com/MusicalNinjas/cibuildwheel-rust/main/.github/workflows/publish_docker.yml" + ``` + +!!! warning "avoiding a 6 hour build" + At one point I had a build run for nearly 6 hours before I cancelled it. The problem was that I use `ruff` for linting, and ruff doesn't provide pre-built wheels for some manylinux architectures. That meant that I was building ruff _from scratch_, _under emulation_ for _each wheel_ I built on these architectures! + + Separating the `[test]`, `[lint]`, `[doc]` and `[cov]`erage dependencies solved this; after a lot of searching I also found the way to provide a single `[dev]` set of dependencies combing them all. + + In **`./pyproject.toml`**: + ```toml + ... + [tool.cibuildwheel] + ... + test-extras = "test" + ... + [project.optional-dependencies] + lint = ["ruff"] + test = ["pytest", "pytest-doctest-mkdocstrings"] + cov = ["fizzbuzz[test]", "pytest-cov"] + doc = ["black", "mkdocs", "mkdocstrings[python]", "mkdocs-material"] + dev = ["fizzbuzz[lint,test,cov,doc]"] + ... + ``` + +!!! warning "failing wheel tests due to namespace collisions" + I lost over half a day at one point trying to track down the reason that my wheel builds were failing final testing. There were two problems: + + 1. Make sure you clean any pre-compiled `.so` files before building a wheel, otherwise `pip wheel` may decide that you already have a valid `.so` and use that, with the wrong versions of the C-libraries. This is only a problem when doing local wheel builds for debugging, because you're not checking the `.so` files into git are you? (I created a `just clean` command for this) + 1. Make sure you directly specify the test path for wheel tests in **`./pyproject.toml`**: + ```toml + ... + [tool.cibuildwheel] + # `test-command` will FAIL if only passed `pytest {package}` + # as this tries to resolve imports based on source files (where there is no `.so` for rust libraries), not installed wheel + test-command = "pytest {package}/tests" + ... + ``` + otherwise the directive `tool.pytest.ini_options.testpaths = ["tests", "python"]` will cause pytest to look in `python`, find your namespace, and ignore the installed wheel! + + ??? python "**`./pyproject.toml`** - full source" + ```toml + --8<-- "./pyproject.toml" + ``` + ??? python "**`./justfile`** - full source" + ```justfile + --8<-- "./justfile" + ``` diff --git a/docs/structure.md b/docs/structure.md new file mode 100644 index 0000000..bda88c4 --- /dev/null +++ b/docs/structure.md @@ -0,0 +1,133 @@ +# Structuring the codebase + +## Basic structure + +!!! abstract "Keep your rust, wrapped-rust and python code separate" + You want a top level directory structure like this - with directories for `rust`, `python` and `tests`. Within the `rust` directory you want separate directories for your main logic and the wrapped exports for python. + + ```text + FizzBuzz + - rust + - fizzbuzz + - src + ... core rust code goes here + - tests + ... tests for core rust code go here + - fizzbuzzo3 + - src + ... pyo3 code goes here + - python + - fizzbuzz + ... additional python code goes here + - tests + ... python tests go here + ``` + +!!! warning "Warning: Import errors" + Having any top-level directories with names that match your package leads to all kinds of fun import errors. Depending on the exact context, python can decide that you have implicit namespace packages which collide with your explicit package namespaces. + + I ran into problems twice: + + - firstly, I had a time where my tests ran fine but I couldn't import my code in repl; + - later, the final wheels were unusable but sdist was fine. + + There are also [reports](https://github.com/PyO3/maturin/issues/490) of end-users being the first to run into trouble + + This is also the reason for keeping your final set of python tests in a separate top-level directory: you can be sure they are using the right import logic. + +## Considerations + +1. When you distribute the final python library as source the primary audience are python-coders, make it understandable for them without a lot of extra explanation by putting your rust code in a clearly marked location +1. You will want to test your code at each stage of integration: core rust, wrapped rust, final python result; so that you can easily track down bugs +1. Context switching between two languages is hard, even if you know both parts of the codebase well. Keeping it structured by language boundary helps when coding. For much larger projects you may want to provide a higher-level structure by domain and _then_ structure each domain as above ... but that's outside the scope of a simple starter how-to :wink: +1. Your underlying code probably does something useful - so you could also publish it to the rust eco-system as a dedicated crate for others to use directly in rust. Those users don't want the extra code wrapping your function for python! + +## Configuration files (pyproject.toml & Cargo.toml) + +!!! python "./pyproject.toml" + We'll be using `setuptools` as the main build agent and `pyproject.toml` to manage all the config and tooling - that way we keep the number of files to a minimum and stick to as few languages as possible. + + The important section is: + + ```toml + [tool.setuptools.packages.find] + where = ["python"] + ``` + + More info on `pyproject.toml` is available from both [pypa - the python packaging authority](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/) and [pip - python's package manager](https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/) + +!!! rust "./Cargo.toml" + You need to set up a [cargo workspace](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html) + + ```toml + [workspace] + + members = [ + "rust/*" + ] + + resolver = "2" + ``` + +!!! pyo3 "rust/fizzbuzzo3/Cargo.toml" + In the `Cargo.toml` for your wrapped code you need to specify the core rust code as a path dependency: + + ```toml + ... + [dependencies] + fizzbuzz = { path = "../fizzbuzz" } + ... + ``` + + Full details on `Cargo.toml` in [the Cargo book](https://doc.rust-lang.org/cargo/reference/manifest.html) + +!!! warning "Avoiding namespace, plug-in and publishing issues" + 1. Point setuptools explicitly to look in `python` - this helps avoid implicit/explicit namespace errors later + 1. Use a cargo workspace to avoid occasional strange errors from rust-analyzer in your IDE and to make running tests, lints etc. easier from the root directory + 1. Use a path dependency to your core code in the wrapped-code `Cargo.toml` so you are always using the latest changes. Without a version tag, any attempts to upload the wrapped code as a crate to fail - but you don't want to do that anyway and this is another safety measure to make sure you never do. + 1. You don't need anything special in your core-code `Cargo.toml` but don't forget to add one! + +!!! abstract "Overview of all config files" + The root has a `pyproject.toml` and `Cargo.toml`, each folder under `rust` also has a `Cargo.toml` + + ```text + FizzBuzz + - rust + - fizzbuzz + - src + ... core rust code goes here + - tests + ... tests for core rust code go here + - Cargo.toml + - fizzbuzzo3 + - src + ... pyo3 code goes here + - Cargo.toml + - python + - fizzbuzz + ... additional python code goes here + - tests + ... python tests go here + - Cargo.toml + - pyproject.toml + ``` + + ??? python "**`./pyproject.toml`** - full source" + ```toml + --8<-- "pyproject.toml" + ``` + + ??? rust "**`./Cargo.toml`** - full source" + ```toml + --8<-- "Cargo.toml" + ``` + + ??? rust "**`rust/fizzbuzz/Cargo.toml`** - full source" + ```toml + --8<-- "rust/fizzbuzz/Cargo.toml" + ``` + + ??? rust "**`rust/fizzbuzzo3/Cargo.toml`** - full source" + ```toml + --8<-- "rust/fizzbuzzo3/Cargo.toml" + ``` diff --git a/docs/stylesheets/admonitions.css b/docs/stylesheets/admonitions.css new file mode 100644 index 0000000..08a8c13 --- /dev/null +++ b/docs/stylesheets/admonitions.css @@ -0,0 +1,102 @@ +:root { + --md-admonition-icon--python: url('data:image/svg+xml;charset=utf-8,') + } + .md-typeset .admonition.python, + .md-typeset details.python { + border-color: rgb(43, 148, 155); + } + .md-typeset .python > .admonition-title, + .md-typeset .python > summary { + background-color: rgba(43, 148, 155, 0.1); + } + .md-typeset .python > .admonition-title::before, + .md-typeset .python > summary::before { + background-color: rgb(43, 148, 155); + -webkit-mask-image: var(--md-admonition-icon--python); + mask-image: var(--md-admonition-icon--python); + } + +:root { + --md-admonition-icon--rust: url('data:image/svg+xml;charset=utf-8,') + } + .md-typeset .admonition.rust, + .md-typeset details.rust { + border-color: rgb(155, 105, 43); + } + .md-typeset .rust > .admonition-title, + .md-typeset .rust > summary { + background-color: rgba(155, 105, 43, 0.1); + } + .md-typeset .rust > .admonition-title::before, + .md-typeset .rust > summary::before { + background-color: rgb(155, 105, 43); + -webkit-mask-image: var(--md-admonition-icon--rust); + mask-image: var(--md-admonition-icon--rust); + } + + :root { + --md-admonition-icon--pyo3: url('data:image/svg+xml;charset=utf-8,') + } + .md-typeset .admonition.pyo3, + .md-typeset details.pyo3 { + border-color: rgb(155, 105, 43); + } + .md-typeset .pyo3 > .admonition-title, + .md-typeset .pyo3 > summary { + background-color: rgba(155, 105, 43, 0.1); + } + .md-typeset .pyo3 > .admonition-title::before, + .md-typeset .pyo3 > summary::before { + background-color: rgb(155, 105, 43); + -webkit-mask-image: var(--md-admonition-icon--pyo3); + mask-image: var(--md-admonition-icon--pyo3); + } + +.md-typeset .admonition.warning, +.md-typeset details.warning { + border-color: rgb(155, 43, 43); +} +.md-typeset .warning > .admonition-title, +.md-typeset .warning > summary { + background-color: rgba(155, 43, 43, 0.1); +} +.md-typeset .warning > .admonition-title::before, +.md-typeset .warning > summary::before { + background-color: rgb(155, 43, 43); + -webkit-mask-image: var(--md-admonition-icon--warning); + mask-image: var(--md-admonition-icon--warning); +} + + +.md-typeset .admonition.tip, +.md-typeset details.tip { + border-color: rgb(158, 158, 158); +} +.md-typeset .tip > .admonition-title, +.md-typeset .tip > summary { + background-color: rgba(158, 158, 158, 0.1); +} +.md-typeset .tip > .admonition-title::before, +.md-typeset .tip > summary::before { + background-color: rgb(158, 158, 158); + -webkit-mask-image: var(--md-admonition-icon--tip); + mask-image: var(--md-admonition-icon--tip); +} + +:root { + --md-admonition-icon--rocket: url('data:image/svg+xml;charset=utf-8,') +} +.md-typeset .admonition.rocket, +.md-typeset details.rocket { + border-color: rgb(43, 155, 43); +} +.md-typeset .rocket > .admonition-title, +.md-typeset .rocket > summary { + background-color: rgba(43, 155, 43, 0.1); +} +.md-typeset .rocket > .admonition-title::before, +.md-typeset .rocket > summary::before { + background-color: rgb(43, 155, 43); + -webkit-mask-image: var(--md-admonition-icon--rocket); + mask-image: var(--md-admonition-icon--rocket); +} \ No newline at end of file diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..95910d2 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,247 @@ +# Testing + +If you have structured your code into three sections as suggested in [structuring the codebase](structure.md) then testing will be simplest. + +## Core functionality in rust + +Comprehensively testing your functionality directly in Rust gives you the fastest test execution and +makes finding any issues easier, as you know that they can only originate in the underlying Rust functions, +not in your wrapping, importing or use in Python. + +??? rust "Two levels of tests out-of-the-box" + Rust supports two levels of tests and names them "Unit tests" and "Integration tests". I will stick to those terms for this section. + + - Unit tests: + 1. Go directly into the source file with the code they are testing. + 1. You should expect these tests to be more tightly coupled to the implementation detail and to have a higher change frequency for that reason. I find that I start out with many tests like this as I code new internal APIs for other parts of my code to use. Over time I delete many of them if I find that they add more cost than value when refactoring. When I break out a generic function I'll add new ones to help check it works as expected. + 1. Go in a dedicated module (called `test` by convention) and are annotated to be excluded from final builds: + ```rust + #[cfg(test)] + mod test { + use super::*; + + #[test] + fn vec_to_string() { + ... + ``` + - Integration tests: + 1. Go in a separate directory `tests`. + 1. You should expect these tests to be coupled to your public API. Changes to these tests are a clear indication of the type of version number change you should apply. + 1. Changing the internal implementation details of your code base should _not_ require you to change these tests. + 1. Can consist of as many different `.rs` files as you like. + 1. Need to import your library, just as your users would and each test needs to be annotated as such for the compiler: + ```rust + use fizzbuzz::MultiFizzBuzz; + + mod vectors { + + use super::*; + + #[test] + fn test_small_vec() { + ... + ``` + + [Chapter 11 of the rust book](https://doc.rust-lang.org/book/ch11-00-testing.html) contains more details + + ??? rust "**`rust/fizzbuzz/src/lib.rs`** - full source with unit tests at the end" + ```rust + --8<-- "rust/fizzbuzz/src/lib.rs" + ``` + + ??? rust "**`rust/fizzbuzz/tests/test_vec.rs`** (simple example of an integration test) - full source" + ```rust + --8<-- "rust/fizzbuzz/tests/test_vec.rs" + ``` + +## Code wrapped by pyo3 + +Given that you have tested the core code functionality well, you don't need to repeat a full suite of functional tests on the wrapped code. Instead you can focus on the wrapping and any type and error handling you implemented. + +!!! rocket "The `pyo3-testing` crate" + The `pyo3-testing` crate is designed to make this step simple. + + It is currently available on a forked version of pyo3, I'll release it separately as a dedicated crate ASAP unless [PR pyo3/#4099](https://github.com/PyO3/pyo3/pull/4099) lands in the meantime. + + Use it by importing pyo3 from the fork in **rust/fizzbuzzo3/Cargo.toml**: + ```toml + ... + [dependencies] + pyo3 = { git = "https://github.com/MusicalNinjaDad/pyo3.git", branch = "pyo3-testing" } + ... + ``` + +??? warning "Testing without the `pyo3-testing` crate" + If you chose to test without the `pyo3-testing` crate you will need to make use of the guidance in [Chapter 3 of the pyo3 book](https://pyo3.rs/latest/python-from-rust) and also be willing to accept random failures due to mistimed interpreter initialisation. + + You can take a look at [an example case from pyo3-testing](https://github.com/MusicalNinjaDad/pyo3/blob/pyo3-testing/tests/test_pyo3test.rs) to see how I worked around these issues. + +!!! pyo3 "Testing the wrapping once for each supported argument type" + Use pyo3's capability to embed a python interpreter and call python code from rust to create one test per type to check that you get the right result when you call the resulting python function: + + Example from **`rust/fizzbuzzo3/src.rs`**: + ```rust + #[pyo3test] + #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)] + fn test_fizzbuzz() { + let result: PyResult = match fizzbuzz.call1((1i32,)) { + Ok(r) => r.extract(), + Err(e) => Err(e), + }; + let result = result.unwrap(); + let expected_result = "1"; + assert_eq!(result, expected_result); + } + ``` + +!!! pyo3 "Testing error cases" + Again using pyo3's ability to embed python in rust, check that you get the expected python Exception type from bad input. + + Example from **`rust/fizzbuzzo3/src.rs`**: + ```rust + #[pyo3test] + #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)] + fn test_fizzbuzz_string() { + let result: PyResult = match fizzbuzz.call1(("one",)) { + Ok(_) => Ok(false), + Err(error) if error.is_instance_of::(py) => Ok(true), + Err(e) => Err(e), + }; + assert!(result.unwrap()); + } + ``` + +!!! warning "Only unit tests possible" + You can only create tests directly in the source file along side your wrappings. This should be fine, as your integration tests will be run natively by pytest. + + If for some reason you did want to create external rust tests you need to change the library type in `rust/fizzbuzzo3/Cargo.toml` to `crate-type = ["cdylib, lib"]` + +??? pyo3 "**`rust/fizzbuzzo3/src/lib.rs`** - full source (tests at the end)" + ```rust + --8<-- "rust/fizzbuzzo3/src/lib.rs" + ``` + +## Integration testing with pytest + +Now that you are confident that your functionality is correct and your wrappings work, you can create your final tests in Python to validate that the wrapped functionality imports and really does what you expect when called from python. + +??? python "pytest vs. unittest" + Python offers testing via the [`unittest` module](https://docs.python.org/3/library/unittest.html) in the standard library. [pytest](https://docs.pytest.org/) is a central part of the python ecosystem which removes a lot of the boilerplate and provides a lot of additional features. You will find it used in an overwhelmingly large number of libraries. + +!!! python "Testing that you can import your module" + Testing the import mechanics is as simple as adding the import statement to the top of your test case. + + From **`tests/test_fizbuzzo3.py`**: + ```python + from fizzbuzz.fizzbuzzo3 import fizzbuzz + ``` + + If this doesn't work, you'll receive an `ModuleNotFoundError` when running your tests + + !!! tip "Don't forget to recompile your wrapped functions" + You'll need to (re)compile wrapped functions after changing them and before testing from python: + ```sh + (.venv)/projectroot$ pip install -e . + ``` + + !!! warning "Import and namespace errors" + It's easy to end up in a situation where your pytests pass when you run them locally but imports fail at other times. See the warning in [Structuring the Codebase](structure.md#basic-structure) for more details. + + Avoid this problem by: + 1. Following the guidance on project structure + 1. **Not** placing any `__init__.py` files in your `tests` directory. Rely on `pip install -e .` to properly place your packing into `globals` + +!!! python "Testing functionality for each type" + Similar to how you focussed your tests in rust on the wrapped code, you want to also focus here on ensuring you cover all the possible types you can pass to your function. You probably also want to check the functionality a bit more as well, but not to the extent you did in the core rust code. + + From **`tests/test_fizbuzzo3.py`**: + ```python + ... + def test_list(): + assert fizzbuzz([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15]) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, fizzbuzz" + ... + ``` + +!!! python "Testing error cases" + pytest offers an easy way to check that an Exception is raised using `raises`: + + From **`tests/test_fizbuzzo3.py`**: + ```python + import pytest + ... + def test_string(): + with pytest.raises(TypeError): + fizzbuzz("1") + ``` + +??? python "**`tests/test_fizzbuzzo3.py`** - full source:" + ```python + --8<-- "tests/test_fizzbuzzo3.py" + ``` + +!!! python "Doctests" + If you've followed the guidance on type hinting (later) you can also run [doctests](https://docs.python.org/3/library/doctest.html) on the docstrings attached to your wrapped functions. + + Add the following to **`./pyproject.toml`**: + ```toml + [tool.pytest.ini_options] + ... + addopts = [ + ... + "--doctest-modules", + "--doctest-glob=*.pyi", + ... + ] + testpaths = ["tests", "python"] + ... + ``` + + ??? python "**`./pyproject.toml`** - full source" + ```toml + --8<-- "./pyproject.toml" + ``` + +## Running the tests + +!!! rust "Running all rust tests (unit, integration, doc)" + ```sh + /projectroot$ cargo test -p fizzbuzz + ``` + + or + + ```sh + /projectroot/rust/fizzbuzz$ cargo test + ``` + +!!! pyo3 "Running all tests on wrapped code" + ```sh + /projectroot$ cargo test -p fizzbuzzo3 + ``` + + or + + ```sh + /projectroot/rust/fizzbuzzo3$ cargo test + ``` + +!!! python "Running all python tests" + ```sh + (.venv)/projectroot$ pytest + ``` + + !!! warning "Don't forget to recompile your wrapped functions" + You'll need to (re)compile wrapped functions after changing them and before testing from python: + ```sh + (.venv)/projectroot$ pip install -e . + ``` + +!!! abstract "Running all tests _and lints_ with just" + Just run `just check` or `just reset check` to also clean and recompile first + + ??? abstract "**`./justfile`** - full source" + ```justfile + --8<-- "justfile" + ``` + +## TODO - CI diff --git a/justfile b/justfile index b58846e..6eb51a4 100644 --- a/justfile +++ b/justfile @@ -54,10 +54,6 @@ check-python: lint-python test-python # lint and test both rust and python check: check-rust check-python -# build and test a wheel (a suitable venv must already by active!) -test-wheel: clean - cibuildwheel --only cp312-manylinux_x86_64 - #run coverage analysis on rust & python code cov: RUSTFLAGS="-Cinstrument-coverage" cargo build @@ -73,3 +69,16 @@ rust-cov: # serve python coverage results on localhost:8000 (doesn't run coverage analysis) py-cov: python -m http.server -d ./pycov + +# serve python docs on localhost:8000 +py-docs: + mkdocs serve + +#build and serve rust API docs on localhost:8000 +rust-docs: + cargo doc + python -m http.server -d target/doc + +# build and test a wheel (a suitable venv must already by active!) +test-wheel: clean + cibuildwheel --only cp312-manylinux_x86_64 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..4984940 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,79 @@ +site_name: "FizzBuzz" +repo_url: https://github.com/MusicalNinjaDad/FizzBuzz +repo_name: MusicalNinjaDad/FizzBuzz + +watch: +- python # To update live preview when docstrings change + +theme: + name: "material" + icon: + logo: fontawesome/solid/cubes + palette: # Palette toggles for auto-light-dark mode + - media: "(prefers-color-scheme)" + toggle: + icon: material/link + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/toggle-switch + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/toggle-switch-off-outline + name: Switch to system preference + features: + - navigation.expand + - navigation.path + - navigation.tabs + - toc.integrate + - navigation.indexes + +extra_css: + - stylesheets/admonitions.css + +plugins: +- search +- mkdocstrings: + handlers: + python: + options: + show_bases: false + show_root_heading: true + heading_level: 2 + show_root_full_path: false + show_symbol_type_toc: true + show_symbol_type_heading: true + signature_crossrefs: true + separate_signature: true + show_signature_annotations: true + docstring_section_style: spacy + docstring_options: + ignore_init_summary: true + merge_init_into_class: true + +markdown_extensions: +- admonition +- pymdownx.details +- pymdownx.highlight +- pymdownx.inlinehilite +- pymdownx.snippets: + url_download: true +- pymdownx.superfences: +- attr_list +- pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + +nav: + - Combining python & rust: + - index.md + - dev-environment.md + - structure.md + - dev-build.md + - testing.md + - packaging.md + - coverage.md + - documentation.md diff --git a/pyproject.toml b/pyproject.toml index 76681da..4802380 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,144 +1,152 @@ -[build-system] -requires = ["setuptools", "setuptools-rust"] -build-backend = "setuptools.build_meta" - [project] -name = "fizzbuzz" -description = "An implementation of the traditional fizzbuzz kata" -# readme = "README.md" -license = { text = "MIT" } -authors = [{ name = "Mike Foster" }] -dynamic = ["version"] -requires-python = ">=3.8" -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Topic :: Software Development :: Libraries :: Python Modules", - "Intended Audience :: Developers", - "Natural Language :: English", -] + name = "fizzbuzz" + description = "An implementation of the traditional fizzbuzz kata" + # readme = "README.md" + license = { text = "MIT" } + authors = [{ name = "Mike Foster" }] + dynamic = ["version"] + requires-python = ">=3.8" + classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Intended Audience :: Developers", + "Natural Language :: English", + ] [project.urls] -# Homepage = "https://github.com/MusicalNinjaDad/recurtools" -# Documentation = "https://musicalninjadad.github.io/recurtools/" -Repository = "https://github.com/MusicalNinjaDad/fizzbuzz" -Issues = "https://github.com/MusicalNinjaDad/fizzbuzz/issues" -# Changelog = "https://github.com/MusicalNinjaDad/recurtools/blob/main/CHANGELOG.md" + # Homepage = "" + # Documentation = "" + Repository = "https://github.com/MusicalNinjaDad/fizzbuzz" + Issues = "https://github.com/MusicalNinjaDad/fizzbuzz/issues" + # Changelog = "" [tool.setuptools.dynamic] -version = { file = "__pyversion__" } + version = { file = "__pyversion__" } + + +# =========================== +# Build, package +# =========================== + +[build-system] + requires = ["setuptools", "setuptools-rust"] + build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -where = ["python"] + where = ["python"] [[tool.setuptools-rust.ext-modules]] -# Private Rust extension module to be nested into the Python package -target = "fizzbuzz.fizzbuzzo3" # The last part of the name (e.g. "_lib") has to match lib.name in Cargo.toml, -# but you can add a prefix to nest it inside of a Python package. -path = "rust/fizzbuzzo3/Cargo.toml" # Default value, can be omitted -binding = "PyO3" # Default value, can be omitted -features = ["pyo3/extension-module"] + # The last part of the name (e.g. "_lib") has to match lib.name in Cargo.toml, + # but you can add a prefix to nest it inside of a Python package. + target = "fizzbuzz.fizzbuzzo3" + path = "rust/fizzbuzzo3/Cargo.toml" + binding = "PyO3" + features = ["pyo3/extension-module"] # IMPORTANT!!! [tool.cibuildwheel] -skip = [ - "*-musllinux_i686", # No musllinux-i686 rust compiler available. - "*-musllinux_ppc64le", # no rust target available. - "*-musllinux_s390x", # no rust target available. -] -# Use prebuilt copies of pypa images with rust and just ready compiled (saves up to 25 mins on emulated archs) -manylinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust" -manylinux-i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust" -manylinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust" -manylinux-ppc64le-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_ppc64le-rust" -manylinux-s390x-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_s390x-rust" -manylinux-pypy_x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust" -manylinux-pypy_i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust" -manylinux-pypy_aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust" -musllinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_x86_64-rust" -musllinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_aarch64-rust" - -# `test-command` will FAIL if only passed `pytest {package}` -# as this tries to resolve imports based on source files (where there is no `.so` for rust libraries), not installed wheel -test-command = "pytest {package}/tests" -test-extras = "test" - -[tool.cibuildwheel.linux] -before-all = "just clean" -archs = "all" - -[tool.cibuildwheel.macos] -before-all = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && cargo install just && just clean" - -[tool.cibuildwheel.windows] -before-all = "rustup target add aarch64-pc-windows-msvc i586-pc-windows-msvc i686-pc-windows-msvc x86_64-pc-windows-msvc && cargo install just && just clean" -before-build = "pip install delvewheel" -repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" + skip = [ + "*-musllinux_i686", # No musllinux-i686 rust compiler available. + "*-musllinux_ppc64le", # no rust target available. + "*-musllinux_s390x", # no rust target available. + ] + # Use prebuilt copies of pypa images with rust and just ready compiled (saves up to 25 mins on emulated archs) + manylinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust" + manylinux-i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust" + manylinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust" + manylinux-ppc64le-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_ppc64le-rust" + manylinux-s390x-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_s390x-rust" + manylinux-pypy_x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust" + manylinux-pypy_i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust" + manylinux-pypy_aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust" + musllinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_x86_64-rust" + musllinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_aarch64-rust" + + # `test-command` will FAIL if only passed `pytest {package}` + # as this tries to resolve imports based on source files (where there is no `.so` for rust libraries), not installed wheel + test-command = "pytest {package}/tests" + test-extras = "test" + + [tool.cibuildwheel.linux] + before-all = "just clean" + archs = "all" + + [tool.cibuildwheel.macos] + before-all = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && cargo install just && just clean" + + [tool.cibuildwheel.windows] + before-all = "rustup target add aarch64-pc-windows-msvc i586-pc-windows-msvc i686-pc-windows-msvc x86_64-pc-windows-msvc && cargo install just && just clean" + before-build = "pip install delvewheel" + repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" + + +# =========================== +# Test, lint, documentation +# =========================== [project.optional-dependencies] -lint = ["ruff"] -test = ["pytest", "pytest-doctest-mkdocstrings"] -cov = ["fizzbuzz[test]","pytest-cov"] -dev = ["fizzbuzz[lint,test,cov]"] + lint = ["ruff"] + test = ["pytest", "pytest-doctest-mkdocstrings"] + cov = ["fizzbuzz[test]", "pytest-cov"] + doc = ["black", "mkdocs", "mkdocstrings[python]", "mkdocs-material"] + dev = ["fizzbuzz[lint,test,cov,doc]"] [tool.pytest.ini_options] -xfail_strict = true -addopts = [ - "--doctest-modules", - "--doctest-mdcodeblocks", - "--doctest-glob=*.pyi", - "--doctest-glob=*.md", -] -testpaths = [ - "tests", - "python" -] + xfail_strict = true + addopts = [ + "--doctest-modules", + "--doctest-mdcodeblocks", + "--doctest-glob=*.pyi", + "--doctest-glob=*.md", + ] + testpaths = ["tests", "python"] [tool.coverage.run] -branch = true -omit = ["tests/*"] -dynamic_context = "test_function" + branch = true + omit = ["tests/*"] + dynamic_context = "test_function" [tool.ruff] -line-length = 120 -format.skip-magic-trailing-comma = false -format.quote-style = "double" + line-length = 120 + format.skip-magic-trailing-comma = false + format.quote-style = "double" [tool.ruff.lint] -select = ["ALL"] -flake8-pytest-style.fixture-parentheses = false -flake8-annotations.mypy-init-return = true -extend-ignore = [ - "E701", # One-line ifs are not great, one-liners with suppression are worse - "ANN101", # Type annotation for `self` - "ANN202", # Return type annotation for private functions - "ANN401", # Using Any often enough that supressing individually is pointless - "W291", # Double space at EOL is linebreak in md-docstring - "W292", # Too much typing to add newline at end of each file - "W293", # Whitespace only lines are OK for us - "D401", # First line of docstring should be in imperative mood ("Returns ..." is OK, for example) - "D203", # 1 blank line required before class docstring (No thank you, only after the class docstring) - "D212", # Multi-line docstring summary should start at the first line (We start on new line, D209) - "D215", # Section underline is over-indented (No underlines) - "D400", # First line should end with a period (We allow any punctuation, D415) - "D406", # Section name should end with a newline (We use a colon, D416) - "D407", # Missing dashed underline after section (No underlines) - "D408", # Section underline should be in the line following the section's name (No underlines) - "D409", # Section underline should match the length of its name (No underlines) - "D413", # Missing blank line after last section (Not required) -] + select = ["ALL"] + flake8-pytest-style.fixture-parentheses = false + flake8-annotations.mypy-init-return = true + extend-ignore = [ + "E701", # One-line ifs are not great, one-liners with suppression are worse + "ANN101", # Type annotation for `self` + "ANN202", # Return type annotation for private functions + "ANN401", # Using Any often enough that supressing individually is pointless + "W291", # Double space at EOL is linebreak in md-docstring + "W292", # Too much typing to add newline at end of each file + "W293", # Whitespace only lines are OK for us + "D401", # First line of docstring should be in imperative mood ("Returns ..." is OK, for example) + "D203", # 1 blank line required before class docstring (No thank you, only after the class docstring) + "D212", # Multi-line docstring summary should start at the first line (We start on new line, D209) + "D215", # Section underline is over-indented (No underlines) + "D400", # First line should end with a period (We allow any punctuation, D415) + "D406", # Section name should end with a newline (We use a colon, D416) + "D407", # Missing dashed underline after section (No underlines) + "D408", # Section underline should be in the line following the section's name (No underlines) + "D409", # Section underline should match the length of its name (No underlines) + "D413", # Missing blank line after last section (Not required) + ] [tool.ruff.lint.per-file-ignores] -# Additional ignores for tests -"**/test_*.py" = [ - "INP001", # Missing __init__.py - "ANN", # Missing type annotations - "S101", # Use of `assert` - "PLR2004", # Magic number comparisons are OK in tests - "D1", # Don't REQUIRE docstrings for tests - but they are nice -] - -"**/__init__.py" = [ - "D104", # Don't require module docstring in __init__.py - "F401", # Unused imports are fine: using __init__.py to expose them with implicit __ALL__ -] + # Additional ignores for tests + "**/test_*.py" = [ + "INP001", # Missing __init__.py + "ANN", # Missing type annotations + "S101", # Use of `assert` + "PLR2004", # Magic number comparisons are OK in tests + "D1", # Don't REQUIRE docstrings for tests - but they are nice + ] + + "**/__init__.py" = [ + "D104", # Don't require module docstring in __init__.py + "F401", # Unused imports are fine: using __init__.py to expose them with implicit __ALL__ + ] diff --git a/rust/fizzbuzzo3/src/lib.rs b/rust/fizzbuzzo3/src/lib.rs index a20a3eb..e017a5d 100644 --- a/rust/fizzbuzzo3/src/lib.rs +++ b/rust/fizzbuzzo3/src/lib.rs @@ -70,17 +70,6 @@ mod tests { assert_eq!(result, expected_result); } - #[pyo3test] - #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)] - fn test_fizzbuzz_string() { - let result: PyResult = match fizzbuzz.call1(("one",)) { - Ok(_) => Ok(false), - Err(error) if error.is_instance_of::(py) => Ok(true), - Err(e) => Err(e), - }; - assert!(result.unwrap()); - } - #[pyo3test] #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)] fn test_fizzbuzz_float() { @@ -105,4 +94,15 @@ mod tests { let expected_result = "1, 2, fizz, 4, buzz"; assert_eq!(result, expected_result); } + + #[pyo3test] + #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)] + fn test_fizzbuzz_string() { + let result: PyResult = match fizzbuzz.call1(("one",)) { + Ok(_) => Ok(false), + Err(error) if error.is_instance_of::(py) => Ok(true), + Err(e) => Err(e), + }; + assert!(result.unwrap()); + } } diff --git a/tests/test_fizzbuzzo3.py b/tests/test_fizzbuzzo3.py index 80c5572..bbc22fb 100644 --- a/tests/test_fizzbuzzo3.py +++ b/tests/test_fizzbuzzo3.py @@ -11,8 +11,18 @@ def test_lazy(): assert fizzbuzz(6) == "fizz" assert fizzbuzz(15) == "fizzbuzz" +def test_float(): + assert fizzbuzz(1.0) == "1" + assert fizzbuzz(3.0) == "fizz" + +def test_list(): + assert fizzbuzz([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15]) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, fizzbuzz" -def test_rules(): +def test_string(): + with pytest.raises(TypeError): + fizzbuzz("1") + +def test_1_to_100(): results = [fizzbuzz(i) for i in range(1, 101)] every_3rd_has_fizz = all("fizz" in r for r in results[2::3]) assert every_3rd_has_fizz @@ -28,17 +38,3 @@ def test_rules(): assert every_fizzbuzz_is_mod15 all_numbers_correct = all(r == str(i + 1) for i, r in enumerate(results) if r not in ("fizz", "buzz", "fizzbuzz")) assert all_numbers_correct - - -def test_string(): - with pytest.raises(TypeError): - fizzbuzz("1") - - -def test_float(): - assert fizzbuzz(1.0) == "1" - assert fizzbuzz(3.0) == "fizz" - - -def test_list(): - assert fizzbuzz([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15]) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, fizzbuzz"