Adding a capability¶
How to expose another piece of mzLib. The recipe is short by design — if adding a capability were hard, coverage would never grow.
Worked end to end below with a hypothetical pymzlib.chemistry.formula_mass().
Before you write anything¶
Check that it belongs here. Two questions:
- Is it coarse-grained? Each call costs about 120 ms of process startup — measured, and far more than it sounds — and must serialize its data as JSON. Fetching a project manifest: perfect. Computing an isotopic envelope inside a loop over 100,000 spectra: wrong — that needs a different transport, and forcing it through this one produces something slow enough to discredit the whole package.
- Does mzLib already do it? Don't reimplement logic on the bridge side. The bridge is a translation layer. If mzLib's version needs a change, change mzLib.
Check the dependency cost. Referencing a new mzLib project may drag substantial weight into the payload. Not a blocker (D8), but worth knowing before rather than after.
0 · Write the spec first¶
Every wire verb has one language-neutral spec, <module>.<verb>.yaml, in the bridge repository's
design/verbs/. The spec holds the facts every binding must agree on:
- parameters, with their units and valid ranges;
- result fields, with their units and what null means;
- error kinds, caveats, the mzLib code it wraps, DOIs;
- the name in each binding, and a recorded example fixture.
It lands before your handler, or in the same change, and your pull request cites it. The switch key
in DispatchAsync must equal the spec's verb.
That repository is private, so copy the spec in and render its tables:
Do not edit docs/specs/ by hand. How the reference facts are generated
explains why, and what CI then checks.
1 · Add the verb to the bridge¶
In pkg/bridge/Program.cs, route it in DispatchAsync and write the handler:
The handler below needs using Chemistry; at the top of Program.cs. Before reaching for a new
ProjectReference, check whether the type is already reachable — Chemistry arrives transitively
through UsefulProteomicsDatabases, so this particular example needs no new reference at all.
/// <summary>
/// <c>chemistry formula-mass --formula H2O</c> — the monoisotopic and average mass of a
/// chemical formula.
/// </summary>
private static object ChemistryFormulaMass(Arguments arguments)
{
string formula = arguments.Required("formula");
var chemicalFormula = ChemicalFormula.ParseFormula(formula);
return new
{
formula,
monoisotopic_mass = chemicalFormula.MonoisotopicMass,
average_mass = chemicalFormula.AverageMass,
atom_count = chemicalFormula.AtomCount,
};
}
Rules that matter:
- Return an anonymous object, not an mzLib type. mzLib's shapes carry things a JSON consumer can't use and would couple the wire format to mzLib's internals.
snake_casekeys.JsonNamingPolicy.SnakeCaseLowerhandles this; don't fight it.- Never catch and swallow.
Mainconverts any exception into a structured error envelope. Letting it propagate gives the caller the .NET exception type, which is the useful part. - Throw
UsageExceptionfor bad input, so it exits2and surfaces asUsageErrorin Python rather than looking like a runtime failure. - Name it for the contract, not the caller. Someone writing a Rust binding will read this — see D6.
Check it directly, before any Python exists. The executable is not on PATH — run the one
publish-bridge.ps1 staged into the package:
./pkg/python/src/pymzlib/_dotnet/win-x64/mzlib-bridge.exe chemistry formula-mass --formula H2O
{"ok":true,"data":{"formula":"H2O","monoisotopic_mass":18.01056468403,"average_mass":18.015349999999998,"atom_count":3},"error":null}
Note "error": null — the envelope always carries all three keys, so a consumer in a statically
typed language can deserialize one fixed shape whatever the outcome.
Re-stage the bridge, or Python keeps using the old one
bridge_path() prefers the executable staged under src/pymzlib/_dotnet/, so after changing
C# you must either re-run publish-bridge.ps1 or point PYMZLIB_BRIDGE at your
dotnet build output. Otherwise your new verb reports Unknown command 'chemistry
formula-mass' while you stare at the registration you just wrote. The build page frames
PYMZLIB_BRIDGE as a speed optimisation; here it is load-bearing for correctness.
2 · Add the Python surface¶
A new area gets its own module (pkg/python/src/pymzlib/chemistry.py); an addition to an
existing area goes in that module.
"""Chemical formula and mass calculations, backed by mzLib's Chemistry library."""
from __future__ import annotations # required — keeps modern syntax legal on 3.9
from dataclasses import dataclass
from . import _bridge
__all__ = ["FormulaMass", "formula_mass"]
@dataclass(frozen=True)
class FormulaMass:
"""The masses of a chemical formula.
Attributes:
formula: The formula as given, e.g. ``"H2O"``.
monoisotopic_mass: Mass using the most abundant isotope of each element, in daltons.
average_mass: Mass weighted by natural isotopic abundance, in daltons.
atom_count: The number of atoms — 3 for water, not 2. Name a wire key for what mzLib
actually returns: the envelope is a durable, language-neutral contract, so a
misnamed key outlives the mistake and every copy of this example repeats it.
"""
formula: str
monoisotopic_mass: float
average_mass: float
atom_count: int
def formula_mass(formula: str, timeout: float | None = 60) -> FormulaMass:
"""Return the monoisotopic and average mass of a chemical formula.
Args:
formula: A chemical formula in mzLib's notation, e.g. ``"H2O"``, ``"C6H12O6"``.
timeout: Seconds to allow.
Returns:
The computed masses.
Raises:
UsageError: the formula is blank or cannot be parsed.
Examples:
>>> formula_mass("H2O").monoisotopic_mass
18.01056468403
"""
if not formula or not formula.strip():
raise _bridge.UsageError("A chemical formula is required, e.g. 'H2O'.")
data = _bridge.invoke("chemistry", "formula-mass", "--formula", formula.strip(), timeout=timeout)
return FormulaMass(
formula=data["formula"],
monoisotopic_mass=float(data["monoisotopic_mass"]),
average_mass=float(data["average_mass"]),
atom_count=int(data["atom_count"]),
)
Conventions:
- Validate in Python too, where it's cheap. Catching a blank argument without starting a process gives a better error and a faster one. Not duplicated logic — a fast path in front of the authoritative check.
- Return dataclasses, not dicts. Attributes autocomplete, typos raise, and the API reference documents itself.
- Google-style docstrings — the API reference is generated from them.
- Never leak the transport. No
subprocess, no exit codes, no raw JSON above_bridge.py. - No new runtime dependency. Not negotiable — see D2.
Export it in __init__.py — the import and __all__, or from pymzlib import * misses it:
3 · Test it, both ways¶
Offline (pytest -m "not network") — the Python layer against a recorded payload. These are
the tests that run constantly, so make them cover the behavior you actually care about:
from __future__ import annotations
import pytest
import pymzlib
from pymzlib import _bridge, chemistry
def test_formula_mass_parses(monkeypatch):
monkeypatch.setattr(_bridge, "invoke", lambda *a, **k: {
"formula": "H2O", "monoisotopic_mass": 18.01056468403,
"average_mass": 18.015349999999998, "atom_count": 3,
})
assert chemistry.formula_mass("H2O").monoisotopic_mass == pytest.approx(18.01056, abs=1e-5)
def test_blank_formula_rejected_without_starting_a_process():
with pytest.raises(pymzlib.UsageError):
chemistry.formula_mass(" ")
Live (@pytest.mark.network, or @pytest.mark.slow if it moves real data) — the real bridge,
which is what would catch mzLib changing underneath. Put these in a _live.py module and route
each through external_service() so a PRIDE outage skips rather than fails.
Record a fixture from the real bridge rather than hand-writing one; hand-written fixtures encode
what you think the format is. (The stub payload above is inline only to keep the example short.)
scripts/record_fixture.py does it in one command, from a bridge built at the pin, on data that
ships with mzLib's tests where possible:
python scripts/record_fixture.py readers_records_crux.json -- readers read-records --path crux.txt --limit 3
It writes the envelope's data, the shape every fixture has, and refuses to overwrite one
without --force. Pass --compact for a recording over a few hundred KB, and --stdin <file> for
a verb that reads its input there.
3b · Test the C# half too — CI gates it separately¶
This is the step most likely to be skipped, and skipping it fails the build. The bridge has its own suite and its own coverage gate at 85%. New C# lines with no C# test will drag the whole project under it, and nothing in the Python suite will warn you.
Copy the shape from pkg/bridge.tests/VerbHandlerTests.cs — a StubHandler for anything that
would otherwise reach the network, plus direct tests of whatever the verb computes:
dotnet test pkg/bridge.tests/MzLibBridge.Tests.csproj --filter "TestCategory!=ExternalService"
./pkg/build/check-bridge-coverage.ps1
Both must pass before you open the pull request. The raw coverage number dotnet test prints is
meaningless here — it measures all of mzLib and reads about 0.6% — which is exactly why the script
exists. See the testing section of CONTRIBUTING.md
for the full picture, including the skip-versus-fail convention for anything touching a live service.
4 · Document it¶
The bar is that a reader can trust every number on the page, because CI produced it.
pkg/python/tests/test_docs_lint.py checks the parts of this a machine can check.
- A guide page under
docs/guides/if it's a new area, following the FlashLFQ guide:- Open with a question table:
| You want to | Call | mzLib does it with |, one row per question a reader arrives with. - Every example runs. Write it as a
>>>session in a fencedpyconblock, on real data, against a recording.docs/conftest.pyruns it in CI through the replay bridge, so a guide that drifts from the code fails the build. A block that cannot run (it downloads, writes a file, or needs pandas) is apythonblock with a visibletitle="Not run: <why>"; there are no silent skips. - No counts or mzLib versions in prose. A count of formats once sat in seven places and went stale in all of them with one release. Show a count as executed output, and put a version on the Upgrading page.
- End with a Cite section that includes
docs/reference/_generated/cite.<guide>.md, which is rendered from the spec'sciteDOIs. Check every DOI resolves before it goes in the spec. - Say what a result means for the reader's science, in plain, short sentences.
- Open with a question table:
- If the new mzLib changes a value an existing function returns, add it to
docs/upgrading.md, citing the mzLib pull request. - Add it to the nav in
mkdocs.ymland to the coverage table on the home page. - Add
pymzlib.<module>todocs/reference.md. The reference is generated from your docstrings, but only for modules it is told about — a new module is silently absent otherwise. - Include the verb's rendered fact table on
docs/reference/<module>.md, with--8<-- "docs/reference/_generated/<module>.<verb>.md"under a heading. Create the page, and its nav entry, for a new module.test_spec_docs.pyfails for a shipped verb whose table is on no page. - Make the docstring agree with the spec. Every spec param goes in
Args:, and every result field goes in the result class'sAttributes:, each naming its unit.test_spec_docs.pychecks this. A deliberate rename is declared inPYTHON_DEVIATIONSinscripts/spec_facts.py. - Make the example run. The doctests run in CI against the spec's recorded fixture through a
replay bridge, so write the call the fixture was recorded with and paste what it prints. Keep
# doctest: +SKIPonly for the network, a download or pandas, with a>>> #line above saying why.
Checklist¶
- [ ] Spec in the bridge, synced to
docs/specs/, tables rendered - [ ] Coarse-grained enough that per-call startup is irrelevant
- [ ] No logic reimplemented that mzLib already has
- [ ] Bridge verb returns an anonymous object with
snake_casekeys - [ ] Errors propagate; bad input throws
UsageException - [ ] Verb readable as a contract by a non-Python caller
- [ ] Python returns a dataclass, validates cheaply, hides the transport
- [ ] No new runtime dependency
- [ ] Offline Python tests with a recorded fixture; live test for the real path
- [ ] C# tests for the new verb, and
check-bridge-coverage.ps1still passes (separate gate) - [ ] Guide page, nav entry, coverage table, and
reference.mdentry - [ ] Guide opens with a question table, every example runs or says why not, ends with Cite;
test_docs_lint.pypasses - [ ] Fact table included on
docs/reference/<module>.md;test_spec_docs.pyand the doctests pass