Skip to content

Commit 0b03796

Browse files
committed
Fix UniProt DE parsing and model metadata fields
1 parent 3e30e3a commit 0b03796

3 files changed

Lines changed: 232 additions & 5 deletions

File tree

tests/test_uniprot_metadata.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
from __future__ import annotations
2+
3+
from uniprot.scripts.extract_subset import parse_de_sections, parse_organism
4+
from uniprot.scripts.export_model_metadata import (
5+
ManifestRow,
6+
ModelMetadataRow,
7+
build_record,
8+
)
9+
10+
11+
def test_parse_de_sections_preserves_square_brackets_in_names() -> None:
12+
lines = [
13+
"DE RecName: Full=Acyl-[acyl-carrier-protein]--UDP-N-acetylglucosamine O-acyltransferase;",
14+
]
15+
16+
full_names, short_names = parse_de_sections(lines)
17+
18+
assert full_names == [
19+
"Acyl-[acyl-carrier-protein]--UDP-N-acetylglucosamine O-acyltransferase"
20+
]
21+
assert short_names == []
22+
23+
24+
def test_parse_de_sections_preserves_semicolons_inside_names() -> None:
25+
lines = [
26+
"DE RecName: Full=nitrite reductase (cytochrome; ammonia-forming) {ECO:0000256|ARBA:ARBA00011887};",
27+
]
28+
29+
full_names, short_names = parse_de_sections(lines)
30+
31+
assert full_names == ["nitrite reductase (cytochrome; ammonia-forming)"]
32+
assert short_names == []
33+
34+
35+
def test_parse_de_sections_uses_submitted_names_as_full_name_fallback() -> None:
36+
lines = [
37+
"DE SubName: Full=Methyl-coenzyme M reductase alpha {ECO:0000313|EMBL:ABW73421.1};",
38+
"DE Flags: Fragment;",
39+
]
40+
41+
full_names, short_names = parse_de_sections(lines)
42+
43+
assert full_names == ["Methyl-coenzyme M reductase alpha"]
44+
assert short_names == []
45+
46+
47+
def test_parse_de_sections_keeps_recommended_names_before_submitted_names() -> None:
48+
lines = [
49+
"DE RecName: Full=Curated recommended name;",
50+
"DE SubName: Full=Submitted fallback name {ECO:0000313|EMBL:ABC123.1};",
51+
]
52+
53+
full_names, short_names = parse_de_sections(lines)
54+
55+
assert full_names == ["Curated recommended name", "Submitted fallback name"]
56+
assert short_names == []
57+
58+
59+
def test_parse_organism_preserves_parenthetical_text_in_scientific_name() -> None:
60+
organism, common_names, synonyms = parse_organism("Escherichia coli (strain K12).")
61+
62+
assert organism == "Escherichia coli (strain K12)"
63+
assert common_names == ["strain K12"]
64+
assert synonyms == []
65+
66+
67+
def test_build_record_adds_model_level_complex_fields() -> None:
68+
config = {
69+
"latestVersion": 1,
70+
"allVersions": [1],
71+
"providerId": "AF-TEST",
72+
"entityType": "protein",
73+
}
74+
manifest_rows = [
75+
ManifestRow(
76+
model_entity_id="AF-0000000000000001",
77+
entity_id="1",
78+
chain_id="A",
79+
uniprot_ac="P11111",
80+
sequence_start=None,
81+
sequence_end=None,
82+
is_fragment=False,
83+
is_isoform=False,
84+
entity_type="protein",
85+
average_plddt=90.0,
86+
fraction_plddt_very_low=0.0,
87+
fraction_plddt_low=0.0,
88+
fraction_plddt_confident=0.2,
89+
fraction_plddt_very_high=0.8,
90+
),
91+
ManifestRow(
92+
model_entity_id="AF-0000000000000001",
93+
entity_id="2",
94+
chain_id="B",
95+
uniprot_ac="Q22222",
96+
sequence_start=None,
97+
sequence_end=None,
98+
is_fragment=False,
99+
is_isoform=False,
100+
entity_type="protein",
101+
average_plddt=88.0,
102+
fraction_plddt_very_low=0.0,
103+
fraction_plddt_low=0.1,
104+
fraction_plddt_confident=0.2,
105+
fraction_plddt_very_high=0.7,
106+
),
107+
]
108+
entry_map = {
109+
"P11111": {
110+
"primary_ac": "P11111",
111+
"entry_name": "PROT1_TEST",
112+
"protein_full_names": ["Protein one"],
113+
"protein_short_names": None,
114+
"gene_names": "GENE1",
115+
"gene_synonyms": None,
116+
"gene_ordered_locus_names": None,
117+
"gene_orf_names": None,
118+
"organism": "Organismus exampleus",
119+
"organism_common_names": None,
120+
"organism_synonyms": None,
121+
"taxid": 1111,
122+
"sequence_version_date": "2024-01-01",
123+
"sequence": "ACDE",
124+
"is_uniprot_reference_proteome": True,
125+
"reviewed": True,
126+
},
127+
"Q22222": {
128+
"primary_ac": "Q22222",
129+
"entry_name": "PROT2_TEST",
130+
"protein_full_names": ["Protein two"],
131+
"protein_short_names": None,
132+
"gene_names": "GENE2",
133+
"gene_synonyms": None,
134+
"gene_ordered_locus_names": None,
135+
"gene_orf_names": None,
136+
"organism": "Organismus exampleus",
137+
"organism_common_names": None,
138+
"organism_synonyms": None,
139+
"taxid": 1111,
140+
"sequence_version_date": "2024-01-01",
141+
"sequence": "FGHI",
142+
"is_uniprot_reference_proteome": True,
143+
"reviewed": True,
144+
},
145+
}
146+
model_metadata = {
147+
"AF-0000000000000001": ModelMetadataRow(
148+
iptm=0.91,
149+
average_plddt=89.3,
150+
complex_name=None,
151+
is_am_data=False,
152+
)
153+
}
154+
155+
record = build_record(
156+
"AF-0000000000000001",
157+
config,
158+
manifest_rows,
159+
entry_map,
160+
model_metadata,
161+
)
162+
163+
assert record["assemblyType"] == "Hetero"
164+
assert record["oligomericState"] == "dimer"
165+
assert record["oligomericStateDescription"] == "Heterodimer"
166+
assert record["complexComposition"] == ["P11111_1", "Q22222_1"]

