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.
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.
Example:
>>> formula_mass("H2O").monoisotopic_mass # doctest: +SKIP
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.)
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¶
- A guide page under
docs/guides/if it's a new area, following the PRIDE guide: what it does, the common cases, the errors, one worked example that solves a real problem. - 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.
Checklist¶
- [ ] 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