Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/hgvs/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,12 +377,30 @@
self.alt = aa_to_aa1(self.alt)
self.aaterm = aa_to_aa1(self.aaterm)

def format(self, conf=None):

Check failure on line 380 in src/hgvs/edit.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaDSCBB143bC2CNewGgn&open=AaDSCBB143bC2CNewGgn&pullRequest=890
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)
Expand Down
2 changes: 2 additions & 0 deletions src/hgvs/parsers/_data/hgvs.pymeta
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions src/hgvs/parsers/generated/hgvs_grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
# --------------------------------------------------
Expand Down Expand Up @@ -2736,9 +2736,33 @@
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():

Check warning on line 2739 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "_G_or_1021" to match the regular expression ^[a-z_][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdo&open=AaEPmVPkduo64IbB_bdo&pullRequest=890
self._trace("", (13115, 13125), self.input.position)
_G_apply_1022, lastError = self._apply(self.rule_aat13_seq, "aat13_seq", [])

Check warning on line 2741 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "_G_apply_1022" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdk&open=AaEPmVPkduo64IbB_bdk&pullRequest=890

Check warning on line 2741 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "lastError" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdn&open=AaEPmVPkduo64IbB_bdn&pullRequest=890
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", [])

Check warning on line 2745 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "_G_apply_1023" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdl&open=AaEPmVPkduo64IbB_bdl&pullRequest=890
self.considerError(lastError, None)
_locals["n"] = _G_apply_1023
_G_python_1024, lastError = eval(self._G_expr_734, self.globals, _locals), None

Check warning on line 2748 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "_G_python_1024" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdm&open=AaEPmVPkduo64IbB_bdm&pullRequest=890
self.considerError(lastError, None)
return (_G_python_1024, self.currentError)
def _G_or_1025():

Check warning on line 2751 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "_G_or_1025" to match the regular expression ^[a-z_][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdt&open=AaEPmVPkduo64IbB_bdt&pullRequest=890
self._trace("", (13159, 13169), self.input.position)
_G_apply_1026, lastError = self._apply(self.rule_aat13_seq, "aat13_seq", [])

Check warning on line 2753 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "_G_apply_1026" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdp&open=AaEPmVPkduo64IbB_bdp&pullRequest=890

Check warning on line 2753 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "lastError" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bds&open=AaEPmVPkduo64IbB_bds&pullRequest=890
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", [])

Check warning on line 2757 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "_G_apply_1027" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdq&open=AaEPmVPkduo64IbB_bdq&pullRequest=890
self.considerError(lastError, None)
_locals["n"] = _G_apply_1027
_G_python_1028, lastError = eval(self._G_expr_734, self.globals, _locals), None

Check warning on line 2760 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "_G_python_1028" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdr&open=AaEPmVPkduo64IbB_bdr&pullRequest=890
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])

Check warning on line 2763 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "lastError" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdj&open=AaEPmVPkduo64IbB_bdj&pullRequest=890

Check warning on line 2763 in src/hgvs/parsers/generated/hgvs_grammar.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this local variable "_G_or_1029" to match the regular expression ^[_a-z][a-z0-9_]*$.

See more on https://sonarcloud.io/project/issues?id=biocommons_hgvs&issues=AaEPmVPkduo64IbB_bdi&open=AaEPmVPkduo64IbB_bdi&pullRequest=890
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}
Expand Down
9 changes: 9 additions & 0 deletions src/hgvs/parsers/pyparsing_grammar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
63 changes: 63 additions & 0 deletions tests/test_hgvs_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import hgvs.edit
import hgvs.location
import hgvs.posedit
from hgvs.enums import Datum
from hgvs.exceptions import HGVSError

Expand Down Expand Up @@ -85,6 +86,68 @@ def test_AAExt(self):
self.assertEqual(str(hgvs.edit.AAExt("A", "V", None, -10).type), "ext")
self.assertEqual(str(hgvs.edit.AAExt("A", None, None, -5).type), "ext")

def test_AAExt_issue804(self):
Comment thread
reece marked this conversation as resolved.
Outdated
# 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).
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),
)
self.assertEqual(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),
)
self.assertEqual(str(c_term), "Ter10LeuextTer2")

# spec-conformant constructions are unchanged
self.assertEqual(str(hgvs.edit.AAExt(ref="", alt=None, aaterm="M", length=-5)), "ext-5")
self.assertEqual(str(hgvs.edit.AAExt(ref="", alt="Q", aaterm="*", length=17)), "GlnextTer17")
self.assertEqual(str(hgvs.edit.AAExt(ref="", alt="R", aaterm="*", length="?")), "ArgextTer?")

def test_AAExt_issue804_roundtrip(self):
# Full-circle: every pdot string the formatter emits must parse back
# to a variant that re-formats identically.
# https://github.com/biocommons/hgvs/issues/804
from hgvs.parsers import Parser

parser = Parser()
# 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"),
]
for pdot, expected in roundtrips:
with self.subTest(pdot=pdot):
var = parser.parse_hgvs_variant("NP_000000.0:p." + pdot)
reformatted = str(var.posedit)
self.assertEqual(reformatted, expected)
# second lap: the normalized form is a fixed point
var2 = parser.parse_hgvs_variant("NP_000000.0:p." + reformatted)
self.assertEqual(str(var2.posedit), expected)

def test_Dup(self):
self.assertEqual(str(hgvs.edit.Dup()), "dup")
self.assertEqual(hgvs.edit.Dup("T").format(conf={"max_ref_length": None}), "dupT")
Expand Down