# unimodpy

> Typed, dependency-free Python library for parsing and querying the UNIMOD
> mass-spectrometry modifications database, with the full database bundled for
> offline use, plus an optional FastAPI REST + MCP server (hosted at unimod.tacular.dev).

This is the complete usage guide for unimodpy 0.2.x, written for AI tools and people
who use the package. Contributor notes live in CLAUDE.md in the repository.

- Source: https://github.com/tacular-omics/unimodpy
- PyPI: https://pypi.org/project/unimodpy/
- Hosted API + MCP: https://unimod.tacular.dev
- Browser: https://tacular-omics.github.io/unimodpy/
- Upstream data: http://www.unimod.org/ (Creasy & Cottrell, Proteomics 2004)

## Install

```bash
pip install unimodpy              # core: Python >= 3.12, no third-party dependencies
pip install "unimodpy[server]"    # + fastapi, uvicorn, mcp>=2.1.1,<3
uv add unimodpy
```

The UNIMOD OBO file (1,561 terms including the root node `UNIMOD:0`) ships inside the
wheel at `unimodpy/data/UNIMOD.obo`. Nothing touches the network unless you ask for a
refresh.

## Quick start

```python
import unimodpy

db = unimodpy.load()                      # bundled database
len(db)                                   # 1561

acetyl = db.get_by_id(1)                  # int, "1", "UNIMOD:1" or "unimod:1"
acetyl.name, acetyl.delta_mono_mass       # ('Acetyl', 42.010565)
acetyl.proforma_formula                   # 'C2H2O'

phospho = db.get_by_name("phospho")       # exact name, case-insensitive
phospho.id                                # 21

db["Oxidation"].id                        # 35  (db[...] tries ID, then name; KeyError if neither)
len(db.search("glycosyl"))                # 6   (substring over name, definition, synonyms)

db.get_by_id(999999)                      # None
db.get_by_name("not a mod")               # None
```

## Public API

Everything below is importable from `unimodpy` (`unimodpy.__all__`).

### Loading

`load(source: Path | str | None = None, *, refresh: bool = False, cache: bool = False) -> UnimodDatabase`
: Load the database. No arguments: the bundled OBO. `source`: parse that OBO file.
  `refresh=True`: call `download(force=True)` first and parse the fresh file. Passing
  both `source` and `refresh=True` raises `ValueError`. `cache=True` (1.1): parse the
  bundled file once per process and return that same database on every later
  `load(cache=True)` call; treat it as read-only (entries are frozen; do not reassign its
  attributes). `cache=True` with `source` or `refresh=True` raises `ValueError`. Without
  it every `load()` parses anew.

`parse_obo(path: Path | str) -> UnimodDatabase`
: Parse any UNIMOD-format OBO file. Streams `[Term]` blocks; keeps the header lines
  (everything before the first `[Term]`) in `db.header_lines`. A term without `id` or
  `name` is skipped with a `UserWarning`. A site, position or classification this
  version does not know is kept as the raw string, with a `UserWarning`, so a newer
  upstream file still loads. A malformed value (bad id, number, date, incomplete
  neutral loss) raises `UnimodParseError` naming the file, line and entry. Two terms
  with the same id raise `UnimodError`.

`download(dest: Path | str | None = None, *, force: bool = False) -> Path`
: Download `https://www.unimod.org/obo/unimod.obo` to `dest` (default
  `~/.cache/unimodpy/UNIMOD.obo`), creating parent directories. Returns the path. An
  existing `dest` is returned as is unless `force=True`. Network errors propagate from
  `urllib`; a failed download leaves no partial file.

### Errors

`UnimodError(Exception)` is the base class;
`UnimodParseError(UnimodError, ValueError)` is raised for a malformed OBO file;
`UnimodKeyError(UnimodError, KeyError)` is raised by `db[key]` on a miss, so both
`except KeyError` and `except UnimodError` catch it. All live in `unimodpy.errors` and
are exported.

### Writing

`write_tsv(entries: Iterable[UnimodEntry], path: Path | str, *, delimiter: str = "\t") -> Path`
: One row per entry. Columns: `id` (as `UNIMOD:N`), `name`, `definition`, `synonyms`,
  `comment`, `record_id`, `delta_mono_mass`, `delta_avge_mass`, `delta_composition`,
  `username_of_poster`, `group_of_poster`, `date_time_posted`, `date_time_modified`,
  `approved`, `is_a`, `specificities`. Multi-valued cells are joined with `"; "`;
  a specificity is written `site:position:classification`. `None` becomes an empty cell.
  Pass `delimiter=","` for CSV.

