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.
@@ -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