uniprot/scripts/export_model_metadata.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,15 @@ def derive_oligomeric_state(subunit_count: int) -> str:
421421
return OLIGOMERIC_STATE_MAP.get(subunit_count, "oligomer")
422422

423423

424+
def derive_oligomeric_state_description(
425+
assembly_type: Optional[str],
426+
oligomeric_state: Optional[str],
427+
) -> Optional[str]:
428+
if not assembly_type or not oligomeric_state:
429+
return None
430+
return f"{assembly_type}{oligomeric_state}"
431+
432+
424433
def aggregate_metric(rows: Sequence[ManifestRow], attr: str) -> Optional[float]:
425434
values = [getattr(row, attr) for row in rows if getattr(row, attr) is not None]
426435
if not values:
@@ -617,13 +626,23 @@ def build_record(
617626
)
618627

619628
is_complex = len(manifest_rows) > 1
629+
complex_composition = (
630+
[f"{component.accession}_{count}" for component, count in zip(components, component_counts)]
631+
if is_complex
632+
else None
633+
)
620634

621635
assembly_type = None
622636
oligomeric_state = None
637+
oligomeric_state_description = None
623638
if is_complex:
624639
unique_accessions = {row.uniprot_ac for row in manifest_rows if row.uniprot_ac}
625640
assembly_type = "Hetero" if len(unique_accessions) > 1 else "Homo"
626641
oligomeric_state = derive_oligomeric_state(len(manifest_rows))
642+
oligomeric_state_description = derive_oligomeric_state_description(
643+
assembly_type,
644+
oligomeric_state,
645+
)
627646