`write_obo(entries: Iterable[UnimodEntry], path: Path | str, *, header_lines: Iterable[str] = ()) -> Path`
: Write entries back to UNIMOD OBO. With no `header_lines`, a minimal
  `format-version: 1.4` / `default-namespace: UNIMOD` header is written.
  `parse_obo(write_obo(db, ...))` reproduces every entry and the header exactly.

### UnimodDatabase

`UnimodDatabase(entries: Iterable[UnimodEntry], *, header_lines: tuple[str, ...] = ())`
: In-memory collection. Build one yourself to filter or combine entries. Raises
  `UnimodError` if two entries share an id.

| member | purpose |
|---|---|
| `get_by_id(id: int \| str) -> UnimodEntry \| None` | int, `"21"`, `"UNIMOD:21"` (prefix case-insensitive); the number must be plain ASCII digits (`"2_1"`, `"+21"` -> `None`); other strings or `bool` -> `None` |
| `get_by_name(name: str) -> UnimodEntry \| None` | exact name, case-insensitive; if names collide the first entry in file order wins; non-`str` -> `None` |
| `search(query: str) -> list[UnimodEntry]` | case-insensitive substring over name, definition, synonyms; file order; non-`str` -> `[]` |
| `search_mass(delta: float, *, tolerance: float = 0.01, tolerance_unit: str = "da", site: str \| None = None, position: str \| None = None) -> list[tuple[Entry, float]]` | (1.1) delta-mass search on `delta_mono_mass`; `(entry, delta - mass)` pairs, closest first; see Mass search below |
| `get_by_site(site: str) -> list[Entry]` | (1.1) entries with a specificity on residue `site` or on `"N-term"`/`"C-term"` (case-insensitive), in file order. It takes exactly one residue (`search_mass(site=)` takes several). Unknown or non-string input returns `[]`. |
| `db[key]` | `get_by_id(key)`, else `get_by_name(key)` for a string, else `UnimodKeyError` (a `KeyError`; also for non-int/str keys) |
| `db.get(key, default=None)` | `db[key]`, or `default` instead of `KeyError` |
| `len(db)`, `iter(db)` | count and file-order iteration |
| `write_tsv(path, *, delimiter="\t") -> Path` | as the module function |
| `write_obo(path) -> Path` | as the module function, reusing `db.header_lines` |
| `header_lines: tuple[str, ...]` | OBO header, e.g. `('format-version: 1.4', 'date: 17:02:2026 11:36', ...)` |

### Mass search (1.1)

`search_mass(delta, *, tolerance=0.01, tolerance_unit="da", site=None, position=None) -> list[tuple[entry, float]]`: entries whose `delta_mono_mass` is within `tolerance` of `delta`, as `(entry, error)` pairs with `error = delta - mass`, closest first (ties in mass, then file order). `tolerance_unit` accepts only `"da"` (exact, lowercase): ppm is not offered because a ppm window on a delta mass is ill-defined, and the keyword keeps the call shape of `tacular.tolerance` so units can be added later. Both window edges are inclusive. Entries without a mass are skipped. `site` is one or more residue letters (`"S"`, `"STY"`, any case; any of them matches, while `get_by_site` takes exactly one) or `"N-term"`/`"C-term"`, matched against the entry's specificities (hidden ones included), `site` letters and `N-term`/`C-term`. `position` is where the residue was observed: `"anywhere"`, `"peptide n-term"`, `"peptide c-term"`, `"protein n-term"`, `"protein c-term"` (case-insensitive; `"Any N-term"`/`"Any C-term"` are aliases for the peptide ones); it is matched against each specificity's `position` (Anywhere, Any N-term, Any C-term, Protein N-term, Protein C-term): `"protein n-term"` also matches Any N-term rules, `"peptide n-term"` does not match Protein N-term rules. A modification of the terminus itself matches any residue at that terminus. A sorted mass index is built on the first call, and each search is a bisect. A bad `site`, `position`, `tolerance_unit`, `delta` or `tolerance` raises `UnimodError`.

### Entry mass (1.1)

`UnimodEntry.get_mass(*, monoisotopic=True) -> float | None`: the mass difference in Da, `delta_mono_mass` (default) or `delta_avge_mass` (`monoisotopic=False`), with the same keyword as tacular 2.0's `get_mass`. The old attributes stay. `search_mass` uses it.

### UnimodEntry (frozen, slots)

