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 +33 -0
- psimodpy/_download.py +30 -0
- psimodpy/_formula.py +92 -0
- psimodpy/database.py +171 -0
- psimodpy/models.py +209 -0
- psimodpy/parser.py +250 -0
- psimodpy/py.typed +0 -0
- psimodpy-0.1.0.dist-info/METADATA +150 -0
- psimodpy-0.1.0.dist-info/RECORD +11 -0
- psimodpy-0.1.0.dist-info/WHEEL +4 -0
- psimodpy-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://github.com/tacular-omics/psimodpy/actions/workflows/ci.yml)
|
|
29
|
+
[](https://pypi.org/project/psimodpy/)
|
|
30
|
+
[](https://pypi.org/project/psimodpy/)
|
|
31
|
+
[](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,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.
|