From ceb1de71d0a63a6af7479c10ab3c353f089f79e4 Mon Sep 17 00:00:00 2001 From: Joshua Klein Date: Sun, 28 Jun 2026 22:23:26 -0400 Subject: [PATCH 1/8] fix: fix numerous logic errors from historical lack of tests, boost test coverage to 87% --- pyteomics/proforma.py | 294 +++++++++++++++++++++++++++-------------- tests/test_proforma.py | 216 +++++++++++++++++++++++++++--- 2 files changed, 392 insertions(+), 118 deletions(-) diff --git a/pyteomics/proforma.py b/pyteomics/proforma.py index 0aefccc3..59013489 100644 --- a/pyteomics/proforma.py +++ b/pyteomics/proforma.py @@ -13,6 +13,7 @@ import itertools import re import warnings + from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, ClassVar, Sequence, Tuple, Type, Union, Generic, TypeVar, NamedTuple, overload, Literal from collections import Counter, deque, namedtuple from functools import partial @@ -32,6 +33,10 @@ np = None +NUMERIC_PAT = re.compile( + r"^(?P\+|-)?(?P\d*(?:\.\d+)?(?:e(?:\+|-)?\d+(?:\.\d+)?)?)$" +) + _WATER_MASS = calculate_mass(formula="H2O") std_aa_mass = std_aa_mass.copy() @@ -42,6 +47,61 @@ T = TypeVar('T') +class Chimeric(Generic[T], Sequence[T]): + ''' + A container for chimeric ProForma sequence parsing. + + Supports the :class:`Sequence` protocol over the generic type and + pattern matching on attributes. + + Attributes + ---------- + peptides : :class:`list` of ``T`` + The parsed peptides + chimeric : :class:`bool` + Whether the parsing process produced a chimeric interpretation + of two or more sequences + ''' + peptides: List[T] + chimeric: bool + + __slots__ = ('peptides', 'chimeric') + + __match_args__ = ["peptides", "chimeric"] + + def __init__(self, peptides: List[T], chimeric: Optional[bool]=None): + self.peptides = peptides + if chimeric is None: + chimeric = len(self.peptides) > 1 + self.chimeric = chimeric + + def __repr__(self) -> str: # pragma: no cover + return "{self.__class__.__name__}({self.peptides}, chimeric={self.chimeric})".format(self=self) + + @overload + def __getitem__(self, i: int) -> T: # pragma: no cover + ... + + @overload + def __getitem__(self, i: slice) -> List[T]: # pragma: no cover + ... + + def __getitem__(self, i: Union[int, slice]) -> Union[T, List[T]]: + return self.peptides[i] + + def __iter__(self): + yield from self.peptides + + def __len__(self): + return len(self.peptides) + + def __bool__(self): + return self.peptides + + def __contains__(self, value): + return value in self.peptides + + class ProFormaError(PyteomicsError): def __init__(self, message, index=None, parser_state=None, **kwargs): super(ProFormaError, self).__init__(PyteomicsError, message, index, parser_state) @@ -186,7 +246,7 @@ def __str__(self): label = '%s%s' % (label, self.group_id) return '%s' % label - def __repr__(self): + def __repr__(self): # pragma: no cover template = "{self.__class__.__name__}({self.value!r}, {self.extra!r}, {self.group_id!r})" return template.format(self=self) @@ -538,6 +598,11 @@ def load_database(self): return Unimod() def _resolve_impl(self, name=None, id=None, **kwargs): + ''' + Flags: + strict - use strict, full name matching of any of the three name fields + exhaustive - if strict, try non-strict lookup + ''' strict = kwargs.get("strict", self.strict) exhaustive = kwargs.get("exhaustive", True) if name is not None: @@ -1259,43 +1324,31 @@ def resolve(self): hit = hit.groupdict() cnt = hit['count'] - tok = hit.get('known_name') + known_name = hit.get('known_name') base_name = hit.get('base_name') - formula = hit.get('charged_formula') + formula_or_mass = hit.get('charged_formula') if cnt: cnt = int(cnt) else: cnt = 1 - if tok is not None: - if tok not in self.valid_monosaccharides: - parts = self.monomer_tokenizer.findall(tok) - t = 0 - for p in parts: - if p not in self.valid_monosaccharides: - break - t += len(p) - if t != len(tok): - raise ValueError("{tok!r} is not a valid monosaccharide name".format(tok=tok)) - else: - for p in parts: - if p not in self.valid_monosaccharides: - raise UnknownMonosaccharideError(p) - m, c, sym = self.valid_monosaccharides[p] - mass += m * cnt - chemcomp += c * cnt - composite[sym] += cnt + if known_name is not None: + m, c, sym = self.valid_monosaccharides[known_name] + mass += m * cnt + chemcomp += c * cnt + composite[sym] += cnt + elif formula_or_mass is not None: + defn = formula_or_mass[1:-1] + is_mass = NUMERIC_PAT.match(defn) + if is_mass: + mass += float(defn) * cnt else: - m, c, sym = self.valid_monosaccharides[tok] - mass += m * cnt - chemcomp += c * cnt - composite[sym] += cnt - elif formula is not None: - inner = FormulaModification(formula[1:-1]).resolve() - mass += inner['mass'] * cnt - chemcomp += inner['composition'] * cnt - composite[formula] += cnt - charge += inner['charge'] * cnt + inner = FormulaModification(defn).resolve() + mass += inner['mass'] * cnt + chemcomp += inner['composition'] * cnt + composite[formula_or_mass] += cnt + if inner['charge'] is not None: + charge += inner['charge'] * cnt elif base_name is not None: parts = self.monomer_tokenizer.findall(base_name) t = 0 @@ -1501,7 +1554,7 @@ def __call__(self): ''' return self.source_cls(self.name) - def __repr__(self): + def __repr__(self): # pragma: no cover template = "{self.__class__.__name__}({self.name!r}, {self.id!r}, {self.provider!r}, {self.source_cls})" return template.format(self=self) @@ -1709,7 +1762,7 @@ def __str__(self): buffer.append(self.aa) return ':'.join(buffer) - def __repr__(self): + def __repr__(self): # pragma: no cover return str(self) def is_valid(self, aa: str, n_term: bool, c_term: bool) -> bool: @@ -1845,7 +1898,7 @@ def __str__(self): targets = ','.join(map(str, self.targets)) return "<[{self.modification_tag}]@{targets}>".format(self=self, targets=targets) - def __repr__(self): + def __repr__(self): # pragma: no cover return "{self.__class__.__name__}({self.modification_tag!r}, {self.targets})".format(self=self) @@ -1879,7 +1932,7 @@ def __ne__(self, other): def __str__(self): return "<{self.isotope}>".format(self=self) - def __repr__(self): + def __repr__(self): # pragma: no cover return "{self.__class__.__name__}({self.isotope})".format(self=self) @@ -1916,7 +1969,7 @@ class TaggedInterval(object): def __init__(self, start, end=None, tags=None, ambiguous=False): self.start = start self.end = end - self.tags = tags + self.tags = tags or [] self.ambiguous = ambiguous def copy(self): @@ -1933,7 +1986,7 @@ def __eq__(self, other): return self.start == other.start and self.end == other.end and self.tags == other.tags def __hash__(self): - return hash((self.start, self.end, tuple(self.tags or []), self.ambiguous)) + return hash((self.start, self.end, tuple(self.tags or ()), self.ambiguous)) def __ne__(self, other): return not self == other @@ -1941,7 +1994,7 @@ def __ne__(self, other): def __str__(self): return f"({'?' if self.ambiguous else ''}{self.start}-{self.end}){self.tags!r}" - def __repr__(self): + def __repr__(self): # pragma: no cover return f"{self.__class__.__name__}({self.start}, {self.end}, {self.tags}, ambiguous={self.ambiguous})" def as_slice(self): @@ -2144,8 +2197,8 @@ def __rdiv__(self, other): return other / int(self) def __eq__(self, other): - if not isinstance(other, ChargeState): - other = ChargeState(other) + if isinstance(other, Integral): + return int(self) == other return self.charge == other.charge and (self.adducts == other.adducts) def __ne__(self, other): @@ -2188,7 +2241,7 @@ def format_local(self, local_charge_to_remove: int=0): def __str__(self): return self.format_local() - def __repr__(self): + def __repr__(self): # pragma: no cover template = "{self.__class__.__name__}({self.charge}, {self.adducts})" return template.format(self=self) @@ -2489,6 +2542,10 @@ def _local_charges( This specifically counts modifications with a registered charge state like charged :class:`FormulaModification` instances. + This triggers modification resolution, which may fail. Errors in + resolution are ignored so that this may be called with unknown + modifications. + Returns ------- local_charges : int @@ -2500,32 +2557,47 @@ def _local_charges( n_charged_modifications = 0 for _, tags in position_list or (): for tag in tags or (): # tags may be None + try: + z_of = getattr(tag, "charge", 0) + if z_of: + n_charged_modifications += 1 + local_charges += z_of + except Exception: # pragma: no cover + pass + for iv in intervals or (): + for tag in iv.tags or (): + try: + z_of = getattr(tag, "charge", 0) + if z_of: + n_charged_modifications += 1 + local_charges += z_of + except Exception: # pragma: no cover + pass + for tag in unlocalized_modifications or (): + try: z_of = getattr(tag, "charge", 0) if z_of: n_charged_modifications += 1 local_charges += z_of - for iv in intervals or (): - for tag in iv.tags or (): + except Exception: # pragma: no cover + pass + for tag in labile_modifications or (): + try: z_of = getattr(tag, "charge", 0) if z_of: n_charged_modifications += 1 local_charges += z_of - for tag in unlocalized_modifications or (): - z_of = getattr(tag, "charge", 0) - if z_of: - n_charged_modifications += 1 - local_charges += z_of - for tag in labile_modifications or (): - z_of = getattr(tag, "charge", 0) - if z_of: - n_charged_modifications += 1 - local_charges += z_of + except Exception: # pragma: no cover + pass for fixed_mod in fixed_modifications or (): - z_of = getattr(fixed_mod.modification_tag, "charge", 0) - if z_of: - for _ in fixed_mod._find_all((aa for aa, _ in position_list), len(position_list)): - local_charges += z_of - n_charged_modifications += 1 + try: + z_of = getattr(fixed_mod.modification_tag, "charge", 0) + if z_of: + for _ in fixed_mod._find_all((aa for aa, _ in position_list), len(position_list)): + local_charges += z_of + n_charged_modifications += 1 + except Exception: # pragma: no cover + pass return local_charges, n_charged_modifications @@ -2635,6 +2707,11 @@ def _reset_component(self): self.labile_modifications = [] self.state = BEFORE + names = self.names + self.names = {} + if 3 in names: + self.names[3] = names[3] + def _chimeric_disabled_error(self): raise ProFormaError( ( @@ -2673,7 +2750,7 @@ def handle_before(self, c: str): self.name_level = 1 else: self.state = INTERVAL_INIT - self.current_interval = TaggedInterval(len(self.positions) + 1) + self._new_interval() elif c == '+': if self.chimeric: raise ProFormaError("Empty peptidoform in chimeric ProForma string", self.index, self.state) @@ -2713,7 +2790,7 @@ def handle_seq(self, c: str): self.index, self.state, ) - self.current_interval = TaggedInterval(len(self.positions) + 1) + self._new_interval() self.state = INTERVAL_INIT elif c == ')': self.pack_sequence_position() @@ -2725,13 +2802,12 @@ def handle_seq(self, c: str): ) else: self.current_interval.end = len(self.positions) + self.intervals.append(self.current_interval) + self.current_interval = None if self.index + 1 < self.n and self.sequence[self.index + 1] == "[": self.index += 1 self.depth = 1 self.state = INTERVAL_TAG - else: - self.intervals.append(self.current_interval) - self.current_interval = None elif c == '-': if self.current_aa: self.pack_sequence_position() @@ -2778,13 +2854,24 @@ def handle_tag(self, c: str): self.state = POST_GLOBAL elif self.state == INTERVAL_TAG: self.state = POST_INTERVAL_TAG - # self.current_interval.tags.append(self.current_tag()) + self._add_tag_to_last_interval() self.depth = 0 else: self.current_tag.append(c) else: self.current_tag.append(c) + def _new_interval(self): + # If the current amino acid isn't initialized, we are starting a fresh interval without + # regular sequence interspersed + self.current_interval = TaggedInterval(len(self.positions) + (1 if self.current_aa else 0)) + + def _add_tag_to_last_interval(self): + i = self.intervals[-1] + if i.tags is None: + i.tags = [] + i.tags.extend(self.current_tag()) + def handle_fixed(self, c: str): if c == '[': self.state = GLOBAL @@ -2817,19 +2904,15 @@ def handle_labile(self, c: str): def handle_post_interval_tag(self, c: str): if c == "[": - self.current_tag.bound() self.state = INTERVAL_TAG elif c in self._VALID_AA: self.current_aa = c - self.current_interval.tags = self.current_tag() - self.intervals.append(self.current_interval) - self.current_interval = None self.state = SEQ elif c == "-": self.state = TAG_AFTER # Unroll next state to immediately fall into a tag parsing state instead of # including a separate post-dash state - if self.index >= self.n or self.sequence[self.index] != "[": + if self.index >= self.n or self.sequence[self.index + 1] != "[": raise ProFormaError("Missing Closing Tag", self.index, self.state) self.index += 1 self.depth = 1 @@ -2837,13 +2920,13 @@ def handle_post_interval_tag(self, c: str): self.state = CHARGE_START self.charge_buffer = NumberParser() elif c == "+": - self.current_interval.tags = self.current_tag() - self.intervals.append(self.current_interval) - self.current_interval = None self._handle_chimeric_separator() + elif c == "(": + self._new_interval() + self.state = INTERVAL_INIT else: raise ProFormaError( - f"Error In State {self.state}, unexpected {self.c} found at index {self.index}", + f"Error In State {self.state}, unexpected {c} found at index {self.index}", self.index, self.state, ) @@ -2862,7 +2945,7 @@ def handle_post_tag_before(self, c: str): self.state = TAG_BEFORE else: raise ProFormaError( - f"Error In State {self.state}, unexpected {self.c} found at index {self.index}", + f"Error In State {self.state}, unexpected {c} found at index {self.index}", self.index, self.state, ) @@ -3147,19 +3230,18 @@ def _finish_component(self) -> ProFormaParseResult: "isotopes": self.isotopes, "group_ids": sorted(set(self.current_tag.group_ids) | self.shared_group_ids), "charge_state": charge_state, - "names": self.names + "names": self.names.copy() } def _apply_shared_properties(self): for _positions, props in self.components: props["fixed_modifications"] = list(self.fixed_modifications) props["isotopes"] = list(self.isotopes) - props["names"] = self.names.copy() props["group_ids"] = sorted(set(props["group_ids"]) | self.shared_group_ids) def finish( self, - ) -> Union[ProFormaParseResult, List[ProFormaParseResult]]: + ) -> Union[ProFormaParseResult, Chimeric[ProFormaParseResult]]: """ Post-process the parser's accumulated parsed token data and return the parsed sequence and metadata. @@ -3176,7 +3258,7 @@ def finish( if self.chimeric: self.components.append(component) self._apply_shared_properties() - return self.components + return Chimeric(self.components, len(self.components) > 1) return component def _local_charges(self) -> Tuple[int, int]: @@ -3213,16 +3295,20 @@ def empty_properties(): @overload -def parse(sequence: str, *, chimeric: Literal[False] = False, **kwargs) -> ProFormaParseResult: +def parse(sequence: str, *, chimeric: Literal[False] = False, **kwargs) -> ProFormaParseResult: # pragma: no cover ... @overload -def parse(sequence: str, *, chimeric: Literal[True], **kwargs) -> List[ProFormaParseResult]: +def parse( + sequence: str, *, chimeric: Literal[True], **kwargs +) -> Chimeric[ProFormaParseResult]: # pragma: no cover ... -def parse(sequence: str, *, chimeric: bool = False, **kwargs) -> Union[ProFormaParseResult, List[ProFormaParseResult]]: +def parse( + sequence: str, *, chimeric: bool = False, **kwargs +) -> Union[ProFormaParseResult, Chimeric[ProFormaParseResult]]: """ Tokenize a ProForma sequence into a sequence of amino acid+tag positions, and a mapping of sequence-spanning modifiers. @@ -3263,7 +3349,7 @@ def parse(sequence: str, *, chimeric: bool = False, **kwargs) -> Union[ProFormaP return parser.parse() -def _parse(sequence): +def _parse(sequence): # pragma: no cover '''Tokenize a ProForma sequence into a sequence of amino acid+tag positions, and a mapping of sequence-spanning modifiers. @@ -3726,7 +3812,7 @@ def __get__(self, obj, cls) -> T: def __set__(self, obj, value: T): obj.properties[self.name] = value - def __repr__(self): + def __repr__(self): # pragma: no cover template = "{self.__class__.__name__}({self.name!r})" return template.format(self=self) @@ -3799,7 +3885,7 @@ def __init__(self, sequence, properties): def __str__(self): return to_proforma(self.sequence, **self.properties) - def __repr__(self): + def __repr__(self): # pragma: no cover return "{self.__class__.__name__}({self.sequence}, {self.properties})".format(self=self) def __len__(self): @@ -3834,11 +3920,15 @@ def __getitem__(self, i: Union[int, slice]): # We sliced the sequence, but only the localization markers were captured, # not the actual modification definition. Update the first occurrence of the # localization marker with a group id marked modification tag. - if all(not isinstance(v, LocalizationMarker) for _, v in tag_hits): - i = tag_hits[0] + if all(isinstance(v, (LocalizationMarker, PositionLabelTag)) for _, v in tag_hits): + i, _tag = tag_hits[0] + if not isinstance(i, int): + continue val: TagBase for val in self.find_tags_by_id(group_id, include_position=False): - if not isinstance(val, LocalizationMarker): + if not isinstance( + val, (LocalizationMarker, PositionLabelTag) + ): val = val.copy() for j, tag in enumerate(subseq[i][1]): if tag.group_id == group_id: @@ -3931,17 +4021,19 @@ def charge_state(self, value: Union[int, ChargeState, None]): @classmethod @overload - def parse(cls, string, *, chimeric: Literal[False] = False, **kwargs) -> "ProForma": + def parse(cls, string, *, chimeric: Literal[False] = False, **kwargs) -> "ProForma": # pragma: no cover ... @classmethod @overload - def parse(cls, string, *, chimeric: Literal[True], **kwargs) -> List["ProForma"]: + def parse( + cls, string, *, chimeric: Literal[True], **kwargs + ) -> Chimeric["ProForma"]: # pragma: no cover ... @classmethod def parse(cls, string, *, chimeric: bool = False, **kwargs): - '''Parse a ProForma string. + """Parse a ProForma string. Parameters ---------- @@ -3954,11 +4046,11 @@ def parse(cls, string, *, chimeric: bool = False, **kwargs): Forwarded to :class:`Parser` Returns ------- - ProForma or list[ProForma] - ''' + ProForma or Chimeric[ProForma] + """ result = parse(string, chimeric=chimeric, **kwargs) if chimeric: - return [cls(*component) for component in result] + return Chimeric([cls(*component) for component in result], result.chimeric) return cls(*result) @property @@ -4008,7 +4100,7 @@ def mass(self) -> float: mass += calculate_mass(formula="OH") for iv in self.properties['intervals']: - for tag in iv.tags: + for tag in iv.tags or (): if tag.has_mass(): mass += tag.mass return mass @@ -4130,7 +4222,7 @@ def fragments(self, ion_shift, charge=1, reverse=None, include_labile=True, incl intervals = sorted(intervals, key=lambda x: x.start, reverse=reverse) intervals = deque(intervals) - if not include_labile: + if include_labile: for mod in self.properties['labile_modifications']: mass += mod.mass @@ -4186,7 +4278,7 @@ def fragments(self, ion_shift, charge=1, reverse=None, include_labile=True, incl while intervals and intervals[0].contains(i): iv = intervals.popleft() - for tag in iv.tags: + for tag in iv.tags or (): if tag.has_mass(): mass += tag.mass @@ -4229,8 +4321,9 @@ def find_tags_by_id(self, tag_id, include_position=True): else: matches.append(tag) for iv in self.properties['intervals']: - if iv.tag.group_id == tag_id: - matches.append((iv, iv.tag) if include_position else iv.tag) + for tag in iv.tags or (): + if tag.group_id == tag_id: + matches.append((iv, tag) if include_position else tag) for ulmod in self.properties['unlocalized_modifications']: if ulmod.group_id == tag_id: matches.append(('unlocalized_modifications', ulmod) @@ -4420,7 +4513,7 @@ def create(self) -> TagBase: tag.extra.clear() return tag - def __repr__(self): + def __repr__(self): # pragma: no cover return f"{self.__class__.__name__}({self.rule}, {self.region}, {self.colocal_known}, {self.colocal_unknown})" @staticmethod @@ -4805,7 +4898,8 @@ def _extract_rules(self) -> None: if block: rules.extend(block) iv = iv.copy() - iv.tags = [t for t in iv.tags if not t.is_modification()] + if iv.tags: + iv.tags = [t for t in iv.tags if not t.is_modification()] remains.append(iv) else: remains.append(iv) diff --git a/tests/test_proforma.py b/tests/test_proforma.py index 1cbe78e7..536d26dd 100644 --- a/tests/test_proforma.py +++ b/tests/test_proforma.py @@ -105,29 +105,93 @@ def test_fragments(self): i = ProForma.parse("PEPTIDE") masses = i.fragments('b', 1) - expected = [98.06004032, 227.1026334, 324.15539725, 425.20307572, + b_expected = [98.06004032, 227.1026334, 324.15539725, 425.20307572, 538.2871397, 653.31408272] - for o, e in zip(masses, expected): + for o, e in zip(masses, b_expected): self.assertAlmostEqual(o, e, 3) masses = i.fragments('y', 1) - expected = [148.06043424, 263.08737726, 376.17144124, 477.21911971, + y_expected = [148.06043424, 263.08737726, 376.17144124, 477.21911971, 574.27188356, 703.31447664] - for o, e in zip(masses, expected): + for o, e in zip(masses, y_expected): self.assertAlmostEqual(o, e, 3) + # Test include labile + i = ProForma.parse("{+204}PEPTIDE") + for o, e in zip(i.fragments('y', include_labile=True), masses): + self.assertAlmostEqual(o, e + 204, 3) + for o, e in zip(i.fragments("y", include_labile=False), masses): + self.assertAlmostEqual(o, e, 3) + + # Test include unlocalized + i = ProForma.parse("[+204]?PEPTIDE") + for o, e in zip(i.fragments("y", include_unlocalized=True), masses): + self.assertAlmostEqual(o, e + 204, 3) + for o, e in zip(i.fragments("y", include_unlocalized=False), masses): + self.assertAlmostEqual(o, e, 3) + + # Test C-terminal modification + i = ProForma.parse("PEPTIDE-[+204]") + for o, e in zip(i.fragments("y"), y_expected): + self.assertAlmostEqual(o, e + 204, 3) + for o, e in zip(i.fragments("b"), b_expected): + self.assertAlmostEqual(o, e, 3) + + # Test N-terminal modification + i = ProForma.parse("[+204]-PEPTIDE") + for o, e in zip(i.fragments("b"), b_expected): + self.assertAlmostEqual(o, e + 204, 3) + for o, e in zip(i.fragments("y"), y_expected): + self.assertAlmostEqual(o, e, 3) + + # Test fixed modifications + i = ProForma.parse("<[+204]@P>PEPTIDE") + for o, e in zip(i.fragments("b"), b_expected): + d = 204 + if (o - e) > 205: + d += 204 + self.assertAlmostEqual(o, e + d, 3) + + # Test regular modifications + i = ProForma.parse("P[+204]EP[+204]TIDE") + for o, e in zip(i.fragments("b"), b_expected): + d = 204 + if (o - e) > 205: + d += 204 + self.assertAlmostEqual(o, e + d, 3) + + i = ProForma.parse("(PEP)[+204](TIDE)[+204]") + for o, e in zip(i.fragments("b"), b_expected): + d = 204 + if (o - e) > 205: + d += 204 + self.assertAlmostEqual(o, e + d, 3) + self.assertEqual(d, 408) + def test_slice(self): - i = ProForma.parse('[U:1]-MPEP-[UNIMOD:2]/2') - assert i.n_term is not None - assert i.c_term is not None + seq = ProForma.parse('[U:1]-MPEP-[UNIMOD:2]/2') + assert seq.n_term is not None + assert seq.c_term is not None + + assert seq[:1].n_term is not None + assert seq[:1].c_term is None + + assert seq[1:].n_term is None + assert seq[1:].c_term is not None + + seq = ProForma.parse("MPE[#1]PET[+204#1]ID[#1]E") + sub = seq[:2] + assert not sub.find_tags_by_id('1') + + sub = seq[:3] + of = sub.find_tags_by_id('1') + assert of + assert of[0][0] == 2 + self.assertAlmostEqual(of[0][1].mass, 204.0, 3) - assert i[:1].n_term is not None - assert i[:1].c_term is None - assert i[1:].n_term is None - assert i[1:].c_term is not None def test_charge_adducts(self): sequences = ['PEPTIDE/1[+2Na+,-H+]', 'PEPTIDE/-1[+e-]', 'PEPTIDE/1[+2H+,+e-]'] @@ -180,11 +244,20 @@ def test_chimeric_shared_fixed_modifications(self): self.assertEqual(forms[1].composition(), Composition(sequence='camCcamC', aa_comp=aa_comp)) def test_chimeric_shared_names_and_isotopes(self): - parsed = parse('(>sample)<13C>AC+CC', chimeric=True) - self.assertEqual(parsed[0][1]['names'], {1: 'sample'}) - self.assertEqual(parsed[1][1]['names'], {1: 'sample'}) + parsed = parse('(>>>pair)(>sample)<13C>AC+CC', chimeric=True) + self.assertEqual(parsed[0][1]['names'], {1: 'sample', 3: 'pair'}) + self.assertEqual(parsed[1][1]['names'], {3: 'pair'}) self.assertEqual(parsed[0][1]['isotopes'], [StableIsotope('13C')]) self.assertEqual(parsed[1][1]['isotopes'], [StableIsotope('13C')]) + parsed = ProForma.parse("(>>>pair)(>sample)<13C>AC+CC", chimeric=True) + self.assertEqual(str(parsed[0]), "(>>>pair)<13C>(>sample)AC") + self.assertEqual(str(parsed[1]), "(>>>pair)<13C>CC") + + def test_empty_name(self): + for i in range(1, 4): + p = ProForma.parse("({})PEPTIDE".format(">" * i)) + assert p.names[i] == '' + assert str(p) == "({})PEPTIDE".format(">" * i) def test_chimeric_adduct_separator(self): forms = ProForma.parse('PEPTIDE/[Na:z+1,H:z+1]+ELVIS/2', chimeric=True) @@ -317,8 +390,8 @@ def test_from_spec(self): "ELVIS[Phospho|INFO:newly discovered|INFO:Created by software Tool1]K", "<13C>ATPEILTVNSIGQLK", "EMEVEESPEK/2", - # "EMEVEESPEK+ELVISLIVER", - # "EMEVEESPEK/2+ELVISLIVER/3", + "EMEVEESPEK+ELVISLIVER", + "EMEVEESPEK/2+ELVISLIVER/3", # "A[X:DSS#XL1]//B[#XL1]+C[X:DSS#XL1]//D[#XL1]", "<[Carbamidomethyl]@C>ATPEILTCNSIGCLK", "<[Oxidation]@C,M>MTPEILTCNSIGCLK", @@ -413,7 +486,7 @@ def test_from_spec(self): "EMEVEESPEK/2", "EM[U:Oxidation]EVEES[U:Phospho]PEK/3", "[U:iTRAQ4plex]-EM[U:Oxidation]EVNES[U:Phospho]PEK[U:iTRAQ4plex]-[U:Methyl]/3", - # "EMEVEESPEK/2+ELVISLIVER/3", + "EMEVEESPEK/2+ELVISLIVER/3", "AA(?AA)", "AA(?AA)AA", "[dehydro]^3?[gln->pyro-glu]-QSC", @@ -450,7 +523,7 @@ def test_from_spec(self): ] for seq in positive: with self.subTest(seq=seq): - parsed = ProForma.parse(seq) + parsed = ProForma.parse(seq, chimeric='+' in seq) assert parsed is not None def test_nonstandard_amino_acid(self): @@ -481,12 +554,106 @@ def test_charged_tags(self): self.assertWarns(UserWarning, lambda: mixed.mz(charge=2)) + template = "<[Formula:Zn:z+2]@E>SEQUENCE" + seq = ProForma.parse(template) + assert seq.charge_state == 6 + def test_mass(self): sequences = ["PEPTIDE", "PEPTIDE/2"] for seq in sequences: with self.subTest(seq=seq): parsed = ProForma.parse(seq) self.assertAlmostEqual(parsed.mass, mass.fast_mass(sequences[0])) + parsed = ProForma.parse("<[+204]@P>PEPTIDE") + self.assertAlmostEqual(parsed.mass, mass.fast_mass(sequences[0]) + 408) + + seq = "<[+{}]@X>XEXTIDE".format(mass.std_aa_mass['P']) + with self.subTest(seq): + parsed = ProForma.parse(seq) + self.assertAlmostEqual(parsed.mass, mass.fast_mass(sequences[0])) + + def test_terminal_mass(self): + parsed = ProForma.parse("[+22]-PEPTIDE-[+26]") + ref = ProForma.parse("PEPTIDE") + self.assertAlmostEqual(parsed.mass, mass.fast_mass('PEPTIDE') + 48) + for a, b in zip(parsed.fragments('b'), ref.fragments('b')): + self.assertAlmostEqual(a, b + 22) + for a, b in zip(parsed.fragments("y"), ref.fragments("y")): + self.assertAlmostEqual(a, b + 26) + + def test_charge_settable(self): + t = "PEPTIDE" + + parsed = ProForma.parse(t) + self.assertAlmostEqual(parsed.mass, mass.fast_mass(t)) + self.assertEqual(str(parsed), t) + + parsed.charge_state = 1 + self.assertAlmostEqual(parsed.mass, mass.fast_mass(t)) + self.assertAlmostEqual(parsed.mz(), mass.fast_mass(t, charge=1)) + self.assertEqual(str(parsed), t + '/1') + + parsed.charge_state = 2 + self.assertAlmostEqual(parsed.mass, mass.fast_mass(t)) + self.assertAlmostEqual(parsed.mz(), mass.fast_mass(t, charge=2)) + self.assertEqual(str(parsed), t + '/2') + + parsed.charge_state = None + self.assertAlmostEqual(parsed.mass, mass.fast_mass(t)) + self.assertEqual(str(parsed), t) + + parsed.charge_state = ChargeState(2) + self.assertAlmostEqual(parsed.mass, mass.fast_mass(t)) + self.assertAlmostEqual(parsed.mz(), mass.fast_mass(t, charge=2)) + self.assertEqual(str(parsed), t + "/2") + + def test_position_labels(self): + t = "PETIEM[Dioxidation#1][Oxidation#2]REM[#1][#2]REM[#2]RM[#1]PEPTIDE" + seq = ProForma.parse(t) + tags = seq.find_tags_by_id('2') + self.assertEqual(len(tags), 3) + self.assertEqual(str(seq), t) + t = "[Dioxidation#1]?PETIE(MREMREMRM)[#1][Oxidation#2]PEPTIDE" + seq = ProForma.parse(t) + tags = seq.find_tags_by_id('2') + # Matches the interval + self.assertEqual(len(tags), 1) + tags = seq.find_tags_by_id("1") + # Matches the unlocalized tag and the interval + self.assertEqual(len(tags), 2) + + + def test_glycan_composition_resolution(self): + seqs = [ + ("NEEYN[Glycan:Hex5HexNAc5NeuAc1]K", 2912.0957972884694), + ("NEEYN[Glycan:{C6H12N4O2S1}5HexNAc4NeuAc1]K", 2919.0929805042692), + ("NEEYN[Glycan:{+204.068}5HexNAc4NeuAc1]K", 2919.0924972884695), + ("NEEYN[Glycan:HexHexHex3HexNAc5NeuAc1]K", 2912.0957972884694), + ] + for seq, mass_of in seqs: + parsed = ProForma.parse(seq) + self.assertAlmostEqual(parsed.mass, mass_of, 2) + + self.assertRaises( + ValueError, lambda: ProForma.parse("NEEYN[Glycan:HexHexHex3HexNAc5NeuAc1Kxo]K").mass + ) + + def test_post_interval_tag(self): + seqs = [ + "PEPTI(DE)[INFO:foo]", + "PEPTI(DE)[INFO:foo]-[INFO:bar]", + "PEPTI(DE)[INFO:foo]/2", + "PEPTI(DE)[INFO:foo]+PEPTI(DE)[INFO:foo]", + ] + for i, s in enumerate(seqs): + with self.subTest("seq={s}".format(s=s)): + [p, *rest] = ProForma.parse(s, chimeric=True) + if i == 1: + assert p.c_term + elif i == 2: + assert p.charge_state == 2 + elif i == 3: + assert rest def test_mz(self): self.assertAlmostEqual(ProForma.parse("PEPTIDE/2").mz(), mass.fast_mass("PEPTIDE", charge=2), 5) @@ -509,6 +676,13 @@ def test_mz(self): # its neutral salt cousin by as many protons as it has positive charges. self.assertAlmostEqual(seq.mass, salted.mass + proton * 2, 4) + self.assertRaises(ProFormaError, lambda: ProForma.parse("PEPTIDE").mz()) + + def test_parse_unresolved(self): + p = ProForma.parse("PEPT[Cmm]IDE") + assert p + assert str(p) == "PEPT[Cmm]IDE" + def test_to_proforma_with_incomplete_signature(self): seq = to_proforma([("I", []), ("P", [])], charge_state=ChargeState(2)) assert seq == "IP/2" @@ -598,6 +772,12 @@ def test_mass_modifications_copiable(self): modcopy = mod.copy() self.assertEqual(mod, modcopy) + def test_resolve_unimod_by_alias(self): + mod = UnimodModification("U:Acetylation").resolve() + self.assertEqual(mod['name'], 'Acetyl') + mod = GenericModification("Acetylation").resolve() + self.assertEqual(mod["name"], "Acetyl") + class ModificationPicklingTest(unittest.TestCase): def test_pickle(self): From b54a2911bdbcfb8668b49c39ed5573824a5bfcfd Mon Sep 17 00:00:00 2001 From: Joshua Klein Date: Mon, 29 Jun 2026 20:23:59 -0400 Subject: [PATCH 2/8] fix: make TaggedInterval orderable --- pyteomics/proforma.py | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/pyteomics/proforma.py b/pyteomics/proforma.py index 59013489..01fa6feb 100644 --- a/pyteomics/proforma.py +++ b/pyteomics/proforma.py @@ -16,7 +16,7 @@ from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, ClassVar, Sequence, Tuple, Type, Union, Generic, TypeVar, NamedTuple, overload, Literal from collections import Counter, deque, namedtuple -from functools import partial +from functools import partial, total_ordering from itertools import chain from array import array as _array from enum import Enum @@ -1943,7 +1943,7 @@ class IntersectionEnum(Enum): start_overlap = 3 end_overlap = 4 - +@total_ordering class TaggedInterval(object): '''Define a fixed interval over the associated sequence which contains the localization of the associated tag or denotes a region of general sequence order ambiguity. @@ -1980,6 +1980,37 @@ def copy(self): self.ambiguous ) + def __lt__(self, other: "TaggedInterval"): + if other is None: + return False + if self.start is None: + if other.start is None: + if self.end is None: + if other.end is None: + return False + else: + return True + elif other.end is None: + return False + else: + return self.end < other.end + else: + return True + else: + if other.start is None: + return False + elif self.start == other.start: + if self.end is None: + if other.end is None: + return False + else: + return True + elif other.end is None: + return False + else: + return self.end < other.end + return self.start < other.start + def __eq__(self, other): if other is None: return False @@ -3767,7 +3798,7 @@ def to_proforma( else: primary.append(str(aa) + "".join(["[{0!s}]".format(t) for t in tags])) if intervals: - for iv in sorted(intervals, key=lambda x: x.start): + for iv in sorted(intervals): if iv.ambiguous: primary[iv.start] = "(?" + primary[iv.start] else: @@ -4219,7 +4250,7 @@ def fragments(self, ion_shift, charge=1, reverse=None, include_labile=True, incl intervals = self.intervals if intervals: - intervals = sorted(intervals, key=lambda x: x.start, reverse=reverse) + intervals = sorted(intervals, reverse=bool(reverse)) intervals = deque(intervals) if include_labile: From 17f6c186128ee512bebec4312d356b019c947de1 Mon Sep 17 00:00:00 2001 From: Joshua Klein Date: Mon, 29 Jun 2026 20:44:14 -0400 Subject: [PATCH 3/8] puzzled about Nones --- pyteomics/proforma.py | 3 +++ tests/test_proforma.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/pyteomics/proforma.py b/pyteomics/proforma.py index 01fa6feb..c016b6a6 100644 --- a/pyteomics/proforma.py +++ b/pyteomics/proforma.py @@ -3251,6 +3251,9 @@ def _finish_component(self) -> ProFormaParseResult: self.index, self.state, ) + for v in self.intervals: + assert v.start is not None + assert v.end is not None return self.positions, { "n_term": self.n_term, "c_term": self.c_term, diff --git a/tests/test_proforma.py b/tests/test_proforma.py index 536d26dd..7e126520 100644 --- a/tests/test_proforma.py +++ b/tests/test_proforma.py @@ -169,6 +169,9 @@ def test_fragments(self): d += 204 self.assertAlmostEqual(o, e + d, 3) self.assertEqual(d, 408) + for v in i.intervals: + assert v.start is not None + assert v.end is not None def test_slice(self): seq = ProForma.parse('[U:1]-MPEP-[UNIMOD:2]/2') From c24dd11cd0dc15606da3f78208537b2d2f2b7fa4 Mon Sep 17 00:00:00 2001 From: Joshua Klein Date: Mon, 29 Jun 2026 20:57:46 -0400 Subject: [PATCH 4/8] remove debugging asserts. Leave the test testing this in place --- pyteomics/proforma.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyteomics/proforma.py b/pyteomics/proforma.py index c016b6a6..01fa6feb 100644 --- a/pyteomics/proforma.py +++ b/pyteomics/proforma.py @@ -3251,9 +3251,6 @@ def _finish_component(self) -> ProFormaParseResult: self.index, self.state, ) - for v in self.intervals: - assert v.start is not None - assert v.end is not None return self.positions, { "n_term": self.n_term, "c_term": self.c_term, From 7d5bc3e17b4a141b57a47f1304164cb423755f30 Mon Sep 17 00:00:00 2001 From: Joshua Klein Date: Wed, 1 Jul 2026 07:39:28 -0400 Subject: [PATCH 5/8] syntax check, fix fast path --- pyteomics/proforma.py | 5 +++-- tests/test_proforma.py | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/pyteomics/proforma.py b/pyteomics/proforma.py index 01fa6feb..c56faa4b 100644 --- a/pyteomics/proforma.py +++ b/pyteomics/proforma.py @@ -67,7 +67,7 @@ class Chimeric(Generic[T], Sequence[T]): __slots__ = ('peptides', 'chimeric') - __match_args__ = ["peptides", "chimeric"] + __match_args__ = ("peptides", "chimeric") def __init__(self, peptides: List[T], chimeric: Optional[bool]=None): self.peptides = peptides @@ -3374,7 +3374,7 @@ def parse( Parser.empty_properties() ) if chimeric: - return [result] + return Chimeric([result], chimeric=False) return result parser = Parser(sequence, chimeric=chimeric, **kwargs) return parser.parse() @@ -4081,6 +4081,7 @@ def parse(cls, string, *, chimeric: bool = False, **kwargs): """ result = parse(string, chimeric=chimeric, **kwargs) if chimeric: + return Chimeric([cls(*component) for component in result], result.chimeric) return cls(*result) diff --git a/tests/test_proforma.py b/tests/test_proforma.py index 7e126520..0799f1ea 100644 --- a/tests/test_proforma.py +++ b/tests/test_proforma.py @@ -1,3 +1,4 @@ +import sys import os from os import path import unittest @@ -7,7 +8,7 @@ pyteomics.__path__ = [path.abspath( path.join(path.dirname(__file__), path.pardir, 'pyteomics'))] from pyteomics.proforma import ( - PSIModModification, ProForma, TaggedInterval, parse, MassModification, ProFormaError, TagTypeEnum, + Chimeric, PSIModModification, ProForma, TaggedInterval, parse, MassModification, ProFormaError, TagTypeEnum, ModificationRule, StableIsotope, GenericModification, Composition, to_proforma, ModificationMassNotFoundError, UnimodModification, ModificationTarget, AdductParser, ChargeState, proteoforms, _coerce_string_to_modification, @@ -222,6 +223,41 @@ def test_chimeric_parse(self): self.assertEqual(forms[0].charge_state.charge, 2) self.assertEqual(forms[1].charge_state.charge, 3) + seq = ProForma.parse("PEPTIDE", chimeric=True) + assert not seq.chimeric + assert len(seq) == 1 + if sys.version_info.major >= 3 and sys.version_info.minor >= 10: + match ProForma.parse("PEPTIDE+EDITPEP", chimeric=True): + case Chimeric(peptides, chimeric=True): + assert len(peptides) > 1 + case Chimeric((_peptide, ), chimeric=False): + raise ValueError("Failed to match") + case ProForma() as _peptide: + raise ValueError("Failed to match") + case _: + raise ValueError("Failed to match") + + match ProForma.parse("PEPTIDE", chimeric=True): + case Chimeric(peptides, chimeric=True): + raise ValueError("Failed to match") + case Chimeric((_peptide,), chimeric=False): + self.assertEqual(_peptide, ProForma.parse("PEPTIDE")) + case ProForma() as _peptide: + raise ValueError("Failed to match") + case _: + raise ValueError("Failed to match") + + match ProForma.parse("PEPTIDE", chimeric=False): + case Chimeric(peptides, chimeric=True): + raise ValueError("Failed to match") + case Chimeric((_peptide,), chimeric=False): + raise ValueError("Failed to match") + case ProForma() as _peptide: + self.assertEqual(_peptide, ProForma.parse("PEPTIDE")) + case _: + raise ValueError("Failed to match") + + def test_chimeric_single_component_opt_in(self): forms = ProForma.parse('PEPTIDE/+2', chimeric=True) self.assertEqual(len(forms), 1) From f4194dade28d648876a1338b96043e111def5606 Mon Sep 17 00:00:00 2001 From: Lev Levitsky Date: Wed, 8 Jul 2026 18:14:22 +0200 Subject: [PATCH 6/8] Suggested fix for fixed modifications in proteoforms --- pyteomics/proforma.py | 2 +- tests/test_proforma.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pyteomics/proforma.py b/pyteomics/proforma.py index c56faa4b..449708fb 100644 --- a/pyteomics/proforma.py +++ b/pyteomics/proforma.py @@ -4909,7 +4909,7 @@ def __init__(self, base_proteoform: ProForma, include_unmodified: bool=False, in def _apply_fixed_modifications(self): for c in self.template.fixed_modifications: - rule = GeneratorModificationRuleDirective(c) + rule = GeneratorModificationRuleDirective.from_unlocalized_rule(c) positions = rule.find_positions(self.template) for i in positions: (aa, tags) = self.template[i] diff --git a/tests/test_proforma.py b/tests/test_proforma.py index 0799f1ea..4c9377e6 100644 --- a/tests/test_proforma.py +++ b/tests/test_proforma.py @@ -216,6 +216,7 @@ def test_chimeric_parse(self): self.assertEqual(len(parsed), 2) self.assertEqual(parsed[0][1]['charge_state'].charge, 2) self.assertEqual(parsed[1][1]['charge_state'].charge, 3) + self.assertTrue(parsed.chimeric) forms = ProForma.parse(seq, chimeric=True) self.assertEqual(len(forms), 2) @@ -971,6 +972,22 @@ def test_from_str(self): forms = list(proteoforms(pf, variable_modifications=variable_mods, expand_rules=True)) self.assertEqual(len(forms), 2 ** nsites) # all combinations of phospho / no phospho on each S or T + def test_fixed_mods_from_str(self): + seq = "EMECTSESPEK" + fixed_mods = ["Carbamidomethyl|Position:C"] + pf = ProForma.parse(seq) + forms = list(proteoforms(pf, fixed_modifications=fixed_mods)) + self.assertEqual(len(forms), 1) + self.assertTrue(isinstance(forms[0].sequence[3][1][0], GenericModification)) + + def test_fixed_mods_from_dict(self): + seq = "EMECTSESPEK" + fixed_mods = {"Carbamidomethyl": ["C"]} + pf = ProForma.parse(seq) + forms = list(proteoforms(pf, fixed_modifications=fixed_mods)) + self.assertEqual(len(forms), 1) + self.assertTrue(isinstance(forms[0].sequence[3][1][0], GenericModification)) + def test_expand_mods_from_list(self): seq = "EMEVTSESPEK" variable_mods = ["Phospho|Position:S", "Phospho|Position:T"] From 1d7903fea9c25d475225fecdc1e1ae87b0b65b3e Mon Sep 17 00:00:00 2001 From: Joshua Klein Date: Mon, 13 Jul 2026 23:38:38 -0400 Subject: [PATCH 7/8] handle scenario where fixed modifications aren't `ModificationRule` --- pyteomics/proforma.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pyteomics/proforma.py b/pyteomics/proforma.py index 449708fb..b78943f2 100644 --- a/pyteomics/proforma.py +++ b/pyteomics/proforma.py @@ -4529,7 +4529,16 @@ def __ne__(self, other): def __hash__(self): return hash(self.token) - def __init__(self, rule, region=None, colocal_known: bool = False, colocal_unknown: bool = False, limit: int = 1, labile: bool = False, strip: bool = False): + def __init__( + self, + rule: ModificationRule, + region: Optional[TaggedInterval]=None, + colocal_known: bool = False, + colocal_unknown: bool = False, + limit: int = 1, + labile: bool = False, + strip: bool = False, + ): self.rule = rule self.region = region self.colocal_known = colocal_known @@ -4909,7 +4918,10 @@ def __init__(self, base_proteoform: ProForma, include_unmodified: bool=False, in def _apply_fixed_modifications(self): for c in self.template.fixed_modifications: - rule = GeneratorModificationRuleDirective.from_unlocalized_rule(c) + if isinstance(c, ModificationRule): + rule = GeneratorModificationRuleDirective(c) + else: + rule = GeneratorModificationRuleDirective.from_unlocalized_rule(c) positions = rule.find_positions(self.template) for i in positions: (aa, tags) = self.template[i] From 55301c8c1ed8eea748f0f293af563ac9161293d5 Mon Sep 17 00:00:00 2001 From: Joshua Klein Date: Mon, 13 Jul 2026 23:42:03 -0400 Subject: [PATCH 8/8] borrow test from #213 --- tests/test_proforma.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_proforma.py b/tests/test_proforma.py index 4c9377e6..f98e6850 100644 --- a/tests/test_proforma.py +++ b/tests/test_proforma.py @@ -102,6 +102,16 @@ def test_c_terminal_modification(self): self.assertEqual(i.c_term[0].name, "Methyl") self.assertEqual(i[-1][1][0].name, "iTRAQ4plex") + def test_slice_grouped_modification(self): + # Regression test: slicing a sequence with a grouped modification tag + # (e.g. "#g1") used to raise a TypeError because the tag position tuple + # returned by find_tags_by_id was used directly as a sequence index. + seq = "EMEVT[#g1]S[#g1]ES[#g1]PEK" + i = ProForma.parse(seq) + sub = i[2:9] + self.assertEqual(str(sub), "EVT[#g1]S[#g1]ES[#g1]P") + self.assertEqual(sub.group_ids, ["#g1"]) + def test_fragments(self): i = ProForma.parse("PEPTIDE") masses = i.fragments('b', 1)