| field | type | notes |
|---|---|---|
| `id` | `int` | `35` for `UNIMOD:35` |
| `name` | `str` | e.g. `"Oxidation"` |
| `definition` | `str` | `""` if missing |
| `synonyms` | `tuple[str, ...]` | |
| `definition_ref` | `str` | citation list from the `def:` line without brackets, e.g. `"RESID:AA0027, PMID:11461766, ..."`; `""` if none |
| `comment` | `str \| None` | |
| `record_id` | `int \| None` | |
| `delta_mono_mass`, `delta_avge_mass` | `float \| None` | monoisotopic / average mass shift, Da |
| `delta_composition` | `str \| None` | raw UNIMOD string, e.g. `"H(-1) N(-1) O"`, `"HexNAc"`, `"C(-6) 13C(6)"` |
| `username_of_poster`, `group_of_poster` | `str \| None` | |
| `date_time_posted`, `date_time_modified` | `datetime.datetime \| None` | |
| `approved` | `bool \| None` | |
| `is_a` | `int \| None` | parent ID: `0` for every term except the root, which has `None` |
| `specificities` | `tuple[Specificity, ...]` | sorted by `spec_num`; includes hidden ones |

Properties:

- `accession -> str`: `"UNIMOD:21"`, the same string as the server's `accession`.
- `dict_composition -> dict[str, int] | None`: `delta_composition` expanded to elements.
  A malformed token (`"H(2"`, `"H(x)"`) or one that is neither an element nor a known
  monosaccharide (`"Foo(2)"`) gives `None` plus a `UserWarning` (so does `proforma_formula`);
  `unimodpy._formula.parse_delta_composition` itself raises `UnimodParseError`.
  Monosaccharide abbreviations (`Hex`, `HexNAc`, `HexA`, `dHex`, `NeuAc`, `NeuGc`, `Pent`,
  `HexN`, `Kdn`, `Hep`, `Sulf`, `sulfate`, `Ac`, `Me`, `Su`) expand to residue formulas;
  isotopes stay separate keys (`"13C"`, `"2H"`, `"15N"`); counts may be negative; zero
  counts are dropped.
- `proforma_formula -> str | None`: Hill-ordered formula string from `dict_composition`
  (C, then C isotopes, H, then H isotopes, then alphabetical), e.g. `"C2H2O"`,
  `"H-1N-1O"`, `"C-6[13C6]N-2[15N2]"`. Isotopes are bracketed as in ProForma 2.0.

The root node `UNIMOD:0` ("unimod root node") has every xref-derived field `None`.

### Specificity (frozen, slots)

`spec_num: int`, `group: int`, `hidden: bool`, `site: Site | str`, `position: Position | str`,
`classification: Classification | str` (a raw `str` only for a value this version does
not know), `misc_notes: str | None`,
`neutral_losses: tuple[NeutralLoss, ...]` (sorted by key).
`hidden=True` marks rare or deprecated sites that UNIMOD hides from default listings.
`str(spec)` gives `"Spec 1: S @ Anywhere [Post-translational]"` plus notes and losses.

### NeutralLoss (frozen, slots)

`key: int` (nominal loss mass from the xref name, e.g. `98`), `mono_mass: float`,
`avge_mass: float`, `flag: bool`, `composition: str` (raw, e.g. `"H(3) O(4) P"`).
Properties `dict_composition -> dict[str, int] | None` and `proforma_formula -> str | None`
work as on `UnimodEntry`: a zero loss (`"0"`) gives `{}` / `""`, and `None` plus a
`UserWarning` naming the token means the composition could not be parsed.

### Enums (StrEnum)

- `Site` (23): `A C D E F G H I K L M N P Q R S T U V W Y`, plus `N_TERM = "N-term"`,
  `C_TERM = "C-term"`.
- `Position` (5): `ANYWHERE "Anywhere"`, `ANY_N_TERM "Any N-term"`, `ANY_C_TERM "Any C-term"`,
  `PROTEIN_N_TERM "Protein N-term"`, `PROTEIN_C_TERM "Protein C-term"`.
- `Classification` (14): `"AA substitution"`, `"Artefact"`, `"Chemical derivative"`,
  `"Co-translational"`, `"Isotopic label"`, `"Multiple"`, `"N-linked glycosylation"`,
  `"Non-standard residue"`, `"O-linked glycosylation"`, `"Other"`, `"Other glycosylation"`,
  `"Post-translational"`, `"Pre-translational"`, `"Synth. pep. protect. gp."`.

Being `StrEnum`s, members compare equal to their strings: `Site.C == "C"`,
`Site("N-term") is Site.N_TERM`.

`__version__: str` is the package version.

