diff --git a/src/hgvs/edit.py b/src/hgvs/edit.py index 1603d5d1..07637da8 100644 --- a/src/hgvs/edit.py +++ b/src/hgvs/edit.py @@ -381,8 +381,26 @@ def format(self, conf=None): p_3_letter, p_term_asterisk, p_init_met = self._format_config_aa(conf) st_alt = self.alt or "" - st_aaterm = self.aaterm or "" st_length = self.length or "" + + # HGVS nomenclature spec 21.1.3 (protein extension): + # * N-terminal extensions are described as "Met1ext-5": aaterm is not + # part of the syntax (this supersedes the legacy "Met1extMet-5" + # form), so it is never embedded in the description. + # * C-terminal extensions are described as "Ter110GlnextTer17", i.e. + # {aa replacing the stop codon}extTer{extension length}. A + # multi-residue aaterm (a tail sequence such as "LG*") is therefore + # reduced to its first residue (the aa replacing the stop codon, + # used when alt is unset) and its last residue (the new terminator). + if isinstance(self.length, int) and self.length < 0: + st_aaterm = "" + else: + st_aaterm = self.aaterm or "" + if len(st_aaterm) > 1: + if not st_alt: + st_alt = st_aaterm[0] + st_aaterm = st_aaterm[-1] + if p_3_letter: st_alt = aa1_to_aa3(st_alt) st_aaterm = aa1_to_aa3(st_aaterm) diff --git a/src/hgvs/parsers/_data/hgvs.pymeta b/src/hgvs/parsers/_data/hgvs.pymeta index 712762a0..b375caca 100644 --- a/src/hgvs/parsers/_data/hgvs.pymeta +++ b/src/hgvs/parsers/_data/hgvs.pymeta @@ -185,6 +185,8 @@ ext = 'ext' (aa13_ext | -> (None, None)):(aat, n) -> (aat, n) aa13_fs = term13 fsext_offset:n -> n aa13_ext = term13:aat fsext_offset:n -> (aat, n) | (aa13 | (-> None)):aat nnum:n -> (aat, n) + | aat13_seq:aat nnum:n -> (aat, n) + | aat13_seq:aat num:n -> (aat, n) fsext_offset = num|'?'| (-> None) # sequences of NA/AA diff --git a/src/hgvs/parsers/generated/hgvs_grammar.py b/src/hgvs/parsers/generated/hgvs_grammar.py index 8d20e6ca..2a0c325e 100644 --- a/src/hgvs/parsers/generated/hgvs_grammar.py +++ b/src/hgvs/parsers/generated/hgvs_grammar.py @@ -3,7 +3,7 @@ # Changes will be overwritten by the generation script. # Generated by: sbin/generate_parser.py # Grammar file: src/hgvs/parsers/_data/hgvs.pymeta -# Grammar hash: 11afb0372e45be6fc32a62aea732b3e5 +# Grammar hash: 0840e087f3a22a5e1a426b378a799992 # Parsley version: 1.3 # Python version: 3.13.14 (main, Aug 5 2026, 15:44:26) [Clang 22.1.3 ] # -------------------------------------------------- @@ -2736,9 +2736,33 @@ def _G_or_746(): self.considerError(lastError, None) return (_G_python_750, self.currentError) - _G_or_751, lastError = self._or([_G_or_739, _G_or_743]) + def _G_or_1021(): + self._trace("", (13115, 13125), self.input.position) + _G_apply_1022, lastError = self._apply(self.rule_aat13_seq, "aat13_seq", []) + self.considerError(lastError, None) + _locals["aat"] = _G_apply_1022 + self._trace("", (13129, 13134), self.input.position) + _G_apply_1023, lastError = self._apply(self.rule_nnum, "nnum", []) + self.considerError(lastError, None) + _locals["n"] = _G_apply_1023 + _G_python_1024, lastError = eval(self._G_expr_734, self.globals, _locals), None + self.considerError(lastError, None) + return (_G_python_1024, self.currentError) + def _G_or_1025(): + self._trace("", (13159, 13169), self.input.position) + _G_apply_1026, lastError = self._apply(self.rule_aat13_seq, "aat13_seq", []) + self.considerError(lastError, None) + _locals["aat"] = _G_apply_1026 + self._trace("", (13173, 13177), self.input.position) + _G_apply_1027, lastError = self._apply(self.rule_num, "num", []) + self.considerError(lastError, None) + _locals["n"] = _G_apply_1027 + _G_python_1028, lastError = eval(self._G_expr_734, self.globals, _locals), None + self.considerError(lastError, None) + return (_G_python_1028, self.currentError) + _G_or_1029, lastError = self._or([_G_or_739, _G_or_743, _G_or_1021, _G_or_1025]) self.considerError(lastError, "aa13_ext") - return (_G_or_751, self.currentError) + return (_G_or_1029, self.currentError) def rule_fsext_offset(self): _locals = {"self": self} diff --git a/src/hgvs/parsers/pyparsing_grammar.py b/src/hgvs/parsers/pyparsing_grammar.py index 0686b3ef..f636439f 100644 --- a/src/hgvs/parsers/pyparsing_grammar.py +++ b/src/hgvs/parsers/pyparsing_grammar.py @@ -190,6 +190,15 @@ def _build_basic_types(self): lambda t: (t[0], t[1]) ) | ( (self.aa13 | pp.Empty().add_parse_action(pp.replace_with(None))) + self.nnum + ).add_parse_action(lambda t: (t[0], t[1])) | ( + # legacy multi-residue aaterm tails (as emitted by older + # formatters), e.g. "extLeuGlyMet-2" or "extLG*17"; reduced to + # spec slots on format, mirroring AAExt.format(). The C-terminal + # form requires an explicit numeric length, as the tail is + # meaningless without one (bare "extGlyTrp" remains invalid). + self.aat13_seq + self.nnum + ).add_parse_action(lambda t: (t[0], t[1])) | ( + self.aat13_seq + self.num ).add_parse_action(lambda t: (t[0], t[1])) self.ext = ( diff --git a/tests/issues/test_804.py b/tests/issues/test_804.py new file mode 100644 index 00000000..b3b94519 --- /dev/null +++ b/tests/issues/test_804.py @@ -0,0 +1,70 @@ +"""https://github.com/biocommons/hgvs/issues/804 + +HGVS spec 21.1.3: an N-terminal extension is "Met1ext-5" -- aaterm must not +be embedded in the description (it supersedes the legacy "Met1extMet-5" form). +""" + +import pytest + +import hgvs.edit +import hgvs.location +import hgvs.posedit +from hgvs.parsers import Parser + + +def test_aaext_issue804(): + n_term = hgvs.posedit.PosEdit( + pos=hgvs.location.Interval( + start=hgvs.location.AAPosition(base=1, aa="M"), + end=hgvs.location.AAPosition(base=1, aa="M"), + ), + edit=hgvs.edit.AAExt(ref="M", aaterm="LGM", length=-2), + ) + assert str(n_term) == "Met1ext-2" + + # A C-terminal extension is "Ter110GlnextTer17": the aa replacing the + # stop codon, then extTer, then the extension length. A multi-residue + # aaterm tail sequence must be reduced to those two slots. + c_term = hgvs.posedit.PosEdit( + pos=hgvs.location.Interval( + start=hgvs.location.AAPosition(base=10, aa="*"), + end=hgvs.location.AAPosition(base=10, aa="*"), + ), + edit=hgvs.edit.AAExt(ref="*", aaterm="LG*", length=2), + ) + assert str(c_term) == "Ter10LeuextTer2" + + # spec-conformant constructions are unchanged + assert str(hgvs.edit.AAExt(ref="", alt=None, aaterm="M", length=-5)) == "ext-5" + assert str(hgvs.edit.AAExt(ref="", alt="Q", aaterm="*", length=17)) == "GlnextTer17" + assert str(hgvs.edit.AAExt(ref="", alt="R", aaterm="*", length="?")) == "ArgextTer?" + + +# formatted pdot -> expected re-formatted pdot (identity when already +# spec-conformant; legacy forms normalize to the spec 21.1.3 shape) +_roundtrips = [ + ("Met1ext-2", "Met1ext-2"), + ("Ter10LeuextTer2", "Ter10LeuextTer2"), + ("Ter110GlnextTer17", "Ter110GlnextTer17"), + ("Ter10extTer2", "Ter10extTer2"), + # legacy single-residue N-terminal form: aaterm is dropped + ("Met1extMet-5", "Met1ext-5"), + # legacy multi-residue tails (as older formatters emitted): + # reduced to the spec slots on format + ("Met1extLeuGlyMet-2", "Met1ext-2"), + ("Ter10GlnextLG*17", "Ter10GlnextTer17"), + ("Ter10extLG*17", "Ter10LeuextTer17"), +] + + +@pytest.mark.parametrize("pdot,expected", _roundtrips) +def test_aaext_issue804_roundtrip(pdot, expected): + # Full-circle: every pdot string the formatter emits must parse back + # to a variant that re-formats identically. + parser = Parser() + var = parser.parse_hgvs_variant("NP_000000.0:p." + pdot) + reformatted = str(var.posedit) + assert reformatted == expected + # second lap: the normalized form is a fixed point + var2 = parser.parse_hgvs_variant("NP_000000.0:p." + reformatted) + assert str(var2.posedit) == expected