scigantic-msa 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scigantic_msa/__init__.py +25 -0
- scigantic_msa/a3m.py +366 -0
- scigantic_msa/adapters.py +568 -0
- scigantic_msa/alignment.py +218 -0
- scigantic_msa/cache.py +203 -0
- scigantic_msa/cli.py +227 -0
- scigantic_msa/http.py +184 -0
- scigantic_msa/pairing.py +89 -0
- scigantic_msa/py.typed +0 -0
- scigantic_msa/remote/__init__.py +49 -0
- scigantic_msa/remote/colabfold.py +326 -0
- scigantic_msa/remote/nvidia.py +132 -0
- scigantic_msa/seqkey.py +37 -0
- scigantic_msa/tool.py +227 -0
- scigantic_msa/writers.py +449 -0
- scigantic_msa-0.2.0.dist-info/METADATA +234 -0
- scigantic_msa-0.2.0.dist-info/RECORD +21 -0
- scigantic_msa-0.2.0.dist-info/WHEEL +5 -0
- scigantic_msa-0.2.0.dist-info/entry_points.txt +2 -0
- scigantic_msa-0.2.0.dist-info/licenses/LICENSE +21 -0
- scigantic_msa-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""scigantic-msa: one command from a protein sequence to the MSA input files
|
|
2
|
+
a structure predictor reads.
|
|
3
|
+
|
|
4
|
+
from scigantic_msa import MSA
|
|
5
|
+
from scigantic_msa.adapters import AlphaFold3, Boltz
|
|
6
|
+
|
|
7
|
+
result = MSA().get(sequence, remote=True) # sends the sequence to api.colabfold.com
|
|
8
|
+
af3_input = AlphaFold3.prepare(result) # dict ready for json.dump
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
from .seqkey import InvalidSequence, canonical_sequence, seq_sha256 # noqa: E402
|
|
14
|
+
from .a3m import A3MError, Row, parse_a3m, parse_stockholm # noqa: E402
|
|
15
|
+
from .alignment import Alignment, SOURCES # noqa: E402
|
|
16
|
+
from .remote import ColabFoldClient, NvidiaClient, RemoteError # noqa: E402
|
|
17
|
+
from .tool import MSA, RemoteNotAllowed # noqa: E402
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"MSA", "Alignment", "SOURCES", "RemoteNotAllowed", "RemoteError",
|
|
22
|
+
"seq_sha256", "canonical_sequence", "InvalidSequence",
|
|
23
|
+
"parse_a3m", "parse_stockholm", "Row", "A3MError",
|
|
24
|
+
"ColabFoldClient", "NvidiaClient",
|
|
25
|
+
]
|
scigantic_msa/a3m.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Strict A3M and Stockholm parsing.
|
|
2
|
+
|
|
3
|
+
An alignment here is a list of :class:`Row`. Row 0 is the query: no lowercase,
|
|
4
|
+
no gaps, and its residues equal the canonical chain sequence. Every row has
|
|
5
|
+
the same number of match columns (uppercase letters plus ``-``) as the query
|
|
6
|
+
has residues. Lowercase letters are insertions relative to the query.
|
|
7
|
+
|
|
8
|
+
Rules (from the AlphaFold 3, Boltz and Chai parsers and the ColabFold server
|
|
9
|
+
output):
|
|
10
|
+
|
|
11
|
+
- NUL bytes are removed. ColabFold ends each query block with one.
|
|
12
|
+
- A leading ``#`` line is only accepted as the ColabFold header
|
|
13
|
+
``#<len>\\t<cardinality>``. A header naming several chains is a complex
|
|
14
|
+
file and is refused here (use :func:`parse_colabfold_complex`).
|
|
15
|
+
Other ``#`` lines are refused unless ``skip_comments=True``.
|
|
16
|
+
- Records whose name ends in ``_consensus`` are dropped (UniClust30 files).
|
|
17
|
+
- Wrapped sequence lines are joined and whitespace inside them is removed.
|
|
18
|
+
- Allowed characters in a row: ``A-Z``, ``a-z`` and ``-``.
|
|
19
|
+
- Rows with no match residue (all ``-``) are dropped.
|
|
20
|
+
- A row identical to the query row after the first one (ColabFold repeats the
|
|
21
|
+
query at the start of the env block) is dropped.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Iterable, Iterator, List, Optional, Sequence, Tuple
|
|
28
|
+
|
|
29
|
+
from .seqkey import canonical_sequence
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"A3MError",
|
|
33
|
+
"Row",
|
|
34
|
+
"parse_a3m",
|
|
35
|
+
"parse_stockholm",
|
|
36
|
+
"format_a3m",
|
|
37
|
+
"match_length",
|
|
38
|
+
"ungapped",
|
|
39
|
+
"parse_colabfold_complex",
|
|
40
|
+
"classify_header",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_ALLOWED = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-")
|
|
44
|
+
_CF_HEADER = re.compile(r"^#(\d+(?:,\d+)*)\t(\d+(?:,\d+)*)\s*$")
|
|
45
|
+
_TAXID = re.compile(r"(?:\bTaxID=|\bOX=)(\d+)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class A3MError(ValueError):
|
|
49
|
+
"""The alignment text does not follow the rules above."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Row:
|
|
54
|
+
"""One alignment row.
|
|
55
|
+
|
|
56
|
+
header: text after ``>``, kept verbatim.
|
|
57
|
+
seq: the A3M row (uppercase and ``-`` are match columns, lowercase are insertions).
|
|
58
|
+
db: where the row came from: query, uniref, env, uniref90, mgnify,
|
|
59
|
+
bfd_uniclust, cfdb_uniref30, uniprot, uniclust30 or other.
|
|
60
|
+
taxid: NCBI taxonomy id as a string when known, else None.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
header: str
|
|
64
|
+
seq: str
|
|
65
|
+
db: str = "other"
|
|
66
|
+
taxid: Optional[str] = None
|
|
67
|
+
extra: dict = field(default_factory=dict, compare=False, repr=False)
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict:
|
|
70
|
+
d = {"header": self.header, "seq": self.seq, "db": self.db}
|
|
71
|
+
if self.taxid is not None:
|
|
72
|
+
d["taxid"] = self.taxid
|
|
73
|
+
return d
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def match_length(row: str) -> int:
|
|
77
|
+
return sum(1 for c in row if c == "-" or "A" <= c <= "Z")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ungapped(row: str) -> str:
|
|
81
|
+
"""Residues of a row with gaps removed, uppercased."""
|
|
82
|
+
return "".join(c for c in row if c != "-").upper()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _match_residues(row: str) -> str:
|
|
86
|
+
return "".join(c for c in row if c == "-" or "A" <= c <= "Z")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _taxid_from_header(header: str) -> Optional[str]:
|
|
90
|
+
m = _TAXID.search(header)
|
|
91
|
+
return m.group(1) if m else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _records(text: str, *, skip_comments: bool, what: str) -> Iterator[Tuple[str, str]]:
|
|
95
|
+
name: Optional[str] = None
|
|
96
|
+
parts: List[str] = []
|
|
97
|
+
seen = False
|
|
98
|
+
for lineno, raw in enumerate(text.split("\n"), 1):
|
|
99
|
+
line = raw.rstrip("\r")
|
|
100
|
+
if not line.strip():
|
|
101
|
+
continue
|
|
102
|
+
if line.startswith("#"):
|
|
103
|
+
m = _CF_HEADER.match(line)
|
|
104
|
+
if m and not seen:
|
|
105
|
+
seen = True
|
|
106
|
+
lens = m.group(1).split(",")
|
|
107
|
+
if len(lens) > 1:
|
|
108
|
+
raise A3MError(
|
|
109
|
+
f"{what}: line {lineno} is a ColabFold complex header ({line.strip()!r}); "
|
|
110
|
+
"read it with parse_colabfold_complex"
|
|
111
|
+
)
|
|
112
|
+
yield ("#len", lens[0])
|
|
113
|
+
continue
|
|
114
|
+
seen = True
|
|
115
|
+
if skip_comments:
|
|
116
|
+
continue
|
|
117
|
+
raise A3MError(f"{what}: line {lineno} is a '#' line that is not a ColabFold header: {line[:60]!r}")
|
|
118
|
+
seen = True
|
|
119
|
+
if line.startswith(">"):
|
|
120
|
+
if name is not None:
|
|
121
|
+
yield (name, "".join(parts))
|
|
122
|
+
name = line[1:]
|
|
123
|
+
parts = []
|
|
124
|
+
continue
|
|
125
|
+
if name is None:
|
|
126
|
+
raise A3MError(f"{what}: line {lineno} has sequence data before any '>' header")
|
|
127
|
+
parts.append("".join(line.split()))
|
|
128
|
+
if name is not None:
|
|
129
|
+
yield (name, "".join(parts))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_consensus(header: str) -> bool:
|
|
133
|
+
first = header.lstrip(">").split()
|
|
134
|
+
return bool(first) and first[0].endswith("_consensus")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_a3m(
|
|
138
|
+
text: "str | bytes",
|
|
139
|
+
query: Optional[str] = None,
|
|
140
|
+
*,
|
|
141
|
+
db: str = "auto",
|
|
142
|
+
skip_comments: bool = False,
|
|
143
|
+
what: str = "a3m",
|
|
144
|
+
) -> List[Row]:
|
|
145
|
+
"""Parse and validate an A3M. Returns rows with the query first.
|
|
146
|
+
|
|
147
|
+
``query``, when given, must equal the first record's residues after
|
|
148
|
+
canonicalisation. ``db`` labels the hit rows; ``auto`` reads the label
|
|
149
|
+
from each header (see :func:`classify_header`).
|
|
150
|
+
"""
|
|
151
|
+
if isinstance(text, (bytes, bytearray)):
|
|
152
|
+
try:
|
|
153
|
+
text = bytes(text).decode("utf-8")
|
|
154
|
+
except UnicodeDecodeError as e:
|
|
155
|
+
raise A3MError(f"{what}: not UTF-8 text ({e})") from None
|
|
156
|
+
text = text.replace("\x00", "")
|
|
157
|
+
rows: List[Row] = []
|
|
158
|
+
declared_len: Optional[int] = None
|
|
159
|
+
qrow: Optional[str] = None
|
|
160
|
+
for header, seq in _records(text, skip_comments=skip_comments, what=what):
|
|
161
|
+
if header == "#len":
|
|
162
|
+
declared_len = int(seq)
|
|
163
|
+
continue
|
|
164
|
+
if _is_consensus(header):
|
|
165
|
+
continue
|
|
166
|
+
if not seq:
|
|
167
|
+
raise A3MError(f"{what}: record {header[:40]!r} has no sequence")
|
|
168
|
+
bad = set(seq) - _ALLOWED
|
|
169
|
+
if bad:
|
|
170
|
+
raise A3MError(
|
|
171
|
+
f"{what}: record {header[:40]!r} has characters outside A-Z, a-z and '-': "
|
|
172
|
+
f"{''.join(sorted(bad))!r}"
|
|
173
|
+
)
|
|
174
|
+
if qrow is None:
|
|
175
|
+
if any(c.islower() for c in seq) or "-" in seq:
|
|
176
|
+
raise A3MError(
|
|
177
|
+
f"{what}: the first record {header[:40]!r} has gaps or lowercase letters, "
|
|
178
|
+
"so it is not a usable query row"
|
|
179
|
+
)
|
|
180
|
+
qrow = seq
|
|
181
|
+
if query is not None:
|
|
182
|
+
want = canonical_sequence(query)
|
|
183
|
+
if seq != want:
|
|
184
|
+
raise A3MError(
|
|
185
|
+
f"{what}: the first record does not match the query "
|
|
186
|
+
f"(length {len(seq)} vs {len(want)}; first difference at "
|
|
187
|
+
f"position {_first_diff(seq, want) + 1})"
|
|
188
|
+
)
|
|
189
|
+
if declared_len is not None and declared_len != len(seq):
|
|
190
|
+
raise A3MError(f"{what}: header says length {declared_len} but the query has {len(seq)} residues")
|
|
191
|
+
rows.append(Row(header, seq, "query"))
|
|
192
|
+
continue
|
|
193
|
+
n = match_length(seq)
|
|
194
|
+
if n != len(qrow):
|
|
195
|
+
raise A3MError(
|
|
196
|
+
f"{what}: record {header[:40]!r} has {n} match columns, the query has {len(qrow)}"
|
|
197
|
+
)
|
|
198
|
+
if not any("A" <= c <= "Z" for c in seq):
|
|
199
|
+
continue
|
|
200
|
+
if seq == qrow:
|
|
201
|
+
# ColabFold repeats the query record at the start of the env block.
|
|
202
|
+
if header == rows[0].header or header.split()[0:1] == rows[0].header.split()[0:1]:
|
|
203
|
+
continue
|
|
204
|
+
rdb = classify_header(header) if db == "auto" else db
|
|
205
|
+
rows.append(Row(header, seq, rdb, _taxid_from_header(header)))
|
|
206
|
+
if qrow is None:
|
|
207
|
+
raise A3MError(f"{what}: no records")
|
|
208
|
+
return rows
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def classify_header(header: str) -> str:
|
|
212
|
+
"""Database label for a hit row, from its header."""
|
|
213
|
+
first = header.split(None, 1)[0] if header.strip() else ""
|
|
214
|
+
if first.startswith("UniRef90_"):
|
|
215
|
+
return "uniref90"
|
|
216
|
+
if first.startswith("UniRef"):
|
|
217
|
+
return "uniref"
|
|
218
|
+
if first.startswith(("tr|", "sp|")):
|
|
219
|
+
return "uniprot"
|
|
220
|
+
if first.startswith("MGYP"):
|
|
221
|
+
return "mgnify"
|
|
222
|
+
return "env"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _first_diff(a: str, b: str) -> int:
|
|
226
|
+
for i, (x, y) in enumerate(zip(a, b)):
|
|
227
|
+
if x != y:
|
|
228
|
+
return i
|
|
229
|
+
return min(len(a), len(b))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def format_a3m(rows: Sequence[Row]) -> str:
|
|
233
|
+
return "".join(f">{r.header}\n{r.seq}\n" for r in rows)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# --------------------------------------------------------------------------- Stockholm
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def parse_stockholm(
|
|
240
|
+
lines: Iterable["str | bytes"],
|
|
241
|
+
query: Optional[str] = None,
|
|
242
|
+
*,
|
|
243
|
+
db: str = "auto",
|
|
244
|
+
max_rows: Optional[int] = None,
|
|
245
|
+
what: str = "stockholm",
|
|
246
|
+
) -> List[Row]:
|
|
247
|
+
"""Convert a Stockholm alignment to A3M rows.
|
|
248
|
+
|
|
249
|
+
Columns where the first sequence has a gap become insertions: residues
|
|
250
|
+
there are lowercased and gaps dropped. ``max_rows`` keeps only the first
|
|
251
|
+
that many sequences (query included). Interleaved files (several blocks of
|
|
252
|
+
columns, as jackhmmer writes for wide alignments) are joined by name; a
|
|
253
|
+
file cut short inside a block fails the width or query check instead of
|
|
254
|
+
returning a short alignment.
|
|
255
|
+
"""
|
|
256
|
+
order: List[str] = []
|
|
257
|
+
seqs: dict = {}
|
|
258
|
+
desc: dict = {}
|
|
259
|
+
skipped: set = set()
|
|
260
|
+
started = False
|
|
261
|
+
blocks = 0
|
|
262
|
+
in_block = False
|
|
263
|
+
for raw in lines:
|
|
264
|
+
line = raw.decode("utf-8", "replace") if isinstance(raw, (bytes, bytearray)) else raw
|
|
265
|
+
line = line.rstrip("\r\n")
|
|
266
|
+
if not started:
|
|
267
|
+
if not line.strip():
|
|
268
|
+
continue
|
|
269
|
+
if not line.startswith("# STOCKHOLM"):
|
|
270
|
+
raise A3MError(f"{what}: does not start with '# STOCKHOLM 1.0'")
|
|
271
|
+
started = True
|
|
272
|
+
continue
|
|
273
|
+
if line.startswith("//"):
|
|
274
|
+
break
|
|
275
|
+
if not line.strip():
|
|
276
|
+
in_block = False
|
|
277
|
+
continue
|
|
278
|
+
if line.startswith("#=GS "):
|
|
279
|
+
parts = line[5:].split(None, 2)
|
|
280
|
+
if len(parts) == 3 and parts[1] == "DE":
|
|
281
|
+
desc[parts[0]] = parts[2]
|
|
282
|
+
continue
|
|
283
|
+
if line.startswith("#"):
|
|
284
|
+
continue
|
|
285
|
+
parts = line.split()
|
|
286
|
+
if len(parts) != 2:
|
|
287
|
+
raise A3MError(f"{what}: unexpected line {line[:60]!r}")
|
|
288
|
+
name, aln = parts
|
|
289
|
+
if not in_block:
|
|
290
|
+
in_block = True
|
|
291
|
+
blocks += 1
|
|
292
|
+
if name in skipped:
|
|
293
|
+
continue
|
|
294
|
+
if name in seqs:
|
|
295
|
+
if blocks == 1:
|
|
296
|
+
raise A3MError(f"{what}: sequence name {name!r} appears twice in one block")
|
|
297
|
+
seqs[name].append(aln)
|
|
298
|
+
else:
|
|
299
|
+
if blocks > 1:
|
|
300
|
+
raise A3MError(f"{what}: name {name!r} first appears in block {blocks}")
|
|
301
|
+
if max_rows is not None and len(order) >= max_rows:
|
|
302
|
+
skipped.add(name)
|
|
303
|
+
continue
|
|
304
|
+
order.append(name)
|
|
305
|
+
seqs[name] = [aln]
|
|
306
|
+
if not started:
|
|
307
|
+
raise A3MError(f"{what}: empty file")
|
|
308
|
+
if not order:
|
|
309
|
+
raise A3MError(f"{what}: no sequences")
|
|
310
|
+
aligned = ["".join(seqs[n]) for n in order]
|
|
311
|
+
width = len(aligned[0])
|
|
312
|
+
qaln = aligned[0]
|
|
313
|
+
insert = [c in "-." for c in qaln]
|
|
314
|
+
rows: List[Row] = []
|
|
315
|
+
for name, aln in zip(order, aligned):
|
|
316
|
+
if len(aln) != width:
|
|
317
|
+
raise A3MError(f"{what}: row {name!r} is {len(aln)} columns wide, the first row is {width}")
|
|
318
|
+
out = []
|
|
319
|
+
for c, ins in zip(aln, insert):
|
|
320
|
+
if c in "-.":
|
|
321
|
+
if not ins:
|
|
322
|
+
out.append("-")
|
|
323
|
+
elif ins:
|
|
324
|
+
out.append(c.lower())
|
|
325
|
+
else:
|
|
326
|
+
out.append(c.upper())
|
|
327
|
+
header = name + (" " + desc[name] if name in desc else "")
|
|
328
|
+
rows.append(Row(header, "".join(out)))
|
|
329
|
+
return parse_a3m(format_a3m(rows), query, db=db, what=what)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# --------------------------------------------------------------------------- ColabFold complex
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def parse_colabfold_complex(text: str) -> Tuple[List[int], List[int], List[str], List[Tuple[str, str]]]:
|
|
336
|
+
"""Read a ColabFold complex A3M. Returns (lengths, cardinalities, query
|
|
337
|
+
sequences per chain, records) where records are (header, row) pairs of the
|
|
338
|
+
full concatenated width."""
|
|
339
|
+
text = text.replace("\x00", "")
|
|
340
|
+
lines = [l.rstrip("\r") for l in text.split("\n")]
|
|
341
|
+
while lines and not lines[0].strip():
|
|
342
|
+
lines.pop(0)
|
|
343
|
+
if not lines:
|
|
344
|
+
raise A3MError("complex a3m: empty")
|
|
345
|
+
m = _CF_HEADER.match(lines[0])
|
|
346
|
+
if not m:
|
|
347
|
+
raise A3MError("complex a3m: first line is not '#<lens>\\t<cardinalities>'")
|
|
348
|
+
lens = [int(x) for x in m.group(1).split(",")]
|
|
349
|
+
cards = [int(x) for x in m.group(2).split(",")]
|
|
350
|
+
if len(lens) != len(cards):
|
|
351
|
+
raise A3MError("complex a3m: lengths and cardinalities differ in count")
|
|
352
|
+
records = list(_records("\n".join(lines[1:]), skip_comments=False, what="complex a3m"))
|
|
353
|
+
if not records:
|
|
354
|
+
raise A3MError("complex a3m: no records")
|
|
355
|
+
q = records[0][1]
|
|
356
|
+
if len(q) != sum(lens) or "-" in q:
|
|
357
|
+
raise A3MError("complex a3m: first record is not the concatenated query")
|
|
358
|
+
for h, s in records:
|
|
359
|
+
if match_length(s) != sum(lens):
|
|
360
|
+
raise A3MError(f"complex a3m: record {h[:40]!r} is not {sum(lens)} columns wide")
|
|
361
|
+
queries = []
|
|
362
|
+
pos = 0
|
|
363
|
+
for n in lens:
|
|
364
|
+
queries.append(q[pos : pos + n])
|
|
365
|
+
pos += n
|
|
366
|
+
return lens, cards, queries, records
|