## Worked examples

### Entry details

```python
import unimodpy

db = unimodpy.load()
ox = db["Oxidation"]
repr(ox)            # "UnimodEntry(id=35, name='Oxidation', formula='O', mono_mass=15.994915)"
ox.delta_avge_mass  # 15.9994
ox.date_time_posted # datetime.datetime(2002, 8, 19, 19, 17, 11)
print(db[1])        # multi-line summary: id, definition, formula, masses, sites
```

### Sites and neutral losses

```python
from unimodpy import Classification, Position, Site

phospho = db["Phospho"]
spec = phospho.specificities[0]
spec.site, spec.position, spec.classification
# (<Site.S: 'S'>, <Position.ANYWHERE: 'Anywhere'>, <Classification.POST_TRANSLATIONAL: 'Post-translational'>)

nl = spec.neutral_losses[1]
nl.key, nl.mono_mass, nl.proforma_formula      # (98, 97.976896, 'H3O4P')

visible = [s for s in phospho.specificities if not s.hidden]   # 2 of 8
```

### Filtering

```python
# every modification allowed on cysteine, anywhere in the sequence
cys = [
    e for e in db
    if any(s.site == Site.C and s.position == Position.ANYWHERE and not s.hidden
           for s in e.specificities)
]

# isotopic labels only
labels = [e for e in db
          if any(s.classification == Classification.ISOTOPIC_LABEL for s in e.specificities)]

# mass match within 1 mDa
hits = [e.name for e in db
        if e.delta_mono_mass is not None and abs(e.delta_mono_mass - 15.994915) < 0.001]
# ['Ala->Ser', 'Oxidation', 'Phe->Tyr']
```

### Compositions and formulas

```python
db["HexNAc"].dict_composition       # {'C': 8, 'H': 13, 'N': 1, 'O': 5}
db["Deamidated"].proforma_formula    # 'H-1N-1O'
db["Label:13C(6)15N(2)"].delta_composition   # 'C(-6) 13C(6) N(-2) 15N(2)'
db["Label:13C(6)15N(2)"].proforma_formula    # 'C-6[13C6]N-2[15N2]'
```

### Export and refresh

```python
db.write_tsv("unimod.tsv")
db.write_tsv("unimod.csv", delimiter=",")
db.write_obo("out/UNIMOD.obo")
assert list(unimodpy.parse_obo("out/UNIMOD.obo")) == list(db)

unimodpy.write_tsv([e for e in db if e.approved], "approved.tsv")   # any iterable of entries

fresh = unimodpy.load(refresh=True)          # re-downloads to ~/.cache/unimodpy/UNIMOD.obo
path = unimodpy.download("/tmp/UNIMOD.obo")  # just the file; reused if it exists
path = unimodpy.download("/tmp/UNIMOD.obo", force=True)  # always fetch
```

## HTTP API (server extra)

Hosted at https://unimod.tacular.dev (Vercel). Run your own:

```bash
pip install "unimodpy[server]"
uvicorn unimodpy.server.app:app --reload     # http://127.0.0.1:8000
```

The app is `unimodpy.server.app:app` (also `from unimodpy.server import app`). It loads
the bundled database once at import.

| method + path | query params | returns |
|---|---|---|
| `GET /api/health` | | `{"ok": true, "package": "unimodpy", "version": "1.1.0", "count": 1561}` |
| `GET /api/entries` | `limit` 1-500 (50), `offset` >= 0 (0), `include_hidden` (false) | `{total, limit, offset, items: [UnimodEntry]}`; starts with the root node id 0 |
| `GET /api/entries/{id}` | `include_hidden` | `UnimodEntry`; `id` is `21` or `UNIMOD:21`; 404 `{"detail": "No entry for id='...'"}` |
| `GET /api/entries/by-name/{name}` | `include_hidden` | `UnimodEntry`; exact, case-insensitive; 404 if missing |
| `GET /api/search` | `q` (required, min length 1), `limit` 1-500 (50) | `{query, total, limit, items: [UnimodSummary]}` |
| `GET /` | | browser dashboard HTML (404 when `docs/index.html` is not on disk, e.g. a pip install) |
| `GET /data.json` | | dashboard payload: list of 1,560 entries (root node excluded) |
| `GET /docs`, `/redoc`, `/openapi.json` | | FastAPI OpenAPI docs |
| `POST /mcp` | | MCP (below) |

Out-of-range query values return 422.

Wire models (`unimodpy.server.models`, pydantic):

