2518 lines
92 KiB
Python
2518 lines
92 KiB
Python
import json
|
|
import mimetypes
|
|
import multiprocessing
|
|
import os
|
|
import queue
|
|
import re
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from decimal import Decimal
|
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
from urllib.parse import parse_qs, urlencode, urlparse
|
|
from uuid import uuid4
|
|
|
|
import numpy
|
|
from nupack import (
|
|
Complex,
|
|
Complementarity,
|
|
Domain,
|
|
Diversity,
|
|
EnergyMatch,
|
|
DesignOptions,
|
|
Library,
|
|
Match,
|
|
Model,
|
|
Pattern,
|
|
Similarity,
|
|
SSM,
|
|
SetSpec,
|
|
Strand,
|
|
TargetComplex,
|
|
TargetStrand,
|
|
TargetTube,
|
|
Tube,
|
|
Weights,
|
|
Window,
|
|
complex_analysis,
|
|
complex_design,
|
|
tube_analysis,
|
|
tube_design,
|
|
)
|
|
from nupack import config as nupack_config
|
|
from account import AccountStore, OIDCAuth
|
|
from split_strand_svg import render_split_strands_svg
|
|
|
|
try:
|
|
import redis
|
|
except ImportError:
|
|
redis = None
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
INDEX_PATH = ROOT / "index.html"
|
|
FAVICON_PATH = ROOT / "favicon.svg"
|
|
GUIDE_PATH = ROOT / "design-guide.html"
|
|
|
|
HOST = os.environ.get("NP_HOST", "0.0.0.0")
|
|
PORT = int(os.environ.get("NP_PORT", "18765"))
|
|
RNA_PLOT_CMD = os.environ.get("RNA_PLOT_CMD", "RNAplot")
|
|
ENABLE_RNAPLOT = os.environ.get("ENABLE_RNAPLOT", "1") != "0"
|
|
STRUCTURE_PLOT_MODE = os.environ.get("NP_STRUCTURE_PLOT_MODE", "auto").strip().lower()
|
|
RUN_MODE = os.environ.get("NP_RUN_MODE", "server")
|
|
REDIS_URL = os.environ.get("NP_REDIS_URL", "").strip()
|
|
JOB_QUEUE_KEY = os.environ.get("NP_JOB_QUEUE_KEY", "np_replica:jobs")
|
|
JOB_RUNNING_KEY = os.environ.get("NP_JOB_RUNNING_KEY", "np_replica:jobs:running")
|
|
WORKER_CONCURRENCY = int(os.environ.get("NP_WORKER_CONCURRENCY", "2"))
|
|
PER_JOB_THREAD_LIMIT = int(os.environ.get("NP_PER_JOB_THREAD_LIMIT", "1"))
|
|
NUPACK_CACHE_GB = float(os.environ.get("NP_NUPACK_CACHE_GB", "2.0"))
|
|
ACCOUNT_DB_PATH = os.environ.get("NP_ACCOUNT_DB_PATH", "/data/np-replica.sqlite3")
|
|
|
|
UNIT_SCALE = {
|
|
"M": 1.0,
|
|
"mM": 1e-3,
|
|
"uM": 1e-6,
|
|
"nM": 1e-9,
|
|
"pM": 1e-12,
|
|
}
|
|
IUPAC_CODES = "ACGTUWSMKRYBDHVN"
|
|
IUPAC_CONSTRAINT_TOKEN = re.compile(rf"[{IUPAC_CODES}](?:\d+)?")
|
|
|
|
VALID_COMPUTE = {"pfunc", "pairs", "mfe", "sample", "subopt", "ensemble_size"}
|
|
CANCEL_REQUESTED_STATUS = "cancel_requested"
|
|
CANCELED_STATUS = "canceled"
|
|
TERMINAL_JOB_STATUSES = {"success", "error", CANCELED_STATUS}
|
|
JOB_STORE = {}
|
|
JOB_LOCK = threading.Lock()
|
|
JOB_TTL_SECONDS = int(os.environ.get("NP_JOB_TTL_SECONDS", "3600"))
|
|
JOB_HEARTBEAT_SECONDS = max(1, int(os.environ.get("NP_JOB_HEARTBEAT_SECONDS", "30")))
|
|
JOB_MAX_COUNT = int(os.environ.get("NP_JOB_MAX_COUNT", "64"))
|
|
SHARE_STORE = {}
|
|
SHARE_LOCK = threading.Lock()
|
|
SHARE_MAX_COUNT = int(os.environ.get("NP_SHARE_MAX_COUNT", "100"))
|
|
SHARE_MAX_BYTES = int(os.environ.get("NP_SHARE_MAX_BYTES", str(12 * 1024 * 1024)))
|
|
SHARE_KEY_PREFIX = os.environ.get("NP_SHARE_KEY_PREFIX", "np_replica:shares")
|
|
SHARE_INDEX_KEY = f"{SHARE_KEY_PREFIX}:index"
|
|
REDIS_CLIENT = None
|
|
ACCOUNT_STORE = AccountStore(ACCOUNT_DB_PATH)
|
|
OIDC_AUTH = None
|
|
|
|
|
|
def json_bytes(payload, status=HTTPStatus.OK):
|
|
return (
|
|
status,
|
|
"application/json; charset=utf-8",
|
|
json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8"),
|
|
)
|
|
|
|
|
|
def log_event(message):
|
|
print(f"[np-replica] {time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True)
|
|
|
|
|
|
def apply_thread_limits():
|
|
# Keep native math libraries and NUPACK's own executor from using all CPU cores inside one job.
|
|
thread_limit_int = max(1, PER_JOB_THREAD_LIMIT)
|
|
thread_limit = str(thread_limit_int)
|
|
for key in (
|
|
"OMP_NUM_THREADS",
|
|
"OPENBLAS_NUM_THREADS",
|
|
"MKL_NUM_THREADS",
|
|
"NUMEXPR_NUM_THREADS",
|
|
"VECLIB_MAXIMUM_THREADS",
|
|
"GOTO_NUM_THREADS",
|
|
):
|
|
os.environ[key] = thread_limit
|
|
nupack_config.threads = thread_limit_int
|
|
nupack_config.cache = max(0.1, NUPACK_CACHE_GB)
|
|
|
|
|
|
def html_bytes(path):
|
|
body = path.read_bytes()
|
|
mime, _ = mimetypes.guess_type(str(path))
|
|
return HTTPStatus.OK, mime or "text/html; charset=utf-8", body
|
|
|
|
|
|
def file_etag(path):
|
|
stat = path.stat()
|
|
return f'W/"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
|
|
|
|
|
|
def redis_enabled():
|
|
return bool(REDIS_URL and redis is not None)
|
|
|
|
|
|
def redis_client():
|
|
global REDIS_CLIENT
|
|
if not redis_enabled():
|
|
return None
|
|
if REDIS_CLIENT is None:
|
|
REDIS_CLIENT = redis.Redis.from_url(REDIS_URL, decode_responses=True)
|
|
return REDIS_CLIENT
|
|
|
|
|
|
OIDC_AUTH = OIDCAuth(redis_client)
|
|
|
|
|
|
def queue_size():
|
|
if not redis_enabled():
|
|
with JOB_LOCK:
|
|
return sum(1 for job in JOB_STORE.values() if job.get("status") in {"queued", "running"})
|
|
try:
|
|
return int(redis_client().llen(JOB_QUEUE_KEY))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def prune_redis_running_jobs():
|
|
if not redis_enabled():
|
|
return 0
|
|
client = redis_client()
|
|
removed = 0
|
|
try:
|
|
for job_id in client.smembers(JOB_RUNNING_KEY):
|
|
raw = client.get(job_key(job_id))
|
|
if raw is None:
|
|
client.srem(JOB_RUNNING_KEY, job_id)
|
|
removed += 1
|
|
continue
|
|
try:
|
|
if json.loads(raw).get("status") != "running":
|
|
client.srem(JOB_RUNNING_KEY, job_id)
|
|
removed += 1
|
|
except Exception:
|
|
client.srem(JOB_RUNNING_KEY, job_id)
|
|
removed += 1
|
|
except Exception:
|
|
return removed
|
|
return removed
|
|
|
|
|
|
def job_stats():
|
|
stats = {
|
|
"queued": 0,
|
|
"running": 0,
|
|
"success": 0,
|
|
"error": 0,
|
|
}
|
|
if redis_enabled():
|
|
prune_redis_running_jobs()
|
|
client = redis_client()
|
|
stats["queued"] = int(client.llen(JOB_QUEUE_KEY))
|
|
stats["running"] = int(client.scard(JOB_RUNNING_KEY))
|
|
return stats
|
|
|
|
with JOB_LOCK:
|
|
prune_jobs()
|
|
for job in JOB_STORE.values():
|
|
status = job.get("status")
|
|
if status in stats:
|
|
stats[status] += 1
|
|
return stats
|
|
|
|
|
|
def job_key(job_id):
|
|
return f"np_replica:job:{job_id}"
|
|
|
|
|
|
def share_key(share_id):
|
|
return f"{SHARE_KEY_PREFIX}:item:{share_id}"
|
|
|
|
|
|
def normalize_sequence(sequence):
|
|
return "".join(sequence.upper().split())
|
|
|
|
|
|
def normalize_design_sequence(sequence):
|
|
return re.sub(r"\s+", "", str(sequence or "").upper())
|
|
|
|
|
|
def is_valid_iupac_constraint(sequence):
|
|
seq = normalize_design_sequence(sequence)
|
|
if not seq:
|
|
return False
|
|
index = 0
|
|
while index < len(seq):
|
|
match = IUPAC_CONSTRAINT_TOKEN.match(seq, index)
|
|
if not match:
|
|
return False
|
|
index = match.end()
|
|
return True
|
|
|
|
|
|
def ensure_unit_interval_limits(lower, upper, label):
|
|
if not (0 <= lower <= upper <= 1):
|
|
raise ValueError(f"{label} limits must satisfy 0 <= lower <= upper <= 1.")
|
|
|
|
|
|
def unit_to_molar(value, unit):
|
|
if unit not in UNIT_SCALE:
|
|
raise ValueError(f"Unsupported concentration unit: {unit}")
|
|
return float(value) * UNIT_SCALE[unit]
|
|
|
|
|
|
def build_model(model_input):
|
|
return Model(
|
|
material=model_input.get("material", "rna"),
|
|
ensemble=model_input.get("ensemble", "stacking"),
|
|
celsius=float(model_input.get("celsius", 37.0)),
|
|
sodium=float(model_input.get("sodium", 1.0)),
|
|
magnesium=float(model_input.get("magnesium", 0.0)),
|
|
)
|
|
|
|
|
|
def build_model_summary(model_input):
|
|
return {
|
|
"material": model_input.get("material", "rna"),
|
|
"ensemble": model_input.get("ensemble", "stacking"),
|
|
"celsius": float(model_input.get("celsius", 37.0)),
|
|
"sodium": float(model_input.get("sodium", 1.0)),
|
|
"magnesium": float(model_input.get("magnesium", 0.0)),
|
|
}
|
|
|
|
|
|
def parse_compute(payload):
|
|
compute = payload.get("compute") or ["pfunc", "mfe"]
|
|
compute = [item for item in compute if item in VALID_COMPUTE]
|
|
if not compute:
|
|
raise ValueError("At least one compute option is required.")
|
|
return compute
|
|
|
|
|
|
def parse_options(payload):
|
|
raw = payload.get("options") or {}
|
|
options = {
|
|
"num_sample": int(raw.get("num_sample", 20)),
|
|
"energy_gap": float(raw.get("energy_gap", 1.0)),
|
|
"sparsity_fraction": float(raw.get("sparsity_fraction", 1.0)),
|
|
"sparsity_threshold": float(raw.get("sparsity_threshold", 0.0)),
|
|
"single_mfe": bool(raw.get("single_mfe", False)),
|
|
"result_limit": int(raw.get("result_limit", 25)),
|
|
"pairs_preview_size": int(raw.get("pairs_preview_size", 24)),
|
|
}
|
|
if options["num_sample"] < 0 or options["num_sample"] > 1000:
|
|
raise ValueError("num_sample must be between 0 and 1000.")
|
|
if options["energy_gap"] < 0:
|
|
raise ValueError("energy_gap must be non-negative.")
|
|
if not 0 <= options["sparsity_fraction"] <= 1:
|
|
raise ValueError("sparsity_fraction must be between 0 and 1.")
|
|
if options["sparsity_threshold"] < 0:
|
|
raise ValueError("sparsity_threshold must be non-negative.")
|
|
if options["result_limit"] < 1:
|
|
raise ValueError("result_limit must be at least 1.")
|
|
if options["pairs_preview_size"] < 8 or options["pairs_preview_size"] > 128:
|
|
raise ValueError("pairs_preview_size must be between 8 and 128.")
|
|
return options
|
|
|
|
|
|
def parse_design_options(payload):
|
|
raw = payload.get("design") or {}
|
|
options = {
|
|
"trials": int(raw.get("trials", 1)),
|
|
"result_limit": int(raw.get("result_limit", 25)),
|
|
"off_target_max_size": int(raw.get("off_target_max_size", payload.get("tube", {}).get("max_size", 2))),
|
|
"stop_condition": float(raw.get("stop_condition", 0.02)),
|
|
"seed": int(raw.get("seed", 0)),
|
|
"wobble_mutations": bool(raw.get("wobble_mutations", False)),
|
|
"max_time_seconds": int(raw.get("max_time_seconds", 0)),
|
|
}
|
|
if options["trials"] < 1 or options["trials"] > 8:
|
|
raise ValueError("design trials must be between 1 and 8.")
|
|
if options["result_limit"] < 1:
|
|
raise ValueError("design result_limit must be at least 1.")
|
|
if options["off_target_max_size"] < 1 or options["off_target_max_size"] > 8:
|
|
raise ValueError("design off_target_max_size must be between 1 and 8.")
|
|
if not 0 < options["stop_condition"] < 1:
|
|
raise ValueError("design stop_condition must be between 0 and 1.")
|
|
if options["max_time_seconds"] < 0:
|
|
raise ValueError("design max_time_seconds must be non-negative.")
|
|
return options
|
|
|
|
|
|
def build_strands(strand_payload):
|
|
if not strand_payload:
|
|
raise ValueError("At least one strand is required.")
|
|
|
|
strand_map = {}
|
|
ordered = []
|
|
|
|
for row in strand_payload:
|
|
name = (row.get("name") or "").strip()
|
|
sequence = normalize_sequence(row.get("sequence") or "")
|
|
if not name:
|
|
raise ValueError("Every strand needs a name.")
|
|
if not sequence:
|
|
raise ValueError(f"Strand {name} is missing a sequence.")
|
|
if name in strand_map:
|
|
raise ValueError(f"Duplicate strand name: {name}")
|
|
if not re.fullmatch(r"[ACGTUWSMKRYBDHVN]+", sequence):
|
|
raise ValueError(f"Strand {name} contains unsupported characters.")
|
|
concentration = float(row.get("concentration", 0))
|
|
if concentration < 0:
|
|
raise ValueError(f"Strand {name} concentration must be non-negative.")
|
|
|
|
strand = Strand(sequence, name=name)
|
|
strand_map[name] = strand
|
|
ordered.append(
|
|
{
|
|
"name": name,
|
|
"sequence": sequence,
|
|
"concentration": concentration,
|
|
"unit": row.get("unit", "uM"),
|
|
"object": strand,
|
|
}
|
|
)
|
|
|
|
return strand_map, ordered
|
|
|
|
|
|
def build_design_domains(domain_payload):
|
|
domain_map = {}
|
|
ordered = []
|
|
for row in domain_payload:
|
|
name = (row.get("name") or "").strip()
|
|
sequence_constraint = normalize_design_sequence(row.get("sequence") or "")
|
|
if not name:
|
|
raise ValueError("Every design domain needs a name.")
|
|
if not sequence_constraint:
|
|
raise ValueError(f"Design domain {name} is missing a sequence constraint.")
|
|
if name in domain_map:
|
|
raise ValueError(f"Duplicate domain name: {name}")
|
|
if not is_valid_iupac_constraint(sequence_constraint):
|
|
raise ValueError(f"Design domain {name} contains unsupported constraint characters.")
|
|
|
|
domain = Domain(sequence_constraint, name=name)
|
|
domain_map[name] = domain
|
|
ordered.append(
|
|
{
|
|
"name": name,
|
|
"constraint": sequence_constraint,
|
|
"object": domain,
|
|
}
|
|
)
|
|
return domain_map, ordered
|
|
|
|
|
|
def parse_domain_composition(text, domain_map):
|
|
tokens = [token.strip() for token in re.split(r"[\s,]+", str(text or "").strip()) if token.strip()]
|
|
if not tokens:
|
|
raise ValueError("Domain composition cannot be empty.")
|
|
|
|
domains = []
|
|
for token in tokens:
|
|
complement = False
|
|
domain_name = token
|
|
if token.startswith("~"):
|
|
complement = True
|
|
domain_name = token[1:]
|
|
elif token.endswith("*"):
|
|
complement = True
|
|
domain_name = token[:-1]
|
|
|
|
if not domain_name:
|
|
raise ValueError(f"Invalid domain token: {token}")
|
|
if domain_name not in domain_map:
|
|
raise ValueError(f"Unknown domain name in strand composition: {domain_name}")
|
|
|
|
domain = domain_map[domain_name]
|
|
domains.append(~domain if complement else domain)
|
|
return domains
|
|
|
|
|
|
def build_design_strands(strand_payload, domain_map=None):
|
|
if not strand_payload:
|
|
raise ValueError("At least one design strand is required.")
|
|
|
|
domain_map = domain_map or {}
|
|
use_domain_composition = bool(domain_map)
|
|
target_strand_map = {}
|
|
ordered = []
|
|
|
|
for row in strand_payload:
|
|
name = (row.get("name") or "").strip()
|
|
raw_definition = str(row.get("sequence") or "").strip()
|
|
sequence_constraint = normalize_design_sequence(raw_definition)
|
|
if not name:
|
|
raise ValueError("Every design strand needs a name.")
|
|
if not raw_definition:
|
|
raise ValueError(f"Design strand {name} is missing a strand definition.")
|
|
if name in target_strand_map:
|
|
raise ValueError(f"Duplicate strand name: {name}")
|
|
|
|
if use_domain_composition:
|
|
try:
|
|
strand_domains = parse_domain_composition(raw_definition, domain_map)
|
|
except ValueError:
|
|
if (" " in raw_definition) or ("," in raw_definition) or ("~" in raw_definition) or ("*" in raw_definition):
|
|
raise
|
|
if not is_valid_iupac_constraint(sequence_constraint):
|
|
raise
|
|
inline_domain = Domain(sequence_constraint, name=name)
|
|
strand_domains = [inline_domain]
|
|
target_strand = TargetStrand(strand_domains, name=name)
|
|
constraint_kind = "sequence_constraint"
|
|
constraint_value = sequence_constraint
|
|
else:
|
|
inline_domain = None
|
|
target_strand = TargetStrand(strand_domains, name=name)
|
|
constraint_kind = "domain_composition"
|
|
constraint_value = raw_definition
|
|
else:
|
|
if not is_valid_iupac_constraint(sequence_constraint):
|
|
raise ValueError(f"Design strand {name} contains unsupported constraint characters.")
|
|
inline_domain = Domain(sequence_constraint, name=name)
|
|
strand_domains = [inline_domain]
|
|
target_strand = TargetStrand(strand_domains, name=name)
|
|
constraint_kind = "sequence_constraint"
|
|
constraint_value = sequence_constraint
|
|
|
|
target_strand_map[name] = target_strand
|
|
ordered.append(
|
|
{
|
|
"name": name,
|
|
"definition": raw_definition,
|
|
"constraint": constraint_value,
|
|
"constraint_kind": constraint_kind,
|
|
"object": target_strand,
|
|
"domains": strand_domains,
|
|
"domain": inline_domain,
|
|
}
|
|
)
|
|
|
|
return target_strand_map, ordered
|
|
|
|
|
|
def resolve_scope_reference(expr, domain_map, strand_map, *, allow_global=False):
|
|
text = str(expr or "").strip()
|
|
if not text:
|
|
if allow_global:
|
|
return None
|
|
raise ValueError("Constraint scope cannot be empty.")
|
|
|
|
if text in strand_map:
|
|
return strand_map[text]
|
|
return parse_domain_composition(text, domain_map)
|
|
|
|
|
|
def resolve_domain_scope(expr, domain_map):
|
|
text = str(expr or "").strip()
|
|
if not text:
|
|
raise ValueError("Domain scope cannot be empty.")
|
|
return parse_domain_composition(text, domain_map)
|
|
|
|
|
|
def resolve_target_complex_scope(expr, target_complex_map):
|
|
text = str(expr or "").strip()
|
|
if not text:
|
|
raise ValueError("Target complex scope cannot be empty.")
|
|
tokens = [token.strip() for token in re.split(r"[\s,]+", text) if token.strip()]
|
|
if not tokens:
|
|
raise ValueError("Target complex scope cannot be empty.")
|
|
missing = [token for token in tokens if token not in target_complex_map]
|
|
if missing:
|
|
raise ValueError(f"Unknown target complex name(s): {', '.join(missing)}")
|
|
return [target_complex_map[token] for token in tokens]
|
|
|
|
|
|
def parse_sequence_list(text):
|
|
if isinstance(text, list):
|
|
values = text
|
|
else:
|
|
normalized = str(text or "").replace("|", "\n").replace(";", "\n")
|
|
values = normalized.splitlines()
|
|
output = [item.strip() for item in values if item and item.strip()]
|
|
if not output:
|
|
raise ValueError("Constraint sequence list cannot be empty.")
|
|
for seq in output:
|
|
if not re.fullmatch(rf"[{IUPAC_CODES}]+", seq.upper()):
|
|
raise ValueError(f"Invalid sequence source: {seq!r}")
|
|
return output
|
|
|
|
|
|
def parse_pattern_list(text):
|
|
if isinstance(text, list):
|
|
values = text
|
|
else:
|
|
values = re.split(r"[\n,]+", str(text or ""))
|
|
output = [item.strip().upper() for item in values if item and item.strip()]
|
|
if not output:
|
|
raise ValueError("Pattern constraint requires at least one pattern.")
|
|
for pattern in output:
|
|
if not is_valid_iupac_constraint(pattern):
|
|
raise ValueError(f"Invalid pattern: {pattern!r}")
|
|
return output
|
|
|
|
|
|
def parse_catalog_list(text):
|
|
raw_lines = [line.strip() for line in str(text or "").splitlines() if line.strip()]
|
|
if not raw_lines:
|
|
raise ValueError("Library constraint requires at least one catalog row.")
|
|
catalog = []
|
|
for line in raw_lines:
|
|
library = [item.strip().upper() for item in re.split(r"[,|]+", line) if item.strip()]
|
|
if not library:
|
|
raise ValueError("Library constraint contains an empty catalog row.")
|
|
for seq in library:
|
|
if not re.fullmatch(rf"[{IUPAC_CODES}]+", seq):
|
|
raise ValueError(f"Invalid library sequence: {seq!r}")
|
|
catalog.append(library)
|
|
return catalog
|
|
|
|
|
|
def build_hard_constraints(payload, domain_map, strand_map):
|
|
constraints = []
|
|
for index, row in enumerate(payload.get("hard_constraints") or [], start=1):
|
|
constraint_type = (row.get("type") or "").strip().lower()
|
|
if not constraint_type:
|
|
continue
|
|
|
|
try:
|
|
def require_text(field, label):
|
|
value = str(row.get(field) or "").strip()
|
|
if not value:
|
|
raise ValueError(f"{label} is required.")
|
|
return value
|
|
|
|
if constraint_type == "match":
|
|
constraints.append(
|
|
Match(
|
|
resolve_scope_reference(require_text("left", "Match left scope"), domain_map, strand_map),
|
|
resolve_scope_reference(require_text("right", "Match right scope"), domain_map, strand_map),
|
|
)
|
|
)
|
|
elif constraint_type == "complementarity":
|
|
constraints.append(
|
|
Complementarity(
|
|
resolve_scope_reference(require_text("left", "Complementarity left scope"), domain_map, strand_map),
|
|
resolve_scope_reference(require_text("right", "Complementarity right scope"), domain_map, strand_map),
|
|
wobble_mutations=bool(row.get("wobble_mutations", False)),
|
|
)
|
|
)
|
|
elif constraint_type == "diversity":
|
|
word = int(row.get("word", 4))
|
|
types = int(row.get("types", 2))
|
|
if word < 1:
|
|
raise ValueError("Diversity word must be >= 1.")
|
|
if types < 1 or types > 4:
|
|
raise ValueError("Diversity types must be between 1 and 4.")
|
|
kwargs = {
|
|
"word": word,
|
|
"types": types,
|
|
}
|
|
scope = resolve_scope_reference(row.get("scope"), domain_map, strand_map, allow_global=True)
|
|
if scope is not None:
|
|
kwargs["scope"] = scope
|
|
constraints.append(Diversity(**kwargs))
|
|
elif constraint_type == "similarity":
|
|
lower = float(row.get("min_fraction", 0.0))
|
|
upper = float(row.get("max_fraction", 1.0))
|
|
ensure_unit_interval_limits(lower, upper, "Similarity")
|
|
reference = normalize_design_sequence(row.get("reference") or "")
|
|
if not is_valid_iupac_constraint(reference):
|
|
raise ValueError("Similarity reference must be a valid IUPAC constraint.")
|
|
constraints.append(
|
|
Similarity(
|
|
resolve_scope_reference(require_text("scope", "Similarity scope"), domain_map, strand_map),
|
|
reference,
|
|
limits=[lower, upper],
|
|
)
|
|
)
|
|
elif constraint_type == "window":
|
|
constraints.append(
|
|
Window(
|
|
resolve_scope_reference(require_text("scope", "Window scope"), domain_map, strand_map),
|
|
sources=parse_sequence_list(row.get("sources") or ""),
|
|
)
|
|
)
|
|
elif constraint_type == "library":
|
|
constraints.append(
|
|
Library(
|
|
resolve_scope_reference(require_text("scope", "Library scope"), domain_map, strand_map),
|
|
catalog=parse_catalog_list(row.get("catalog") or ""),
|
|
)
|
|
)
|
|
elif constraint_type == "pattern":
|
|
kwargs = {"patterns": parse_pattern_list(row.get("patterns") or "")}
|
|
scope = resolve_scope_reference(row.get("scope"), domain_map, strand_map, allow_global=True)
|
|
if scope is not None:
|
|
kwargs["scope"] = scope
|
|
constraints.append(Pattern(**kwargs))
|
|
else:
|
|
raise ValueError(f"Unsupported hard constraint type: {constraint_type}")
|
|
except Exception as exc:
|
|
raise ValueError(f"Invalid hard constraint #{index}: {exc}") from exc
|
|
|
|
return constraints
|
|
|
|
|
|
def build_soft_constraints(payload, domain_map, strand_map, target_complex_map):
|
|
constraints = []
|
|
for index, row in enumerate(payload.get("soft_constraints") or [], start=1):
|
|
constraint_type = (row.get("type") or "").strip().lower()
|
|
if not constraint_type:
|
|
continue
|
|
|
|
try:
|
|
def require_text(field, label):
|
|
value = str(row.get(field) or "").strip()
|
|
if not value:
|
|
raise ValueError(f"{label} is required.")
|
|
return value
|
|
|
|
if constraint_type == "pattern":
|
|
weight = float(row.get("weight", 1.0))
|
|
if weight < 0:
|
|
raise ValueError("Pattern weight must be non-negative.")
|
|
kwargs = {
|
|
"patterns": parse_pattern_list(row.get("patterns") or ""),
|
|
"weight": weight,
|
|
}
|
|
scope = resolve_scope_reference(row.get("scope"), domain_map, strand_map, allow_global=True)
|
|
if scope is not None:
|
|
kwargs["scope"] = scope
|
|
constraints.append(Pattern(**kwargs))
|
|
elif constraint_type == "similarity":
|
|
lower = float(row.get("min_fraction", 0.0))
|
|
upper = float(row.get("max_fraction", 1.0))
|
|
ensure_unit_interval_limits(lower, upper, "Similarity")
|
|
reference = normalize_design_sequence(row.get("reference") or "")
|
|
if not is_valid_iupac_constraint(reference):
|
|
raise ValueError("Similarity reference must be a valid IUPAC constraint.")
|
|
weight = float(row.get("weight", 1.0))
|
|
if weight < 0:
|
|
raise ValueError("Similarity weight must be non-negative.")
|
|
constraints.append(
|
|
Similarity(
|
|
resolve_scope_reference(require_text("scope", "Similarity scope"), domain_map, strand_map),
|
|
reference,
|
|
limits=[lower, upper],
|
|
weight=weight,
|
|
)
|
|
)
|
|
elif constraint_type == "ssm":
|
|
word = int(row.get("word", 4))
|
|
if word < 1:
|
|
raise ValueError("SSM word must be >= 1.")
|
|
weight = float(row.get("weight", 1.0))
|
|
if weight < 0:
|
|
raise ValueError("SSM weight must be non-negative.")
|
|
kwargs = {
|
|
"word": word,
|
|
"weight": weight,
|
|
}
|
|
scope = resolve_target_complex_scope(row.get("scope"), target_complex_map) if row.get("scope") else None
|
|
if scope is not None:
|
|
kwargs["scope"] = scope
|
|
constraints.append(SSM(**kwargs))
|
|
elif constraint_type == "energy_match":
|
|
weight = float(row.get("weight", 1.0))
|
|
if weight < 0:
|
|
raise ValueError("EnergyMatch weight must be non-negative.")
|
|
kwargs = {
|
|
"domains": resolve_domain_scope(require_text("scope", "EnergyMatch domain scope"), domain_map),
|
|
"weight": weight,
|
|
}
|
|
if row.get("energy_ref") not in {None, ""}:
|
|
kwargs["energy_ref"] = float(row.get("energy_ref"))
|
|
constraints.append(EnergyMatch(**kwargs))
|
|
else:
|
|
raise ValueError(f"Unsupported soft constraint type: {constraint_type}")
|
|
except Exception as exc:
|
|
raise ValueError(f"Invalid soft constraint #{index}: {exc}") from exc
|
|
|
|
return constraints
|
|
|
|
|
|
def parse_design_complexes(payload, target_strand_map):
|
|
target_rows = payload.get("design_complexes") or payload.get("design_targets") or []
|
|
if not target_rows:
|
|
raise ValueError("At least one design target complex is required.")
|
|
|
|
targets = []
|
|
target_complex_map = {}
|
|
|
|
for idx, row in enumerate(target_rows, start=1):
|
|
name = (row.get("name") or f"target_{idx}").strip() or f"target_{idx}"
|
|
if name in target_complex_map:
|
|
raise ValueError(f"Duplicate design target complex name: {name}")
|
|
strand_text = (row.get("strands") or "").strip()
|
|
structure = (row.get("structure") or "").strip()
|
|
if not strand_text:
|
|
raise ValueError(f"Design target complex {name} is missing strand membership.")
|
|
if not structure:
|
|
raise ValueError(f"Design target complex {name} is missing a target structure.")
|
|
|
|
tokens = [token.strip() for token in strand_text.split("+") if token.strip()]
|
|
if not tokens:
|
|
raise ValueError(f"Design target complex {name} has an invalid strand list.")
|
|
missing = [token for token in tokens if token not in target_strand_map]
|
|
if missing:
|
|
raise ValueError(
|
|
f"Unknown strand name(s) in design target complex {name}: {', '.join(missing)}"
|
|
)
|
|
|
|
target_complex = TargetComplex(
|
|
[target_strand_map[token] for token in tokens],
|
|
structure,
|
|
name=name,
|
|
)
|
|
target_payload = {
|
|
"name": name,
|
|
"strands": tokens,
|
|
"structure": structure,
|
|
"object": target_complex,
|
|
}
|
|
targets.append(target_payload)
|
|
target_complex_map[name] = target_complex
|
|
|
|
return targets, target_complex_map
|
|
|
|
|
|
def parse_design_tubes(payload, target_rows, target_complex_map, default_max_size):
|
|
tube_rows = payload.get("design_tubes") or []
|
|
if not tube_rows:
|
|
legacy_targets = payload.get("design_targets") or []
|
|
if legacy_targets:
|
|
on_targets = []
|
|
for row in legacy_targets:
|
|
if row.get("concentration") in {None, ""}:
|
|
continue
|
|
on_targets.append(
|
|
{
|
|
"complex": row.get("name"),
|
|
"concentration": row.get("concentration"),
|
|
"unit": row.get("unit", "uM"),
|
|
}
|
|
)
|
|
if on_targets:
|
|
tube_rows = [{
|
|
"name": ((payload.get("tube") or {}).get("name") or "design_tube"),
|
|
"max_size": default_max_size,
|
|
"on_targets": on_targets,
|
|
}]
|
|
|
|
if not tube_rows:
|
|
raise ValueError("Tube design requires at least one target tube.")
|
|
|
|
tubes = []
|
|
ordered_rows = []
|
|
tube_name_set = set()
|
|
for index, row in enumerate(tube_rows, start=1):
|
|
tube_name = (row.get("name") or f"tube_{index}").strip() or f"tube_{index}"
|
|
if tube_name in tube_name_set:
|
|
raise ValueError(f"Duplicate target tube name: {tube_name}")
|
|
tube_name_set.add(tube_name)
|
|
max_size = int(row.get("max_size", default_max_size))
|
|
if max_size < 1 or max_size > 8:
|
|
raise ValueError(f"Target tube {tube_name} max_size must be between 1 and 8.")
|
|
|
|
on_targets_input = row.get("on_targets") or []
|
|
if not on_targets_input:
|
|
raise ValueError(f"Target tube {tube_name} must include at least one on-target complex.")
|
|
|
|
on_targets = {}
|
|
serialized_on_targets = []
|
|
seen_complex_names = set()
|
|
for entry in on_targets_input:
|
|
complex_name = (entry.get("complex") or "").strip()
|
|
if complex_name not in target_complex_map:
|
|
raise ValueError(f"Unknown target complex {complex_name} in tube {tube_name}.")
|
|
if complex_name in seen_complex_names:
|
|
raise ValueError(f"Duplicate on-target complex {complex_name} in tube {tube_name}.")
|
|
seen_complex_names.add(complex_name)
|
|
concentration = float(entry.get("concentration", 0))
|
|
unit = entry.get("unit", "uM")
|
|
if concentration <= 0:
|
|
raise ValueError(f"Target tube {tube_name} requires positive target concentrations.")
|
|
concentration_M = unit_to_molar(concentration, unit)
|
|
complex_obj = target_complex_map[complex_name]
|
|
on_targets[complex_obj] = concentration_M
|
|
serialized_on_targets.append(
|
|
{
|
|
"complex": complex_name,
|
|
"concentration": concentration,
|
|
"unit": unit,
|
|
"target_concentration_M": concentration_M,
|
|
}
|
|
)
|
|
|
|
tube = TargetTube(
|
|
on_targets=on_targets,
|
|
off_targets=SetSpec(max_size=max_size),
|
|
name=tube_name,
|
|
)
|
|
ordered_rows.append(
|
|
{
|
|
"name": tube_name,
|
|
"max_size": max_size,
|
|
"on_targets": serialized_on_targets,
|
|
"object": tube,
|
|
}
|
|
)
|
|
tubes.append(tube)
|
|
|
|
return ordered_rows, tubes
|
|
|
|
|
|
def validate_design_object_names(design_domains, design_strands, target_rows, tube_rows):
|
|
name_map = {}
|
|
for kind, rows in (
|
|
("domain", design_domains),
|
|
("strand", design_strands),
|
|
("target complex", target_rows),
|
|
("target tube", tube_rows),
|
|
):
|
|
for row in rows:
|
|
name = (row.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
prev = name_map.get(name)
|
|
if prev and prev != kind:
|
|
raise ValueError(
|
|
f"Object name {name!r} is reused across {prev} and {kind}. "
|
|
"Design object names must be unique in a job."
|
|
)
|
|
name_map[name] = kind
|
|
|
|
|
|
def build_defect_weights(payload, design_mode, design_domains, design_strands, target_rows, tube_rows):
|
|
rows = payload.get("defect_weights") or []
|
|
if not rows:
|
|
return None
|
|
|
|
domain_index = {item["name"]: item["object"] for item in design_domains}
|
|
strand_index = {item["name"]: item["object"] for item in design_strands}
|
|
complex_index = {item["name"]: item["object"] for item in target_rows}
|
|
tube_index = {item["name"]: item["object"] for item in tube_rows}
|
|
weight_base = [item["object"] for item in tube_rows] if design_mode == "tube" else [item["object"] for item in target_rows]
|
|
weights = Weights(weight_base)
|
|
|
|
for index, row in enumerate(rows, start=1):
|
|
kind = (row.get("kind") or "").strip().lower()
|
|
value = float(row.get("weight", 1.0))
|
|
if value < 0:
|
|
raise ValueError(f"Invalid defect weight #{index}: weight must be non-negative")
|
|
name = (row.get("name") or "").strip()
|
|
try:
|
|
if kind == "global":
|
|
weights[:] = value
|
|
elif kind == "domain":
|
|
weights[domain_index[name]] = value
|
|
elif kind == "strand":
|
|
weights[:, strand_index[name]] = value
|
|
elif kind == "complex":
|
|
weights[:, :, complex_index[name]] = value
|
|
elif kind == "tube":
|
|
if design_mode != "tube":
|
|
raise ValueError("tube-level defect weights are only valid in tube design mode")
|
|
weights[:, :, :, tube_index[name]] = value
|
|
else:
|
|
raise ValueError(f"Unsupported defect weight kind: {kind}")
|
|
except KeyError as exc:
|
|
raise ValueError(f"Unknown entity for defect weight #{index}: {name}") from exc
|
|
except Exception as exc:
|
|
raise ValueError(f"Invalid defect weight #{index}: {exc}") from exc
|
|
|
|
return weights
|
|
|
|
|
|
def parse_complex_lines(text, strand_map):
|
|
complexes = []
|
|
raw_lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
if not raw_lines:
|
|
raise ValueError("Complex mode requires at least one complex definition.")
|
|
|
|
for idx, line in enumerate(raw_lines, start=1):
|
|
tokens = [token.strip() for token in line.split("+") if token.strip()]
|
|
if not tokens:
|
|
raise ValueError(f"Invalid complex definition on line {idx}: {line}")
|
|
missing = [token for token in tokens if token not in strand_map]
|
|
if missing:
|
|
raise ValueError(
|
|
f"Unknown strand name(s) in complex line {idx}: {', '.join(missing)}"
|
|
)
|
|
strands = [strand_map[token] for token in tokens]
|
|
complexes.append(Complex(strands, name=f"complex_{idx}"))
|
|
|
|
return complexes
|
|
|
|
|
|
def stringify_complex(complex_obj):
|
|
return " + ".join(strand.name for strand in complex_obj.strands)
|
|
|
|
|
|
def flatten_sequence(complex_obj):
|
|
return "".join(str(strand) for strand in complex_obj.strands)
|
|
|
|
|
|
def sanitize_structure_for_rnaplot(structure):
|
|
return str(structure).replace("+", "")
|
|
|
|
|
|
def safe_name(name):
|
|
return re.sub(r"[^A-Za-z0-9_.-]+", "_", name).strip("_") or "structure"
|
|
|
|
|
|
def render_rnaplot_svg(complex_obj, structure, suffix):
|
|
if not ENABLE_RNAPLOT:
|
|
return None
|
|
|
|
seq_name = safe_name(f"{stringify_complex(complex_obj)}_{suffix}")
|
|
sequence = flatten_sequence(complex_obj)
|
|
structure_text = sanitize_structure_for_rnaplot(structure)
|
|
|
|
with tempfile.TemporaryDirectory(prefix="rnaplot-") as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
input_path = tmp_path / f"{seq_name}.seq"
|
|
output_path = tmp_path / f"{seq_name}_ss.svg"
|
|
|
|
input_path.write_text(
|
|
f">{seq_name}\n{sequence}\n{structure_text}\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
|
|
process = subprocess.run(
|
|
[RNA_PLOT_CMD, "-f", "svg", input_path.name],
|
|
cwd=tmpdir,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=20,
|
|
)
|
|
|
|
if process.returncode != 0:
|
|
raise RuntimeError(process.stderr.strip() or "RNAplot failed.")
|
|
|
|
if not output_path.exists():
|
|
raise RuntimeError("RNAplot finished without producing an SVG file.")
|
|
|
|
return output_path.read_text(encoding="utf-8")
|
|
|
|
|
|
def render_structure_svg(complex_obj, structure, suffix):
|
|
structure_text = str(structure)
|
|
multistrand = len(complex_obj.strands) > 1 and "+" in structure_text
|
|
should_try_split = STRUCTURE_PLOT_MODE == "split" or (
|
|
STRUCTURE_PLOT_MODE != "rnaplot" and multistrand
|
|
)
|
|
split_error = None
|
|
|
|
if should_try_split:
|
|
try:
|
|
return render_split_strands_svg(
|
|
[str(strand) for strand in complex_obj.strands],
|
|
structure_text,
|
|
title=safe_name(f"{stringify_complex(complex_obj)}_{suffix}"),
|
|
)
|
|
except Exception as exc:
|
|
split_error = exc
|
|
if STRUCTURE_PLOT_MODE == "split":
|
|
raise RuntimeError(f"Split-strand layout failed: {exc}") from exc
|
|
|
|
if STRUCTURE_PLOT_MODE == "split":
|
|
if split_error is not None:
|
|
raise RuntimeError(f"Split-strand layout failed: {split_error}") from split_error
|
|
return None
|
|
|
|
try:
|
|
return render_rnaplot_svg(complex_obj, structure, suffix)
|
|
except Exception as exc:
|
|
if split_error is not None:
|
|
raise RuntimeError(
|
|
f"Split-strand layout failed: {split_error}; RNAplot fallback failed: {exc}"
|
|
) from exc
|
|
raise
|
|
|
|
|
|
def serialize_structures(complex_obj, items, plot_kind=None):
|
|
output = []
|
|
for index, item in enumerate(items or []):
|
|
row = {
|
|
"structure": str(item.structure),
|
|
"energy": round(float(item.energy), 6),
|
|
"stack_energy": round(float(item.stack_energy), 6),
|
|
}
|
|
if plot_kind and index == 0:
|
|
try:
|
|
row["rnaplot_svg"] = render_structure_svg(complex_obj, item.structure, plot_kind)
|
|
except Exception as exc:
|
|
row["rnaplot_error"] = str(exc)
|
|
output.append(row)
|
|
return output
|
|
|
|
|
|
def serialize_samples(items):
|
|
return [str(item) for item in (items or [])]
|
|
|
|
|
|
def serialize_pairs(pair_matrix, preview_limit=24):
|
|
if pair_matrix is None:
|
|
return None
|
|
|
|
dense = numpy.asarray(pair_matrix.to_array(), dtype=float)
|
|
preview = dense[:preview_limit, :preview_limit]
|
|
return {
|
|
"shape": list(dense.shape),
|
|
"preview": numpy.round(preview, 6).tolist(),
|
|
"preview_size": int(preview_limit),
|
|
"preview_truncated": bool(dense.shape[0] > preview_limit),
|
|
}
|
|
|
|
|
|
def parse_structure_pairs(structure):
|
|
stack = []
|
|
pairs = []
|
|
compact_index = -1
|
|
index_map = []
|
|
|
|
for char in str(structure):
|
|
if char == "+":
|
|
continue
|
|
compact_index += 1
|
|
index_map.append(compact_index)
|
|
if char == "(":
|
|
stack.append(compact_index)
|
|
elif char == ")":
|
|
if not stack:
|
|
raise ValueError("Unbalanced structure: missing opening bracket.")
|
|
left = stack.pop()
|
|
pairs.append((left, compact_index))
|
|
|
|
if stack:
|
|
raise ValueError("Unbalanced structure: missing closing bracket.")
|
|
|
|
pairs.sort()
|
|
return pairs, compact_index + 1
|
|
|
|
|
|
def build_structure_probabilities(structure, pair_matrix):
|
|
if pair_matrix is None:
|
|
return None
|
|
|
|
dense = numpy.asarray(pair_matrix.to_array(), dtype=float)
|
|
mfe_pairs, structure_length = parse_structure_pairs(structure)
|
|
|
|
if dense.shape[0] != dense.shape[1]:
|
|
return None
|
|
if structure_length != dense.shape[0]:
|
|
return None
|
|
|
|
pair_partner = {}
|
|
for left, right in mfe_pairs:
|
|
pair_partner[left] = right
|
|
pair_partner[right] = left
|
|
|
|
residue_probabilities = []
|
|
for index in range(structure_length):
|
|
partner = pair_partner.get(index)
|
|
if partner is None:
|
|
probability = float(dense[index, index])
|
|
else:
|
|
probability = float(dense[index, partner])
|
|
residue_probabilities.append(round(probability, 6))
|
|
|
|
pair_probabilities = [
|
|
{
|
|
"i": left + 1,
|
|
"j": right + 1,
|
|
"probability": round(float(dense[left, right]), 6),
|
|
}
|
|
for left, right in mfe_pairs
|
|
]
|
|
|
|
return {
|
|
"length": structure_length,
|
|
"residue_probabilities": residue_probabilities,
|
|
"pair_probabilities": pair_probabilities,
|
|
}
|
|
|
|
|
|
def serialize_complex_result(complex_obj, data, pairs_preview_size=24):
|
|
payload = {
|
|
"name": getattr(complex_obj, "name", None),
|
|
"display": stringify_complex(complex_obj),
|
|
"strand_names": [strand.name for strand in complex_obj.strands],
|
|
"sequence": flatten_sequence(complex_obj),
|
|
"strand_lengths": [len(str(strand)) for strand in complex_obj.strands],
|
|
}
|
|
|
|
if data.pfunc is not None:
|
|
payload["pfunc"] = format(data.pfunc, "g") if isinstance(data.pfunc, Decimal) else str(data.pfunc)
|
|
if data.free_energy is not None:
|
|
payload["free_energy_kcal_mol"] = round(float(data.free_energy), 6)
|
|
if data.ensemble_size is not None:
|
|
payload["ensemble_size"] = int(data.ensemble_size)
|
|
if data.mfe_stack is not None:
|
|
payload["mfe_stack_kcal_mol"] = round(float(data.mfe_stack), 6)
|
|
if data.mfe is not None:
|
|
payload["mfe"] = serialize_structures(complex_obj, data.mfe, plot_kind="mfe")
|
|
if data.subopt is not None:
|
|
payload["subopt"] = serialize_structures(complex_obj, data.subopt)
|
|
if data.sample is not None:
|
|
payload["sample"] = serialize_samples(data.sample)
|
|
if data.pairs is not None:
|
|
payload["pairs"] = serialize_pairs(data.pairs, preview_limit=pairs_preview_size)
|
|
if payload.get("mfe"):
|
|
payload["mfe"][0]["pair_probability_annotations"] = build_structure_probabilities(
|
|
payload["mfe"][0]["structure"], data.pairs
|
|
)
|
|
|
|
return payload
|
|
|
|
|
|
def attach_pairs_result(payload, pair_data, pairs_preview_size=24):
|
|
if pair_data is None or pair_data.pairs is None:
|
|
return payload
|
|
|
|
payload["pairs"] = serialize_pairs(pair_data.pairs, preview_limit=pairs_preview_size)
|
|
if payload.get("mfe"):
|
|
payload["mfe"][0]["pair_probability_annotations"] = build_structure_probabilities(
|
|
payload["mfe"][0]["structure"], pair_data.pairs
|
|
)
|
|
return payload
|
|
|
|
|
|
def serialize_target_defect_rows(df, limit=None):
|
|
if df is None:
|
|
return []
|
|
rows = []
|
|
for record in df.to_dict("records"):
|
|
row = {}
|
|
for key, value in record.items():
|
|
if key in {"tube", "complex"}:
|
|
continue
|
|
if isinstance(value, numpy.floating):
|
|
row[key] = float(value)
|
|
else:
|
|
row[key] = value
|
|
rows.append(row)
|
|
if limit is not None:
|
|
rows = rows[:limit]
|
|
return rows
|
|
|
|
|
|
def serialize_design_result(
|
|
design_result,
|
|
design_mode,
|
|
model_summary,
|
|
design_options,
|
|
target_rows,
|
|
tube_rows,
|
|
ordered_domains,
|
|
ordered_strands,
|
|
):
|
|
designed_domains = []
|
|
designed_domain_map = getattr(design_result, "domains", {}) or {}
|
|
for item in ordered_domains:
|
|
designed_domain = designed_domain_map.get(item["object"])
|
|
designed_domains.append(
|
|
{
|
|
"name": item["name"],
|
|
"constraint": item["constraint"],
|
|
"sequence": str(designed_domain) if designed_domain is not None else None,
|
|
"length": len(str(designed_domain)) if designed_domain is not None else None,
|
|
}
|
|
)
|
|
|
|
designed_strands = []
|
|
for item in ordered_strands:
|
|
target_strand = item["object"]
|
|
analysis_strand = design_result.to_analysis[target_strand]
|
|
designed_strands.append(
|
|
{
|
|
"name": item["name"],
|
|
"constraint": item["constraint"],
|
|
"constraint_kind": item.get("constraint_kind", "sequence_constraint"),
|
|
"definition": item.get("definition", item["constraint"]),
|
|
"sequence": str(analysis_strand),
|
|
"length": len(str(analysis_strand)),
|
|
}
|
|
)
|
|
|
|
target_complexes = []
|
|
for target in target_rows:
|
|
target_complex = target["object"]
|
|
analysis_complex = design_result.to_analysis[target_complex]
|
|
target_complexes.append(
|
|
{
|
|
"name": target["name"],
|
|
"display": stringify_complex(analysis_complex),
|
|
"strand_names": list(target["strands"]),
|
|
"structure": target["structure"],
|
|
"sequence": flatten_sequence(analysis_complex),
|
|
"target_concentration_M": target.get("target_concentration_M"),
|
|
}
|
|
)
|
|
|
|
concentration_rows = []
|
|
concentration_table = getattr(getattr(design_result, "concentrations", None), "table", None)
|
|
if design_mode == "tube" and concentration_table is not None:
|
|
for record in concentration_table.to_dict("records"):
|
|
concentration_rows.append(
|
|
{
|
|
"complex_name": record["complex_name"],
|
|
"concentration_M": float(record["concentration"]),
|
|
"target_concentration_M": float(record["target_concentration"]),
|
|
"nucleotides": int(record["nucleotides"]),
|
|
}
|
|
)
|
|
concentration_rows.sort(key=lambda row: row["concentration_M"], reverse=True)
|
|
|
|
return {
|
|
"workflow": "design",
|
|
"mode": design_mode,
|
|
"model": model_summary,
|
|
"compute": ["design"],
|
|
"options": design_options,
|
|
"strands": designed_strands,
|
|
"complexes": target_complexes,
|
|
"total_complex_count": len(target_complexes),
|
|
"displayed_complex_count": len(target_complexes),
|
|
"tube": {
|
|
"name": tube_rows[0]["name"] if tube_rows else "design_tube",
|
|
"max_size": tube_rows[0]["max_size"] if tube_rows else design_options["off_target_max_size"],
|
|
"complex_concentrations": concentration_rows[: design_options["result_limit"]],
|
|
"total_complex_concentrations": len(concentration_rows),
|
|
} if design_mode == "tube" else None,
|
|
"design": {
|
|
"ensemble_defect": float(design_result.ensemble_defect),
|
|
"domains": designed_domains,
|
|
"stats": {
|
|
key: float(value) if isinstance(value, (int, float, numpy.floating)) else value
|
|
for key, value in design_result.stats.items()
|
|
},
|
|
"job_options": {
|
|
"f_stop": design_options["stop_condition"],
|
|
"seed": design_options["seed"],
|
|
"wobble_mutations": design_options["wobble_mutations"],
|
|
"max_time_seconds": design_options["max_time_seconds"],
|
|
},
|
|
"target_tubes": [
|
|
{
|
|
"name": row["name"],
|
|
"max_size": row["max_size"],
|
|
"on_targets": row["on_targets"],
|
|
}
|
|
for row in tube_rows
|
|
],
|
|
"targets": target_complexes,
|
|
"defects": {
|
|
"tubes": serialize_target_defect_rows(getattr(design_result.defects, "tubes", None)),
|
|
"complexes": serialize_target_defect_rows(getattr(design_result.defects, "complexes", None)),
|
|
"tube_complexes": serialize_target_defect_rows(getattr(design_result.defects, "tube_complexes", None)),
|
|
},
|
|
"concentrations": concentration_rows[: design_options["result_limit"]],
|
|
},
|
|
}
|
|
|
|
|
|
def sort_concentrations(complex_concentrations):
|
|
rows = []
|
|
for complex_obj, value in complex_concentrations.items():
|
|
rows.append(
|
|
{
|
|
"display": stringify_complex(complex_obj),
|
|
"strand_names": [strand.name for strand in complex_obj.strands],
|
|
"concentration_M": float(value),
|
|
}
|
|
)
|
|
rows.sort(key=lambda row: row["concentration_M"], reverse=True)
|
|
return rows
|
|
|
|
|
|
def run_job_payload(payload):
|
|
workflow = payload.get("workflow", "analysis")
|
|
mode = payload.get("mode", "tube")
|
|
model_input = payload.get("model") or {}
|
|
model = build_model(model_input)
|
|
model_summary = build_model_summary(model_input)
|
|
|
|
if workflow == "design":
|
|
design_options = parse_design_options(payload)
|
|
design_job_option_kwargs = {
|
|
"f_stop": design_options["stop_condition"],
|
|
"seed": design_options["seed"],
|
|
"wobble_mutations": design_options["wobble_mutations"],
|
|
}
|
|
if design_options["max_time_seconds"] > 0:
|
|
design_job_option_kwargs["max_time"] = design_options["max_time_seconds"]
|
|
design_job_options = DesignOptions(**design_job_option_kwargs)
|
|
design_domain_map, design_domains = build_design_domains(payload.get("design_domains") or [])
|
|
target_strand_map, design_strands = build_design_strands(
|
|
payload.get("strands") or [],
|
|
domain_map=design_domain_map,
|
|
)
|
|
target_rows, target_complex_map = parse_design_complexes(payload, target_strand_map)
|
|
hard_constraints = build_hard_constraints(payload, design_domain_map, target_strand_map)
|
|
soft_constraints = build_soft_constraints(
|
|
payload,
|
|
design_domain_map,
|
|
target_strand_map,
|
|
target_complex_map,
|
|
)
|
|
tube_rows = []
|
|
design_tubes = []
|
|
if mode == "tube":
|
|
tube_rows, design_tubes = parse_design_tubes(
|
|
payload,
|
|
target_rows,
|
|
target_complex_map,
|
|
design_options["off_target_max_size"],
|
|
)
|
|
validate_design_object_names(design_domains, design_strands, target_rows, tube_rows)
|
|
defect_weights = build_defect_weights(
|
|
payload,
|
|
mode,
|
|
design_domains,
|
|
design_strands,
|
|
target_rows,
|
|
tube_rows,
|
|
)
|
|
|
|
if mode == "tube":
|
|
tube_name = ((payload.get("tube") or {}).get("name") or "design_tube").strip() or "design_tube"
|
|
design_job = tube_design(
|
|
tubes=design_tubes,
|
|
model=model,
|
|
options=design_job_options,
|
|
hard_constraints=hard_constraints,
|
|
soft_constraints=soft_constraints,
|
|
defect_weights=defect_weights,
|
|
)
|
|
elif mode == "complex":
|
|
design_job = complex_design(
|
|
complexes=[row["object"] for row in target_rows],
|
|
model=model,
|
|
options=design_job_options,
|
|
hard_constraints=hard_constraints,
|
|
soft_constraints=soft_constraints,
|
|
defect_weights=defect_weights,
|
|
)
|
|
else:
|
|
raise ValueError(f"Unsupported design mode: {mode}")
|
|
|
|
results = design_job.run(trials=design_options["trials"])
|
|
best_result = min(results, key=lambda item: float(item.ensemble_defect))
|
|
return serialize_design_result(
|
|
best_result,
|
|
mode,
|
|
model_summary,
|
|
design_options,
|
|
target_rows,
|
|
tube_rows,
|
|
design_domains,
|
|
design_strands,
|
|
)
|
|
|
|
if workflow != "analysis":
|
|
raise ValueError(f"Unsupported workflow: {workflow}")
|
|
|
|
compute = parse_compute(payload)
|
|
options = parse_options(payload)
|
|
nupack_options = {
|
|
key: value
|
|
for key, value in options.items()
|
|
if key not in {"result_limit", "pairs_preview_size"}
|
|
}
|
|
strand_map, strands = build_strands(payload.get("strands") or [])
|
|
|
|
if mode == "tube":
|
|
wants_pairs = "pairs" in compute
|
|
tube_compute = [item for item in compute if item != "pairs"]
|
|
if not tube_compute:
|
|
tube_compute = ["pfunc"]
|
|
tube_cfg = payload.get("tube") or {}
|
|
max_size = int(tube_cfg.get("max_size", 2))
|
|
if max_size < 1 or max_size > 8:
|
|
raise ValueError("max_size must be between 1 and 8.")
|
|
strand_concentrations = {
|
|
item["object"]: unit_to_molar(item["concentration"], item["unit"])
|
|
for item in strands
|
|
}
|
|
tube = Tube(
|
|
strands=strand_concentrations,
|
|
complexes=SetSpec(max_size=max_size),
|
|
name=(tube_cfg.get("name") or "tube1").strip() or "tube1",
|
|
)
|
|
result = tube_analysis([tube], model=model, compute=tube_compute, options=nupack_options)
|
|
concentration_rows = sort_concentrations(result[tube].complex_concentrations)
|
|
total_complex_count = len(concentration_rows)
|
|
result_limit = options["result_limit"]
|
|
displayed_concentration_rows = concentration_rows[:result_limit]
|
|
concentration_by_display = {
|
|
row["display"]: row["concentration_M"] for row in displayed_concentration_rows
|
|
}
|
|
pair_data_by_display = {}
|
|
if wants_pairs:
|
|
displayed_complex_objects = [
|
|
complex_obj
|
|
for complex_obj in result.complexes
|
|
if stringify_complex(complex_obj) in concentration_by_display
|
|
]
|
|
if displayed_complex_objects:
|
|
pair_result = complex_analysis(
|
|
displayed_complex_objects,
|
|
model=model,
|
|
compute=["pairs"],
|
|
options=nupack_options,
|
|
)
|
|
pair_data_by_display = {
|
|
stringify_complex(complex_obj): data
|
|
for complex_obj, data in pair_result.complexes.items()
|
|
}
|
|
complexes = []
|
|
for complex_obj, data in result.complexes.items():
|
|
display_name = stringify_complex(complex_obj)
|
|
if display_name not in concentration_by_display:
|
|
continue
|
|
row = serialize_complex_result(
|
|
complex_obj,
|
|
data,
|
|
pairs_preview_size=options["pairs_preview_size"],
|
|
)
|
|
if wants_pairs:
|
|
attach_pairs_result(
|
|
row,
|
|
pair_data_by_display.get(display_name),
|
|
pairs_preview_size=options["pairs_preview_size"],
|
|
)
|
|
row["concentration_M"] = concentration_by_display[row["display"]]
|
|
complexes.append(row)
|
|
complexes.sort(key=lambda row: row["concentration_M"], reverse=True)
|
|
return {
|
|
"workflow": "analysis",
|
|
"mode": "tube",
|
|
"model": model_summary,
|
|
"compute": compute,
|
|
"options": options,
|
|
"strands": [
|
|
{
|
|
"name": item["name"],
|
|
"sequence": item["sequence"],
|
|
"concentration": item["concentration"],
|
|
"unit": item["unit"],
|
|
"concentration_M": unit_to_molar(item["concentration"], item["unit"]),
|
|
}
|
|
for item in strands
|
|
],
|
|
"tube": {
|
|
"name": tube.name,
|
|
"max_size": max_size,
|
|
"complex_concentrations": displayed_concentration_rows,
|
|
"total_complex_concentrations": total_complex_count,
|
|
},
|
|
"complexes": complexes,
|
|
"total_complex_count": total_complex_count,
|
|
"displayed_complex_count": len(complexes),
|
|
}
|
|
|
|
if mode == "complex":
|
|
complexes = parse_complex_lines(payload.get("complexes_text", ""), strand_map)
|
|
result = complex_analysis(complexes, model=model, compute=compute, options=nupack_options)
|
|
rows = [
|
|
serialize_complex_result(
|
|
complex_obj,
|
|
data,
|
|
pairs_preview_size=options["pairs_preview_size"],
|
|
)
|
|
for complex_obj, data in result.complexes.items()
|
|
]
|
|
rows.sort(key=lambda row: row["display"])
|
|
total_complex_count = len(rows)
|
|
rows = rows[: options["result_limit"]]
|
|
return {
|
|
"workflow": "analysis",
|
|
"mode": "complex",
|
|
"model": model_summary,
|
|
"compute": compute,
|
|
"options": options,
|
|
"strands": [
|
|
{
|
|
"name": item["name"],
|
|
"sequence": item["sequence"],
|
|
}
|
|
for item in strands
|
|
],
|
|
"complexes": rows,
|
|
"total_complex_count": total_complex_count,
|
|
"displayed_complex_count": len(rows),
|
|
}
|
|
|
|
raise ValueError(f"Unsupported analysis mode: {mode}")
|
|
|
|
|
|
def prune_jobs(now=None):
|
|
now = now or time.time()
|
|
expired_ids = []
|
|
for job_id, job in JOB_STORE.items():
|
|
updated_at = job.get("updated_at", job.get("created_at", now))
|
|
if now - updated_at > JOB_TTL_SECONDS:
|
|
expired_ids.append(job_id)
|
|
for job_id in expired_ids:
|
|
JOB_STORE.pop(job_id, None)
|
|
|
|
if len(JOB_STORE) > JOB_MAX_COUNT:
|
|
keep_ids = sorted(
|
|
JOB_STORE,
|
|
key=lambda job_id: JOB_STORE[job_id].get("created_at", 0),
|
|
reverse=True,
|
|
)[:JOB_MAX_COUNT]
|
|
keep_set = set(keep_ids)
|
|
for job_id in list(JOB_STORE):
|
|
if job_id not in keep_set:
|
|
JOB_STORE.pop(job_id, None)
|
|
|
|
|
|
def set_job_data(job):
|
|
if redis_enabled():
|
|
client = redis_client()
|
|
key = job_key(job["job_id"])
|
|
encoded = json.dumps(job, ensure_ascii=False)
|
|
if job.get("status") in TERMINAL_JOB_STATUSES:
|
|
client.setex(key, JOB_TTL_SECONDS, encoded)
|
|
else:
|
|
# Active jobs may legitimately outlive the result-retention TTL.
|
|
client.set(key, encoded)
|
|
return
|
|
with JOB_LOCK:
|
|
prune_jobs(job.get("updated_at"))
|
|
JOB_STORE[job["job_id"]] = dict(job)
|
|
|
|
|
|
def get_job_data(job_id, include_payload=False):
|
|
if redis_enabled():
|
|
raw = redis_client().get(job_key(job_id))
|
|
if raw is None:
|
|
return None
|
|
job = json.loads(raw)
|
|
if not include_payload:
|
|
job.pop("payload", None)
|
|
return job
|
|
with JOB_LOCK:
|
|
prune_jobs()
|
|
job = JOB_STORE.get(job_id)
|
|
if job is None:
|
|
return None
|
|
output = dict(job)
|
|
if not include_payload:
|
|
output.pop("payload", None)
|
|
return output
|
|
|
|
|
|
def should_keep_cancel_state(current, updates):
|
|
current_status = current.get("status") if current else None
|
|
next_status = updates.get("status")
|
|
if current_status in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
|
|
return next_status not in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}
|
|
return False
|
|
|
|
|
|
def update_job_data(job_id, **updates):
|
|
if redis_enabled():
|
|
client = redis_client()
|
|
key = job_key(job_id)
|
|
while True:
|
|
pipe = client.pipeline()
|
|
try:
|
|
pipe.watch(key)
|
|
raw = pipe.get(key)
|
|
current = json.loads(raw) if raw is not None else {"job_id": job_id, "created_at": time.time()}
|
|
if should_keep_cancel_state(current, updates):
|
|
pipe.unwatch()
|
|
return current
|
|
current.update(updates)
|
|
current["updated_at"] = time.time()
|
|
pipe.multi()
|
|
encoded = json.dumps(current, ensure_ascii=False)
|
|
if current.get("status") in TERMINAL_JOB_STATUSES:
|
|
pipe.setex(key, JOB_TTL_SECONDS, encoded)
|
|
else:
|
|
pipe.set(key, encoded)
|
|
pipe.execute()
|
|
return current
|
|
except redis.WatchError:
|
|
continue
|
|
finally:
|
|
pipe.reset()
|
|
|
|
with JOB_LOCK:
|
|
current = JOB_STORE.get(job_id)
|
|
if current is None:
|
|
current = {"job_id": job_id, "created_at": time.time()}
|
|
if should_keep_cancel_state(current, updates):
|
|
return dict(current)
|
|
current.update(updates)
|
|
current["updated_at"] = time.time()
|
|
prune_jobs(current["updated_at"])
|
|
JOB_STORE[job_id] = current
|
|
return dict(current)
|
|
|
|
|
|
def create_job(payload, owner):
|
|
job_id = uuid4().hex
|
|
now = time.time()
|
|
job = {
|
|
"job_id": job_id,
|
|
"user_id": owner["user_id"],
|
|
"status": "queued",
|
|
"error": None,
|
|
"result": None,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"payload": payload,
|
|
}
|
|
ACCOUNT_STORE.create_job(job_id, owner, payload, status="queued", created_at=now)
|
|
set_job_data(job)
|
|
log_event(f"accepted job_id={job_id} user_id={owner['user_id']} mode={payload.get('mode', 'tube')}")
|
|
|
|
if redis_enabled():
|
|
redis_client().lpush(JOB_QUEUE_KEY, job_id)
|
|
else:
|
|
thread = threading.Thread(target=_run_job, args=(job_id, payload), daemon=True)
|
|
thread.start()
|
|
return job_id
|
|
|
|
|
|
def _job_process_entry(payload, result_queue):
|
|
apply_thread_limits()
|
|
try:
|
|
result_queue.put({"status": "success", "result": run_job_payload(payload)})
|
|
except BaseException as exc:
|
|
result_queue.put(
|
|
{
|
|
"status": "error",
|
|
"error": {
|
|
"message": str(exc),
|
|
"traceback": traceback.format_exc(),
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def terminate_process(process):
|
|
if not process.is_alive():
|
|
return
|
|
if hasattr(process, "kill"):
|
|
process.kill()
|
|
else:
|
|
process.terminate()
|
|
process.join(timeout=2)
|
|
if process.is_alive():
|
|
process.terminate()
|
|
process.join(timeout=2)
|
|
|
|
|
|
def _run_job(job_id, payload):
|
|
started_at = time.time()
|
|
last_heartbeat_at = started_at
|
|
current = get_job_data(job_id)
|
|
if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
|
|
elapsed = round(time.time() - started_at, 3)
|
|
update_job_data(job_id, status=CANCELED_STATUS, payload=None, elapsed_seconds=elapsed)
|
|
ACCOUNT_STORE.update_job(job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed)
|
|
return
|
|
|
|
log_event(f"running job_id={job_id}")
|
|
update_job_data(job_id, status="running")
|
|
ACCOUNT_STORE.update_job(job_id, "running")
|
|
if redis_enabled():
|
|
redis_client().sadd(JOB_RUNNING_KEY, job_id)
|
|
|
|
ctx = multiprocessing.get_context("spawn")
|
|
result_queue = ctx.Queue(maxsize=1)
|
|
process = ctx.Process(target=_job_process_entry, args=(payload, result_queue), daemon=True)
|
|
message = None
|
|
|
|
try:
|
|
process.start()
|
|
while process.is_alive():
|
|
current = get_job_data(job_id)
|
|
if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
|
|
terminate_process(process)
|
|
elapsed = round(time.time() - started_at, 3)
|
|
update_job_data(
|
|
job_id,
|
|
status=CANCELED_STATUS,
|
|
error={"message": "Job canceled by user."},
|
|
result=None,
|
|
payload=None,
|
|
elapsed_seconds=elapsed,
|
|
)
|
|
ACCOUNT_STORE.update_job(
|
|
job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed
|
|
)
|
|
log_event(f"canceled job_id={job_id} elapsed={elapsed}s")
|
|
return
|
|
now = time.time()
|
|
if now - last_heartbeat_at >= JOB_HEARTBEAT_SECONDS:
|
|
update_job_data(job_id, heartbeat_at=now)
|
|
last_heartbeat_at = now
|
|
try:
|
|
message = result_queue.get_nowait()
|
|
break
|
|
except queue.Empty:
|
|
time.sleep(0.25)
|
|
|
|
process.join(timeout=2)
|
|
if message is None:
|
|
try:
|
|
message = result_queue.get_nowait()
|
|
except queue.Empty:
|
|
message = None
|
|
|
|
current = get_job_data(job_id)
|
|
if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
|
|
elapsed = round(time.time() - started_at, 3)
|
|
update_job_data(
|
|
job_id,
|
|
status=CANCELED_STATUS,
|
|
error={"message": "Job canceled by user."},
|
|
result=None,
|
|
payload=None,
|
|
elapsed_seconds=elapsed,
|
|
)
|
|
ACCOUNT_STORE.update_job(
|
|
job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed
|
|
)
|
|
log_event(f"canceled job_id={job_id} elapsed={elapsed}s")
|
|
return
|
|
|
|
elapsed = round(time.time() - started_at, 3)
|
|
if message and message.get("status") == "success":
|
|
update_job_data(job_id, status="success", result=message["result"], payload=None, elapsed_seconds=elapsed)
|
|
ACCOUNT_STORE.update_job(job_id, "success", result=message["result"], elapsed_seconds=elapsed)
|
|
log_event(f"success job_id={job_id} elapsed={elapsed}s")
|
|
elif message and message.get("status") == "error":
|
|
error = message.get("error") or {"message": "Job failed."}
|
|
update_job_data(
|
|
job_id,
|
|
status="error",
|
|
error=error,
|
|
payload=None,
|
|
elapsed_seconds=elapsed,
|
|
)
|
|
ACCOUNT_STORE.update_job(job_id, "error", error=error, elapsed_seconds=elapsed)
|
|
log_event(f"error job_id={job_id} elapsed={elapsed}s message={message.get('error', {}).get('message')}")
|
|
else:
|
|
error = {"message": f"Job process exited with code {process.exitcode}."}
|
|
update_job_data(
|
|
job_id,
|
|
status="error",
|
|
error=error,
|
|
payload=None,
|
|
elapsed_seconds=elapsed,
|
|
)
|
|
ACCOUNT_STORE.update_job(job_id, "error", error=error, elapsed_seconds=elapsed)
|
|
log_event(f"error job_id={job_id} elapsed={elapsed}s exitcode={process.exitcode}")
|
|
finally:
|
|
terminate_process(process)
|
|
result_queue.close()
|
|
result_queue.join_thread()
|
|
if redis_enabled():
|
|
redis_client().srem(JOB_RUNNING_KEY, job_id)
|
|
|
|
|
|
def cancel_job(job_id):
|
|
current = get_job_data(job_id, include_payload=True)
|
|
if current is None:
|
|
return None
|
|
status = current.get("status")
|
|
if status in TERMINAL_JOB_STATUSES:
|
|
output = dict(current)
|
|
output.pop("payload", None)
|
|
return output
|
|
|
|
if redis_enabled() and status == "queued":
|
|
redis_client().lrem(JOB_QUEUE_KEY, 0, job_id)
|
|
|
|
if status == "queued":
|
|
updated = update_job_data(
|
|
job_id,
|
|
status=CANCELED_STATUS,
|
|
error={"message": "Job canceled by user."},
|
|
result=None,
|
|
payload=None,
|
|
)
|
|
ACCOUNT_STORE.update_job(job_id, CANCELED_STATUS, error={"message": "Job canceled by user."})
|
|
else:
|
|
updated = update_job_data(
|
|
job_id,
|
|
status=CANCEL_REQUESTED_STATUS,
|
|
error={"message": "Cancellation requested."},
|
|
)
|
|
ACCOUNT_STORE.update_job(job_id, CANCEL_REQUESTED_STATUS, error={"message": "Cancellation requested."})
|
|
updated.pop("payload", None)
|
|
return updated
|
|
|
|
|
|
def get_job(job_id):
|
|
return get_job_data(job_id, include_payload=False)
|
|
|
|
|
|
def prune_shares(now=None):
|
|
if SHARE_MAX_COUNT < 1:
|
|
return
|
|
if redis_enabled():
|
|
client = redis_client()
|
|
extra = client.zcard(SHARE_INDEX_KEY) - SHARE_MAX_COUNT
|
|
if extra <= 0:
|
|
return
|
|
stale_ids = client.zrange(SHARE_INDEX_KEY, 0, extra - 1)
|
|
if stale_ids:
|
|
client.delete(*(share_key(share_id) for share_id in stale_ids))
|
|
client.zrem(SHARE_INDEX_KEY, *stale_ids)
|
|
return
|
|
|
|
keep_ids = sorted(
|
|
SHARE_STORE,
|
|
key=lambda share_id: SHARE_STORE[share_id].get("created_at", 0),
|
|
reverse=True,
|
|
)[:SHARE_MAX_COUNT]
|
|
keep_set = set(keep_ids)
|
|
for share_id in list(SHARE_STORE):
|
|
if share_id not in keep_set:
|
|
SHARE_STORE.pop(share_id, None)
|
|
|
|
|
|
def create_share(record):
|
|
payload = record.get("payload")
|
|
result = record.get("result")
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("Share payload must contain an input payload object.")
|
|
if not isinstance(result, dict):
|
|
raise ValueError("Share payload must contain a result object.")
|
|
|
|
now = time.time()
|
|
share_id = uuid4().hex[:16]
|
|
item = {
|
|
"id": share_id,
|
|
"created_at": now,
|
|
"created_at_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
|
|
"payload": payload,
|
|
"result": result,
|
|
"result_summary": record.get("result_summary") or {},
|
|
}
|
|
|
|
if redis_enabled():
|
|
client = redis_client()
|
|
client.set(share_key(share_id), json.dumps(item, ensure_ascii=False))
|
|
client.zadd(SHARE_INDEX_KEY, {share_id: now})
|
|
prune_shares(now)
|
|
return item
|
|
|
|
with SHARE_LOCK:
|
|
SHARE_STORE[share_id] = item
|
|
prune_shares(now)
|
|
return dict(item)
|
|
|
|
|
|
def get_share(share_id):
|
|
share_id = str(share_id or "").strip()
|
|
if not re.fullmatch(r"[0-9a-fA-F]{8,64}", share_id):
|
|
return None
|
|
if redis_enabled():
|
|
raw = redis_client().get(share_key(share_id))
|
|
return json.loads(raw) if raw else None
|
|
with SHARE_LOCK:
|
|
item = SHARE_STORE.get(share_id)
|
|
return dict(item) if item else None
|
|
|
|
|
|
def recover_interrupted_jobs():
|
|
client = redis_client()
|
|
recovered = 0
|
|
canceled = 0
|
|
for job_id in client.smembers(JOB_RUNNING_KEY):
|
|
raw = client.get(job_key(job_id))
|
|
if raw is None:
|
|
client.srem(JOB_RUNNING_KEY, job_id)
|
|
continue
|
|
job = json.loads(raw)
|
|
status = job.get("status")
|
|
if status == "running":
|
|
client.lrem(JOB_QUEUE_KEY, 0, job_id)
|
|
update_job_data(
|
|
job_id,
|
|
status="queued",
|
|
recovered_at=time.time(),
|
|
recovery_count=int(job.get("recovery_count", 0)) + 1,
|
|
)
|
|
client.lpush(JOB_QUEUE_KEY, job_id)
|
|
ACCOUNT_STORE.update_job(job_id, "queued")
|
|
recovered += 1
|
|
elif status == CANCEL_REQUESTED_STATUS:
|
|
update_job_data(
|
|
job_id,
|
|
status=CANCELED_STATUS,
|
|
error={"message": "Job canceled while the worker was restarting."},
|
|
result=None,
|
|
payload=None,
|
|
)
|
|
ACCOUNT_STORE.update_job(
|
|
job_id,
|
|
CANCELED_STATUS,
|
|
error={"message": "Job canceled while the worker was restarting."},
|
|
)
|
|
canceled += 1
|
|
client.srem(JOB_RUNNING_KEY, job_id)
|
|
if recovered or canceled:
|
|
log_event(f"recovered jobs queued={recovered} canceled={canceled}")
|
|
|
|
|
|
def run_worker_loop():
|
|
if not redis_enabled():
|
|
raise RuntimeError("Worker mode requires NP_REDIS_URL and the redis package.")
|
|
|
|
client = redis_client()
|
|
recover_interrupted_jobs()
|
|
worker_count = max(1, WORKER_CONCURRENCY)
|
|
log_event(
|
|
f"Starting worker loop on Redis queue {JOB_QUEUE_KEY} "
|
|
f"(concurrency={worker_count}, per_job_threads={max(1, PER_JOB_THREAD_LIMIT)})"
|
|
)
|
|
|
|
with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="np-job") as executor:
|
|
inflight = set()
|
|
|
|
while True:
|
|
finished = {future for future in inflight if future.done()}
|
|
if finished:
|
|
inflight.difference_update(finished)
|
|
for future in finished:
|
|
future.result()
|
|
|
|
if len(inflight) >= worker_count:
|
|
done, _ = wait(inflight, return_when=FIRST_COMPLETED, timeout=1)
|
|
inflight.difference_update(done)
|
|
for future in done:
|
|
future.result()
|
|
continue
|
|
|
|
item = client.brpop(JOB_QUEUE_KEY, timeout=2)
|
|
if not item:
|
|
continue
|
|
_, job_id = item
|
|
raw = client.get(job_key(job_id))
|
|
if raw is None:
|
|
continue
|
|
job = json.loads(raw)
|
|
if job.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
|
|
continue
|
|
payload = job.get("payload")
|
|
if payload is None:
|
|
continue
|
|
inflight.add(executor.submit(_run_job, job_id, payload))
|
|
|
|
|
|
EXAMPLE_PAYLOAD = {
|
|
"workflow": "analysis",
|
|
"mode": "tube",
|
|
"model": {
|
|
"material": "rna",
|
|
"ensemble": "stacking",
|
|
"celsius": 37,
|
|
"sodium": 1.0,
|
|
"magnesium": 0.0,
|
|
},
|
|
"compute": ["pfunc", "mfe", "pairs"],
|
|
"options": {
|
|
"num_sample": 20,
|
|
"energy_gap": 1.0,
|
|
"sparsity_fraction": 1.0,
|
|
"sparsity_threshold": 0.0,
|
|
"single_mfe": False,
|
|
"result_limit": 25,
|
|
"pairs_preview_size": 24,
|
|
},
|
|
"strands": [
|
|
{"name": "A", "sequence": "AGUCUAGGAU", "concentration": 1.0, "unit": "uM"},
|
|
{"name": "B", "sequence": "UUAACCCACG", "concentration": 2.0, "unit": "uM"},
|
|
],
|
|
"tube": {"name": "tube1", "max_size": 2},
|
|
"complexes_text": "A\nB\nA+B",
|
|
"design": {
|
|
"trials": 1,
|
|
"result_limit": 25,
|
|
"off_target_max_size": 2,
|
|
"stop_condition": 0.02,
|
|
"seed": 0,
|
|
"wobble_mutations": False,
|
|
"max_time_seconds": 0,
|
|
},
|
|
"design_domains": [
|
|
{"name": "a", "sequence": "N10"},
|
|
{"name": "b", "sequence": "N10"},
|
|
],
|
|
"design_complexes": [
|
|
{
|
|
"name": "AB_target",
|
|
"strands": "A+B",
|
|
"structure": "(10+)10",
|
|
}
|
|
],
|
|
"design_tubes": [
|
|
{
|
|
"name": "Tube 1",
|
|
"max_size": 2,
|
|
"on_targets": [
|
|
{"complex": "AB_target", "concentration": 1.0, "unit": "uM"},
|
|
],
|
|
}
|
|
],
|
|
"hard_constraints": [],
|
|
"soft_constraints": [],
|
|
"defect_weights": [],
|
|
}
|
|
|
|
# Use A=a and B=~a so the full-duplex target "(10+)10" is base-pair consistent.
|
|
DESIGN_TUBE_EXAMPLE_PAYLOAD = {
|
|
"workflow": "design",
|
|
"mode": "tube",
|
|
"model": {
|
|
"material": "rna",
|
|
"ensemble": "stacking",
|
|
"celsius": 37,
|
|
"sodium": 1.0,
|
|
"magnesium": 0.0,
|
|
},
|
|
"compute": ["pfunc", "mfe", "pairs"],
|
|
"options": {
|
|
"num_sample": 20,
|
|
"energy_gap": 1.0,
|
|
"sparsity_fraction": 1.0,
|
|
"sparsity_threshold": 0.0,
|
|
"single_mfe": False,
|
|
"result_limit": 25,
|
|
"pairs_preview_size": 24,
|
|
},
|
|
"strands": [
|
|
{"name": "A", "sequence": "a", "concentration": 1.0, "unit": "uM"},
|
|
{"name": "B", "sequence": "~a", "concentration": 1.0, "unit": "uM"},
|
|
],
|
|
"tube": {"name": "Design Tube", "max_size": 2},
|
|
"complexes_text": "A+B",
|
|
"design": {
|
|
"trials": 1,
|
|
"result_limit": 25,
|
|
"off_target_max_size": 2,
|
|
"stop_condition": 0.05,
|
|
"seed": 1,
|
|
"wobble_mutations": False,
|
|
"max_time_seconds": 0,
|
|
},
|
|
"design_domains": [
|
|
{"name": "a", "sequence": "N10"},
|
|
],
|
|
"design_complexes": [
|
|
{
|
|
"name": "AB_target",
|
|
"strands": "A+B",
|
|
"structure": "(10+)10",
|
|
}
|
|
],
|
|
"design_tubes": [
|
|
{
|
|
"name": "Design Tube",
|
|
"max_size": 2,
|
|
"on_targets": [
|
|
{"complex": "AB_target", "concentration": 1.0, "unit": "uM"},
|
|
],
|
|
}
|
|
],
|
|
"hard_constraints": [],
|
|
"soft_constraints": [],
|
|
"defect_weights": [],
|
|
}
|
|
|
|
DESIGN_COMPLEX_EXAMPLE_PAYLOAD = {
|
|
**DESIGN_TUBE_EXAMPLE_PAYLOAD,
|
|
"mode": "complex",
|
|
"tube": {"name": "Design Tube", "max_size": 1},
|
|
"design": {
|
|
**DESIGN_TUBE_EXAMPLE_PAYLOAD["design"],
|
|
"off_target_max_size": 1,
|
|
},
|
|
"design_tubes": [],
|
|
}
|
|
|
|
|
|
def get_example_payload(query):
|
|
params = parse_qs(query)
|
|
workflow = (params.get("workflow") or ["analysis"])[0]
|
|
mode = (params.get("mode") or ["tube"])[0]
|
|
if workflow == "design":
|
|
if mode == "complex":
|
|
return DESIGN_COMPLEX_EXAMPLE_PAYLOAD
|
|
return DESIGN_TUBE_EXAMPLE_PAYLOAD
|
|
return EXAMPLE_PAYLOAD
|
|
|
|
|
|
class AppHandler(BaseHTTPRequestHandler):
|
|
server_version = "NPReplica/0.1"
|
|
|
|
def _session(self):
|
|
if not hasattr(self, "_cached_session"):
|
|
self._cached_session = OIDC_AUTH.current_session(self.headers)
|
|
if self._cached_session:
|
|
ACCOUNT_STORE.upsert_user(self._cached_session["user"])
|
|
return self._cached_session
|
|
|
|
def _require_user(self):
|
|
session = self._session()
|
|
if session is None:
|
|
self._respond(*json_bytes({"error": "Authentication required", "login_url": "/auth/login"}, status=HTTPStatus.UNAUTHORIZED))
|
|
return None
|
|
return session["user"]
|
|
|
|
def _read_json(self, max_bytes=16 * 1024 * 1024):
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
if length < 0 or length > max_bytes:
|
|
raise ValueError(f"Request payload is too large. Limit is {max_bytes} bytes.")
|
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
|
|
|
def _redirect(self, location, *, cookie=None):
|
|
headers = {"Location": location}
|
|
if cookie:
|
|
headers["Set-Cookie"] = cookie
|
|
self._respond(HTTPStatus.FOUND, "text/plain; charset=utf-8", b"Redirecting", extra_headers=headers)
|
|
|
|
def _require_page_user(self, next_path):
|
|
if self._session() is not None:
|
|
return True
|
|
self._redirect(f"/auth/login?{urlencode({'next': next_path})}")
|
|
return False
|
|
|
|
def _owned_job(self, user, job_id, include_content=True):
|
|
if ACCOUNT_STORE.owner_id(job_id) != user["user_id"]:
|
|
return None
|
|
live = get_job(job_id)
|
|
if live is not None:
|
|
live.pop("user_id", None)
|
|
return live
|
|
return ACCOUNT_STORE.get_job(user["user_id"], job_id, include_content=include_content)
|
|
|
|
def do_GET(self):
|
|
parsed = urlparse(self.path)
|
|
|
|
if parsed.path == "/auth/login":
|
|
next_path = (parse_qs(parsed.query).get("next") or ["/"])[0]
|
|
try:
|
|
self._redirect(OIDC_AUTH.begin_login(next_path))
|
|
except Exception as exc:
|
|
self._respond(*json_bytes({"error": f"Unable to start login: {exc}"}, status=HTTPStatus.BAD_GATEWAY))
|
|
return
|
|
|
|
if parsed.path == "/auth/callback":
|
|
try:
|
|
session_id, user, next_path = OIDC_AUTH.complete_login(parse_qs(parsed.query))
|
|
ACCOUNT_STORE.upsert_user(user)
|
|
self._redirect(next_path, cookie=OIDC_AUTH.cookie_header(session_id))
|
|
except Exception as exc:
|
|
log_event(f"OIDC callback failed: {exc}")
|
|
self._respond(*json_bytes({"error": f"Login failed: {exc}"}, status=HTTPStatus.BAD_REQUEST))
|
|
return
|
|
|
|
if parsed.path == "/auth/logout":
|
|
session = self._session()
|
|
try:
|
|
location = OIDC_AUTH.logout_url(session)
|
|
except Exception:
|
|
location = "/"
|
|
OIDC_AUTH.delete_session(session)
|
|
self._redirect(location, cookie=OIDC_AUTH.clear_cookie_header())
|
|
return
|
|
|
|
share_page = re.fullmatch(r"/share/([A-Za-z0-9_-]{8,128})", parsed.path)
|
|
if share_page:
|
|
self._respond_file(INDEX_PATH, cache_control="public, max-age=60, stale-while-revalidate=86400")
|
|
return
|
|
|
|
legacy_share = (parse_qs(parsed.query).get("share") or [""])[0]
|
|
if parsed.path == "/" and re.fullmatch(r"[A-Za-z0-9_-]{8,128}", legacy_share):
|
|
self._respond_file(INDEX_PATH, cache_control="public, max-age=60, stale-while-revalidate=86400")
|
|
return
|
|
|
|
if parsed.path == "/":
|
|
if not self._require_page_user(self.path):
|
|
return
|
|
self._respond_file(INDEX_PATH, cache_control="private, no-cache")
|
|
return
|
|
|
|
if parsed.path in {"/favicon.svg", "/favicon.ico"}:
|
|
self._respond_file(FAVICON_PATH, cache_control="public, max-age=86400")
|
|
return
|
|
|
|
if parsed.path == "/design-guide.html":
|
|
if not self._require_page_user(self.path):
|
|
return
|
|
self._respond_file(GUIDE_PATH, cache_control="public, max-age=3600")
|
|
return
|
|
|
|
if parsed.path == "/health":
|
|
stats = job_stats()
|
|
self._respond(
|
|
*json_bytes(
|
|
{
|
|
"status": "ok",
|
|
"job_backend": "redis" if redis_enabled() else "memory",
|
|
"run_mode": RUN_MODE,
|
|
"queue_depth": queue_size(),
|
|
"jobs_running": stats["running"],
|
|
"jobs_queued": stats["queued"],
|
|
"job_ttl_seconds": JOB_TTL_SECONDS,
|
|
"worker_concurrency": max(1, WORKER_CONCURRENCY),
|
|
"per_job_thread_limit": max(1, PER_JOB_THREAD_LIMIT),
|
|
"nupack_threads": int(getattr(nupack_config, "threads", PER_JOB_THREAD_LIMIT)),
|
|
"nupack_cache_gb": float(getattr(nupack_config, "cache", NUPACK_CACHE_GB)),
|
|
}
|
|
)
|
|
)
|
|
return
|
|
|
|
if parsed.path == "/api/me":
|
|
user = self._require_user()
|
|
if user is None:
|
|
return
|
|
self._respond(*json_bytes({"status": "success", "user": user, "usage": ACCOUNT_STORE.usage(user["user_id"])}))
|
|
return
|
|
|
|
if parsed.path == "/api/history":
|
|
user = self._require_user()
|
|
if user is None:
|
|
return
|
|
params = {key: values[0] for key, values in parse_qs(parsed.query).items() if values}
|
|
self._respond(*json_bytes({"status": "success", **ACCOUNT_STORE.list_jobs(user["user_id"], params)}))
|
|
return
|
|
|
|
if parsed.path.startswith("/api/history/"):
|
|
user = self._require_user()
|
|
if user is None:
|
|
return
|
|
job_id = parsed.path.rsplit("/", 1)[-1]
|
|
job = ACCOUNT_STORE.get_job(user["user_id"], job_id, include_content=True)
|
|
if job is None:
|
|
self._respond(*json_bytes({"error": "History record not found"}, status=HTTPStatus.NOT_FOUND))
|
|
return
|
|
self._respond(*json_bytes({"status": "success", "item": job}))
|
|
return
|
|
|
|
if parsed.path == "/api/account/shares":
|
|
user = self._require_user()
|
|
if user is None:
|
|
return
|
|
self._respond(*json_bytes({"status": "success", "items": ACCOUNT_STORE.list_shares(user["user_id"])}))
|
|
return
|
|
|
|
if parsed.path == "/api/example":
|
|
user = self._require_user()
|
|
if user is None:
|
|
return
|
|
self._respond(*json_bytes(get_example_payload(parsed.query)))
|
|
return
|
|
|
|
if parsed.path.startswith("/api/jobs/"):
|
|
user = self._require_user()
|
|
if user is None:
|
|
return
|
|
job_id = parsed.path.rsplit("/", 1)[-1]
|
|
job = self._owned_job(user, job_id)
|
|
if job is None:
|
|
self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND))
|
|
return
|
|
self._respond(*json_bytes(job))
|
|
return
|
|
|
|
if parsed.path.startswith("/api/shares/"):
|
|
share_id = parsed.path.rsplit("/", 1)[-1]
|
|
share = ACCOUNT_STORE.resolve_share(share_id) or get_share(share_id)
|
|
if share is None:
|
|
self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND))
|
|
return
|
|
self._respond(*json_bytes({"status": "success", "share": share}))
|
|
return
|
|
|
|
self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND))
|
|
|
|
def do_POST(self):
|
|
parsed = urlparse(self.path)
|
|
user = self._require_user()
|
|
if user is None:
|
|
return
|
|
try:
|
|
if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/cancel"):
|
|
job_id = parsed.path.split("/")[-2]
|
|
if ACCOUNT_STORE.owner_id(job_id) != user["user_id"]:
|
|
self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND))
|
|
return
|
|
job = cancel_job(job_id)
|
|
if job is None:
|
|
self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND))
|
|
return
|
|
job.pop("user_id", None)
|
|
self._respond(*json_bytes({"status": "success", "job": job}))
|
|
return
|
|
|
|
if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/shares"):
|
|
job_id = parsed.path.split("/")[-2]
|
|
body = self._read_json()
|
|
expires_in = body.get("expires_in")
|
|
if expires_in not in {None, "", 0}:
|
|
expires_in = int(expires_in)
|
|
if expires_in < 60:
|
|
raise ValueError("Share duration must be at least 60 seconds.")
|
|
else:
|
|
expires_in = None
|
|
share = ACCOUNT_STORE.create_share(user["user_id"], job_id, expires_in)
|
|
self._respond(*json_bytes({"status": "success", "share": share, "url": f"/share/{share['share_id']}"}, status=HTTPStatus.CREATED))
|
|
return
|
|
|
|
if parsed.path == "/api/jobs":
|
|
payload = self._read_json()
|
|
job_id = create_job(payload, user)
|
|
self._respond(*json_bytes({"status": "accepted", "job_id": job_id}, status=HTTPStatus.ACCEPTED))
|
|
return
|
|
|
|
if parsed.path == "/api/history/import":
|
|
body = self._read_json(max_bytes=64 * 1024 * 1024)
|
|
entries = body.get("history") if isinstance(body, dict) else None
|
|
if not isinstance(entries, list):
|
|
raise ValueError("History import requires a history array.")
|
|
imported = ACCOUNT_STORE.import_history(user, entries)
|
|
self._respond(*json_bytes({"status": "success", "imported": imported}))
|
|
return
|
|
|
|
if parsed.path == "/api/shares":
|
|
body = self._read_json()
|
|
job_id = str(body.get("job_id") or "")
|
|
if not job_id:
|
|
raise ValueError("Cloud shares require a job_id.")
|
|
share = ACCOUNT_STORE.create_share(user["user_id"], job_id, body.get("expires_in"))
|
|
self._respond(*json_bytes({"status": "success", "share_id": share["share_id"], "url": f"/share/{share['share_id']}"}, status=HTTPStatus.CREATED))
|
|
return
|
|
|
|
if parsed.path == "/api/analyze":
|
|
payload = self._read_json()
|
|
job_id = uuid4().hex
|
|
started = time.time()
|
|
ACCOUNT_STORE.create_job(job_id, user, payload, status="running", created_at=started)
|
|
try:
|
|
result = run_job_payload(payload)
|
|
except Exception as exc:
|
|
elapsed = round(time.time() - started, 3)
|
|
ACCOUNT_STORE.update_job(job_id, "error", error={"message": str(exc), "traceback": traceback.format_exc()}, elapsed_seconds=elapsed)
|
|
raise
|
|
elapsed = round(time.time() - started, 3)
|
|
ACCOUNT_STORE.update_job(job_id, "success", result=result, elapsed_seconds=elapsed)
|
|
self._respond(*json_bytes({"status": "success", "job_id": job_id, "result": result}))
|
|
return
|
|
|
|
self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND))
|
|
except Exception as exc:
|
|
self._respond(
|
|
*json_bytes(
|
|
{
|
|
"status": "error",
|
|
"error": str(exc),
|
|
"traceback": traceback.format_exc(),
|
|
},
|
|
status=HTTPStatus.BAD_REQUEST,
|
|
)
|
|
)
|
|
|
|
def do_PATCH(self):
|
|
parsed = urlparse(self.path)
|
|
user = self._require_user()
|
|
if user is None:
|
|
return
|
|
try:
|
|
if parsed.path.startswith("/api/account/shares/"):
|
|
share_id = parsed.path.rsplit("/", 1)[-1]
|
|
body = self._read_json()
|
|
expires_in = body["expires_in"] if "expires_in" in body else "unchanged"
|
|
share = ACCOUNT_STORE.update_share(
|
|
user["user_id"], share_id, active=body.get("active"), expires_in=expires_in
|
|
)
|
|
if share is None:
|
|
self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND))
|
|
return
|
|
self._respond(*json_bytes({"status": "success", "share": share}))
|
|
return
|
|
self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND))
|
|
except Exception as exc:
|
|
self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST))
|
|
|
|
def do_DELETE(self):
|
|
parsed = urlparse(self.path)
|
|
user = self._require_user()
|
|
if user is None:
|
|
return
|
|
try:
|
|
if parsed.path.startswith("/api/history/"):
|
|
job_id = parsed.path.rsplit("/", 1)[-1]
|
|
if not ACCOUNT_STORE.delete_job(user["user_id"], job_id):
|
|
self._respond(*json_bytes({"error": "History record not found"}, status=HTTPStatus.NOT_FOUND))
|
|
return
|
|
self._respond(*json_bytes({"status": "success"}))
|
|
return
|
|
if parsed.path.startswith("/api/account/shares/"):
|
|
share_id = parsed.path.rsplit("/", 1)[-1]
|
|
share = ACCOUNT_STORE.update_share(user["user_id"], share_id, active=False)
|
|
if share is None:
|
|
self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND))
|
|
return
|
|
self._respond(*json_bytes({"status": "success", "share": share}))
|
|
return
|
|
self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND))
|
|
except Exception as exc:
|
|
self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST))
|
|
|
|
def log_message(self, format_, *args):
|
|
print(f"{self.address_string()} - {format_ % args}")
|
|
|
|
def _respond_file(self, path, cache_control):
|
|
etag = file_etag(path)
|
|
if self.headers.get("If-None-Match") == etag:
|
|
self.send_response(HTTPStatus.NOT_MODIFIED)
|
|
self.send_header("ETag", etag)
|
|
self.send_header("Cache-Control", cache_control)
|
|
self.end_headers()
|
|
return
|
|
status, content_type, body = html_bytes(path)
|
|
self._respond(status, content_type, body, cache_control=cache_control, extra_headers={"ETag": etag})
|
|
|
|
def _respond(self, status, content_type, body, cache_control=None, extra_headers=None):
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", cache_control or "no-store, max-age=0")
|
|
if cache_control is None:
|
|
self.send_header("Pragma", "no-cache")
|
|
self.send_header("Expires", "0")
|
|
for key, value in (extra_headers or {}).items():
|
|
self.send_header(key, value)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
|
|
def main():
|
|
apply_thread_limits()
|
|
ACCOUNT_STORE.initialize()
|
|
if RUN_MODE == "worker":
|
|
run_worker_loop()
|
|
return
|
|
server = ThreadingHTTPServer((HOST, PORT), AppHandler)
|
|
log_event(
|
|
f"Serving NP replica on http://{HOST}:{PORT} "
|
|
f"(jobs via {'redis' if redis_enabled() else 'memory'}, "
|
|
f"per_job_threads={max(1, PER_JOB_THREAD_LIMIT)})"
|
|
)
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|