diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f4629c2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,84 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+# A new push makes the previous run pointless.
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ python:
+ name: Python ${{ matrix.python }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ python: ["3.10", "3.11", "3.12", "3.13"]
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python }}
+ - run: pip install -e ".[dev]"
+ - run: pytest -q
+
+ splice:
+ name: Python 3.12 with MaxEntScan
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ # The default splice backend prefers MaxEntScan when it is installed, so
+ # without this job that path is never exercised in CI.
+ - run: pip install -e ".[dev]" "git+https://github.com/kepbod/maxentpy.git"
+ - name: Confirm MaxEntScan really is in use
+ run: python -c "from codonopt.splice import maxent_available; assert maxent_available()"
+ - run: pytest -q
+
+ web:
+ name: Interface (type-check, build, end-to-end)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ - uses: actions/setup-node@v5
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - run: pip install -e .
+ - run: npm ci
+ working-directory: web
+ - name: Type-check and verify the committed build is current
+ run: npm run check
+ working-directory: web
+ - run: npx playwright install --with-deps chromium
+ working-directory: web
+ - name: End-to-end tests against the Python server
+ run: npm test
+ working-directory: web
+ env:
+ CI: "true"
+ # Traces and screenshots, so a failure here can be read rather than guessed at.
+ - uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: playwright-report
+ path: |
+ web/playwright-report
+ web/test-results
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 71dbf08..8ee937f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,8 @@ dist/
.pytest_cache/
.venv/
venv/
+
+# web interface tooling
+web/node_modules/
+web/test-results/
+web/playwright-report/
diff --git a/README.md b/README.md
index 9b20491..c79142d 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,52 @@
-# computational_codon_optimization
+
codonopt
+
+
+ Codon optimization for gene synthesis and expression.
+ Rewrite the DNA of an open reading frame so it is easier to make and express,
+ without changing the protein it encodes.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Give it a protein, or an existing coding sequence, and it returns a synonymous
+sequence that balances GC content, repeats, hairpins, homopolymers, forbidden
+motifs, cryptic splice sites and host codon usage in a single score. It is a
+pure-Python package with no runtime dependencies, a command line, a Python API
+and a local web interface.
-Rewrites the DNA of an open reading frame so it is easier to synthesise and
-express, without changing the protein it encodes.
+## Highlights
-Give it a protein (or an existing coding sequence) and it returns a synonymous
-sequence that balances GC content, repeats, hairpins, homopolymers, forbidden
-motifs, cryptic splice sites and host codon usage in a single score.
+- **One objective, not four passes.** Every concern is a weighted penalty in one score, so fixing one problem cannot silently undo another.
+- **Fast.** Scoring is incremental: a codon swap re-scores only what sits near it. An 800-residue protein takes 19 s, against 234 s for the scripts this replaces.
+- **The protein cannot change.** The only move is swapping a codon for a synonymous one, so correctness holds by construction, not by checking afterwards.
+- **Three ways in.** `codonopt` on the command line, a small Python API, and a browser or desktop interface.
+- **Checked against brute force.** The tests compare the incremental score with a from-scratch recomputation and with simple reference implementations.
## Quickstart
```bash
pip install -e .
+codonopt gui # the web interface (opens in your browser)
+```
+
+Or from the command line:
+
+```bash
codonopt optimize --add-stop --avoid golden_gate,EcoRI --seed 5 --splice-backend motif \
--protein MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEENFKALVLIAFAQYLQQCPF
```
@@ -38,6 +73,21 @@ Splice acceptor sites 1 0
`--json FILE` writes the same numbers, the per-term score and the sequence.
+## Web interface
+
+`codonopt gui` starts a local server and opens the interface in a native window
+if [pywebview](https://pywebview.flowrl.com/) is installed (`pip install -e ".[gui]"`),
+otherwise in your browser. Paste or open a protein or DNA sequence, pick a host
+table and what to keep out, and press **Optimize**. You get:
+
+- the sequence with every changed codon highlighted, and the protein above it;
+- a before/after table, a GC plot along the sequence and the score breakdown;
+- copy and download for the DNA, FASTA and a report; runs can be cancelled and keep the best result so far.
+
+It is local only: the server binds to loopback, checks Host and Origin, requires
+a per-run token, sends a strict content security policy and never touches your
+files. Add `--no-window --no-browser` to just serve it.
+
## What it optimizes
| Criterion | Why | Default |
@@ -51,10 +101,7 @@ Splice acceptor sites 1 0
| Cryptic splice sites | Unwanted splicing of a transgene | MaxEntScan or a consensus screen |
| Codon usage | Rare codons slow translation | host codon adaptation index |
-The protein cannot change: the only move the optimizer has is swapping a codon
-for a synonymous one.
-
-## Commands
+## Command line
```bash
codonopt optimize --protein-fasta gene.faa --out gene.fna # design from a protein
@@ -77,7 +124,7 @@ codonopt motifs # known motifs and g
`codonopt optimize --help` lists the rest. MaxEntScan is optional and not on
PyPI: `pip install "git+https://github.com/kepbod/maxentpy.git"`.
-## Python
+## Python API
```python
from codonopt import DesignConfig, OptimizerSettings, get_table, optimize, resolve_motifs
@@ -92,8 +139,8 @@ improves an existing sequence.
## How it works
-Each penalty term is tied to a position — a GC window, a homopolymer, a motif
-match, a splice window, a codon — so a codon swap only re-scores what sits near
+Each penalty term is tied to a position (a GC window, a homopolymer, a motif
+match, a splice window, a codon), so a codon swap only re-scores what sits near
it. Repeats and hairpins are not local, so an incrementally updated k-mer index
carries them. A candidate swap costs time proportional to the window sizes, not
the sequence length.
@@ -104,6 +151,29 @@ followed by simulated annealing and several restarts.
incremental path against it and against brute-force implementations in
`codonopt.metrics`.
+## Development
+
+The Python package needs nothing but Python. The interface is written in
+TypeScript in [`web/`](web/) and compiled with esbuild to plain HTML, CSS and
+JavaScript in `src/codonopt/web/`. That output is committed, so running the
+interface never needs Node; Node is only for working on it.
+
+```bash
+pip install -e ".[dev]" && pytest # Python side
+
+cd web && npm install
+npm run build # regenerate src/codonopt/web
+npm run check # type-check, and fail if the committed build is stale
+npm test # Playwright end-to-end tests against the real Python server
+```
+
+```
+src/codonopt/ optimizer, scoring, CLI and the local server (stdlib only)
+web/ TypeScript and CSS sources, build script, Playwright tests
+benchmarks/ reproducible performance comparison
+legacy/ the two original scripts this replaces, kept for reference
+```
+
## History
This replaces two work-in-progress scripts, kept in [`legacy/`](legacy/). They
@@ -114,13 +184,6 @@ takes 19 s instead of 234 s and ends with a better codon adaptation index
(0.90 vs 0.72) and no forbidden motifs (vs four). Details and a reproducible
benchmark are in [`legacy/README.md`](legacy/README.md).
-## Development
-
-```bash
-pip install -e ".[dev]"
-pytest
-```
-
## Limitations
- Hairpins are inverted repeats, not free-energy folds.
@@ -128,3 +191,7 @@ pytest
for production work. The consensus splice screen is a filter, not a model.
- The search is local. A given `--seed` is reproducible, but another seed may do
slightly better; use `--restarts` or `--anneal` when it counts.
+
+## License
+
+[GPL-3.0](LICENSE) © Thom de Hoog
diff --git a/docs/interface-dark.png b/docs/interface-dark.png
new file mode 100644
index 0000000..12b3b4f
Binary files /dev/null and b/docs/interface-dark.png differ
diff --git a/docs/interface-light.png b/docs/interface-light.png
new file mode 100644
index 0000000..9060f87
Binary files /dev/null and b/docs/interface-light.png differ
diff --git a/pyproject.toml b/pyproject.toml
index 365dad1..d4d409a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,8 @@ dependencies = []
# MaxEntScan splice site scoring. Not on PyPI, so it is installed from source:
# pip install "git+https://github.com/kepbod/maxentpy.git"
splice = ["maxentpy"]
+# Optional native window for `codonopt gui`; without it the browser is used.
+gui = ["pywebview"]
dev = ["pytest>=7"]
[project.scripts]
@@ -30,6 +32,11 @@ codonopt = "codonopt.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
+# The pre-built web interface ships inside the package, so using it needs no
+# Node toolchain.
+[tool.setuptools.package-data]
+codonopt = ["web/*"]
+
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
diff --git a/src/codonopt/__main__.py b/src/codonopt/__main__.py
new file mode 100644
index 0000000..395bc05
--- /dev/null
+++ b/src/codonopt/__main__.py
@@ -0,0 +1,7 @@
+"""Allow ``python -m codonopt``."""
+
+import sys
+
+from .cli import main
+
+sys.exit(main())
diff --git a/src/codonopt/cli.py b/src/codonopt/cli.py
index 2db539d..5519d0b 100644
--- a/src/codonopt/cli.py
+++ b/src/codonopt/cli.py
@@ -2,6 +2,7 @@
codonopt optimize --protein-fasta gene.faa --table human --out gene.fna
codonopt report --dna-fasta gene.fna
+ codonopt gui
codonopt tables
codonopt motifs
"""
@@ -71,6 +72,29 @@ def build_parser() -> argparse.ArgumentParser:
)
report_parser.set_defaults(handler=_run_report)
+ gui_parser = subparsers.add_parser(
+ "gui",
+ help="open the graphical interface (a native window with pywebview, "
+ "otherwise your browser)",
+ )
+ gui_parser.add_argument(
+ "--port", type=int, default=0, metavar="N", help="port to listen on (default: any free one)"
+ )
+ gui_parser.add_argument(
+ "--no-window",
+ action="store_true",
+ help="serve the interface without opening a native window",
+ )
+ gui_parser.add_argument(
+ "--no-browser",
+ action="store_true",
+ help="do not open the default browser (use with --no-window)",
+ )
+ gui_parser.add_argument(
+ "--verbose", action="store_true", help="log each request to stderr"
+ )
+ gui_parser.set_defaults(handler=_run_gui)
+
tables_parser = subparsers.add_parser("tables", help="list codon usage tables")
tables_parser.set_defaults(handler=_run_tables)
@@ -388,6 +412,17 @@ def _run_report(args: argparse.Namespace) -> int:
return 0
+def _run_gui(args: argparse.Namespace) -> int:
+ from .server import run_gui
+
+ return run_gui(
+ args.port,
+ window=not args.no_window,
+ open_browser=not args.no_browser,
+ verbose=args.verbose,
+ )
+
+
def _run_tables(args: argparse.Namespace) -> int:
for name in available_tables():
table = get_table(name)
diff --git a/src/codonopt/config.py b/src/codonopt/config.py
index 759074d..4bca2e8 100644
--- a/src/codonopt/config.py
+++ b/src/codonopt/config.py
@@ -149,6 +149,9 @@ class OptimizerSettings:
codons by host frequency, ``"best"`` takes the most frequent codon
everywhere, ``"random"`` samples uniformly.
progress: Called with a short status line after each phase.
+ should_stop: Called regularly during the search; once it returns True
+ the search stops cleanly and returns the best sequence found so
+ far, which is how a caller cancels a long run.
"""
restarts: int = 1
@@ -160,6 +163,7 @@ class OptimizerSettings:
seed: int | None = None
seed_mode: str = "weighted"
progress: object | None = None
+ should_stop: object | None = None
def __post_init__(self) -> None:
if self.restarts < 1:
diff --git a/src/codonopt/metrics.py b/src/codonopt/metrics.py
index 33bec6a..e9682cb 100644
--- a/src/codonopt/metrics.py
+++ b/src/codonopt/metrics.py
@@ -19,6 +19,7 @@
"HomopolymerRun",
"RepeatReport",
"Hairpin",
+ "gc_profile",
"gc_window_report",
"homopolymer_runs",
"homopolymer_excess",
@@ -133,6 +134,24 @@ def gc_window_report(
)
+def gc_profile(
+ sequence: str, *, window: int = 30, step: int = 3
+) -> list[tuple[int, float]]:
+ """Return ``(start, GC fraction)`` for every scored window.
+
+ The same windows :func:`gc_window_report` scores, so a plot of this profile
+ shows exactly what the penalty sees.
+ """
+ if not sequence:
+ return []
+ prefix = gc_prefix(sequence)
+ profile = []
+ for start in window_starts(len(sequence), window, step):
+ end = min(start + window, len(sequence))
+ profile.append((start, (prefix[end] - prefix[start]) / (end - start)))
+ return profile
+
+
def _check_fraction_range(target_range: tuple[float, float]) -> tuple[float, float]:
low, high = target_range
if not (0.0 <= low <= high <= 1.0):
diff --git a/src/codonopt/optimize.py b/src/codonopt/optimize.py
index 574e12b..c484cc7 100644
--- a/src/codonopt/optimize.py
+++ b/src/codonopt/optimize.py
@@ -61,6 +61,7 @@ class OptimizationResult:
restarts_run: Restarts actually run before the time budget ran out.
elapsed: Wall-clock seconds spent.
hit_time_limit: True if the time budget stopped the search early.
+ cancelled: True if ``should_stop`` stopped the search early.
"""
sequence: str
@@ -76,6 +77,7 @@ class OptimizationResult:
restarts_run: int = 0
elapsed: float = 0.0
hit_time_limit: bool = False
+ cancelled: bool = False
notes: list[str] = field(default_factory=list)
@property
@@ -148,6 +150,13 @@ def optimize(
deadline = (
started + settings.max_seconds if settings.max_seconds is not None else None
)
+ should_stop: Callable[[], bool] | None = settings.should_stop # type: ignore[assignment]
+
+ def expired() -> bool:
+ """True once the time budget is spent or the caller asked to stop."""
+ if should_stop is not None and should_stop():
+ return True
+ return deadline is not None and time.perf_counter() >= deadline
best: SequenceState | None = None
best_initial: tuple[str, float] | None = None
@@ -155,7 +164,7 @@ def optimize(
hit_limit = False
for restart in range(settings.restarts):
- if deadline is not None and time.perf_counter() >= deadline and best is not None:
+ if best is not None and expired():
hit_limit = True
break
seed = (
@@ -172,7 +181,7 @@ def optimize(
)
local_sweeps, local_evaluated, local_accepted, stopped = _greedy(
- state, rng, settings, deadline
+ state, rng, settings, expired
)
sweeps += local_sweeps
evaluated += local_evaluated
@@ -181,7 +190,7 @@ def optimize(
if settings.anneal_steps and not stopped:
annealed, anneal_evaluated, anneal_accepted, stopped = _anneal(
- state, rng, settings, deadline
+ state, rng, settings, expired
)
evaluated += anneal_evaluated
accepted += anneal_accepted
@@ -201,6 +210,7 @@ def optimize(
if translate(best.sequence) != protein: # pragma: no cover - safety net
raise AssertionError("optimizer changed the encoded protein")
+ cancelled = bool(hit_limit and should_stop is not None and should_stop())
initial_state = SequenceState(best_initial[0], config)
return OptimizationResult(
sequence=best.sequence,
@@ -215,7 +225,8 @@ def optimize(
moves_accepted=accepted,
restarts_run=restarts_run,
elapsed=time.perf_counter() - started,
- hit_time_limit=hit_limit,
+ hit_time_limit=hit_limit and not cancelled,
+ cancelled=cancelled,
)
@@ -235,7 +246,7 @@ def _greedy(
state: SequenceState,
rng: random.Random,
settings: OptimizerSettings,
- deadline: float | None,
+ expired: Callable[[], bool],
) -> tuple[int, int, int, bool]:
"""Run greedy sweeps until nothing improves. Returns counters."""
order = list(range(state.codon_count))
@@ -245,9 +256,8 @@ def _greedy(
sweeps += 1
improved = 0
for counter, position in enumerate(order):
- if deadline is not None and not counter % 64:
- if time.perf_counter() >= deadline:
- return sweeps, evaluated, accepted, True
+ if not counter % 64 and expired():
+ return sweeps, evaluated, accepted, True
options = state.alternatives(position)
if len(options) == 1:
continue
@@ -274,7 +284,7 @@ def _anneal(
state: SequenceState,
rng: random.Random,
settings: OptimizerSettings,
- deadline: float | None,
+ expired: Callable[[], bool],
) -> tuple[SequenceState | None, int, int, bool]:
"""Run simulated annealing, returning the best state seen.
@@ -299,7 +309,7 @@ def _anneal(
stopped = False
for step in range(steps):
- if deadline is not None and not step % 256 and time.perf_counter() >= deadline:
+ if not step % 256 and expired():
stopped = True
break
position = rng.choice(positions)
diff --git a/src/codonopt/server.py b/src/codonopt/server.py
new file mode 100644
index 0000000..0dbdb8f
--- /dev/null
+++ b/src/codonopt/server.py
@@ -0,0 +1,772 @@
+"""Local web server behind the codonopt interface.
+
+Standard library only, so the interface works wherever the package does. The
+browser (or a ``pywebview`` window) loads a pre-built page from
+``codonopt/web`` and talks to this module over a small JSON API:
+
+ GET /api/meta tables, motif groups, defaults, limits
+ POST /api/jobs start an optimisation, returns {"id": ...}
+ GET /api/jobs/ progress, and the result once finished
+ POST /api/jobs//cancel stop early and keep the best sequence so far
+
+Optimisation can take minutes, so a request starts a job on a worker thread and
+the page polls for its state rather than holding a connection open.
+
+The server binds to the loopback interface only, and still defends itself,
+because anything running in the user's browser can reach ``127.0.0.1``:
+
+* the ``Host`` header must name the loopback address and this server's port,
+ which stops DNS rebinding;
+* every ``/api`` call must carry a random per-process token that is only ever
+ embedded in the page this server serves, which stops other sites from driving
+ it;
+* the API never reads or writes files -- sequences and tables arrive as text;
+* responses carry a strict Content-Security-Policy.
+"""
+
+from __future__ import annotations
+
+import hmac
+import json
+import math
+import secrets
+import sys
+import threading
+import time
+import traceback
+import uuid
+import webbrowser
+from dataclasses import dataclass, field, fields
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from importlib import resources
+from urllib.parse import urlsplit
+
+from . import __version__
+from .config import DesignConfig, OptimizerSettings, Weights
+from .fasta import Record, format_fasta, parse_fasta
+from .metrics import gc_profile, motif_hits
+from .motifs import (
+ MOTIF_GROUPS,
+ REGULATORY_MOTIFS,
+ RESTRICTION_SITES,
+ resolve_motifs,
+)
+from .optimize import OptimizationResult, optimize
+from .report import report_dict
+from .sequence import translate, validate_dna, validate_protein
+from .splice import get_scorer, maxent_available
+from .tables import TABLES, get_table, table_from_text
+
+__all__ = [
+ "App",
+ "JobSpec",
+ "RequestError",
+ "build_request",
+ "create_server",
+ "run_gui",
+]
+
+# --------------------------------------------------------------------------
+# Limits. The server is local, but a runaway request should still be bounded.
+# --------------------------------------------------------------------------
+
+MAX_BODY_BYTES = 2_000_000
+MAX_RESIDUES = 15_000
+MAX_SECONDS = 900
+MAX_RESTARTS = 20
+MAX_ANNEAL_STEPS = 1_000_000
+MAX_RUNNING_JOBS = 2
+MAX_KEPT_JOBS = 30
+
+#: How hard each effort level searches.
+EFFORT: dict[str, dict[str, int]] = {
+ "quick": {"max_sweeps": 3, "restarts": 1, "anneal_steps": 0},
+ "standard": {"max_sweeps": 6, "restarts": 1, "anneal_steps": 0},
+ "thorough": {"max_sweeps": 8, "restarts": 3, "anneal_steps": 5000},
+}
+
+EXAMPLE_NAME = "example"
+EXAMPLE_PROTEIN = (
+ "MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEENFKALVLIAFAQYLQQCPF"
+ "DEHVKLVNELTEFAKTCVADESHAGCEKSLHTLFGDELCKVASLRETYGDMADCCEKQEP"
+)
+
+_ASSETS: dict[str, tuple[str, str]] = {
+ "/": ("index.html", "text/html; charset=utf-8"),
+ "/index.html": ("index.html", "text/html; charset=utf-8"),
+ "/app.js": ("app.js", "text/javascript; charset=utf-8"),
+ "/app.css": ("app.css", "text/css; charset=utf-8"),
+ "/favicon.svg": ("favicon.svg", "image/svg+xml"),
+}
+
+_CSP = (
+ "default-src 'none'; script-src 'self'; style-src 'self'; "
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; "
+ "form-action 'none'; frame-ancestors 'none'"
+)
+_TOKEN_PLACEHOLDER = "__CODONOPT_TOKEN__"
+_TOKEN_HEADER = "X-Codonopt-Token"
+
+
+class RequestError(ValueError):
+ """The request was understood but cannot be acted on; the text is for the user."""
+
+
+# --------------------------------------------------------------------------
+# Turning a JSON payload into a validated job
+# --------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class JobSpec:
+ """A validated optimisation request.
+
+ Attributes:
+ name: Record name for the output.
+ protein: Protein to encode, with a trailing ``*`` if a stop is wanted.
+ start: Coding sequence to improve, or None to build one.
+ config: Design constraints.
+ settings: Keyword arguments for :class:`OptimizerSettings`.
+ kind: ``"protein"`` or ``"dna"``, as interpreted.
+ """
+
+ name: str
+ protein: str
+ start: str | None
+ config: DesignConfig
+ settings: dict
+ kind: str
+
+
+def _number(
+ payload: dict,
+ key: str,
+ default,
+ *,
+ low: float,
+ high: float,
+ integer: bool = False,
+ label: str | None = None,
+):
+ """Read an optional numeric field, with a readable error if it is wrong."""
+ value = payload.get(key)
+ if value is None or value == "":
+ return default
+ label = label or key.replace("_", " ")
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise RequestError(f"{label} must be a number")
+ try:
+ # A JSON integer has no width limit, so this is where one too large for
+ # a float arrives; it is a bad request, not a server fault.
+ if not math.isfinite(value):
+ raise RequestError(f"{label} must be a finite number")
+ except OverflowError:
+ raise RequestError(f"{label} must be between {low:g} and {high:g}") from None
+ if integer and int(value) != value:
+ raise RequestError(f"{label} must be a whole number")
+ if not low <= value <= high:
+ raise RequestError(f"{label} must be between {low:g} and {high:g}")
+ return int(value) if integer else float(value)
+
+
+def _parse_sequence(payload: dict) -> tuple[str, str, str]:
+ """Return ``(name, sequence, kind)`` from the pasted text."""
+ raw = payload.get("sequence")
+ if not isinstance(raw, str) or not raw.strip():
+ raise RequestError("Paste a protein or DNA sequence first.")
+ name = payload.get("name")
+ name = name.strip() if isinstance(name, str) and name.strip() else None
+
+ text = raw.strip()
+ if text.startswith(">"):
+ records = list(parse_fasta(text.splitlines()))
+ if len(records) != 1:
+ raise RequestError(
+ f"Found {len(records)} FASTA records; paste one sequence at a time."
+ )
+ name = name or records[0].name
+ sequence = records[0].sequence
+ else:
+ sequence = "".join(text.split())
+ sequence = sequence.upper()
+
+ kind = payload.get("kind", "auto")
+ if kind not in {"auto", "protein", "dna"}:
+ raise RequestError("kind must be auto, protein or dna")
+ if kind == "auto":
+ looks_like_dna = len(sequence) >= 9 and set(sequence) <= set("ACGTU")
+ kind = "dna" if looks_like_dna else "protein"
+ return name or "sequence", sequence, kind
+
+
+def _build_table(payload: dict):
+ text = payload.get("table_text")
+ if isinstance(text, str) and text.strip():
+ stripped = text.lstrip()
+ fmt = "json" if stripped.startswith("{") else ("tsv" if "\t" in text else "csv")
+ return table_from_text(text, fmt, name="custom", source="custom table")
+ if text is not None and "table" not in payload:
+ # A custom table was asked for and left blank. Falling back to a
+ # built-in host here would optimise against the wrong organism.
+ raise RequestError("The custom codon table is empty; paste one or choose a host.")
+ name = payload.get("table", "human")
+ if not isinstance(name, str):
+ raise RequestError("table must be a name")
+ try:
+ return get_table(name)
+ except KeyError as exc:
+ raise RequestError(str(exc.args[0])) from None
+
+
+def _build_weights(raw) -> Weights:
+ if raw is None:
+ return Weights()
+ if not isinstance(raw, dict):
+ raise RequestError("weights must be an object")
+ known = {field.name for field in fields(Weights)}
+ values = {}
+ for key, value in raw.items():
+ if key not in known:
+ raise RequestError(f"unknown weight {key!r}")
+ values[key] = _number(
+ {key: value},
+ key,
+ getattr(Weights(), key),
+ low=0,
+ high=1e6,
+ label=f"weight {key}",
+ )
+ return Weights(**values)
+
+
+def build_request(payload: object) -> JobSpec:
+ """Validate a decoded JSON request body into a :class:`JobSpec`.
+
+ Raises:
+ RequestError: With a message fit to show the user, for anything wrong
+ with the request.
+ """
+ if not isinstance(payload, dict):
+ raise RequestError("The request body must be a JSON object.")
+ try:
+ name, sequence, kind = _parse_sequence(payload)
+ if kind == "dna":
+ start = validate_dna(sequence.replace("U", "T"), in_frame=True)
+ protein = translate(start)
+ else:
+ start = None
+ protein = validate_protein(sequence)
+ if payload.get("add_stop") and not protein.endswith("*"):
+ protein += "*"
+ if len(protein) > MAX_RESIDUES:
+ raise RequestError(
+ f"That is {len(protein):,} residues; the interface accepts up to "
+ f"{MAX_RESIDUES:,}. Use the command line for longer genes."
+ )
+
+ avoid = payload.get("avoid", [])
+ if not isinstance(avoid, list) or not all(isinstance(a, str) for a in avoid):
+ raise RequestError("avoid must be a list of motif names or patterns")
+ if len(avoid) > 100 or any(len(a) > 200 for a in avoid):
+ raise RequestError("too many, or too long, motifs to avoid")
+ motifs = tuple(resolve_motifs(avoid))
+
+ gc_range = payload.get("gc_range", [0.30, 0.70])
+ if not (isinstance(gc_range, list) and len(gc_range) == 2):
+ raise RequestError("gc range must be two numbers")
+ gc_low = _number(
+ {"v": gc_range[0]}, "v", 0.30, low=0, high=1, label="GC range low"
+ )
+ gc_high = _number(
+ {"v": gc_range[1]}, "v", 0.70, low=0, high=1, label="GC range high"
+ )
+
+ backend = payload.get("splice_backend", "auto")
+ if backend not in {"auto", "maxent", "motif", "none"}:
+ raise RequestError("splice backend must be auto, maxent, motif or none")
+ try:
+ scorer = get_scorer(backend)
+ except RuntimeError as exc:
+ raise RequestError(str(exc).splitlines()[0]) from None
+
+ def whole(key: str, default: int, low: int, high: int) -> int:
+ return _number(payload, key, default, low=low, high=high, integer=True)
+
+ config = DesignConfig(
+ table=_build_table(payload),
+ gc_target=_number(payload, "gc_target", 0.50, low=0, high=1),
+ gc_range=(gc_low, gc_high),
+ gc_window=whole("gc_window", 30, 6, 300),
+ gc_step=whole("gc_step", 3, 1, 300),
+ repeat_k=whole("repeat_k", 12, 4, 60),
+ hairpin_stem=whole("hairpin_stem", 10, 4, 40),
+ hairpin_min_loop=whole("hairpin_min_loop", 3, 0, 100),
+ hairpin_max_loop=whole("hairpin_max_loop", 60, 0, 300),
+ max_homopolymer=whole("max_homopolymer", 6, 2, 30),
+ motifs=motifs,
+ splice_scorer=scorer,
+ donor_cutoff=_number(payload, "donor_cutoff", None, low=-100, high=100),
+ acceptor_cutoff=_number(
+ payload, "acceptor_cutoff", None, low=-100, high=100
+ ),
+ weights=_build_weights(payload.get("weights")),
+ )
+
+ effort = payload.get("effort", "standard")
+ if effort not in EFFORT:
+ raise RequestError("effort must be quick, standard or thorough")
+ settings = dict(EFFORT[effort])
+ settings["restarts"] = min(settings["restarts"], MAX_RESTARTS)
+ settings["anneal_steps"] = min(settings["anneal_steps"], MAX_ANNEAL_STEPS)
+ settings["max_seconds"] = _number(
+ payload, "seconds", 120.0, low=1, high=MAX_SECONDS
+ )
+ seed = _number(payload, "seed", None, low=0, high=2**31 - 1, integer=True)
+ if seed is not None:
+ settings["seed"] = seed
+ # Surface bad settings now rather than inside the worker thread.
+ OptimizerSettings(**settings)
+ except RequestError:
+ raise
+ except ValueError as exc: # SequenceError, FASTA, table and config errors
+ raise RequestError(str(exc)) from None
+
+ return JobSpec(
+ name=name,
+ protein=protein,
+ start=start,
+ config=config,
+ settings=settings,
+ kind=kind,
+ )
+
+
+# --------------------------------------------------------------------------
+# Jobs
+# --------------------------------------------------------------------------
+
+
+@dataclass
+class Job:
+ """One optimisation run and everything the page needs to show about it."""
+
+ id: str
+ spec: JobSpec
+ status: str = "running" # running | done | cancelled | error
+ messages: list[str] = field(default_factory=list)
+ restarts_done: int = 0
+ started: float = field(default_factory=time.perf_counter)
+ finished: float | None = None
+ result: dict | None = None
+ error: str | None = None
+ stop: threading.Event = field(default_factory=threading.Event)
+
+ def snapshot(self) -> dict:
+ """Return the JSON-serialisable state of the job."""
+ end = self.finished if self.finished is not None else time.perf_counter()
+ body = {
+ "id": self.id,
+ "status": self.status,
+ "elapsed": round(end - self.started, 2),
+ "max_seconds": self.spec.settings["max_seconds"],
+ "restarts_total": self.spec.settings["restarts"],
+ "restarts_done": self.restarts_done,
+ "messages": self.messages[-12:],
+ }
+ if self.error is not None:
+ body["error"] = self.error
+ if self.result is not None:
+ body["result"] = self.result
+ return body
+
+
+class JobStore:
+ """Thread-safe registry of jobs, bounded in size and concurrency."""
+
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self._jobs: dict[str, Job] = {}
+
+ def start(self, spec: JobSpec) -> Job:
+ """Create a job and run it on a worker thread.
+
+ Raises:
+ RequestError: If too many jobs are already running.
+ """
+ with self._lock:
+ running = sum(job.status == "running" for job in self._jobs.values())
+ if running >= MAX_RUNNING_JOBS:
+ raise RequestError(
+ "Already running as many optimisations as allowed; "
+ "wait for one to finish or cancel it."
+ )
+ job = Job(id=uuid.uuid4().hex[:12], spec=spec)
+ self._jobs[job.id] = job
+ finished = [j for j in self._jobs.values() if j.status != "running"]
+ for old in finished[: max(0, len(self._jobs) - MAX_KEPT_JOBS)]:
+ del self._jobs[old.id]
+ threading.Thread(target=_run_job, args=(job,), daemon=True).start()
+ return job
+
+ def get(self, job_id: str) -> Job | None:
+ with self._lock:
+ return self._jobs.get(job_id)
+
+
+def _run_job(job: Job) -> None:
+ spec = job.spec
+
+ def progress(message: str) -> None:
+ job.messages.append(message)
+ # One "final score" line is printed as each restart finishes.
+ if "final score" in message:
+ job.restarts_done += 1
+
+ try:
+ settings = OptimizerSettings(
+ **spec.settings, progress=progress, should_stop=job.stop.is_set
+ )
+ result = optimize(spec.protein, spec.config, settings, start=spec.start)
+ job.result = result_payload(result, spec)
+ job.status = "cancelled" if result.cancelled else "done"
+ except (ValueError, KeyError) as exc:
+ job.error = str(exc.args[0]) if exc.args else str(exc)
+ job.status = "error"
+ except Exception as exc: # pragma: no cover - a bug, not a user error
+ traceback.print_exc(file=sys.stderr)
+ job.error = f"Unexpected error: {type(exc).__name__}: {exc}"
+ job.status = "error"
+ finally:
+ job.finished = time.perf_counter()
+
+
+def result_payload(result: OptimizationResult, spec: JobSpec) -> dict:
+ """Everything the page needs to show a finished run."""
+ config = spec.config
+ payload = report_dict(result, config)
+ profile_before = gc_profile(
+ result.initial_sequence, window=config.gc_window, step=config.gc_step
+ )
+ profile_after = gc_profile(
+ result.sequence, window=config.gc_window, step=config.gc_step
+ )
+ lengths = {motif.name: motif.length for motif in config.motifs}
+ hits = motif_hits(result.sequence, list(config.motifs))
+ payload.update(
+ name=spec.name,
+ kind=spec.kind,
+ protein=result.protein,
+ initial_sequence=result.initial_sequence,
+ cancelled=result.cancelled,
+ gc={
+ "window": config.gc_window,
+ "step": config.gc_step,
+ "range": list(config.gc_range),
+ "target": config.gc_target,
+ "starts": [start for start, _ in profile_after],
+ "initial": [round(value, 4) for _, value in profile_before],
+ "final": [round(value, 4) for _, value in profile_after],
+ },
+ motif_hits=[
+ {"name": name, "length": lengths[name], "positions": list(positions)}
+ for name, positions in hits.items()
+ ],
+ fasta=format_fasta(
+ Record(
+ name=spec.name,
+ description=(
+ f"{spec.name} codonopt table={config.table.name} "
+ f"score={result.score:.2f}"
+ ),
+ sequence=result.sequence,
+ )
+ ),
+ )
+ return payload
+
+
+# --------------------------------------------------------------------------
+# HTTP
+# --------------------------------------------------------------------------
+
+
+def meta_payload() -> dict:
+ """Static facts the page needs to build its form."""
+ defaults = DesignConfig()
+ return {
+ "version": __version__,
+ "tables": [
+ {"name": name, "description": table.description}
+ for name, table in sorted(TABLES.items())
+ ],
+ "default_table": "human",
+ "motif_groups": {name: list(members) for name, members in MOTIF_GROUPS.items()},
+ "restriction_sites": dict(sorted(RESTRICTION_SITES.items())),
+ "regulatory_motifs": dict(sorted(REGULATORY_MOTIFS.items())),
+ "splice_backends": ["auto", "maxent", "motif", "none"],
+ "maxent_available": maxent_available(),
+ "weights": {
+ field.name: getattr(Weights(), field.name) for field in fields(Weights)
+ },
+ "defaults": {
+ "gc_target": defaults.gc_target,
+ "gc_range": list(defaults.gc_range),
+ "gc_window": defaults.gc_window,
+ "gc_step": defaults.gc_step,
+ "repeat_k": defaults.repeat_k,
+ "hairpin_stem": defaults.hairpin_stem,
+ "hairpin_min_loop": defaults.hairpin_min_loop,
+ "hairpin_max_loop": defaults.hairpin_max_loop,
+ "max_homopolymer": defaults.max_homopolymer,
+ "seconds": 120,
+ },
+ "effort": EFFORT,
+ "limits": {
+ "max_residues": MAX_RESIDUES,
+ "max_seconds": MAX_SECONDS,
+ },
+ "example": {"name": EXAMPLE_NAME, "protein": EXAMPLE_PROTEIN},
+ }
+
+
+class App:
+ """State shared by every request: the token, the jobs, the allowed hosts."""
+
+ def __init__(self, port: int = 0) -> None:
+ self.token = secrets.token_urlsafe(24)
+ self.jobs = JobStore()
+ self.port = port
+
+ def host_allowed(self, host: str | None) -> bool:
+ return host is not None and host.lower() in {
+ f"127.0.0.1:{self.port}",
+ f"localhost:{self.port}",
+ }
+
+ def origin_allowed(self, origin: str | None) -> bool:
+ return origin is None or origin.lower() in {
+ f"http://127.0.0.1:{self.port}",
+ f"http://localhost:{self.port}",
+ }
+
+ def token_ok(self, supplied: str | None) -> bool:
+ return supplied is not None and hmac.compare_digest(supplied, self.token)
+
+
+class _Handler(BaseHTTPRequestHandler):
+ # HTTP/1.0: one request per connection, so an early error response can never
+ # leave an unread request body to be mistaken for the next request.
+ server_version = "codonopt"
+
+ def log_message(self, format: str, *args) -> None: # noqa: A002
+ if getattr(self.server, "verbose", False):
+ super().log_message(format, *args)
+
+ @property
+ def app(self) -> App:
+ return self.server.app # type: ignore[attr-defined]
+
+ def do_GET(self) -> None: # noqa: N802
+ self._handle("GET")
+
+ def do_POST(self) -> None: # noqa: N802
+ self._handle("POST")
+
+ def _handle(self, method: str) -> None:
+ try:
+ path = urlsplit(self.path).path
+ if not self.app.host_allowed(self.headers.get("Host")):
+ return self._error(HTTPStatus.FORBIDDEN, "Unexpected Host header.")
+ if path.startswith("/api/"):
+ if not self.app.token_ok(self.headers.get(_TOKEN_HEADER)):
+ return self._error(HTTPStatus.FORBIDDEN, "Missing or wrong token.")
+ if method == "POST" and not self.app.origin_allowed(
+ self.headers.get("Origin")
+ ):
+ return self._error(HTTPStatus.FORBIDDEN, "Unexpected Origin.")
+ return self._api(method, path)
+ if method != "GET":
+ return self._error(HTTPStatus.METHOD_NOT_ALLOWED, "GET only.")
+ return self._static(path)
+ except (BrokenPipeError, ConnectionResetError):
+ return
+ except Exception: # pragma: no cover - a bug, not a user error
+ traceback.print_exc(file=sys.stderr)
+ self._error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.")
+
+ # -- static ------------------------------------------------------------
+
+ def _static(self, path: str) -> None:
+ asset = _ASSETS.get(path)
+ if asset is None:
+ return self._error(HTTPStatus.NOT_FOUND, "Not found.")
+ filename, content_type = asset
+ try:
+ data = resources.files("codonopt").joinpath("web", filename).read_bytes()
+ except (FileNotFoundError, OSError):
+ return self._error(
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ "The interface has not been built. From a source checkout run "
+ "'npm install && npm run build' inside web/.",
+ )
+ if filename == "index.html":
+ data = data.replace(_TOKEN_PLACEHOLDER.encode(), self.app.token.encode())
+ self._send(HTTPStatus.OK, data, content_type)
+
+ # -- api ---------------------------------------------------------------
+
+ def _api(self, method: str, path: str) -> None:
+ parts = [part for part in path.split("/") if part] # ["api", ...]
+ if method == "GET" and parts == ["api", "meta"]:
+ return self._json(HTTPStatus.OK, meta_payload())
+ if method == "POST" and parts == ["api", "jobs"]:
+ return self._start_job()
+ if len(parts) >= 3 and parts[:2] == ["api", "jobs"]:
+ job = self.app.jobs.get(parts[2])
+ if job is None:
+ return self._error(HTTPStatus.NOT_FOUND, "No such job.")
+ if method == "GET" and len(parts) == 3:
+ return self._json(HTTPStatus.OK, job.snapshot())
+ if method == "POST" and parts[3:] == ["cancel"]:
+ job.stop.set()
+ return self._json(HTTPStatus.OK, job.snapshot())
+ return self._error(HTTPStatus.NOT_FOUND, "Not found.")
+
+ def _start_job(self) -> None:
+ try:
+ length = int(self.headers.get("Content-Length", ""))
+ except ValueError:
+ return self._error(HTTPStatus.LENGTH_REQUIRED, "Content-Length required.")
+ if length < 0 or length > MAX_BODY_BYTES:
+ return self._error(
+ HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "That request is too large."
+ )
+ try:
+ # ValueError also covers a number with more digits than int() will
+ # parse (CPython's 4300-digit limit).
+ payload = json.loads(self.rfile.read(length) or b"null")
+ except (UnicodeDecodeError, ValueError):
+ return self._error(HTTPStatus.BAD_REQUEST, "The request is not valid JSON.")
+ try:
+ job = self.app.jobs.start(build_request(payload))
+ except RequestError as exc:
+ return self._error(HTTPStatus.BAD_REQUEST, str(exc))
+ self._json(HTTPStatus.ACCEPTED, job.snapshot())
+
+ # -- responses ---------------------------------------------------------
+
+ def _json(self, status: HTTPStatus, body: object) -> None:
+ self._send(
+ status,
+ json.dumps(body, separators=(",", ":")).encode(),
+ "application/json",
+ )
+
+ def _error(self, status: HTTPStatus, message: str) -> None:
+ self._json(status, {"error": message})
+
+ def _send(self, status: HTTPStatus, data: bytes, content_type: str) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(data)))
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.send_header("Referrer-Policy", "no-referrer")
+ self.send_header("Content-Security-Policy", _CSP)
+ self.end_headers()
+ self.wfile.write(data)
+
+
+class _Server(ThreadingHTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+ def __init__(self, address, handler, app: App, verbose: bool = False) -> None:
+ super().__init__(address, handler)
+ self.app = app
+ self.verbose = verbose
+
+
+def create_server(
+ host: str = "127.0.0.1", port: int = 0, *, verbose: bool = False
+) -> ThreadingHTTPServer:
+ """Create (but do not start) the interface server.
+
+ Args:
+ host: Interface to bind; only loopback addresses are accepted.
+ port: Port to bind, or 0 to let the system choose a free one.
+ verbose: Log each request to stderr.
+
+ Raises:
+ ValueError: If ``host`` is not a loopback address.
+ """
+ if host not in {"127.0.0.1", "localhost"}:
+ raise ValueError(
+ "the interface only binds to the loopback address (127.0.0.1); "
+ "it has no authentication and must not be exposed to a network"
+ )
+ app = App()
+ server = _Server(("127.0.0.1", port), _Handler, app, verbose)
+ app.port = server.server_address[1]
+ return server
+
+
+def server_url(server: ThreadingHTTPServer) -> str:
+ """Return the address a browser should open."""
+ return f"http://127.0.0.1:{server.server_address[1]}/"
+
+
+def run_gui(
+ port: int = 0,
+ *,
+ window: bool = True,
+ open_browser: bool = True,
+ verbose: bool = False,
+) -> int:
+ """Serve the interface until it is closed.
+
+ With ``pywebview`` installed (``pip install pywebview``) and ``window``
+ true, the page opens in a native window and the server stops when that
+ window closes. Otherwise the address is printed, the page is opened in the
+ default browser if ``open_browser`` is true, and the server runs until
+ interrupted.
+ """
+ server = create_server(port=port, verbose=verbose)
+ url = server_url(server)
+
+ webview = None
+ if window:
+ try:
+ import webview # type: ignore[import-not-found,no-redef]
+ except ImportError:
+ print(
+ "pywebview is not installed, so the interface opens in your browser "
+ "instead.\nInstall it for a native window: pip install pywebview",
+ file=sys.stderr,
+ )
+
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ if webview is not None:
+ try: # let the Download buttons save files from the native window
+ webview.settings["ALLOW_DOWNLOADS"] = True
+ except Exception: # older pywebview without this setting
+ pass
+ webview.create_window(
+ "codonopt", url, width=1180, height=860, min_size=(820, 600)
+ )
+ webview.start() # blocks until the window is closed
+ else:
+ print(f"codonopt interface: {url} (Ctrl+C to stop)")
+ if open_browser:
+ webbrowser.open(url)
+ while thread.is_alive():
+ thread.join(0.5)
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.shutdown()
+ server.server_close()
+ return 0
diff --git a/src/codonopt/tables.py b/src/codonopt/tables.py
index 9aa215d..3a2b717 100644
--- a/src/codonopt/tables.py
+++ b/src/codonopt/tables.py
@@ -16,6 +16,7 @@
from __future__ import annotations
import csv
+import io
import json
import math
import random
@@ -24,7 +25,14 @@
from .sequence import AA_TO_CODONS, CODON_TO_AA, STOP, split_codons
-__all__ = ["CodonUsage", "TABLES", "get_table", "load_table", "available_tables"]
+__all__ = [
+ "CodonUsage",
+ "TABLES",
+ "available_tables",
+ "get_table",
+ "load_table",
+ "table_from_text",
+]
# Approximate relative synonymous codon usage, Homo sapiens.
_HUMAN: dict[str, float] = {
@@ -225,47 +233,78 @@ def get_table(name: str) -> CodonUsage:
def load_table(path: str | Path, *, name: str | None = None) -> CodonUsage:
- """Load a codon usage table from a JSON or CSV file.
+ """Load a codon usage table from a JSON, CSV or TSV file.
- JSON files hold an object mapping codon to frequency or count. CSV files
- hold two columns, codon and value, with an optional header row.
+ JSON files hold an object mapping codon to frequency or count. CSV and TSV
+ files hold two columns, codon and value, with an optional header row.
Raises:
ValueError: If the file extension is not ``.json``, ``.csv`` or
``.tsv``, or a row cannot be parsed.
"""
path = Path(path)
- suffix = path.suffix.lower()
- if suffix == ".json":
- payload = json.loads(path.read_text())
+ return table_from_text(
+ path.read_text(),
+ path.suffix,
+ name=name or path.stem,
+ source=str(path),
+ )
+
+
+def table_from_text(
+ text: str,
+ fmt: str,
+ *,
+ name: str,
+ source: str = "table",
+) -> CodonUsage:
+ """Build a codon usage table from the text of a JSON, CSV or TSV table.
+
+ Args:
+ text: The table contents.
+ fmt: ``"json"``, ``"csv"`` or ``"tsv"``, with or without a leading dot.
+ name: Name to give the table.
+ source: Where the text came from, used to prefix error messages.
+
+ Raises:
+ ValueError: If ``fmt`` is unsupported, a row cannot be parsed, or no
+ codon rows are found.
+ """
+ fmt = fmt.lower().lstrip(".")
+ if fmt == "json":
+ payload = json.loads(text)
if not isinstance(payload, dict):
- raise ValueError(f"{path}: expected a JSON object of codon -> value")
- frequencies = {str(k): float(v) for k, v in payload.items()}
- elif suffix in {".csv", ".tsv"}:
- delimiter = "\t" if suffix == ".tsv" else ","
+ raise ValueError(f"{source}: expected a JSON object of codon -> value")
+ try:
+ frequencies = {str(k): float(v) for k, v in payload.items()}
+ except (TypeError, ValueError):
+ raise ValueError(f"{source}: every JSON value must be a number") from None
+ elif fmt in {"csv", "tsv"}:
+ delimiter = "\t" if fmt == "tsv" else ","
frequencies = {}
- with path.open(newline="") as handle:
- for lineno, row in enumerate(csv.reader(handle, delimiter=delimiter), 1):
- if not row or row[0].startswith("#"):
- continue
- if len(row) < 2:
- raise ValueError(f"{path}:{lineno}: expected two columns")
- codon = row[0].strip().upper().replace("U", "T")
- if codon not in CODON_TO_AA:
- continue # header or annotation row
- try:
- frequencies[codon] = float(row[1])
- except ValueError:
- raise ValueError(
- f"{path}:{lineno}: {row[1]!r} is not a number"
- ) from None
+ for lineno, row in enumerate(
+ csv.reader(io.StringIO(text, newline=""), delimiter=delimiter), 1
+ ):
+ if not row or row[0].startswith("#"):
+ continue
+ if len(row) < 2:
+ raise ValueError(f"{source}:{lineno}: expected two columns")
+ codon = row[0].strip().upper().replace("U", "T")
+ if codon not in CODON_TO_AA:
+ continue # header or annotation row
+ try:
+ frequencies[codon] = float(row[1])
+ except ValueError:
+ raise ValueError(
+ f"{source}:{lineno}: {row[1]!r} is not a number"
+ ) from None
else:
- raise ValueError(f"{path}: expected a .json, .csv or .tsv table")
+ raise ValueError(f"{source}: expected a .json, .csv or .tsv table")
if not frequencies:
- raise ValueError(f"{path}: no codon rows found")
+ raise ValueError(f"{source}: no codon rows found")
return CodonUsage.from_frequencies(
- name or path.stem,
+ name,
frequencies,
- description=f"Loaded from {path}",
+ description=f"Loaded from {source}",
)
diff --git a/src/codonopt/web/app.css b/src/codonopt/web/app.css
new file mode 100644
index 0000000..fe553b0
--- /dev/null
+++ b/src/codonopt/web/app.css
@@ -0,0 +1 @@
+:root{--bg: #f4f6f4;--surface: #ffffff;--surface-2: #eef1ee;--text: #17201e;--muted: #56635f;--border: #d9dfdb;--accent: #0f766e;--accent-strong: #0b5a54;--accent-ink: #ffffff;--accent-soft: #d6eeea;--good: #146c37;--bad: #b42318;--warn: #8a4b08;--warn-soft: #fdecc8;--error-soft: #fde4e1;--before: #7b8794;--focus: #1d4ed8;--shadow: 0 1px 2px rgb(16 24 22 / 8%), 0 4px 16px rgb(16 24 22 / 5%);--radius: 12px;--mono: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, monospace;--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif}@media(prefers-color-scheme:dark){:root{--bg: #0e1413;--surface: #161e1c;--surface-2: #1d2725;--text: #e5ece9;--muted: #98a7a1;--border: #2a3835;--accent: #3fb6aa;--accent-strong: #7fd6cc;--accent-ink: #06201d;--accent-soft: #17403b;--good: #5fd38b;--bad: #ff8a80;--warn: #f2b45a;--warn-soft: #3a2a10;--error-soft: #3b1a17;--before: #8794a3;--focus: #8ab4ff;--shadow: 0 1px 2px rgb(0 0 0 / 40%)}}*{box-sizing:border-box}[hidden]{display:none!important}html{-webkit-text-size-adjust:100%}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 var(--sans)}:focus-visible{outline:2px solid var(--focus);outline-offset:2px}h1,h2,h3,p{margin:0}h1{font-size:1.15rem;line-height:1.2;letter-spacing:-.01em}h2{font-size:.8125rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:650}h3{font-size:1rem}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.mono{font-family:var(--mono)}.hint{color:var(--muted);font-size:.8125rem}.top{max-width:1280px;margin:0 auto;padding:16px 16px 4px;display:flex;align-items:center;justify-content:space-between;gap:12px}.brand{display:flex;align-items:center;gap:12px}.brand p{color:var(--muted);font-size:.8125rem}.logo{width:36px;height:36px;flex:none}.logo-bg{fill:var(--accent)}.logo-strand{fill:none;stroke:var(--accent-ink);stroke-width:2.4;stroke-linecap:round}.version{color:var(--muted);font:.75rem var(--mono)}.app{max-width:1280px;margin:0 auto;padding:12px 16px 48px;display:grid;gap:20px;grid-template-columns:minmax(0,1fr)}@media(min-width:980px){.app{grid-template-columns:430px minmax(0,1fr);align-items:start}.panel{position:sticky;top:16px;max-height:calc(100vh - 32px)}.panel-scroll{overflow-y:auto}}.panel{display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}.panel[inert]{opacity:.55}.panel-scroll{padding:4px 18px 8px;min-height:0}.actions{position:sticky;bottom:0;display:flex;gap:8px;padding:12px 18px;background:var(--surface);border-top:1px solid var(--border);border-radius:0 0 var(--radius) var(--radius)}.group{padding:16px 0;border-bottom:1px solid var(--border);display:grid;gap:10px}.group:last-child{border-bottom:0}.group-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.tools{display:flex;gap:12px}.row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}textarea,input[type=text],input[type=number],select{width:100%;font:inherit;color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 10px}textarea{font-family:var(--mono);font-size:.8125rem;line-height:1.45;resize:vertical;min-height:7rem}input::placeholder,textarea::placeholder{color:var(--muted);opacity:.8}textarea:hover,input:hover,select:hover{border-color:color-mix(in srgb,var(--border),var(--text) 25%)}.field{display:grid;gap:4px;min-width:0}.field>span,.small-label{font-size:.8125rem;color:var(--muted)}.grid3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.grid3 .wide{grid-column:span 2}.link{background:none;border:0;padding:0;font:inherit;font-size:.8125rem;color:var(--accent-strong);cursor:pointer;text-decoration:underline;text-underline-offset:3px}.link:hover{text-decoration-thickness:2px}.check{display:inline-flex;align-items:center;gap:6px;font-size:.875rem;cursor:pointer}.check input{width:16px;height:16px;accent-color:var(--accent)}.seg{position:relative;display:inline-flex;margin:0;padding:2px;min-width:0;border:1px solid var(--border);border-radius:10px;background:var(--surface-2)}.seg input{position:absolute;opacity:0;pointer-events:none}.seg label{padding:5px 12px;border-radius:8px;cursor:pointer;font-size:.875rem;color:var(--muted)}.seg input:checked+label{background:var(--surface);color:var(--text);font-weight:600;box-shadow:0 1px 2px #00000026}.seg input:focus-visible+label{outline:2px solid var(--focus);outline-offset:1px}.detected{font-size:.8125rem;color:var(--muted)}.detected.ok{color:var(--accent-strong)}.detected.error{color:var(--bad)}.chips{display:flex;flex-wrap:wrap;gap:8px}.chip-check{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;font-size:.8125rem;border:1px solid var(--border);border-radius:999px;cursor:pointer;background:var(--surface)}.chip-check:has(input:checked){background:var(--accent-soft);border-color:var(--accent);color:var(--accent-strong);font-weight:600}.chip-check input{accent-color:var(--accent)}.advanced>summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px}.advanced>summary::-webkit-details-marker{display:none}.advanced>summary:before{content:"\25b8";color:var(--muted);transition:transform .15s}.advanced[open]>summary:before{transform:rotate(90deg)}.advanced[open]{gap:12px}.weights{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}button.primary,button.secondary{font:inherit;font-weight:650;border-radius:10px;padding:10px 16px;cursor:pointer;border:1px solid transparent;flex:1}button.primary{background:var(--accent);color:var(--accent-ink)}button.primary:hover:not(:disabled){background:var(--accent-strong);color:var(--accent-ink)}button.primary:disabled{opacity:.45;cursor:not-allowed}button.secondary{background:var(--surface-2);color:var(--text);border-color:var(--border)}button.secondary:hover{border-color:var(--muted)}.output{min-width:0;display:grid;gap:16px;outline:none}.empty{padding:28px 24px;background:var(--surface);border:1px dashed var(--border);border-radius:var(--radius);display:grid;gap:14px}.empty h2{font-size:1.25rem;text-transform:none;letter-spacing:-.01em;color:var(--text);font-weight:650}.empty p{color:var(--muted);max-width:60ch}.steps{margin:0;padding-left:1.2em;display:grid;gap:6px}.steps strong{color:var(--accent-strong)}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px 18px;display:grid;gap:12px;min-width:0}.callout{padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:var(--surface-2)}.callout.error{background:var(--error-soft);border-color:var(--bad);color:var(--text);display:grid;gap:4px}.callout.error strong{color:var(--bad)}.callout.warn{background:var(--warn-soft);border-color:var(--warn);color:var(--text)}.result-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.result-head h2{font-size:1.25rem;text-transform:none;letter-spacing:-.01em;color:var(--text);font-weight:650}.toolbar{display:flex;flex-wrap:wrap;gap:8px}.chip{font:inherit;font-size:.8125rem;padding:6px 12px;border-radius:999px;cursor:pointer;background:var(--surface);color:var(--text);border:1px solid var(--border)}.chip:hover{border-color:var(--muted)}.primary-chip{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);font-weight:600}.primary-chip:hover{background:var(--accent-strong);border-color:var(--accent-strong)}.stats{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(170px,1fr))}.stat{display:grid;gap:2px;padding:12px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}.stat-label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:650}.stat-value{font-size:1.35rem;font-variant-numeric:tabular-nums;letter-spacing:-.01em}.stat-value .was{color:var(--muted)}.stat-value strong{color:var(--accent-strong)}.stat-note{font-size:.8125rem;color:var(--muted)}.progress{gap:10px}.bar{height:8px;border-radius:999px;background:var(--surface-2);overflow:hidden}.bar-fill{height:100%;width:0;background:var(--accent);border-radius:999px;transition:width .3s}.bar.indeterminate .bar-fill{width:35%!important;animation:slide 1.4s ease-in-out infinite}@keyframes slide{0%{transform:translate(-100%)}to{transform:translate(300%)}}@media(prefers-reduced-motion:reduce){.bar.indeterminate .bar-fill{animation:none;width:100%!important;opacity:.5}}.progress-meta{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;font-size:.8125rem}.log{margin:0;padding:8px 10px 8px 28px;max-height:9rem;overflow:auto;font-size:.75rem;color:var(--muted);background:var(--surface-2);border-radius:8px}.seq-wrap{display:grid;gap:10px;min-width:0}.seq-tools{display:flex;align-items:center;justify-content:space-between;gap:8px 16px;flex-wrap:wrap}.toggles{display:flex;gap:14px;flex-wrap:wrap}.note.warn{color:var(--warn);font-size:.8125rem}.seq{font-family:var(--mono);font-size:.8125rem;line-height:1.35;overflow-x:auto;padding:10px 12px;background:var(--surface-2);border-radius:10px;max-height:26rem;overflow-y:auto;font-variant-ligatures:none}.block{display:flex;gap:12px;padding:3px 0;white-space:nowrap}.pos{color:var(--muted);white-space:pre;user-select:none;text-align:right;flex:none}.line{display:block}.line .cell{display:inline-block;width:4ch}.line.aa{color:var(--muted);font-size:.75rem;display:none}.seq.show-aa .line.aa{display:block}.seq.show-changes .cell.chg{background:var(--accent-soft);color:var(--accent-strong);border-radius:3px;font-weight:600}.cell.hit{color:var(--bad);text-decoration:underline wavy;text-underline-offset:3px;font-weight:700}.compare{width:100%;border-collapse:collapse;font-size:.875rem}.compare th,.compare td{padding:8px 10px;border-bottom:1px solid var(--border);text-align:left}.compare thead th{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}.compare tbody th{font-weight:500}.compare tbody tr:last-child th,.compare tbody tr:last-child td{border-bottom:0}.compare .num{text-align:right;font-variant-numeric:tabular-nums}.compare .strong{font-weight:650}.verdict{white-space:nowrap}.verdict.good{color:var(--good)}.verdict.bad{color:var(--bad);font-weight:600}.verdict.same{color:var(--muted)}.card:has(.compare){overflow-x:auto}.figure{margin:0;display:grid;gap:8px;overflow-x:auto}svg.chart{width:100%;min-width:560px;height:auto;display:block}.chart .band{fill:var(--accent-soft);opacity:.7}.chart .grid{stroke:var(--border);stroke-width:1}.chart .grid.faint{stroke-dasharray:2 4;opacity:.6}.chart .tick{fill:var(--muted);font:11px var(--sans)}.chart .target{stroke:var(--accent-strong);stroke-width:1;stroke-dasharray:5 4}.chart .line{fill:none;stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round}.chart .line.before,.chart .dot.before{stroke:var(--before);fill:none;opacity:.9}.chart .line.after{stroke:var(--accent);stroke-width:2.2}.chart .dot.before{fill:var(--before)}.chart .dot.after{fill:var(--accent)}.chart .cursor{stroke:var(--muted);stroke-width:1}.chart .overlay{fill:transparent;cursor:crosshair}.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin:0;padding:0;list-style:none;font-size:.8125rem;color:var(--muted)}.legend li{display:inline-flex;align-items:center;gap:6px}.swatch{display:inline-block;width:18px;height:3px;border-radius:2px}.swatch.before{background:var(--before)}.swatch.after{background:var(--accent);height:4px}.swatch.band{background:var(--accent-soft);height:10px;border:1px solid var(--accent)}.swatch.target{height:0;border-top:2px dashed var(--accent-strong);background:none}.readout{font-size:.8125rem;color:var(--muted);min-height:1.4em;font-variant-numeric:tabular-nums}.terms{display:grid;gap:10px}.term{display:grid;grid-template-columns:8.5rem minmax(0,1fr) auto;align-items:center;gap:12px;font-size:.875rem}.term-bars{display:grid;gap:3px}.track{display:block;height:7px;background:var(--surface-2);border-radius:4px;overflow:hidden}.fill{display:block;height:100%;border-radius:4px}.fill.before{background:var(--before)}.fill.after{background:var(--accent)}.term-values{font-size:.75rem;color:var(--muted);text-align:right;white-space:nowrap}@media(max-width:560px){.term{grid-template-columns:1fr;gap:4px}.term-values{text-align:left}.weights{grid-template-columns:1fr}}.run-info{text-align:center}.noscript{padding:16px;text-align:center}
diff --git a/src/codonopt/web/app.js b/src/codonopt/web/app.js
new file mode 100644
index 0000000..b87c020
--- /dev/null
+++ b/src/codonopt/web/app.js
@@ -0,0 +1,5 @@
+"use strict";(()=>{var P=class extends Error{constructor(n,o){super(n);this.status=o;this.name="ApiError"}},Me="X-Codonopt-Token",I=class{constructor(e){this.token=e}meta(){return this.request("GET","/api/meta")}start(e){return this.request("POST","/api/jobs",e)}poll(e){return this.request("GET",`/api/jobs/${encodeURIComponent(e)}`)}cancel(e){return this.request("POST",`/api/jobs/${encodeURIComponent(e)}/cancel`)}async request(e,n,o){let a;try{a=await fetch(n,{method:e,headers:{[Me]:this.token,...o===void 0?{}:{"Content-Type":"application/json"}},body:o===void 0?void 0:JSON.stringify(o),cache:"no-store"})}catch{throw new P("Cannot reach the codonopt server. Was the window or terminal that started it closed?",0)}let i=null;try{i=await a.json()}catch{}if(!a.ok){let r=i&&typeof i=="object"&&"error"in i?String(i.error):`The server answered ${a.status}.`;throw new P(r,a.status)}if(i===null||typeof i!="object")throw new P(`The server answered ${a.status} but sent no readable data.`,a.status);return i}};var Le=new Set("ACDEFGHIKLMNPQRSTVWY*"),re=new Set("ACGTU");function ce(t,e,n,o=!1){let a=t.trim();if(!a)return{state:"empty",message:"Nothing pasted yet.",kind:null,length:0};let i=a;if(a.startsWith(">")){let b=a.split(/\r?\n/),d=b.filter(w=>w.startsWith(">")).length;if(d>1)return q(`${d} FASTA records found. Paste one sequence at a time.`);i=b.slice(1).join("")}let r=i.replace(/\s+/g,"").toUpperCase();if(!r)return q("The FASTA header has no sequence under it.");let h=r.length>=9&&[...r].every(b=>re.has(b));if((e==="auto"?h?"dna":"protein":e)==="dna"){let b=le([...r].filter(w=>!re.has(w)));if(b.length)return q(`Not DNA: ${b.join(", ")}`);if(r.length%3)return q(`DNA \xB7 ${r.length} bases is not a whole number of codons (${r.length%3} extra).`);let d=r.length/3;return d>n?q(`DNA \xB7 ${d.toLocaleString()} codons; this page takes up to ${n.toLocaleString()}.`):ie("dna",r.length,`DNA \xB7 ${r.length.toLocaleString()} bases, ${d} codons`)}let m=le([...r].filter(b=>!Le.has(b)));if(m.length)return q(`Not a protein letter: ${m.join(", ")}`);let p=r.indexOf("*");if(p!==-1&&p!==r.length-1)return q(`Protein \xB7 stop symbol (*) at position ${p+1}, before the end.`);let L=r.length+(o&&!r.endsWith("*")?1:0);if(L>n)return q(`Protein \xB7 ${L.toLocaleString()} residues${L>r.length?" with the stop codon":""}; this page takes up to ${n.toLocaleString()}.`);let f=r.length===1?"residue":"residues";return ie("protein",r.length,`Protein \xB7 ${r.length.toLocaleString()} ${f}`)}function ie(t,e,n){return{state:"ok",message:n,kind:t,length:e}}function q(t){return{state:"error",message:t,kind:null,length:0}}function le(t){return[...new Set(t)].sort()}function V(t,e){for(let n of e)n==null||n===!1||t.appendChild(typeof n=="object"?n:document.createTextNode(String(n)))}function s(t,e,...n){let o=document.createElement(t);if(e){let{attrs:a,on:i,text:r,...h}=e;for(let[u,m]of Object.entries(h))m===void 0||m===!1||(u==="class"?o.className=String(m):typeof m=="boolean"?o.setAttribute(u,""):o.setAttribute(u,String(m)));for(let[u,m]of Object.entries(a??{}))o.setAttribute(u,m);if(r!==void 0&&(o.textContent=r),i)for(let[u,m]of Object.entries(i))o.addEventListener(u,m)}return V(o,n),o}var _e="http://www.w3.org/2000/svg";function E(t,e,...n){let o=document.createElementNS(_e,t);for(let[a,i]of Object.entries(e??{}))o.setAttribute(a,String(i));return V(o,n),o}function c(t){let e=document.getElementById(t);if(!e)throw new Error(`Missing #${t} in the page`);return e}function k(t,...e){t.replaceChildren(),V(t,e)}var x=(t,e=2)=>Number.isFinite(t)?t.toFixed(e):"\u2013",T=t=>Math.round(t).toLocaleString("en-US");var Se={gc_global:"Overall GC",gc_window:"Local GC",repeat:"Repeats",hairpin:"Hairpins",homopolymer:"Single-base runs",motif:"Forbidden sites",splice:"Splice sites",usage:"Codon usage"},ke={golden_gate:"Golden Gate",common_cloning:"Common cloning sites",regulatory:"Poly(A) and TATA signals"},$e={auto:"Automatic",maxent:"MaxEntScan",motif:"Consensus screen",none:"Off"},M=t=>c(t);function de(t){c("version").textContent=`v${t.version}`;let e=c("table");k(e,...t.tables.map(n=>s("option",{value:n.name,text:n.name})),s("option",{value:"custom",text:"Custom table\u2026"})),k(c("avoid-groups"),...Object.entries(t.motif_groups).filter(([,n])=>n.length).map(([n,o])=>s("label",{class:"chip-check",title:o.join(", ")},s("input",{type:"checkbox",value:n,attrs:{name:"avoid"}}),s("span",{text:ke[n]??n})))),k(c("splice"),...t.splice_backends.map(n=>s("option",{value:n,text:$e[n]??n}))),k(c("weights"),...Object.keys(t.weights).map(n=>s("label",{class:"field"},s("span",{text:Se[n]??n}),s("input",{type:"number",id:`weight-${n}`,attrs:{min:"0",step:"any",inputmode:"decimal","data-weight":n}})))),X(t)}function X(t){let e=t.defaults;c("table").value=t.default_table,c("table-text").value="",Y();for(let n of document.querySelectorAll('#avoid-groups input[type="checkbox"]'))n.checked=!1;c("avoid-extra").value="",M("gc-target").value=String(e.gc_target),M("gc-low").value=String(e.gc_range[0]),M("gc-high").value=String(e.gc_range[1]),M("max-homopolymer").value=String(e.max_homopolymer),c("splice").value="auto",c("effort-standard").checked=!0,M("seconds").value=String(e.seconds),M("seed").value="",M("gc-window").value=String(e.gc_window),M("gc-step").value=String(e.gc_step),M("repeat-k").value=String(e.repeat_k),M("hairpin-stem").value=String(e.hairpin_stem),M("hairpin-min-loop").value=String(e.hairpin_min_loop),M("hairpin-max-loop").value=String(e.hairpin_max_loop);for(let[n,o]of Object.entries(t.weights))M(`weight-${n}`).value=String(o);Q(t),Z(t)}function Y(){let t=c("table").value==="custom";c("custom-table").hidden=!t}function ue(){return c("table").value!=="custom"||c("table-text").value.trim()?null:"Paste or open a codon usage table, or pick a built-in host instead of \u201CCustom table\u2026\u201D."}function B(t){let e=c("table").value;c("table-hint").textContent=e==="custom"?"":t.tables.find(n=>n.name===e)?.description??""}function Q(t){let e=pe(),n=t.effort[e];if(!n)return;let o=[`${n.max_sweeps} greedy passes`];n.restarts>1&&o.push(`${n.restarts} independent restarts`),n.anneal_steps&&o.push(`${n.anneal_steps.toLocaleString()} annealing steps each`),c("effort-hint").textContent=`${o.join(", ")}.`}function Z(t){let e=c("splice").value,n=c("splice-hint");e==="maxent"&&!t.maxent_available?n.textContent="MaxEntScan is not installed, so this will report an error. Pick Automatic or Consensus screen.":e==="auto"?n.textContent=t.maxent_available?"Uses MaxEntScan.":"MaxEntScan is not installed, so a simpler consensus screen is used.":e==="motif"?n.textContent="A fast consensus screen. It flags likely sites rather than modelling splicing.":e==="none"?n.textContent="Splice sites are ignored. Right for sequences that will not be spliced, and faster.":n.textContent=""}function pe(){return document.querySelector('input[name="effort"]:checked')?.value??"standard"}function ee(){return document.querySelector('input[name="kind"]:checked')?.value??"auto"}function S(t){let e=M(t).value.trim();if(e==="")return;let n=Number(e);return Number.isFinite(n)?n:e}function he(){let t=[...document.querySelectorAll("#avoid-groups input:checked")].map(a=>a.value);t.push(...c("avoid-extra").value.split(/[,\n]/).map(a=>a.trim()).filter(Boolean));let e={};for(let a of document.querySelectorAll("#weights input")){let i=a.value.trim();i!==""&&(e[a.dataset.weight??""]=Number.isFinite(Number(i))?Number(i):i)}let n=c("table").value,o=ee();return{sequence:c("sequence").value,kind:o,add_stop:c("add-stop").checked,table:n==="custom"?void 0:n,table_text:n==="custom"?c("table-text").value:void 0,avoid:t,gc_target:S("gc-target"),gc_range:[S("gc-low"),S("gc-high")],max_homopolymer:S("max-homopolymer"),splice_backend:c("splice").value,effort:pe(),seconds:S("seconds"),seed:S("seed"),gc_window:S("gc-window"),gc_step:S("gc-step"),repeat_k:S("repeat-k"),hairpin_stem:S("hairpin-stem"),hairpin_min_loop:S("hairpin-min-loop"),hairpin_max_loop:S("hairpin-max-loop"),weights:e}}var O=720,ne=236,v={left:46,right:14,top:12,bottom:34},G=O-v.left-v.right,D=ne-v.top-v.bottom,te=[50,100,200,250,500,1e3,2e3,2500,5e3,1e4,2e4,5e4];function Ce(t){return te.find(e=>t/e<=8)??te[te.length-1]}function me(t,e){let n=Math.max(e,1),o=d=>v.left+d/n*G,a=d=>v.top+(1-d)*D,i=d=>Math.min(d+t.window/2,n),r=d=>d.map((w,_)=>`${_?"L":"M"}${o(i(t.starts[_])).toFixed(1)} ${a(w).toFixed(1)}`).join(""),h=E("svg",{viewBox:`0 0 ${O} ${ne}`,role:"img",class:"chart","aria-label":`GC content in ${t.window} base windows along the sequence, before and after optimisation, against the allowed range of ${x(t.range[0]*100,0)} to ${x(t.range[1]*100,0)} percent.`});h.append(E("rect",{class:"band",x:v.left,y:a(t.range[1]),width:G,height:Math.max(0,a(t.range[0])-a(t.range[1]))}));for(let d of[0,.25,.5,.75,1])h.append(E("line",{class:"grid",x1:v.left,x2:O-v.right,y1:a(d),y2:a(d)}),E("text",{class:"tick",x:v.left-8,y:a(d)+4,"text-anchor":"end"},`${d*100}%`));let u=Ce(n);for(let d=0;d<=n;d+=u)h.append(E("line",{class:"grid faint",x1:o(d),x2:o(d),y1:v.top,y2:v.top+D}),E("text",{class:"tick",x:o(d),y:ne-12,"text-anchor":"middle"},T(d)));h.append(E("line",{class:"target",x1:v.left,x2:O-v.right,y1:a(t.target),y2:a(t.target)})),t.starts.length>1?h.append(E("path",{class:"line before",d:r(t.initial)}),E("path",{class:"line after",d:r(t.final)})):t.starts.length===1&&h.append(E("circle",{class:"dot before",cx:o(i(t.starts[0])),cy:a(t.initial[0]),r:4}),E("circle",{class:"dot after",cx:o(i(t.starts[0])),cy:a(t.final[0]),r:4}));let m=E("line",{class:"cursor",x1:0,x2:0,y1:v.top,y2:v.top+D});m.setAttribute("visibility","hidden");let p=E("rect",{class:"overlay",x:v.left,y:v.top,width:G,height:D});h.append(m,p);let L="Hover the chart to read a window.",f=s("p",{class:"readout",text:L});p.addEventListener("pointermove",d=>{let w=h.getBoundingClientRect(),A=((d.clientX-w.left)/w.width*O-v.left)/G*n,y=0;for(let N=1;N{m.setAttribute("visibility","hidden"),f.textContent=L});let b=s("ul",{class:"legend"},s("li",null,s("span",{class:"swatch before"}),"Before"),s("li",null,s("span",{class:"swatch after"}),"After"),s("li",null,s("span",{class:"swatch band"}),"Allowed range"),s("li",null,s("span",{class:"swatch target"}),`Target ${x(t.target*100,0)}%`));return s("figure",{class:"figure"},h,b,f)}var fe=20;function ge(t){let e=t.sequence,n=t.initial_sequence,o=e.length/3,a=new Set;for(let f of t.motif_hits)for(let b of f.positions)for(let d=Math.floor(b/3);d<=Math.floor((b+f.length-1)/3);d++)a.add(d);let i=0,r=[],h=String(e.length).length;for(let f=0;fs("label",{class:"check"},s("input",{type:"checkbox",checked:d,on:{change:w=>u.classList.toggle(b,w.target.checked)}}),` ${f}`);!t.motif_hits.length&&!i&&u.classList.remove("show-changes");let p=i===0?"No codon needed to change.":`${T(i)} of ${T(o)} codons changed (${Math.round(i/o*100)}%).`,L=t.motif_hits.length?s("p",{class:"note warn",role:"status"},`Still contains ${t.motif_hits.map(f=>`${f.name} (${f.positions.length})`).join(", ")}; the marked codons overlap a site you asked to keep out.`):null;return s("div",{class:"seq-wrap"},s("div",{class:"seq-tools"},s("p",{class:"hint",text:p}),s("div",{class:"toggles"},m("Highlight changes","show-changes",u.classList.contains("show-changes")),m("Show protein","show-aa",!0))),L,u)}async function be(t){try{return await navigator.clipboard.writeText(t),!0}catch{let e=s("textarea",{attrs:{"aria-hidden":"true",tabindex:"-1"}});e.value=t,document.body.append(e),e.select();let n=!1;try{n=document.execCommand("copy")}catch{n=!1}return e.remove(),n}}function ve(t,e,n){let o=URL.createObjectURL(new Blob([e],{type:n})),a=s("a",{attrs:{href:o,download:t}});document.body.append(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(o),1e4)}function F(t,e,n=!1){let o=s("button",{type:"button",class:n?"chip primary-chip":"chip",text:t,on:{click:()=>e(o)}});return o}var se=new WeakMap;function we(t,e){let n=se.get(t);n!==void 0&&window.clearTimeout(n),t.dataset.label===void 0&&(t.dataset.label=t.textContent??""),t.textContent=e,se.set(t,window.setTimeout(()=>{t.textContent=t.dataset.label??"",se.delete(t)},1600))}var He=t=>t.replace(/[^\w.-]+/g,"_")||"sequence";function H(t,e){k(t,s("div",{class:"callout error",role:"alert"},s("strong",{text:"That did not work"}),s("p",{text:e}))),t.focus({preventScroll:!0})}var j=class{constructor(e){this.bar=s("div",{class:"bar-fill"});this.elapsed=s("span",{class:"mono"});this.detail=s("span",{class:"hint"});this.log=s("ol",{class:"log mono",attrs:{"aria-label":"Search log"}});this.trouble=s("p",{class:"note warn",role:"alert"});this.lastLog="";this.trouble.hidden=!0,k(e,s("div",{class:"card progress"},s("h2",{text:"Optimizing"}),s("p",{class:"hint",text:"Trying synonymous codons and keeping the changes that lower the score."}),s("div",{class:"bar",role:"progressbar",attrs:{"aria-label":"Optimization progress"}},this.bar),s("div",{class:"progress-meta"},this.elapsed,this.detail),this.trouble,this.log)),e.focus({preventScroll:!0})}note(e){this.trouble.textContent=e,this.trouble.hidden=!1}update(e){this.elapsed.textContent=`${x(e.elapsed,1)} s of at most ${T(e.max_seconds)} s`;let n=this.bar.parentElement;if(e.restarts_total>1){let a=Math.min(e.restarts_done,e.restarts_total);this.detail.textContent=`Restart ${Math.min(a+1,e.restarts_total)} of ${e.restarts_total}`,this.bar.style.width=`${a/e.restarts_total*100}%`,n?.classList.remove("indeterminate"),n?.setAttribute("aria-valuemin","0"),n?.setAttribute("aria-valuemax",String(e.restarts_total)),n?.setAttribute("aria-valuenow",String(a))}else this.detail.textContent="Stops as soon as nothing improves.",n?.classList.add("indeterminate"),n?.removeAttribute("aria-valuenow");let o=e.messages.join(`
+`);o!==this.lastLog&&(this.lastLog=o,k(this.log,...e.messages.map(a=>s("li",{text:a}))))}},J=t=>`${x(t*100,1)}%`;function Ae(t){let e=t.splice_backend!==void 0;return[{label:"GC content",help:"Share of G and C bases over the whole sequence.",get:n=>n.gc_fraction,format:J,better:"target"},{label:"GC windows out of range",help:"Windows whose local GC falls outside the allowed range.",get:n=>n.gc_windows_below_range+n.gc_windows_above_range,better:"lower"},{label:"Codon adaptation index",help:"How closely the codons follow the host's preferences (1 is ideal).",get:n=>n.cai,format:n=>x(n,3),better:"higher"},{label:`Repeated ${t.repeat_k}-mers`,help:"Extra copies of any stretch that appears more than once. Repeats misassemble during synthesis.",get:n=>n.redundant_kmers,better:"lower"},{label:"Hairpin pairs",help:"Pairs of inverted repeats that can fold into stem-loops.",get:n=>n.hairpin_pairs,better:"lower"},{label:"Longest single-base run",help:"The longest stretch of one repeated base.",get:n=>n.longest_homopolymer,better:"lower"},{label:"Forbidden sites",help:"Matches to the enzymes and patterns you asked to keep out.",get:n=>n.forbidden_motif_hits,better:"lower"},{label:"Splice donor sites",help:"Windows that look like a 5\u2032 splice site.",get:n=>n.splice_donor_sites??0,better:"lower",show:()=>e},{label:"Splice acceptor sites",help:"Windows that look like a 3\u2032 splice site.",get:n=>n.splice_acceptor_sites??0,better:"lower",show:()=>e}]}function xe(t){return t.gc_windows_below_range+t.gc_windows_above_range+t.redundant_kmers+t.hairpin_pairs+t.homopolymer_excess_bases+t.forbidden_motif_hits+(t.splice_donor_sites??0)+(t.splice_acceptor_sites??0)}function Re(t){let e=t.gc.target,n=Ae(t.final).filter(o=>!o.show||o.show(t.final)).map(o=>{let a=o.get(t.initial),i=o.get(t.final),r=o.format??T,h;if(o.better==="target"){let p=Math.abs(i-e)-Math.abs(a-e);h=Math.abs(p)<5e-4?"same":p<0?"good":"bad"}else{let p=i-a;h=Math.abs(p)<1e-9?"same":p<0==(o.better==="lower")?"good":"bad"}let u={good:"better",bad:"worse",same:"no change"}[h],m={good:"\u2713",bad:"!",same:"\u2013"}[h];return s("tr",null,s("th",{attrs:{scope:"row",title:o.help}},o.label),s("td",{class:"num",text:r(a)}),s("td",{class:"num strong",text:r(i)}),s("td",{class:`verdict ${h}`},s("span",{attrs:{"aria-hidden":"true"}},`${m} `),u))});return s("table",{class:"compare"},s("thead",null,s("tr",null,s("th",{attrs:{scope:"col"}},"Property"),s("th",{class:"num",attrs:{scope:"col"}},"Before"),s("th",{class:"num",attrs:{scope:"col"}},"After"),s("th",{attrs:{scope:"col"}},"Change"))),s("tbody",null,...n))}var oe={gc_global:["Overall GC","Distance of the whole-sequence GC from the target."],gc_window:["Local GC","How far windows stray outside the allowed range."],repeat:["Repeats","Redundant copies of repeated stretches."],hairpin:["Hairpins","Inverted repeats that can fold back on themselves."],homopolymer:["Single-base runs","Bases beyond the longest run you allow."],motif:["Forbidden sites","Matches to enzymes and patterns you keep out."],splice:["Splice sites","How far splice-site scores exceed their cutoffs."],usage:["Codon usage","Cost of using codons the host dislikes."]};function qe(t){let e=t.score.initial_terms,n=t.score.final_terms,o=Object.keys(oe).filter(r=>(e[r]??0)>1e-9||(n[r]??0)>1e-9);if(!o.length)return s("p",{class:"hint",text:"No penalties at all: every constraint is met and codon usage is ideal."});let a=Math.max(...o.flatMap(r=>[e[r]??0,n[r]??0]),1e-9),i=(r,h)=>{let u=s("span",{class:`fill ${h}`});return u.style.width=`${Math.max(r>0?1.5:0,r/a*100)}%`,s("span",{class:"track"},u)};return s("div",{class:"terms"},...o.map(r=>s("div",{class:"term",title:oe[r][1]},s("span",{class:"term-name",text:oe[r][0]}),s("span",{class:"term-bars"},i(e[r]??0,"before"),i(n[r]??0,"after")),s("span",{class:"term-values mono",text:`${x(e[r]??0,2)} \u2192 ${x(n[r]??0,2)}`}))))}function K(t,e,...n){return s("section",{class:"card"},s("h3",{text:t}),e?s("p",{class:"hint",text:e}):null,...n)}function W(t,e,n,o){return s("div",{class:"stat"},s("span",{class:"stat-label",text:t}),s("span",{class:"stat-value"},s("span",{class:"was",text:e})," \u2192 ",s("strong",{text:n})),s("span",{class:"stat-note",text:o}))}function ye(t,e,n){let o=xe(e.initial),a=xe(e.final),i=e.score.initial>0?`${Math.round((1-e.score.final/e.score.initial)*100)}% lower score`:"score unchanged",r=He(e.name),h=e.cancelled?s("div",{class:"callout warn",role:"status"},"Stopped early. This is the best sequence found so far."):e.search.hit_time_limit?s("div",{class:"callout warn",role:"status"},"The time limit ended the search before it settled. Raise the limit or use a thorough search for more."):null,u=s("div",{class:"toolbar"},F("Copy DNA",m=>{be(e.sequence).then(p=>we(m,p?"Copied":"Copy failed"))},!0),F("Copy FASTA",m=>{be(e.fasta).then(p=>we(m,p?"Copied":"Copy failed"))}),F("Download FASTA",()=>ve(`${r}.codonopt.fna`,e.fasta,"text/plain")),F("Download report",()=>ve(`${r}.codonopt.json`,JSON.stringify(e,null,2)+`
+`,"application/json")));k(t,s("div",{class:"result-head"},s("div",null,s("h2",{text:"Optimized sequence"}),s("p",{class:"hint",text:`${e.name} \xB7 ${e.protein.length.toLocaleString()} residues \xB7 ${e.codon_usage_table} codon usage`})),u),h,s("div",{class:"stats"},W("Issues found",T(o),T(a),a===0?"none left":"still to resolve"),W("Score",x(e.score.initial,2),x(e.score.final,2),i),W("GC content",J(e.initial.gc_fraction),J(e.final.gc_fraction),`target ${J(e.gc.target)}`),W("Codon adaptation",x(e.initial.cai,2),x(e.final.cai,2),"1.0 is ideal")),K("DNA sequence","The protein is identical to your input; only synonymous codons changed.",ge(e)),K("Before and after",null,Re(e)),K("GC content along the sequence",`Each point is a ${e.gc.window} base window.`,me(e.gc,e.sequence.length)),K("Where the score comes from","Lower is better. Each term is weighted as in your settings.",qe(e)),s("p",{class:"run-info hint"},`${e.search.restarts_run} ${e.search.restarts_run===1?"restart":"restarts"} \xB7 ${T(e.search.greedy_sweeps)} sweeps \xB7 ${T(e.search.moves_evaluated)} codon swaps tried \xB7 ${x(e.search.seconds,1)} s`)),t.focus({preventScroll:!0})}var Ne=350;async function Pe(){let t=document.querySelector('meta[name="codonopt-token"]')?.content??"",e=new I(t),n=c("output"),o=c("form"),a;try{a=await e.meta()}catch(l){H(n,l instanceof Error?l.message:String(l));return}de(a),B(a),o.removeAttribute("inert"),o.removeAttribute("aria-busy");let i=c("sequence"),r=c("detected"),h=c("run"),u=c("cancel"),m=c("add-stop-wrap"),p=null,L=!1,f=()=>{let l=ce(i.value,ee(),a.limits.max_residues,c("add-stop").checked);r.textContent=l.message,r.className=`detected ${l.state}`,m.hidden=l.kind==="dna",h.disabled=p!==null||L||l.state==="empty"};i.addEventListener("input",f),c("add-stop").addEventListener("change",f);for(let l of document.querySelectorAll('input[name="kind"]'))l.addEventListener("change",f);let b=8e6,d=async(l,g)=>{if(!l)return;if(l.size>b){H(n,`${l.name} is ${Math.round(l.size/1e6)} MB; open a file under 8 MB.`);return}let R;try{R=await l.text()}catch{H(n,`Could not read ${l.name||"that file"}. Open a text file, not a folder.`);return}g.value=R,g.dispatchEvent(new Event("input",{bubbles:!0}))},w=c("file-input");c("btn-open").addEventListener("click",()=>w.click()),w.addEventListener("change",()=>{d(w.files?.[0],i),w.value=""}),i.addEventListener("dragover",l=>l.preventDefault()),i.addEventListener("drop",l=>{l.preventDefault(),d(l.dataTransfer?.files[0],i)}),c("btn-example").addEventListener("click",()=>{i.value=`>${a.example.name}
+${a.example.protein}
+`,c("kind-auto").checked=!0,f(),i.focus()}),c("btn-clear").addEventListener("click",()=>{i.value="",f(),i.focus()}),c("table").addEventListener("change",()=>{Y(),B(a)});let A=c("table-text"),y=c("table-file");c("btn-open-table").addEventListener("click",()=>y.click()),y.addEventListener("change",()=>{d(y.files?.[0],A),y.value=""}),c("splice").addEventListener("change",()=>Z(a));for(let l of document.querySelectorAll('input[name="effort"]'))l.addEventListener("change",()=>Q(a));c("btn-reset").addEventListener("click",()=>{X(a),B(a)});let $=l=>{u.hidden=!l,u.disabled=!1,u.textContent="Stop",h.hidden=l,f()},C=l=>{c("status").textContent=l},N=l=>{window.clearInterval(l.timer),p===l&&(p=null,$(!1))},Ee=(l,g)=>{let R=p===l;N(l),R&&(g.status==="error"?(C("The optimization failed."),H(n,g.error??"The optimization failed.")):g.result&&(C(g.status==="cancelled"?"Stopped early; showing the best sequence found.":"Optimization finished."),ye(n,g.result,a)))},Te=async l=>{if(p===l)try{let g=await e.poll(l.id);if(p!==l)return;g.status==="running"?l.progress.update(g):Ee(l,g)}catch(g){if(p!==l)return;N(l),H(n,g instanceof Error?g.message:String(g))}};o.addEventListener("submit",async l=>{if(l.preventDefault(),p||L||h.disabled)return;let g=ue();if(g){H(n,g);return}L=!0,h.disabled=!0;let R;try{R=await e.start(he())}catch(U){H(n,U instanceof P?U.message:String(U));return}finally{L=!1,f()}C("Optimizing.");let ae=new j(n);ae.update(R);let z={id:R.id,timer:0,progress:ae};p=z,$(!0),z.timer=window.setInterval(()=>{Te(z)},Ne)}),u.addEventListener("click",async()=>{let l=p;if(l){u.disabled=!0,u.textContent="Stopping\u2026";try{await e.cancel(l.id)}catch(g){if(p!==l)return;u.disabled=!1,u.textContent="Stop",l.progress.note(`${g instanceof Error?g.message:String(g)} The run is still going; try Stop again.`)}}}),f()}Pe().catch(t=>{let e=document.getElementById("output"),n=t instanceof Error?t.message:String(t);e?H(e,`The interface failed to start: ${n}`):document.title="codonopt - failed to start",console.error(t)});})();
diff --git a/src/codonopt/web/favicon.svg b/src/codonopt/web/favicon.svg
new file mode 100644
index 0000000..1428b17
--- /dev/null
+++ b/src/codonopt/web/favicon.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/codonopt/web/index.html b/src/codonopt/web/index.html
new file mode 100644
index 0000000..80a3390
--- /dev/null
+++ b/src/codonopt/web/index.html
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+
+ codonopt
+
+
+
+
+
+
+
+
+
+
+
+
+
codonopt
+
Codon optimization for synthesis and expression
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Design a sequence that is easy to make
+
The protein stays exactly the same. Only the synonymous codons change, so the DNA
+ avoids what makes genes hard to synthesise and express.
+
+ Paste a protein, or a DNA sequence you want to improve.
+ Choose a host and the sites to keep out.
+ Optimize , then compare before and after.
+
+
Nothing leaves this computer: the page talks only to the local codonopt process.
+
+
+
+
+ codonopt needs JavaScript to run.
+
+
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index 15e9e45..81cd1e4 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -153,3 +153,17 @@ def test_find_hairpins_respects_the_limit():
unit = "ACGTTGCACC"
sequence = (unit + "TTTTT" + reverse_complement(unit) + "AAA") * 6
assert len(find_hairpins(sequence, stem=10, min_loop=3, max_loop=20, limit=3)) == 3
+
+
+def test_gc_profile_matches_the_windows_that_are_scored():
+ from codonopt.metrics import gc_profile
+
+ sequence = "A" * 30 + "G" * 30
+ profile = gc_profile(sequence, window=30, step=15)
+ assert [start for start, _ in profile] == [0, 15, 30]
+ assert [round(value, 3) for _, value in profile] == [0.0, 0.5, 1.0]
+ report = gc_window_report(sequence, window=30, step=15, target_range=(0.4, 0.6))
+ assert report.windows == len(profile)
+ assert report.min_fraction == min(value for _, value in profile)
+ assert report.max_fraction == max(value for _, value in profile)
+ assert gc_profile("") == []
diff --git a/tests/test_optimize.py b/tests/test_optimize.py
index 71dc3a1..3f127ab 100644
--- a/tests/test_optimize.py
+++ b/tests/test_optimize.py
@@ -179,3 +179,44 @@ def test_single_residue_protein_is_handled(config):
result = optimize("M", config, OptimizerSettings(seed=20))
assert result.sequence == "ATG"
assert result.score == pytest.approx(result.initial_score)
+
+
+def test_should_stop_cancels_cleanly_and_keeps_the_protein(config):
+ protein = random_protein(200, seed=60)
+ settings = OptimizerSettings(
+ seed=21, max_sweeps=50, restarts=5, should_stop=lambda: True
+ )
+ result = optimize(protein, config, settings)
+ assert result.cancelled is True
+ assert result.hit_time_limit is False
+ assert translate(result.sequence) == protein
+ assert result.restarts_run == 1 # the first restart always produces a result
+
+
+def test_should_stop_can_flip_part_way_through(plain_config):
+ protein = random_protein(300, seed=61)
+ calls = {"n": 0}
+
+ def stop_after_a_while() -> bool:
+ calls["n"] += 1
+ return calls["n"] > 12
+
+ full = optimize(protein, plain_config, OptimizerSettings(seed=22, max_sweeps=6))
+ cut = optimize(
+ protein,
+ plain_config,
+ OptimizerSettings(seed=22, max_sweeps=6, should_stop=stop_after_a_while),
+ )
+ assert cut.cancelled
+ assert cut.moves_evaluated < full.moves_evaluated
+ assert cut.score <= cut.initial_score # whatever it got to is no worse
+
+
+def test_a_run_that_is_not_stopped_is_not_marked_cancelled(config):
+ result = optimize(
+ random_protein(30, seed=62),
+ config,
+ OptimizerSettings(seed=23, max_sweeps=2, should_stop=lambda: False),
+ )
+ assert result.cancelled is False
+ assert result.hit_time_limit is False
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
index 0000000..224c0fc
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,498 @@
+"""Tests for the local web server.
+
+These drive a real server on a free loopback port over HTTP, because the
+properties that matter -- the token and Host checks, the job lifecycle, the
+status codes -- live in the request handling rather than in any one function.
+"""
+
+from __future__ import annotations
+
+import http.client
+import json
+import threading
+import time
+
+import pytest
+
+from codonopt import translate
+from codonopt.server import (
+ MAX_RESIDUES,
+ RequestError,
+ build_request,
+ create_server,
+)
+
+from conftest import random_protein
+
+TOKEN_HEADER = "X-Codonopt-Token"
+PROTEIN = random_protein(40, seed=50)
+FAST = {"splice_backend": "none", "effort": "quick", "seed": 1, "seconds": 30}
+
+
+class Client:
+ """A small HTTP client for the test server."""
+
+ def __init__(self, server) -> None:
+ self.server = server
+ self.port = server.server_address[1]
+ self.token = server.app.token
+
+ def call(
+ self,
+ method: str,
+ path: str,
+ body=None,
+ *,
+ token: str | None = "default",
+ host: str | None = "default",
+ origin: str | None = None,
+ raw: bytes | None = None,
+ content_length: str | None = None,
+ ):
+ connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30)
+ connection.putrequest(method, path, skip_host=True, skip_accept_encoding=True)
+ if host is not None:
+ connection.putheader(
+ "Host", f"127.0.0.1:{self.port}" if host == "default" else host
+ )
+ if token == "default":
+ token = self.token
+ if token is not None:
+ connection.putheader(TOKEN_HEADER, token)
+ if origin is not None:
+ connection.putheader("Origin", origin)
+ payload = raw if raw is not None else (
+ json.dumps(body).encode() if body is not None else b""
+ )
+ if method == "POST":
+ connection.putheader(
+ "Content-Length",
+ content_length if content_length is not None else str(len(payload)),
+ )
+ connection.putheader("Content-Type", "application/json")
+ connection.endheaders(payload if method == "POST" and content_length is None else b"")
+ response = connection.getresponse()
+ data = response.read()
+ headers = {key.lower(): value for key, value in response.getheaders()}
+ connection.close()
+ try:
+ parsed = json.loads(data)
+ except (ValueError, UnicodeDecodeError):
+ parsed = data
+ return response.status, headers, parsed
+
+ def start(self, payload: dict):
+ return self.call("POST", "/api/jobs", payload)
+
+ def wait(self, job_id: str, timeout: float = 90.0) -> dict:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ status, _, body = self.call("GET", f"/api/jobs/{job_id}")
+ assert status == 200, body
+ if body["status"] != "running":
+ return body
+ time.sleep(0.05)
+ raise AssertionError(f"job {job_id} did not finish in {timeout}s")
+
+
+@pytest.fixture
+def client():
+ server = create_server()
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ yield Client(server)
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+
+
+# -- static serving and request hygiene --------------------------------------
+
+
+def test_index_is_served_with_the_session_token_and_a_strict_policy(client):
+ status, headers, body = client.call("GET", "/", token=None)
+ assert status == 200
+ assert headers["content-type"].startswith("text/html")
+ assert client.token.encode() in body
+ assert b"__CODONOPT_TOKEN__" not in body
+ assert "default-src 'none'" in headers["content-security-policy"]
+ assert "script-src 'self'" in headers["content-security-policy"]
+ assert headers["cache-control"] == "no-store"
+ assert headers["x-content-type-options"] == "nosniff"
+
+
+def test_each_server_has_its_own_token():
+ first, second = create_server(), create_server()
+ try:
+ assert first.app.token != second.app.token
+ assert len(first.app.token) >= 24
+ finally:
+ first.server_close()
+ second.server_close()
+
+
+@pytest.mark.parametrize(
+ "path",
+ ["/nope", "/../pyproject.toml", "/web/../cli.py", "/%2e%2e/cli.py", "/app.js/../x"],
+)
+def test_unknown_and_traversal_paths_are_not_found(client, path):
+ status, _, _ = client.call("GET", path, token=None)
+ assert status == 404
+
+
+def test_static_paths_reject_other_methods(client):
+ status, _, _ = client.call("POST", "/", {})
+ assert status == 405
+
+
+def test_foreign_host_header_is_refused(client):
+ """A DNS-rebinding page reaches this server under its own hostname."""
+ status, _, _ = client.call("GET", "/", token=None, host="evil.example")
+ assert status == 403
+ status, _, _ = client.call("GET", "/api/meta", host="evil.example")
+ assert status == 403
+ status, _, _ = client.call("GET", "/", token=None, host=None)
+ assert status == 403
+
+
+def test_localhost_name_is_accepted(client):
+ status, _, _ = client.call("GET", "/api/meta", host=f"localhost:{client.port}")
+ assert status == 200
+
+
+def test_wrong_port_in_host_header_is_refused(client):
+ status, _, _ = client.call("GET", "/", token=None, host="127.0.0.1:1")
+ assert status == 403
+
+
+def test_api_requires_the_token(client):
+ assert client.call("GET", "/api/meta", token=None)[0] == 403
+ assert client.call("GET", "/api/meta", token="wrong")[0] == 403
+ assert client.call("POST", "/api/jobs", {"sequence": "MKV"}, token=None)[0] == 403
+
+
+def test_cross_origin_posts_are_refused(client):
+ status, _, body = client.call(
+ "POST", "/api/jobs", {"sequence": PROTEIN, **FAST}, origin="https://evil.example"
+ )
+ assert status == 403
+ status, _, body = client.call(
+ "POST",
+ "/api/jobs",
+ {"sequence": PROTEIN, **FAST},
+ origin=f"http://127.0.0.1:{client.port}",
+ )
+ assert status == 202
+
+
+def test_only_loopback_addresses_can_be_bound():
+ for host in ("0.0.0.0", "192.168.1.5", "example.com", ""):
+ with pytest.raises(ValueError, match="loopback"):
+ create_server(host)
+
+
+# -- meta --------------------------------------------------------------------
+
+
+def test_meta_describes_the_form(client):
+ status, _, meta = client.call("GET", "/api/meta")
+ assert status == 200
+ assert {table["name"] for table in meta["tables"]} >= {"human", "ecoli", "uniform"}
+ assert "BsaI" in meta["motif_groups"]["golden_gate"]
+ assert meta["restriction_sites"]["EcoRI"] == "GAATTC"
+ assert meta["defaults"]["gc_range"] == [0.3, 0.7]
+ assert set(meta["weights"]) >= {"gc_global", "repeat", "motif", "usage"}
+ assert set(meta["effort"]) == {"quick", "standard", "thorough"}
+ assert isinstance(meta["maxent_available"], bool)
+ assert meta["example"]["protein"].startswith("M")
+ assert meta["limits"]["max_residues"] == MAX_RESIDUES
+
+
+# -- job lifecycle -----------------------------------------------------------
+
+
+def test_a_job_runs_to_completion_and_keeps_the_protein(client):
+ status, _, started = client.start(
+ {"sequence": PROTEIN, "avoid": ["golden_gate", "EcoRI"], **FAST}
+ )
+ assert status == 202
+ assert started["status"] == "running"
+
+ done = client.wait(started["id"])
+ assert done["status"] == "done"
+ result = done["result"]
+ assert translate(result["sequence"]) == PROTEIN
+ assert result["protein"] == PROTEIN
+ assert translate(result["initial_sequence"]) == PROTEIN
+ assert result["score"]["final"] <= result["score"]["initial"]
+ assert result["final"]["forbidden_motif_hits"] == 0
+ assert result["fasta"].startswith(">sequence ")
+ assert result["fasta"].replace("\n", "").endswith(result["sequence"])
+ assert result["cancelled"] is False
+
+
+def test_the_gc_profile_lines_up_for_plotting(client):
+ _, _, started = client.start({"sequence": PROTEIN, **FAST})
+ gc = client.wait(started["id"])["result"]["gc"]
+ assert len(gc["starts"]) == len(gc["initial"]) == len(gc["final"]) > 0
+ assert gc["range"] == [0.3, 0.7]
+ assert all(0.0 <= value <= 1.0 for value in gc["final"])
+ assert gc["starts"] == sorted(gc["starts"])
+
+
+def test_progress_is_reported_while_running(client):
+ _, _, started = client.start(
+ {"sequence": random_protein(120, seed=51), **FAST, "effort": "thorough"}
+ )
+ snapshot = client.call("GET", f"/api/jobs/{started['id']}")[2]
+ assert snapshot["restarts_total"] == 3
+ assert snapshot["max_seconds"] == 30
+ done = client.wait(started["id"])
+ assert done["restarts_done"] == 3
+ assert any("final score" in message for message in done["messages"])
+
+
+def test_dna_is_detected_and_improved_in_place(client):
+ dna = "ATGAAATGGGTTACCTTTATCTCTCTGCTGTTG"
+ _, _, started = client.start({"sequence": f">gene one\n{dna}\n", **FAST})
+ result = client.wait(started["id"])["result"]
+ assert result["kind"] == "dna"
+ assert result["name"] == "gene"
+ assert result["initial_sequence"] == dna
+ assert translate(result["sequence"]) == translate(dna)
+
+
+def test_a_trailing_stop_codon_can_be_requested(client):
+ _, _, started = client.start({"sequence": "MKWVTF", "add_stop": True, **FAST})
+ result = client.wait(started["id"])["result"]
+ assert result["protein"] == "MKWVTF*"
+ assert translate(result["sequence"]) == "MKWVTF*"
+
+
+def test_a_custom_codon_table_can_be_pasted(client):
+ table = json.dumps({"CTG": 1, "CTC": 0.1})
+ _, _, started = client.start(
+ {"sequence": "MLLLLL", "table_text": table, **FAST}
+ )
+ result = client.wait(started["id"])["result"]
+ assert result["codon_usage_table"] == "custom"
+
+
+def test_remaining_motif_hits_are_reported_for_highlighting(client):
+ # Weight the motif term to zero so a site is allowed to survive.
+ _, _, started = client.start(
+ {
+ "sequence": "MKWVTF",
+ "avoid": ["x=ATG"],
+ "weights": {"motif": 0},
+ **FAST,
+ }
+ )
+ hits = client.wait(started["id"])["result"]["motif_hits"]
+ assert hits and hits[0]["name"] == "x" and hits[0]["length"] == 3
+ assert hits[0]["positions"][0] == 0
+
+
+def test_cancelling_returns_the_best_sequence_so_far(client):
+ protein = random_protein(500, seed=52)
+ _, _, started = client.start(
+ {"sequence": protein, **FAST, "effort": "thorough", "seconds": 300}
+ )
+ begun = time.monotonic()
+ status, _, _ = client.call("POST", f"/api/jobs/{started['id']}/cancel")
+ assert status == 200
+ done = client.wait(started["id"], timeout=60)
+ assert time.monotonic() - begun < 30 # nowhere near the 300 s budget
+ assert done["status"] == "cancelled"
+ assert translate(done["result"]["sequence"]) == protein
+ assert done["result"]["cancelled"] is True
+
+
+def test_the_concurrency_limit_is_enforced_and_recovers(client):
+ protein = random_protein(600, seed=53)
+ slow = {"sequence": protein, **FAST, "effort": "thorough", "seconds": 300}
+ ids = []
+ for _ in range(2):
+ status, _, body = client.start(slow)
+ assert status == 202
+ ids.append(body["id"])
+ status, _, body = client.start(slow)
+ assert status == 400
+ assert "as many optimisations as allowed" in body["error"]
+
+ for job_id in ids:
+ client.call("POST", f"/api/jobs/{job_id}/cancel")
+ client.wait(job_id, timeout=60)
+ assert client.start({"sequence": PROTEIN, **FAST})[0] == 202
+
+
+def test_unknown_jobs_are_not_found(client):
+ assert client.call("GET", "/api/jobs/nonexistent")[0] == 404
+ assert client.call("POST", "/api/jobs/nonexistent/cancel")[0] == 404
+ assert client.call("GET", "/api/elsewhere")[0] == 404
+
+
+# -- bad requests ------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "payload, message",
+ [
+ ({"sequence": ""}, "Paste a protein or DNA sequence"),
+ ({"sequence": " \n "}, "Paste a protein or DNA sequence"),
+ ({"sequence": "MKZ"}, "unsupported residues"),
+ ({"sequence": "MK*V"}, "internal stop"),
+ ({"sequence": "ATGAAATGGG"}, "multiple of three"),
+ ({"sequence": ">a\nMKV\n>b\nMKW\n"}, "2 FASTA records"),
+ ({"sequence": "MKV", "gc_range": [30, 70]}, "between 0 and 1"),
+ ({"sequence": "MKV", "gc_range": [0.7, 0.3]}, "low <= high"),
+ ({"sequence": "MKV", "gc_range": [0.3]}, "two numbers"),
+ ({"sequence": "MKV", "gc_target": 2}, "between 0 and 1"),
+ ({"sequence": "MKV", "gc_window": 2.5}, "whole number"),
+ ({"sequence": "MKV", "gc_window": "wide"}, "must be a number"),
+ ({"sequence": "MKV", "table": "mouse"}, "unknown codon usage table"),
+ ({"sequence": "MKV", "table_text": "TTT,abc"}, "not a number"),
+ ({"sequence": "MKV", "table_text": "nothing useful"}, "expected two columns"),
+ ({"sequence": "MKV", "table_text": "codon,count\n"}, "no codon rows"),
+ ({"sequence": "MKV", "avoid": ["NotAnEnzyme"]}, "cannot resolve motif"),
+ ({"sequence": "MKV", "avoid": "EcoRI"}, "list of motif names"),
+ ({"sequence": "MKV", "weights": {"nonsense": 1}}, "unknown weight"),
+ ({"sequence": "MKV", "weights": {"usage": -1}}, "between 0 and"),
+ ({"sequence": "MKV", "weights": []}, "must be an object"),
+ ({"sequence": "MKV", "effort": "extreme"}, "effort must be"),
+ ({"sequence": "MKV", "splice_backend": "magic"}, "splice backend must be"),
+ ({"sequence": "MKV", "seconds": 0}, "between 1 and"),
+ ({"sequence": "MKV", "seconds": 10**6}, "between 1 and"),
+ ({"sequence": "MKV", "seed": -1}, "between 0 and"),
+ # An integer too wide for a float used to escape as a 500.
+ ({"sequence": "MKV", "seconds": 10**400}, "between 1 and"),
+ ({"sequence": "MKV", "gc_target": -(10**400)}, "between 0 and"),
+ # "Custom table" with nothing in it must not fall back to another host.
+ ({"sequence": "MKV", "table_text": ""}, "custom codon table is empty"),
+ ({"sequence": "MKV", "table_text": " "}, "custom codon table is empty"),
+ ({"sequence": "MKV", "kind": "rna"}, "kind must be"),
+ ({"sequence": "A" * (MAX_RESIDUES + 1), "kind": "protein"}, "accepts up to"),
+ ],
+)
+def test_bad_requests_get_a_readable_400(client, payload, message):
+ status, _, body = client.call("POST", "/api/jobs", payload)
+ assert status == 400, body
+ assert message in body["error"]
+
+
+def test_malformed_bodies(client):
+ assert client.call("POST", "/api/jobs", raw=b"{not json")[0] == 400
+ assert client.call("POST", "/api/jobs", raw=b"[1, 2]")[0] == 400
+ assert client.call("POST", "/api/jobs", raw=b"\xff\xfe")[0] == 400
+ # More digits than CPython will parse into an int: a bad request, not a crash.
+ huge = b'{"sequence": "MKV", "seconds": ' + b"9" * 5000 + b"}"
+ status, _, body = client.call("POST", "/api/jobs", raw=huge)
+ assert status == 400
+ assert "not valid JSON" in body["error"]
+
+
+def test_an_explicit_table_still_wins_over_an_empty_custom_one(client):
+ """The page omits `table` for a custom table; other callers may send both."""
+ status, _, started = client.start(
+ {"sequence": "MKV", "table": "ecoli", "table_text": "", **FAST}
+ )
+ assert status == 202
+ assert client.wait(started["id"])["status"] == "done"
+ status, _, body = client.call("POST", "/api/jobs", raw=b"null")
+ assert status == 400
+
+
+def test_oversized_and_unsized_bodies_are_refused(client):
+ status, _, _ = client.call(
+ "POST", "/api/jobs", raw=b"", content_length=str(3_000_000)
+ )
+ assert status == 413
+ status, _, _ = client.call("POST", "/api/jobs", raw=b"", content_length="lots")
+ assert status == 411
+
+
+# -- build_request, without HTTP --------------------------------------------
+
+
+def test_build_request_interprets_the_input():
+ spec = build_request({"sequence": "MKWVTF", "add_stop": True})
+ assert (spec.protein, spec.start, spec.kind) == ("MKWVTF*", None, "protein")
+
+ spec = build_request({"sequence": "atgaaatgggtt"})
+ assert spec.kind == "dna" and spec.start == "ATGAAATGGGTT"
+ assert spec.protein == "MKWV"
+
+ # RNA spelling is accepted and normalised.
+ spec = build_request({"sequence": "AUGAAAUGGGUU"})
+ assert spec.start == "ATGAAATGGGTT"
+
+
+def test_add_stop_does_not_alter_a_dna_input():
+ spec = build_request({"sequence": "ATGAAATGGGTT", "add_stop": True})
+ assert spec.protein == "MKWV"
+
+
+def test_kind_overrides_detection():
+ # Alanine, cysteine, glycine and threonine spell valid DNA letters, so a
+ # sequence of only those is ambiguous and the caller may need to say.
+ assert build_request({"sequence": "ACGTACGTA"}).kind == "dna"
+ spec = build_request({"sequence": "ACGTACGTA", "kind": "protein"})
+ assert spec.kind == "protein" and spec.start is None
+ assert spec.protein == "ACGTACGTA"
+ # Short strings are read as protein unless told otherwise.
+ assert build_request({"sequence": "ATGAAATG"}).kind == "protein"
+
+
+def test_build_request_names_the_record():
+ assert build_request({"sequence": ">albumin sample\nMKV"}).name == "albumin"
+ assert build_request({"sequence": "MKV", "name": "mine"}).name == "mine"
+ assert build_request({"sequence": "MKV"}).name == "sequence"
+
+
+def test_build_request_resolves_every_option():
+ spec = build_request(
+ {
+ "sequence": "MKV",
+ "table": "ecoli",
+ "avoid": ["golden_gate", "AATAAA"],
+ "gc_target": 0.6,
+ "gc_range": [0.35, 0.65],
+ "repeat_k": 9,
+ "weights": {"usage": 0, "motif": 10},
+ "effort": "thorough",
+ "seed": 7,
+ "seconds": 45,
+ "splice_backend": "motif",
+ }
+ )
+ config = spec.config
+ assert config.table.name == "ecoli"
+ assert config.gc_target == 0.6 and config.gc_range == (0.35, 0.65)
+ assert config.repeat_k == 9
+ assert config.weights.usage == 0 and config.weights.motif == 10
+ assert len(config.motifs) == 5 # four golden-gate enzymes plus AATAAA
+ assert config.splice_scorer.name == "motif"
+ assert spec.settings["restarts"] == 3 and spec.settings["seed"] == 7
+ assert spec.settings["max_seconds"] == 45
+
+
+def test_request_errors_are_value_errors():
+ assert issubclass(RequestError, ValueError)
+ with pytest.raises(RequestError):
+ build_request("not an object")
+
+
+# ---------------------------------------------------------------- built assets
+
+
+def test_built_interface_is_shipped_and_wired_to_the_token():
+ """The Node-free install serves committed files; make sure they exist and fit together."""
+ from importlib import resources
+
+ web = resources.files("codonopt") / "web"
+ names = {p.name for p in web.iterdir()}
+ assert {"index.html", "app.js", "app.css", "favicon.svg"} <= names
+ html = (web / "index.html").read_text(encoding="utf-8")
+ assert "__CODONOPT_TOKEN__" in html
+ assert "app.js" in html and "app.css" in html
+ assert "
+
+
+
+
+
+
+
+
+
+
codonopt
+
Codon optimization for synthesis and expression
+
+
+
+
+
+
+
+
+
+
+
+ Optimize
+ Stop
+
+
+
+
+
+
+
+
Design a sequence that is easy to make
+
The protein stays exactly the same. Only the synonymous codons change, so the DNA
+ avoids what makes genes hard to synthesise and express.
+
+ Paste a protein, or a DNA sequence you want to improve.
+ Choose a host and the sites to keep out.
+ Optimize , then compare before and after.
+
+
Nothing leaves this computer: the page talks only to the local codonopt process.
+
+
+
+
+ codonopt needs JavaScript to run.
+
+