- `UnimodEntry`: `id`, `accession` (`"UNIMOD:21"`), `name`, `definition` (`None` if empty),
  `references: [Reference]`, `synonyms`, `comment`, `is_a` (parent id),
  `delta_mono_mass`, `delta_avge_mass`, `delta_composition`, `proforma_formula`,
  `dict_composition`, `approved`, `specificities: [Specificity]`.
  Hidden specificities are removed unless `include_hidden=true`.
- `Specificity`: `spec_num`, `group`, `hidden`, `site`, `position`, `classification`
  (plain strings), `misc_notes`, `neutral_losses: [NeutralLoss]`.
- `NeutralLoss`: `key`, `mono_mass`, `avge_mass`, `flag`, `composition`, `proforma_formula`
  (`None` if the composition cannot be parsed).
- `UnimodSummary`: `id`, `accession`, `name`, `delta_mono_mass`, `proforma_formula`.
- `Reference`: `type`, `accession`, `value`. Parsed from `definition_ref` by
  `unimodpy.server.references.parse_definition_ref`: `RESID:AA0036` ->
  `{"type": "RESID", "accession": "AA0036"}`; `URL:http\://...` (also `UNIMODURL`,
  `FindModURL`, `MISCURL`) -> `{"type": "URL", "value": "http://..."}` with OBO escapes
  removed; a token without a colon -> `{"type": "Misc", "value": ...}`.

```bash
curl -s https://unimod.tacular.dev/api/entries/UNIMOD:21
curl -s "https://unimod.tacular.dev/api/entries/by-name/Carbamidomethyl?include_hidden=true"
curl -s "https://unimod.tacular.dev/api/search?q=TMT&limit=5"
```

## MCP server (server extra)

Server name `unimodpy`, instructions "Query the UNIMOD mass spectrometry modifications
database." Built on `mcp.server.MCPServer` (mcp 2.x). Tools:

| tool | arguments | returns |
|---|---|---|
| `get_by_id` | `id: str` (`"1"` or `"UNIMOD:1"`), `include_hidden: bool = false` | `UnimodEntry` or null |
| `get_by_name` | `name: str` (exact, case-insensitive), `include_hidden: bool = false` | `UnimodEntry` or null |
| `search` | `query: str` (non-empty), `limit: int = 25` (1-500) | list of `UnimodSummary`; call `get_by_id` for full records |

Results come back as `structuredContent` with an `outputSchema`, plus a JSON text block.

Three ways to reach it:

1. Hosted, streamable HTTP: `https://unimod.tacular.dev/mcp`
   `claude mcp add unimod https://unimod.tacular.dev/mcp --transport http`
2. Local HTTP: run uvicorn as above, then use `http://localhost:8000/mcp`.
3. Local stdio (no console script is installed; launch through Python):
   `python -c "from unimodpy.server import mcp; mcp.run()"`
   e.g. `claude mcp add unimod -- python -c "from unimodpy.server import mcp; mcp.run()"`

The HTTP transport is stateless: each POST is handled by a fresh server, so a client can
call `tools/call` without a prior `initialize`. Send
`Accept: application/json, text/event-stream`; responses are SSE `event: message`
frames. There is no authentication and DNS-rebinding protection is off (the service is
public and read-only).

```bash
curl -s -X POST https://unimod.tacular.dev/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"get_by_name","arguments":{"name":"Oxidation"}}}'
```

## Gotchas

- Lookups return `None`, not an exception; only `db[...]` raises (`UnimodKeyError`, a `KeyError`).
- `get_by_id` only accepts numbers or `UNIMOD:N`; `get_by_id("Acetyl")` is `None`. Use
  `db["Acetyl"]` if the key may be either.
- `search` is plain substring matching, not fuzzy or ranked: `search("TMT")` returns 13
  entries in file order.
- `len(db)` and `/api/entries` include the root node `UNIMOD:0`, whose masses and
  composition are `None`. Guard with `e.delta_mono_mass is not None`.
- `specificities` on the dataclass includes hidden sites; the server hides them by default.
- `NeutralLoss.composition` is UNIMOD's raw string. Zero-mass losses have composition
  `"0"` (so `dict_composition == {}` and `proforma_formula == ""`), and one uses `"Water"`,
  which expands to `H2O`.
- `approved` reflects UNIMOD's own flag; many common modifications (e.g. Oxidation) are
  `False`.
- `load(refresh=True)` needs network access to `https://www.unimod.org`; the bundled file
  is a snapshot (see `db.header_lines` for its date).
- The dashboard route `/` only works when `docs/index.html` is present (repo checkout or
  the Vercel bundle), not from an installed wheel.