628647
model_meta = model_metadata.get(model_entity_id)
629648
iptm_value = model_meta.iptm if (is_complex and model_meta) else None
@@ -680,6 +699,10 @@ def consistent_or_none(values: Sequence[Optional[int]]) -> Optional[int]:
680699
if is_complex and assembly_type and oligomeric_state:
681700
record["assemblyType"] = assembly_type
682701
record["oligomericState"] = oligomeric_state
702+
if complex_composition:
703+
record["complexComposition"] = complex_composition
704+
if oligomeric_state_description:
705+
record["oligomericStateDescription"] = oligomeric_state_description
683706
record["uniprotAccession"] = accessions
684707
record["uniprotDescription"] = descriptions
685708
record["isUniProtReferenceProteome"] = is_uniprot_reference_proteome

uniprot/scripts/extract_subset.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,50 @@ def extract_accessions(lines: Sequence[str]) -> List[str]:
175175

176176
def strip_annotations(value: str) -> str:
177177
cleaned = value.strip()
178-
cleaned = re.split(r"\s*[\{\[]", cleaned, 1)[0].strip()
178+
while True:
179+
updated = re.sub(r"\s*\{[^{}]*\}\s*$", "", cleaned).strip()
180+
if updated == cleaned:
181+
break
182+
cleaned = updated
179183
return cleaned.rstrip(";").strip()
180184

181185

186+
def iter_de_fields(content: str) -> Iterable[tuple[str, str]]:
187+
"""Yield DE name fields without splitting on semicolons inside names."""
188+
189+
field_matcher = re.compile(r"(Full|Short|Allergen|Biotech|CD_antigen|INN)=")
190+
position = 0
191+
while True:
192+
match = field_matcher.search(content, position)
193+
if not match:
194+
break
195+
kind = match.group(1)
196+
start = match.end()
197+
paren_depth = 0
198+
square_depth = 0
199+
brace_depth = 0
200+
end = start
201+
while end < len(content):
202+
char = content[end]
203+
if char == "(":
204+
paren_depth += 1
205+
elif char == ")" and paren_depth:
206+
paren_depth -= 1
207+
elif char == "[":
208+
square_depth += 1
209+
elif char == "]" and square_depth:
210+
square_depth -= 1
211+
elif char == "{":
212+
brace_depth += 1
213+
elif char == "}" and brace_depth:
214+
brace_depth -= 1
215+
elif char == ";" and paren_depth == 0 and square_depth == 0 and brace_depth == 0:
216+
break
217+
end += 1
218+
yield kind, content[start:end]
219+
position = end + 1
220+
221+
182222
def parse_alt_products(lines: Sequence[str]) -> Dict[str, List[str]]:
183223
"""
184224
Extract isoform definitions from the CC ALTERNATIVE PRODUCTS block.
@@ -390,9 +430,7 @@ def unique(values: Sequence[str]) -> List[str]:
390430
if other_match:
391431
current_section = None
392432
content = other_match.group(1).strip()
393-
for match in RE_DE_FIELD.finditer(content):
394-
kind = match.group(1)
395-
raw_val = match.group(2)
433+
for kind, raw_val in iter_de_fields(content):
396434
value = strip_annotations(raw_val)
397435
if not value:
398436
continue
@@ -411,7 +449,7 @@ def parse_organism(raw: str) -> tuple[Optional[str], List[str], List[str]]:
411449
return None, [], []
412450
value = value.rstrip(".")
413451
paren_values = [match.strip().rstrip(".") for match in re.findall(r"\(([^)]+)\)", value)]
414-
main = value.split("(", 1)[0].strip() if "(" in value else value.strip()
452+
main = value.strip()
415453
if paren_values:
416454
common_names = [paren_values[0]] if paren_values[0] else []
417455
synonyms = [name for name in paren_values[1:] if name]

0 commit comments

Comments
 (0)