Spaces:
Paused
Paused
File size: 7,493 Bytes
ff34739 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | """Parse the scg_lib_structs ``10xChromium3.html`` into raw sequences.
Extraction only — no interpretation/assembly (that is ``builder.py``). We read the tagged
oligo sequences from the "Adapter and primer sequences" section (selecting the v3+ variant where
the page shows v2-vs-v3+ alternatives), the verbatim v3/v3.1/v4 final-library strand lines, and a
few sequencing hints. Placeholder text becomes tokens: ``[16-bp cell barcode]`` -> ``[CELL_BARCODE:16]``.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
from bs4 import BeautifulSoup
__all__ = ["ParsedProtocol", "parse_protocol"]
# (key, label prefix in the <p>, layout: "inline" | "pre", kind)
_OLIGO_SPECS: list[tuple[str, str, str, str]] = [
("beads_oligo_dt", "Beads-oligo-dT", "pre", "assembled"),
("tso", "Template Switching Oligo", "inline", "assembled"),
("cdna_forward_primer", "cDNA Forward primer", "inline", "single"),
("cdna_reverse_primer", "cDNA Reverse primer", "pre", "single"),
("truseq_read1_primer", "Illumina TruSeq Read 1 primer", "inline", "assembled"),
("truseq_read2_primer", "Illumina TruSeq Read 2 primer", "inline", "single"),
("truseq_adapter", "Truseq adapter", "pre", "double_stranded"),
("library_pcr_primer_1", "Library PCR primer 1", "inline", "assembled"),
("library_pcr_primer_2", "Library PCR primer 2", "inline", "assembled"),
("sample_index_seq_primer", "Sample index sequencing primer", "inline", "single"),
("p5", "Illumina P5 adapter", "inline", "single"),
("p7", "Illumina P7 adapter", "inline", "single"),
]
_VARIANT_MARKER = "V3" # select the "V3, V3.1, V4" line in variant <pre> blocks
@dataclass
class ParsedProtocol:
source_path: str
source_html_sha256: str
oligos: dict[str, object] = field(default_factory=dict) # key -> str | {"fwd","rev"}
oligo_kinds: dict[str, str] = field(default_factory=dict)
final_library: dict[str, str] = field(default_factory=dict)
sequencing: dict[str, int] = field(default_factory=dict)
library_generation: list = field(default_factory=list)
def _clean_seq(fragment: str) -> str:
"""HTML sequence fragment -> clean sequence-with-tokens (uppercase ACGTN + [TOKEN:n] + VN)."""
s = fragment
# (T)<sub>30</sub> -> 30x T ; (A)<sub>30</sub> -> 30x A (last base char is the repeat unit)
s = re.sub(r"\((d?[ACGT])\)\s*<sub>(\d+)</sub>", lambda m: m.group(1)[-1] * int(m.group(2)), s)
# placeholder text -> tokens
s = re.sub(r"\[(\d+)-bp cell barcode\]", r"[CELL_BARCODE:\1]", s)
s = re.sub(r"\[(\d+)-bp UMI\]", r"[UMI:\1]", s)
s = re.sub(r"\[(\d+)-bp sample index\]", r"[SAMPLE_INDEX:\1]", s)
# ribo-G notation (TSO): rGrGrG -> GGG
s = s.replace("rG", "G")
# drop remaining HTML tags
s = re.sub(r"<[^>]+>", "", s)
# drop strand markers, arrows, asterisks, ellipses, and all whitespace
s = re.sub(r"5'-|-3'|3'-|-5'|\|--|-+>|<-+|\.\.\.|\*|\s+", "", s)
return s
def _inline_seq(p_tag) -> str:
frag = p_tag.decode_contents()
idx = frag.find("5'-")
if idx < 0:
raise ValueError(f"no 5'- marker in inline oligo <p>: {frag[:80]!r}")
return _clean_seq(frag[idx:])
def _pre_lines(pre_tag) -> list[str]:
return [ln for ln in pre_tag.decode_contents().splitlines() if ln.strip()]
def _variant_line_index(lines: list[str]) -> int:
for i, ln in enumerate(lines):
if _VARIANT_MARKER in ln and "5'-" in ln:
return i
raise ValueError(f"no {_VARIANT_MARKER!r} variant line found in <pre>")
def _find_labeled_p(soup, label_prefix: str):
for p in soup.find_all("p"):
if p.get_text().strip().startswith(label_prefix):
return p
raise ValueError(f"no <p> starting with {label_prefix!r}")
def _parse_oligos(soup) -> tuple[dict[str, object], dict[str, str]]:
oligos: dict[str, object] = {}
kinds: dict[str, str] = {}
for key, label, layout, kind in _OLIGO_SPECS:
p = _find_labeled_p(soup, label)
kinds[key] = kind
if layout == "inline":
oligos[key] = _inline_seq(p)
else: # "pre"
pre = p.find_next_sibling("pre")
if pre is None:
raise ValueError(f"no <pre> after label {label!r}")
lines = _pre_lines(pre)
i = _variant_line_index(lines)
fwd_line = lines[i]
fwd = _clean_seq(fwd_line[fwd_line.find("5'-"):])
if kind == "double_stranded":
rev_line = lines[i + 1]
rev = _clean_seq(rev_line[rev_line.find("3'-"):])
oligos[key] = {"fwd": fwd, "rev": rev}
else:
oligos[key] = fwd
return oligos, kinds
def _parse_final_library(soup) -> dict[str, str]:
for h4 in soup.find_all("h4"):
if "V3, V3.1 & V4" in h4.get_text():
pre = h4.find_next_sibling("pre")
if pre is None:
raise ValueError("no <pre> after V3/V3.1/V4 library heading")
lines = _pre_lines(pre)
top = next(ln for ln in lines if "5'-" in ln and "-3'" in ln)
bot = next(ln for ln in lines if "3'-" in ln and "-5'" in ln)
strip_tags = lambda ln: re.sub(r"<[^>]+>", "", ln).strip()
return {
"source_label": "V3, V3.1 & V4 final library structure",
"strand_5to3_html": top.strip(),
"strand_3to5_html": bot.strip(),
"strand_5to3_text": strip_tags(top),
"strand_3to5_text": strip_tags(bot),
}
raise ValueError("V3/V3.1/V4 final library section not found")
def _parse_library_generation(soup) -> list[dict]:
"""The ordered 'Step-by-step library generation' section: one entry per numbered <h3> step."""
h2 = next((h for h in soup.find_all("h2") if "step-by-step" in h.get_text().lower()), None)
if h2 is None:
return []
steps: list[dict] = []
for el in h2.find_all_next():
if el.name == "h2":
break # reached the next section (Library sequencing)
if el.name == "h3":
title = re.sub(r"\s+", " ", el.get_text()).strip().rstrip(":")
m = re.match(r"\((\d+)\)\s*(.*)", title)
if m:
steps.append({"step": int(m.group(1)), "title": m.group(2).strip(), "note": None})
else:
steps.append({"step": len(steps) + 1, "title": title, "note": None})
return steps
def _parse_sequencing(soup) -> dict[str, int]:
text = soup.get_text()
out: dict[str, int] = {}
m = re.search(r"(\d+)\s*cycles for V3", text)
if m:
out["R1_cycles"] = int(m.group(1))
m = re.search(r"sequence cDNA,\s*(\d+)\s*cycles", text)
if m:
out["R2_cycles_html"] = int(m.group(1))
m = re.search(r"\[(\d+)-bp sample index\]", text)
if m:
out["i7_length"] = int(m.group(1))
return out
def parse_protocol(html_path: str | Path) -> ParsedProtocol:
raw = Path(html_path).read_bytes()
soup = BeautifulSoup(raw.decode("utf-8"), "lxml")
oligos, kinds = _parse_oligos(soup)
return ParsedProtocol(
source_path=str(html_path),
source_html_sha256=hashlib.sha256(raw).hexdigest(),
oligos=oligos,
oligo_kinds=kinds,
final_library=_parse_final_library(soup),
sequencing=_parse_sequencing(soup),
library_generation=_parse_library_generation(soup),
)
|