Development¶
Contributions are welcome. The repository is matthiaskoenig/sbmlutils; 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/sbmlutils 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 |
Python 3.14 on Linux, macOS and Windows; Python 3.12 with minimum dependencies |
ruff |
ruff.yml |
ruff check and ruff format --check |
ty |
ty.yml |
tox r -e ty |
docs |
docs.yml |
the zensical build including the api reference and the agent files |
tests aggregates the test matrix into a single job, so the name of the required check stays the same when the matrix changes.
Continuous integration is kept small: every workflow cancels its running build when a newer commit of the same branch or pull request arrives, uv caches the packages and the interpreters between runs, and dependabot proposes its updates once a month. The matrix tests the newest stable Python on every platform and the minimum Python with the lowest direct dependencies on Linux. The remaining Python versions and cobra are tested 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 |
linear history, no force push, no deletion, no bypass. The fast-forward of the release workflow needs none, only a force push or a merge commit would be rejected |
tags.json |
all tags | a tag cannot be deleted or moved, so a release tag keeps pointing at what was released |
tag-creation.json |
all tags | only a repository admin can create a tag. Every tag starts the release, so a tag is a release to PyPI. A ruleset of its own, since the bypass of the admins must not extend to tags.json |
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¶
The CI test matrix covers Python 3.14 on Linux, Windows and macOS, plus Python
3.12 with the minimum direct dependency versions through the lowest tox
environment. The remaining Python versions and optional workflows can also be
tested locally with tox.
From an installed checkout, python scripts/performance_report.py --size 10000
reports import time, full and fast validation, full and lightweight reports,
and reporting unnamed elements. Each workflow runs in a fresh process. On Unix,
peak RSS includes libsbml and other native allocations. Compare results on the
same machine and dependency versions; normal regression tests also verify
linear list indexing and that lightweight reports skip expensive calculations.
Development needs uv and a checkout of the repository:
A single sync creates the virtual environment in .venv, installs sbmlutils into it in editable mode and adds the complete tooling:
The environment is resolved from uv.lock, which is committed, so local development, including uv run ty check, and the documentation build use the same versions. The tox environments, i.e. the test matrix and the ty check of continuous integration, resolve from pyproject.toml and do not use the lock; the lower bounds in pyproject.toml are what a user of the library installs against. After changing a dependency in pyproject.toml run uv lock: the documentation workflow syncs with uv sync --locked, which fails when pyproject.toml and the lock disagree. uv lock --upgrade moves the lock to the newest releases.
The dev extra contains everything used below, i.e., pytest, ruff, ty, tox, pre-commit, zensical and bump-my-version, so nothing has to be installed separately. The python version is taken from .python-version (currently 3.14); to work against the oldest supported version instead use uv sync --extra dev --python 3.12, 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.
Testing¶
The tests are written with pytest, tox runs them against every supported python version.
The tox environments are named after the interpreter (py3.12 to py3.15, see envlist in tox.ini), a single one is run with
and the complete matrix, including the ty environment, in parallel with
This needs the interpreters to be available, which uv installs with uv python install 3.12 3.13 3.14 3.15. It is the complete test and is run before a pull request is opened: continuous integration runs only py3.14, on linux, macos and windows, as uvx --with tox-uv tox -e py3.14.
What follows -- is passed on to pytest, so a single module or test can be run in the environment of a tox run rather than against the development environment, which is what continuous integration runs:
tox r -e py3.14 -- tests/test_factory.py
tox r -e py3.14 -- tests/test_factory.py::test_model_units -x
Without it the whole suite runs, with the sbml_testsuite sweep deselected, which is what the environments do in continuous integration.
Two more markers run by default but can be deselected for a quick local run: slow marks the tests which take many seconds (the fbc round trip of Recon3D) and runs locally only, continuous integration deselects it; network marks the tests which query the live BioModels service and skip when it does not answer.
The suite is safe to run in parallel with pytest-xdist, which every tox environment installs: tox r -e py3.14 -- -n auto spreads it over all cores. Continuous integration runs it so, with --durations=15, so that a new slow test shows up in the log; a test which takes seconds gets the slow marker or is made faster.
Python 3.15 tests the core package without the examples extra because libroadrunner does not yet publish CPython 3.15 wheels. Tests requiring simulation skip in that environment; Python 3.12 to 3.14 still install the extra and run them.
The cobra environment is the one which installs cobrapy, the optional cobra extra, and runs the tests which need it, i.e., the flux balance comparison of tests/test_package_semantics.py and tests/fbc/test_cobra.py; it is pinned to python 3.14 and runs locally only, continuous integration does not install cobrapy.
Here -- replaces those two modules, so tox r -e cobra -- tests/fbc/test_cobra.py runs that one.
The lowest environment installs the oldest version of every dependency which the lower bounds in pyproject.toml allow (uv_resolution = lowest-direct, their own dependencies stay at the newest) on python 3.12 and runs the suite against it, so a lower bound is only ever raised or lowered together with a run of it. pymetadata requires newer releases of rich and requests than sbmlutils does, which would lift them above the bounds; lowest-overrides.txt therefore overrides rich, requests and markdown-it-py to exactly their bounds (through UV_OVERRIDE), and has to be kept equal to them. It runs locally only, as part of tox run-parallel.
On Linux the tests run with the standalone interpreters of uv (UV_PYTHON_PREFERENCE=only-managed). The extension of libroadrunner links against libpython3.X.so.1.0, which these interpreters ship in their lib directory but, being linked statically, never load; the workflow puts that directory on LD_LIBRARY_PATH, which tox passes on to the tests. Locally the same import error (libpython3.X.so.1.0: cannot open shared object file) is solved by that variable or by the libpython3.X package of the distribution.
The workflows pin every action to the full commit SHA of a release, with the version in a comment; dependabot updates both. The tools run with uvx (tox, tox-uv, twine) are pinned in the env of ci-cd.yml and ty.yml, which dependabot does not update, so they are raised by hand. The same holds for uv itself, pinned with the version input of every astral-sh/setup-uv step in ci-cd.yml, ty.yml and docs.yml. hatchling in [build-system] stays a lower bound on purpose: the package is built in an isolated environment which resolves it anew, and the lower bound is the oldest release the build is known to work with.
Dependabot also bumps the locked python dependencies (uv.lock, versioning-strategy: lockfile-only so the lower bounds in pyproject.toml are never raised by it) and the hook revisions in .pre-commit-config.yaml, each as one grouped weekly pull request. The ruff workflow installs the ruff version locked in uv.lock, so CI and uv run ruff agree, and the ruff and ty hook revisions are expected to match the lock; after merging one of these pull requests, raise the other to the same version if it lags.
To run the tests directly against the development environment use
pytest # the full suite
pytest tests/test_factory.py # a single module
pytest tests/test_factory.py::test_model_units # a single test
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 tests/examples/ can import the examples.
Some tests are skipped unless what they need is there: the models of the SBML test suite and the biomodels archives are only present in a checkout, tests/fbc/test_cobra.py and the flux balance comparison of tests/test_package_semantics.py need cobrapy (the cobra extra, which the tox cobra environment installs), and tests/test_biomodels.py queries the live BioModels service.
The downloads of sbmlutils.biomodels go through the retrying session of pymetadata, which retries the transient error responses (429, 500, 502, 503, 504) with an exponential backoff and times out after 30 seconds; test_download_file_retries_transient_error covers this against a local server and needs no network. What retrying cannot fix is a service which is unreachable or which refuses the request - BioModels answers the GitHub runners with 403 Forbidden - so those tests probe the service first and are skipped rather than failed.
The ODE export¶
The ODE export is the package sbmlode, a dependency which sbmlutils.converters.ode re-exports. Its tests, with the julia, R and LaTeX toolchains and the verification against libroadrunner over the SBML test suite, live in that repository; sbmlutils only tests the re-export and create_model(create_markdown=True) (tests/converters/test_ode_reexport.py).
Linting and formatting¶
Linting and formatting use ruff:
The model definitions of the examples are written against the names of sbmlutils.factory, which they import with a star import. This is the documented style, so F403/F405 are ignored for examples/ and tests/ in .ruff.toml instead of globally.
Type checking¶
Type checking is performed with ty:
Or directly in the working tree:
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 has no type stubs and creates its objects through a SWIG layer, so ty sees an untyped API. Annotate the libsbml 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/ builds every model definition and 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/sbmlutils 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:
mkdocstrings renders a name which a package re-exports from one of its modules only if the name is in __all__ or the page lists it in members. sbmlutils.factory re-exports far more than the names of its __all__, which are what a model definition imports with its star import, so docs/api/factory.md lists its classes and functions in members, and tests/test_factory_facade.py checks that the list is complete.
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.
Included files¶
pymdownx.snippets includes a file of docs/ into a page, --8<-- "path/in/docs.md", and fails the build for a file which does not exist. The exclude plugin in zensical.toml keeps docs/superpowers/, where coding agents write their design documents and plans, from becoming pages of a local build; the directory is gitignored, such documents are not part of the repository.
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.
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,tox r -e ty - 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/sbmlutils/__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 - 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:Only a repository admin can push a tag, see
tag-creation.json. This starts theCI-CDworkflow, which runs the test matrix, builds the distributions in a job without write permissions (build), publishes them to pypi with attestations by trusted publishing from thepypienvironment (publish), creates the GitHub release fromrelease-notes/x.y.z.md(github-release) and fast-forwardsmainto the tagged commit (sync-main). 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
The construction diagnostics specification and implementation plan describe the opt-in strict authoring contract and its validation.
The round-trip preservation specification and plan describe known-loss detection and strict parsing/writing.