Development¶
Contributions are welcome. The repository is matthiaskoenig/sbmlsim; development happens against the develop branch via pull requests.
Branch model¶
Two branches are permanent:
developis the default branch and the branch everything is integrated into. The documentation on matthiaskoenig.github.io/sbmlsim is published from it.maintracks the latest published release. It is fast-forwarded to the released commit by thesync-mainjob of theCI-CDworkflow after the package went to pypi, somainand the newest version on pypi always agree. Nothing is developed onmainand nothing is merged into it by hand.
Work happens on short lived branches off develop, which GitHub deletes after the merge. Releases are tagged on develop, see Release.
Pull requests¶
Neither branch accepts a direct push, every change goes through a pull request against develop. This includes the maintainer, there is no bypass.
A pull request can only be merged once the four required checks are green:
| check | workflow | content |
|---|---|---|
tests |
ci-cd.yml |
the test matrix, python 3.14 on linux, macos and windows and python 3.15 on linux, the plain install and the engine AMICI |
ruff |
lint.yml |
ruff check and ruff format --check, with the ruff of uv.lock |
ty |
lint.yml |
ty check |
docs |
docs.yml |
the zensical build including the api reference and the agent files |
tests aggregates the test matrix, the job plain install and the job amici engine into a single job, so the name of the required check stays the same when the matrix changes. plain install installs the package without extras, as pip install sbmlsim does, and runs scripts/plain_install.py, a scan whose result is written as netCDF and read back: the test matrix has the extras of dev, which would hide a dependency the package needs and does not declare. It is the one job which does not use the lock, see Dependencies. amici engine runs on linux only: the extra amici is built from source, so it is not part of dev and of the test matrix; the job installs the build requirements of AMICI from the system (a C++ compiler, BLAS and the headers of HDF5; CMake, SWIG and ninja come from PyPI), syncs with --extra dev --extra amici and runs the tests of the engines, tests/test_engine.py, every test file with amici in its name and the tests of every engine besides roadrunner, test_*_engines.py.
Every job sets up its environment with the action .github/actions/setup: uv with its cache of the packages and the interpreters, the python of the job, the libpython roadrunner needs on linux, and uv sync --locked with the extras of the job, i.e. the versions of uv.lock, see Dependencies. The key of the cache of uv has the os and the python version but not the job, so jobs on the same os and python would race to save it and warn Unable to reserve cache: only the jobs of the test matrix save the cache (save-cache: "true", each entry has a key of its own), the others restore it. The jobs run the same commands as a local environment (pytest, ty check, zensical build), the tests against the package installed as a wheel (uv sync --no-editable). A run of a pull request is cancelled by the next push to it; a push to develop or main is never cancelled, a release waits for the run of its commit. The release builds the distributions without the cache (cache: "false").
Continuous integration is kept small: every workflow cancels its running build when a newer commit of the same pull request arrives, uv caches the packages and the interpreters between runs, dependabot proposes its updates once a month, and the matrix tests python 3.14 on every platform and python 3.15 on linux only: h5py publishes no wheels for python 3.15 yet, the job builds it from source against the headers of HDF5 (libhdf5-dev). Python 3.15 is tested on every platform locally, see Testing, before a pull request is opened.
Further rules of a pull request:
- conversations have to be resolved before the merge
- an approval is dismissed when new commits are pushed
- the history stays linear, i.e., a pull request is merged with squash or rebase; merge commits are disabled
- the maintainer is the code owner of the repository (
.github/CODEOWNERS) and is requested for review on every pull request. A pull request of a contributor is therefore reviewed and merged by the maintainer, who has the only write access. The rulesets themselves do not require an approval: on a personal repository a ruleset cannot ask for an approval only from somebody else, and requiring one would block the pull requests of the maintainer, who cannot approve their own. Once a second person has write access, a ruleset requiring an approving review of a code owner can be added
Auto-merge is enabled for the repository, so a pull request can be queued and is merged as soon as the checks pass and the required approval is there.
Repository policies¶
The protection is implemented with repository rulesets. They are part of the repository in .github/rulesets/ instead of only living in the web interface, so a change to a policy is reviewed like any other change:
| ruleset | applies to | rules |
|---|---|---|
develop.json |
develop |
pull request required, the four checks above, resolved conversations, linear history, no force push, no deletion. No bypass, for anybody. |
main.json |
main |
no force push, no deletion, no bypass. The fast-forward of the release workflow needs none, only a force push would be rejected. main mirrors develop, whose history carries merge commits from before merge commits were disabled, so main cannot require a linear history |
tags.json |
all tags | a tag cannot be deleted or moved, so a release tag keeps pointing at what was released |
Changing a policy means changing the json and applying it:
The script is idempotent: it updates the rulesets which exist and creates the missing ones. It also sets the merge settings of the repository, i.e., auto-merge, delete branch on merge, and squash and rebase as the only merge methods. It needs the github cli authenticated as a user with admin permission on the repository.
Setup development environment¶
Development needs uv and a checkout of the repository:
A single sync creates the virtual environment in .venv, installs sbmlsim into it in editable mode and adds the complete tooling, in the versions of uv.lock:
The dev extra contains everything used below, i.e., pytest, ruff, ty, tox with tox-uv, pre-commit, zensical and bump-my-version, so nothing has to be installed separately. The engine AMICI is not part of it, since AMICI is built from source: uv sync --extra dev --extra amici adds it, with the build requirements of Optional packages installed, and the tests of AMICI, which are skipped without it, then run as well. The python version is taken from .python-version (3.14, the oldest supported version); to work against python 3.15 instead use uv sync --extra dev --python 3.15, which replaces the environment.
The tools are then run either with uv run <command>, which uses the environment without activating it, or from the activated environment:
The commands in this document are written without the uv run prefix; prepend it if the environment is not activated.
The last step installs the git hook:
uv run pre-commit install # install the hook, once per checkout
uv run pre-commit run --all-files # check the current state of the repository
From now on every commit is checked with ruff (lint and format) and ty, i.e., the same checks that run in continuous integration. On a commit only the changed files are looked at, --all-files checks the whole repository and is what a newly added hook should be tried with.
Dependencies¶
The environment is resolved from uv.lock, which is committed. It pins every package of the environment, i.e. the dependencies, the sciml extra and the tools of dev (ruff, ty, pytest, zensical, ...), for linux, macOS and windows, so a local uv sync and continuous integration install the same versions and a result can be reproduced. Every job which syncs through the action .github/actions/setup runs against the lock, i.e. the test matrix, ty, the documentation, the test suites and the submission of a release and the pin of the SBML Test Suite, and the ruff check installs the ruff version of the lock (version-file: uv.lock), so continuous integration and uv run ruff agree.
Three things do not use the lock on purpose. The job plain install resolves from pyproject.toml as pip install sbmlsim does, i.e. the newest releases within the bounds, so a release of a dependency which breaks sbmlsim shows up there before the lock is moved to it. The tox environments resolve from pyproject.toml as well. The release builds and checks the package with uvx hatch and uvx twine. The lower bounds in pyproject.toml are what a user of the library installs against, the lock only records the versions the development runs with.
After changing a dependency in pyproject.toml run uv lock: the action syncs with uv sync --locked, which fails when pyproject.toml and the lock disagree, so the change and the uv lock which records it go into the same pull request. uv lock --upgrade moves every package of the lock to its newest release, uv lock --upgrade-package <name> a single one.
Dependabot keeps the lock current: once a month it opens one pull request which bumps the packages of the lock together, with the prefix build. Its versioning-strategy: lockfile-only only changes uv.lock, the lower bounds in pyproject.toml are never raised by it and change by hand. The revisions of the ruff and ty hooks in .pre-commit-config.yaml are the versions of the lock, which dependabot does not update: when such a pull request moves ruff or ty, raise the revision of the hook to the same version.
Testing¶
The tests are written with pytest, tox runs them against every supported python version.
The tox environments are named after the interpreter (py3.14 and py3.15, see envlist in tox.ini), a single one is run with
and the complete matrix in parallel with
This needs the interpreters to be available, which uv installs with uv python install 3.14 3.15; the environment of python 3.15 builds h5py from source, which needs the headers of HDF5 (libhdf5-dev on linux). It is the complete test and is run before a pull request is opened: continuous integration runs python 3.14 on linux, macos and windows and python 3.15 on linux, without the tests marked slow. It does not use tox, it runs pytest in an environment of the python of the job, see pull requests.
To run the tests directly against the development environment use
pytest # the full suite
pytest -n 0 # in one process, e.g. for --pdb
pytest tests/simulation/test_simulation.py # a single module
pytest tests/simulation/test_simulation.py::test_timecourse # a single test
The tests run in parallel, -n auto --dist worksteal in the addopts of pyproject.toml gives pytest-xdist one worker per core, and a worker which is idle takes over the tests which wait at a busy one; -n 0 on the command line runs everything in one process, which the debugger needs.
The conftest.py at the root of the repository selects the non-interactive matplotlib backend for the session and puts the repository on sys.path, so that the tests can import the examples.
Some tests are skipped on purpose: the simulation experiment examples are marked with pytest.mark.skip while that part of the package is reworked. The skips are listed with pytest -rs.
tests/examples/test_example_scripts.py runs the examples as python -m examples.<module> in a temporary working directory, so a broken example fails the test suite.
The tests marked slow are long end-to-end runs, the examples which fit, sample or scan a whole study and take ten seconds and more. They run by default, locally and in tox; continuous integration deselects them with --skip-slow, an option of the root conftest.py, and lists the 15 slowest of the rest (--durations=15), so a test which becomes slow shows up in the log.
pytest --skip-slow # the suite as continuous integration runs it
pytest -m slow # only the slow tests
Linting and formatting¶
Linting and formatting use ruff:
The docstring rules (D) are enforced for the package, not for examples/ and tests/, which are scripts and fixtures; the model definitions of the examples import the names of sbmlutils.factory with a star import, so F403/F405 are ignored there as well, see .ruff.toml.
Type checking¶
Type checking is performed with ty:
ty resolves the imports in the environment of the project, which uv sync --extra dev creates; the ty check of continuous integration runs the same command and the pre-commit hook runs it on every commit.
The configuration lives in [tool.ty] in pyproject.toml. Warnings are treated as errors, so the codebase is kept free of diagnostics. Suppress an unavoidable diagnostic with a rule specific # ty: ignore[rule-name] rather than a blanket comment.
libsbml and roadrunner have no type stubs and create their objects through a SWIG layer, so ty sees an untyped API. Annotate their objects (doc: libsbml.SBMLDocument = ...) and use the explicit getters (getVariable()) rather than the attributes the SWIG layer synthesizes (variable), which the type checker cannot see.
Examples¶
The examples are runnable scripts in examples/, they are not part of the package. They are run as modules from the root of the repository:
An example writes what it creates into the current working directory and never opens a window: a plotting example saves its figure to a file. tests/examples/test_example_scripts.py runs the example scripts in a temporary directory, so a broken example fails the test suite. See examples/README.md.
Documentation¶
The documentation is built with Zensical, the static site generator of the Material for MkDocs authors. The sources are markdown files in docs/, the site is configured in zensical.toml in the repository root. Nothing rendered is committed: the site is built by the documentation workflow on every push and published to matthiaskoenig.github.io/sbmlsim from the develop branch.
Build the site into site/:
For writing, the preview rebuilds on save:
The API reference is rendered from the docstrings by mkdocstrings; a page in docs/api/ only contains the module directive:
Docstrings are therefore the place to document functions and classes, the markdown files provide the narrative around them. Adding a module to the reference means adding such a page and an entry to nav in zensical.toml.
Code and outputs of the pages¶
Every python block of a page runs, in the order of the page and in one namespace, as a reader runs it. Below a block the page shows what it prints, in a block titled Output, and the matplotlib figures it creates, as images in docs/images/outputs/. Both are generated, never written by hand:
uv run python scripts/docs_outputs.py # every page with code
uv run python scripts/docs_outputs.py tutorial.md # one page
A figure is shown when it is an open figure of pyplot after the block or a matplotlib.figure.Figure which a name of the block holds. The outputs and images are committed. tests/docs/test_docs_code.py runs the code of every page and fails when a page is out of step with it: a block which prints without an output, an output of a block which prints nothing, a missing image or a page with code which is not in PAGES of the script. It does not compare the text, whose last digits differ between platforms, so run the script after changing a page or what its code prints. The documentation workflow runs the script before it builds the site, so the published pages show the outputs of the code they were built from even when the committed ones fell behind, e.g. the version installation.md prints (#447), and a pull request checks that the script runs; a release writes them again, see Release. A page with code is added to PAGES; keep the code of a page fast, the tests run it on every platform.
Files for agents¶
Agents and language models read markdown, not rendered html. scripts/llms_txt.py writes the files of the llms.txt convention into the built site, i.e., llms.txt as an annotated index of all pages, llms-full.txt with the complete documentation in a single file, and the markdown of every page next to its html (/creation.md for /creation/). The markdown of the API reference is generated from the docstrings with inspect, since the pages themselves only contain the mkdocstrings directive.
The documentation workflow runs both steps, so the files are regenerated with every push. docs/robots.txt points crawlers at the sitemap and at these files. Zensical will provide agent context files itself at some point, then this script can go.
Caches¶
sbmlsim keeps its caches below ~/.cache/sbmlsim (or XDG_CACHE_HOME): the test suites, the compiled models of AMICI (SBMLSIM_AMICI_CACHE) and the parsed unit definitions of pint (units/, SBMLSIM_UNITS_CACHE, an empty value turns it off), which make import sbmlsim about 65 ms faster. Every cache can be deleted at any time.
Release¶
A release is made from develop. Since develop only accepts pull requests, the release is prepared on a branch and tagged once that pull request is merged:
- branch off
develop:git switch -c release/x.y.z develop - write the release notes for the version in
release-notes/x.y.z.md - make sure everything passes:
tox run-parallel,ruff check,uv run ty check - check the version bump:
uvx bump-my-version bump [major|minor|patch] --dry-run -vv - bump the version:
uvx bump-my-version bump [major|minor|patch], which updatessrc/sbmlsim/__init__.pyandCITATION.cffand commits. It does not create the tag; a squash or rebase merge would rewrite the commit and leave the tag behind on a commit which is not part ofdevelop - write the outputs of the pages again with the new version, which
installation.mdprints:uv run python scripts/docs_outputs.py, and commit them - push the branch, open the pull request against
developand merge it once the checks are green -
tag the merged commit on
developand push the tag:This starts the
CI-CDworkflow, which publishes to pypi, creates the GitHub release fromrelease-notes/x.y.z.mdand fast-forwardsmainto the tagged commit. It does not run the test matrix again: the merge intodevelopran it on the same commit, and the jobtested on developwaits for that run and stops the release unless it passed, so the tag can be pushed right after the merge. Next to it the SBML Test Suite and the PEtab SciML test suite run against their pins and the submission to the SBML Test Suite Database is written, which the release attaches; a release takes a few minutes once the run ondevelophas finished. Check the version before pushing, a tag cannot be moved or deleted afterwards. -
test the installation from pypi in a fresh environment:
-
once Zenodo has archived the release, update the citation information, i.e.,
date-releasedinCITATION.cffand the version, date and version DOI of the release in the citation ofREADME.mdanddocs/index.md.bump-my-versiononly updates the version, not the date and the DOI, which are only known after the release. These changes go in through a pull request like everything else