peff-digest 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- peff_digest/__init__.py +18 -0
- peff_digest/api.py +114 -0
- peff_digest/cli.py +108 -0
- peff_digest/config.py +83 -0
- peff_digest/digest.py +563 -0
- peff_digest/io.py +195 -0
- peff_digest-0.1.0.dist-info/METADATA +235 -0
- peff_digest-0.1.0.dist-info/RECORD +10 -0
- peff_digest-0.1.0.dist-info/WHEEL +4 -0
- peff_digest-0.1.0.dist-info/entry_points.txt +2 -0
peff_digest/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .api import digest
|
|
4
|
+
from .config import DigestConfig, InternalMod, TerminalMod
|
|
5
|
+
from .digest import Peptide, ann_to_map, digest_peff_sequence, get_cut_sites
|
|
6
|
+
from .io import read_sequences
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DigestConfig",
|
|
10
|
+
"InternalMod",
|
|
11
|
+
"Peptide",
|
|
12
|
+
"TerminalMod",
|
|
13
|
+
"ann_to_map",
|
|
14
|
+
"digest",
|
|
15
|
+
"digest_peff_sequence",
|
|
16
|
+
"get_cut_sites",
|
|
17
|
+
"read_sequences",
|
|
18
|
+
]
|
peff_digest/api.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""High-level digest API — single orchestration entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import itertools
|
|
7
|
+
import logging
|
|
8
|
+
import multiprocessing as mp
|
|
9
|
+
import tempfile
|
|
10
|
+
from functools import partial
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import polars as pl
|
|
14
|
+
from tqdm import tqdm
|
|
15
|
+
|
|
16
|
+
from peff_digest.config import DigestConfig
|
|
17
|
+
from peff_digest.io import _digest_batch_worker, read_sequences
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _write_csv(
|
|
23
|
+
results: list[list[tuple[str, str, str | None, int, float | None]]],
|
|
24
|
+
output_file: str,
|
|
25
|
+
) -> int:
|
|
26
|
+
"""Write digest results to CSV atomically. Returns the number of peptides written."""
|
|
27
|
+
output_path = Path(output_file)
|
|
28
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=output_path.parent, suffix=".tmp")
|
|
29
|
+
try:
|
|
30
|
+
with open(tmp_fd, "w", newline="") as f:
|
|
31
|
+
writer = csv.writer(f)
|
|
32
|
+
writer.writerow(["protein_id", "sequence", "variant", "length", "mass"])
|
|
33
|
+
for rows in results:
|
|
34
|
+
writer.writerows(rows)
|
|
35
|
+
Path(tmp_path).replace(output_path)
|
|
36
|
+
except Exception:
|
|
37
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
38
|
+
raise
|
|
39
|
+
return sum(len(r) for r in results)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _to_dataframe(
|
|
43
|
+
results: list[list[tuple[str, str, str | None, int, float | None]]],
|
|
44
|
+
) -> pl.DataFrame:
|
|
45
|
+
"""Convert digest results to a Polars DataFrame."""
|
|
46
|
+
protein_ids: list[str] = []
|
|
47
|
+
seqs: list[str] = []
|
|
48
|
+
variants: list[str | None] = []
|
|
49
|
+
lengths: list[int] = []
|
|
50
|
+
masses: list[float | None] = []
|
|
51
|
+
|
|
52
|
+
for rows in results:
|
|
53
|
+
for protein_id, seq, variant, length, mass in rows:
|
|
54
|
+
protein_ids.append(protein_id)
|
|
55
|
+
seqs.append(seq)
|
|
56
|
+
variants.append(variant)
|
|
57
|
+
lengths.append(length)
|
|
58
|
+
masses.append(mass)
|
|
59
|
+
|
|
60
|
+
return pl.DataFrame(
|
|
61
|
+
{
|
|
62
|
+
"protein_id": protein_ids,
|
|
63
|
+
"sequence": seqs,
|
|
64
|
+
"variant": variants,
|
|
65
|
+
"length": lengths,
|
|
66
|
+
"mass": masses,
|
|
67
|
+
},
|
|
68
|
+
schema={
|
|
69
|
+
"protein_id": pl.String,
|
|
70
|
+
"sequence": pl.String,
|
|
71
|
+
"variant": pl.String,
|
|
72
|
+
"length": pl.Int64,
|
|
73
|
+
"mass": pl.Float64,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def digest(
|
|
79
|
+
config: DigestConfig,
|
|
80
|
+
show_progress: bool = False,
|
|
81
|
+
write_file: bool = False,
|
|
82
|
+
) -> pl.DataFrame | None:
|
|
83
|
+
"""
|
|
84
|
+
Run a full digest pipeline.
|
|
85
|
+
|
|
86
|
+
If write_file is False (default), returns a Polars DataFrame with columns:
|
|
87
|
+
protein_id (str), sequence (str), variant (str | null), length (i64), mass (f64 | null).
|
|
88
|
+
If write_file is True, writes results to config.output_file as CSV and returns None.
|
|
89
|
+
"""
|
|
90
|
+
logger.info("Reading sequences from %s", config.input_file)
|
|
91
|
+
sequences, n_malformed = read_sequences(config.input_file)
|
|
92
|
+
skipped = f", {n_malformed} malformed entries skipped" if n_malformed else ""
|
|
93
|
+
logger.info("%d sequences loaded%s", len(sequences), skipped)
|
|
94
|
+
|
|
95
|
+
batches = list(itertools.batched(sequences, config.batch_size))
|
|
96
|
+
n_workers = config.workers or mp.cpu_count()
|
|
97
|
+
logger.info(
|
|
98
|
+
"Digesting with %d worker(s), batch size %d (%d batches total)",
|
|
99
|
+
n_workers,
|
|
100
|
+
config.batch_size,
|
|
101
|
+
len(batches),
|
|
102
|
+
)
|
|
103
|
+
worker = partial(_digest_batch_worker, config=config)
|
|
104
|
+
with mp.Pool(config.workers) as pool:
|
|
105
|
+
imap = pool.imap(worker, batches)
|
|
106
|
+
results = list(tqdm(imap, total=len(batches), disable=not show_progress, desc="Digesting", unit="batch"))
|
|
107
|
+
|
|
108
|
+
if write_file:
|
|
109
|
+
n_peptides = _write_csv(results, config.output_file)
|
|
110
|
+
logger.info("%d peptides written to %s", n_peptides, config.output_file)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
logger.info("%d peptides returned", sum(len(r) for r in results))
|
|
114
|
+
return _to_dataframe(results)
|
peff_digest/cli.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Command-line interface for peff-digest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import multiprocessing as mp
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import pydantic
|
|
11
|
+
|
|
12
|
+
from peff_digest.api import digest
|
|
13
|
+
from peff_digest.config import DigestConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
mp.freeze_support()
|
|
18
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
|
|
19
|
+
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="peff-digest",
|
|
22
|
+
description="Digest a PEFF or FASTA file and write peptides to CSV.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("--config", metavar="FILE", help="JSON or TOML config file")
|
|
25
|
+
parser.add_argument("--input-file", metavar="FILE", help="Input PEFF or FASTA file")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--output-file", metavar="FILE", default=argparse.SUPPRESS, help="Output CSV file (default: peptides.csv)"
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--cleave-on", metavar="AAS", default=argparse.SUPPRESS, help="Amino acids to cleave on (default: KR)"
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--missed-cleavages", type=int, metavar="N", default=argparse.SUPPRESS)
|
|
33
|
+
parser.add_argument("--semi-enzymatic", action="store_true", default=argparse.SUPPRESS)
|
|
34
|
+
parser.add_argument("--max-ptm-per-peptide", type=int, metavar="N", default=argparse.SUPPRESS)
|
|
35
|
+
parser.add_argument("--min-length", type=int, metavar="N", default=argparse.SUPPRESS)
|
|
36
|
+
parser.add_argument("--max-length", type=int, metavar="N", default=argparse.SUPPRESS)
|
|
37
|
+
parser.add_argument("--restrict-after", metavar="AAS", default=argparse.SUPPRESS)
|
|
38
|
+
parser.add_argument("--restrict-before", metavar="AAS", default=argparse.SUPPRESS)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--workers", type=int, metavar="N", default=argparse.SUPPRESS, help="Worker processes (default: all CPUs)"
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--batch-size", type=int, metavar="N", default=argparse.SUPPRESS, help="Sequences per worker batch (default: 1)"
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument("--min-mass", type=float, metavar="DA", default=argparse.SUPPRESS)
|
|
46
|
+
parser.add_argument("--max-mass", type=float, metavar="DA", default=argparse.SUPPRESS)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--drop-invalid-mass",
|
|
49
|
+
action="store_true",
|
|
50
|
+
default=argparse.SUPPRESS,
|
|
51
|
+
help="Exclude peptides whose mass cannot be computed",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--no-psi-mods",
|
|
55
|
+
dest="use_psi_mods",
|
|
56
|
+
action="store_false",
|
|
57
|
+
default=argparse.SUPPRESS,
|
|
58
|
+
help="Do not apply PSI-MOD (ModResPsi) annotations from the PEFF file",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--use-unimod-output",
|
|
62
|
+
action="store_true",
|
|
63
|
+
default=argparse.SUPPRESS,
|
|
64
|
+
help="Convert PSI-MOD mod tags to UniMod accessions in output; peptides with unmapped mods are dropped",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--no-simple-variants",
|
|
68
|
+
dest="include_simple_variants",
|
|
69
|
+
action="store_false",
|
|
70
|
+
default=argparse.SUPPRESS,
|
|
71
|
+
help="Skip VariantSimple (single AA substitution) entries",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--no-complex-variants",
|
|
75
|
+
dest="include_complex_variants",
|
|
76
|
+
action="store_false",
|
|
77
|
+
default=argparse.SUPPRESS,
|
|
78
|
+
help="Skip VariantComplex (multi-residue insertion/deletion/substitution) entries",
|
|
79
|
+
)
|
|
80
|
+
args = parser.parse_args()
|
|
81
|
+
|
|
82
|
+
# Build config: file first, then CLI overrides
|
|
83
|
+
cli_overrides = {
|
|
84
|
+
k: v for k, v in vars(args).items() if k != "config" and k in DigestConfig.model_fields and v is not None
|
|
85
|
+
}
|
|
86
|
+
cli_overrides = {k.replace("-", "_"): v for k, v in cli_overrides.items()}
|
|
87
|
+
|
|
88
|
+
if args.input_file:
|
|
89
|
+
cli_overrides["input_file"] = args.input_file
|
|
90
|
+
|
|
91
|
+
logger = logging.getLogger(__name__)
|
|
92
|
+
try:
|
|
93
|
+
if args.config:
|
|
94
|
+
config = DigestConfig.from_file(args.config, **cli_overrides)
|
|
95
|
+
logger.info("Config loaded from %s", args.config)
|
|
96
|
+
else:
|
|
97
|
+
config = DigestConfig(**cli_overrides)
|
|
98
|
+
except pydantic.ValidationError as exc:
|
|
99
|
+
for error in exc.errors():
|
|
100
|
+
loc = ".".join(str(x) for x in error["loc"])
|
|
101
|
+
logger.error(" %s: %s", loc, error["msg"])
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
|
|
104
|
+
digest(config, show_progress=True, write_file=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
peff_digest/config.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InternalMod(BaseModel, frozen=True):
|
|
12
|
+
modification: str
|
|
13
|
+
residue: str # one or more AAs (e.g. "C" or "KR") — mod applies to each
|
|
14
|
+
mod_type: Literal["fixed", "variable"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TerminalMod(BaseModel, frozen=True):
|
|
18
|
+
modification: str
|
|
19
|
+
position: Literal["nterm", "cterm"]
|
|
20
|
+
mod_type: Literal["fixed", "variable"]
|
|
21
|
+
residue: str | None = None # AA that must be at the terminus; None = any
|
|
22
|
+
protein_terminus: bool = False # True = only the protein N- or C-terminal peptide
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DigestConfig(BaseModel):
|
|
26
|
+
input_file: str = ""
|
|
27
|
+
output_file: str = "peptides.csv"
|
|
28
|
+
cleave_on: str = "KR"
|
|
29
|
+
missed_cleavages: int = Field(default=2, ge=0)
|
|
30
|
+
semi_enzymatic: bool = False
|
|
31
|
+
max_ptm_per_peptide: int = Field(default=2, ge=0)
|
|
32
|
+
min_length: int = Field(default=7, ge=1)
|
|
33
|
+
max_length: int = Field(default=40, ge=1)
|
|
34
|
+
restrict_after: str = "P"
|
|
35
|
+
restrict_before: str = ""
|
|
36
|
+
cterminal: bool = True
|
|
37
|
+
internal_mods: list[InternalMod] = Field(default_factory=list)
|
|
38
|
+
terminal_mods: list[TerminalMod] = Field(default_factory=list)
|
|
39
|
+
min_mass: float | None = Field(default=None, gt=0)
|
|
40
|
+
max_mass: float | None = Field(default=None, gt=0)
|
|
41
|
+
drop_invalid_mass: bool = False
|
|
42
|
+
use_mod_names: bool = False
|
|
43
|
+
use_psi_mods: bool = True
|
|
44
|
+
use_unimod_output: bool = False
|
|
45
|
+
include_simple_variants: bool = True
|
|
46
|
+
include_complex_variants: bool = True
|
|
47
|
+
workers: int | None = Field(default=None, ge=1)
|
|
48
|
+
batch_size: int = Field(default=1, ge=1)
|
|
49
|
+
|
|
50
|
+
@field_validator("input_file")
|
|
51
|
+
@classmethod
|
|
52
|
+
def input_file_must_exist(cls, v: str) -> str:
|
|
53
|
+
if not v:
|
|
54
|
+
return v
|
|
55
|
+
if not Path(v).exists():
|
|
56
|
+
raise ValueError(f"input_file does not exist: {v}")
|
|
57
|
+
return v
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def _parse_file(cls, path: Path) -> dict[str, Any]:
|
|
61
|
+
if path.suffix == ".toml":
|
|
62
|
+
with open(path, "rb") as f:
|
|
63
|
+
return tomllib.load(f)
|
|
64
|
+
with open(path) as f:
|
|
65
|
+
return json.load(f)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_file(cls, path: str | Path, **overrides: Any) -> DigestConfig:
|
|
69
|
+
data = cls._parse_file(Path(path))
|
|
70
|
+
return cls(**{**data, **overrides})
|
|
71
|
+
|
|
72
|
+
def to_file(self, path: str | Path) -> None:
|
|
73
|
+
p = Path(path)
|
|
74
|
+
data = self.model_dump()
|
|
75
|
+
if p.suffix == ".toml":
|
|
76
|
+
import tomli_w
|
|
77
|
+
|
|
78
|
+
data = {k: v for k, v in data.items() if v is not None}
|
|
79
|
+
with open(p, "wb") as f:
|
|
80
|
+
tomli_w.dump(data, f)
|
|
81
|
+
else:
|
|
82
|
+
with open(p, "w") as f:
|
|
83
|
+
json.dump(data, f, indent=2)
|
peff_digest/digest.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PEFF-aware protein digest.
|
|
3
|
+
|
|
4
|
+
Produces all peptide ProFormaAnnotation variants from a PEFF SequenceEntry by:
|
|
5
|
+
1. Generating one sequence per variant (canonical + each VariantSimple/VariantComplex
|
|
6
|
+
applied independently — no combinations).
|
|
7
|
+
2. Digesting each variant sequence with the standard enzymatic rules.
|
|
8
|
+
3. For each peptide span, enumerating PEFF-annotated PTM combinations up to
|
|
9
|
+
max_ptm_per_peptide (0 = no PEFF PTMs applied, only unmodified peptides returned).
|
|
10
|
+
|
|
11
|
+
Skipped: Processed entries (signal peptide / mature-chain trimming).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import itertools
|
|
18
|
+
from bisect import bisect_left, bisect_right
|
|
19
|
+
from collections.abc import Generator
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
import pefftacular as pf
|
|
23
|
+
import peptacular as pt
|
|
24
|
+
import psimodpy
|
|
25
|
+
from psimodpy import AminoAcid, PsiModDatabase, TermSpec
|
|
26
|
+
|
|
27
|
+
from peff_digest.config import DigestConfig
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_cut_sites(
|
|
31
|
+
protein_sequence: str,
|
|
32
|
+
cleave_on: set[str],
|
|
33
|
+
restrict_after: set[str],
|
|
34
|
+
restrict_before: set[str],
|
|
35
|
+
cterminal: bool,
|
|
36
|
+
) -> list[int]:
|
|
37
|
+
seq_len = len(protein_sequence)
|
|
38
|
+
cut_sites: list[int] = [0]
|
|
39
|
+
for i, aa in enumerate(protein_sequence):
|
|
40
|
+
if aa not in cleave_on:
|
|
41
|
+
continue
|
|
42
|
+
cut_pos = (i + 1) if cterminal else i
|
|
43
|
+
if cut_pos == 0 or cut_pos == seq_len:
|
|
44
|
+
continue
|
|
45
|
+
if restrict_after and protein_sequence[cut_pos] in restrict_after:
|
|
46
|
+
continue
|
|
47
|
+
if restrict_before and protein_sequence[cut_pos - 1] in restrict_before:
|
|
48
|
+
continue
|
|
49
|
+
cut_sites.append(cut_pos)
|
|
50
|
+
cut_sites.append(seq_len)
|
|
51
|
+
return cut_sites
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Internal helpers
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class _Variant:
|
|
61
|
+
"""A concrete protein sequence derived from one PEFF annotation event."""
|
|
62
|
+
|
|
63
|
+
sequence: str
|
|
64
|
+
# Maps 1-based PEFF position → 0-based position in this variant's sequence.
|
|
65
|
+
# None means the position was deleted by a VariantComplex.
|
|
66
|
+
pos_map: dict[int, int | None]
|
|
67
|
+
source: pf.VariantSimple | pf.VariantComplex | None = None
|
|
68
|
+
source_orig: str = "" # original AA(s) at the variant site
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _canonical(sequence: str) -> _Variant:
|
|
72
|
+
return _Variant(sequence, {i + 1: i for i in range(len(sequence))})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _apply_simple(sequence: str, v: pf.VariantSimple) -> _Variant:
|
|
76
|
+
"""Single AA substitution — sequence length is unchanged."""
|
|
77
|
+
pos0 = int(v.position) - 1 # 0-based
|
|
78
|
+
if pos0 < 0 or pos0 >= len(sequence):
|
|
79
|
+
return _canonical(sequence) # out-of-range annotation → skip
|
|
80
|
+
new_seq = sequence[:pos0] + v.new_amino_acid + sequence[pos0 + 1 :]
|
|
81
|
+
pos_map: dict[int, int | None] = {i + 1: i for i in range(len(sequence))}
|
|
82
|
+
return _Variant(new_seq, pos_map, source=v, source_orig=sequence[pos0])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _apply_complex(sequence: str, v: pf.VariantComplex) -> _Variant:
|
|
86
|
+
"""Multi-residue variant — sequence length may change."""
|
|
87
|
+
start0 = int(v.start_pos) - 1 # 0-based inclusive
|
|
88
|
+
end0 = int(v.end_pos) # 0-based exclusive (PEFF end is 1-based inclusive)
|
|
89
|
+
if start0 < 0 or end0 > len(sequence) or start0 > end0:
|
|
90
|
+
return _canonical(sequence) # out-of-range annotation → skip
|
|
91
|
+
|
|
92
|
+
orig_seq = sequence[start0:end0]
|
|
93
|
+
new_seq = sequence[:start0] + v.new_sequence + sequence[end0:]
|
|
94
|
+
delta = len(v.new_sequence) - (end0 - start0)
|
|
95
|
+
|
|
96
|
+
pos_map: dict[int, int | None] = {}
|
|
97
|
+
for peff_pos in range(1, len(sequence) + 1):
|
|
98
|
+
orig0 = peff_pos - 1
|
|
99
|
+
if orig0 < start0:
|
|
100
|
+
pos_map[peff_pos] = orig0
|
|
101
|
+
elif orig0 < end0:
|
|
102
|
+
pos_map[peff_pos] = None # consumed by the variant
|
|
103
|
+
else:
|
|
104
|
+
pos_map[peff_pos] = orig0 + delta
|
|
105
|
+
|
|
106
|
+
return _Variant(new_seq, pos_map, source=v, source_orig=orig_seq)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _variant_in_span(variant: _Variant, pep_start: int, pep_end: int) -> bool:
|
|
110
|
+
"""Return True if the variant's mutation site overlaps the peptide span [pep_start, pep_end)."""
|
|
111
|
+
if variant.source is None:
|
|
112
|
+
return False
|
|
113
|
+
if isinstance(variant.source, pf.VariantSimple):
|
|
114
|
+
mapped = variant.pos_map.get(int(variant.source.position))
|
|
115
|
+
if mapped is None:
|
|
116
|
+
return False
|
|
117
|
+
return pep_start <= mapped < pep_end
|
|
118
|
+
# VariantComplex: the new sequence occupies [start0, start0+len(new_sequence))
|
|
119
|
+
v = variant.source
|
|
120
|
+
start0 = int(v.start_pos) - 1
|
|
121
|
+
new_len = len(v.new_sequence)
|
|
122
|
+
# After complex variant, the inserted region sits at [start0, start0+new_len)
|
|
123
|
+
# in the variant sequence. Check overlap with peptide span.
|
|
124
|
+
var_end = start0 + new_len
|
|
125
|
+
return pep_start < var_end and start0 < pep_end
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _mods_in_span(
|
|
129
|
+
variant: _Variant,
|
|
130
|
+
span_start: int, # 0-based in variant.sequence
|
|
131
|
+
span_end: int, # 0-based exclusive
|
|
132
|
+
mod_entries: list[pf.ModResPsi | pf.ModResUnimod],
|
|
133
|
+
use_mod_names: bool = False,
|
|
134
|
+
psi_db: PsiModDatabase | None = None,
|
|
135
|
+
protein_len: int = 0,
|
|
136
|
+
) -> list[tuple[int, str]]:
|
|
137
|
+
"""Return (0-based peptide-local position, ProForma tag) for each PEFF mod in the span.
|
|
138
|
+
|
|
139
|
+
When *psi_db* is provided each mod is looked up to:
|
|
140
|
+
- Validate the origin residue against the (possibly variant-mutated) sequence.
|
|
141
|
+
Mods whose origin does not match the current residue are silently dropped.
|
|
142
|
+
- Promote terminus-specific mods (TermSpec.N_TERM / C_TERM) to the appropriate
|
|
143
|
+
terminal sentinel position (_NTERM_POS / _CTERM_POS) when the mod sits at the
|
|
144
|
+
protein terminus and the peptide spans that terminus.
|
|
145
|
+
"""
|
|
146
|
+
result: list[tuple[int, str]] = []
|
|
147
|
+
for mod in mod_entries:
|
|
148
|
+
for peff_pos in mod.positions:
|
|
149
|
+
try:
|
|
150
|
+
peff_pos_int = int(peff_pos)
|
|
151
|
+
except (ValueError, TypeError):
|
|
152
|
+
continue # skip unknown positions (e.g. '?')
|
|
153
|
+
local0 = variant.pos_map.get(peff_pos_int)
|
|
154
|
+
if local0 is None:
|
|
155
|
+
continue
|
|
156
|
+
pep_local = local0 - span_start
|
|
157
|
+
if not (0 <= pep_local < (span_end - span_start)):
|
|
158
|
+
continue
|
|
159
|
+
tag = mod.name if use_mod_names else mod.accession
|
|
160
|
+
|
|
161
|
+
if psi_db is not None:
|
|
162
|
+
entry = psi_db.get_by_id(mod.accession)
|
|
163
|
+
if entry is not None:
|
|
164
|
+
# Validate: origin residue must match the (possibly mutated) residue.
|
|
165
|
+
origin = entry.origin
|
|
166
|
+
if isinstance(origin, AminoAcid) and origin != AminoAcid.ANY:
|
|
167
|
+
if variant.sequence[local0] != str(origin):
|
|
168
|
+
continue # residue changed by variant — drop this mod
|
|
169
|
+
|
|
170
|
+
# Terminal promotion: convert positional mod to terminal sentinel.
|
|
171
|
+
# If a mod is terminal-specific but the conditions aren't met, drop it —
|
|
172
|
+
# it cannot be validly applied as an internal modification.
|
|
173
|
+
if entry.term_spec == TermSpec.N_TERM:
|
|
174
|
+
if local0 == 0 and span_start == 0:
|
|
175
|
+
result.append((_NTERM_POS, tag))
|
|
176
|
+
continue
|
|
177
|
+
if entry.term_spec == TermSpec.C_TERM:
|
|
178
|
+
if local0 == protein_len - 1 and span_end == protein_len:
|
|
179
|
+
result.append((_CTERM_POS, tag))
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
result.append((pep_local, tag))
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
_NTERM_POS = -1 # sentinel for N-terminal mods in the applicable list
|
|
187
|
+
_CTERM_POS = -2 # sentinel for C-terminal mods in the applicable list
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _apply_mod(ann: pt.ProFormaAnnotation, pos: int, tag: str, seq_len: int) -> None:
|
|
191
|
+
if pos == _NTERM_POS:
|
|
192
|
+
ann.append_nterm_mod(tag)
|
|
193
|
+
elif pos == _CTERM_POS:
|
|
194
|
+
ann.append_cterm_mod(tag)
|
|
195
|
+
else:
|
|
196
|
+
ann.append_internal_mod_at_index(pos, tag)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _yield_mod_variants(
|
|
200
|
+
base_sequence: str,
|
|
201
|
+
applicable: list[tuple[int, str]],
|
|
202
|
+
max_ptm: int,
|
|
203
|
+
) -> list[pt.ProFormaAnnotation]:
|
|
204
|
+
"""
|
|
205
|
+
Yield ProFormaAnnotation objects for the base peptide plus every combination
|
|
206
|
+
of 1..max_ptm mods applied to it.
|
|
207
|
+
|
|
208
|
+
Sentinel positions: _NTERM_POS (-1) = N-terminal, _CTERM_POS (-2) = C-terminal.
|
|
209
|
+
"""
|
|
210
|
+
base = pt.parse(base_sequence)
|
|
211
|
+
variants: list[pt.ProFormaAnnotation] = [base]
|
|
212
|
+
|
|
213
|
+
if not applicable or max_ptm <= 0:
|
|
214
|
+
return variants
|
|
215
|
+
|
|
216
|
+
seq_len = len(base_sequence)
|
|
217
|
+
for n in range(1, min(max_ptm, len(applicable)) + 1):
|
|
218
|
+
for combo in itertools.combinations(applicable, n):
|
|
219
|
+
positions = [pos for pos, _ in combo]
|
|
220
|
+
if len(positions) != len(set(positions)):
|
|
221
|
+
continue # multiple mods at the same site — skip this combo
|
|
222
|
+
ann = pt.parse(base_sequence)
|
|
223
|
+
for pos, tag in combo:
|
|
224
|
+
_apply_mod(ann, pos, tag, seq_len)
|
|
225
|
+
variants.append(ann)
|
|
226
|
+
|
|
227
|
+
return variants
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _count_mods(ann: pt.ProFormaAnnotation) -> int:
|
|
231
|
+
"""Return the total number of modifications on an annotation."""
|
|
232
|
+
count = 0
|
|
233
|
+
if ann._nterm_mods:
|
|
234
|
+
count += sum(ann._nterm_mods.values())
|
|
235
|
+
if ann._cterm_mods:
|
|
236
|
+
count += sum(ann._cterm_mods.values())
|
|
237
|
+
if ann._internal_mods:
|
|
238
|
+
for mods_dict in ann._internal_mods.values():
|
|
239
|
+
count += sum(mods_dict.values())
|
|
240
|
+
return count
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _get_occupied_positions(ann: pt.ProFormaAnnotation) -> set[int]:
|
|
244
|
+
"""Return the set of positions (including terminal sentinels) already carrying a mod."""
|
|
245
|
+
positions: set[int] = set()
|
|
246
|
+
if ann._nterm_mods:
|
|
247
|
+
positions.add(_NTERM_POS)
|
|
248
|
+
if ann._cterm_mods:
|
|
249
|
+
positions.add(_CTERM_POS)
|
|
250
|
+
if ann._internal_mods:
|
|
251
|
+
positions.update(ann._internal_mods.keys())
|
|
252
|
+
return positions
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _yield_user_mod_variants(
|
|
256
|
+
base_ann: pt.ProFormaAnnotation,
|
|
257
|
+
user_applicable: list[tuple[int, str]],
|
|
258
|
+
remaining: int,
|
|
259
|
+
) -> list[pt.ProFormaAnnotation]:
|
|
260
|
+
"""Return annotation variants produced by layering user mods on top of *base_ann*.
|
|
261
|
+
|
|
262
|
+
Works like :func:`_yield_mod_variants` but starts from an existing annotation
|
|
263
|
+
(which may already carry PEFF mods) rather than a bare sequence string.
|
|
264
|
+
Uses :func:`copy.deepcopy` to avoid mutating the shared base.
|
|
265
|
+
"""
|
|
266
|
+
variants: list[pt.ProFormaAnnotation] = [copy.deepcopy(base_ann)]
|
|
267
|
+
if not user_applicable or remaining <= 0:
|
|
268
|
+
return variants
|
|
269
|
+
|
|
270
|
+
seq_len = len(base_ann.stripped_sequence)
|
|
271
|
+
for n in range(1, min(remaining, len(user_applicable)) + 1):
|
|
272
|
+
for combo in itertools.combinations(user_applicable, n):
|
|
273
|
+
positions = [pos for pos, _ in combo]
|
|
274
|
+
if len(positions) != len(set(positions)):
|
|
275
|
+
continue # multiple mods at the same site — skip
|
|
276
|
+
ann = copy.deepcopy(base_ann)
|
|
277
|
+
for pos, tag in combo:
|
|
278
|
+
_apply_mod(ann, pos, tag, seq_len)
|
|
279
|
+
variants.append(ann)
|
|
280
|
+
return variants
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Public API
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass
|
|
289
|
+
class Peptide:
|
|
290
|
+
proforma: pt.ProFormaAnnotation
|
|
291
|
+
missed_cleavages: int
|
|
292
|
+
semi_enzymatic: bool
|
|
293
|
+
is_protein_nterm: bool = False
|
|
294
|
+
is_protein_cterm: bool = False
|
|
295
|
+
variant: pf.VariantSimple | pf.VariantComplex | None = None
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def sequence(self) -> str:
|
|
299
|
+
return self.proforma.stripped_sequence
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def mass(self) -> float:
|
|
303
|
+
return self.proforma.neutral_mass()
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def mod_map(self) -> dict[int, str]:
|
|
307
|
+
_, mod_map = ann_to_map(self.proforma)
|
|
308
|
+
return mod_map
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def digest_peff_sequence(
|
|
312
|
+
peff_entry: pf.SequenceEntry,
|
|
313
|
+
config: DigestConfig,
|
|
314
|
+
psi_db: PsiModDatabase | None = None,
|
|
315
|
+
) -> Generator[Peptide, None, None]:
|
|
316
|
+
"""
|
|
317
|
+
Digest a PEFF SequenceEntry and return all peptide variants as Peptide objects.
|
|
318
|
+
|
|
319
|
+
Each PEFF VariantSimple / VariantComplex is applied independently (not combined).
|
|
320
|
+
PEFF PTMs (ModResPsi) are applied in combinations of up to
|
|
321
|
+
``config.max_ptm_per_peptide`` per peptide. Pass 0 to skip PEFF PTMs entirely.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
if psi_db is None:
|
|
325
|
+
psi_db = psimodpy.load()
|
|
326
|
+
|
|
327
|
+
sequence = peff_entry.sequence
|
|
328
|
+
_min = config.min_length if config.min_length is not None else 0
|
|
329
|
+
_max = config.max_length if config.max_length is not None else len(sequence)
|
|
330
|
+
|
|
331
|
+
restrict_after: set[str] = set(config.restrict_after)
|
|
332
|
+
restrict_before: set[str] = set(config.restrict_before)
|
|
333
|
+
cleave_on: set[str] = set(config.cleave_on)
|
|
334
|
+
|
|
335
|
+
internal_mods = config.internal_mods or []
|
|
336
|
+
terminal_mods = config.terminal_mods or []
|
|
337
|
+
|
|
338
|
+
fixed_mods: dict[str, str] = {}
|
|
339
|
+
variable_mods: dict[str, list[str]] = {}
|
|
340
|
+
for m in internal_mods:
|
|
341
|
+
for aa in m.residue:
|
|
342
|
+
if m.mod_type == "fixed":
|
|
343
|
+
fixed_mods[aa] = m.modification
|
|
344
|
+
else:
|
|
345
|
+
variable_mods.setdefault(aa, []).append(m.modification)
|
|
346
|
+
|
|
347
|
+
# Collect all PEFF mod annotations (PSI-MOD)
|
|
348
|
+
all_mods: list[pf.ModResPsi | pf.ModResUnimod] = []
|
|
349
|
+
if config.use_psi_mods:
|
|
350
|
+
all_mods.extend(peff_entry.mod_res_psi)
|
|
351
|
+
|
|
352
|
+
# One variant per PEFF event (canonical + each simple/complex independently)
|
|
353
|
+
variants: list[_Variant] = [_canonical(sequence)]
|
|
354
|
+
if config.include_simple_variants:
|
|
355
|
+
for v in peff_entry.variant_simple:
|
|
356
|
+
variants.append(_apply_simple(sequence, v))
|
|
357
|
+
if config.include_complex_variants:
|
|
358
|
+
for v in peff_entry.variant_complex:
|
|
359
|
+
variants.append(_apply_complex(sequence, v))
|
|
360
|
+
|
|
361
|
+
max_ptm_per_peptide = config.max_ptm_per_peptide
|
|
362
|
+
use_mod_names = config.use_mod_names
|
|
363
|
+
semi_enzymatic = config.semi_enzymatic
|
|
364
|
+
missed_cleavages = config.missed_cleavages
|
|
365
|
+
|
|
366
|
+
seen: set[str] = set()
|
|
367
|
+
|
|
368
|
+
for variant in variants:
|
|
369
|
+
vseq = variant.sequence
|
|
370
|
+
seq_len = len(vseq)
|
|
371
|
+
|
|
372
|
+
cut_sites = get_cut_sites(vseq, cleave_on, restrict_after, restrict_before, config.cterminal)
|
|
373
|
+
n_cuts = len(cut_sites)
|
|
374
|
+
|
|
375
|
+
def _process_span(
|
|
376
|
+
start: int,
|
|
377
|
+
end: int,
|
|
378
|
+
mc: int,
|
|
379
|
+
is_semi: bool,
|
|
380
|
+
_vseq: str = vseq,
|
|
381
|
+
_variant: _Variant = variant,
|
|
382
|
+
_seq_len: int = seq_len,
|
|
383
|
+
) -> list[Peptide]:
|
|
384
|
+
length = end - start
|
|
385
|
+
if length < _min or length > _max:
|
|
386
|
+
return []
|
|
387
|
+
pep_seq = _vseq[start:end]
|
|
388
|
+
is_protein_nterm = start == 0
|
|
389
|
+
is_protein_cterm = end == _seq_len
|
|
390
|
+
|
|
391
|
+
# ----------------------------------------------------------------
|
|
392
|
+
# Phase 1: PEFF mods only
|
|
393
|
+
# When psi_db is provided, _mods_in_span validates each mod against
|
|
394
|
+
# the (possibly variant-mutated) residue and promotes terminus-specific
|
|
395
|
+
# mods to terminal sentinels.
|
|
396
|
+
# ----------------------------------------------------------------
|
|
397
|
+
peff_applicable = _mods_in_span(_variant, start, end, all_mods, use_mod_names, psi_db, _seq_len)
|
|
398
|
+
|
|
399
|
+
# Drop PEFF mods at positions reserved for fixed user mods.
|
|
400
|
+
if fixed_mods:
|
|
401
|
+
fixed_positions = {i for i, res in enumerate(pep_seq) if res in fixed_mods}
|
|
402
|
+
peff_applicable = [(pos, tag) for pos, tag in peff_applicable if pos not in fixed_positions]
|
|
403
|
+
|
|
404
|
+
span_variant = _variant.source if _variant_in_span(_variant, start, end) else None
|
|
405
|
+
try:
|
|
406
|
+
peff_variants = _yield_mod_variants(pep_seq, peff_applicable, max_ptm_per_peptide)
|
|
407
|
+
except ValueError:
|
|
408
|
+
return [] # unparseable sequence (e.g. contains '*' or spaces) — skip span
|
|
409
|
+
|
|
410
|
+
# ----------------------------------------------------------------
|
|
411
|
+
# Build user variable mod candidates once for this span.
|
|
412
|
+
# These are filtered per-peff-variant below to exclude occupied positions.
|
|
413
|
+
# ----------------------------------------------------------------
|
|
414
|
+
user_variable: list[tuple[int, str]] = []
|
|
415
|
+
if variable_mods:
|
|
416
|
+
for aa, tags in variable_mods.items():
|
|
417
|
+
for i, res in enumerate(pep_seq):
|
|
418
|
+
if res == aa:
|
|
419
|
+
for tag in tags:
|
|
420
|
+
user_variable.append((i, tag))
|
|
421
|
+
if terminal_mods:
|
|
422
|
+
for tm in terminal_mods:
|
|
423
|
+
if tm.mod_type != "variable":
|
|
424
|
+
continue
|
|
425
|
+
if tm.position == "nterm":
|
|
426
|
+
is_term = is_protein_nterm if tm.protein_terminus else True
|
|
427
|
+
term_aa = pep_seq[0]
|
|
428
|
+
sentinel = _NTERM_POS
|
|
429
|
+
else:
|
|
430
|
+
is_term = is_protein_cterm if tm.protein_terminus else True
|
|
431
|
+
term_aa = pep_seq[-1]
|
|
432
|
+
sentinel = _CTERM_POS
|
|
433
|
+
if not is_term:
|
|
434
|
+
continue
|
|
435
|
+
if tm.residue is not None and term_aa not in tm.residue:
|
|
436
|
+
continue
|
|
437
|
+
user_variable.append((sentinel, tm.modification))
|
|
438
|
+
|
|
439
|
+
# ----------------------------------------------------------------
|
|
440
|
+
# Phase 2: for each validated PEFF variant, apply user mods.
|
|
441
|
+
# ----------------------------------------------------------------
|
|
442
|
+
span_results: list[Peptide] = []
|
|
443
|
+
for peff_ann in peff_variants:
|
|
444
|
+
existing = _count_mods(peff_ann)
|
|
445
|
+
remaining = max_ptm_per_peptide - existing
|
|
446
|
+
occupied = _get_occupied_positions(peff_ann)
|
|
447
|
+
|
|
448
|
+
# Exclude positions already carrying a PEFF mod or reserved by fixed mods.
|
|
449
|
+
filtered_user = [
|
|
450
|
+
(pos, tag)
|
|
451
|
+
for pos, tag in user_variable
|
|
452
|
+
if pos not in occupied and (not fixed_mods or pos not in fixed_positions)
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
user_variants = _yield_user_mod_variants(peff_ann, filtered_user, remaining)
|
|
456
|
+
|
|
457
|
+
for ann in user_variants:
|
|
458
|
+
# Apply fixed internal mods unconditionally (positions were excluded
|
|
459
|
+
# from PEFF and user variable pools so no double-mod conflict).
|
|
460
|
+
if fixed_mods:
|
|
461
|
+
for aa, mod_str in fixed_mods.items():
|
|
462
|
+
for i, res in enumerate(pep_seq):
|
|
463
|
+
if res == aa:
|
|
464
|
+
ann.append_internal_mod_at_index(i, mod_str)
|
|
465
|
+
# Apply fixed terminal mods.
|
|
466
|
+
if terminal_mods:
|
|
467
|
+
for tm in terminal_mods:
|
|
468
|
+
if tm.mod_type != "fixed":
|
|
469
|
+
continue
|
|
470
|
+
if tm.position == "nterm":
|
|
471
|
+
is_term = is_protein_nterm if tm.protein_terminus else True
|
|
472
|
+
term_aa = pep_seq[0]
|
|
473
|
+
else:
|
|
474
|
+
is_term = is_protein_cterm if tm.protein_terminus else True
|
|
475
|
+
term_aa = pep_seq[-1]
|
|
476
|
+
if not is_term:
|
|
477
|
+
continue
|
|
478
|
+
if tm.residue is not None and term_aa not in tm.residue:
|
|
479
|
+
continue
|
|
480
|
+
if tm.position == "nterm":
|
|
481
|
+
ann.append_nterm_mod(tm.modification)
|
|
482
|
+
else:
|
|
483
|
+
ann.append_cterm_mod(tm.modification)
|
|
484
|
+
span_results.append(
|
|
485
|
+
Peptide(
|
|
486
|
+
proforma=ann,
|
|
487
|
+
missed_cleavages=mc,
|
|
488
|
+
semi_enzymatic=is_semi,
|
|
489
|
+
is_protein_nterm=is_protein_nterm,
|
|
490
|
+
is_protein_cterm=is_protein_cterm,
|
|
491
|
+
variant=span_variant,
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
return span_results
|
|
495
|
+
|
|
496
|
+
def _yield_deduped(peptides: list[Peptide]) -> Generator[Peptide, None, None]:
|
|
497
|
+
for peptide in peptides:
|
|
498
|
+
key = str(peptide.proforma)
|
|
499
|
+
if key not in seen:
|
|
500
|
+
seen.add(key)
|
|
501
|
+
yield peptide
|
|
502
|
+
|
|
503
|
+
# Fully enzymatic peptides
|
|
504
|
+
for i in range(n_cuts - 1):
|
|
505
|
+
for j in range(i + 1, min(i + 2 + missed_cleavages, n_cuts)):
|
|
506
|
+
yield from _yield_deduped(_process_span(cut_sites[i], cut_sites[j], mc=j - i - 1, is_semi=False))
|
|
507
|
+
|
|
508
|
+
# Semi-enzymatic peptides (one free end)
|
|
509
|
+
if semi_enzymatic:
|
|
510
|
+
cut_set = set(cut_sites)
|
|
511
|
+
for enz_idx, enz_pos in enumerate(cut_sites):
|
|
512
|
+
# Right-open: enzymatic N-term, non-enzymatic C-term
|
|
513
|
+
for end in range(enz_pos + _min, seq_len + 1):
|
|
514
|
+
if _max and (end - enz_pos) > _max:
|
|
515
|
+
break
|
|
516
|
+
if end in cut_set:
|
|
517
|
+
continue
|
|
518
|
+
mc = bisect_right(cut_sites, end - 1) - enz_idx - 1
|
|
519
|
+
if mc <= missed_cleavages:
|
|
520
|
+
yield from _yield_deduped(_process_span(enz_pos, end, mc=mc, is_semi=True))
|
|
521
|
+
|
|
522
|
+
# Left-open: non-enzymatic N-term, enzymatic C-term
|
|
523
|
+
for start in range(enz_pos - _min, -1, -1):
|
|
524
|
+
if _max and (enz_pos - start) > _max:
|
|
525
|
+
break
|
|
526
|
+
if start in cut_set:
|
|
527
|
+
continue
|
|
528
|
+
mc = enz_idx - bisect_left(cut_sites, start + 1)
|
|
529
|
+
if mc <= missed_cleavages:
|
|
530
|
+
yield from _yield_deduped(_process_span(start, enz_pos, mc=mc, is_semi=True))
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def ann_to_map(ann: pt.ProFormaAnnotation) -> tuple[str, dict[int, str]]:
|
|
534
|
+
|
|
535
|
+
mod_map: dict[int, str] = {}
|
|
536
|
+
|
|
537
|
+
# Process C-terminal modifications
|
|
538
|
+
if ann._cterm_mods is not None:
|
|
539
|
+
for mod_name, count in ann._cterm_mods.items():
|
|
540
|
+
if count != 1:
|
|
541
|
+
raise ValueError("format does not support modification multipliers.")
|
|
542
|
+
mod_map[-2] = mod_name
|
|
543
|
+
|
|
544
|
+
# Process N-terminal modifications
|
|
545
|
+
if ann._nterm_mods is not None:
|
|
546
|
+
for mod_name, count in ann._nterm_mods.items():
|
|
547
|
+
if count != 1:
|
|
548
|
+
raise ValueError("format does not support modification multipliers.")
|
|
549
|
+
mod_map[-1] = mod_name
|
|
550
|
+
|
|
551
|
+
# Process internal modifications
|
|
552
|
+
if ann._internal_mods is not None:
|
|
553
|
+
for index, mods_dict in ann._internal_mods.items():
|
|
554
|
+
if len(mods_dict) > 1:
|
|
555
|
+
raise ValueError("format does not support multiple modifications at the same site.")
|
|
556
|
+
for mod_name, count in mods_dict.items():
|
|
557
|
+
if count != 1:
|
|
558
|
+
raise ValueError("format does not support modification multipliers.")
|
|
559
|
+
mod_map[index] = mod_name
|
|
560
|
+
|
|
561
|
+
unmod_sequence = ann.stripped_sequence
|
|
562
|
+
|
|
563
|
+
return unmod_sequence, dict(sorted(mod_map.items()))
|
peff_digest/io.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""File I/O, database caching, and multiprocessing worker functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pefftacular as pf
|
|
9
|
+
import peptacular as pt
|
|
10
|
+
from psimodpy import PsiModDatabase
|
|
11
|
+
|
|
12
|
+
from peff_digest.config import DigestConfig
|
|
13
|
+
from peff_digest.digest import _apply_mod, ann_to_map, digest_peff_sequence
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Lazy database loaders (cached per process for multiprocessing)
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
_PSI_DB: PsiModDatabase | None = None
|
|
22
|
+
_UNI_DB = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_psi_db() -> PsiModDatabase:
|
|
26
|
+
"""Load the bundled PSI-MOD database once per process and cache it."""
|
|
27
|
+
global _PSI_DB
|
|
28
|
+
if _PSI_DB is None:
|
|
29
|
+
import psimodpy as _psimodpy
|
|
30
|
+
|
|
31
|
+
_PSI_DB = _psimodpy.load()
|
|
32
|
+
return _PSI_DB
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_uni_db():
|
|
36
|
+
"""Load the bundled UniMod database once per process and cache it."""
|
|
37
|
+
global _UNI_DB
|
|
38
|
+
if _UNI_DB is None:
|
|
39
|
+
import unimodpy as _unimodpy
|
|
40
|
+
|
|
41
|
+
_UNI_DB = _unimodpy.load()
|
|
42
|
+
return _UNI_DB
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# PSI-MOD → UniMod conversion
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _try_convert_psimod_to_unimod(
|
|
51
|
+
ann: pt.ProFormaAnnotation,
|
|
52
|
+
psi_db: PsiModDatabase,
|
|
53
|
+
uni_db,
|
|
54
|
+
) -> pt.ProFormaAnnotation | None:
|
|
55
|
+
"""Replace MOD:NNNNN tags with UNIMOD:N accessions.
|
|
56
|
+
|
|
57
|
+
Returns None if any PSI-MOD mod has no UniMod xref — the caller should drop the peptide.
|
|
58
|
+
Non-PSI-MOD tags (user-added mods) are passed through unchanged.
|
|
59
|
+
"""
|
|
60
|
+
sequence, mod_map = ann_to_map(ann)
|
|
61
|
+
new_mod_map: dict[int, str] = {}
|
|
62
|
+
for pos, tag in mod_map.items():
|
|
63
|
+
if tag.startswith("MOD:"):
|
|
64
|
+
psi_entry = psi_db.get_by_id(tag)
|
|
65
|
+
if psi_entry is None or not psi_entry.xref_unimod:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
unimod_id = int(psi_entry.xref_unimod.replace("Unimod:", "").split("#")[0])
|
|
69
|
+
except ValueError:
|
|
70
|
+
return None
|
|
71
|
+
if uni_db.get_by_id(unimod_id) is None:
|
|
72
|
+
return None
|
|
73
|
+
new_mod_map[pos] = f"UNIMOD:{unimod_id}"
|
|
74
|
+
else:
|
|
75
|
+
new_mod_map[pos] = tag
|
|
76
|
+
new_ann = pt.parse(sequence)
|
|
77
|
+
seq_len = len(sequence)
|
|
78
|
+
for pos, tag in new_mod_map.items():
|
|
79
|
+
_apply_mod(new_ann, pos, tag, seq_len)
|
|
80
|
+
return new_ann
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# File reading (PEFF and FASTA)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
_FASTA_EXTENSIONS = {".fasta", ".fa", ".faa", ".fas"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _iter_fasta(path: str):
|
|
91
|
+
"""Yield (header, sequence) pairs from a FASTA file."""
|
|
92
|
+
header = None
|
|
93
|
+
chunks: list[str] = []
|
|
94
|
+
with open(path) as f:
|
|
95
|
+
for line in f:
|
|
96
|
+
line = line.rstrip()
|
|
97
|
+
if line.startswith(">"):
|
|
98
|
+
if header is not None:
|
|
99
|
+
yield header, "".join(chunks)
|
|
100
|
+
header = line[1:]
|
|
101
|
+
chunks = []
|
|
102
|
+
elif header is not None:
|
|
103
|
+
chunks.append(line)
|
|
104
|
+
if header is not None:
|
|
105
|
+
yield header, "".join(chunks)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fasta_to_entry(header: str, sequence: str) -> pf.SequenceEntry:
|
|
109
|
+
"""Convert a FASTA record to a minimal SequenceEntry."""
|
|
110
|
+
first_token = header.split()[0]
|
|
111
|
+
parts = first_token.split("|")
|
|
112
|
+
if len(parts) >= 3:
|
|
113
|
+
prefix, db_unique_id = parts[0], parts[1]
|
|
114
|
+
else:
|
|
115
|
+
prefix, db_unique_id = "", first_token
|
|
116
|
+
return pf.SequenceEntry(prefix=prefix, db_unique_id=db_unique_id, sequence=sequence)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_sequences(path: str) -> tuple[list[pf.SequenceEntry], int]:
|
|
120
|
+
"""Read sequences from a PEFF or FASTA file. Returns (entries, n_malformed)."""
|
|
121
|
+
sequences: list[pf.SequenceEntry] = []
|
|
122
|
+
n_malformed = 0
|
|
123
|
+
if Path(path).suffix.lower() in _FASTA_EXTENSIONS:
|
|
124
|
+
for header, seq in _iter_fasta(path):
|
|
125
|
+
try:
|
|
126
|
+
sequences.append(_fasta_to_entry(header, seq))
|
|
127
|
+
except Exception:
|
|
128
|
+
n_malformed += 1
|
|
129
|
+
logger.warning("Skipping malformed FASTA entry: %s", header.split()[0] if header else "<unknown>")
|
|
130
|
+
else:
|
|
131
|
+
reader = iter(pf.PeffReader(path))
|
|
132
|
+
while True:
|
|
133
|
+
try:
|
|
134
|
+
sequences.append(next(reader))
|
|
135
|
+
except StopIteration:
|
|
136
|
+
break
|
|
137
|
+
except Exception:
|
|
138
|
+
n_malformed += 1
|
|
139
|
+
logger.warning("Skipping malformed PEFF entry at position %d", len(sequences) + n_malformed)
|
|
140
|
+
return sequences, n_malformed
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Multiprocessing worker functions
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _format_variant(variant: pf.VariantSimple | pf.VariantComplex | None) -> str | None:
|
|
149
|
+
"""Format a variant object as a PEFF-notation string for output."""
|
|
150
|
+
if variant is None:
|
|
151
|
+
return None
|
|
152
|
+
if isinstance(variant, pf.VariantSimple):
|
|
153
|
+
return f"({variant.position}|{variant.new_amino_acid})"
|
|
154
|
+
return f"({variant.start_pos}|{variant.end_pos}|{variant.new_sequence})"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _digest_worker(
|
|
158
|
+
sequence: pf.SequenceEntry,
|
|
159
|
+
config: DigestConfig,
|
|
160
|
+
) -> list[tuple[str, str, str | None, int, float | None]]:
|
|
161
|
+
psi_db = _get_psi_db() if config.use_psi_mods else None
|
|
162
|
+
protein_id = sequence.db_unique_id
|
|
163
|
+
peptides = digest_peff_sequence(sequence, config, psi_db=psi_db)
|
|
164
|
+
rows = []
|
|
165
|
+
for peptide in peptides:
|
|
166
|
+
ann = peptide.proforma
|
|
167
|
+
variant_str = _format_variant(peptide.variant)
|
|
168
|
+
if config.use_unimod_output:
|
|
169
|
+
ann = _try_convert_psimod_to_unimod(ann, _get_psi_db(), _get_uni_db())
|
|
170
|
+
if ann is None:
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
mass = ann.mass()
|
|
174
|
+
except Exception:
|
|
175
|
+
mass = None
|
|
176
|
+
if mass is None:
|
|
177
|
+
logger.debug("Could not compute mass for peptide %s (protein %s)", str(ann), protein_id)
|
|
178
|
+
if mass is None and config.drop_invalid_mass:
|
|
179
|
+
continue
|
|
180
|
+
if mass is not None and config.min_mass is not None and mass < config.min_mass:
|
|
181
|
+
continue
|
|
182
|
+
if mass is not None and config.max_mass is not None and mass > config.max_mass:
|
|
183
|
+
continue
|
|
184
|
+
rows.append((protein_id, str(ann), variant_str, len(ann), mass))
|
|
185
|
+
return rows
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _digest_batch_worker(
|
|
189
|
+
batch: list[pf.SequenceEntry],
|
|
190
|
+
config: DigestConfig,
|
|
191
|
+
) -> list[tuple[str, str, str | None, int, float | None]]:
|
|
192
|
+
rows = []
|
|
193
|
+
for sequence in batch:
|
|
194
|
+
rows.extend(_digest_worker(sequence, config))
|
|
195
|
+
return rows
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: peff_digest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PEFF-aware in-silico protein digestion with PTM enumeration, sequence variants, and ProForma output.
|
|
5
|
+
Author-email: Patrick Garrett <pgarrett@scripps.edu>
|
|
6
|
+
Maintainer-email: Patrick Garrett <pgarrett@scripps.edu>
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: PEFF,PTM,ProForma,bioinformatics,digestion,peptide,proteomics
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: pefftacular>=0.1.0
|
|
19
|
+
Requires-Dist: peptacular>=3.1.0
|
|
20
|
+
Requires-Dist: polars>=1.0
|
|
21
|
+
Requires-Dist: psimodpy
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
Requires-Dist: tomli-w>=1.0
|
|
24
|
+
Requires-Dist: tqdm>=4.67.3
|
|
25
|
+
Requires-Dist: unimodpy
|
|
26
|
+
Requires-Dist: uniprotptmpy
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# peff_digest
|
|
30
|
+
|
|
31
|
+
A PEFF-aware protein digest tool. Given a PEFF file and enzymatic digestion parameters, produces a CSV of peptides with their sequence (ProForma notation), variant annotation, length, and monoisotopic mass.
|
|
32
|
+
|
|
33
|
+
Each PEFF `VariantSimple` and `VariantComplex` annotation is applied independently (not combined). PEFF PTMs (`ModResPsi` / `ModResUnimod`) are applied combinatorially up to a configurable limit per peptide. Fixed and variable user-defined modifications — including terminal modifications — are also supported.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv sync
|
|
39
|
+
# or
|
|
40
|
+
just install
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.12+.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### Via config file
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
peff-digest --config config.toml
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Via flags
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
peff-digest --peff-file human.peff --output-file peptides.csv
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Flags override any values set in `--config`. Run `peff-digest --help` for the full flag reference.
|
|
60
|
+
|
|
61
|
+
### Output
|
|
62
|
+
|
|
63
|
+
The output CSV has five columns:
|
|
64
|
+
|
|
65
|
+
| Column | Description |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `protein_id` | `db_unique_id` from the PEFF header |
|
|
68
|
+
| `sequence` | ProForma-annotated peptide sequence (includes mods) |
|
|
69
|
+
| `variant` | PEFF variant notation, e.g. `(42\|R)`, or empty for canonical |
|
|
70
|
+
| `length` | Peptide length in residues |
|
|
71
|
+
| `mass` | Monoisotopic mass in Da, or empty if not computable |
|
|
72
|
+
|
|
73
|
+
## Config reference
|
|
74
|
+
|
|
75
|
+
All options can be set in a TOML or JSON config file. TOML example:
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
peff_file = "human.peff"
|
|
79
|
+
output_file = "peptides.csv"
|
|
80
|
+
|
|
81
|
+
cleave_on = "KR"
|
|
82
|
+
missed_cleavages = 2
|
|
83
|
+
semi_enzymatic = false
|
|
84
|
+
max_ptm_per_peptide = 2
|
|
85
|
+
min_length = 7
|
|
86
|
+
max_length = 40
|
|
87
|
+
restrict_after = "P"
|
|
88
|
+
restrict_before = ""
|
|
89
|
+
cterminal = true
|
|
90
|
+
min_mass = 400.0
|
|
91
|
+
max_mass = 10000.0
|
|
92
|
+
drop_invalid_mass = false
|
|
93
|
+
annotate_variants = true
|
|
94
|
+
|
|
95
|
+
# Internal modifications — one [[internal_mods]] block per mod:
|
|
96
|
+
# [[internal_mods]]
|
|
97
|
+
# modification = "Carbamidomethyl"
|
|
98
|
+
# residue = "C"
|
|
99
|
+
# mod_type = "fixed"
|
|
100
|
+
#
|
|
101
|
+
# [[internal_mods]]
|
|
102
|
+
# modification = "Oxidation"
|
|
103
|
+
# residue = "M"
|
|
104
|
+
# mod_type = "variable"
|
|
105
|
+
|
|
106
|
+
# Terminal modifications — one [[terminal_mods]] block per mod:
|
|
107
|
+
# [[terminal_mods]]
|
|
108
|
+
# modification = "Acetyl"
|
|
109
|
+
# position = "nterm"
|
|
110
|
+
# mod_type = "variable"
|
|
111
|
+
# protein_terminus = true # only the first peptide of each protein
|
|
112
|
+
#
|
|
113
|
+
# [[terminal_mods]]
|
|
114
|
+
# modification = "UNIMOD:737"
|
|
115
|
+
# position = "nterm"
|
|
116
|
+
# mod_type = "fixed"
|
|
117
|
+
# residue = "M" # only if the terminal residue is M
|
|
118
|
+
#
|
|
119
|
+
# [[terminal_mods]]
|
|
120
|
+
# modification = "Amidated"
|
|
121
|
+
# position = "cterm"
|
|
122
|
+
# mod_type = "variable"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### `DigestConfig` fields
|
|
126
|
+
|
|
127
|
+
| Field | Type | Default | Description |
|
|
128
|
+
|---|---|---|---|
|
|
129
|
+
| `peff_file` | `str` | required | Path to the input PEFF file. Must exist. |
|
|
130
|
+
| `output_file` | `str` | `"peptides.csv"` | Path for the output CSV. |
|
|
131
|
+
| `cleave_on` | `str` | `"KR"` | Amino acids at which to cleave (e.g. `"KR"` for trypsin). |
|
|
132
|
+
| `missed_cleavages` | `int` | `2` | Maximum number of missed cleavage sites per peptide. Min 0. |
|
|
133
|
+
| `semi_enzymatic` | `bool` | `false` | Include semi-enzymatic peptides (one non-enzymatic terminus). |
|
|
134
|
+
| `max_ptm_per_peptide` | `int` | `2` | Maximum number of variable mods (PEFF + user) applied simultaneously per peptide. `0` disables all variable mods. Min 0. |
|
|
135
|
+
| `min_length` | `int` | `7` | Minimum peptide length in residues (inclusive). Min 1. |
|
|
136
|
+
| `max_length` | `int` | `40` | Maximum peptide length in residues (inclusive). Min 1. |
|
|
137
|
+
| `restrict_after` | `str` | `"P"` | Skip cleavage when the following residue is in this set (e.g. `"P"` for trypsin/Pro rule). |
|
|
138
|
+
| `restrict_before` | `str` | `""` | Skip cleavage when the preceding residue is in this set. |
|
|
139
|
+
| `cterminal` | `bool` | `true` | `true` = C-terminal cleavage (standard); `false` = N-terminal. |
|
|
140
|
+
| `internal_mods` | `list[InternalMod]` | `[]` | Per-residue modifications. See `InternalMod` fields below. |
|
|
141
|
+
| `terminal_mods` | `list[TerminalMod]` | `[]` | Terminal modifications. See `TerminalMod` fields below. |
|
|
142
|
+
| `min_mass` | `float \| None` | `None` | Minimum peptide mass in Da. Ignored if `None`. |
|
|
143
|
+
| `max_mass` | `float \| None` | `None` | Maximum peptide mass in Da. Ignored if `None`. |
|
|
144
|
+
| `drop_invalid_mass` | `bool` | `false` | If `true`, exclude peptides whose mass cannot be computed. |
|
|
145
|
+
| `annotate_variants` | `bool` | `true` | If `false`, do not set `peptide_name` on variant peptides. |
|
|
146
|
+
| `workers` | `int \| None` | `None` | Number of worker processes. Defaults to all available CPUs. Min 1. |
|
|
147
|
+
|
|
148
|
+
### `InternalMod` fields
|
|
149
|
+
|
|
150
|
+
| Field | Type | Default | Description |
|
|
151
|
+
|---|---|---|---|
|
|
152
|
+
| `modification` | `str` | required | Modification name (e.g. `"Carbamidomethyl"`, `"UNIMOD:21"`). |
|
|
153
|
+
| `residue` | `str` | required | One or more amino acids the mod applies to (e.g. `"C"` or `"KR"`). |
|
|
154
|
+
| `mod_type` | `"fixed" \| "variable"` | required | `"fixed"` = always applied; `"variable"` = enumerated combinatorially (counts against `max_ptm_per_peptide`). |
|
|
155
|
+
|
|
156
|
+
### `TerminalMod` fields
|
|
157
|
+
|
|
158
|
+
| Field | Type | Default | Description |
|
|
159
|
+
|---|---|---|---|
|
|
160
|
+
| `modification` | `str` | required | Modification name (e.g. `"Acetyl"`, `"UNIMOD:737"`). |
|
|
161
|
+
| `position` | `"nterm" \| "cterm"` | required | Which terminus to apply the mod to. |
|
|
162
|
+
| `mod_type` | `"fixed" \| "variable"` | required | `"fixed"` = always applied; `"variable"` = enumerated combinatorially (counts against `max_ptm_per_peptide`). |
|
|
163
|
+
| `residue` | `str \| None` | `None` | If set, the mod is only applied when the terminal residue is in this string (e.g. `"M"` or `"KR"`). |
|
|
164
|
+
| `protein_terminus` | `bool` | `false` | If `true`, only apply to the protein-level terminus (first peptide for N-term, last for C-term). |
|
|
165
|
+
|
|
166
|
+
## Python API
|
|
167
|
+
|
|
168
|
+
### Full digest → Polars DataFrame
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from peff_digest import DigestConfig, InternalMod, TerminalMod, digest
|
|
172
|
+
|
|
173
|
+
config = DigestConfig(
|
|
174
|
+
peff_file="human.peff",
|
|
175
|
+
missed_cleavages=2,
|
|
176
|
+
min_length=7,
|
|
177
|
+
max_length=40,
|
|
178
|
+
min_mass=400.0,
|
|
179
|
+
max_mass=10000.0,
|
|
180
|
+
drop_invalid_mass=True,
|
|
181
|
+
internal_mods=[
|
|
182
|
+
InternalMod(modification="Carbamidomethyl", residue="C", mod_type="fixed"),
|
|
183
|
+
InternalMod(modification="Oxidation", residue="M", mod_type="variable"),
|
|
184
|
+
],
|
|
185
|
+
terminal_mods=[
|
|
186
|
+
TerminalMod(modification="Acetyl", position="nterm", mod_type="variable", protein_terminus=True),
|
|
187
|
+
],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
df = digest(config)
|
|
191
|
+
print(df)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Returns a `polars.DataFrame` with columns `protein_id`, `sequence`, `variant`, `length`, `mass`. All filtering from the config (mass bounds, `drop_invalid_mass`) is applied before returning.
|
|
195
|
+
|
|
196
|
+
### Single-entry digest
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
import pefftacular as pf
|
|
200
|
+
from peff_digest import InternalMod, TerminalMod, digest_peff_sequence
|
|
201
|
+
|
|
202
|
+
entry = next(iter(pf.PeffReader("human.peff")))
|
|
203
|
+
|
|
204
|
+
peptides = digest_peff_sequence(
|
|
205
|
+
entry,
|
|
206
|
+
cleave_on="KR",
|
|
207
|
+
missed_cleavages=2,
|
|
208
|
+
min_length=7,
|
|
209
|
+
max_length=40,
|
|
210
|
+
restrict_after="P",
|
|
211
|
+
internal_mods=[
|
|
212
|
+
InternalMod(modification="Carbamidomethyl", residue="C", mod_type="fixed"),
|
|
213
|
+
InternalMod(modification="Oxidation", residue="M", mod_type="variable"),
|
|
214
|
+
],
|
|
215
|
+
max_ptm_per_peptide=2,
|
|
216
|
+
terminal_mods=[
|
|
217
|
+
TerminalMod(modification="Acetyl", position="nterm", mod_type="variable", protein_terminus=True),
|
|
218
|
+
TerminalMod(modification="Amidated", position="cterm", mod_type="variable"),
|
|
219
|
+
],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
for peptide in peptides:
|
|
223
|
+
print(str(peptide), len(peptide), peptide.mass())
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Returns a `set[peptacular.ProFormaAnnotation]`. Each element supports `len()`, `.mass()`, `str()`, and `.peptide_name` (PEFF variant notation, or `None` for canonical).
|
|
227
|
+
|
|
228
|
+
## Development
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
just lint # ruff check
|
|
232
|
+
just format # ruff format + import sort
|
|
233
|
+
just test # pytest
|
|
234
|
+
just check # lint + type check (ty) + test
|
|
235
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
peff_digest/__init__.py,sha256=EKvVEONc3vG7_0t7xTZ2cTYqOW6lww2R_R0R4cJgPzY,418
|
|
2
|
+
peff_digest/api.py,sha256=U_hE8xn_qRWWgtndBTcXDUJ8_7DfS1Hyw4Q6t-6UYrA,3724
|
|
3
|
+
peff_digest/cli.py,sha256=qLVXbLE0V4-Sb5nJPWd2jjHMGHDX73k_PPxUr3CFS8Q,4239
|
|
4
|
+
peff_digest/config.py,sha256=2259cUKziryeci8D4HbNcL6nOW-ELLVUlTQbxwstd20,2768
|
|
5
|
+
peff_digest/digest.py,sha256=x4vXqlSXemOoEscqNoXWD5AwPzY-cWQQGNgz3rH4Mus,22986
|
|
6
|
+
peff_digest/io.py,sha256=pF_Fbh3INTaO9iKlzSO7JYAf7dtPXxQ5OVi4OPyUMG0,6759
|
|
7
|
+
peff_digest-0.1.0.dist-info/METADATA,sha256=rNHTcKK2dxEEyPJ1gTN5DgRxPWGdF7x1hA5hK6hduyE,8639
|
|
8
|
+
peff_digest-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
peff_digest-0.1.0.dist-info/entry_points.txt,sha256=PwjwCI63wRekkTDOKcNpcGInXIcjV0O1AOyTtM6KOtQ,53
|
|
10
|
+
peff_digest-0.1.0.dist-info/RECORD,,
|