diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 64955b11..3b25f93e 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -828,7 +828,10 @@ jobs:
# moving, not the passage of time: bump kind, then read the support
# table again.
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/kind/deploy.yaml
- kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=90s
+ # rollout status waits for the controller pod to exist; `kubectl wait`
+ # on a selector fails at once ("no matching resources found") when
+ # it runs before the Deployment has created the pod.
+ kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=180s
- name: Set up Helm
# kubectl kustomize --enable-helm shells out to `helm` to inflate the
diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
index 733018fc..dc1769b6 100644
--- a/.github/workflows/build-image.yml
+++ b/.github/workflows/build-image.yml
@@ -139,8 +139,8 @@ jobs:
# the template it names the app's own image, and a hardcoded
# template ref leaves every pod in ErrImageNeverPull.
overlay=k8s/overlays/prod/kustomization.yaml
- name=$(awk '$1 == "newName:" {print $2; exit}' "$overlay")
- tag=$(awk '$1 == "newTag:" {print $2; exit}' "$overlay")
+ name=$(awk '$1 == "newName:" {print $2; exit}' "$overlay" | tr -d "\"'")
+ tag=$(awk '$1 == "newTag:" {print $2; exit}' "$overlay" | tr -d "\"'")
if [ -z "$name" ] || [ -z "$tag" ]; then
echo "::error::$overlay has no images newName/newTag to tag the kind image with"
exit 1
diff --git a/electron/scripts/package.sh b/electron/scripts/package.sh
index 98736b59..d36d9133 100755
--- a/electron/scripts/package.sh
+++ b/electron/scripts/package.sh
@@ -56,7 +56,11 @@ case "$(uname -s)" in
export CSC_FOR_PULL_REQUEST=true
# Notarize with an App Store Connect API key, or else with an Apple ID
# and an app-specific password.
- if [[ -n "${APPLE_API_KEY_CONTENT:-}" ]]; then
+ if [[ -n "${APPLE_API_KEY_CONTENT:-}" ]] && { [[ -z "${APPLE_API_KEY_ID:-}" ]] || [[ -z "${APPLE_API_ISSUER:-}" ]]; } && [[ -z "${APPLE_ID:-}" ]]; then
+ echo "::error::APPLE_API_KEY is set, but APPLE_API_KEY_ID or APPLE_API_ISSUER is empty: notarization needs all three"
+ exit 1
+ fi
+ if [[ -n "${APPLE_API_KEY_CONTENT:-}" && -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER:-}" ]]; then
printf '%s\n' "$APPLE_API_KEY_CONTENT" > "${RUNNER_TEMP:-/tmp}/AuthKey.p8"
trap 'rm -f "${RUNNER_TEMP:-/tmp}/AuthKey.p8"' EXIT
export APPLE_API_KEY="${RUNNER_TEMP:-/tmp}/AuthKey.p8" APPLE_API_KEY_ID APPLE_API_ISSUER
diff --git a/src/common/common.py b/src/common/common.py
index 0bf1b802..a0e678f5 100644
--- a/src/common/common.py
+++ b/src/common/common.py
@@ -87,7 +87,9 @@ def get_branding(theme: str | None = None) -> dict[str, str]:
* "page_icon": the browser tab icon (one image; the tab ignores the theme),
* "logo": the image at the top of the sidebar,
* "logo_icon": the smaller image shown when the sidebar is collapsed,
- * "app_icon": the image an app page shows next to its title,
+ * "app_icon": an icon for the app's own pages to use, e.g. next to a
+ title (the template shows it nowhere itself; it is also the default
+ "logo_icon"),
* "logo_size": "small", "medium" (default) or "large"; a wide logo with
the app name in it reads better at "large".
diff --git a/src/workflow/RunManager.py b/src/workflow/RunManager.py
index 95e07ff3..18e7a1c4 100644
--- a/src/workflow/RunManager.py
+++ b/src/workflow/RunManager.py
@@ -15,10 +15,12 @@
"""
import json
+import re
import shutil
import stat
import uuid
import zipfile
+import zlib
from datetime import datetime
from pathlib import Path
from typing import Optional
@@ -27,6 +29,10 @@
MAX_NAME_LENGTH = 100
# Largest unpacked size import_zip() accepts by default
MAX_IMPORT_BYTES = 20 * 1024**3
+# Most files a run ZIP may add, and the largest run.json / params.json read
+MAX_IMPORT_MEMBERS = 100_000
+MAX_RUN_FILE_BYTES = 1024**2
+MAX_PARAMS_FILE_BYTES = 64 * 1024**2
class RunImportError(ValueError):
@@ -188,7 +194,10 @@ def import_zip(
Every member is checked before anything is written: absolute paths,
``..``, drive letters, backslashes and symbolic links are refused, as
- is a ZIP that unpacks to more than ``max_bytes``. Unpacking goes to a
+ as are names Windows reserves (``NUL``, ``CON``, ...), a ZIP that
+ unpacks to more than ``max_bytes`` or holds more than
+ MAX_IMPORT_MEMBERS files, and an oversized run.json or params.json.
+ A run that was still running when it was zipped is imported as failed. Unpacking goes to a
temporary folder that becomes the run only when it is complete.
Args:
@@ -204,84 +213,123 @@ def import_zip(
archive = zipfile.ZipFile(source)
except (zipfile.BadZipFile, OSError) as e:
raise RunImportError("This is not a ZIP file.") from e
- with archive:
- members = [m for m in archive.infolist() if not m.is_dir()]
- for m in archive.infolist():
- _check_member(m)
- total = sum(m.file_size for m in members)
- if total > max_bytes:
- raise RunImportError(
- f"The ZIP unpacks to {total / 1024**3:.1f} GB, more than "
- f"the {max_bytes / 1024**3:.1f} GB allowed."
- )
- prefix = _run_prefix([m.filename for m in members])
- if prefix is None:
+ try:
+ with archive:
+ return self._import_archive(archive, name, max_bytes)
+ except (zipfile.BadZipFile, zlib.error, EOFError) as e:
+ raise RunImportError(f"The ZIP is damaged ({e}).") from e
+ except RuntimeError as e:
+ # Encrypted members, an unsupported compression method, or JSON
+ # nested too deeply to parse
+ raise RunImportError(f"The ZIP can not be read ({e}).") from e
+ except OSError as e:
+ raise RunImportError(f"The ZIP could not be unpacked ({e}).") from e
+
+ def _import_archive(self, archive: zipfile.ZipFile, name: str, max_bytes: int) -> str:
+ members = [m for m in archive.infolist() if not m.is_dir()]
+ for m in archive.infolist():
+ _check_member(m)
+ if len(members) > MAX_IMPORT_MEMBERS:
+ raise RunImportError(
+ f"The ZIP holds {len(members)} files, more than the "
+ f"{MAX_IMPORT_MEMBERS} allowed."
+ )
+ total = sum(m.file_size for m in members)
+ if total > max_bytes:
+ raise RunImportError(
+ f"The ZIP unpacks to {total / 1024**3:.1f} GB, more than "
+ f"the {max_bytes / 1024**3:.1f} GB allowed."
+ )
+ prefix = _run_prefix([m.filename for m in members])
+ if prefix is None:
+ raise RunImportError(
+ "This ZIP holds no run: a run's ZIP has a results/ "
+ "folder or a run.json."
+ )
+
+ for m in members:
+ limit = {
+ f"{prefix}{RUN_FILE}": MAX_RUN_FILE_BYTES,
+ f"{prefix}params.json": MAX_PARAMS_FILE_BYTES,
+ }.get(m.filename)
+ if limit is not None and m.file_size > limit:
raise RunImportError(
- "This ZIP holds no run: a run's ZIP has a results/ folder."
+ f"Refused: {m.filename} is larger than "
+ f"{limit / 1024**2:.0f} MB."
)
+ meta = {}
+ if f"{prefix}{RUN_FILE}" in archive.namelist():
+ try:
+ meta = json.loads(archive.read(f"{prefix}{RUN_FILE}"))
+ except ValueError:
+ meta = {}
+ if not isinstance(meta, dict):
meta = {}
- if f"{prefix}{RUN_FILE}" in archive.namelist():
+ status = meta.get("status")
+ if status == RUNNING:
+ # Zipped while it ran, or after its process died: its results
+ # may be incomplete.
+ status = FAILED
+ elif status not in (FINISHED, FAILED, CANCELLED):
+ status = FINISHED
+
+ tmp = Path(self.workflow_dir, f".run-import-{uuid.uuid4().hex[:8]}")
+ tmp.mkdir(parents=True)
+ try:
+ for m in members:
+ rel = m.filename[len(prefix):]
+ if not rel or rel == RUN_FILE:
+ continue
+ target = Path(tmp, *rel.split("/"))
+ target.parent.mkdir(parents=True, exist_ok=True)
+ with archive.open(m) as src, open(target, "wb") as dst:
+ shutil.copyfileobj(src, dst)
+ params = Path(tmp, "params.json")
+ if params.exists():
try:
- meta = json.loads(archive.read(f"{prefix}{RUN_FILE}"))
+ if not isinstance(json.loads(params.read_text(encoding="utf-8")), dict):
+ raise ValueError
except ValueError:
- meta = {}
- if not isinstance(meta, dict):
- meta = {}
-
- tmp = Path(self.workflow_dir, f".run-import-{uuid.uuid4().hex[:8]}")
- tmp.mkdir(parents=True)
- try:
- for m in members:
- rel = m.filename[len(prefix):]
- if not rel or rel == RUN_FILE:
- continue
- target = Path(tmp, *rel.split("/"))
- target.parent.mkdir(parents=True, exist_ok=True)
- with archive.open(m) as src, open(target, "wb") as dst:
- shutil.copyfileobj(src, dst)
- params = Path(tmp, "params.json")
- if params.exists():
- try:
- if not isinstance(json.loads(params.read_text(encoding="utf-8")), dict):
- raise ValueError
- except ValueError:
- params.unlink()
- Path(tmp, "results").mkdir(exist_ok=True)
-
- run = {
- "name": _clean_name(name) or _clean_name(meta.get("name")) or "Imported run",
- "created": meta.get("created") or _now(),
- "status": meta.get("status") if meta.get("status") in (
- FINISHED, FAILED, CANCELLED
- ) else FINISHED,
- "imported": _now(),
- }
- if meta.get("finished"):
- run["finished"] = meta["finished"]
- run["name"] = self._unique_name(run["name"][:MAX_NAME_LENGTH])
-
- self.runs_dir.mkdir(parents=True, exist_ok=True)
- base = datetime.now().strftime("%Y%m%d-%H%M%S")
- run_id, n = base, 1
- while self.run_dir(run_id).exists():
- n += 1
- run_id = f"{base}-{n}"
- with open(Path(tmp, RUN_FILE), "w", encoding="utf-8") as f:
- json.dump(run, f, indent=2)
- tmp.rename(self.run_dir(run_id))
- finally:
- shutil.rmtree(tmp, ignore_errors=True)
+ params.unlink()
+ Path(tmp, "results").mkdir(exist_ok=True)
+
+ run = {
+ "name": _clean_name(name) or _clean_name(meta.get("name")) or "Imported run",
+ "created": _iso_time(meta.get("created")) or _now(),
+ "status": status,
+ "imported": _now(),
+ }
+ if _iso_time(meta.get("finished")):
+ run["finished"] = _iso_time(meta["finished"])
+ run["name"] = self._unique_name(run["name"])
+
+ self.runs_dir.mkdir(parents=True, exist_ok=True)
+ base = datetime.now().strftime("%Y%m%d-%H%M%S")
+ run_id, n = base, 1
+ while self.run_dir(run_id).exists():
+ n += 1
+ run_id = f"{base}-{n}"
+ with open(Path(tmp, RUN_FILE), "w", encoding="utf-8") as f:
+ json.dump(run, f, indent=2)
+ tmp.rename(self.run_dir(run_id))
+ finally:
+ shutil.rmtree(tmp, ignore_errors=True)
return run_id
def _unique_name(self, name: str) -> str:
+ """``name``, or ``name (n)`` if a run has it, within MAX_NAME_LENGTH."""
taken = {r.get("name") for r in self.list_runs()}
+ name = name[:MAX_NAME_LENGTH]
if name not in taken:
return name
n = 2
- while f"{name} ({n})" in taken:
+ while True:
+ suffix = f" ({n})"
+ candidate = name[: MAX_NAME_LENGTH - len(suffix)].rstrip() + suffix
+ if candidate not in taken:
+ return candidate
n += 1
- return f"{name} ({n})"
def adopt_legacy_results(self) -> Optional[str]:
"""
@@ -342,6 +390,21 @@ def _is_safe_id(run_id: str) -> bool:
return bool(run_id) and "/" not in run_id and "\\" not in run_id and run_id not in (".", "..")
+def _iso_time(value) -> Optional[str]:
+ """``value`` if it is an ISO timestamp string, else None."""
+ if not isinstance(value, str):
+ return None
+ try:
+ datetime.fromisoformat(value)
+ except ValueError:
+ return None
+ return value
+
+
+# Names Windows maps to devices, with or without an extension (NUL, nul.txt)
+_WINDOWS_RESERVED = re.compile(r"^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$", re.I)
+
+
def _check_member(member: zipfile.ZipInfo) -> None:
"""Refuse a ZIP member that could land outside the folder it is unpacked to."""
name = member.filename
@@ -352,6 +415,7 @@ def _check_member(member: zipfile.ZipInfo) -> None:
or ".." in parts
or (len(name) > 1 and name[1] == ":")
or "\x00" in name
+ or any(_WINDOWS_RESERVED.match(p.rstrip(" .")) for p in parts if p)
):
raise RunImportError(f"Refused: the ZIP has an unsafe path ({name!r}).")
mode = member.external_attr >> 16
@@ -362,14 +426,21 @@ def _check_member(member: zipfile.ZipInfo) -> None:
def _run_prefix(names: list[str]) -> Optional[str]:
"""
Where the run sits in the ZIP: "" when results/ is at the top, "
/"
- when everything is in one folder, None when there is no results/ folder.
+ when everything is in one folder, None when it holds no run. A run is
+ a results/ folder or a run.json: zip() leaves out an empty results/.
"""
- if any(n.startswith("results/") for n in names):
+ def is_run(prefix: str) -> bool:
+ return any(
+ n.startswith(f"{prefix}results/") or n == f"{prefix}{RUN_FILE}"
+ for n in names
+ )
+
+ if is_run(""):
return ""
tops = {n.split("/", 1)[0] for n in names}
if len(tops) == 1:
top = tops.pop() + "/"
- if any(n.startswith(f"{top}results/") for n in names):
+ if is_run(top):
return top
return None
diff --git a/src/workflow/StreamlitUI.py b/src/workflow/StreamlitUI.py
index aec444b7..0e8d5832 100644
--- a/src/workflow/StreamlitUI.py
+++ b/src/workflow/StreamlitUI.py
@@ -110,6 +110,19 @@ def _plain(value):
return value
+def _as_str_list(value) -> list[str]:
+ """A list parameter's value as a list of str: bytes items decoded, and
+ newline-separated text (as params.json stores lists) split into items,
+ empty ones included, as CommandExecutor.run_topp() passes them."""
+ if isinstance(value, (list, tuple)):
+ return [v.decode() if isinstance(v, bytes) else str(v) for v in value]
+ if isinstance(value, bytes):
+ value = value.decode()
+ if value is None or value == "":
+ return []
+ return str(value).split("\n")
+
+
def _short(value) -> str:
"""A parameter value for a caption or tooltip."""
if isinstance(value, list):
@@ -1510,8 +1523,10 @@ def _all_parameters_dialog(
name = key.decode().split(":1:")[1]
default = defaults.get(name, entry.value)
value = user.get(name, default)
- if isinstance(entry.value, list) and isinstance(value, str):
- value = value.split("\n") if value else []
+ if isinstance(entry.value, list):
+ # pyOpenMS gives list items as bytes, and params.json or
+ # custom_defaults can hold a list as newline-separated text
+ default, value = _as_str_list(default), _as_str_list(value)
rows.append(
{
"name": name,
@@ -1661,8 +1676,8 @@ def to_preset(k=wkey, r=row, v=preset_val):
if isinstance(default, bool):
current = value if isinstance(value, bool) else str(value).lower() == "true"
c_value.checkbox(label, value=current, **common)
- elif isinstance(default, list):
- items = [str(v) for v in value] if isinstance(value, list) else str(value).split("\n")
+ elif row["is_list"]:
+ items = _as_str_list(value)
if row["valid_strings"]:
c_value.multiselect(
label,
diff --git a/src/workflow/WorkflowManager.py b/src/workflow/WorkflowManager.py
index d59c8b42..2b636114 100644
--- a/src/workflow/WorkflowManager.py
+++ b/src/workflow/WorkflowManager.py
@@ -339,6 +339,10 @@ def show_results_section(self) -> None:
A multi-run workflow first shows a run picker and points
self.results_dir at the chosen run.
"""
+ if self.multi_run:
+ from .run_widgets import show_import_notice
+
+ show_import_notice(self.runs)
if self.multi_run and not self.select_run():
from .run_widgets import run_import
diff --git a/src/workflow/run_widgets.py b/src/workflow/run_widgets.py
index 729b748e..20ed35a5 100644
--- a/src/workflow/run_widgets.py
+++ b/src/workflow/run_widgets.py
@@ -216,6 +216,28 @@ def run_manager_section(
st.rerun()
+def show_import_notice(runs: RunManager) -> None:
+ """
+ The message left by run_import() for the rerun after an import. Shown
+ once: run_import() and the results pages both call this, whichever runs
+ first takes it.
+ """
+ done = st.session_state.pop(f"_runs-{_slug(runs)}-imported", None)
+ if not done:
+ return
+ if isinstance(done, dict):
+ name, shown = done.get("name", ""), done.get("shown", False)
+ else:
+ name, shown = done, True
+ if shown:
+ st.success(f"Imported **{name}**. It is shown on the results pages.")
+ else:
+ st.warning(
+ f"Imported **{name}**. Its run did not finish, so the results "
+ "pages do not show it; it is listed on the Download page."
+ )
+
+
def run_import(runs: RunManager, expanded: bool = False) -> None:
"""
Upload a run's ZIP (as the download button of run_manager_section() makes
@@ -225,13 +247,13 @@ def run_import(runs: RunManager, expanded: bool = False) -> None:
slug = _slug(runs)
counter = f"_runs-{slug}-import-n"
n = st.session_state.get(counter, 0)
- done = st.session_state.pop(f"_runs-{slug}-imported", None)
- if done:
- st.success(f"Imported **{done}**. It is shown on the results pages.")
+ show_import_notice(runs)
with st.expander("📥 Open a run from a ZIP", expanded=expanded):
st.caption(
"A run downloaded from this page, here or in another workspace or "
- "installation: results, parameters and logs are added as a new run."
+ "installation: results, parameters and logs are added as a new run. "
+ f"Up to {st.get_option('server.maxUploadSize')} MB "
+ "(`server.maxUploadSize` in `.streamlit/config.toml`)."
)
upload = st.file_uploader(
"Run ZIP", type=["zip"], key=f"runs-{slug}-import-{n}",
@@ -247,11 +269,14 @@ def run_import(runs: RunManager, expanded: bool = False) -> None:
st.error(f"**{upload.name}** was not imported. {e}")
return
run = runs.get(run_id)
- if run.get("status") == FINISHED:
+ shown = run.get("status") == FINISHED
+ if shown:
set_selected_run(runs, run_id)
# A fresh uploader, and the message after the rerun
st.session_state[counter] = n + 1
- st.session_state[f"_runs-{slug}-imported"] = run.get("name", run_id)
+ st.session_state[f"_runs-{slug}-imported"] = {
+ "name": run.get("name", run_id), "shown": shown,
+ }
st.rerun()
diff --git a/tests/test_run_import.py b/tests/test_run_import.py
index bfa3329b..2f8d46ad 100644
--- a/tests/test_run_import.py
+++ b/tests/test_run_import.py
@@ -16,9 +16,12 @@
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(PROJECT_ROOT)
+from src.workflow import RunManager as run_manager_module # noqa: E402
from src.workflow.RunManager import ( # noqa: E402
FAILED,
FINISHED,
+ MAX_NAME_LENGTH,
+ RUNNING,
RunImportError,
RunManager,
)
@@ -98,7 +101,11 @@ def test_zip_of_the_run_folder_and_minimal_zip(tmp_path):
@pytest.mark.parametrize(
"name",
- ["../evil.txt", "results/../../evil.txt", "/etc/evil", "C:/evil.txt", "results\\..\\evil"],
+ [
+ "../evil.txt", "results/../../evil.txt", "/etc/evil", "C:/evil.txt",
+ "results\\..\\evil", "results/NUL", "results/con.txt", "results/LPT1/a.tsv",
+ "results/aux. ",
+ ],
)
def test_path_traversal_is_refused(tmp_path, name):
runs = RunManager(tmp_path / "wf")
@@ -120,3 +127,113 @@ def test_other_refusals(tmp_path):
with pytest.raises(RunImportError, match="more than"):
runs.import_zip(_zip({"results/big.bin": "0" * 5000}), max_bytes=1000)
assert runs.list_runs() == []
+
+
+def test_names_like_reserved_ones_are_kept(tmp_path):
+ runs = RunManager(tmp_path / "wf")
+ run_id = runs.import_zip(_zip({"results/nullable.tsv": "1", "results/console/a": "2"}))
+ assert (runs.results_dir(run_id) / "nullable.tsv").exists()
+ assert (runs.results_dir(run_id) / "console" / "a").exists()
+
+
+def test_run_without_result_files_round_trips(tmp_path):
+ src = RunManager(tmp_path / "src")
+ run_id = src.new_run("Empty")
+ src.finish(run_id, FINISHED)
+ archive = src.zip(run_id)
+ with zipfile.ZipFile(archive) as zf:
+ assert not any(n.startswith("results/") for n in zf.namelist())
+
+ dst = RunManager(tmp_path / "dst")
+ new_id = dst.import_zip(archive)
+ assert dst.get(new_id)["name"] == "Empty"
+ assert dst.results_dir(new_id).is_dir()
+ # Also inside one top folder
+ wrapped = dst.import_zip(_zip({"Run 9/run.json": json.dumps({"name": "Run 9"})}))
+ assert dst.get(wrapped)["name"] == "Run 9"
+
+
+def test_interrupted_run_is_imported_as_failed(tmp_path):
+ runs = RunManager(tmp_path / "wf")
+ run_id = runs.import_zip(_zip({
+ "run.json": json.dumps({"name": "Cut off", "status": RUNNING}),
+ "results/partial.tsv": "1",
+ }))
+ assert runs.get(run_id)["status"] == FAILED
+
+
+def test_bad_timestamps_are_replaced(tmp_path):
+ runs = RunManager(tmp_path / "wf")
+ run_id = runs.import_zip(_zip({
+ "run.json": json.dumps({"created": 123, "finished": ["x"], "status": FINISHED}),
+ "results/a.tsv": "1",
+ }))
+ run = runs.get(run_id)
+ assert isinstance(run["created"], str) and run["created"]
+ assert "finished" not in run
+ # list_runs() sorts by "created": a number there would break it
+ runs.import_zip(_zip({"run.json": json.dumps({"created": "not a time"}), "results/b": "1"}))
+ assert len(runs.list_runs()) == 2
+ for r in runs.list_runs():
+ run_manager_module.format_time(r["created"])
+
+
+def test_name_stays_within_the_limit_with_a_suffix(tmp_path):
+ runs = RunManager(tmp_path / "wf")
+ long = "x" * (MAX_NAME_LENGTH + 20)
+ entries = {"run.json": json.dumps({"name": long}), "results/a": "1"}
+ first = runs.import_zip(_zip(entries))
+ second = runs.import_zip(_zip(entries))
+ third = runs.import_zip(_zip(entries))
+ names = [runs.get(r)["name"] for r in (first, second, third)]
+ assert names[0] == "x" * MAX_NAME_LENGTH
+ assert names[1].endswith(" (2)") and names[2].endswith(" (3)")
+ assert all(len(n) <= MAX_NAME_LENGTH for n in names)
+ assert len(set(names)) == 3
+
+
+def test_size_and_count_limits(tmp_path, monkeypatch):
+ runs = RunManager(tmp_path / "wf")
+ monkeypatch.setattr(run_manager_module, "MAX_IMPORT_MEMBERS", 3)
+ with pytest.raises(RunImportError, match="files, more than"):
+ runs.import_zip(_zip({f"results/{i}": "" for i in range(4)}))
+ monkeypatch.setattr(run_manager_module, "MAX_RUN_FILE_BYTES", 100)
+ with pytest.raises(RunImportError, match="run.json is larger"):
+ runs.import_zip(_zip({"run.json": json.dumps({"name": "y" * 200}), "results/a": "1"}))
+ monkeypatch.setattr(run_manager_module, "MAX_PARAMS_FILE_BYTES", 100)
+ with pytest.raises(RunImportError, match="params.json is larger"):
+ runs.import_zip(_zip({"params.json": json.dumps({"k": "z" * 200}), "results/a": "1"}))
+ assert runs.list_runs() == []
+
+
+def test_damaged_member_data_is_refused(tmp_path):
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("results/a.tsv", "".join(f"{i}\t{i * i}\n" for i in range(5000)))
+ data = bytearray(buf.getvalue())
+ # Corrupt the compressed data of the one member, between its local
+ # header and the central directory at the end
+ for i in range(200, 240):
+ data[i] ^= 0xFF
+ runs = RunManager(tmp_path / "wf")
+ with pytest.raises(RunImportError, match="damaged"):
+ runs.import_zip(io.BytesIO(bytes(data)))
+ assert runs.list_runs() == []
+
+
+def test_unreadable_members_are_refused(tmp_path, monkeypatch):
+ runs = RunManager(tmp_path / "wf")
+ # Over-nested JSON in run.json: json.loads raises RecursionError
+ nested = "[" * 100_000 + "]" * 100_000
+ with pytest.raises(RunImportError, match="can not be read"):
+ runs.import_zip(_zip({"run.json": nested, "results/a": "1"}))
+
+ # Encrypted members: ZipFile.open raises RuntimeError
+ def encrypted(*args, **kwargs):
+ raise RuntimeError("File 'results/a' is encrypted, password required")
+
+ archive = _zip({"results/a": "1"})
+ monkeypatch.setattr(zipfile.ZipFile, "open", encrypted)
+ with pytest.raises(RunImportError, match="can not be read"):
+ runs.import_zip(archive)
+ assert runs.list_runs() == []
diff --git a/tests/test_run_import_widgets.py b/tests/test_run_import_widgets.py
new file mode 100644
index 00000000..80d9c6ac
--- /dev/null
+++ b/tests/test_run_import_widgets.py
@@ -0,0 +1,55 @@
+"""
+The message after a run import (run_widgets.show_import_notice) and how the
+"All parameters" dialog reads list values (StreamlitUI._as_str_list).
+"""
+import os
+import sys
+
+PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.append(PROJECT_ROOT)
+
+from streamlit.testing.v1 import AppTest # noqa: E402
+
+
+def _notice_app(workflow_dir, notice):
+ def app(workflow_dir, notice):
+ import streamlit as st
+
+ from src.workflow.RunManager import RunManager
+ from src.workflow.run_widgets import show_import_notice
+
+ runs = RunManager(workflow_dir)
+ if "seeded" not in st.session_state:
+ st.session_state.seeded = True
+ st.session_state[f"_runs-{runs.workflow_dir.name}-imported"] = notice
+ show_import_notice(runs)
+ show_import_notice(runs)
+
+ return AppTest.from_function(app, args=(str(workflow_dir), notice))
+
+
+def test_notice_depends_on_whether_the_run_is_shown(tmp_path):
+ at = _notice_app(tmp_path / "my-workflow", {"name": "Run 1", "shown": True}).run()
+ assert [s.value for s in at.success] == [
+ "Imported **Run 1**. It is shown on the results pages."
+ ]
+ assert not at.warning
+
+ at = _notice_app(tmp_path / "my-workflow", {"name": "Run 2", "shown": False}).run()
+ assert not at.success
+ assert len(at.warning) == 1 and "did not finish" in at.warning[0].value
+
+ # Shown once, and gone on the next rerun
+ at.run()
+ assert not at.success and not at.warning
+
+
+def test_list_values_as_str_lists():
+ from src.workflow.StreamlitUI import _as_str_list
+
+ assert _as_str_list([b"a", "b", 3]) == ["a", "b", "3"]
+ # Empty entries stay: run_topp() passes them to the tool
+ assert _as_str_list("a\nb\n") == ["a", "b", ""]
+ assert _as_str_list(0) == ["0"]
+ assert _as_str_list(b"a\nb") == ["a", "b"]
+ assert _as_str_list("") == [] and _as_str_list(None) == []