Spaces:
Runtime error
Runtime error
| """Paste-anything target resolution (Phase 3, M2). | |
| One input box, three kinds of input — auto-detected and resolved to an | |
| editable DNA sequence (plus a human-readable label and, where known, a | |
| gene symbol that feeds base-edit AA consequences + the structure viewer): | |
| * raw DNA / FASTA → used as-is (never leaves the Space). | |
| * gene symbol → Ensembl canonical-transcript CDS (human / mouse), | |
| via the existing exon.fetch_gene_structure(). | |
| * accession → Ensembl transcript/gene ID (ENST…/ENSG…) or RefSeq | |
| (NM_/XM_/NR_/XR_) fetched from Ensembl / NCBI. | |
| Privacy: for symbol/accession lookups, ONLY the (organism, identifier) | |
| leaves the Space — never the user's pasted sequence. The raw-sequence | |
| path makes no network calls at all. | |
| Classification is conservative: a long, DNA-looking blob is always a | |
| sequence; a short token that matches an ID pattern is an accession; an | |
| alphanumeric token is a gene symbol (needs an organism). Ambiguous short | |
| tokens fall through to a symbol lookup, which fails gracefully. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| import urllib.parse | |
| from typing import Dict, Tuple | |
| from dee.core import exon as _exon | |
| # NCBI taxonomy IDs for the UniProt structure lookup (M5). | |
| _UNIPROT_TAXID = {"human": "9606", "mouse": "10090"} | |
| # ─── Identifier patterns ───────────────────────────────────────────── | |
| _ENSEMBL_TX = re.compile(r"^ENS[A-Z]*T\d{6,}(?:\.\d+)?$", re.I) # ENST…, ENSMUST… | |
| _ENSEMBL_GENE = re.compile(r"^ENS[A-Z]*G\d{6,}(?:\.\d+)?$", re.I) # ENSG…, ENSMUSG… | |
| _REFSEQ = re.compile(r"^[NX][MR]_\d+(?:\.\d+)?$", re.I) # NM_/NR_/XM_/XR_ | |
| _SYMBOL = re.compile(r"^[A-Za-z][A-Za-z0-9._-]{0,19}$") # TP53, BRCA1, … | |
| _NCBI_EFETCH = ("https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi" | |
| "?db=nuccore&rettype=fasta&retmode=text&id=") | |
| MIN_TARGET_LEN = 23 # shortest usable target (Cas12a spacer+PAM) | |
| MAX_TARGET_LEN = 1_000_000 | |
| def _clean_dna(text: str) -> str: | |
| """Strip FASTA headers + whitespace + non-ACGTN, uppercase.""" | |
| lines = [ln for ln in text.splitlines() if not ln.strip().startswith(">")] | |
| return re.sub(r"[^ACGTNacgtn]", "", "".join(lines)).upper() | |
| def classify(text: str) -> Tuple[str, str]: | |
| """Return (kind, value). kind ∈ {empty, sequence, symbol, ensembl_tx, | |
| ensembl_gene, refseq, unknown}. `value` is the cleaned sequence (for | |
| 'sequence') or the identifier token otherwise.""" | |
| t = (text or "").strip() | |
| if not t: | |
| return ("empty", "") | |
| if t.lstrip().startswith(">"): | |
| return ("sequence", _clean_dna(t)) | |
| cleaned = _clean_dna(t) | |
| letters = re.sub(r"[^A-Za-z]", "", re.sub(r"\s", "", t)) | |
| dna_ratio = len(cleaned) / max(1, len(letters)) | |
| has_ws = bool(re.search(r"\s", t)) | |
| # A long, DNA-dominant blob (or any multi-line / spaced DNA) is a sequence. | |
| if len(cleaned) >= MIN_TARGET_LEN and dna_ratio >= 0.9 and (has_ws or len(cleaned) > 20): | |
| return ("sequence", cleaned) | |
| token = t.split()[0] if t.split() else "" | |
| if _ENSEMBL_TX.match(token): | |
| return ("ensembl_tx", token) | |
| if _ENSEMBL_GENE.match(token): | |
| return ("ensembl_gene", token) | |
| if _REFSEQ.match(token): | |
| return ("refseq", token.upper()) | |
| if _SYMBOL.match(token): | |
| return ("symbol", token.upper()) | |
| if len(cleaned) >= MIN_TARGET_LEN and dna_ratio >= 0.9: | |
| return ("sequence", cleaned) | |
| return ("unknown", token) | |
| def _err(msg: str) -> Dict: | |
| return {"ok": False, "error": msg, "kind": "", "sequence": "", | |
| "gene_symbol": "", "label": "", "source": ""} | |
| def _ok(kind: str, sequence: str, label: str, source: str, | |
| gene_symbol: str = "") -> Dict: | |
| return {"ok": True, "kind": kind, "sequence": sequence, | |
| "gene_symbol": gene_symbol, "label": label, "source": source} | |
| def _fetch_ensembl_cds(identifier: str, is_gene: bool) -> Tuple[str, str]: | |
| """Fetch a CDS sequence for an Ensembl transcript or gene ID. | |
| Returns (sequence, label) or ("", "").""" | |
| tx_id = identifier | |
| if is_gene: | |
| # Resolve gene → canonical transcript first. | |
| info = _exon._http_get_json( | |
| f"{_exon.ENSEMBL_BASE}/lookup/id/{identifier}?expand=1") | |
| if not info: | |
| return ("", "") | |
| transcripts = info.get("Transcript", []) or [] | |
| if not transcripts: | |
| return ("", "") | |
| canonical = next((t for t in transcripts if t.get("is_canonical") == 1), | |
| transcripts[0]) | |
| tx_id = canonical.get("id") or "" | |
| if not tx_id: | |
| return ("", "") | |
| fasta = _exon._http_get_text( | |
| f"{_exon.ENSEMBL_BASE}/sequence/id/{tx_id}?type=cds") | |
| seq = _exon._fasta_to_seq(fasta) if fasta else "" | |
| if not seq: | |
| return ("", "") | |
| label = (f"{identifier} → {tx_id} · CDS {len(seq):,} nt" | |
| if is_gene else f"{tx_id} · CDS {len(seq):,} nt") | |
| return (seq, label) | |
| def _fetch_refseq(accession: str) -> str: | |
| fasta = _exon._http_get_text(_NCBI_EFETCH + accession) | |
| return _exon._fasta_to_seq(fasta) if fasta else "" | |
| def resolve_uniprot(organism: str, gene_symbol: str) -> Dict: | |
| """Resolve a gene symbol (+ organism) to a UniProt accession and the | |
| AlphaFold-DB structure URLs, for the structure viewer (Phase 3, M5). | |
| Returns {ok, uniprot, alphafold_url, alphafold_page, error?}. Only the | |
| (organism, gene_symbol) leaves the Space. Uses Ensembl xrefs, which | |
| map the gene symbol → SwissProt accession for human/mouse. | |
| """ | |
| taxid = _UNIPROT_TAXID.get((organism or "").lower()) | |
| if not taxid: | |
| return {"ok": False, "error": "Structure view needs Human or Mouse."} | |
| if not gene_symbol: | |
| return {"ok": False, "error": "No gene symbol to look up."} | |
| # UniProt REST: reviewed (SwissProt) entry for this gene + organism. | |
| # Gene-level Ensembl xrefs don't carry SwissProt (that's protein-level), | |
| # so we query UniProt directly. Only (gene, organism) is sent. | |
| q = urllib.parse.quote(f"gene:{gene_symbol} AND organism_id:{taxid} AND reviewed:true") | |
| url = (f"https://rest.uniprot.org/uniprotkb/search?query={q}" | |
| f"&fields=accession&format=json&size=1") | |
| data = _exon._http_get_json(url) | |
| acc = "" | |
| results = (data or {}).get("results") if isinstance(data, dict) else None | |
| if results: | |
| acc = (results[0] or {}).get("primaryAccession", "") | |
| if not acc: | |
| return {"ok": False, | |
| "error": f"No reviewed UniProt entry found for {gene_symbol} ({organism})."} | |
| return { | |
| "ok": True, | |
| "uniprot": acc, | |
| # Fallback URL only — the client re-resolves the current model version | |
| # via the AlphaFold prediction API (the DB bumps versions: v4→v6→…). | |
| "alphafold_url": f"https://alphafold.ebi.ac.uk/files/AF-{acc}-F1-model_v6.pdb", | |
| "alphafold_page": f"https://alphafold.ebi.ac.uk/entry/{acc}", | |
| } | |
| def resolve_target(text: str, organism: str = "") -> Dict: | |
| """Resolve pasted text to an editable sequence. | |
| Returns a dict: {ok, kind, sequence, gene_symbol, label, source, error?}. | |
| """ | |
| organism = (organism or "").lower().strip() | |
| kind, val = classify(text) | |
| if kind == "empty": | |
| return _err("Paste a DNA sequence, a gene symbol, or an accession.") | |
| if kind == "sequence": | |
| if len(val) < MIN_TARGET_LEN: | |
| return _err(f"Sequence is only {len(val)} nt — need at least " | |
| f"{MIN_TARGET_LEN} nt. Check you pasted DNA, not protein.") | |
| if len(val) > MAX_TARGET_LEN: | |
| return _err("Sequence too long. Cap is 1 Mbp — paste just the " | |
| "gene / region you're editing.") | |
| return _ok("sequence", val, f"pasted sequence · {len(val):,} nt", "input") | |
| if kind == "symbol": | |
| if organism not in ("human", "mouse"): | |
| return _err(f"To look up “{val}” by gene symbol, pick Human or " | |
| f"Mouse — or paste the sequence directly.") | |
| gene = _exon.fetch_gene_structure(organism, val) | |
| if gene is None: | |
| return _err(f"Couldn't find “{val}” in {organism}. Check the " | |
| f"symbol, or paste the sequence directly.") | |
| return _ok("gene", gene.cds_sequence, | |
| f"{val} · {gene.transcript_id} · CDS {len(gene.cds_sequence):,} nt", | |
| "ensembl", gene_symbol=val) | |
| if kind in ("ensembl_tx", "ensembl_gene"): | |
| seq, label = _fetch_ensembl_cds(val, is_gene=(kind == "ensembl_gene")) | |
| if not seq: | |
| return _err(f"Couldn't fetch “{val}” from Ensembl. Check the ID, " | |
| f"or paste the sequence directly.") | |
| return _ok("ensembl", seq, label, "ensembl") | |
| if kind == "refseq": | |
| seq = _fetch_refseq(val) | |
| if not seq: | |
| return _err(f"Couldn't fetch “{val}” from NCBI. Check the " | |
| f"accession, or paste the sequence directly.") | |
| return _ok("refseq", seq, f"{val} · {len(seq):,} nt", "ncbi") | |
| return _err("Unrecognized input. Paste a DNA sequence, a gene symbol " | |
| "(with Human/Mouse selected), or an accession (ENST…, NM_…).") | |