diff --git a/CHANGELOG.md b/CHANGELOG.md index e7bcfa9..c986c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project intends to use semantic versioning after the initial alpha releases. +## [0.2.0] - 2026-08-04 + +### Added + +- Expanded site-specific peptide PTMs and terminal groups, including acylation, methylation, + oxidation-state, sulfation, ubiquitin-remnant, nitration, ester, and hydrazide representations. +- Site-aware aliases for selected Unimod accessions. +- Layerable JSON custom-modification registries, a JSON Schema, Python registration APIs, and + explicit override protection. +- English project homepage with linked Simplified Chinese documentation. +- Structural formula tests, registry validation tests, custom CLI examples, and modification-system + documentation. + +### Changed + +- Generalized peptide parsing and conversion APIs to accept a modification registry. +- `formats` now reports active modifications and accepts repeatable `--mod-config` files. + ## [0.1.0] - 2026-08-04 ### Added diff --git a/CITATION.cff b/CITATION.cff index 01fcb1b..507a5d5 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -4,7 +4,7 @@ title: "MolTranscode" type: software authors: - family-names: "PerinMu" -version: 0.1.0 +version: 0.2.0 date-released: 2026-08-04 license: MIT repository-code: "https://github.com/PerinMu/MolTranscode" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca0db50..beab7a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,5 +7,17 @@ new modification. 5. Do not map a mass shift or ambiguous name to a structure without an explicit resolution rule. +## Adding modifications + +Use a JSON registry first for laboratory- or project-specific chemistry; see +[`docs/modifications.md`](docs/modifications.md). A modification belongs in the built-in registry +only when it has a stable controlled-vocabulary or primary-literature reference and a broadly useful +structural interpretation. + +Built-in proposals must document residue/terminus specificity, attachment semantics, formal charge, +tautomer and stereochemical choices. Include RDKit sanitization plus formula or exact-mass tests. +Aliases that resemble controlled-vocabulary accessions must be site-aware and traceable to the +corresponding record. Never silently replace an existing definition. + Bug reports should include the exact input, requested formats, full error, Python version, and output of `moltranscode --version`. Never include confidential structures in a public issue. diff --git a/README.md b/README.md index 5577699..0224d33 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,38 @@ # MolTranscode +[简体中文](README.zh-CN.md) + [![CI](https://github.com/PerinMu/MolTranscode/actions/workflows/ci.yml/badge.svg)](https://github.com/PerinMu/MolTranscode/actions/workflows/ci.yml) [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -**MolTranscode** 是一个可审计、可批处理的分子表示转码工具。它把原来的“肽序列手工拼接 -SMILES”脚本整理成 Python 包和命令行程序,面向肽/蛋白序列、小分子结构与机器学习特征。 +**MolTranscode** is an auditable, batch-friendly toolkit for transcoding molecular +representations without hiding chemical ambiguity. It connects peptide and protein sequences, +small-molecule formats, line notations, descriptors, and fingerprints through one Python API and +command-line interface. -> Transcode molecular representations without hiding chemical ambiguity. -> -> 不同表示之间并非都能无损互转。SMILES 描述的是分子图,不保存 -> FASTA 注释、蛋白高级结构或实验条件;任意 SMILES 也无法可靠还原成唯一的肽序列。 +> Molecular representations are not universally lossless. SMILES stores a molecular graph, not a +> FASTA header, residue numbering, conformational ensemble, pH, or experimental context. An +> arbitrary SMILES string therefore cannot be claimed to have one uniquely recoverable peptide +> sequence. -## 功能 +## Highlights -- 输入:肽/蛋白序列、FASTA、HELM(RDKit 支持的肽子集)、SMILES、InChI、SELFIES、 - MOL、SDF、CSV、TSV、TXT、JSONL -- 输出:canonical/isomeric SMILES、InChI、InChIKey、SELFIES、MOL、SDF、PDB、 - FASTA/序列(仅保留单体信息的来源) -- 肽修饰:N 端乙酰化、C 端酰胺化、残基骨架 N-甲基化,以及常见的磷酸化、氧化、 - Lys 甲基化/二甲基化/三甲基化、Lys 乙酰化、Cys 甲基化和 carbamidomethyl -- 指纹:Morgan/ECFP-like、RDKit path、MACCS、Atom Pair、Topological Torsion,另有蛋白 - 序列 k-mer 哈希指纹 -- 描述符:分子式、精确质量、MolLogP、TPSA、HBD/HBA、可旋转键、重原子数、电荷 -- 可选在线名称解析:通过 PubChem PUG-REST 将常用名/CAS 等解析为结构 -- 批处理输出逐条记录 `status` 与 `error`,不会因单条坏数据丢失整批结果 +- Inputs: peptide/protein sequences, FASTA, peptide HELM, SMILES, InChI, SELFIES, MOL, SDF, PDB, + CSV, TSV, TXT, and JSONL +- Outputs: canonical/isomeric SMILES, InChI, InChIKey, SELFIES, MOL, SDF, PDB, and metadata-assisted + FASTA/sequence/HELM +- 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 +- 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 +- Per-record `status` and `error` fields for resilient batch processing -## 安装 +## Installation -推荐使用 Python 3.10–3.13 的独立环境: +MolTranscode supports Python 3.10 and newer. ```bash git clone https://github.com/PerinMu/MolTranscode.git @@ -39,7 +43,7 @@ python -m pip install -U pip python -m pip install -e . ``` -开发环境: +For development: ```bash python -m pip install -e '.[dev]' @@ -47,87 +51,191 @@ pytest ruff check . ``` -## 快速开始 +## Quick start ```bash -# 标准肽序列;小写字母表示对应 D-氨基酸 +# Standard and modified peptides; lowercase residues are D amino acids moltranscode convert ACDY --from peptide --to smiles -moltranscode convert Ac-AK[Dimethyl]S[Phospho]M[Oxidation]-NH2 --from peptide --to smiles +moltranscode convert 'Ac-AK[Dimethyl]S[Phospho]M[Oxidation]-NH2' \ + --from peptide --to smiles + +# Site-aware controlled-vocabulary aliases +moltranscode convert 'AS[UNIMOD:21]M[UNIMOD:35]K[UNIMOD:121]' \ + --from peptide --to inchikey -# 小分子格式转换 +# Small-molecule representations moltranscode convert 'CC(=O)Oc1ccccc1C(=O)O' --from smiles --to inchi moltranscode convert 'InChI=1S/CH4/h1H4' --from inchi --to canonical-smiles moltranscode convert '[C][C][O]' --from selfies --to smiles -# 指纹与描述符 +# Fingerprints and descriptors moltranscode fingerprint 'CCO' --from smiles --kind morgan --format hex moltranscode fingerprint MKWVTFISLL --from sequence --kind kmer --bits 512 --k 3 -moltranscode describe 'CCO' --from smiles +moltranscode describe 'K[Succinyl]' --from peptide -# FASTA/CSV/SDF 批处理 -moltranscode convert proteins.fasta --file --from fasta --to smiles --output out.csv -moltranscode convert compounds.csv --file --column structure --from smiles --to inchikey --output out.csv -moltranscode convert compounds.sdf --file --from sdf --to canonical-smiles --output out.jsonl +# Inspect the active format and modification registry +moltranscode formats +moltranscode formats --json +``` -# 在线名称解析(需要网络;结果来自数据库,不是名称语法的本地推导) -moltranscode resolve-name aspirin --to smiles +Automatic input detection is deliberately conservative. For example, `CC` can mean ethane SMILES +or a two-cysteine peptide, so MolTranscode asks for an explicit `--from` value. + +## Peptide notation and built-in modifications + +MolTranscode accepts a documented, structure-resolving **ProForma-inspired subset**. It supports +controlled-vocabulary names and selected accessions, but does not claim complete ProForma 2.0 +compliance. + +### Termini and backbone + +| Notation | Structure represented | +|---|---| +| `[Acetyl]-PEPTIDE` or `Ac-PEPTIDE` | N-terminal acetylation | +| `[Formyl]-PEPTIDE` | N-terminal formylation | +| `[Myristoyl]-PEPTIDE` | N-terminal myristoylation | +| `[Palmitoyl]-PEPTIDE` | N-terminal palmitoylation | +| `PEPTIDE-[Amide]` or `PEPTIDE-NH2` | C-terminal amidation | +| `PEPTIDE-[MethylEster]` | C-terminal methyl ester | +| `PEPTIDE-[EthylEster]` | C-terminal ethyl ester | +| `PEPTIDE-[Hydrazide]` | C-terminal hydrazide | +| `A[NMe]` | Backbone N-methylation; unavailable on Pro | + +### Side chains + +| Sites | Built-in modification names | +|---|---| +| S, T, Y | `Phospho`, `Sulfo`, `Acetyl` | +| S, T | `Methyl` | +| M | `Oxidation`, `Dioxidation` | +| C | `Oxidation`, `SulfinicAcid`, `SulfonicAcid`, `Persulfide`, `Methyl`, `Carbamidomethyl` | +| N, Q | `Deamidated` | +| R | `Citrullination` | +| K | `Methyl`, `Dimethyl`, `Trimethyl`, `Acetyl`, `Succinyl`, `Malonyl`, `Crotonyl`, `GlyGly`, `Carbamyl` | +| Y | `Nitro` | + +Selected site-aware aliases include `UNIMOD:1`, `UNIMOD:4`, `UNIMOD:7`, `UNIMOD:21`, +`UNIMOD:34`, `UNIMOD:35`, `UNIMOD:36`, `UNIMOD:37`, `UNIMOD:40`, `UNIMOD:64`, +`UNIMOD:121`, `UNIMOD:747`, and `UNIMOD:1363`. The same accession can resolve differently by +site: `R[UNIMOD:7]` is represented as citrulline, while `N[UNIMOD:7]` and `Q[UNIMOD:7]` are +represented as deamidated residues. + +## Custom modification channel + +Custom registries extend the structure library without editing MolTranscode source code. They are +JSON files whose values are **attachment-aware SMILES fragments**, not mass deltas. + +```json +{ + "schema_version": 1, + "override": false, + "side_chain_modifications": [ + { + "name": "AzidoLys", + "residue": "K", + "side_chain_smiles": "CCCCN=[N+]=[N-]", + "aliases": ["AzK"] + } + ], + "n_terminal_modifications": [ + { + "name": "Propionyl", + "prefix_smiles": "CCC(=O)", + "aliases": ["Prop"] + } + ], + "c_terminal_modifications": [ + { + "name": "MethylAmide", + "terminus_smiles": "C(=O)NC", + "aliases": ["NHMe"] + } + ] +} +``` -moltranscode formats +Use one registry, or layer several in command-line order: + +```bash +moltranscode convert 'Prop-AK[AzK]-NHMe' --from peptide --to smiles \ + --mod-config examples/custom_modifications.json + +moltranscode formats --json --mod-config lab-mods.json --mod-config project-mods.json ``` -`--from auto` 仅在无歧义时猜测。像 `CC` 既可能是乙烷 SMILES,也可能是两个半胱氨酸, -工具会要求显式指定 `--from smiles` 或 `--from peptide`。 +Built-in definitions cannot be replaced silently. A registry must set `"override": true` to replace +an existing site/name pair. Modified D-isoleucine or D-threonine definitions must provide +`d_side_chain_smiles` so the second stereocenter is explicit. Every final molecule is sanitized by +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. -本项目接受一个明确记录在案的 **ProForma-inspired 子集**,并不声称完整实现 ProForma 2.0: +## Batch conversion -| 写法 | 含义 | 适用位置 | -|---|---|---| -| `Ac-PEPTIDE` 或 `[Acetyl]-PEPTIDE` | N 端乙酰化 | N 端 | -| `PEPTIDE-NH2` 或 `PEPTIDE-[Amide]` | C 端酰胺化 | C 端 | -| `A[NMe]` | 肽键氮甲基化 | 除 Pro 外的任意残基 | -| `S[Phospho]`、`T[Phospho]`、`Y[Phospho]` | 磷酸化 | S/T/Y | -| `M[Oxidation]` | 甲硫氨酸亚砜 | M | -| `K[Methyl]`、`K[Dimethyl]`、`K[Trimethyl]` | Lys 侧链甲基化 | K | -| `K[Acetyl]` | Lys 侧链乙酰化 | K | -| `C[Methyl]` | Cys S-甲基化 | C | -| `C[Carbamidomethyl]` | Cys 烷基化 | C | +```bash +moltranscode convert proteins.fasta --file --from fasta --to smiles --output out.csv +moltranscode convert compounds.csv --file --column structure \ + --from smiles --to inchikey --output out.csv +moltranscode convert examples/modified_peptides.csv --file --column sequence \ + --from peptide --to smiles --output peptides.jsonl +``` -歧义修饰(例如未指定定位的质量偏移)、交联、糖链、二硫键、环肽和任意非天然单体目前会 -明确报错。此策略避免生成“语法有效但化学含义错误”的结构。完整复杂大分子表示建议走 HELM -及其单体库,详见 [科学依据与边界](docs/scientific-basis.md)。 +CSV/TSV/JSONL input columns are preserved during ingestion. Result tables contain `id`, `input`, +`input_format`, `output_format`, `result`, `status`, and `error`. One invalid record does not discard +the rest of the batch unless `--fail-fast` is used. ## Python API ```python -from moltranscode import convert, fingerprint, describe +from moltranscode import convert, describe, fingerprint, load_modification_registry -smiles = convert("Ac-AK[Dimethyl]S[Phospho]-NH2", "peptide", "smiles") +registry = load_modification_registry("examples/custom_modifications.json") + +smiles = convert( + "Prop-AK[AzK]-NHMe", + "peptide", + "smiles", + modification_registry=registry, +) fp = fingerprint("CCO", "smiles", kind="morgan", n_bits=2048) -props = describe("CCO", "smiles") +properties = describe("K[Succinyl]", "peptide") ``` -## 项目结构 +Advanced users can construct a `ModificationRegistry` directly and call `register_side_chain()`, +`register_n_terminus()`, or `register_c_terminus()`. -```text -src/moltranscode/ # 转换、肽构建、I/O、CLI、PubChem 客户端 -tests/ # 单元测试与 CLI 测试 -examples/ # FASTA、CSV 和修饰肽示例 -docs/ # 科学依据、限制和路线图 -``` +## Scientific and reproducibility boundaries + +- Output depends on the RDKit version and MolTranscode parameters; record both with published data. +- Neutral termini and neutral phosphate/sulfate acid forms are representation policies, not claims + about the dominant microspecies at a particular pH. +- `M[Oxidation]` does not assign sulfur stereochemistry, and `K[Crotonyl]` does not infer alkene E/Z + geometry. +- Mass-only deltas, ambiguous localizations, glycans, cross-links, disulfides, and cyclic peptides + are rejected until a unique molecular graph can be constructed. +- Fingerprints are lossy features, not molecular identity keys. Prefer Standard InChI/InChIKey for + identity exchange. +- `resolve-name` queries PubChem; synonyms can be ambiguous and should be checked against the + returned structure. + +Read [Scientific basis](docs/scientific-basis.md) and the [Roadmap](docs/roadmap.md) before using the +tool in a publication workflow. -## 可复现性与安全性 +## Project layout -- 输出由 RDKit 版本决定;论文或数据发布时请同时记录 `moltranscode --version` 与参数。 -- `resolve-name` 会访问 PubChem,名称可能有同义词歧义;关键研究应核对 InChIKey/结构图。 -- 指纹是检索/建模特征,不是分子身份标识;身份交换优先使用 Standard InChI/InChIKey。 -- 本工具不替代化学家审查,也不用于直接给出合成、剂量或安全性结论。 +```text +src/moltranscode/ conversion, peptide, registry, I/O, and CLI modules +tests/ chemistry, registry, batch, and CLI regression tests +examples/ FASTA, SMI, modified peptide, and custom-registry examples +docs/ scientific conventions, modification schema, and roadmap +``` -## 进一步开发 +## Contributing -见 [路线图](docs/roadmap.md)。优先方向包括完整 ProForma 2.0 解析、OpenHELM 单体库、 -环肽/二硫键/交联、核酸、反应 SMILES、3D 构象和大规模流式处理。 +New built-in modifications need an explicit attachment structure, site specificity, stereochemical +policy, focused formula/structure tests, and a primary or controlled-vocabulary reference. See +[CONTRIBUTING.md](CONTRIBUTING.md). ## License diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..014d4f4 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,192 @@ +# MolTranscode + +[English](README.md) + +[![CI](https://github.com/PerinMu/MolTranscode/actions/workflows/ci.yml/badge.svg)](https://github.com/PerinMu/MolTranscode/actions/workflows/ci.yml) +[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) + +**MolTranscode** 是一个可审计、支持批处理的分子表示转码工具。它通过统一的 Python API +和命令行界面,连接肽/蛋白序列、小分子格式、分子描述符与指纹。 + +> 分子表示并非总能无损互转。SMILES 保存分子图,但不保存 FASTA 注释、残基编号、 +> 构象集合、pH 或实验条件;任意 SMILES 也不能可靠地还原成唯一肽序列。 + +## 主要功能 + +- 输入:肽/蛋白序列、FASTA、肽 HELM、SMILES、InChI、SELFIES、MOL、SDF、PDB、 + CSV、TSV、TXT、JSONL +- 输出:canonical/isomeric SMILES、InChI、InChIKey、SELFIES、MOL、SDF、PDB, + 以及依赖单体元数据的 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` + +## 安装 + +支持 Python 3.10 及以上版本: + +```bash +git clone https://github.com/PerinMu/MolTranscode.git +cd MolTranscode +python -m venv .venv +source .venv/bin/activate +python -m pip install -U pip +python -m pip install -e . +``` + +开发环境: + +```bash +python -m pip install -e '.[dev]' +pytest +ruff check . +``` + +## 快速开始 + +```bash +# 小写残基代表对应 D-氨基酸 +moltranscode convert ACDY --from peptide --to smiles +moltranscode convert 'Ac-AK[Dimethyl]S[Phospho]M[Oxidation]-NH2' \ + --from peptide --to smiles + +# 按位点解析受控词表编号 +moltranscode convert 'AS[UNIMOD:21]M[UNIMOD:35]K[UNIMOD:121]' \ + --from peptide --to inchikey + +# 小分子、指纹与描述符 +moltranscode convert 'CC(=O)Oc1ccccc1C(=O)O' --from smiles --to inchi +moltranscode fingerprint 'CCO' --from smiles --kind morgan --format hex +moltranscode describe 'K[Succinyl]' --from peptide + +# 查看支持格式和当前修饰库 +moltranscode formats --json +``` + +自动识别会保守处理歧义。例如 `CC` 既可能是乙烷 SMILES,也可能是二半胱氨酸肽, +此时应显式指定 `--from smiles` 或 `--from peptide`。 + +## 肽修饰语法 + +MolTranscode 实现的是一个能够解析为明确结构的 **ProForma-inspired 子集**,并不声称 +完整兼容 ProForma 2.0。 + +### 端基与骨架 + +| 写法 | 表示的结构 | +|---|---| +| `[Acetyl]-PEPTIDE` 或 `Ac-PEPTIDE` | N 端乙酰化 | +| `[Formyl]-PEPTIDE` | N 端甲酰化 | +| `[Myristoyl]-PEPTIDE` | N 端肉豆蔻酰化 | +| `[Palmitoyl]-PEPTIDE` | N 端棕榈酰化 | +| `PEPTIDE-[Amide]` 或 `PEPTIDE-NH2` | C 端酰胺化 | +| `PEPTIDE-[MethylEster]` | C 端甲酯化 | +| `PEPTIDE-[EthylEster]` | C 端乙酯化 | +| `PEPTIDE-[Hydrazide]` | C 端酰肼 | +| `A[NMe]` | 骨架 N-甲基化(Pro 不适用) | + +### 侧链修饰 + +| 位点 | 内置修饰名称 | +|---|---| +| S、T、Y | `Phospho`、`Sulfo`、`Acetyl` | +| S、T | `Methyl` | +| M | `Oxidation`、`Dioxidation` | +| C | `Oxidation`、`SulfinicAcid`、`SulfonicAcid`、`Persulfide`、`Methyl`、`Carbamidomethyl` | +| N、Q | `Deamidated` | +| R | `Citrullination` | +| K | `Methyl`、`Dimethyl`、`Trimethyl`、`Acetyl`、`Succinyl`、`Malonyl`、`Crotonyl`、`GlyGly`、`Carbamyl` | +| Y | `Nitro` | + +支持的位点感知别名包括 `UNIMOD:1`、`UNIMOD:4`、`UNIMOD:7`、`UNIMOD:21`、 +`UNIMOD:34`、`UNIMOD:35`、`UNIMOD:36`、`UNIMOD:37`、`UNIMOD:40`、 +`UNIMOD:64`、`UNIMOD:121`、`UNIMOD:747`、`UNIMOD:1363`。同一个编号会根据位点 +解析,例如 `R[UNIMOD:7]` 表示瓜氨酸,而 N/Q 位点表示脱酰胺。 + +## 自定义修饰渠道 + +JSON 修饰库中的 SMILES 是**带连接语义的结构片段**,不是质量差值: + +```json +{ + "schema_version": 1, + "override": false, + "side_chain_modifications": [ + { + "name": "AzidoLys", + "residue": "K", + "side_chain_smiles": "CCCCN=[N+]=[N-]", + "aliases": ["AzK"] + } + ], + "n_terminal_modifications": [ + { + "name": "Propionyl", + "prefix_smiles": "CCC(=O)", + "aliases": ["Prop"] + } + ], + "c_terminal_modifications": [ + { + "name": "MethylAmide", + "terminus_smiles": "C(=O)NC", + "aliases": ["NHMe"] + } + ] +} +``` + +```bash +moltranscode convert 'Prop-AK[AzK]-NHMe' --from peptide --to smiles \ + --mod-config examples/custom_modifications.json + +# 多个修饰库按命令行顺序叠加 +moltranscode formats --json --mod-config lab-mods.json --mod-config project-mods.json +``` + +内置定义不能被静默覆盖;必须显式设置 `"override": true`。D-Ile 或 D-Thr 的自定义 +修饰必须另给 `d_side_chain_smiles`,以明确第二个手性中心。最终结构都会经过 RDKit +sanitization,但用户仍需对自定义片段的化学含义负责,并随结果保存配置文件。 + +完整字段、连接规则和 Python API 见[修饰系统说明](docs/modifications.md)。 + +## 批处理与 Python API + +```bash +moltranscode convert proteins.fasta --file --from fasta --to smiles --output out.csv +moltranscode convert compounds.sdf --file --from sdf --to canonical-smiles --output out.jsonl +moltranscode convert examples/modified_peptides.csv --file --column sequence \ + --from peptide --to smiles --output peptides.csv +``` + +```python +from moltranscode import convert, load_modification_registry + +registry = load_modification_registry("examples/custom_modifications.json") +smiles = convert( + "Prop-AK[AzK]-NHMe", + "peptide", + "smiles", + modification_registry=registry, +) +``` + +也可直接构造 `ModificationRegistry`,调用 `register_side_chain()`、 +`register_n_terminus()` 和 `register_c_terminus()`。 + +## 科学边界与复现 + +- 输出取决于 MolTranscode/RDKit 版本和参数,发表数据时应一并记录。 +- 中性端基与中性磷酸/硫酸形式属于表示策略,不代表特定 pH 下的优势微观状态。 +- 质量差值、模糊定位、糖链、交联、二硫键和环肽目前不会被猜测为某个结构。 +- 指纹是有损特征,不是分子身份标识;身份交换优先使用 Standard InChI/InChIKey。 +- `resolve-name` 查询 PubChem,同义词可能有歧义,应核对返回结构。 + +正式研究使用前请阅读[科学依据与边界](docs/scientific-basis.md)和[路线图](docs/roadmap.md)。 + +## License + +MIT diff --git a/docs/custom-modifications.schema.json b/docs/custom-modifications.schema.json new file mode 100644 index 0000000..1136063 --- /dev/null +++ b/docs/custom-modifications.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/PerinMu/MolTranscode/blob/main/docs/custom-modifications.schema.json", + "title": "MolTranscode custom modification registry", + "type": "object", + "additionalProperties": false, + "properties": { + "schema_version": { + "const": 1, + "default": 1 + }, + "override": { + "type": "boolean", + "default": false + }, + "side_chain_modifications": { + "type": "array", + "items": { + "$ref": "#/$defs/sideChain" + } + }, + "n_terminal_modifications": { + "type": "array", + "items": { + "$ref": "#/$defs/nTerminus" + } + }, + "c_terminal_modifications": { + "type": "array", + "items": { + "$ref": "#/$defs/cTerminus" + } + } + }, + "$defs": { + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "sideChain": { + "type": "object", + "additionalProperties": false, + "required": ["name", "residue", "side_chain_smiles"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "residue": {"type": "string", "pattern": "^[ARNDCQEGHILKMFPSTWYVarndcqeghilkmfpstwyv]$"}, + "side_chain_smiles": {"type": "string", "minLength": 1}, + "d_side_chain_smiles": {"type": "string", "minLength": 1}, + "aliases": {"$ref": "#/$defs/aliases"}, + "description": {"type": "string"} + } + }, + "nTerminus": { + "type": "object", + "additionalProperties": false, + "required": ["name", "prefix_smiles"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "prefix_smiles": {"type": "string", "minLength": 1}, + "aliases": {"$ref": "#/$defs/aliases"}, + "description": {"type": "string"} + } + }, + "cTerminus": { + "type": "object", + "additionalProperties": false, + "required": ["name", "terminus_smiles"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "terminus_smiles": {"type": "string", "minLength": 1}, + "aliases": {"$ref": "#/$defs/aliases"}, + "description": {"type": "string"} + } + } + } +} diff --git a/docs/modifications.md b/docs/modifications.md new file mode 100644 index 0000000..caca04e --- /dev/null +++ b/docs/modifications.md @@ -0,0 +1,161 @@ +# Modification system + +MolTranscode resolves peptide annotations to explicit molecular structures. Its notation is a +documented, structure-resolving subset inspired by ProForma 2.0; it is not a general ProForma +parser and does not turn an unsupported mass delta into a guessed structure. + +## Accepted notation + +```text +[N-terminal-name]-PEP[R-esidue-modification]TIDE-[C-terminal-name] +``` + +- Uppercase residues are L amino acids; lowercase residues are D amino acids. +- One side-chain modification and optional `[NMe]` may be assigned to a residue. +- Bracketed terminal names are preferred. Built-in short forms such as `Ac-` and `-NH2` remain + available, and a custom alias can also be used as a short form when peptide input is explicit. +- Names and aliases are case-insensitive; underscores and spaces normalize to hyphens. +- Controlled-vocabulary accessions are site-aware. An accession is accepted only at registered + residues and termini. + +Run `moltranscode formats --json` to inspect the active registry. + +## Built-in controlled-vocabulary aliases + +The built-in structures include aliases for selected records in the +[Unimod modification database](https://www.unimod.org/). Site specificity is part of the lookup: + +| Accession | Built-in meaning and supported sites | +|---|---| +| `UNIMOD:1` | Acetyl: N terminus, K, S, T, Y | +| `UNIMOD:4` | Carbamidomethyl: C | +| `UNIMOD:7` | Deamidated: N/Q; citrullination: R | +| `UNIMOD:21` | Phospho: S/T/Y | +| `UNIMOD:34`, `36`, `37` | Methyl/Dimethyl/Trimethyl at supported sites | +| `UNIMOD:35` | Oxidation: M/C | +| `UNIMOD:40` | Sulfo: S/T/Y | +| `UNIMOD:64`, `747`, `1363` | Succinyl/Malonyl/Crotonyl: K | +| `UNIMOD:121` | GlyGly remnant: K | + +These aliases resolve to MolTranscode's explicit structural conventions; they do not mean that all +specificities or neutral losses recorded by Unimod are implemented. + +## JSON registry schema + +Use [`examples/custom_modifications.json`](../examples/custom_modifications.json) as a working +template. [`custom-modifications.schema.json`](custom-modifications.schema.json) is supplied for +editor validation. Runtime validation is built in and does not require a JSON Schema package. + +```json +{ + "schema_version": 1, + "override": false, + "side_chain_modifications": [ + { + "name": "AzidoLys", + "residue": "K", + "side_chain_smiles": "CCCCN=[N+]=[N-]", + "aliases": ["AzK"], + "description": "Optional provenance note" + } + ], + "n_terminal_modifications": [ + { + "name": "Propionyl", + "prefix_smiles": "CCC(=O)", + "aliases": ["Prop"] + } + ], + "c_terminal_modifications": [ + { + "name": "MethylAmide", + "terminus_smiles": "C(=O)NC", + "aliases": ["NHMe"] + } + ] +} +``` + +### Attachment semantics + +Fragments are not standalone modified amino-acid SMILES. They must be one sanitizable connected +SMILES fragment and must not contain `.` or a wildcard atom. + +- `side_chain_smiles` replaces the entire side-chain branch attached to the residue alpha carbon. + For lysine, ordinary `CCCCN` therefore represents the beta-through-epsilon branch. +- `d_side_chain_smiles` is the corresponding branch for the D residue. It is optional except for + modified D-Ile and D-Thr, whose side chains contain a second stereocenter. +- `prefix_smiles` is placed immediately before the first residue nitrogen. For example, + `CCC(=O)` produces an N-propionyl peptide. +- `terminus_smiles` replaces the final `C(=O)O` group and includes its carbonyl carbon. For example, + `C(=O)NC` produces a C-terminal methylamide. + +Each final graph is parsed and sanitized by RDKit. Sanitization catches invalid valence and syntax, +but it cannot prove that a valid graph matches the intended chemistry. + +### Layering and overrides + +Repeat `--mod-config` to layer registries in command-line order: + +```bash +moltranscode convert 'Prop-AK[AzK]-NHMe' --from peptide --to smiles \ + --mod-config lab-mods.json --mod-config project-mods.json +``` + +A definition or alias collision is rejected by default. Set `"override": true` in a registry only +when deliberate replacement is required. This setting applies to that whole file, so keep override +files small and review them as source data. + +Unknown keys, invalid residues, empty fragments, malformed aliases, and unsupported schema versions +are rejected. The optional `description` field is accepted as human-readable provenance but is not +used during structure construction. + +## Python registration API + +Load JSON: + +```python +from moltranscode import convert, load_modification_registry + +registry = load_modification_registry(["lab-mods.json", "project-mods.json"]) +smiles = convert( + "Prop-AK[AzK]-NHMe", + "peptide", + "smiles", + modification_registry=registry, +) +``` + +Or register definitions programmatically: + +```python +from moltranscode import ModificationRegistry, convert, default_modification_registry + +registry: ModificationRegistry = default_modification_registry() +registry.register_side_chain( + name="AzidoLys", + residue="K", + side_chain_smiles="CCCCN=[N+]=[N-]", + aliases=("AzK",), +) +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. + +## Provenance and contribution rules + +Archive custom JSON files alongside generated outputs and record the MolTranscode and RDKit +versions. A proposed built-in definition should include: + +1. a controlled-vocabulary or primary-literature reference; +2. supported residue/terminus sites; +3. attachment structure, formal charge, tautomer, and stereochemical conventions; +4. formula or exact-mass regression tests and RDKit sanitization tests; +5. explicit limitations such as unspecified sulfur or alkene stereochemistry. + +ProForma 2.0 describes broader syntax, including ambiguity, labile modifications, glycans, +cross-links, and mass shifts: [LeDuc et al., Nature Methods (2022)](https://doi.org/10.1038/s41592-021-01196-8). +Those annotations require a separate syntax and structure-resolution layer before MolTranscode can +safely emit one molecular graph. diff --git a/docs/roadmap.md b/docs/roadmap.md index f7c4b5e..2db564f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,7 @@ every transformation explicit and testable. ## Near term: make the current core production-ready - Add golden structures for every supported PTM from curated public records. +- Add per-definition provenance fields and checksums to the custom modification registry. - Emit a provenance manifest containing package/RDKit/SELFIES versions and command arguments. - Add gzip input, iterator-based million-row processing, chunked output, and optional multiprocessing. - Add configurable salt stripping, fragment selection, normalization, tautomer, and charge policies. @@ -16,6 +17,8 @@ every transformation explicit and testable. - Implement the ProForma 2.0 grammar separately from structure resolution. A valid ProForma mass annotation is not automatically enough information to construct a molecular graph. +- Map additional PSI-MOD/Unimod terms only after site-aware structures and regression fixtures are + available; keep the JSON registry as the escape hatch for laboratory-specific chemistry. - Integrate the public OpenHELM monomer library and record its version/checksum. This enables non-natural residues and linkers without hard-coding hundreds of templates. - Represent polymers as an internal residue/atom graph before rendering SMILES, HELM, or SDF. diff --git a/docs/scientific-basis.md b/docs/scientific-basis.md index a0ad6b2..184b9d4 100644 --- a/docs/scientific-basis.md +++ b/docs/scientific-basis.md @@ -29,13 +29,20 @@ molecule with RDKit. The annotation syntax is inspired by ProForma 2.0. ProForma is a community standard for expressing specific proteoforms and peptidoforms, including terminal modifications, controlled-vocabulary terms, mass shifts, ambiguity, glycans, cross-links, and isotopes. The current parser implements -only the structure-resolvable subset listed in the README. It therefore rejects constructs whose -mass annotation cannot be mapped to one unambiguous molecular graph. +only the structure-resolvable subset listed in the README. It accepts selected site-aware Unimod +names/accessions and custom structure registries, but rejects constructs whose mass annotation +cannot be mapped to one unambiguous molecular graph. Primary reference: LeDuc et al., “ProForma 2.0: Unifying the Encoding of Proteoforms and Peptidoforms,” *Nature Methods* (2022), [doi:10.1038/s41592-021-01196-8](https://doi.org/10.1038/s41592-021-01196-8). +Unimod is used as a controlled-vocabulary reference for selected built-in aliases. MolTranscode +implements only explicitly tested site/structure combinations; accepting one Unimod accession does +not imply support for every specificity, neutral loss, or isotopic variant in that record. See the +[Unimod database](https://www.unimod.org/) and the +[modification-system documentation](modifications.md). + For complex, non-natural biomolecules, HELM is a better scalable abstraction than raw atom-level SMILES. HELM was designed for peptides, proteins, nucleic acids, chemical modifiers, and conjugates. RDKit's current HELM reader supports a peptide subset, which is the scope exposed here. @@ -46,7 +53,7 @@ Structure Representation,” *J. Chem. Inf. Model.* (2012), [RDKit molecule file API](https://rdkit.org/docs/source/rdkit.Chem.rdmolfiles.html) for the exact FASTA/HELM capabilities of the installed RDKit release. -### Structural conventions in version 0.1 +### Structural conventions in version 0.2 - uppercase residues are L and lowercase residues are D; - termini are neutral `NH2`/`COOH` unless acetylated/amidated; @@ -54,12 +61,20 @@ FASTA/HELM capabilities of the installed RDKit release. - histidine uses one explicit tautomer from the residue template; - `M[Oxidation]` means methionine sulfoxide but does not assign sulfur stereochemistry; - Lys methylation is placed on the epsilon nitrogen; +- `K[Crotonyl]` does not assign alkene E/Z geometry; +- terminal and side-chain registry fragments replace documented attachment regions rather than + being merged as disconnected complete molecules; - multiple simultaneous side-chain modifications on one residue are rejected; - no disulfide, cyclic peptide, cross-link, glycan, or ambiguous localization is inferred. These conventions are representation choices, not claims about the dominant solution-state microstate. A future standardization layer should expose charge/tautomer policy explicitly. +Custom registry files are executable only in the chemical sense: MolTranscode reads them as data +and never evaluates Python or shell code, but a syntactically valid fragment can still encode the +wrong chemical structure. Registry files, references, and checksums should therefore be treated as +part of result provenance. + ## 3. Molecular representations and fingerprints RDKit provides parsing, sanitization, canonicalization, descriptors, and fingerprint generators. diff --git a/examples/custom_modifications.json b/examples/custom_modifications.json new file mode 100644 index 0000000..8b49e16 --- /dev/null +++ b/examples/custom_modifications.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "override": false, + "side_chain_modifications": [ + { + "name": "AzidoLys", + "residue": "K", + "side_chain_smiles": "CCCCN=[N+]=[N-]", + "aliases": ["AzK"] + }, + { + "name": "ChloroThr", + "residue": "T", + "side_chain_smiles": "[C@H](Cl)C", + "d_side_chain_smiles": "[C@@H](Cl)C" + } + ], + "n_terminal_modifications": [ + { + "name": "Propionyl", + "prefix_smiles": "CCC(=O)", + "aliases": ["Prop"] + } + ], + "c_terminal_modifications": [ + { + "name": "MethylAmide", + "terminus_smiles": "C(=O)NC", + "aliases": ["NHMe"] + } + ] +} diff --git a/examples/modified_peptides.csv b/examples/modified_peptides.csv index 22481e8..c25fa50 100644 --- a/examples/modified_peptides.csv +++ b/examples/modified_peptides.csv @@ -3,3 +3,7 @@ phosphopeptide,AS[Phospho]TY,site-specific phosphoserine oxidized,ACM[Oxidation]K,methionine sulfoxide terminal,Ac-AK[Dimethyl]C[Carbamidomethyl]-NH2,terminal and side-chain modifications mixed_d_l,Ac-AcD-NH2,lowercase c is D-cysteine +histone_acylation,AK[Succinyl]K[Malonyl]K[Crotonyl],extended lysine acylation +ubiquitin_remnant,ACK[GlyGly]T,lysine diglycine remnant +oxidized_cysteine,AC[SulfonicAcid]DM[Dioxidation],higher oxidation states +unimod_alias,AS[UNIMOD:21]M[UNIMOD:35]K[UNIMOD:121],site-resolved Unimod accessions diff --git a/pyproject.toml b/pyproject.toml index 0b5178a..80b9f89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "moltranscode" -version = "0.1.0" +version = "0.2.0" description = "Auditable transcoding between biomolecular sequences, chemical formats, and fingerprints" readme = "README.md" requires-python = ">=3.10" diff --git a/src/moltranscode/__init__.py b/src/moltranscode/__init__.py index 6f8c5af..367d596 100644 --- a/src/moltranscode/__init__.py +++ b/src/moltranscode/__init__.py @@ -8,6 +8,11 @@ MolTranscodeError, UnsupportedConversionError, ) +from .modifications import ( + ModificationRegistry, + default_modification_registry, + load_modification_registry, +) from .peptide import parse_peptide, peptide_to_smiles __all__ = [ @@ -15,12 +20,15 @@ "MolTranscodeError", "InputFormatError", "ModificationError", + "ModificationRegistry", "UnsupportedConversionError", "convert", + "default_modification_registry", "describe", "fingerprint", + "load_modification_registry", "parse_peptide", "peptide_to_smiles", ] -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/moltranscode/chemistry.py b/src/moltranscode/chemistry.py index f5a1d76..4ab2b8a 100644 --- a/src/moltranscode/chemistry.py +++ b/src/moltranscode/chemistry.py @@ -11,7 +11,8 @@ from rdkit.Chem import Crippen, Descriptors, Lipinski, MACCSkeys, rdFingerprintGenerator from .exceptions import AmbiguousInputError, InputFormatError, UnsupportedConversionError -from .peptide import STANDARD_AA, parse_peptide, peptide_to_smiles +from .modifications import STANDARD_AA, ModificationRegistry +from .peptide import parse_peptide, peptide_to_smiles INPUT_FORMATS = ( "auto", @@ -79,7 +80,9 @@ def detect_input_format(value: str) -> str: ("-nh2", "-[amide]") ): return "peptide" - if re.search(r"\[[A-Za-z][A-Za-z0-9_-]*\]", text) and re.search(r"[A-Za-z]\[", text): + if re.match(r"^\[[^]]+\]-", text) or re.search(r"-\[[^]]+\]$", text): + return "peptide" + if re.search(r"\[[A-Za-z][A-Za-z0-9_:-]*\]", text) and re.search(r"[A-Za-z]\[", text): return "peptide" compact = "".join(text.split()) @@ -143,7 +146,12 @@ def _validate_standard_sequence(sequence: str) -> str: return compact -def molecule_from_value(value: str, input_format: str = "auto") -> tuple[Chem.Mol, str]: +def molecule_from_value( + value: str, + input_format: str = "auto", + *, + modification_registry: ModificationRegistry | None = None, +) -> tuple[Chem.Mol, str]: """Parse one value and return an RDKit molecule and resolved input format.""" fmt = normalize_format(input_format) @@ -176,11 +184,11 @@ def molecule_from_value(value: str, input_format: str = "auto") -> tuple[Chem.Mo source_sequence = _validate_standard_sequence(sequence) mol = Chem.MolFromFASTA(source_sequence, sanitize=True, flavor=0) elif fmt == "peptide": - spec = parse_peptide(value) + spec = parse_peptide(value, modification_registry) source_sequence = spec.sequence is_plain = ( - not spec.n_acetyl - and not spec.c_amide + not spec.n_terminus + and not spec.c_terminus and not any(residue.modifications for residue in spec.residues) ) all_l = all(not residue.is_d for residue in spec.residues) @@ -188,7 +196,7 @@ def molecule_from_value(value: str, input_format: str = "auto") -> tuple[Chem.Mo if is_plain and (all_l or all_d): mol = Chem.MolFromFASTA(spec.sequence.upper(), sanitize=True, flavor=0 if all_l else 1) else: - mol = Chem.MolFromSmiles(peptide_to_smiles(value)) + mol = Chem.MolFromSmiles(peptide_to_smiles(value, modification_registry)) else: # pragma: no cover - guarded by the supported set raise InputFormatError(f"Unsupported input format: {fmt!r}.") @@ -248,8 +256,18 @@ def molecule_to_value(mol: Chem.Mol, output_format: str) -> str: raise UnsupportedConversionError(f"Unsupported output format: {fmt!r}.") -def convert(value: str, input_format: str = "auto", output_format: str = "smiles") -> str: - mol, _ = molecule_from_value(value, input_format) +def convert( + value: str, + input_format: str = "auto", + output_format: str = "smiles", + *, + modification_registry: ModificationRegistry | None = None, +) -> str: + mol, _ = molecule_from_value( + value, + input_format, + modification_registry=modification_registry, + ) return molecule_to_value(mol, output_format) @@ -288,6 +306,7 @@ def fingerprint( radius: int = 2, k: int = 3, output_format: str = "hex", + modification_registry: ModificationRegistry | None = None, ) -> str | list[int]: """Generate a deterministic molecular or sequence fingerprint.""" @@ -299,7 +318,11 @@ def fingerprint( if fmt == "fasta": sequence = _single_fasta_sequence(value) elif fmt in {"sequence", "protein", "peptide"}: - sequence = parse_peptide(value).sequence.upper() if fmt == "peptide" else value + sequence = ( + parse_peptide(value, modification_registry).sequence.upper() + if fmt == "peptide" + else value + ) else: raise InputFormatError( "k-mer fingerprints require sequence, protein, peptide, or FASTA input." @@ -308,7 +331,11 @@ def fingerprint( if n_bits <= 0: raise InputFormatError("Fingerprint size must be positive.") - mol, _ = molecule_from_value(value, input_format) + mol, _ = molecule_from_value( + value, + input_format, + modification_registry=modification_registry, + ) if fp_kind == "morgan": fp = rdFingerprintGenerator.GetMorganGenerator(radius=radius, fpSize=n_bits).GetFingerprint( mol @@ -326,8 +353,17 @@ def fingerprint( return _format_bits(fp.ToBitString(), output_format) -def describe(value: str, input_format: str = "auto") -> dict[str, Any]: - mol, resolved_format = molecule_from_value(value, input_format) +def describe( + value: str, + input_format: str = "auto", + *, + modification_registry: ModificationRegistry | None = None, +) -> dict[str, Any]: + mol, resolved_format = molecule_from_value( + value, + input_format, + modification_registry=modification_registry, + ) return { "input_format": resolved_format, "canonical_smiles": Chem.MolToSmiles(mol, canonical=True, isomericSmiles=True), diff --git a/src/moltranscode/cli.py b/src/moltranscode/cli.py index a8be1e5..2f92f10 100644 --- a/src/moltranscode/cli.py +++ b/src/moltranscode/cli.py @@ -19,9 +19,24 @@ ) from .exceptions import MolTranscodeError from .io import InputRecord, read_records, write_results +from .modifications import ( + ModificationRegistry, + default_modification_registry, + load_modification_registry, +) from .pubchem import resolve_name +def _add_modification_config_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--mod-config", + action="append", + default=[], + metavar="FILE", + help="merge a JSON modification registry; repeat to layer multiple registries", + ) + + def _base_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="moltranscode", @@ -44,6 +59,7 @@ def _base_parser() -> argparse.ArgumentParser: convert_parser.add_argument( "--fail-fast", action="store_true", help="stop the batch after the first failed record" ) + _add_modification_config_argument(convert_parser) convert_parser.set_defaults(handler=_handle_convert) fp_parser = subparsers.add_parser( @@ -60,6 +76,7 @@ def _base_parser() -> argparse.ArgumentParser: fp_parser.add_argument("--column", help="input column for CSV/TSV/JSONL") fp_parser.add_argument("--output", "-o", help="output file") fp_parser.add_argument("--fail-fast", action="store_true") + _add_modification_config_argument(fp_parser) fp_parser.set_defaults(handler=_handle_fingerprint) describe_parser = subparsers.add_parser("describe", help="calculate basic 2D descriptors") @@ -67,6 +84,7 @@ def _base_parser() -> argparse.ArgumentParser: describe_parser.add_argument( "--from", dest="input_format", default="auto", choices=INPUT_FORMATS ) + _add_modification_config_argument(describe_parser) describe_parser.set_defaults(handler=_handle_describe) resolve_parser = subparsers.add_parser( @@ -81,6 +99,7 @@ def _base_parser() -> argparse.ArgumentParser: formats_parser = subparsers.add_parser("formats", help="show formats, PTMs, and fingerprints") 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) return parser @@ -136,17 +155,32 @@ def _run_batch( def _handle_convert(args: argparse.Namespace) -> int: + registry = _registry_from_args(args) if not args.file: - print(convert(args.input, args.input_format, args.output_format)) + print( + convert( + args.input, + args.input_format, + args.output_format, + modification_registry=registry, + ) + ) return 0 return _run_batch( args, args.output_format, - lambda record: convert(record.value, record.input_format, args.output_format), + lambda record: convert( + record.value, + record.input_format, + args.output_format, + modification_registry=registry, + ), ) def _handle_fingerprint(args: argparse.Namespace) -> int: + registry = _registry_from_args(args) + def operation(record: InputRecord) -> str | list[int]: return fingerprint( record.value, @@ -156,6 +190,7 @@ def operation(record: InputRecord) -> str | list[int]: radius=args.radius, k=args.k, output_format=args.format, + modification_registry=registry, ) if not args.file: @@ -166,7 +201,18 @@ def operation(record: InputRecord) -> str | list[int]: def _handle_describe(args: argparse.Namespace) -> int: - print(json.dumps(describe(args.input, args.input_format), ensure_ascii=False, indent=2)) + registry = _registry_from_args(args) + print( + json.dumps( + describe( + args.input, + args.input_format, + modification_registry=registry, + ), + ensure_ascii=False, + indent=2, + ) + ) return 0 @@ -175,20 +221,19 @@ def _handle_resolve_name(args: argparse.Namespace) -> int: return 0 +def _registry_from_args(args: argparse.Namespace) -> ModificationRegistry: + paths = getattr(args, "mod_config", []) + return load_modification_registry(paths) if paths else default_modification_registry() + + def _handle_formats(args: argparse.Namespace) -> int: + registry = _registry_from_args(args) data = { "input_formats": INPUT_FORMATS, "output_formats": OUTPUT_FORMATS, "fingerprints": FINGERPRINT_KINDS, - "peptide_modifications": { - "N-terminus": ["Acetyl"], - "C-terminus": ["Amide"], - "backbone": ["NMe"], - "S/T/Y": ["Phospho"], - "M": ["Oxidation"], - "K": ["Methyl", "Dimethyl", "Trimethyl", "Acetyl"], - "C": ["Methyl", "Carbamidomethyl"], - }, + "peptide_modifications": registry.summary(), + "modification_config": list(args.mod_config), } if args.json: print(json.dumps(data, ensure_ascii=False, indent=2)) @@ -196,8 +241,13 @@ def _handle_formats(args: argparse.Namespace) -> int: print("Input formats : " + ", ".join(INPUT_FORMATS)) print("Output formats: " + ", ".join(OUTPUT_FORMATS)) print("Fingerprints : " + ", ".join(FINGERPRINT_KINDS)) - print("Peptide PTMs : Ac/NH2 termini; NMe; Phospho; Oxidation; Lys Me/Me2/Me3/Ac;") - print(" Cys Methyl/Carbamidomethyl (documented ProForma-inspired subset)") + summary = registry.summary() + print("N termini : " + ", ".join(summary["n_terminal_modifications"])) + print("C termini : " + ", ".join(summary["c_terminal_modifications"])) + print("Backbone : " + ", ".join(summary["backbone_modifications"])) + print("Side-chain PTMs:") + for residue, names in summary["side_chain_modifications"].items(): + print(f" {residue}: {', '.join(names)}") return 0 diff --git a/src/moltranscode/modifications.py b/src/moltranscode/modifications.py new file mode 100644 index 0000000..d9604aa --- /dev/null +++ b/src/moltranscode/modifications.py @@ -0,0 +1,478 @@ +"""Curated and user-extensible peptide modification registry. + +Registry fragments are attachment-aware SMILES snippets for MolTranscode's +linear peptide builder. They are molecular structures, not mass-only deltas. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from rdkit import Chem, rdBase + +from .exceptions import ModificationError + +STANDARD_AA = frozenset("ARNDCQEGHILKMFPSTWYV") + + +def normalize_modification_name(name: str) -> str: + return "-".join(name.strip().lower().replace("_", "-").split()) + + +def _validate_fragment(smiles: str, *, context: str) -> str: + fragment = smiles.strip() + if not fragment: + raise ModificationError(f"{context} must not be empty.") + if "." in fragment: + raise ModificationError(f"{context} must contain exactly one connected SMILES fragment.") + if "*" in fragment: + raise ModificationError( + f"{context} must use MolTranscode's implicit attachment point, not a wildcard atom." + ) + with rdBase.BlockLogs(): + molecule = Chem.MolFromSmiles(fragment, sanitize=True) + if molecule is None: + raise ModificationError(f"{context} is not a sanitizable SMILES fragment.") + return fragment + + +@dataclass(frozen=True) +class SideChainDefinition: + """Replacement for the side-chain branch attached to the alpha carbon.""" + + l_smiles: str + d_smiles: str | None = None + + def for_configuration(self, *, is_d: bool, residue: str, name: str) -> str: + if is_d and self.d_smiles: + return self.d_smiles + if is_d and residue in {"I", "T"}: + raise ModificationError( + f"Modification [{name}] on D-{residue} requires d_side_chain_smiles " + "in the modification registry." + ) + return self.l_smiles + + +@dataclass +class ModificationRegistry: + """Structure-resolving peptide modifications and terminal groups.""" + + side_chains: dict[tuple[str, str], SideChainDefinition] = field(default_factory=dict) + side_aliases: dict[tuple[str, str], str] = field(default_factory=dict) + n_termini: dict[str, str] = field(default_factory=dict) + n_aliases: dict[str, str] = field(default_factory=dict) + c_termini: dict[str, str] = field(default_factory=dict) + c_aliases: dict[str, str] = field(default_factory=dict) + + def clone(self) -> ModificationRegistry: + return ModificationRegistry( + side_chains=dict(self.side_chains), + side_aliases=dict(self.side_aliases), + n_termini=dict(self.n_termini), + n_aliases=dict(self.n_aliases), + c_termini=dict(self.c_termini), + c_aliases=dict(self.c_aliases), + ) + + def register_side_chain( + self, + *, + name: str, + residue: str, + side_chain_smiles: str, + d_side_chain_smiles: str | None = None, + aliases: tuple[str, ...] = (), + override: bool = False, + ) -> None: + aa = residue.strip().upper() + if aa not in STANDARD_AA: + raise ModificationError(f"Invalid residue {residue!r} for modification {name!r}.") + canonical = name.strip() + if not canonical: + raise ModificationError("Modification name must not be empty.") + l_fragment = _validate_fragment( + side_chain_smiles, + context=f"Modification [{canonical}] on {aa} side_chain_smiles", + ) + d_fragment = ( + _validate_fragment( + d_side_chain_smiles, + context=f"Modification [{canonical}] on {aa} d_side_chain_smiles", + ) + if d_side_chain_smiles is not None + else None + ) + key = (aa, canonical) + if key in self.side_chains and not override: + raise ModificationError( + f"Modification [{canonical}] on {aa} already exists; set override=true explicitly." + ) + self.side_chains[key] = SideChainDefinition( + l_fragment, + d_fragment, + ) + for alias in (canonical, *aliases): + alias_key = (aa, normalize_modification_name(alias)) + existing = self.side_aliases.get(alias_key) + if existing and existing != canonical and not override: + raise ModificationError( + f"Alias {alias!r} on {aa} already resolves to [{existing}]." + ) + self.side_aliases[alias_key] = canonical + + def register_n_terminus( + self, + *, + name: str, + prefix_smiles: str, + aliases: tuple[str, ...] = (), + override: bool = False, + ) -> None: + self._register_terminus( + name=name, + smiles=prefix_smiles, + aliases=aliases, + groups=self.n_termini, + alias_map=self.n_aliases, + label="N-terminal", + override=override, + ) + + def register_c_terminus( + self, + *, + name: str, + terminus_smiles: str, + aliases: tuple[str, ...] = (), + override: bool = False, + ) -> None: + self._register_terminus( + name=name, + smiles=terminus_smiles, + aliases=aliases, + groups=self.c_termini, + alias_map=self.c_aliases, + label="C-terminal", + override=override, + ) + + @staticmethod + def _register_terminus( + *, + name: str, + smiles: str, + aliases: tuple[str, ...], + groups: dict[str, str], + alias_map: dict[str, str], + label: str, + override: bool, + ) -> None: + canonical = name.strip() + if not canonical: + raise ModificationError(f"{label} name must not be empty.") + fragment = _validate_fragment(smiles, context=f"{label} [{canonical}] SMILES fragment") + if canonical in groups and not override: + raise ModificationError( + f"{label} modification [{canonical}] already exists; set override=true explicitly." + ) + groups[canonical] = fragment + for alias in (canonical, *aliases): + key = normalize_modification_name(alias) + existing = alias_map.get(key) + if existing and existing != canonical and not override: + raise ModificationError( + f"{label} alias {alias!r} already resolves to [{existing}]." + ) + alias_map[key] = canonical + + def resolve_side_chain(self, residue: str, name: str) -> tuple[str, SideChainDefinition]: + aa = residue.upper() + canonical = self.side_aliases.get((aa, normalize_modification_name(name))) + if not canonical: + raise ModificationError(f"Modification [{name}] is not supported on {aa}.") + return canonical, self.side_chains[(aa, canonical)] + + def resolve_n_terminus(self, name: str) -> tuple[str, str]: + canonical = self.n_aliases.get(normalize_modification_name(name)) + if not canonical: + raise ModificationError(f"Unsupported N-terminal modification [{name}].") + return canonical, self.n_termini[canonical] + + def resolve_c_terminus(self, name: str) -> tuple[str, str]: + canonical = self.c_aliases.get(normalize_modification_name(name)) + if not canonical: + raise ModificationError(f"Unsupported C-terminal modification [{name}].") + return canonical, self.c_termini[canonical] + + def summary(self) -> dict[str, Any]: + side: dict[str, list[str]] = {} + for residue, name in sorted(self.side_chains): + side.setdefault(residue, []).append(name) + return { + "side_chain_modifications": side, + "n_terminal_modifications": sorted(self.n_termini), + "c_terminal_modifications": sorted(self.c_termini), + "backbone_modifications": ["NMe"], + } + + +def _register_builtins(registry: ModificationRegistry) -> None: + side_entries = ( + ("Phospho", "S", "COP(=O)(O)O", None, ("Phosphorylation", "UNIMOD:21")), + ( + "Phospho", + "T", + "[C@H](OP(=O)(O)O)C", + "[C@@H](OP(=O)(O)O)C", + ("Phosphorylation", "UNIMOD:21"), + ), + ( + "Phospho", + "Y", + "CC1=CC=C(OP(=O)(O)O)C=C1", + None, + ("Phosphorylation", "UNIMOD:21"), + ), + ("Sulfo", "S", "COS(=O)(=O)O", None, ("Sulfation", "UNIMOD:40")), + ( + "Sulfo", + "T", + "[C@H](OS(=O)(=O)O)C", + "[C@@H](OS(=O)(=O)O)C", + ("Sulfation", "UNIMOD:40"), + ), + ( + "Sulfo", + "Y", + "CC1=CC=C(OS(=O)(=O)O)C=C1", + None, + ("Sulfation", "UNIMOD:40"), + ), + ("Oxidation", "M", "CCS(=O)C", None, ("Sulfoxide", "UNIMOD:35")), + ("Dioxidation", "M", "CCS(=O)(=O)C", None, ("Sulfone",)), + ("Oxidation", "C", "CSO", None, ("SulfenicAcid", "UNIMOD:35")), + ("SulfinicAcid", "C", "CS(=O)O", None, ("CysSO2H",)), + ("SulfonicAcid", "C", "CS(=O)(=O)O", None, ("CysSO3H",)), + ("Persulfide", "C", "CSS", None, ("Sulfhydration",)), + ("Methyl", "C", "CSC", None, ("S-Methyl", "UNIMOD:34")), + ("Carbamidomethyl", "C", "CSCC(=O)N", None, ("CAM", "UNIMOD:4")), + ("Acetyl", "S", "COC(=O)C", None, ("O-Acetyl", "UNIMOD:1")), + ( + "Acetyl", + "T", + "[C@H](OC(=O)C)C", + "[C@@H](OC(=O)C)C", + ("O-Acetyl", "UNIMOD:1"), + ), + ( + "Acetyl", + "Y", + "CC1=CC=C(OC(=O)C)C=C1", + None, + ("O-Acetyl", "UNIMOD:1"), + ), + ("Methyl", "S", "COC", None, ("O-Methyl", "UNIMOD:34")), + ( + "Methyl", + "T", + "[C@H](OC)C", + "[C@@H](OC)C", + ("O-Methyl", "UNIMOD:34"), + ), + ("Deamidated", "N", "CC(=O)O", None, ("Deamidation", "UNIMOD:7")), + ("Deamidated", "Q", "CCC(=O)O", None, ("Deamidation", "UNIMOD:7")), + ( + "Citrullination", + "R", + "CCCNC(=O)N", + None, + ("Deimination", "Deamidated", "UNIMOD:7"), + ), + ("Methyl", "K", "CCCCN(C)", None, ("Me", "UNIMOD:34")), + ("Dimethyl", "K", "CCCCN(C)C", None, ("Me2", "UNIMOD:36")), + ("Trimethyl", "K", "CCCC[N+](C)(C)C", None, ("Me3", "UNIMOD:37")), + ("Acetyl", "K", "CCCCNC(=O)C", None, ("UNIMOD:1",)), + ("Succinyl", "K", "CCCCNC(=O)CCC(=O)O", None, ("UNIMOD:64",)), + ("Malonyl", "K", "CCCCNC(=O)CC(=O)O", None, ("UNIMOD:747",)), + ("Crotonyl", "K", "CCCCNC(=O)C=CC", None, ("UNIMOD:1363",)), + ("GlyGly", "K", "CCCCNC(=O)CNC(=O)CN", None, ("GG", "UNIMOD:121")), + ("Carbamyl", "K", "CCCCNC(=O)N", None, ("Carbamylation",)), + ( + "Nitro", + "Y", + "CC1=CC([N+](=O)[O-])=C(O)C=C1", + None, + ("Nitration", "3-Nitrotyrosine"), + ), + ) + for name, residue, l_smiles, d_smiles, aliases in side_entries: + registry.register_side_chain( + name=name, + residue=residue, + side_chain_smiles=l_smiles, + d_side_chain_smiles=d_smiles, + aliases=aliases, + ) + + registry.register_n_terminus( + name="Acetyl", prefix_smiles="CC(=O)", aliases=("Ac", "UNIMOD:1") + ) + registry.register_n_terminus(name="Formyl", prefix_smiles="O=C", aliases=("For",)) + registry.register_n_terminus( + name="Myristoyl", prefix_smiles="CCCCCCCCCCCCCC(=O)", aliases=("Myr",) + ) + registry.register_n_terminus( + name="Palmitoyl", prefix_smiles="CCCCCCCCCCCCCCCC(=O)", aliases=("Palm",) + ) + registry.register_c_terminus( + name="Amide", terminus_smiles="C(=O)N", aliases=("NH2",) + ) + registry.register_c_terminus( + name="MethylEster", terminus_smiles="C(=O)OC", aliases=("OMe",) + ) + registry.register_c_terminus( + name="EthylEster", terminus_smiles="C(=O)OCC", aliases=("OEt",) + ) + registry.register_c_terminus(name="Hydrazide", terminus_smiles="C(=O)NN") + + +def default_modification_registry() -> ModificationRegistry: + registry = ModificationRegistry() + _register_builtins(registry) + return registry + + +def _as_aliases(value: Any, *, context: str) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list) or not all( + isinstance(item, str) and item.strip() for item in value + ): + raise ModificationError(f"{context}.aliases must be a list of non-empty strings.") + return tuple(value) + + +def _entry_list(payload: dict[str, Any], key: str) -> list[dict[str, Any]]: + value = payload.get(key, []) + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + raise ModificationError(f"{key} must be a list of objects.") + return value + + +def _required_string(entry: dict[str, Any], key: str, *, context: str) -> str: + value = entry.get(key) + if not isinstance(value, str) or not value.strip(): + raise ModificationError(f"{context}.{key} must be a non-empty string.") + return value + + +def _optional_string(entry: dict[str, Any], key: str, *, context: str) -> str | None: + value = entry.get(key) + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ModificationError(f"{context}.{key} must be a non-empty string when provided.") + return value + + +def _check_entry_keys( + entry: dict[str, Any], allowed: set[str], *, context: str +) -> None: + unknown = set(entry) - allowed + if unknown: + raise ModificationError( + f"Unknown keys in {context}: {', '.join(sorted(unknown))}." + ) + + +def load_modification_registry( + paths: str | Path | list[str | Path] | tuple[str | Path, ...], + *, + base: ModificationRegistry | None = None, +) -> ModificationRegistry: + """Merge one or more JSON registries into a fresh registry instance.""" + + registry = base.clone() if base else default_modification_registry() + path_list = [paths] if isinstance(paths, (str, Path)) else list(paths) + for raw_path in path_list: + path = Path(raw_path) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ModificationError(f"Could not read modification registry {path}: {exc}") from exc + except json.JSONDecodeError as exc: + 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, + ) + return registry diff --git a/src/moltranscode/peptide.py b/src/moltranscode/peptide.py index 7a0372c..0fd16ee 100644 --- a/src/moltranscode/peptide.py +++ b/src/moltranscode/peptide.py @@ -1,7 +1,7 @@ -"""Small, explicit peptide builder used for stereochemistry and selected PTMs. +"""Linear peptide builder with curated and user-defined modifications. -This module intentionally implements a documented ProForma-inspired subset. It -does not silently interpret unknown mass shifts or ambiguous modification sites. +The accepted notation is a documented, structure-resolving ProForma-inspired +subset. Unknown mass deltas and ambiguous sites are rejected instead of guessed. """ from __future__ import annotations @@ -9,6 +9,12 @@ from dataclasses import dataclass from .exceptions import InputFormatError, ModificationError +from .modifications import ( + STANDARD_AA, + ModificationRegistry, + default_modification_registry, + normalize_modification_name, +) SIDE_CHAINS = { "A": "C", @@ -31,36 +37,10 @@ "V": "C(C)C", } -STANDARD_AA = frozenset("ARNDCQEGHILKMFPSTWYV") - -MOD_ALIASES = { +BACKBONE_ALIASES = { "nme": "NMe", "n-methyl": "NMe", - "methyl": "Methyl", - "me": "Methyl", - "dimethyl": "Dimethyl", - "me2": "Dimethyl", - "trimethyl": "Trimethyl", - "me3": "Trimethyl", - "phospho": "Phospho", - "phosphorylation": "Phospho", - "oxidation": "Oxidation", - "acetyl": "Acetyl", - "carbamidomethyl": "Carbamidomethyl", - "cam": "Carbamidomethyl", -} - -SIDE_CHAIN_VARIANTS = { - ("S", "Phospho"): "COP(=O)(O)O", - ("T", "Phospho"): "[C@H](OP(=O)(O)O)C", - ("Y", "Phospho"): "CC1=CC=C(OP(=O)(O)O)C=C1", - ("M", "Oxidation"): "CCS(=O)C", - ("K", "Methyl"): "CCCCN(C)", - ("K", "Dimethyl"): "CCCCN(C)C", - ("K", "Trimethyl"): "CCCC[N+](C)(C)C", - ("K", "Acetyl"): "CCCCNC(=O)C", - ("C", "Methyl"): "CSC", - ("C", "Carbamidomethyl"): "CSCC(=O)N", + "backbone-n-methyl": "NMe", } @@ -81,44 +61,86 @@ def base(self) -> str: @dataclass(frozen=True) class PeptideSpec: residues: tuple[Residue, ...] - n_acetyl: bool = False - c_amide: bool = False + n_terminus: str | None = None + c_terminus: str | None = None @property def sequence(self) -> str: return "".join(residue.code for residue in self.residues) + @property + def n_acetyl(self) -> bool: + """Backward-compatible convenience property.""" -def _canonical_modification(name: str) -> str: - normalized = name.strip().lower().replace("_", "-") - try: - return MOD_ALIASES[normalized] - except KeyError as exc: - raise ModificationError( - f"Unsupported modification [{name}]. Run 'moltranscode formats' " - "for the supported subset." - ) from exc - - -def parse_peptide(text: str) -> PeptideSpec: - """Parse a sequence with D-residue case and a conservative modification subset.""" + return self.n_terminus == "Acetyl" + @property + def c_amide(self) -> bool: + """Backward-compatible convenience property.""" + + return self.c_terminus == "Amide" + + +def _extract_n_terminus(value: str, registry: ModificationRegistry) -> tuple[str, str | None]: + if value.startswith("["): + end = value.find("]-") + if end >= 0: + raw_name = value[1:end] + canonical, _ = registry.resolve_n_terminus(raw_name) + return value[end + 2 :], canonical + if "-" in value: + raw_name, remainder = value.split("-", 1) + try: + canonical, _ = registry.resolve_n_terminus(raw_name) + except ModificationError: + pass + else: + return remainder, canonical + return value, None + + +def _extract_c_terminus(value: str, registry: ModificationRegistry) -> tuple[str, str | None]: + marker = value.rfind("-[") + if marker >= 0 and value.endswith("]"): + raw_name = value[marker + 2 : -1] + canonical, _ = registry.resolve_c_terminus(raw_name) + return value[:marker], canonical + if "-" in value: + sequence, raw_name = value.rsplit("-", 1) + try: + canonical, _ = registry.resolve_c_terminus(raw_name) + except ModificationError: + pass + else: + return sequence, canonical + return value, None + + +def _canonical_modification( + name: str, + residue: str, + registry: ModificationRegistry, +) -> str: + normalized = normalize_modification_name(name) + if normalized in BACKBONE_ALIASES: + return BACKBONE_ALIASES[normalized] + canonical, _ = registry.resolve_side_chain(residue, name) + return canonical + + +def parse_peptide( + text: str, + registry: ModificationRegistry | None = None, +) -> PeptideSpec: + """Parse L/D residues, terminal groups, and site-specific modifications.""" + + active_registry = registry or default_modification_registry() value = "".join(str(text).split()) if not value: raise InputFormatError("Peptide sequence is empty.") - n_acetyl = False - c_amide = False - for prefix in ("[Acetyl]-", "Ac-"): - if value.lower().startswith(prefix.lower()): - n_acetyl = True - value = value[len(prefix) :] - break - for suffix in ("-[Amide]", "-NH2"): - if value.lower().endswith(suffix.lower()): - c_amide = True - value = value[: -len(suffix)] - break + value, n_terminus = _extract_n_terminus(value, active_registry) + value, c_terminus = _extract_c_terminus(value, active_registry) residues: list[Residue] = [] index = 0 @@ -135,17 +157,20 @@ def parse_peptide(text: str) -> PeptideSpec: end = value.find("]", index + 1) if end < 0: raise InputFormatError(f"Unclosed modification after residue {len(residues) + 1}.") - mods.append(_canonical_modification(value[index + 1 : end])) + mods.append(_canonical_modification(value[index + 1 : end], code, active_registry)) index = end + 1 residues.append(Residue(code=code, modifications=tuple(mods))) if not residues: raise InputFormatError("Peptide sequence is empty after terminal annotations.") - _validate_modifications(residues) - return PeptideSpec(tuple(residues), n_acetyl=n_acetyl, c_amide=c_amide) + _validate_modifications(residues, active_registry) + return PeptideSpec(tuple(residues), n_terminus=n_terminus, c_terminus=c_terminus) -def _validate_modifications(residues: list[Residue]) -> None: +def _validate_modifications( + residues: list[Residue], + registry: ModificationRegistry, +) -> None: for position, residue in enumerate(residues, start=1): mods = residue.modifications if len(mods) != len(set(mods)): @@ -159,16 +184,29 @@ def _validate_modifications(residues: list[Residue]) -> None: raise ModificationError( f"Residue {position} is Pro and has no backbone N-H available for N-methylation." ) - if side_mods and (residue.base, side_mods[0]) not in SIDE_CHAIN_VARIANTS: + if side_mods and residue.base in {"G", "P"}: + raise ModificationError( + f"Side-chain replacement [{side_mods[0]}] is not supported on {residue.base}." + ) + if side_mods and (residue.base, side_mods[0]) not in registry.side_chains: raise ModificationError( - f"Modification [{side_mods[0]}] is not supported on {residue.base} " + f"Modification [{side_mods[0]}] is not registered on {residue.base} " f"at residue {position}." ) -def _residue_fragment(residue: Residue, *, is_last: bool, c_amide: bool) -> str: +def _residue_fragment( + residue: Residue, + *, + is_last: bool, + c_terminus: str | None, + registry: ModificationRegistry, +) -> str: aa = residue.base - terminus = "C(=O)N" if is_last and c_amide else "C(=O)O" if is_last else "C(=O)" + if is_last and c_terminus: + terminus = registry.c_termini[c_terminus] + else: + terminus = "C(=O)O" if is_last else "C(=O)" n_atom = "N(C)" if "NMe" in residue.modifications else "N" if aa == "G": @@ -179,30 +217,40 @@ def _residue_fragment(residue: Residue, *, is_last: bool, c_amide: bool) -> str: return f"N1[C{chiral}H](CCC1){terminus}" side_mods = [mod for mod in residue.modifications if mod != "NMe"] - side_chain = SIDE_CHAIN_VARIANTS[(aa, side_mods[0])] if side_mods else SIDE_CHAINS[aa] - - if aa == "I": + if side_mods: + definition = registry.side_chains[(aa, side_mods[0])] + side_chain = definition.for_configuration( + is_d=residue.is_d, + residue=aa, + name=side_mods[0], + ) + elif aa == "I": side_chain = "[C@@H](C)CC" if not residue.is_d else "[C@H](C)CC" - elif aa == "T" and not side_mods: + elif aa == "T": side_chain = "[C@H](O)C" if not residue.is_d else "[C@@H](O)C" - elif aa == "T" and side_mods == ["Phospho"] and residue.is_d: - side_chain = "[C@@H](OP(=O)(O)O)C" + else: + side_chain = SIDE_CHAINS[aa] alpha = "@@" if not residue.is_d else "@" return f"{n_atom}[C{alpha}H]({side_chain}){terminus}" -def peptide_to_smiles(text: str) -> str: - """Build an isomeric SMILES string for a linear peptide and supported modifications.""" +def peptide_to_smiles( + text: str, + registry: ModificationRegistry | None = None, +) -> str: + """Build isomeric SMILES for a linear peptide and registered modifications.""" - spec = parse_peptide(text) + active_registry = registry or default_modification_registry() + spec = parse_peptide(text, active_registry) fragments = [ _residue_fragment( residue, is_last=index == len(spec.residues) - 1, - c_amide=spec.c_amide, + c_terminus=spec.c_terminus, + registry=active_registry, ) for index, residue in enumerate(spec.residues) ] smiles = "".join(fragments) - return f"CC(=O){smiles}" if spec.n_acetyl else smiles + return f"{active_registry.n_termini[spec.n_terminus]}{smiles}" if spec.n_terminus else smiles diff --git a/src/moltranscode/pubchem.py b/src/moltranscode/pubchem.py index 7bfa6ac..75ce14c 100644 --- a/src/moltranscode/pubchem.py +++ b/src/moltranscode/pubchem.py @@ -20,7 +20,7 @@ def resolve_name(name: str, output_format: str = "smiles", *, timeout: float = 1 raise InputFormatError("Chemical name is empty.") properties = "CanonicalSMILES,IsomericSMILES,ConnectivitySMILES,SMILES,InChI,InChIKey" url = f"{PUBCHEM_BASE}/compound/name/{quote(name.strip(), safe='')}/property/{properties}/JSON" - request = Request(url, headers={"User-Agent": "MolTranscode/0.1"}) + request = Request(url, headers={"User-Agent": "MolTranscode/0.2"}) try: with urlopen(request, timeout=timeout) as response: # noqa: S310 - fixed HTTPS host payload = json.load(response) diff --git a/tests/test_cli.py b/tests/test_cli.py index 48ca260..35483c0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import csv +import json from moltranscode.cli import main @@ -36,3 +37,37 @@ def test_cli_batch_keeps_failed_records(tmp_path): def test_cli_formats_json(capsys): assert main(["formats", "--json"]) == 0 assert '"peptide_modifications"' in capsys.readouterr().out + + +def test_cli_loads_custom_modification_registry(tmp_path, capsys): + config = tmp_path / "mods.json" + config.write_text( + json.dumps( + { + "side_chain_modifications": [ + { + "name": "AzidoLys", + "residue": "K", + "side_chain_smiles": "CCCCN=[N+]=[N-]", + } + ] + } + ), + encoding="utf-8", + ) + assert ( + main( + [ + "convert", + "AK[AzidoLys]", + "--from", + "peptide", + "--to", + "smiles", + "--mod-config", + str(config), + ] + ) + == 0 + ) + assert "[N-]" in capsys.readouterr().out diff --git a/tests/test_modifications.py b/tests/test_modifications.py new file mode 100644 index 0000000..d115628 --- /dev/null +++ b/tests/test_modifications.py @@ -0,0 +1,181 @@ +import json + +import pytest +from rdkit import Chem +from rdkit.Chem import rdMolDescriptors + +from moltranscode import convert +from moltranscode.exceptions import ModificationError +from moltranscode.modifications import ( + default_modification_registry, + load_modification_registry, +) +from moltranscode.peptide import parse_peptide, peptide_to_smiles + + +@pytest.mark.parametrize( + "peptide", + [ + "S[Sulfo]T[Acetyl]", + "M[Dioxidation]C[SulfinicAcid]", + "C[SulfonicAcid]C[Persulfide]", + "N[Deamidated]Q[UNIMOD:7]R[Citrullination]", + "K[Succinyl]K[Malonyl]K[Crotonyl]K[GlyGly]", + "Y[Nitro]", + "[Formyl]-ACD-[MethylEster]", + "[Myristoyl]-ACD-[Hydrazide]", + ], +) +def test_extended_builtin_modifications_are_sanitizable(peptide): + assert Chem.MolFromSmiles(peptide_to_smiles(peptide)) is not None + + +def test_unimod_accessions_resolve_by_site(): + spec = parse_peptide("S[UNIMOD:21]M[UNIMOD:35]R[UNIMOD:7]K[UNIMOD:121]") + assert [residue.modifications for residue in spec.residues] == [ + ("Phospho",), + ("Oxidation",), + ("Citrullination",), + ("GlyGly",), + ] + + +@pytest.mark.parametrize( + ("peptide", "formula"), + [ + ("K[Succinyl]", "C10H18N2O5"), + ("K[Malonyl]", "C9H16N2O5"), + ("K[Crotonyl]", "C10H18N2O3"), + ("K[GlyGly]", "C10H20N4O4"), + ("R[Citrullination]", "C6H13N3O3"), + ("C[SulfonicAcid]", "C3H7NO5S"), + ], +) +def test_selected_modification_formulas(peptide, formula): + mol = Chem.MolFromSmiles(peptide_to_smiles(peptide)) + assert rdMolDescriptors.CalcMolFormula(mol) == formula + + +def test_custom_registry_supports_side_chain_and_termini(tmp_path): + path = tmp_path / "custom_modifications.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "side_chain_modifications": [ + { + "name": "AzidoLys", + "residue": "K", + "side_chain_smiles": "CCCCN=[N+]=[N-]", + "aliases": ["AzK"], + } + ], + "n_terminal_modifications": [ + { + "name": "Propionyl", + "prefix_smiles": "CCC(=O)", + "aliases": ["Prop"], + } + ], + "c_terminal_modifications": [ + { + "name": "MethylAmide", + "terminus_smiles": "C(=O)NC", + "aliases": ["NHMe"], + } + ], + } + ), + encoding="utf-8", + ) + registry = load_modification_registry(path) + value = "Prop-AK[AzK]-NHMe" + smiles = peptide_to_smiles(value, registry) + assert Chem.MolFromSmiles(smiles) is not None + assert convert(value, "peptide", "smiles", modification_registry=registry) == Chem.MolToSmiles( + Chem.MolFromSmiles(smiles), isomericSmiles=True + ) + + +def test_registry_rejects_silent_override(tmp_path): + path = tmp_path / "override.json" + path.write_text( + json.dumps( + { + "side_chain_modifications": [ + {"name": "Acetyl", "residue": "K", "side_chain_smiles": "CCCCN"} + ] + } + ), + encoding="utf-8", + ) + with pytest.raises(ModificationError, match="override=true"): + load_modification_registry(path) + + +@pytest.mark.parametrize( + "entry", + [ + {"name": "Bad", "residue": "K", "side_chain_smiles": "CCCCN", "typo": 1}, + { + "name": "Bad", + "residue": "T", + "side_chain_smiles": "[C@H](Cl)C", + "d_side_chain_smiles": 42, + }, + ], +) +def test_registry_rejects_invalid_entry_fields(tmp_path, entry): + path = tmp_path / "invalid.json" + path.write_text( + json.dumps({"side_chain_modifications": [entry]}), + encoding="utf-8", + ) + with pytest.raises(ModificationError): + load_modification_registry(path) + + +@pytest.mark.parametrize("fragment", ["CCCCN.C", "CCCCN*", "C("]) +def test_registry_rejects_unsafe_or_invalid_fragments(tmp_path, fragment): + path = tmp_path / "unsafe.json" + path.write_text( + json.dumps( + { + "side_chain_modifications": [ + {"name": "Unsafe", "residue": "K", "side_chain_smiles": fragment} + ] + } + ), + encoding="utf-8", + ) + with pytest.raises(ModificationError): + load_modification_registry(path) + + +def test_custom_d_threonine_requires_explicit_beta_stereochemistry(tmp_path): + path = tmp_path / "d_threonine.json" + path.write_text( + json.dumps( + { + "side_chain_modifications": [ + {"name": "CustomThr", "residue": "T", "side_chain_smiles": "[C@H](Cl)C"} + ] + } + ), + encoding="utf-8", + ) + registry = load_modification_registry(path) + with pytest.raises(ModificationError, match="d_side_chain_smiles"): + peptide_to_smiles("t[CustomThr]", registry) + + +def test_registry_summary_contains_extended_modifications(): + summary = default_modification_registry().summary() + assert "Succinyl" in summary["side_chain_modifications"]["K"] + assert "Sulfo" in summary["side_chain_modifications"]["Y"] + assert summary["n_terminal_modifications"] == [ + "Acetyl", + "Formyl", + "Myristoyl", + "Palmitoyl", + ]