Skip to content

Commit 4756cc5

Browse files
committed
Save each run's settings and load a settings file into the form
The option layer now builds one mapping that holds every exposed option's value for a run. A form submission and a settings file are two sources for it, and the command line and the saved file are two renderings of it, so the record and the run cannot disagree. A required option with no submitted value and no default now raises there, and the run is refused. This replaces the fallback to the first choice, which decided the alignment preset of every example run by the order cmuts prints its choices. Each bundled example carries a settings.json naming the preset it wants. Every job directory holds a settings.json with all the options the run used and the cmuts that ran it. The results page offers the file, and the zip download holds it. The Options card takes a settings file. The page sends it to /settings, which checks every name and value against the option dumps, and writes the fields it answers with into the controls. A refused file changes nothing, and a loaded value opens the section that holds it. Messages name an option the same way everywhere, as the subcommand and the option name.
1 parent 831c966 commit 4756cc5

10 files changed

Lines changed: 392 additions & 52 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ uv venv .venv
99
uv sync
1010
```
1111

12+
Each bundled example holds a `settings.json` naming the options it runs with. A dataset without one is refused, so the server never picks an option on an example's behalf.
13+
1214
The example FASTQ files go through Git LFS (see `.gitattributes`), so a clone needs `git lfs` installed.
1315

1416
## Running locally

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ license: mit
1212

1313
Compute normalized reactivity profiles from MaP-seq experiments. Upload a reference FASTA and your sequencing reads, and get back an interactive report plus the profiles as HDF5 and CSV.
1414

15+
Each run also saves a `settings.json` holding every option it used. Load that file back into the form to repeat a run, or hand it to someone else with your reads.
16+
1517
See the [cmuts documentation](https://daslab.stanford.edu/cmuts) for details on the methods and outputs.

‎app.py‎

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import asyncio
1111
import io
12+
import json
1213
import os
1314
import re
1415
import shutil
@@ -39,6 +40,7 @@
3940
MAX_CONDITIONS,
4041
MAX_UPLOAD_MB,
4142
RESULTS_TTL_HOURS,
43+
SETTINGS_FILE,
4244
ConditionInput,
4345
JobState,
4446
job_dir_for,
@@ -110,6 +112,10 @@ def _asset_version() -> str:
110112

111113
UPLOAD_CHUNK_BYTES = 1 << 20
112114

115+
# A settings file holds one small JSON object; anything larger is refused
116+
# before it is parsed.
117+
SETTINGS_MAX_BYTES = 1 << 20
118+
113119

114120
# --- Helpers ---
115121

@@ -168,6 +174,10 @@ def _stage_condition(form: FormData, index: int, uploads_dir: str) -> ConditionI
168174
untreated=files["untreated"], denatured=files["denatured"])
169175

170176

177+
def _write_run_settings(job_dir: str, settings: dict) -> None:
178+
pipeline.write_settings(job_dir, options.settings_document(SPECS, settings))
179+
180+
171181
def _submit_job(background_tasks: BackgroundTasks, job_id: str, job_dir: str,
172182
fasta_path: str, conditions: list[ConditionInput],
173183
extra: dict[str, list[str]]) -> RedirectResponse:
@@ -251,9 +261,10 @@ async def run(request: Request, background_tasks: BackgroundTasks):
251261
form = await request.form()
252262

253263
try:
254-
extra = options.all_option_args(SPECS, form)
264+
settings = options.run_settings(SPECS, options.form_settings(SPECS, form))
255265
except ValueError as error:
256266
raise HTTPException(400, str(error))
267+
extra = options.all_option_args(SPECS, settings)
257268

258269
job_id = uuid.uuid4().hex[:12]
259270
job_dir = job_dir_for(job_id)
@@ -263,9 +274,22 @@ async def run(request: Request, background_tasks: BackgroundTasks):
263274
shutil.rmtree(job_dir, ignore_errors=True)
264275
raise
265276

277+
_write_run_settings(job_dir, settings)
266278
return _submit_job(background_tasks, job_id, job_dir, fasta_path, conditions, extra)
267279

268280

281+
def _example_settings(src_dir: str, name: str) -> dict:
282+
"""The settings one bundled example runs with, read from its own file so
283+
no example depends on a default the server chooses for it."""
284+
path = os.path.join(src_dir, SETTINGS_FILE)
285+
try:
286+
with open(path) as f:
287+
document = json.load(f)
288+
return options.run_settings(SPECS, options.document_settings(SPECS, document))
289+
except (OSError, json.JSONDecodeError, ValueError) as error:
290+
raise HTTPException(500, f"Example dataset {name}: {error}")
291+
292+
269293
@app.post("/run-example/{name}")
270294
def run_example(name: str, background_tasks: BackgroundTasks):
271295
"""Submits a bundled example dataset (a subdirectory under examples/)."""
@@ -285,6 +309,8 @@ def run_example(name: str, background_tasks: BackgroundTasks):
285309
if fasta is None or treated is None:
286310
raise HTTPException(500, f"Example dataset {name} is missing required files.")
287311

312+
settings = _example_settings(src_dir, name)
313+
288314
job_id = uuid.uuid4().hex[:12]
289315
job_dir = job_dir_for(job_id)
290316
uploads_dir = os.path.join(job_dir, "uploads")
@@ -300,10 +326,46 @@ def stage(src: str) -> str:
300326
treated=[stage(treated)],
301327
untreated=[stage(untreated)] if untreated else [],
302328
)]
303-
extra = options.all_option_args(SPECS, FormData())
329+
_write_run_settings(job_dir, settings)
330+
extra = options.all_option_args(SPECS, settings)
304331
return _submit_job(background_tasks, job_id, job_dir, stage(fasta), conditions, extra)
305332

306333

334+
# --- Routes: settings ---
335+
336+
337+
def _settings_upload(form: FormData) -> UploadFile:
338+
uploads = _real_uploads(form.getlist("settings"))
339+
if not uploads:
340+
raise HTTPException(400, "No settings file was chosen.")
341+
return uploads[0]
342+
343+
344+
async def _uploaded_document(upload: UploadFile) -> dict:
345+
"""The parsed settings file, refused where it is too large to be one or
346+
is not JSON at all."""
347+
raw = await upload.read(SETTINGS_MAX_BYTES + 1)
348+
if len(raw) > SETTINGS_MAX_BYTES:
349+
raise HTTPException(400, "The settings file is too large.")
350+
try:
351+
return json.loads(raw)
352+
except (json.JSONDecodeError, UnicodeDecodeError):
353+
raise HTTPException(400, "The settings file is not valid JSON.")
354+
355+
356+
@app.post("/settings", response_class=JSONResponse)
357+
async def check_settings(request: Request) -> JSONResponse:
358+
"""Checks an uploaded settings file against the option dumps and answers
359+
with the form fields it sets, which the page writes into the form."""
360+
form = await request.form()
361+
document = await _uploaded_document(_settings_upload(form))
362+
try:
363+
fields = options.settings_fields(SPECS, document)
364+
except ValueError as error:
365+
raise HTTPException(400, str(error))
366+
return JSONResponse({"fields": fields})
367+
368+
307369
# --- Routes: results ---
308370

309371

@@ -322,6 +384,7 @@ def results(request: Request, job_id: str) -> HTMLResponse:
322384
"job_id": job_id,
323385
"status": status_,
324386
"ttl_hours": RESULTS_TTL_HOURS,
387+
"settings_file": SETTINGS_FILE,
325388
"log": log,
326389
"error": error,
327390
"meta": read_meta(job_dir_for(job_id)),
@@ -364,7 +427,7 @@ def _output_files(job_id: str) -> list[str]:
364427
files = []
365428
for condition in meta.get("conditions", []):
366429
files.extend([f"{condition['tag']}.h5", f"{condition['tag']}.csv"])
367-
return files
430+
return files + [SETTINGS_FILE]
368431

369432

370433
@app.get("/results/{job_id}/download/all")

‎examples/multi-ref/settings.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"options": {
3+
"align": {
4+
"preset": "sr"
5+
}
6+
}
7+
}

‎examples/single-ref/settings.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"options": {
3+
"align": {
4+
"preset": "sr"
5+
}
6+
}
7+
}

0 commit comments

Comments
 (0)