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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ peff-digest = peff_digest.cli:main