psimodpy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
psimodpy/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """psimodpy — Python library for the PSI-MOD protein modification ontology."""
2
+
3
+ from psimodpy._download import download_obo
4
+ from psimodpy.database import PsiModDatabase, load, load_from
5
+ from psimodpy.models import (
6
+ AminoAcid,
7
+ Crosslink,
8
+ PsiModEntry,
9
+ Relationship,
10
+ RelationshipType,
11
+ Source,
12
+ Synonym,
13
+ SynonymType,
14
+ TermSpec,
15
+ )
16
+ from psimodpy.parser import parse_obo
17
+
18
+ __all__ = [
19
+ "AminoAcid",
20
+ "Crosslink",
21
+ "PsiModEntry",
22
+ "Synonym",
23
+ "SynonymType",
24
+ "Relationship",
25
+ "RelationshipType",
26
+ "TermSpec",
27
+ "Source",
28
+ "PsiModDatabase",
29
+ "load",
30
+ "load_from",
31
+ "parse_obo",
32
+ "download_obo",
33
+ ]
psimodpy/_download.py ADDED
@@ -0,0 +1,30 @@
1
+ """Utility for downloading the PSI-MOD OBO file from the HUPO-PSI GitHub repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import urllib.request
6
+ from pathlib import Path
7
+
8
+ _PSIMOD_URL = "https://raw.githubusercontent.com/HUPO-PSI/psi-mod-CV/master/PSI-MOD.obo"
9
+ _CACHE_DIR = Path.home() / ".cache" / "psimodpy"
10
+ _CACHE_FILE = _CACHE_DIR / "PSI-MOD.obo"
11
+
12
+
13
+ def download_obo(dest: Path | str | None = None, *, force: bool = False) -> Path:
14
+ """Fetch the PSI-MOD OBO file and cache it locally.
15
+
16
+ Args:
17
+ dest: Destination path. Defaults to ~/.cache/psimodpy/PSI-MOD.obo.
18
+ force: If True, re-download even if the file already exists.
19
+
20
+ Returns:
21
+ Path to the downloaded file.
22
+ """
23
+ target = Path(dest) if dest is not None else _CACHE_FILE
24
+ if target.exists() and not force:
25
+ return target
26
+ target.parent.mkdir(parents=True, exist_ok=True)
27
+ with urllib.request.urlopen(_PSIMOD_URL) as response: # noqa: S310
28
+ data = response.read()
29
+ target.write_bytes(data)
30
+ return target
psimodpy/_formula.py ADDED
@@ -0,0 +1,92 @@
1
+ """PSI-MOD formula string parsing and Hill-notation conversion.
2
+
3
+ PSI-MOD formula format: element-count pairs separated by spaces, with isotopes
4
+ in parentheses before the element symbol. Elements are in strict alphabetical
5
+ order (not CAS/Hill order). Counts can be zero or negative in difference formulas.
6
+
7
+ Examples:
8
+ "C 3 H 5 N 1 O 1"
9
+ "C 0 H 0 N 0 O 3 P 1"
10
+ "(12)C 8 (13)C 4 H 20 (14)N 1 (15)N 1 O 2"
11
+ "C 0 H -2 N 0 O -1"
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ # Matches either "(12)C" or "C" followed by whitespace and an integer count (may be negative).
19
+ _TOKEN_RE = re.compile(r"(\(\d+\)[A-Za-z]+|[A-Za-z]+)\s+(-?\d+)")
20
+
21
+
22
+ def parse_formula(formula: str) -> dict[str, int]:
23
+ """Parse a PSI-MOD formula string into {element_token: count}.
24
+
25
+ Zero counts are included. Negative counts are preserved.
26
+
27
+ Examples:
28
+ >>> parse_formula("C 3 H 5 N 1 O 1")
29
+ {'C': 3, 'H': 5, 'N': 1, 'O': 1}
30
+ >>> parse_formula("(12)C 8 (13)C 4 H 20")
31
+ {'(12)C': 8, '(13)C': 4, 'H': 20}
32
+ """
33
+ result: dict[str, int] = {}
34
+ for element, count in _TOKEN_RE.findall(formula):
35
+ result[element] = int(count)
36
+ return result
37
+
38
+
39
+ def formula_to_hill(composition: dict[str, int]) -> str:
40
+ """Convert an element-count dict to Hill-notation string.
41
+
42
+ Ordering: C (and isotopic carbons) first, H (and isotopic hydrogens) second,
43
+ then all remaining elements alphabetically. Zero counts are skipped.
44
+ A count of 1 is omitted. Negative counts are written as e.g. "O-1".
45
+
46
+ Examples:
47
+ >>> formula_to_hill({"C": 3, "H": 5, "N": 1, "O": 1})
48
+ 'C3H5NO'
49
+ >>> formula_to_hill({"C": 0, "H": -2, "O": -1})
50
+ 'H-2O-1'
51
+ >>> formula_to_hill({"(12)C": 8, "(13)C": 4, "H": 20})
52
+ '(12)C8(13)C4H20'
53
+ """
54
+ # Separate into carbon group, hydrogen group, and other
55
+ carbon_group: list[tuple[str, int]] = []
56
+ hydrogen_group: list[tuple[str, int]] = []
57
+ other: list[tuple[str, int]] = []
58
+
59
+ for element, count in composition.items():
60
+ if count == 0:
61
+ continue
62
+ # Isotopic carbons: "(12)C", "(13)C", "(14)C" — contain "C" after closing paren
63
+ # Non-isotopic carbon: "C"
64
+ base = re.sub(r"^\(\d+\)", "", element) # strip isotope prefix
65
+ if base == "C":
66
+ carbon_group.append((element, count))
67
+ elif base == "H":
68
+ hydrogen_group.append((element, count))
69
+ else:
70
+ other.append((element, count))
71
+
72
+ # Sort each group: isotopic variants before non-isotopic, then by isotope number
73
+ def _sort_key(ec: tuple[str, int]) -> tuple[int, str]:
74
+ element = ec[0]
75
+ m = re.match(r"^\((\d+)\)", element)
76
+ isotope_num = int(m.group(1)) if m else 0
77
+ return (isotope_num, element)
78
+
79
+ carbon_group.sort(key=_sort_key)
80
+ hydrogen_group.sort(key=_sort_key)
81
+ other.sort(key=lambda ec: ec[0])
82
+
83
+ ordered = carbon_group + hydrogen_group + other
84
+
85
+ parts: list[str] = []
86
+ for element, count in ordered:
87
+ if count == 1:
88
+ parts.append(element)
89
+ else:
90
+ parts.append(f"{element}{count}")
91
+
92
+ return "".join(parts)
psimodpy/database.py ADDED
@@ -0,0 +1,171 @@
1
+ """PSI-MOD database: indexing, lookup, and graph traversal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
8
+
9
+ from psimodpy.models import AminoAcid, Crosslink, PsiModEntry, RelationshipType
10
+
11
+
12
+ class PsiModDatabase:
13
+ """In-memory database of PSI-MOD entries with multiple lookup strategies."""
14
+
15
+ def __init__(self, entries: list[PsiModEntry] | Iterator[PsiModEntry]) -> None:
16
+ self._by_id: dict[int, PsiModEntry] = {}
17
+ self._by_name_lower: dict[str, PsiModEntry] = {}
18
+ self._by_origin: dict[str, list[PsiModEntry]] = {}
19
+ self._children: dict[int, list[int]] = {}
20
+
21
+ for entry in entries:
22
+ self._by_id[entry.id] = entry
23
+ self._by_name_lower[entry.name.lower()] = entry
24
+
25
+ # Index by each amino acid in origin
26
+ if isinstance(entry.origin, AminoAcid):
27
+ self._by_origin.setdefault(str(entry.origin), []).append(entry)
28
+ elif isinstance(entry.origin, Crosslink):
29
+ for site in entry.origin.sites:
30
+ self._by_origin.setdefault(site, []).append(entry)
31
+
32
+ # Build reverse is_a index after all entries are loaded
33
+ for entry in self._by_id.values():
34
+ for parent_id in entry.is_a:
35
+ self._children.setdefault(parent_id, []).append(entry.id)
36
+
37
+ # ------------------------------------------------------------------
38
+ # Lookup by identity
39
+ # ------------------------------------------------------------------
40
+
41
+ def get_by_id(self, id: int | str) -> PsiModEntry | None:
42
+ """Return the entry for the given ID, or None if not found.
43
+
44
+ Accepts an integer (34) or a string in MOD:NNNNN format ("MOD:00034").
45
+ """
46
+ if isinstance(id, str):
47
+ # Accept "MOD:00034" or plain "34"
48
+ if id.upper().startswith("MOD:"):
49
+ id = int(id[4:])
50
+ else:
51
+ id = int(id)
52
+ return self._by_id.get(id)
53
+
54
+ def get_by_name(self, name: str) -> PsiModEntry | None:
55
+ """Return the entry with the given name (case-insensitive), or None."""
56
+ return self._by_name_lower.get(name.lower())
57
+
58
+ def __getitem__(self, id: int | str) -> PsiModEntry:
59
+ """Return entry by ID; raise KeyError if not found."""
60
+ entry = self.get_by_id(id)
61
+ if entry is None:
62
+ raise KeyError(id)
63
+ return entry
64
+
65
+ def __len__(self) -> int:
66
+ return len(self._by_id)
67
+
68
+ def __iter__(self) -> Iterator[PsiModEntry]:
69
+ return iter(self._by_id.values())
70
+
71
+ # ------------------------------------------------------------------
72
+ # Search
73
+ # ------------------------------------------------------------------
74
+
75
+ def search(self, query: str) -> list[PsiModEntry]:
76
+ """Return entries whose name, definition, or any synonym contains query (case-insensitive).
77
+
78
+ An empty query returns all entries.
79
+ """
80
+ q = query.lower()
81
+ if not q:
82
+ return list(self._by_id.values())
83
+ results = []
84
+ for entry in self._by_id.values():
85
+ if (
86
+ q in entry.name.lower()
87
+ or q in entry.definition.lower()
88
+ or any(q in s.value.lower() for s in entry.synonyms)
89
+ ):
90
+ results.append(entry)
91
+ return results
92
+
93
+ def get_by_origin(self, aa: str) -> list[PsiModEntry]:
94
+ """Return all entries whose origin includes the given amino acid code.
95
+
96
+ Crosslink entries with origin "C, C" will appear in get_by_origin("C").
97
+ Entries with origin "X" (any) appear in get_by_origin("X").
98
+ """
99
+ return list(self._by_origin.get(aa, []))
100
+
101
+ # ------------------------------------------------------------------
102
+ # Graph traversal
103
+ # ------------------------------------------------------------------
104
+
105
+ def get_parents(self, entry: PsiModEntry) -> list[PsiModEntry]:
106
+ """Return the direct parent entries (via is_a relationships)."""
107
+ return [self._by_id[pid] for pid in entry.is_a if pid in self._by_id]
108
+
109
+ def get_children(self, entry: PsiModEntry) -> list[PsiModEntry]:
110
+ """Return entries that have this entry as a direct parent."""
111
+ return [self._by_id[cid] for cid in self._children.get(entry.id, []) if cid in self._by_id]
112
+
113
+ def get_related(self, entry: PsiModEntry, rel_type: RelationshipType) -> list[PsiModEntry]:
114
+ """Return entries reachable from entry via the given relationship type."""
115
+ return [
116
+ self._by_id[r.target_id] for r in entry.relationships if r.type == rel_type and r.target_id in self._by_id
117
+ ]
118
+
119
+ # ------------------------------------------------------------------
120
+ # Filtering
121
+ # ------------------------------------------------------------------
122
+
123
+ def filter(
124
+ self,
125
+ *,
126
+ include_obsolete: bool = False,
127
+ slim_only: bool = False,
128
+ ) -> list[PsiModEntry]:
129
+ """Return a filtered list of entries.
130
+
131
+ Args:
132
+ include_obsolete: If False (default), exclude obsolete entries.
133
+ slim_only: If True, return only PSI-MOD-slim subset entries.
134
+ """
135
+ entries = list(self._by_id.values())
136
+ if not include_obsolete:
137
+ entries = [e for e in entries if not e.is_obsolete]
138
+ if slim_only:
139
+ entries = [e for e in entries if e.in_slim_subset]
140
+ return entries
141
+
142
+
143
+ def load(*, include_obsolete: bool = True) -> PsiModDatabase:
144
+ """Load the bundled PSI-MOD database.
145
+
146
+ Args:
147
+ include_obsolete: If True (default), include obsolete entries. Obsolete
148
+ entries carry xref_remap redirects useful for cross-reference resolution.
149
+ Pass False to exclude them.
150
+
151
+ Returns:
152
+ A PsiModDatabase populated from the bundled PSI-MOD.obo file.
153
+ """
154
+ from psimodpy.parser import parse_obo
155
+
156
+ pkg_data = importlib.resources.files("psimodpy.data")
157
+ obo_path = pkg_data.joinpath("PSI-MOD.obo")
158
+ # importlib.resources returns a Traversable; write to a temp path if needed
159
+ with importlib.resources.as_file(obo_path) as path:
160
+ db = parse_obo(Path(path))
161
+
162
+ if not include_obsolete:
163
+ return PsiModDatabase(e for e in db if not e.is_obsolete)
164
+ return db
165
+
166
+
167
+ def load_from(path: Path | str) -> PsiModDatabase:
168
+ """Load PSI-MOD database from a custom OBO file path."""
169
+ from psimodpy.parser import parse_obo
170
+
171
+ return parse_obo(path)
psimodpy/models.py ADDED
@@ -0,0 +1,209 @@
1
+ """Domain model for PSI-MOD protein modification ontology entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+
9
+ class SynonymType(StrEnum):
10
+ """Synonym type labels defined in the PSI-MOD OBO file."""
11
+
12
+ DELTA_MASS_LABEL = "DeltaMass-label"
13
+ OMSSA_LABEL = "OMSSA-label"
14
+ PSI_MOD_LABEL = "PSI-MOD-label"
15
+ PSI_MOD_ALTERNATE = "PSI-MOD-alternate"
16
+ PSI_MS_LABEL = "PSI-MS-label"
17
+ RESID_NAME = "RESID-name"
18
+ RESID_ALTERNATE = "RESID-alternate"
19
+ RESID_SYSTEMATIC = "RESID-systematic"
20
+ RESID_MISNOMER = "RESID-misnomer"
21
+ UNIMOD_DESCRIPTION = "Unimod-description"
22
+ UNIMOD_ALTERNATE = "Unimod-alternate"
23
+ UNIMOD_INTERIM = "Unimod-interim"
24
+ UNIPROT_FEATURE = "UniProt-feature"
25
+
26
+
27
+ class RelationshipType(StrEnum):
28
+ """Complex relationship types used in PSI-MOD beyond simple is_a hierarchy."""
29
+
30
+ DERIVES_FROM = "derives_from"
31
+ HAS_FUNCTIONAL_PARENT = "has_functional_parent"
32
+ CONTAINS = "contains"
33
+ PART_OF = "part_of"
34
+
35
+
36
+ class TermSpec(StrEnum):
37
+ """Positional specificity of a modification on the protein sequence."""
38
+
39
+ NONE = "none"
40
+ N_TERM = "N-term"
41
+ C_TERM = "C-term"
42
+
43
+
44
+ class Source(StrEnum):
45
+ """Origin classification of a modification."""
46
+
47
+ NATURAL = "natural"
48
+ ARTIFACT = "artifact"
49
+ # Four entries in the OBO use "artifactual" as a variant spelling
50
+ ARTIFACTUAL = "artifactual"
51
+ HYPOTHETICAL = "hypothetical"
52
+ NONE = "none"
53
+
54
+
55
+ class AminoAcid(StrEnum):
56
+ """Single-letter amino acid codes used in PSI-MOD Origin xrefs."""
57
+
58
+ ALA = "A"
59
+ ARG = "R"
60
+ ASN = "N"
61
+ ASP = "D"
62
+ CYS = "C"
63
+ GLN = "Q"
64
+ GLU = "E"
65
+ GLY = "G"
66
+ HIS = "H"
67
+ ILE = "I"
68
+ LEU = "L"
69
+ LYS = "K"
70
+ MET = "M"
71
+ PHE = "F"
72
+ PRO = "P"
73
+ SER = "S"
74
+ THR = "T"
75
+ TRP = "W"
76
+ TYR = "Y"
77
+ VAL = "V"
78
+ SEC = "U" # selenocysteine
79
+ PYL = "O" # pyrrolysine
80
+ ANY = "X" # unspecified / any residue
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class Crosslink:
85
+ """Multi-residue or MOD-referenced modification origin.
86
+
87
+ Each site is either an :class:`AminoAcid` value (single letter) or a
88
+ ``"MOD:NNNNN"`` string for modifications-of-modifications.
89
+ """
90
+
91
+ sites: tuple[str, ...]
92
+ """Ordered residue sites, e.g. ``("C", "C")`` for a disulfide."""
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class Synonym:
97
+ """A typed synonym for a PSI-MOD entry."""
98
+
99
+ value: str
100
+ type: SynonymType
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class Relationship:
105
+ """A directed relationship from a PSI-MOD entry to another entry."""
106
+
107
+ type: RelationshipType
108
+ target_id: int
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class PsiModEntry:
113
+ """A single term from the PSI-MOD protein modification ontology."""
114
+
115
+ id: int
116
+ """Numeric part of the MOD:NNNNN identifier."""
117
+
118
+ name: str
119
+ definition: str
120
+ """Quoted definition text; citation block stripped."""
121
+
122
+ synonyms: tuple[Synonym, ...]
123
+ is_a: tuple[int, ...]
124
+ """Parent term IDs (numeric). PSI-MOD entries can have multiple parents."""
125
+
126
+ relationships: tuple[Relationship, ...]
127
+ """derives_from / contains / part_of / has_functional_parent links."""
128
+
129
+ comment: str | None
130
+
131
+ # Mass and formula xrefs
132
+ diff_mono: float | None
133
+ """Monoisotopic mass difference (xref: DiffMono)."""
134
+
135
+ diff_avg: float | None
136
+ """Average mass difference (xref: DiffAvg)."""
137
+
138
+ diff_formula: str | None
139
+ """Elemental difference formula in PSI-MOD format, e.g. 'C 0 H 0 N 0 O 3 P 1'."""
140
+
141
+ mass_mono: float | None
142
+ """Full monoisotopic mass (xref: MassMono)."""
143
+
144
+ mass_avg: float | None
145
+ """Full average mass (xref: MassAvg)."""
146
+
147
+ formula: str | None
148
+ """Full elemental formula in PSI-MOD format. None when OBO value is 'none'."""
149
+
150
+ # Position/context xrefs
151
+ origin: AminoAcid | Crosslink | None
152
+ """Residue origin. Single residues are :class:`AminoAcid`; multi-residue
153
+ crosslinks or MOD-referenced origins are :class:`Crosslink`."""
154
+
155
+ term_spec: TermSpec | None
156
+ """Positional specificity (xref: TermSpec)."""
157
+
158
+ source: Source | None
159
+ """Modification source classification (xref: Source)."""
160
+
161
+ formal_charge: int | None
162
+ """Net formal charge as a signed integer, e.g. ``1``, ``-2`` (xref: FormalCharge)."""
163
+
164
+ # External cross-references
165
+ xref_unimod: str | None
166
+ """Unimod cross-reference, e.g. 'Unimod:21#S' (xref: Unimod)."""
167
+
168
+ xref_uniprot_ptm: str | None
169
+ """UniProt PTM cross-reference, e.g. 'PTM-0369' (xref: uniprot.ptm)."""
170
+
171
+ xref_gnome: str | None
172
+ """GNOme glycan ontology cross-reference, e.g. 'GNO:G29068FM' (xref: GNOme)."""
173
+
174
+ xref_remap: int | None
175
+ """Replacement term ID for obsolete entries (xref: Remap). Stored as numeric ID."""
176
+
177
+ in_slim_subset: bool
178
+ """True if this entry belongs to the PSI-MOD-slim curated subset."""
179
+
180
+ is_obsolete: bool
181
+ """True if this entry is marked obsolete in the OBO file."""
182
+
183
+ @property
184
+ def dict_diff_formula(self) -> dict[str, int] | None:
185
+ """Parse diff_formula into {element: count}. Returns None if no formula."""
186
+ if self.diff_formula is None:
187
+ return None
188
+ from psimodpy._formula import parse_formula
189
+
190
+ return parse_formula(self.diff_formula)
191
+
192
+ @property
193
+ def dict_formula(self) -> dict[str, int] | None:
194
+ """Parse formula into {element: count}. Returns None if no formula."""
195
+ if self.formula is None:
196
+ return None
197
+ from psimodpy._formula import parse_formula
198
+
199
+ return parse_formula(self.formula)
200
+
201
+ @property
202
+ def proforma_diff_formula(self) -> str | None:
203
+ """Hill-notation string for diff_formula, e.g. 'C2H2O'. Returns None if no formula."""
204
+ composition = self.dict_diff_formula
205
+ if composition is None:
206
+ return None
207
+ from psimodpy._formula import formula_to_hill
208
+
209
+ return formula_to_hill(composition)
psimodpy/parser.py ADDED
@@ -0,0 +1,250 @@
1
+ """OBO file parser for PSI-MOD protein modification ontology."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from psimodpy.models import (
10
+ AminoAcid,
11
+ Crosslink,
12
+ PsiModEntry,
13
+ Relationship,
14
+ RelationshipType,
15
+ Source,
16
+ Synonym,
17
+ SynonymType,
18
+ TermSpec,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from psimodpy.database import PsiModDatabase
23
+
24
+ # Compiled regexes (module-level, compiled once)
25
+ _DEF_RE = re.compile(r'^def: "(.+?)"')
26
+ _SYNONYM_RE = re.compile(r'^synonym: "(.+?)"\s+\w+\s+(\S+)\s+\[\]')
27
+ _IS_A_RE = re.compile(r"^is_a:\s+MOD:(\d+)")
28
+ _RELATIONSHIP_RE = re.compile(r"^relationship:\s+(\S+)\s+MOD:(\d+)")
29
+ _XREF_RE = re.compile(r'^xref:\s+([^:]+):\s+"(.+?)"$')
30
+ _XREF_UNIPROT_RE = re.compile(r"^xref:\s+(uniprot\.ptm):(\S+)$")
31
+ _SUBSET_SLIM_RE = re.compile(r"^subset:\s+PSI-MOD-slim")
32
+
33
+
34
+ def _parse_float(value: str | None) -> float | None:
35
+ if value is None or value == "none":
36
+ return None
37
+ return float(value)
38
+
39
+
40
+ def _parse_str_or_none(value: str | None) -> str | None:
41
+ if value is None or value == "none":
42
+ return None
43
+ return value
44
+
45
+
46
+ def _parse_formal_charge(value: str) -> int:
47
+ """Parse '1+' → 1, '2-' → -2."""
48
+ if value.endswith("+"):
49
+ return int(value[:-1])
50
+ if value.endswith("-"):
51
+ return -int(value[:-1])
52
+ return int(value)
53
+
54
+
55
+ def _parse_origin(value: str) -> AminoAcid | Crosslink | None:
56
+ """Parse an Origin xref value into a typed representation.
57
+
58
+ - ``"none"`` → ``None``
59
+ - Single letter code → :class:`AminoAcid`
60
+ - Comma-separated, multi-residue, or MOD reference → :class:`Crosslink`
61
+ """
62
+ if value == "none":
63
+ return None
64
+ parts = [p.strip() for p in value.split(",")]
65
+ if len(parts) == 1:
66
+ try:
67
+ return AminoAcid(parts[0])
68
+ except ValueError:
69
+ # MOD:NNNNN reference or unrecognised code
70
+ return Crosslink(sites=(parts[0],))
71
+ return Crosslink(sites=tuple(parts))
72
+
73
+
74
+ def _parse_mod_id(mod_str: str) -> int:
75
+ """Parse 'MOD:NNNNN' to int."""
76
+ return int(mod_str[4:])
77
+
78
+
79
+ def _build_entry(block: list[str]) -> PsiModEntry | None:
80
+ """Parse a list of OBO lines for one [Term] block into a PsiModEntry."""
81
+ entry_id: int | None = None
82
+ name: str | None = None
83
+ definition: str = ""
84
+ synonyms: list[Synonym] = []
85
+ is_a: list[int] = []
86
+ relationships: list[Relationship] = []
87
+ comment: str | None = None
88
+ in_slim_subset: bool = False
89
+ is_obsolete: bool = False
90
+ xrefs: dict[str, str] = {}
91
+ xref_uniprot_ptm: str | None = None
92
+
93
+ for line in block:
94
+ if line.startswith("id: MOD:"):
95
+ entry_id = int(line[8:].split()[0])
96
+ elif line.startswith("name: "):
97
+ name = line[6:]
98
+ elif line.startswith("def: "):
99
+ m = _DEF_RE.match(line)
100
+ if m:
101
+ definition = m.group(1)
102
+ elif line.startswith("synonym: "):
103
+ m = _SYNONYM_RE.match(line)
104
+ if m:
105
+ value, type_str = m.group(1), m.group(2)
106
+ try:
107
+ synonyms.append(Synonym(value=value, type=SynonymType(type_str)))
108
+ except ValueError:
109
+ pass # unknown synonym type — skip
110
+ elif line.startswith("is_a: "):
111
+ m = _IS_A_RE.match(line)
112
+ if m:
113
+ is_a.append(int(m.group(1)))
114
+ elif line.startswith("relationship: "):
115
+ m = _RELATIONSHIP_RE.match(line)
116
+ if m:
117
+ rel_type_str, target_id_str = m.group(1), m.group(2)
118
+ try:
119
+ relationships.append(
120
+ Relationship(
121
+ type=RelationshipType(rel_type_str),
122
+ target_id=int(target_id_str),
123
+ )
124
+ )
125
+ except ValueError:
126
+ pass # unknown relationship type — skip
127
+ elif _SUBSET_SLIM_RE.match(line):
128
+ in_slim_subset = True
129
+ elif line.startswith("comment: "):
130
+ comment = line[9:]
131
+ elif line == "is_obsolete: true":
132
+ is_obsolete = True
133
+ elif line.startswith("xref: "):
134
+ # Try uniprot.ptm special case first (no space before value)
135
+ m = _XREF_UNIPROT_RE.match(line)
136
+ if m:
137
+ xref_uniprot_ptm = m.group(2)
138
+ continue
139
+ # Standard xref: KEY: "VALUE"
140
+ m = _XREF_RE.match(line)
141
+ if m:
142
+ xrefs[m.group(1)] = m.group(2)
143
+
144
+ if entry_id is None or name is None:
145
+ return None
146
+
147
+ # Parse TermSpec
148
+ term_spec: TermSpec | None = None
149
+ if "TermSpec" in xrefs:
150
+ try:
151
+ term_spec = TermSpec(xrefs["TermSpec"])
152
+ except ValueError:
153
+ pass
154
+
155
+ # Parse Source
156
+ source: Source | None = None
157
+ if "Source" in xrefs:
158
+ try:
159
+ source = Source(xrefs["Source"])
160
+ except ValueError:
161
+ pass
162
+
163
+ # Parse Remap (obsolete redirect)
164
+ xref_remap: int | None = None
165
+ if "Remap" in xrefs:
166
+ try:
167
+ xref_remap = _parse_mod_id(xrefs["Remap"])
168
+ except (ValueError, IndexError):
169
+ pass
170
+
171
+ return PsiModEntry(
172
+ id=entry_id,
173
+ name=name,
174
+ definition=definition,
175
+ synonyms=tuple(synonyms),
176
+ is_a=tuple(is_a),
177
+ relationships=tuple(relationships),
178
+ comment=comment,
179
+ diff_mono=_parse_float(xrefs.get("DiffMono")),
180
+ diff_avg=_parse_float(xrefs.get("DiffAvg")),
181
+ diff_formula=_parse_str_or_none(xrefs.get("DiffFormula")),
182
+ mass_mono=_parse_float(xrefs.get("MassMono")),
183
+ mass_avg=_parse_float(xrefs.get("MassAvg")),
184
+ formula=_parse_str_or_none(xrefs.get("Formula")),
185
+ origin=_parse_origin(xrefs["Origin"]) if "Origin" in xrefs else None,
186
+ term_spec=term_spec,
187
+ source=source,
188
+ formal_charge=_parse_formal_charge(xrefs["FormalCharge"]) if "FormalCharge" in xrefs else None,
189
+ xref_unimod=xrefs.get("Unimod"),
190
+ xref_uniprot_ptm=xref_uniprot_ptm,
191
+ xref_gnome=xrefs.get("GNOme"),
192
+ xref_remap=xref_remap,
193
+ in_slim_subset=in_slim_subset,
194
+ is_obsolete=is_obsolete,
195
+ )
196
+
197
+
198
+ def parse_obo(path: Path | str) -> PsiModDatabase:
199
+ """Parse a PSI-MOD OBO file and return a PsiModDatabase.
200
+
201
+ Args:
202
+ path: Path to the OBO file (str or Path).
203
+
204
+ Returns:
205
+ A PsiModDatabase populated with all parsed entries.
206
+ """
207
+ from psimodpy.database import PsiModDatabase
208
+
209
+ path = Path(path)
210
+ entries: list[PsiModEntry] = []
211
+ current_block: list[str] = []
212
+ in_term = False
213
+
214
+ with path.open(encoding="utf-8") as fh:
215
+ for raw_line in fh:
216
+ line = raw_line.rstrip("\n").rstrip("\r")
217
+
218
+ if line == "[Term]":
219
+ in_term = True
220
+ current_block = []
221
+ continue
222
+
223
+ if line.startswith("[") and line != "[Term]":
224
+ # End of a term block when another stanza starts
225
+ if in_term and current_block:
226
+ entry = _build_entry(current_block)
227
+ if entry is not None:
228
+ entries.append(entry)
229
+ in_term = False
230
+ current_block = []
231
+ continue
232
+
233
+ if in_term:
234
+ if line == "":
235
+ # Blank line ends the current block
236
+ entry = _build_entry(current_block)
237
+ if entry is not None:
238
+ entries.append(entry)
239
+ in_term = False
240
+ current_block = []
241
+ else:
242
+ current_block.append(line)
243
+
244
+ # Handle last block if file doesn't end with blank line
245
+ if in_term and current_block:
246
+ entry = _build_entry(current_block)
247
+ if entry is not None:
248
+ entries.append(entry)
249
+
250
+ return PsiModDatabase(entries)
psimodpy/py.typed ADDED
File without changes
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: psimodpy
3
+ Version: 0.1.0
4
+ Summary: Python library for the PSI-MOD protein modification ontology
5
+ Project-URL: Homepage, https://github.com/tacular-omics/psimodpy
6
+ Project-URL: Repository, https://github.com/tacular-omics/psimodpy
7
+ Project-URL: Issues, https://github.com/tacular-omics/psimodpy/issues
8
+ Project-URL: Changelog, https://github.com/tacular-omics/psimodpy/blob/main/HISTORY.md
9
+ Author-email: Patrick Garrett <pgarrett@scripps.edu>
10
+ Maintainer-email: Patrick Garrett <pgarrett@scripps.edu>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: PSI-MOD,bioinformatics,mass spectrometry,ontology,protein modification,proteomics
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Description-Content-Type: text/markdown
25
+
26
+ # psimodpy
27
+
28
+ [![CI](https://github.com/tacular-omics/psimodpy/actions/workflows/ci.yml/badge.svg)](https://github.com/tacular-omics/psimodpy/actions/workflows/ci.yml)
29
+ [![PyPI version](https://img.shields.io/pypi/v/psimodpy)](https://pypi.org/project/psimodpy/)
30
+ [![Python](https://img.shields.io/pypi/pyversions/psimodpy)](https://pypi.org/project/psimodpy/)
31
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
32
+
33
+ Python library for parsing and querying the [PSI-MOD](https://github.com/HUPO-PSI/psi-mod-CV) protein modification ontology.
34
+
35
+ - Zero dependencies
36
+ - Bundled PSI-MOD data (2,116 entries) — works offline out of the box
37
+ - Typed, immutable data models (`py.typed` / PEP 561)
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install psimodpy
43
+ ```
44
+
45
+ Or with [uv](https://docs.astral.sh/uv/):
46
+
47
+ ```bash
48
+ uv add psimodpy
49
+ ```
50
+
51
+ Requires Python 3.12+. No third-party dependencies.
52
+
53
+ ## Quick Start
54
+
55
+ ```python
56
+ import psimodpy
57
+
58
+ # Load the bundled PSI-MOD database
59
+ db = psimodpy.load()
60
+
61
+ # Lookup by ID
62
+ entry = db[46] # O-phospho-L-serine
63
+ print(entry.name) # "O-phospho-L-serine"
64
+ print(entry.diff_mono) # 79.966331
65
+ print(entry.origin) # AminoAcid.SER
66
+
67
+ # Lookup by name (case-insensitive)
68
+ entry = db.get_by_name("O-phospho-L-serine")
69
+
70
+ # Also accepts MOD:NNNNN format
71
+ entry = db.get_by_id("MOD:00046")
72
+
73
+ # Search across names, definitions, and synonyms
74
+ results = db.search("phospho")
75
+
76
+ # Find all modifications for an amino acid
77
+ ser_mods = db.get_by_origin("S")
78
+
79
+ # Filter entries
80
+ slim = db.filter(slim_only=True, include_obsolete=False)
81
+
82
+ # Formula parsing
83
+ print(entry.dict_diff_formula) # {'C': 0, 'H': 0, 'N': 0, 'O': 3, 'P': 1}
84
+ print(entry.proforma_diff_formula) # 'O3P'
85
+ ```
86
+
87
+ ## API Overview
88
+
89
+ ### Loading
90
+
91
+ | Function | Description |
92
+ |----------|-------------|
93
+ | `psimodpy.load()` | Load the bundled PSI-MOD database. |
94
+ | `psimodpy.load_from(path)` | Load from a custom OBO file. |
95
+ | `psimodpy.parse_obo(path)` | Parse an OBO file into a database. |
96
+ | `psimodpy.download_obo()` | Download the latest OBO file from GitHub. |
97
+
98
+ ### PsiModDatabase
99
+
100
+ | Method | Description |
101
+ |--------|-------------|
102
+ | `db[id]` | Lookup by ID (int or `"MOD:00046"`), raises `KeyError`. |
103
+ | `db.get_by_id(id)` | Lookup by ID, returns `None` if missing. |
104
+ | `db.get_by_name(name)` | Case-insensitive name lookup. |
105
+ | `db.search(query)` | Full-text search in names, definitions, synonyms. |
106
+ | `db.get_by_origin(aa)` | Find entries by amino acid origin. |
107
+ | `db.get_parents(entry)` | Direct parent entries (is_a hierarchy). |
108
+ | `db.get_children(entry)` | Direct child entries. |
109
+ | `db.get_related(entry, type)` | Follow relationship edges (derives_from, contains, etc.). |
110
+ | `db.filter(...)` | Filter by obsolete/slim status. |
111
+
112
+ ### PsiModEntry
113
+
114
+ Each entry provides: `id`, `name`, `definition`, `synonyms`, `is_a`, `relationships`,
115
+ `origin`, `diff_mono`, `diff_avg`, `diff_formula`, `mass_mono`, `mass_avg`, `formula`,
116
+ `term_spec`, `source`, `formal_charge`, `xref_unimod`, `xref_uniprot_ptm`, `xref_gnome`,
117
+ `xref_remap`, `in_slim_subset`, `is_obsolete`.
118
+
119
+ Computed properties: `dict_diff_formula`, `dict_formula`, `proforma_diff_formula`.
120
+
121
+ ### Data Types
122
+
123
+ - `AminoAcid` — single-letter amino acid codes
124
+ - `Crosslink` — multi-residue or MOD-referenced origins
125
+ - `Synonym` / `SynonymType` — typed synonyms
126
+ - `Relationship` / `RelationshipType` — directed relationships
127
+ - `TermSpec` — positional specificity
128
+ - `Source` — modification origin
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ just install # install dependencies with uv
134
+ just lint # ruff check
135
+ just format # ruff format
136
+ just ty # ty type check
137
+ just test # pytest
138
+ just check # lint + type check + test
139
+ ```
140
+
141
+ ## Related Projects
142
+
143
+ | Package | Description |
144
+ |---------|-------------|
145
+ | [unimodpy](https://github.com/tacular-omics/unimodpy) | Parse and query the UNIMOD mass spectrometry modifications database |
146
+ | [uniprotptmpy](https://github.com/tacular-omics/uniprotptmpy) | Parse and query the UniProt PTM controlled vocabulary |
147
+
148
+ ## License
149
+
150
+ [MIT](LICENSE)
@@ -0,0 +1,11 @@
1
+ psimodpy/__init__.py,sha256=5CmPXmjdqJywxhuLjE_BiYJ4odYiSyThvxjcHGadAa0,665
2
+ psimodpy/_download.py,sha256=oESOt1ewYBztzKLPrXfBGO6uE1M-RWDbwC3_g1-x5SU,1030
3
+ psimodpy/_formula.py,sha256=-dY6bCaKgykgK09wrNauqL01UJS_6Fo1ivNjBt11stc,3147
4
+ psimodpy/database.py,sha256=jgJabOB9FohKX53VPF5z2u0LlipjJtQ4AFg_qWh-uVM,6493
5
+ psimodpy/models.py,sha256=re7m5RSfkM-O0zkNMC_ftCCDaGcdN8x_mVEaHMXANSA,5912
6
+ psimodpy/parser.py,sha256=M3ypV0iZ7c1E97D8Bu5VQD8n3dX4c-xznrH3871KpF0,7983
7
+ psimodpy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ psimodpy-0.1.0.dist-info/METADATA,sha256=IXLxtQ4yKpNKOL8QJ4WSH03c47TwYA99Q8Nz97YEatk,5175
9
+ psimodpy-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ psimodpy-0.1.0.dist-info/licenses/LICENSE,sha256=pJQM8iXrAidark8oVv-HfWDWDgOrG5FCXUqXw1jWGfQ,1072
11
+ psimodpy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Garrett
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.