diff --git a/doc/source/api/proforma.rst b/doc/source/api/proforma.rst index ae4f1b30..3981a6e1 100644 --- a/doc/source/api/proforma.rst +++ b/doc/source/api/proforma.rst @@ -43,6 +43,16 @@ Data Access :py:func:`to_proforma` - Format a sequence and set of properties as ProForma text. +Chimeric spectra are parsed by opting in with ``chimeric=True``. In that mode, +top-level ``+`` separates peptidoform components and shared fixed modification +rules and isotope labels are applied to each component: + +.. code-block:: python + + >>> components = parse("<[Carbamidomethyl]@C>AC+CC", chimeric=True) + >>> len(components) + 2 + Classes ------- @@ -140,7 +150,7 @@ These features are independent from each other: 6. Spectral Support - [x] Charge state and adducts - - [ ] Chimeric spectra are special cases. + - [x] Chimeric spectra are special cases. - [x] Global modifications (e.g., every C is C13). diff --git a/doc/source/proforma.rst b/doc/source/proforma.rst index 7a14decc..8a667e6c 100644 --- a/doc/source/proforma.rst +++ b/doc/source/proforma.rst @@ -25,3 +25,31 @@ To instantiate a :py:class:`ProForma` object, use the class method :py:meth:`Pro >>> seq.composition() Composition({'H': 86, 'C': 51, 'O': 30, 'N': 12, 'S': 1, 'P': 2}) + +Chimeric spectra +~~~~~~~~~~~~~~~~ + +Top-level ``+`` in ProForma is treated as a chimeric separator only when +``chimeric=True`` is passed. The return value is then a list of parsed +components: + +.. code-block:: python + + >>> forms = ProForma.parse("<[Carbamidomethyl]@C>AC+CC", chimeric=True) + >>> len(forms) + 2 + >>> [str(form) for form in forms] + ['<[Carbamidomethyl]@C>AC', '<[Carbamidomethyl]@C>CC'] + +Fixed modification rules, isotope labels, and peptidoform names are shared +across all chimeric components. + +Other APIs such as mass calculation, fragment series generation, and spectrum +annotation operate on one peptidoform at a time. Use the parsed components +individually: + +.. code-block:: python + + >>> from pyteomics import mass + >>> masses = [mass.calculate_mass(proforma=str(form)) for form in forms] + >>> fragments = [mass.fragment_series(str(form)) for form in forms] diff --git a/pyteomics/proforma.py b/pyteomics/proforma.py index cb22a303..0aefccc3 100644 --- a/pyteomics/proforma.py +++ b/pyteomics/proforma.py @@ -13,7 +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 +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 itertools import chain @@ -2529,6 +2529,9 @@ def _local_charges( return local_charges, n_charged_modifications +ProFormaParseResult = Tuple[List[Tuple[str, Optional[List[TagBase]]]], Dict[str, Any]] + + class Parser: """ A parser for the ProForma 2 syntax. @@ -2569,7 +2572,7 @@ class Parser: charge_buffer: Optional[NumberParser] adduct_buffer: Optional[AdductParser] - def __init__(self, sequence: str, case_sensitive_aa: bool=False): + def __init__(self, sequence: str, case_sensitive_aa: bool=False, chimeric: bool=False): """ Instantiate a ProForma 2 parser for the specified sequence. @@ -2580,6 +2583,9 @@ def __init__(self, sequence: str, case_sensitive_aa: bool=False): case_sensitive_aa : bool Whether to treat amino acids as case sensitive (older behavior) while the specification states they should be handled insensitively. + chimeric : bool + Whether to parse top-level ``+`` as a separator between chimeric + peptidoform components. """ self.sequence = sequence self.index = 0 @@ -2587,29 +2593,16 @@ def __init__(self, sequence: str, case_sensitive_aa: bool=False): self.length = len(sequence) self.state = ParserStateEnum.before_sequence self._VALID_AA = VALID_AA if not case_sensitive_aa else VALID_AA_UPPER + self.chimeric = chimeric + self.components = [] - self.n_term = [] - self.c_term = [] - self.intervals = [] - self.positions = [] - - self.adduct_buffer = None - self.charge_buffer = None - self.current_aa = None - self.current_interval = None - self.current_tag = TagParser() - self.current_aa_targets = StringParser() - self.current_unlocalized_count = NumberParser() - - self.unlocalized_modifications = [] - self.labile_modifications = [] self.fixed_modifications = [] - self.unlocalized_modifications = [] - self.intervals = [] self.isotopes = [] + self.shared_group_ids = set() self.name_level = None self.name_buffer = StringParser() self.names = {} + self._reset_component() @property def n(self) -> int: @@ -2624,6 +2617,41 @@ def pack_sequence_position(self): ) self.current_aa = None + def _reset_component(self): + self.n_term = [] + self.c_term = [] + self.intervals = [] + self.positions = [] + + self.adduct_buffer = None + self.charge_buffer = None + self.current_aa = None + self.current_interval = None + self.current_tag = TagParser(group_ids=self.shared_group_ids) + self.current_aa_targets = StringParser() + self.current_unlocalized_count = NumberParser() + + self.unlocalized_modifications = [] + self.labile_modifications = [] + self.state = BEFORE + + def _chimeric_disabled_error(self): + raise ProFormaError( + ( + f"Error In State {self.state}, + found at index {self.index}. " + "Chimeric ProForma detected but chimeric parsing is disabled. " + "Pass chimeric=True to parse chimeric spectra." + ), + self.index, + self.state, + ) + + def _handle_chimeric_separator(self): + if not self.chimeric: + self._chimeric_disabled_error() + self.components.append(self._finish_component()) + self._reset_component() + def handle_before(self, c: str): if c == '[': self.state = TAG_BEFORE @@ -2646,6 +2674,10 @@ def handle_before(self, c: str): else: self.state = INTERVAL_INIT self.current_interval = TaggedInterval(len(self.positions) + 1) + elif c == '+': + if self.chimeric: + raise ProFormaError("Empty peptidoform in chimeric ProForma string", self.index, self.state) + self._chimeric_disabled_error() else: raise ProFormaError( f"Error In State {self.state}, unexpected {c} found at index {self.index}", @@ -2713,11 +2745,13 @@ def handle_seq(self, c: str): self.charge_buffer = NumberParser() self.adduct_buffer = AdductParser() elif c == '+': - raise ProFormaError( - f"Error In State {self.state}, {c} found at index {self.index}. Chimeric representation not supported", - self.index, - self.state, - ) + if self.current_interval is not None: + raise ProFormaError( + f"Error In State {self.state}, {c} found at index {self.index}. Chimeric representation not supported", + self.index, + self.state, + ) + self._handle_chimeric_separator() else: raise ProFormaError( f"Error In State {self.state}, unexpected {c} found at index {self.index}", @@ -2803,11 +2837,10 @@ def handle_post_interval_tag(self, c: str): self.state = CHARGE_START self.charge_buffer = NumberParser() elif c == "+": - raise ProFormaError( - f"Error In State {self.state}, {self.c} found at index {self.index}. Chimeric representation not supported", - self.index, - self.state, - ) + self.current_interval.tags = self.current_tag() + self.intervals.append(self.current_interval) + self.current_interval = None + self._handle_chimeric_separator() else: raise ProFormaError( f"Error In State {self.state}, unexpected {self.c} found at index {self.index}", @@ -2886,6 +2919,7 @@ def handle_post_global_aa(self, c: str): self.fixed_modifications.append( ModificationRule(self.current_tag()[0], v) ) + self.shared_group_ids.update(self.current_tag.group_ids) except PyteomicsError as err: raise ProFormaError( ( @@ -2908,11 +2942,7 @@ def handle_post_tag_after(self, c: str): self.state = CHARGE_START self.charge_buffer = NumberParser() elif c == "+": - raise ProFormaError( - f"Error In State {self.state}, {c} found at index {self.index}. Chimeric representation not supported", - self.index, - self.state, - ) + self._handle_chimeric_separator() def handle_charge_start(self, c: str): if c in "+-": @@ -2930,6 +2960,7 @@ def handle_charge_start(self, c: str): ) elif c == '[': self.state = ParserStateEnum.charge_state_adduct_start + self.depth = 1 else: raise ProFormaError( f"Error In State {self.state}, unexpected {c} found at index {self.index}", @@ -2944,6 +2975,8 @@ def handle_charge_number(self, c: str): self.state = ADDUCT_START self.depth = 1 self.adduct_buffer = AdductParser() + elif c == "+": + self._handle_chimeric_separator() else: raise ProFormaError( f"Error In State {self.state}, unexpected {c} found at index {self.index}", @@ -2968,11 +3001,7 @@ def handle_adduct_start(self, c: str): def handle_adduct_end(self, c: str): if c == "+": - raise ProFormaError( - f"Error In State {self.state}, {c} found at index {self.index}. Chimeric representation not supported", - self.index, - self.state, - ) + self._handle_chimeric_separator() def handle_name_level(self, c: str): if c == '>' and self.name_level < 3: @@ -3071,21 +3100,7 @@ def step(self) -> bool: self.index += 1 return self.index < self.length - def finish( - self, - ) -> Tuple[List[Tuple[str, Optional[List[TagBase]]]], Dict[str, Any]]: - """ - Post-process the parser's accumulated parsed token data and return the parsed - sequence and metadata. - - Returns - ------- - sequence : List[Tuple[str, Optional[List[TagBase]]]] - The primary amino acid sequence of the ProForma string - metadata : Dict[str, Any] - All other information outside the main sequence, including unlocalized, labile, or global modifications, - names, charge states, and more. - """ + def _finish_component(self) -> ProFormaParseResult: if self.charge_buffer: charge_number = self.charge_buffer() if self.adduct_buffer: @@ -3101,6 +3116,9 @@ def finish( if self.current_aa: self.pack_sequence_position() + if not self.positions and self.chimeric: + raise ProFormaError("Empty peptidoform in chimeric ProForma string", self.index, self.state) + z, k = self._local_charges() if k: if charge_state is None: @@ -3127,11 +3145,40 @@ def finish( "fixed_modifications": self.fixed_modifications, "intervals": self.intervals, "isotopes": self.isotopes, - "group_ids": sorted(self.current_tag.group_ids), + "group_ids": sorted(set(self.current_tag.group_ids) | self.shared_group_ids), "charge_state": charge_state, "names": self.names } + 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]]: + """ + Post-process the parser's accumulated parsed token data and return the parsed + sequence and metadata. + + Returns + ------- + sequence : List[Tuple[str, Optional[List[TagBase]]]] + The primary amino acid sequence of the ProForma string + metadata : Dict[str, Any] + All other information outside the main sequence, including unlocalized, labile, or global modifications, + names, charge states, and more. + """ + component = self._finish_component() + if self.chimeric: + self.components.append(component) + self._apply_shared_properties() + return self.components + return component + def _local_charges(self) -> Tuple[int, int]: return _local_charges( self.positions, @@ -3165,7 +3212,17 @@ def empty_properties(): } -def parse(sequence: str, **kwargs) -> Tuple[List[Tuple[str, Optional[List[TagBase]]]], Dict[str, Any]]: +@overload +def parse(sequence: str, *, chimeric: Literal[False] = False, **kwargs) -> ProFormaParseResult: + ... + + +@overload +def parse(sequence: str, *, chimeric: Literal[True], **kwargs) -> List[ProFormaParseResult]: + ... + + +def parse(sequence: str, *, chimeric: bool = False, **kwargs) -> Union[ProFormaParseResult, List[ProFormaParseResult]]: """ Tokenize a ProForma sequence into a sequence of amino acid+tag positions, and a mapping of sequence-spanning modifiers. @@ -3178,6 +3235,10 @@ def parse(sequence: str, **kwargs) -> Tuple[List[Tuple[str, Optional[List[TagBas ---------- sequence: str The sequence to parse + chimeric : bool + If :const:`True`, top-level ``+`` separates chimeric peptidoform + components and a list of parse results is returned. If :const:`False`, + top-level ``+`` raises a :class:`ProFormaError` suggesting this option. **kwargs : Forwarded to :class:`Parser` @@ -3191,11 +3252,14 @@ def parse(sequence: str, **kwargs) -> Tuple[List[Tuple[str, Optional[List[TagBas """ # short-circuiting the parser for simple sequences with no tags or modifications to avoid overhead if sequence.isupper() and sequence.isalpha(): - return ( + result = ( [(aa, None) for aa in sequence], Parser.empty_properties() ) - parser = Parser(sequence, **kwargs) + if chimeric: + return [result] + return result + parser = Parser(sequence, chimeric=chimeric, **kwargs) return parser.parse() @@ -3866,20 +3930,36 @@ def charge_state(self, value: Union[int, ChargeState, None]): self._charge_state = new @classmethod - def parse(cls, string, **kwargs): + @overload + def parse(cls, string, *, chimeric: Literal[False] = False, **kwargs) -> "ProForma": + ... + + @classmethod + @overload + def parse(cls, string, *, chimeric: Literal[True], **kwargs) -> List["ProForma"]: + ... + + @classmethod + def parse(cls, string, *, chimeric: bool = False, **kwargs): '''Parse a ProForma string. Parameters ---------- string : str The string to parse + chimeric : bool + If :const:`True`, top-level ``+`` separates chimeric peptidoform + components and a list of :class:`ProForma` instances is returned. **kwargs : Forwarded to :class:`Parser` Returns ------- - ProForma + ProForma or list[ProForma] ''' - return cls(*parse(string, **kwargs)) + result = parse(string, chimeric=chimeric, **kwargs) + if chimeric: + return [cls(*component) for component in result] + return cls(*result) @property def mass(self) -> float: diff --git a/tests/test_proforma.py b/tests/test_proforma.py index 98fa57a0..1cbe78e7 100644 --- a/tests/test_proforma.py +++ b/tests/test_proforma.py @@ -138,6 +138,60 @@ def test_charge_adducts(self): self.assertEqual(i.charge_state.charge, charge) self.assertEqual(i.charge_state.adducts, adducts) + def test_chimeric_parse_requires_opt_in(self): + with self.assertRaisesRegex(ProFormaError, 'chimeric=True'): + parse('PEPTIDE+ELVIS') + + def test_chimeric_parse(self): + seq = 'EMEVEESPEK/2+ELVISLIVER/3' + parsed = parse(seq, chimeric=True) + self.assertEqual(len(parsed), 2) + self.assertEqual(parsed[0][1]['charge_state'].charge, 2) + self.assertEqual(parsed[1][1]['charge_state'].charge, 3) + + forms = ProForma.parse(seq, chimeric=True) + self.assertEqual(len(forms), 2) + self.assertTrue(all(isinstance(form, ProForma) for form in forms)) + self.assertEqual(forms[0].charge_state.charge, 2) + self.assertEqual(forms[1].charge_state.charge, 3) + + def test_chimeric_single_component_opt_in(self): + forms = ProForma.parse('PEPTIDE/+2', chimeric=True) + self.assertEqual(len(forms), 1) + self.assertEqual(forms[0].charge_state.charge, 2) + + def test_chimeric_empty_component(self): + with self.assertRaisesRegex(ProFormaError, 'Empty peptidoform'): + parse('+PEPTIDE', chimeric=True) + with self.assertRaisesRegex(ProFormaError, 'Empty peptidoform'): + parse('PEPTIDE+', chimeric=True) + + def test_chimeric_shared_fixed_modifications(self): + seq = '<[Carbamidomethyl]@C>AC+CC' + parsed = parse(seq, chimeric=True) + self.assertEqual(len(parsed), 2) + self.assertEqual(len(parsed[0][1]['fixed_modifications']), 1) + self.assertEqual(len(parsed[1][1]['fixed_modifications']), 1) + + forms = ProForma.parse(seq, chimeric=True) + aa_comp = std_aa_comp.copy() + aa_comp['cam'] = Composition(formula='H3C2NO') + self.assertEqual(forms[0].composition(), Composition(sequence='AcamC', aa_comp=aa_comp)) + 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'}) + self.assertEqual(parsed[0][1]['isotopes'], [StableIsotope('13C')]) + self.assertEqual(parsed[1][1]['isotopes'], [StableIsotope('13C')]) + + def test_chimeric_adduct_separator(self): + forms = ProForma.parse('PEPTIDE/[Na:z+1,H:z+1]+ELVIS/2', chimeric=True) + self.assertEqual(len(forms), 2) + self.assertEqual(forms[0].charge_state.charge, 2) + self.assertEqual(forms[1].charge_state.charge, 2) + def test_composition_with_adducts(self): sequences = ['PEPTIDE/1[+2Na+,-H+]', 'PEPTIDE/-1[+e-]', 'PEPTIDE/1[+2H+,+e-]', 'PEPTIDE', 'PEPTIDE/1'] neutral_comp = Composition(sequence='PEPTIDE')