From 7a4471e1aeb96a74fecea7957b9c13fe25f969f5 Mon Sep 17 00:00:00 2001 From: PerinMu <143702874+PerinMu@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:25:22 +0800 Subject: [PATCH] feat: add graphical modification builder --- CHANGELOG.md | 2 + README.md | 24 +++ README.zh-CN.md | 22 +++ docs/modifications.md | 24 +++ pyproject.toml | 4 + src/moltranscode/__init__.py | 2 + src/moltranscode/cli.py | 20 ++ src/moltranscode/gui.py | 138 +++++++++++++ src/moltranscode/gui_app.py | 315 ++++++++++++++++++++++++++++++ src/moltranscode/modifications.py | 158 ++++++++------- tests/test_gui.py | 87 +++++++++ tests/test_modifications.py | 13 ++ 12 files changed, 742 insertions(+), 67 deletions(-) create mode 100644 src/moltranscode/gui.py create mode 100644 src/moltranscode/gui_app.py create mode 100644 tests/test_gui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c986c7e..7ea5adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ versioning after the initial alpha releases. - Site-aware aliases for selected Unimod accessions. - Layerable JSON custom-modification registries, a JSON Schema, Python registration APIs, and explicit override protection. +- Optional bilingual local GUI for creating, validating, previewing, importing, and downloading + custom modification registries. - English project homepage with linked Simplified Chinese documentation. - Structural formula tests, registry validation tests, custom CLI examples, and modification-system documentation. diff --git a/README.md b/README.md index 0224d33..d819bf9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ command-line interface. - Linear L/D peptide construction with explicit termini, backbone N-methylation, curated PTMs, and site-aware Unimod aliases - Layerable JSON registries and a Python API for custom side-chain and terminal structures +- Optional bilingual browser GUI for building, validating, previewing, importing, and exporting + custom modification registries - Morgan, RDKit path, MACCS, Atom Pair, Topological Torsion, and sequence k-mer fingerprints - Formula, exact mass, MolLogP, TPSA, HBD/HBA, rotatable-bond, heavy-atom, and charge descriptors - Optional PubChem PUG-REST name resolution, kept separate from deterministic local conversion @@ -51,6 +53,16 @@ pytest ruff check . ``` +Install the optional graphical modification builder: + +```bash +python -m pip install -e '.[gui]' +moltranscode gui +``` + +The interface opens locally at `http://localhost:8501`. It does not upload structures to a remote +service. Use `--no-browser`, `--host`, or `--port` when a different launch configuration is needed. + ## Quick start ```bash @@ -171,6 +183,18 @@ RDKit; registry provenance should be archived with generated data. See [Modification system](docs/modifications.md) for the schema, attachment semantics, safety rules, and Python registration API. +### Graphical modification builder + +Run `moltranscode gui` to open the optional English/Chinese interface. It provides: + +- forms for side-chain, N-terminal, and C-terminal definitions; +- immediate fragment, collision, and schema validation; +- an interactive peptide test with formula, exact mass, charge, canonical SMILES, and a 2D drawing; +- JSON import, removal of custom entries, explicit override control, and registry download. + +The interface exports the same schema accepted by `--mod-config`; no separate GUI-only project +format is introduced. + ## Batch conversion ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 014d4f4..a393116 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,6 +20,7 @@ 以及依赖单体元数据的 FASTA/sequence/HELM - 肽结构:混合 L/D 氨基酸、端基、骨架 N-甲基化、常见 PTM、按残基位点解析的 Unimod 别名 - 自定义修饰:可叠加的 JSON 配置和 Python 注册 API,无需修改源代码 +- 可选中英文浏览器图形界面,用于创建、验证、预览、导入和导出自定义修饰 - 指纹:Morgan、RDKit path、MACCS、Atom Pair、Topological Torsion、序列 k-mer - 描述符:分子式、精确质量、MolLogP、TPSA、HBD/HBA、可旋转键、重原子数、电荷 - 可选 PubChem PUG-REST 名称解析;批处理中逐条保留 `status` 和 `error` @@ -45,6 +46,16 @@ pytest ruff check . ``` +安装可选图形界面: + +```bash +python -m pip install -e '.[gui]' +moltranscode gui +``` + +界面默认在本机 `http://localhost:8501` 打开,不会把结构上传到远程服务。也可使用 +`--no-browser`、`--host` 和 `--port` 调整启动方式。 + ## 快速开始 ```bash @@ -153,6 +164,17 @@ sanitization,但用户仍需对自定义片段的化学含义负责,并随 完整字段、连接规则和 Python API 见[修饰系统说明](docs/modifications.md)。 +### 图形化修饰构建器 + +运行 `moltranscode gui` 可以打开中英文界面,支持: + +- 手动填写侧链、N 端和 C 端修饰; +- 即时检查 SMILES 片段、名称冲突和 JSON 字段; +- 输入测试肽并查看分子式、精确质量、电荷、canonical SMILES 与二维结构图; +- 导入现有 JSON、删除自定义条目、控制显式覆盖并下载最终修饰库。 + +图形界面导出的文件与 `--mod-config` 使用相同格式,不会产生只能由 GUI 读取的项目文件。 + ## 批处理与 Python API ```bash diff --git a/docs/modifications.md b/docs/modifications.md index caca04e..61066f8 100644 --- a/docs/modifications.md +++ b/docs/modifications.md @@ -144,6 +144,30 @@ result = convert("AK[AzK]", "peptide", "smiles", modification_registry=registry) Registry instances are mutable by design. Create one during application initialization and treat it as read-only while conversions run; call `clone()` before making request-specific changes. +## Graphical registry builder + +Install and launch the optional local interface: + +```bash +python -m pip install -e '.[gui]' +moltranscode gui +``` + +The Streamlit application supports English and Simplified Chinese. It validates each definition +against the same runtime loader used by the CLI, previews a test peptide as descriptors and a 2D +structure, imports existing registries, and downloads schema-versioned JSON. All processing occurs +in the local Python process; the application does not send molecular structures to MolTranscode or +another hosted service. + +For a headless or remote development environment: + +```bash +moltranscode gui --no-browser --host 127.0.0.1 --port 8765 +``` + +Binding to a non-loopback interface may expose the application to other machines. Add appropriate +network access controls before doing so, especially when working with confidential structures. + ## Provenance and contribution rules Archive custom JSON files alongside generated outputs and record the MolTranscode and RDKit diff --git a/pyproject.toml b/pyproject.toml index 80b9f89..4e23949 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,9 +33,13 @@ dev = [ "pytest-cov>=5.0", "ruff>=0.5", ] +gui = [ + "streamlit>=1.60", +] [project.scripts] moltranscode = "moltranscode.cli:main" +moltranscode-gui = "moltranscode.gui:main" [project.urls] Homepage = "https://github.com/PerinMu/MolTranscode" diff --git a/src/moltranscode/__init__.py b/src/moltranscode/__init__.py index 367d596..d09f788 100644 --- a/src/moltranscode/__init__.py +++ b/src/moltranscode/__init__.py @@ -12,6 +12,7 @@ ModificationRegistry, default_modification_registry, load_modification_registry, + modification_registry_from_dict, ) from .peptide import parse_peptide, peptide_to_smiles @@ -27,6 +28,7 @@ "describe", "fingerprint", "load_modification_registry", + "modification_registry_from_dict", "parse_peptide", "peptide_to_smiles", ] diff --git a/src/moltranscode/cli.py b/src/moltranscode/cli.py index 2f92f10..5411e25 100644 --- a/src/moltranscode/cli.py +++ b/src/moltranscode/cli.py @@ -101,6 +101,16 @@ def _base_parser() -> argparse.ArgumentParser: formats_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") _add_modification_config_argument(formats_parser) formats_parser.set_defaults(handler=_handle_formats) + + gui_parser = subparsers.add_parser( + "gui", help="launch the optional graphical modification builder" + ) + gui_parser.add_argument("--host", default="localhost", help="interface to bind (localhost)") + gui_parser.add_argument("--port", type=int, default=8501, help="local web interface port") + gui_parser.add_argument( + "--no-browser", action="store_true", help="do not open a browser automatically" + ) + gui_parser.set_defaults(handler=_handle_gui) return parser @@ -251,6 +261,16 @@ def _handle_formats(args: argparse.Namespace) -> int: return 0 +def _handle_gui(args: argparse.Namespace) -> int: + from .gui import launch_gui + + return launch_gui( + host=args.host, + port=args.port, + open_browser=not args.no_browser, + ) + + def main(argv: list[str] | None = None) -> int: parser = _base_parser() args = parser.parse_args(argv) diff --git a/src/moltranscode/gui.py b/src/moltranscode/gui.py new file mode 100644 index 0000000..ee063b9 --- /dev/null +++ b/src/moltranscode/gui.py @@ -0,0 +1,138 @@ +"""Launcher and testable helpers for the optional Streamlit interface.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +from .exceptions import InputFormatError +from .modifications import modification_registry_from_dict + +REGISTRY_SECTIONS = ( + "side_chain_modifications", + "n_terminal_modifications", + "c_terminal_modifications", +) + + +def new_registry_payload() -> dict[str, Any]: + """Return an empty schema-versioned custom modification registry.""" + + return { + "schema_version": 1, + "override": False, + "side_chain_modifications": [], + "n_terminal_modifications": [], + "c_terminal_modifications": [], + } + + +def normalize_registry_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Fill optional sections and validate a GUI/import payload.""" + + normalized = new_registry_payload() + normalized.update(copy.deepcopy(payload)) + for section in REGISTRY_SECTIONS: + normalized.setdefault(section, []) + modification_registry_from_dict(normalized, source="GUI registry") + return normalized + + +def append_registry_entry( + payload: dict[str, Any], + section: str, + entry: dict[str, Any], +) -> dict[str, Any]: + """Return a validated payload containing one additional definition.""" + + if section not in REGISTRY_SECTIONS: + raise InputFormatError(f"Unknown modification section: {section!r}.") + updated = normalize_registry_payload(payload) + updated[section].append(copy.deepcopy(entry)) + modification_registry_from_dict(updated, source="GUI registry") + return updated + + +def remove_registry_entry( + payload: dict[str, Any], + section: str, + index: int, +) -> dict[str, Any]: + """Return a validated payload with one custom definition removed.""" + + if section not in REGISTRY_SECTIONS: + raise InputFormatError(f"Unknown modification section: {section!r}.") + updated = normalize_registry_payload(payload) + try: + del updated[section][index] + except IndexError as exc: + raise InputFormatError(f"No modification at index {index} in {section}.") from exc + modification_registry_from_dict(updated, source="GUI registry") + return updated + + +def aliases_from_text(value: str) -> list[str]: + """Parse comma-separated aliases while preserving order.""" + + aliases: list[str] = [] + for item in value.split(","): + alias = item.strip() + if alias and alias not in aliases: + aliases.append(alias) + return aliases + + +def registry_payload_json(payload: dict[str, Any]) -> str: + """Serialize a validated registry for download.""" + + normalized = normalize_registry_payload(payload) + return json.dumps(normalized, ensure_ascii=False, indent=2) + "\n" + + +def launch_gui( + *, + host: str = "localhost", + port: int = 8501, + open_browser: bool = True, +) -> int: + """Launch the optional local Streamlit application.""" + + if not 1 <= port <= 65535: + raise InputFormatError("GUI port must be between 1 and 65535.") + if importlib.util.find_spec("streamlit") is None: + raise InputFormatError( + "The GUI dependency is not installed. Run: python -m pip install -e '.[gui]'" + ) + app_path = Path(__file__).with_name("gui_app.py") + command = [ + sys.executable, + "-m", + "streamlit", + "run", + str(app_path), + "--server.address", + host, + "--server.port", + str(port), + "--server.headless", + "false" if open_browser else "true", + "--browser.gatherUsageStats", + "false", + ] + try: + return subprocess.run(command, check=False).returncode + except KeyboardInterrupt: + return 130 + + +def main() -> int: + try: + return launch_gui() + except InputFormatError as exc: + print(f"moltranscode-gui: error: {exc}", file=sys.stderr) + return 2 diff --git a/src/moltranscode/gui_app.py b/src/moltranscode/gui_app.py new file mode 100644 index 0000000..8f15d61 --- /dev/null +++ b/src/moltranscode/gui_app.py @@ -0,0 +1,315 @@ +"""Streamlit application for building and testing modification registries.""" + +from __future__ import annotations + +import json +from typing import Any + +import streamlit as st +from rdkit import Chem +from rdkit.Chem import Draw + +from moltranscode.chemistry import describe +from moltranscode.exceptions import MolTranscodeError +from moltranscode.gui import ( + aliases_from_text, + append_registry_entry, + new_registry_payload, + normalize_registry_payload, + registry_payload_json, + remove_registry_entry, +) +from moltranscode.modifications import ( + STANDARD_AA, + default_modification_registry, + modification_registry_from_dict, +) + +SECTION_LABELS = { + "side_chain_modifications": "Side chain", + "n_terminal_modifications": "N terminus", + "c_terminal_modifications": "C terminus", +} + +TEXT = { + "English": { + "title": "MolTranscode Modification Builder", + "subtitle": "Build, validate, preview, and export attachment-aware peptide modifications.", + "language": "Language", + "import": "Import registry", + "import_button": "Import JSON", + "import_ok": "Registry imported and validated.", + "reset": "Reset custom registry", + "override": "Allow explicit overrides", + "override_help": "Required only when intentionally replacing a built-in name/site pair.", + "builtins": "Browse built-in modifications", + "build_tab": "Build", + "preview_tab": "Test & preview", + "export_tab": "Export", + "type": "Modification type", + "name": "Name", + "residue": "Residue", + "fragment": "Side-chain SMILES", + "d_fragment": "D-residue side-chain SMILES (optional)", + "prefix": "N-terminal prefix SMILES", + "terminus": "C-terminal terminus SMILES", + "aliases": "Aliases (comma separated)", + "description": "Description / provenance note", + "add": "Validate and add", + "added": "Modification added.", + "custom": "Custom definitions", + "empty": "No custom definitions yet.", + "remove": "Remove", + "peptide": "Test peptide notation", + "test": "Build and validate peptide", + "formula": "Formula", + "mass": "Exact mass", + "charge": "Formal charge", + "smiles": "Canonical isomeric SMILES", + "download": "Download JSON registry", + "filename": "Download filename", + "json": "Registry JSON", + "cli": "Use the downloaded file from the command line", + "warning": ( + "A sanitizable graph can still represent the wrong chemistry. Review attachment " + "semantics and archive the JSON with your results." + ), + }, + "简体中文": { + "title": "MolTranscode 修饰构建器", + "subtitle": "手动创建、验证、预览并导出带连接语义的肽修饰。", + "language": "语言", + "import": "导入修饰库", + "import_button": "导入 JSON", + "import_ok": "修饰库已导入并通过验证。", + "reset": "清空自定义修饰", + "override": "允许显式覆盖", + "override_help": "仅在确实需要替换内置名称/位点组合时启用。", + "builtins": "查看内置修饰", + "build_tab": "创建修饰", + "preview_tab": "测试与预览", + "export_tab": "导出", + "type": "修饰类型", + "name": "名称", + "residue": "残基", + "fragment": "侧链 SMILES", + "d_fragment": "D-残基侧链 SMILES(可选)", + "prefix": "N 端前缀 SMILES", + "terminus": "C 端末端 SMILES", + "aliases": "别名(逗号分隔)", + "description": "说明或来源", + "add": "验证并添加", + "added": "修饰已添加。", + "custom": "自定义定义", + "empty": "还没有自定义修饰。", + "remove": "删除", + "peptide": "测试肽表示", + "test": "构建并验证肽", + "formula": "分子式", + "mass": "精确质量", + "charge": "形式电荷", + "smiles": "Canonical isomeric SMILES", + "download": "下载 JSON 修饰库", + "filename": "下载文件名", + "json": "修饰库 JSON", + "cli": "在命令行中使用下载的文件", + "warning": ( + "能够通过结构检查不等于化学含义一定正确。请核对连接规则,并将 JSON 与结果一起保存。" + ), + }, +} + + +def _payload() -> dict[str, Any]: + if "registry_payload" not in st.session_state: + st.session_state.registry_payload = new_registry_payload() + return st.session_state.registry_payload + + +def _example_for_entry(section: str, entry: dict[str, Any]) -> str: + name = entry["name"] + if section == "side_chain_modifications": + return f"A{entry['residue']}[{name}]A" + if section == "n_terminal_modifications": + return f"[{name}]-AKA" + return f"AKA-[{name}]" + + +def _entry_summary(section: str, entry: dict[str, Any]) -> str: + if section == "side_chain_modifications": + return f"{entry['residue']}[{entry['name']}] · {entry['side_chain_smiles']}" + field = "prefix_smiles" if section == "n_terminal_modifications" else "terminus_smiles" + return f"{entry['name']} · {entry[field]}" + + +def _render_sidebar(t: dict[str, str]) -> None: + uploaded = st.sidebar.file_uploader(t["import"], type=["json"]) + if st.sidebar.button(t["import_button"], disabled=uploaded is None): + try: + imported = json.loads(uploaded.getvalue().decode("utf-8")) + st.session_state.registry_payload = normalize_registry_payload(imported) + except (UnicodeDecodeError, json.JSONDecodeError, MolTranscodeError) as exc: + st.sidebar.error(str(exc)) + else: + st.sidebar.success(t["import_ok"]) + st.rerun() + + payload = _payload() + allow_override = st.sidebar.checkbox( + t["override"], + value=bool(payload.get("override", False)), + help=t["override_help"], + ) + if allow_override != payload.get("override", False): + payload["override"] = allow_override + + if st.sidebar.button(t["reset"]): + st.session_state.registry_payload = new_registry_payload() + st.session_state.test_peptide = "AKA" + st.rerun() + + with st.sidebar.expander(t["builtins"]): + summary = default_modification_registry().summary() + st.write("**N:** " + ", ".join(summary["n_terminal_modifications"])) + st.write("**C:** " + ", ".join(summary["c_terminal_modifications"])) + for residue, names in summary["side_chain_modifications"].items(): + st.write(f"**{residue}:** {', '.join(names)}") + + +def _render_builder(t: dict[str, str]) -> None: + section = st.radio( + t["type"], + list(SECTION_LABELS), + format_func=lambda value: SECTION_LABELS[value], + horizontal=True, + ) + with st.form("add_modification", clear_on_submit=False): + name = st.text_input(t["name"], placeholder="AzidoLys") + residue = "K" + d_fragment = "" + if section == "side_chain_modifications": + residue = st.selectbox(t["residue"], sorted(STANDARD_AA), index=8) + fragment = st.text_input(t["fragment"], placeholder="CCCCN=[N+]=[N-]") + d_fragment = st.text_input(t["d_fragment"]) + elif section == "n_terminal_modifications": + fragment = st.text_input(t["prefix"], placeholder="CCC(=O)") + else: + fragment = st.text_input(t["terminus"], placeholder="C(=O)NC") + aliases_text = st.text_input(t["aliases"], placeholder="AzK, UNIMOD:xxxx") + description = st.text_area(t["description"], height=80) + submitted = st.form_submit_button(t["add"], type="primary") + + if submitted: + aliases = aliases_from_text(aliases_text) + entry: dict[str, Any] = {"name": name.strip()} + if aliases: + entry["aliases"] = aliases + if description.strip(): + entry["description"] = description.strip() + if section == "side_chain_modifications": + entry.update({"residue": residue, "side_chain_smiles": fragment.strip()}) + if d_fragment.strip(): + entry["d_side_chain_smiles"] = d_fragment.strip() + elif section == "n_terminal_modifications": + entry["prefix_smiles"] = fragment.strip() + else: + entry["terminus_smiles"] = fragment.strip() + try: + updated = append_registry_entry(_payload(), section, entry) + except MolTranscodeError as exc: + st.error(str(exc)) + else: + st.session_state.registry_payload = updated + st.session_state.test_peptide = _example_for_entry(section, entry) + st.success(t["added"]) + st.rerun() + + st.subheader(t["custom"]) + total = sum(len(_payload()[key]) for key in SECTION_LABELS) + if not total: + st.info(t["empty"]) + return + for section_key, label in SECTION_LABELS.items(): + entries = _payload()[section_key] + if not entries: + continue + st.markdown(f"**{label}**") + for index, entry in enumerate(entries): + text_col, remove_col = st.columns([5, 1]) + text_col.code(_entry_summary(section_key, entry), language=None) + if remove_col.button(t["remove"], key=f"remove-{section_key}-{index}"): + st.session_state.registry_payload = remove_registry_entry( + _payload(), section_key, index + ) + st.rerun() + + +def _render_preview(t: dict[str, str]) -> None: + if "test_peptide" not in st.session_state: + st.session_state.test_peptide = "AKA" + peptide = st.text_input(t["peptide"], key="test_peptide") + if st.button(t["test"], type="primary"): + try: + registry = modification_registry_from_dict(_payload(), source="GUI registry") + properties = describe(peptide, "peptide", modification_registry=registry) + molecule = Chem.MolFromSmiles(properties["canonical_smiles"]) + except MolTranscodeError as exc: + st.error(str(exc)) + return + metric_columns = st.columns(3) + metric_columns[0].metric(t["formula"], properties["formula"]) + metric_columns[1].metric(t["mass"], f"{properties['exact_mw']:.6f}") + metric_columns[2].metric(t["charge"], properties["formal_charge"]) + st.text_area(t["smiles"], properties["canonical_smiles"], height=120) + if molecule is not None: + st.image(Draw.MolToImage(molecule, size=(900, 420)), width="stretch") + + +def _render_export(t: dict[str, str]) -> None: + try: + content = registry_payload_json(_payload()) + except MolTranscodeError as exc: + st.error(str(exc)) + return + filename = st.text_input(t["filename"], value="moltranscode-modifications.json") + st.download_button( + t["download"], + data=content, + file_name=filename or "moltranscode-modifications.json", + mime="application/json", + type="primary", + ) + st.caption(t["cli"]) + st.code( + f"moltranscode convert 'AK[YourModification]' --from peptide --to smiles " + f"--mod-config {filename or 'moltranscode-modifications.json'}", + language="bash", + ) + with st.expander(t["json"]): + st.code(content, language="json") + + +def run_app() -> None: + st.set_page_config( + page_title="MolTranscode Modification Builder", + page_icon="🧬", + layout="wide", + ) + language = st.sidebar.selectbox(TEXT["English"]["language"], list(TEXT)) + t = TEXT[language] + st.title(t["title"]) + st.caption(t["subtitle"]) + _render_sidebar(t) + build_tab, preview_tab, export_tab = st.tabs( + [t["build_tab"], t["preview_tab"], t["export_tab"]] + ) + with build_tab: + _render_builder(t) + with preview_tab: + _render_preview(t) + with export_tab: + _render_export(t) + st.warning(t["warning"], icon="⚠️") + + +run_app() diff --git a/src/moltranscode/modifications.py b/src/moltranscode/modifications.py index d9604aa..fb1c701 100644 --- a/src/moltranscode/modifications.py +++ b/src/moltranscode/modifications.py @@ -389,6 +389,96 @@ def _check_entry_keys( ) +def _merge_modification_payload( + registry: ModificationRegistry, + payload: dict[str, Any], + *, + source: str, +) -> None: + unknown = set(payload) - { + "schema_version", + "override", + "side_chain_modifications", + "n_terminal_modifications", + "c_terminal_modifications", + } + if unknown: + raise ModificationError( + f"Unknown registry keys in {source}: {', '.join(sorted(unknown))}." + ) + if payload.get("schema_version", 1) != 1: + raise ModificationError(f"Unsupported schema_version in {source}; expected 1.") + override = payload.get("override", False) + if not isinstance(override, bool): + raise ModificationError(f"override in {source} must be true or false.") + + for index, entry in enumerate(_entry_list(payload, "side_chain_modifications"), start=1): + context = f"{source}: side_chain_modifications[{index}]" + _check_entry_keys( + entry, + { + "name", + "residue", + "side_chain_smiles", + "d_side_chain_smiles", + "aliases", + "description", + }, + context=context, + ) + registry.register_side_chain( + name=_required_string(entry, "name", context=context), + residue=_required_string(entry, "residue", context=context), + side_chain_smiles=_required_string(entry, "side_chain_smiles", context=context), + d_side_chain_smiles=_optional_string( + entry, "d_side_chain_smiles", context=context + ), + aliases=_as_aliases(entry.get("aliases"), context=context), + override=override, + ) + for index, entry in enumerate(_entry_list(payload, "n_terminal_modifications"), start=1): + context = f"{source}: n_terminal_modifications[{index}]" + _check_entry_keys( + entry, + {"name", "prefix_smiles", "aliases", "description"}, + context=context, + ) + registry.register_n_terminus( + name=_required_string(entry, "name", context=context), + prefix_smiles=_required_string(entry, "prefix_smiles", context=context), + aliases=_as_aliases(entry.get("aliases"), context=context), + override=override, + ) + for index, entry in enumerate(_entry_list(payload, "c_terminal_modifications"), start=1): + context = f"{source}: c_terminal_modifications[{index}]" + _check_entry_keys( + entry, + {"name", "terminus_smiles", "aliases", "description"}, + context=context, + ) + registry.register_c_terminus( + name=_required_string(entry, "name", context=context), + terminus_smiles=_required_string(entry, "terminus_smiles", context=context), + aliases=_as_aliases(entry.get("aliases"), context=context), + override=override, + ) + + +def modification_registry_from_dict( + payload: dict[str, Any], + *, + base: ModificationRegistry | None = None, + source: str = "in-memory registry", +) -> ModificationRegistry: + """Validate and merge an in-memory registry payload into a fresh registry.""" + + if not isinstance(payload, dict): + raise ModificationError(f"Modification registry {source} must be a JSON object.") + registry = base.clone() if base else default_modification_registry() + _merge_modification_payload(registry, payload, source=source) + return registry + + def load_modification_registry( paths: str | Path | list[str | Path] | tuple[str | Path, ...], *, @@ -408,71 +498,5 @@ def load_modification_registry( raise ModificationError(f"Invalid JSON in modification registry {path}: {exc}") from exc if not isinstance(payload, dict): raise ModificationError(f"Modification registry {path} must contain a JSON object.") - unknown = set(payload) - { - "schema_version", - "override", - "side_chain_modifications", - "n_terminal_modifications", - "c_terminal_modifications", - } - if unknown: - raise ModificationError( - f"Unknown registry keys in {path}: {', '.join(sorted(unknown))}." - ) - if payload.get("schema_version", 1) != 1: - raise ModificationError(f"Unsupported schema_version in {path}; expected 1.") - override = payload.get("override", False) - if not isinstance(override, bool): - raise ModificationError(f"override in {path} must be true or false.") - - for index, entry in enumerate(_entry_list(payload, "side_chain_modifications"), start=1): - context = f"side_chain_modifications[{index}]" - _check_entry_keys( - entry, - { - "name", - "residue", - "side_chain_smiles", - "d_side_chain_smiles", - "aliases", - "description", - }, - context=context, - ) - registry.register_side_chain( - name=_required_string(entry, "name", context=context), - residue=_required_string(entry, "residue", context=context), - side_chain_smiles=_required_string(entry, "side_chain_smiles", context=context), - d_side_chain_smiles=_optional_string( - entry, "d_side_chain_smiles", context=context - ), - aliases=_as_aliases(entry.get("aliases"), context=context), - override=override, - ) - for index, entry in enumerate(_entry_list(payload, "n_terminal_modifications"), start=1): - context = f"n_terminal_modifications[{index}]" - _check_entry_keys( - entry, - {"name", "prefix_smiles", "aliases", "description"}, - context=context, - ) - registry.register_n_terminus( - name=_required_string(entry, "name", context=context), - prefix_smiles=_required_string(entry, "prefix_smiles", context=context), - aliases=_as_aliases(entry.get("aliases"), context=context), - override=override, - ) - for index, entry in enumerate(_entry_list(payload, "c_terminal_modifications"), start=1): - context = f"c_terminal_modifications[{index}]" - _check_entry_keys( - entry, - {"name", "terminus_smiles", "aliases", "description"}, - context=context, - ) - registry.register_c_terminus( - name=_required_string(entry, "name", context=context), - terminus_smiles=_required_string(entry, "terminus_smiles", context=context), - aliases=_as_aliases(entry.get("aliases"), context=context), - override=override, - ) + _merge_modification_payload(registry, payload, source=str(path)) return registry diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..c610639 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,87 @@ +import json +from types import SimpleNamespace + +import pytest +from rdkit import Chem + +from moltranscode.exceptions import InputFormatError, ModificationError +from moltranscode.gui import ( + aliases_from_text, + append_registry_entry, + launch_gui, + main, + new_registry_payload, + registry_payload_json, + remove_registry_entry, +) +from moltranscode.modifications import modification_registry_from_dict +from moltranscode.peptide import peptide_to_smiles + + +def test_gui_payload_add_preview_export_and_remove(): + payload = append_registry_entry( + new_registry_payload(), + "side_chain_modifications", + { + "name": "AzidoLys", + "residue": "K", + "side_chain_smiles": "CCCCN=[N+]=[N-]", + "aliases": ["AzK"], + }, + ) + registry = modification_registry_from_dict(payload) + assert Chem.MolFromSmiles(peptide_to_smiles("AK[AzK]", registry)) is not None + + exported = json.loads(registry_payload_json(payload)) + assert exported["side_chain_modifications"][0]["name"] == "AzidoLys" + + emptied = remove_registry_entry(payload, "side_chain_modifications", 0) + assert emptied["side_chain_modifications"] == [] + + +def test_gui_payload_rejects_builtin_collision(): + with pytest.raises(ModificationError, match="override=true"): + append_registry_entry( + new_registry_payload(), + "side_chain_modifications", + {"name": "Acetyl", "residue": "K", "side_chain_smiles": "CCCCN"}, + ) + + +def test_alias_text_is_trimmed_and_deduplicated(): + assert aliases_from_text(" AzK, azido lysine, AzK, ") == ["AzK", "azido lysine"] + + +def test_gui_launcher_reports_missing_optional_dependency(monkeypatch): + monkeypatch.setattr("moltranscode.gui.importlib.util.find_spec", lambda name: None) + with pytest.raises(InputFormatError, match=r"\[gui\]"): + launch_gui() + + +def test_gui_console_script_reports_missing_dependency(monkeypatch, capsys): + monkeypatch.setattr("moltranscode.gui.importlib.util.find_spec", lambda name: None) + assert main() == 2 + assert "pip install -e '.[gui]'" in capsys.readouterr().err + + +def test_gui_launcher_uses_streamlit_without_shell(monkeypatch): + captured = {} + + monkeypatch.setattr("moltranscode.gui.importlib.util.find_spec", lambda name: object()) + + def fake_run(command, *, check): + captured["command"] = command + captured["check"] = check + return SimpleNamespace(returncode=0) + + monkeypatch.setattr("moltranscode.gui.subprocess.run", fake_run) + assert launch_gui(host="127.0.0.1", port=8765, open_browser=False) == 0 + assert captured["command"][1:4] == ["-m", "streamlit", "run"] + assert "--server.headless" in captured["command"] + assert "true" in captured["command"] + assert captured["check"] is False + + +def test_gui_launcher_rejects_invalid_port(): + with pytest.raises(InputFormatError, match="between 1 and 65535"): + launch_gui(port=0) diff --git a/tests/test_modifications.py b/tests/test_modifications.py index d115628..f1bb2f9 100644 --- a/tests/test_modifications.py +++ b/tests/test_modifications.py @@ -9,6 +9,7 @@ from moltranscode.modifications import ( default_modification_registry, load_modification_registry, + modification_registry_from_dict, ) from moltranscode.peptide import parse_peptide, peptide_to_smiles @@ -179,3 +180,15 @@ def test_registry_summary_contains_extended_modifications(): "Myristoyl", "Palmitoyl", ] + + +def test_in_memory_registry_payload_uses_the_same_validation(): + registry = modification_registry_from_dict( + { + "side_chain_modifications": [ + {"name": "AzidoLys", "residue": "K", "side_chain_smiles": "CCCCN=[N+]=[N-]"} + ] + }, + source="unit test", + ) + assert registry.resolve_side_chain("K", "AzidoLys")[0] == "AzidoLys"