Contributing¶
Open a small pull request, run the linter, the type checker, and the tests, and bring the docs along for anything that changes user-visible behavior. What follows is the detail behind that.
Before you start¶
Skim Architecture first, which explains the AST builder pattern, the vendor-extension hooks, and the serialization registry; most changes touch one of these. Then read the reference section: The .qp file format is the normative description of the text format, src/qprogram/grammar/qp.lark is its machine-readable form, and the API reference is generated from the docstrings under src/, so the code and the reference move together.
A change that alters user-visible behavior changes the docs in the same pull request. Say so in the description, and name the guide pages you touched.
Development workflow¶
- Fork and clone. The repository root is the package root.
- Install the package. The
devdependency group installs by default, so this one command gets the linter, the type checker, pytest, hypothesis, and lark alongside the package and its extras.
- Install the docs environment, if you are changing docs.
--all-extras matters here: mkdocstrings imports the package to render the API reference, so the project and its optional dependencies have to be installed, not just the docs tooling.
-
Make your change.
-
Lint and format. Both run from
pyproject.tomland neither imports the code.
- Type-check.
[tool.ty.src]setsinclude = ["src"], so this covers the package and not the test suite.
- Run the tests. See Testing for what the suite covers and what a new feature needs from it.
-
Update the docs. Anything user-visible needs an entry in the relevant guide page. New operations, waveforms, and sweep sources also need a mention in
docs/reference/qp-format.md, and a new public symbol needs aqprogram.<Name>directive indocs/reference/api-qprogram.md. A new page is unreachable until it appears in the nav inzensical.toml. -
Build the site, if you touched docstrings or a reference page.
--strict turns a warning into a failure. The one that matters is an unresolved mkdocstrings cross-reference, which would otherwise ship as a dead link. This is the same command docs.yml runs.
-
Add a changelog entry. Anything a user would notice gets one news fragment under
changelog/, named<pr-number>.<type>.md. The types areadded,changed,fixed,removed, andmisc; the first four render their text under a heading of the same name, whilemiscis configured withshowcontent = false, so amiscfragment contributes only its pull request link.That writes a file containing the placeholder
Add your info here, which you then replace with one or two sentences about what changed for somebody using the library;--content "..."sets the text in the same command. A fragment written before the pull request has a number takes a+prefix and any name, as in+lazy-waveform-binding.added.md; towncrier renders such a fragment without a link, so rename it once the number exists. Internal refactors, test-only changes, and docs corrections do not need one. -
Open the pull request. Three of the workflows under
.github/workflows/run on it, and each skips while the pull request is a draft:tests.ymlruns the suite on Python 3.11 and 3.14 and uploads coverage from the 3.13 job onmain;code_quality.ymlrunsruff checkandruff format --diffonce on Python 3.13, thenty checkonce per supported version from 3.11 through 3.14; anddocs.ymlbuilds this site withzensical build --strict. Testing describes the jobs in more detail.
What "small PR" means¶
One concept per pull request. A new operation plus a new waveform plus a bug fix in the parser is three pull requests. Each one is easier to review, easier to revert, and easier to bisect against.
If you find yourself in a long branch with many concepts, split it. The maintainers will ask you to anyway.
Style notes¶
Most of this is configured in pyproject.toml and enforced by ruff or by a test; the rest is a review convention. The list is not exhaustive.
Ruff runs with preview = true and select = ["ALL"], minus an ignore list written as rule names rather than codes, so the reason for each exemption reads off the config. Line length is 120 and the formatter owns it, which is why line-too-long is one of the exemptions. tests/** carries its own per-file-ignores, dropping the annotation and docstring families and the security rules, so a test may reach into a private helper and skip its type hints. Expect the linter to push back on code brought in from elsewhere.
Docstrings are enforced under the Google convention. Preview mode means both the D and the DOC families run, so every parameter gets a name (type): Description. entry, where the parenthesized type is house style even though the signature is annotated. A non-None return needs a Returns: section and every exception a caller can observe needs a Raises: entry. Constructor arguments are documented on the class docstring, not on __init__, which is why undocumented-public-init is in the ignore list. docstring-code-format = true means the formatter reformats code inside docstrings, so an example in one has to be valid Python.
Cross-references in docstrings are Markdown, not Sphinx roles. Write [`Variable`][qprogram.Variable] for a target the API reference documents, and plain `Variable` for anything it does not: a builtin, a stdlib name, a private helper no page renders. [`qprogram.Range`][] is the shorthand when the text is already the full path. mkdocstrings reads a docstring as Markdown and has no reStructuredText reader, so a role such as :class:`~qprogram.Variable` would reach the page as literal text. tests/test_docstring_style.py scans every module under src/ and fails the suite on one, and it also catches a cross-reference that lost its target.
Every file carries the Apache header: the standard 13-line notice with Copyright 2026 Qilimanjaro Quantum Tech. Ruff's missing-copyright-notice rule reads the expected author from [tool.ruff.lint.flake8-copyright] and fails the lint on a file without it.
Type hints go everywhere. ty checks src only, and one rule is switched off there: unused-ignore-comment, because a Values(...) call needs an invalid-argument-type suppression on Python 3.12 and later, where numpy's ArrayLike does not admit the narrowed operand type, and that suppression is then reported as unused on 3.11.
.qp files use two-space indentation, in test fixtures as well as in examples. Tests are functions, not methods on a class, and use fixtures and parametrization for shared setup.
New runtime dependencies need discussion first. qprogram depends on numpy and xarray and nothing else, which is what lets it install next to whatever a lab already has; anything heavier belongs in an extra, the way matplotlib sits behind qprogram[viz] and pygls behind qprogram[lsp]. Supported Python versions are 3.11 through 3.14, so anything that only works on a newer interpreter needs a fallback.
What goes where¶
Use this when you are not sure which file to touch.
| Change kind | Where it lands |
|---|---|
| New core operation | src/qprogram/operations/<name>.py, exported from operations/__init__.py, a method on QProgram, and a register_operation line in _register_core_specs() in src/qprogram/serialization/_specs.py. |
| New core waveform | src/qprogram/waveforms/<name>.py, exported from waveforms/__init__.py, and added to the class list in _register_builtin_waveforms() in src/qprogram/serialization/registry.py. |
| New sweep source | src/qprogram/sweeps/builtin.py or combinators.py, exported from sweeps/__init__.py, and added to the register_sweep_source loop in src/qprogram/serialization/_specs.py. That call also registers the class's TOKEN with the capability registry. |
| Parser change | src/qprogram/serialization/parser.py. |
| Writer change | src/qprogram/serialization/writer.py. |
| Grammar change | src/qprogram/grammar/qp.lark, kept in step with the parser by tests/test_grammar.py. A change that stops an existing file from parsing also needs a migration in src/qprogram/serialization/migrations.py, registered under the version that ships it, so files written before it keep loading. |
| New vendor operation | The vendor's own package. See Building a vendor extension. A change to an existing operation's wire form also needs a register_vendor_migration in that package, registered under the version that ships it. |
| New vendor package | A separate package depending on qprogram. Same guide. |
| Docs | docs/, with the nav in zensical.toml. |
| Changelog entry | One fragment in changelog/. Never edit CHANGELOG.md by hand. |
Releasing¶
CHANGELOG.md is assembled from the fragments in changelog/, so it is written once per release rather than edited per pull request. A release goes out from its own pull request:
- Branch from an up-to-date
main. - Set the new version. This writes both
pyproject.tomlanduv.lock; nothing else holds the literal, sinceqprogram.__version__is read from the installed metadata.
- Assemble the changelog. Pass the version explicitly. Left to guess, towncrier reads the installed metadata and can render a stale number into a heading that is never regenerated.
uv run towncrier build --draft --version "$(uv version --short)" # preview
uv run towncrier build --version "$(uv version --short)" --yes
The second command writes a ## <version> (<date>) section into CHANGELOG.md under the <!-- towncrier release notes start --> marker and deletes the fragments it consumed. Each entry carries a [PR #<n>](https://github.com/qilimanjaro-tech/qprogram/pull/<n>) link built from the fragment's file name.
- Read the rendered section and edit it. Fragments are written weeks apart by different people and rarely read as one voice when they land together.
- Open the release pull request, and merge it once CI is green.
- Create the GitHub Release on the merge commit, with a tag matching the version now in
pyproject.toml, and use the new changelog section as the release body. - Bump
mainto the next patch as a development version, so a checkout ofmainstops being indistinguishable from the release that just went out.uv syncmatters as much as the bump: the format version is read from the installed metadata, not frompyproject.toml.
Bump the patch, not the minor. The version is not only what PyPI serves: both file headers carry it truncated to major.minor, so the minor is the format version. 0.2.1.dev0 still writes 0.2, which is what the release writes, while 0.3.0.dev0 would stamp 0.3 into every file written from main for the rest of the cycle. If that cycle then ships as 0.2.1, the release refuses those files, since 0.3 reads as later than 0.2 and nothing runs backwards. Move the minor in the pull request that actually changes the format, next to the migration it registers, rather than in advance.
Publishing the release triggers publish.yml. Its build job runs uv build, which produces one wheel and one sdist; qprogram is pure Python, so a single wheel covers every interpreter and platform, and a package with compiled extensions would need a build matrix here instead. The publish job downloads those artifacts, lists them, validates them with twine check, and uploads them with uv publish --trusted-publishing always. The --check-url pointing at https://pypi.org/simple/qprogram/ lets a retried run skip files that already landed. Pre-releases publish the same way, so a version such as 0.2.0rc1 reaches PyPI and pip installs it only when asked with --pre.
The upload job runs in the pypi GitHub environment, so any protection rule on that environment (a required reviewer, a wait timer) gates the upload. PyPI never lets a file be replaced, so that gate is the last point at which a wrong version can be stopped. The workflow's concurrency group is keyed on the release tag with cancel-in-progress: false, because a publish cancelled mid-upload can leave an index in a state that is hard to recover from.
publish.yml can also be started by hand from the Actions tab, which is how the first release goes out and how a run that failed on a transient error is retried. A manual run takes three inputs. platform chooses between PyPI and the qilimanjaro AWS CodeArtifact domain; the CodeArtifact path authenticates through OIDC role chaining and uploads with twine upload against a CodeArtifact authorization token rather than through trusted publishing. repository names the CodeArtifact repository, and a dispatch that selects aws without it fails in seconds in the check-inputs job rather than after the distributions build. dry_run passes --dry-run to uv publish on the PyPI path and skips the upload step entirely on the CodeArtifact path, so both build and validate without publishing.
Commit messages¶
Short, imperative, explanatory. The body is more important than the title; explain why, not what. The history in git log is a good template.
License and attribution¶
QProgram is licensed under the Apache License, Version 2.0. The full text is in the LICENSE file at the repository root, and every source file carries the matching 13-line header. By opening a pull request you agree to license your contribution under the same terms.
Where to ask¶
Bug reports, feature requests, and chores each have an issue template under .github/ISSUE_TEMPLATE/. A bug report wants the shortest program that shows the problem, and the .qp text for it if serialization is involved. A feature request wants what you cannot do today and why the workaround is not good enough; an operation, waveform, or sweep source that only one vendor's hardware can run belongs in that vendor's extension package rather than here, and Building a vendor extension covers the hooks. For design discussion, open an issue that names the affected code, the behavior you expect, and the guide page that documents it.