From 6f244c12e41824ed036009d59b71a5ff45c5584c Mon Sep 17 00:00:00 2001 From: Adam Morris <15155249+admorris@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:53:49 +0200 Subject: [PATCH 1/9] Add support for parsing decay descriptors --- src/decaylanguage/data/descriptor.lark | 29 +++++ src/decaylanguage/decay/decay.py | 143 +++++++++++++++++++++++++ tests/decay/test_descriptor.py | 69 ++++++++---- 3 files changed, 221 insertions(+), 20 deletions(-) create mode 100644 src/decaylanguage/data/descriptor.lark diff --git a/src/decaylanguage/data/descriptor.lark b/src/decaylanguage/data/descriptor.lark new file mode 100644 index 00000000..52e55c6d --- /dev/null +++ b/src/decaylanguage/data/descriptor.lark @@ -0,0 +1,29 @@ +// Copyright (c) 2018-2026, Eduardo Rodrigues and Henry Schreiner. +// +// Distributed under the 3-clause BSD license, see accompanying file LICENSE +// or https://github.com/scikit-hep/decaylanguage for details. + +start: decay + +decay: particle ARROW daughters + +daughters: daughter+ + +daughter: particle + | sub_decay + +particle: PARTICLE + +sub_decay: LPAR decay RPAR + +// Terminals +ARROW: "->" +LPAR: "(" +RPAR: ")" + +// Particle names start with alphanumeric/underscore and can then include +// common descriptor suffix symbols such as +, -, *, ', and ~. +PARTICLE: /[A-Za-z0-9_][A-Za-z0-9_+*'~̄-]*/ + +%import common.WS +%ignore WS diff --git a/src/decaylanguage/decay/decay.py b/src/decaylanguage/decay/decay.py index 9828a22f..7e54418a 100644 --- a/src/decaylanguage/decay/decay.py +++ b/src/decaylanguage/decay/decay.py @@ -11,8 +11,10 @@ from collections.abc import Collection, Iterator, Sequence from copy import deepcopy from itertools import product +from pathlib import Path from typing import Any, TypedDict +from lark import Lark, LarkError, Token, Transformer from particle import PDGID, ParticleNotFound from particle.converters import EvtGenName2PDGIDBiMap from particle.exceptions import MatchingIDNotFound @@ -462,6 +464,18 @@ def __repr__(self) -> str: def __str__(self) -> str: return repr(self) + def __eq__(self, other: object) -> bool: + if not isinstance(other, DecayMode): + return NotImplemented + return ( + self.bf == other.bf + and self.daughters == other.daughters + and self.metadata == other.metadata + ) + + def __hash__(self) -> int: + raise TypeError(f"unhashable type: '{type(self).__name__}'") + def _has_no_subdecay(ds: list[Any]) -> bool: """ @@ -749,6 +763,60 @@ def _expand_decay_modes( return expanded_modes +class _DescriptorTreeToDict(Transformer): # type: ignore[misc] + """Map a parsed descriptor tree into a ``DecayChainDict`` structure.""" + + def start(self, items: list[Any]) -> DecayChainDict: + # start: decay + return typing.cast(DecayChainDict, items[0]) + + def particle(self, items: list[Any]) -> str: + # particle: PARTICLE + return str(items[0]) + + def daughter(self, items: list[Any]) -> str | DecayChainDict: + # daughter: particle | sub_decay + return typing.cast(str | DecayChainDict, items[0]) + + def daughters(self, items: list[Any]) -> list[str | DecayChainDict]: + # daughters: daughter+ + return [item for item in items if isinstance(item, (str, dict))] + + def sub_decay(self, items: list[Any]) -> DecayChainDict: + # sub_decay: LPAR decay RPAR + for item in items: + if isinstance(item, dict): + return item + raise ValueError("Malformed sub-decay in parse tree") + + def decay(self, items: list[Any]) -> DecayChainDict: + # decay: particle ARROW daughters + mother: str | None = None + daughters: list[str | DecayChainDict] | None = None + + for item in items: + if isinstance(item, str) and mother is None: + mother = item + elif isinstance(item, list): + daughters = item + elif isinstance(item, Token): + # Ignore punctuation tokens such as ARROW. + continue + + if not mother or not daughters: + raise ValueError( + f"Malformed decay in parse tree. Parsing result: {mother=}, {daughters=}" + ) + + mode: DecayModeDict = { + "bf": 1.0, + "fs": daughters, + "model": "", + "model_params": "", + } + return {mother: [mode]} + + class DecayChain: """ Class holding a particle (single) decay chain, which is typically a top-level decay @@ -813,6 +881,73 @@ def from_dict(cls, decay_chain_dict: DecayChainDict) -> Self: return cls(mother, decay_modes) + @classmethod + def from_string( + cls, + descriptor: str, + *, + grammar_file: str | Path | None = None, + ) -> Self: + """ + Construct a ``DecayChain`` by parsing a descriptor string. + + Parameters + ---------- + descriptor : str + The decay descriptor string, e.g. + ``"D*+ -> (D0 -> K+ pi-) pi+"``. + grammar_file : str or Path, optional + Path to a custom Lark grammar file for descriptor parsing. + If not provided, the default grammar is used. + Returns + ------- + DecayChain + + Raises + ------ + ValueError + If the descriptor string is malformed. + + Examples + -------- + >>> dc = DecayChain.from_string("D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)") + >>> dc.mother + 'D0' + >>> len(dc.decays) + 3 + """ + + # Load the grammar + if grammar_file is None: + # Use the default descriptor grammar + grammar_file = Path(__file__).parent.parent / "data" / "descriptor.lark" + + if isinstance(grammar_file, str): + grammar_file = Path(grammar_file) + + if not grammar_file.exists(): + raise FileNotFoundError(f"Grammar file not found: {grammar_file}") + + with grammar_file.open() as f: + grammar = f.read() + + # Parse the descriptor + try: + parser = Lark(grammar, parser="lalr", transformer=None) + tree = parser.parse(descriptor) + except LarkError as e: + raise ValueError(f"Failed to parse descriptor '{descriptor}': {e}") from e + + # Transform to a DecayChainDict + try: + decay_chain_dict = _DescriptorTreeToDict().transform(tree) + except Exception as e: + raise ValueError( + f"Failed to transform parse tree into decay chain: {e}" + ) from e + + return cls.from_dict(decay_chain_dict) + def top_level_decay(self) -> DecayMode: """ Return the top-level decay as a ``DecayMode`` instance. @@ -1061,3 +1196,11 @@ def __repr__(self) -> str: def __str__(self) -> str: return repr(self) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, DecayChain): + return NotImplemented + return self.mother == other.mother and self.decays == other.decays + + def __hash__(self) -> int: + raise TypeError(f"unhashable type: '{type(self).__name__}'") diff --git a/tests/decay/test_descriptor.py b/tests/decay/test_descriptor.py index a7b4e592..4cb70c21 100644 --- a/tests/decay/test_descriptor.py +++ b/tests/decay/test_descriptor.py @@ -5,6 +5,8 @@ from __future__ import annotations +import copy + import pytest from decaylanguage import DecayChain, DecayMode @@ -21,27 +23,54 @@ dm9b = DecayMode(0.154, "pi+ pi- pi0") # phi +EXAMPLES = [ + ( + DecayChain("D0", {"D0": dm2, "K_S0": dm3, "pi0": dm4}), + "D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)", + ), + ( + DecayChain("D*+", {"D*+": dm1, "D0": dm2, "K_S0": dm3, "pi0": dm4}), + "D*+ -> (D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)) pi+", + ), + ( + DecayChain("B0", {"B0": dm5, "D-": dm6, "tau+": dm7}), + "B0 -> (D- -> K+ pi- pi-) (tau+ -> anti-nu_tau pi+ pi+ pi-) nu_tau", + ), + ( + DecayChain("B_s0", {"B_s0": dm8, "phi": dm9a, "phi'": dm9b}), + "B_s0 -> (phi -> K+ K-) (phi' -> pi+ pi- pi0)", + ), +] + + +@pytest.mark.parametrize(("dc", "expected"), EXAMPLES) +def test_descriptor_formatter(dc: DecayChain, expected: str): + descriptor = dc.to_string() + assert descriptor == expected + + +@pytest.mark.parametrize(("expected", "desc"), EXAMPLES) +def test_descriptor_parser(expected: DecayChain, desc: str): + result = DecayChain.from_string(desc) + # Branching fractions are not deduced from the descriptor + # We set them explicitly to 1.0 as a known difference + expected = copy.deepcopy(expected) + for mode in expected.decays.values(): + mode.bf = 1.0 + assert result == expected + + @pytest.mark.parametrize( - ("dc", "expected"), + "descriptor", [ - ( - DecayChain("D0", {"D0": dm2, "K_S0": dm3, "pi0": dm4}), - "D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)", - ), - ( - DecayChain("D*+", {"D*+": dm1, "D0": dm2, "K_S0": dm3, "pi0": dm4}), - "D*+ -> (D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)) pi+", - ), - ( - DecayChain("B0", {"B0": dm5, "D-": dm6, "tau+": dm7}), - "B0 -> (D- -> K+ pi- pi-) (tau+ -> anti-nu_tau pi+ pi+ pi-) nu_tau", - ), - ( - DecayChain("B_s0", {"B_s0": dm8, "phi": dm9a, "phi'": dm9b}), - "B_s0 -> (phi -> K+ K-) (phi' -> pi+ pi- pi0)", - ), + "D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)", + "D*+ -> (D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)) pi+", + "B0 -> (D- -> K+ pi- pi-) (tau+ -> anti-nu_tau pi+ pi+ pi-) nu_tau", + "B_s0 -> (phi -> K+ K-) (phi' -> pi+ pi- pi0)", ], ) -def test_descriptor(dc: DecayChain, expected: str): - descriptor = dc.to_string() - assert descriptor == expected +def test_from_string_to_string(descriptor: str): + dc = DecayChain.from_string(descriptor) + # Verify that we can round-trip: string -> chain -> string + result = dc.to_string() + assert result == descriptor From 88636a1fc6d368bb93c2df97f522650181807d67 Mon Sep 17 00:00:00 2001 From: Adam Morris <15155249+admorris@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:02:52 +0200 Subject: [PATCH 2/9] add test for alternative .lark file --- tests/data/descriptor_alt.lark | 29 +++++++++++++++++++++++++++++ tests/decay/test_descriptor.py | 23 +++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/data/descriptor_alt.lark diff --git a/tests/data/descriptor_alt.lark b/tests/data/descriptor_alt.lark new file mode 100644 index 00000000..ca4ec782 --- /dev/null +++ b/tests/data/descriptor_alt.lark @@ -0,0 +1,29 @@ +// Copyright (c) 2018-2026, Eduardo Rodrigues and Henry Schreiner. +// +// Distributed under the 3-clause BSD license, see accompanying file LICENSE +// or https://github.com/scikit-hep/decaylanguage for details. + +start: decay + +decay: particle ARROW daughters + +daughters: daughter+ + +daughter: particle + | sub_decay + +particle: PARTICLE + +sub_decay: LPAR decay RPAR + +// Terminals +ARROW: "=>" +LPAR: "{" +RPAR: "}" + +// Particle names start with alphanumeric/underscore and can then include +// common descriptor suffix symbols such as +, -, *, ', and ~. +PARTICLE: /[A-Za-z0-9_][A-Za-z0-9_+*'~̄-]*/ + +%import common.WS +%ignore WS diff --git a/tests/decay/test_descriptor.py b/tests/decay/test_descriptor.py index 4cb70c21..2aeb5092 100644 --- a/tests/decay/test_descriptor.py +++ b/tests/decay/test_descriptor.py @@ -6,6 +6,7 @@ from __future__ import annotations import copy +from pathlib import Path import pytest @@ -60,6 +61,28 @@ def test_descriptor_parser(expected: DecayChain, desc: str): assert result == expected +@pytest.mark.parametrize( + ("conventional", "alternative"), + [ + ( + "D*+ -> (D0 -> (K_S0 -> pi+ pi-) (pi0 -> gamma gamma)) pi+", + "D*+ => {D0 => {K_S0 => pi+ pi-} {pi0 => gamma gamma}} pi+", + ), + ( + "B0 -> (D- -> K+ pi- pi-) (tau+ -> anti-nu_tau pi+ pi+ pi-) nu_tau", + "B0 => {D- => K+ pi- pi-} {tau+ => anti-nu_tau pi+ pi+ pi-} nu_tau", + ), + ], +) +def test_descriptor_parser_alternative(conventional: str, alternative: str): + result = DecayChain.from_string( + alternative, + grammar_file=Path(__file__).parent.parent / "data" / "descriptor_alt.lark", + ) + expected = DecayChain.from_string(conventional) + assert result == expected + + @pytest.mark.parametrize( "descriptor", [ From 42faba43cdb35ddc1bd433d49bf77ed2a78d201a Mon Sep 17 00:00:00 2001 From: Adam Morris <15155249+admorris@users.noreply.github.com> Date: Thu, 7 May 2026 17:18:18 +0200 Subject: [PATCH 3/9] Apply suggestion Co-authored-by: Eduardo Rodrigues --- src/decaylanguage/decay/decay.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/decaylanguage/decay/decay.py b/src/decaylanguage/decay/decay.py index 7e54418a..eb10ed82 100644 --- a/src/decaylanguage/decay/decay.py +++ b/src/decaylanguage/decay/decay.py @@ -899,6 +899,7 @@ def from_string( grammar_file : str or Path, optional Path to a custom Lark grammar file for descriptor parsing. If not provided, the default grammar is used. + Returns ------- DecayChain From 6a0d00cd984b41ddb3e875cc0a9b6557027ed0fe Mon Sep 17 00:00:00 2001 From: Adam Morris <15155249+admorris@users.noreply.github.com> Date: Thu, 7 May 2026 17:20:14 +0200 Subject: [PATCH 4/9] describe descriptor.lark in the local README --- src/decaylanguage/data/README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/decaylanguage/data/README.rst b/src/decaylanguage/data/README.rst index fead28c7..2b75e60f 100644 --- a/src/decaylanguage/data/README.rst +++ b/src/decaylanguage/data/README.rst @@ -17,6 +17,13 @@ all generic particle decays. Lark parser grammar definition file for parsing .dec decay files. +``descriptor.lark`` +------------------ +Lark parser grammar definition file for parsing decay descriptors, e.g. with ``DecayChain.from_string``. +Note that alternative ``.lark`` files can be provided. +See an example in ``tests/data/descriptor_alt.lark`` and ``tests/decay/test_descriptor.py``. + + ``MintDalitzSpecialParticles.fwf`` ---------------------------------- From 5c0f2381f0dc21dc2fdd018d542f49b2ea5d101f Mon Sep 17 00:00:00 2001 From: Adam Morris <15155249+admorris@users.noreply.github.com> Date: Thu, 7 May 2026 19:06:11 +0200 Subject: [PATCH 5/9] use lexer="auto" --- src/decaylanguage/decay/decay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/decaylanguage/decay/decay.py b/src/decaylanguage/decay/decay.py index eb10ed82..bba44d1d 100644 --- a/src/decaylanguage/decay/decay.py +++ b/src/decaylanguage/decay/decay.py @@ -934,7 +934,7 @@ def from_string( # Parse the descriptor try: - parser = Lark(grammar, parser="lalr", transformer=None) + parser = Lark(grammar, parser="lalr", transformer=None, lexer="auto") tree = parser.parse(descriptor) except LarkError as e: raise ValueError(f"Failed to parse descriptor '{descriptor}': {e}") from e From 463783fa8e3849a1a0b4fb9a81d485ba75234d35 Mon Sep 17 00:00:00 2001 From: Adam Morris <15155249+admorris@users.noreply.github.com> Date: Thu, 28 May 2026 14:51:54 +0200 Subject: [PATCH 6/9] get lark file from data package, improve docstring --- src/decaylanguage/decay/decay.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/decaylanguage/decay/decay.py b/src/decaylanguage/decay/decay.py index bba44d1d..87af3ffa 100644 --- a/src/decaylanguage/decay/decay.py +++ b/src/decaylanguage/decay/decay.py @@ -19,6 +19,7 @@ from particle.converters import EvtGenName2PDGIDBiMap from particle.exceptions import MatchingIDNotFound +from .. import data from .._compat.typing import Self from ..utils import DescriptorFormat, charge_conjugate_name @@ -920,17 +921,13 @@ def from_string( # Load the grammar if grammar_file is None: - # Use the default descriptor grammar - grammar_file = Path(__file__).parent.parent / "data" / "descriptor.lark" - - if isinstance(grammar_file, str): - grammar_file = Path(grammar_file) - - if not grammar_file.exists(): - raise FileNotFoundError(f"Grammar file not found: {grammar_file}") - - with grammar_file.open() as f: - grammar = f.read() + # Use the default descriptor grammar from the data package + grammar = data.basepath.joinpath("descriptor.lark").read_text() + else: + # Use custom grammar file + if isinstance(grammar_file, str): + grammar_file = Path(grammar_file) + grammar = grammar_file.read_text() # Parse the descriptor try: From 0b3a0f16bf00176b81b7138eb084b60d2077d7b0 Mon Sep 17 00:00:00 2001 From: Adam Morris <15155249+admorris@users.noreply.github.com> Date: Thu, 28 May 2026 14:54:56 +0200 Subject: [PATCH 7/9] annotate descriptor_alt.lark --- tests/data/descriptor_alt.lark | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/data/descriptor_alt.lark b/tests/data/descriptor_alt.lark index ca4ec782..dc470d4f 100644 --- a/tests/data/descriptor_alt.lark +++ b/tests/data/descriptor_alt.lark @@ -3,6 +3,8 @@ // Distributed under the 3-clause BSD license, see accompanying file LICENSE // or https://github.com/scikit-hep/decaylanguage for details. +// Alternative decay descriptor parsing grammar demonstrating different parentheses and arrows + start: decay decay: particle ARROW daughters From 4e27b8c9144b3e42b5dab6700a4d0abfb58da96a Mon Sep 17 00:00:00 2001 From: Adam Morris <15155249+admorris@users.noreply.github.com> Date: Thu, 28 May 2026 15:06:14 +0200 Subject: [PATCH 8/9] specify encoding when doing Path.read_text --- src/decaylanguage/decay/decay.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/decaylanguage/decay/decay.py b/src/decaylanguage/decay/decay.py index 87af3ffa..f4f774b4 100644 --- a/src/decaylanguage/decay/decay.py +++ b/src/decaylanguage/decay/decay.py @@ -899,7 +899,7 @@ def from_string( ``"D*+ -> (D0 -> K+ pi-) pi+"``. grammar_file : str or Path, optional Path to a custom Lark grammar file for descriptor parsing. - If not provided, the default grammar is used. + The default grammar file is ``decaylanguage/data/descriptor.lark``. Returns ------- @@ -922,12 +922,14 @@ def from_string( # Load the grammar if grammar_file is None: # Use the default descriptor grammar from the data package - grammar = data.basepath.joinpath("descriptor.lark").read_text() + grammar = data.basepath.joinpath("descriptor.lark").read_text( + encoding="utf-8" + ) else: # Use custom grammar file if isinstance(grammar_file, str): grammar_file = Path(grammar_file) - grammar = grammar_file.read_text() + grammar = grammar_file.read_text(encoding="utf-8") # Parse the descriptor try: From c8eb6de5cd226e588d49c0336f11f463024fd9e3 Mon Sep 17 00:00:00 2001 From: Adam Morris <15155249+admorris@users.noreply.github.com> Date: Thu, 28 May 2026 18:35:36 +0200 Subject: [PATCH 9/9] attempt to account for parenthesis pairs in particle names --- src/decaylanguage/data/descriptor.lark | 26 ++++++++------- src/decaylanguage/decay/decay.py | 46 ++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/decaylanguage/data/descriptor.lark b/src/decaylanguage/data/descriptor.lark index 52e55c6d..199e2678 100644 --- a/src/decaylanguage/data/descriptor.lark +++ b/src/decaylanguage/data/descriptor.lark @@ -2,28 +2,30 @@ // // Distributed under the 3-clause BSD license, see accompanying file LICENSE // or https://github.com/scikit-hep/decaylanguage for details. +%import common.WS_INLINE -start: decay +start: _ws decay _ws -decay: particle ARROW daughters +decay: particle _ws ARROW _ws daughters -daughters: daughter+ +daughters: daughter (_sep daughter)* _ws daughter: particle | sub_decay -particle: PARTICLE - -sub_decay: LPAR decay RPAR +particle: PHEAD psuffix* +sub_decay: LPAR _ws decay RPAR // Terminals -ARROW: "->" +ARROW.2: "->" LPAR: "(" RPAR: ")" -// Particle names start with alphanumeric/underscore and can then include -// common descriptor suffix symbols such as +, -, *, ', and ~. -PARTICLE: /[A-Za-z0-9_][A-Za-z0-9_+*'~̄-]*/ +psuffix: PCHUNK | pgroup_paren +pgroup_paren: "(" _ws PCHUNK _ws ")" + +PHEAD: /[A-Za-z0-9~][A-Za-z0-9\/\-+*_.'~]*/ +PCHUNK: /[A-Za-z0-9\/\-+*_.'~]+/ -%import common.WS -%ignore WS +_sep: WS_INLINE+ +_ws: WS_INLINE* diff --git a/src/decaylanguage/decay/decay.py b/src/decaylanguage/decay/decay.py index f4f774b4..03a853f5 100644 --- a/src/decaylanguage/decay/decay.py +++ b/src/decaylanguage/decay/decay.py @@ -772,8 +772,26 @@ def start(self, items: list[Any]) -> DecayChainDict: return typing.cast(DecayChainDict, items[0]) def particle(self, items: list[Any]) -> str: - # particle: PARTICLE - return str(items[0]) + # particle: PHEAD psuffix* + # Rebuild full particle name from head + suffixes + return "".join(self._item_text(i) for i in items if not self._is_ws(i)) + + def psuffix(self, items: list[Any]) -> str: + # psuffix: PCHUNK | pgroup_paren + # Return the only significant piece + for item in items: + if not self._is_ws(item): + return self._item_text(item) + return "" + + def pgroup_paren(self, items: list[Any]) -> str: + # pgroup_paren: "(" _ws PCHUNK _ws ")" + inner = "".join( + self._item_text(i) + for i in items + if not self._is_ws(i) and not self._is_paren_token(i) + ) + return f"({inner})" def daughter(self, items: list[Any]) -> str | DecayChainDict: # daughter: particle | sub_decay @@ -781,7 +799,11 @@ def daughter(self, items: list[Any]) -> str | DecayChainDict: def daughters(self, items: list[Any]) -> list[str | DecayChainDict]: # daughters: daughter+ - return [item for item in items if isinstance(item, (str, dict))] + return [ + item + for item in items + if not isinstance(item, Token) and isinstance(item, (str, dict)) + ] def sub_decay(self, items: list[Any]) -> DecayChainDict: # sub_decay: LPAR decay RPAR @@ -796,13 +818,13 @@ def decay(self, items: list[Any]) -> DecayChainDict: daughters: list[str | DecayChainDict] | None = None for item in items: + if isinstance(item, Token): + # Ignore punctuation and whitespace tokens. + continue if isinstance(item, str) and mother is None: mother = item elif isinstance(item, list): daughters = item - elif isinstance(item, Token): - # Ignore punctuation tokens such as ARROW. - continue if not mother or not daughters: raise ValueError( @@ -817,6 +839,18 @@ def decay(self, items: list[Any]) -> DecayChainDict: } return {mother: [mode]} + @staticmethod + def _is_ws(item: Any) -> bool: + return isinstance(item, Token) and item.type == "WS_INLINE" + + @staticmethod + def _is_paren_token(item: Any) -> bool: + return isinstance(item, Token) and item.type in {"LPAR", "RPAR"} + + @staticmethod + def _item_text(item: Any) -> str: + return str(item.value) if isinstance(item, Token) else str(item) + class DecayChain: """