peplocus 0.5.6__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.
- peplocus/__init__.py +11 -0
- peplocus/__main__.py +3 -0
- peplocus/annotation.py +103 -0
- peplocus/api.py +43 -0
- peplocus/cli.py +232 -0
- peplocus/digestion.py +83 -0
- peplocus/discovery.py +169 -0
- peplocus/io.py +143 -0
- peplocus/junctions.py +164 -0
- peplocus/output.py +145 -0
- peplocus/pipeline.py +242 -0
- peplocus/rescue.py +172 -0
- peplocus/rescue_output.py +29 -0
- peplocus/rescue_pipeline.py +93 -0
- peplocus/search.py +82 -0
- peplocus/sequence.py +66 -0
- peplocus-0.5.6.dist-info/METADATA +90 -0
- peplocus-0.5.6.dist-info/RECORD +22 -0
- peplocus-0.5.6.dist-info/WHEEL +5 -0
- peplocus-0.5.6.dist-info/entry_points.txt +2 -0
- peplocus-0.5.6.dist-info/licenses/LICENSE +21 -0
- peplocus-0.5.6.dist-info/top_level.txt +1 -0
peplocus/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""PepLocus: peptide coordinates directly from genome assemblies."""
|
|
2
|
+
__version__ = "0.5.6"
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def map_peptides(assembly=None, peptides=None, out=None, **options):
|
|
6
|
+
"""Map peptide-file paths with CLI-equivalent options; return summary dict."""
|
|
7
|
+
from .api import map_peptides as run
|
|
8
|
+
return run(assembly, peptides, out, **options)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__", "map_peptides"]
|
peplocus/__main__.py
ADDED
peplocus/annotation.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Read transcript exon structures from GTF and GFF3-style Helixer annotations."""
|
|
2
|
+
from collections import Counter, defaultdict
|
|
3
|
+
import gzip
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import unquote
|
|
6
|
+
|
|
7
|
+
from .junctions import Junction, motif
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_ATTRIBUTE = re.compile(
|
|
11
|
+
r'(?:^|;)\s*([^\s=;]+)\s*(?:=\s*|\s+)'
|
|
12
|
+
r'("(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|[^;]*)'
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def annotation_parents(attributes):
|
|
17
|
+
"""Read GTF/GFF2/GFF3 relationships without grouping separate isoforms by gene.
|
|
18
|
+
|
|
19
|
+
Split GFF3 Parent lists before URL decoding: an encoded comma is part of an ID.
|
|
20
|
+
Protein-only and bare transcript labels support older predictor output.
|
|
21
|
+
"""
|
|
22
|
+
attrs = {}
|
|
23
|
+
for match in _ATTRIBUTE.finditer(attributes):
|
|
24
|
+
key, value = match.groups()
|
|
25
|
+
value = value.strip()
|
|
26
|
+
quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}
|
|
27
|
+
if quoted:
|
|
28
|
+
value = re.sub(r'\\([\\"\'])', r'\1', value[1:-1])
|
|
29
|
+
attrs[key.lower()] = value
|
|
30
|
+
for key in ('transcript_id', 'parent', 'protein_id'):
|
|
31
|
+
value = attrs.get(key)
|
|
32
|
+
if value and value != '.':
|
|
33
|
+
parts = value.split(',') if key == 'parent' else [value]
|
|
34
|
+
return [unquote(part.strip()) for part in parts if part.strip()]
|
|
35
|
+
bare = attributes.strip().rstrip(';').strip()
|
|
36
|
+
if re.fullmatch(r'[\w.:%+|/-]+', bare) and bare != '.':
|
|
37
|
+
return [unquote(bare)]
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_annotation(path, fasta, lengths):
|
|
42
|
+
"""Return exon-adjacency junctions, without pretending annotation is read support.
|
|
43
|
+
|
|
44
|
+
Exons are preferred per transcript; CDS intervals are a fallback when exons
|
|
45
|
+
are absent. CDS phase is not trimmed: every junction is searched in all frames,
|
|
46
|
+
preserving codons split across introns. Annotated introns are not BAM-filtered.
|
|
47
|
+
"""
|
|
48
|
+
transcripts = defaultdict(lambda: {"exon": set(), "CDS": set()})
|
|
49
|
+
identities = {}
|
|
50
|
+
stats = Counter()
|
|
51
|
+
opener = gzip.open if str(path).endswith('.gz') else open
|
|
52
|
+
with opener(path, 'rt') as handle:
|
|
53
|
+
for number, line in enumerate(handle, 1):
|
|
54
|
+
if line.startswith('##FASTA'):
|
|
55
|
+
break
|
|
56
|
+
if not line.strip() or line.startswith('#'):
|
|
57
|
+
continue
|
|
58
|
+
fields = line.rstrip('\n').split('\t')
|
|
59
|
+
if len(fields) != 9:
|
|
60
|
+
raise ValueError(f"annotation line {number}: expected 9 GTF/GFF columns")
|
|
61
|
+
chrom, _, feature, start, end, _, strand, _, attributes = fields
|
|
62
|
+
feature = feature.lower()
|
|
63
|
+
if feature not in {'exon', 'cds'}:
|
|
64
|
+
continue
|
|
65
|
+
feature = 'CDS' if feature == 'cds' else 'exon'
|
|
66
|
+
stats['feature_rows'] += 1
|
|
67
|
+
if chrom not in lengths:
|
|
68
|
+
stats['feature_rows_unknown_contig'] += 1
|
|
69
|
+
continue
|
|
70
|
+
start, end = int(start)-1, int(end)
|
|
71
|
+
if not 0 <= start < end <= lengths[chrom]:
|
|
72
|
+
raise ValueError(f"annotation line {number}: interval outside assembly")
|
|
73
|
+
if strand not in {'+', '-'}:
|
|
74
|
+
raise ValueError(f"annotation line {number}: exon/CDS requires + or - strand")
|
|
75
|
+
parents = annotation_parents(attributes)
|
|
76
|
+
if not parents:
|
|
77
|
+
raise ValueError(f"annotation line {number}: exon/CDS lacks transcript_id or Parent (or protein_id/bare transcript label); gene-only features cannot resolve isoforms")
|
|
78
|
+
for parent in parents:
|
|
79
|
+
identity = (chrom, strand)
|
|
80
|
+
if parent in identities and identities[parent] != identity:
|
|
81
|
+
raise ValueError(f"annotation transcript {parent!r} appears on conflicting contigs/strands")
|
|
82
|
+
identities[parent] = identity
|
|
83
|
+
transcripts[(chrom, strand, parent)][feature].add((start, end))
|
|
84
|
+
if not transcripts:
|
|
85
|
+
raise ValueError('annotation contains no usable exon/CDS transcripts on assembly contigs')
|
|
86
|
+
junctions = {}
|
|
87
|
+
for (chrom, strand, parent), features in transcripts.items():
|
|
88
|
+
exons = sorted(features['exon'] or features['CDS'])
|
|
89
|
+
stats['transcripts'] += 1
|
|
90
|
+
stats['transcripts_exon' if features['exon'] else 'transcripts_cds_fallback'] += 1
|
|
91
|
+
# Merge touching/overlapping segments; they do not define an intron.
|
|
92
|
+
merged = []
|
|
93
|
+
for a,b in exons:
|
|
94
|
+
if merged and a <= merged[-1][1]:
|
|
95
|
+
merged[-1] = (merged[-1][0], max(b, merged[-1][1]))
|
|
96
|
+
else:
|
|
97
|
+
merged.append((a,b))
|
|
98
|
+
for left, right in zip(merged, merged[1:]):
|
|
99
|
+
start, end = left[1], right[0]
|
|
100
|
+
key = (chrom, start, end, strand)
|
|
101
|
+
junctions[key] = Junction(chrom, start, end, strand, 0, 'annotation', motif(fasta, chrom, start, end, strand))
|
|
102
|
+
stats['junctions_retained'] = len(junctions)
|
|
103
|
+
return sorted(junctions.values()), dict(stats)
|
peplocus/api.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Python entry point using exactly the same defaults as the command line."""
|
|
2
|
+
import argparse
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def map_peptides(assembly=None, peptides=None, out=None, *, threads=8, **options):
|
|
6
|
+
"""Map a peptide file; return the run summary dictionary.
|
|
7
|
+
|
|
8
|
+
Keyword options use CLI names with underscores, e.g. gtf=..., bam=...,
|
|
9
|
+
rescue_one_edit=True, min_rescue_length=9, chunk_size=2000000.
|
|
10
|
+
Invalid options raise ValueError. Inputs are paths, never modified in place.
|
|
11
|
+
"""
|
|
12
|
+
if "genome" in options:
|
|
13
|
+
if assembly is not None:
|
|
14
|
+
raise ValueError("use assembly only; genome is a legacy alias")
|
|
15
|
+
assembly=options.pop("genome")
|
|
16
|
+
if assembly is None or peptides is None or out is None:
|
|
17
|
+
raise ValueError("assembly, peptides and out are required")
|
|
18
|
+
from .cli import parser, validate_arguments
|
|
19
|
+
root=parser()
|
|
20
|
+
def error(message):raise ValueError(message)
|
|
21
|
+
root.error=error
|
|
22
|
+
actions={action.dest:action for action in root._actions if action.option_strings}
|
|
23
|
+
if "min_rescue_length" in options:
|
|
24
|
+
if "rescue_min_length" in options:
|
|
25
|
+
raise ValueError("use only min_rescue_length (rescue_min_length is a legacy alias)")
|
|
26
|
+
options["rescue_min_length"]=options.pop("min_rescue_length")
|
|
27
|
+
if "il_equivalent" in options and options["il_equivalent"] is not True:
|
|
28
|
+
raise ValueError("I/L equivalence is always enabled; il_equivalent must be True")
|
|
29
|
+
values=dict(options,threads=threads)
|
|
30
|
+
argv=["--assembly",str(assembly),"--peptides",str(peptides),"--out",str(out)]
|
|
31
|
+
for key,value in values.items():
|
|
32
|
+
if key not in actions or key in {"help","help_advanced","version","genome","peptides","out"}:
|
|
33
|
+
raise ValueError("unknown or reserved mapping option: "+key)
|
|
34
|
+
action=actions[key]
|
|
35
|
+
flag=action.option_strings[0]
|
|
36
|
+
if isinstance(action,argparse._StoreTrueAction):
|
|
37
|
+
if not isinstance(value,bool):raise ValueError(key+" must be boolean")
|
|
38
|
+
if value:argv.append(flag)
|
|
39
|
+
elif value is not None:
|
|
40
|
+
argv.extend([flag,str(value)])
|
|
41
|
+
args=root.parse_args(argv)
|
|
42
|
+
validate_arguments(args)
|
|
43
|
+
return args.func(args)
|
peplocus/cli.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""PepLocus command-line interface."""
|
|
2
|
+
import argparse
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
import pysam
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .io import prepare_reference
|
|
14
|
+
from .junctions import extract_bam, write_junctions
|
|
15
|
+
from .annotation import read_annotation
|
|
16
|
+
from .output import EDIT_FIELDS
|
|
17
|
+
from .pipeline import map_peptides
|
|
18
|
+
from .rescue import one_edit
|
|
19
|
+
from .sequence import canonical, join_blocks, translate
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _HelpParser(argparse.ArgumentParser):
|
|
23
|
+
"""Keep tuning flags parseable while presenting a small everyday interface."""
|
|
24
|
+
def _view(self, render):
|
|
25
|
+
hidden=[] if getattr(self,"_show_advanced",False) else getattr(self,"_advanced_actions",[])
|
|
26
|
+
saved=[(action,action.help) for action in hidden]
|
|
27
|
+
try:
|
|
28
|
+
for action,_ in saved:action.help=argparse.SUPPRESS
|
|
29
|
+
return render()
|
|
30
|
+
finally:
|
|
31
|
+
for action,help_text in saved:action.help=help_text
|
|
32
|
+
|
|
33
|
+
def parse_args(self, args=None, namespace=None):
|
|
34
|
+
args=list(sys.argv[1:] if args is None else args)
|
|
35
|
+
if args[:1]==["map"]:args=args[1:] # compatibility with older scripts
|
|
36
|
+
return super().parse_args(args, namespace)
|
|
37
|
+
|
|
38
|
+
def format_help(self):return self._view(super().format_help)
|
|
39
|
+
def format_usage(self):return self._view(super().format_usage)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _AdvancedHelp(argparse.Action):
|
|
43
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
44
|
+
parser._show_advanced=True
|
|
45
|
+
parser.print_help()
|
|
46
|
+
parser.exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def positive(text):
|
|
50
|
+
value = int(text)
|
|
51
|
+
if value < 1:
|
|
52
|
+
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def nonnegative(text):
|
|
57
|
+
value = int(text)
|
|
58
|
+
if value < 0:
|
|
59
|
+
raise argparse.ArgumentTypeError("must be zero or a positive integer")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def junction_options(parser):
|
|
64
|
+
parser.add_argument("--min-mapq", type=nonnegative, default=10, help="minimum BAM mapping quality (default: 10)")
|
|
65
|
+
parser.add_argument("--min-read-anchor", type=positive, default=8, help="aligned bases on each side of a BAM intron (default: 8)")
|
|
66
|
+
parser.add_argument("--min-junction-support", type=positive, default=2, help="minimum primary, nonduplicate alignment observations per junction (default: 2)")
|
|
67
|
+
parser.add_argument("--min-intron", type=positive, default=20, help="minimum BAM/discovery intron length in bp (default: 20)")
|
|
68
|
+
parser.add_argument("--max-intron", type=positive, default=500000, help="maximum BAM/discovery intron length in bp (default: 500000)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_junctions(args):
|
|
72
|
+
out = Path(args.out)
|
|
73
|
+
if out.exists():
|
|
74
|
+
raise ValueError(f"output already exists: {out}")
|
|
75
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
with tempfile.TemporaryDirectory(prefix="peplocus-junctions-") as cache:
|
|
77
|
+
genome, index, lengths = prepare_reference(args.genome, cache)
|
|
78
|
+
with pysam.FastaFile(genome, filepath_index=index) as fasta:
|
|
79
|
+
junctions, stats = [], {}
|
|
80
|
+
if args.bam:
|
|
81
|
+
junctions, bam_stats = extract_bam(args.bam, fasta, lengths, args.min_mapq, args.min_read_anchor, args.min_junction_support, args.min_intron, args.max_intron)
|
|
82
|
+
stats["bam"] = bam_stats
|
|
83
|
+
if args.gtf:
|
|
84
|
+
annotation_junctions, annotation_stats = read_annotation(args.gtf, fasta, lengths)
|
|
85
|
+
junctions.extend(annotation_junctions)
|
|
86
|
+
stats["annotation"] = annotation_stats
|
|
87
|
+
write_junctions(out, junctions)
|
|
88
|
+
print(json.dumps(stats, indent=2))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_verify(args):
|
|
92
|
+
count = 0
|
|
93
|
+
with tempfile.TemporaryDirectory(prefix="peplocus-verify-") as cache:
|
|
94
|
+
genome, index, lengths = prepare_reference(args.genome, cache)
|
|
95
|
+
with pysam.FastaFile(genome, filepath_index=index) as fasta, open(args.mappings) as handle:
|
|
96
|
+
reader = csv.DictReader(handle, delimiter="\t")
|
|
97
|
+
if not {"peptide", "chrom", "strand", "blocks", "matched_sequence"} <= set(reader.fieldnames or []):
|
|
98
|
+
raise ValueError("expected a PepLocus mappings.tsv file")
|
|
99
|
+
for row in reader:
|
|
100
|
+
if not all(isinstance(row.get(name),str) and row[name] for name in ("peptide","chrom","strand","blocks","matched_sequence")):
|
|
101
|
+
raise ValueError("mapping row has empty required fields")
|
|
102
|
+
blocks = json.loads(row["blocks"])
|
|
103
|
+
if not isinstance(blocks,list) or not all(isinstance(block,list) and len(block)==2 and
|
|
104
|
+
all(type(value) is int for value in block) for block in blocks):
|
|
105
|
+
raise ValueError("mapping blocks must be a list of integer coordinate pairs")
|
|
106
|
+
if row["strand"] not in {"+", "-"} or row["chrom"] not in lengths or not blocks:
|
|
107
|
+
raise ValueError(f"invalid mapping: {row}")
|
|
108
|
+
previous = -1
|
|
109
|
+
for a,b in blocks:
|
|
110
|
+
if not 0 <= a < b <= lengths[row["chrom"]] or a < previous:
|
|
111
|
+
raise ValueError(f"invalid genomic blocks: {blocks}")
|
|
112
|
+
previous = b
|
|
113
|
+
if sum(b-a for a,b in blocks) != 3*len(row["matched_sequence"]):
|
|
114
|
+
raise ValueError("mapping blocks do not span complete matched codons")
|
|
115
|
+
if "start" in row and (int(row["start"])!=blocks[0][0] or int(row["end"])!=blocks[-1][1]):
|
|
116
|
+
raise ValueError("mapping start/end disagree with blocks")
|
|
117
|
+
observed = translate(join_blocks(fasta, row["chrom"], row["strand"], blocks), args.genetic_code)
|
|
118
|
+
if row.get("match_type") == "one_edit":
|
|
119
|
+
edit = one_edit(row["peptide"], observed, args.il_equivalent)
|
|
120
|
+
valid = (observed == row["matched_sequence"] and edit is not None
|
|
121
|
+
and row.get("edit_distance") == "1"
|
|
122
|
+
and all(str(edit[key]) == row.get(key) for key in
|
|
123
|
+
EDIT_FIELDS))
|
|
124
|
+
else:
|
|
125
|
+
valid = observed == row["matched_sequence"] and canonical(observed,args.il_equivalent) == canonical(row["peptide"],args.il_equivalent)
|
|
126
|
+
if not valid:
|
|
127
|
+
raise ValueError(f"mapping does not reproduce peptide/alignment: {row['peptide']} at {row['chrom']}:{blocks}")
|
|
128
|
+
count += 1
|
|
129
|
+
print(json.dumps({"verified_loci": count, "valid": True}))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parser(command=None):
|
|
133
|
+
if command in {"junctions", "verify"}:
|
|
134
|
+
return utility_parser(command)
|
|
135
|
+
mapping = _HelpParser(prog="peplocus",
|
|
136
|
+
description=None,
|
|
137
|
+
epilog="For tuning controls: peplocus --help-advanced",
|
|
138
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
139
|
+
mapping.add_argument("--version", action="version", version=f"PepLocus {__version__}")
|
|
140
|
+
mapping.add_argument("--help-advanced", action=_AdvancedHelp, nargs=0,
|
|
141
|
+
help="show advanced controls and exit")
|
|
142
|
+
mapping.add_argument("-a", "--assembly", "--genome", dest="genome", metavar="ASSEMBLY", required=True, help="assembly FASTA (plain or gzip)")
|
|
143
|
+
mapping.add_argument("-p", "--peptides", required=True, help="peptide list, FASTA, CSV or TSV")
|
|
144
|
+
mapping.add_argument("--peptide-column", help="column name in CSV/TSV input")
|
|
145
|
+
mapping.add_argument("--out", required=True, help="output directory without existing PepLocus results")
|
|
146
|
+
mapping.add_argument("-g", "--gtf", "--gff", dest="gtf", help="annotation supplying splice junctions (GTF/GFF)")
|
|
147
|
+
mapping.add_argument("-b", "--bam", help="optional RNA-seq BAM; no index required")
|
|
148
|
+
mapping.add_argument("--junctions", help="optional reusable PepLocus junction TSV")
|
|
149
|
+
mapping.add_argument("--threads", type=positive, default=8, help="worker processes (default: 8)")
|
|
150
|
+
mapping.add_argument("--chunk-size", type=positive, default=2000000, help="genomic bases per worker task (default: 2000000)")
|
|
151
|
+
mapping.add_argument("--min-length", type=positive, default=1, help="minimum peptide length; short matches can be highly ambiguous")
|
|
152
|
+
mapping.add_argument("--il-equivalent", action="store_true", default=True, help=argparse.SUPPRESS)
|
|
153
|
+
mapping.add_argument("--genetic-code", type=positive, default=1, help="NCBI translation table; applies to every contig (default: 1)")
|
|
154
|
+
mapping.add_argument("--rescue-one-edit", action="store_true", help="rescue exact-unmapped peptides with one amino-acid edit")
|
|
155
|
+
mapping.add_argument("--min-rescue-length", dest="rescue_min_length", metavar="MIN_LENGTH", type=positive, default=9, help="minimum query length for one-edit rescue (default: 9)")
|
|
156
|
+
mapping.add_argument("--igv-display-bed", action="store_true", help="also write a display-only BED with reversed negative-strand labels and GAPMS-style colors")
|
|
157
|
+
mapping.add_argument("--no-verify", action="store_true", help="skip final extraction/translation verification (enabled by default)")
|
|
158
|
+
junction_options(mapping)
|
|
159
|
+
mapping.add_argument("--max-junctions", type=positive, default=3, help="maximum introns crossed by a supported peptide path (default: 3)")
|
|
160
|
+
mapping.add_argument("--max-paths-per-junction", type=nonnegative, default=1000, help="splice graph path cap per first intron; 0 means unlimited")
|
|
161
|
+
mapping.add_argument("--discover-junctions", action="store_true", help="discover bounded single-intron alignments without annotation")
|
|
162
|
+
mapping.add_argument("--discover-all", action="store_true", help="run discovery for mapped peptides too (requires --discover-junctions)")
|
|
163
|
+
mapping.add_argument("--novel-anchor", type=positive, default=4, help="amino acids required at each terminal discovery anchor (default: 4)")
|
|
164
|
+
mapping.add_argument("--novel-cache-mb", type=nonnegative, default=256, help="memory budget for caching small contigs during discovery; 0 disables it (default: 256 MiB)")
|
|
165
|
+
mapping.add_argument("--max-seed-hits", type=nonnegative, default=2000, help="discovery seed-hit cap per contig/strand; 0 means unlimited")
|
|
166
|
+
mapping.add_argument("--max-novel-pairs", type=nonnegative, default=10000, help="discovery candidate-junction cap per peptide/contig/strand; 0 means unlimited")
|
|
167
|
+
common={"help","version","help_advanced","genome","peptides","out","gtf","bam","threads",
|
|
168
|
+
"rescue_min_length","rescue_one_edit","discover_junctions"}
|
|
169
|
+
mapping._advanced_actions=[action for action in mapping._actions if action.dest not in common]
|
|
170
|
+
advanced=mapping.add_argument_group("Advanced controls")
|
|
171
|
+
for action in mapping._advanced_actions:
|
|
172
|
+
for group in mapping._action_groups:
|
|
173
|
+
if group is not advanced and action in group._group_actions:group._group_actions.remove(action)
|
|
174
|
+
advanced._group_actions.append(action)
|
|
175
|
+
mapping.set_defaults(func=map_peptides, command="map")
|
|
176
|
+
return mapping
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def utility_parser(command):
|
|
180
|
+
"""Legacy utilities, kept for existing scripts; mapping needs no subcommand."""
|
|
181
|
+
junction = argparse.ArgumentParser(prog="peplocus junctions")
|
|
182
|
+
junction.add_argument("-a", "--assembly", "--genome", dest="genome", metavar="ASSEMBLY", required=True)
|
|
183
|
+
junction.add_argument("-b", "--bam", help="RNA-seq BAM")
|
|
184
|
+
junction.add_argument("-g", "--gtf", "--gff", dest="gtf", help="GTF or Helixer GFF3-style annotation")
|
|
185
|
+
junction.add_argument("--out", required=True, help="new junction TSV path")
|
|
186
|
+
junction_options(junction)
|
|
187
|
+
junction.set_defaults(func=run_junctions, command="junctions")
|
|
188
|
+
verify = argparse.ArgumentParser(prog="peplocus verify")
|
|
189
|
+
verify.add_argument("-a", "--assembly", "--genome", dest="genome", metavar="ASSEMBLY", required=True)
|
|
190
|
+
verify.add_argument("--mappings", required=True)
|
|
191
|
+
verify.add_argument("--il-equivalent", action="store_true", default=True, help=argparse.SUPPRESS)
|
|
192
|
+
verify.add_argument("--genetic-code", type=positive, default=1)
|
|
193
|
+
verify.set_defaults(func=run_verify, command="verify")
|
|
194
|
+
return junction if command == "junctions" else verify
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def validate_arguments(args):
|
|
198
|
+
if args.command == "junctions" and not (args.bam or args.gtf):
|
|
199
|
+
raise ValueError("junctions requires --bam and/or --gtf")
|
|
200
|
+
if hasattr(args,"min_intron") and args.min_intron > args.max_intron:
|
|
201
|
+
raise ValueError("--min-intron cannot exceed --max-intron")
|
|
202
|
+
if getattr(args,"discover_all",False) and not args.discover_junctions:
|
|
203
|
+
raise ValueError("--discover-all requires --discover-junctions")
|
|
204
|
+
from Bio.Data import CodonTable
|
|
205
|
+
if getattr(args,"genetic_code",1) not in CodonTable.unambiguous_dna_by_id:
|
|
206
|
+
raise ValueError("--genetic-code must be a valid NCBI DNA translation-table number")
|
|
207
|
+
code=CodonTable.unambiguous_dna_by_id[getattr(args,"genetic_code",1)]
|
|
208
|
+
if set(code.stop_codons) & set(code.forward_table):
|
|
209
|
+
raise ValueError("context-dependent stop codons are unsupported for --genetic-code; choose a table with unambiguous stop semantics")
|
|
210
|
+
if getattr(args,"rescue_min_length",2) < 2:
|
|
211
|
+
raise ValueError("--min-rescue-length must be at least 2")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main(argv=None):
|
|
215
|
+
argv=list(sys.argv[1:] if argv is None else argv)
|
|
216
|
+
command=argv.pop(0) if argv[:1] and argv[0] in {"junctions", "verify"} else None
|
|
217
|
+
root = parser(command)
|
|
218
|
+
args = root.parse_args(argv)
|
|
219
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
220
|
+
try:
|
|
221
|
+
validate_arguments(args)
|
|
222
|
+
except ValueError as error:
|
|
223
|
+
root.error(str(error))
|
|
224
|
+
try:
|
|
225
|
+
args.func(args)
|
|
226
|
+
except (ValueError, OSError, KeyError) as error:
|
|
227
|
+
logging.getLogger("peplocus").error("%s", error)
|
|
228
|
+
return 2
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
logging.getLogger("peplocus").error("Interrupted; no completed result summary was published")
|
|
231
|
+
return 130
|
|
232
|
+
return 0
|
peplocus/digestion.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Fast cleavage-context checks shared by exact, splice and one-edit searches."""
|
|
2
|
+
from .sequence import reverse_complement, translate
|
|
3
|
+
|
|
4
|
+
AMINO_ACIDS = frozenset("ACDEFGHIKLMNPQRSTVWY")
|
|
5
|
+
METHIONINE_REMOVAL = frozenset("ACGPSTV")
|
|
6
|
+
NON_PROLINE = AMINO_ACIDS - {"P"}
|
|
7
|
+
C_FOLLOWING = NON_PROLINE | {"*"}
|
|
8
|
+
CONTEXT_FIELDS = ["upstream_aa", "downstream_aa", "query_n_terminal_context", "query_c_terminal_context",
|
|
9
|
+
"reference_n_terminal_context", "reference_c_terminal_context", "query_tryptic", "reference_tryptic",
|
|
10
|
+
"n_cleavage_change", "c_cleavage_change", "context_evidence"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cleavage_context(peptide, upstream="", downstream=""):
|
|
14
|
+
"""Check both ends; M-based exceptions establish plausibility, not initiation."""
|
|
15
|
+
previous = upstream[-1:] # upstream holds up to two residues, in protein order
|
|
16
|
+
following = downstream[:1]
|
|
17
|
+
n = "nontryptic" if previous in AMINO_ACIDS | {"*"} else "unknown"
|
|
18
|
+
if previous in {"K", "R"} and peptide[:1] in NON_PROLINE:
|
|
19
|
+
n = "tryptic"
|
|
20
|
+
elif peptide.startswith("M"):
|
|
21
|
+
n = "possible_protein_start"
|
|
22
|
+
elif previous == "M" and peptide[:1] in METHIONINE_REMOVAL:
|
|
23
|
+
n = "possible_methionine_removal"
|
|
24
|
+
c = "nontryptic" if following in AMINO_ACIDS | {"*"} else "unknown"
|
|
25
|
+
if following == "*":
|
|
26
|
+
c = "translated_stop"
|
|
27
|
+
elif peptide[-1:] in {"K", "R"} and following in NON_PROLINE:
|
|
28
|
+
c = "tryptic"
|
|
29
|
+
return dict(context_evidence="genome", upstream_aa=upstream[-2:], downstream_aa=following,
|
|
30
|
+
n_terminal_context=n, c_terminal_context=c,
|
|
31
|
+
enzyme_compatible=n not in {"nontryptic","unknown"} and c not in {"nontryptic","unknown"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compare_contexts(query, reference, upstream="", downstream=""):
|
|
35
|
+
q=cleavage_context(query,upstream,downstream)
|
|
36
|
+
ref=cleavage_context(reference,upstream,downstream)
|
|
37
|
+
def change(which):
|
|
38
|
+
flank=upstream[-1:] if which=="n" else downstream[:1]
|
|
39
|
+
if flank not in AMINO_ACIDS | {"*"}:return "unresolved"
|
|
40
|
+
if flank=="*":return "no_peptide_bond"
|
|
41
|
+
before=ref[which+"_terminal_context"]=="tryptic"
|
|
42
|
+
after=q[which+"_terminal_context"]=="tryptic"
|
|
43
|
+
return "unchanged" if before==after else "gained" if after else "lost"
|
|
44
|
+
return dict(upstream_aa=upstream[-2:],downstream_aa=downstream[:1],
|
|
45
|
+
query_n_terminal_context=q["n_terminal_context"],query_c_terminal_context=q["c_terminal_context"],
|
|
46
|
+
reference_n_terminal_context=ref["n_terminal_context"],reference_c_terminal_context=ref["c_terminal_context"],
|
|
47
|
+
query_tryptic=q["enzyme_compatible"],reference_tryptic=ref["enzyme_compatible"],
|
|
48
|
+
n_cleavage_change=change("n"),c_cleavage_change=change("c"),context_evidence="genome")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def protein_context(protein, start, end, query=None):
|
|
52
|
+
reference=protein[start:end]
|
|
53
|
+
return compare_contexts(reference if query is None else query,reference,
|
|
54
|
+
protein[max(0,start-2):start],protein[end:end+1])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def genomic_context(reference, chrom, strand, blocks, peptide, table=1):
|
|
58
|
+
"""Only outer flanks are needed; internal split codons remain in the peptide."""
|
|
59
|
+
left, right = blocks[0][0], blocks[-1][1]
|
|
60
|
+
size = reference.get_reference_length(chrom)
|
|
61
|
+
if strand == "+":
|
|
62
|
+
upstream = reference.fetch(chrom,max(0,left-6),left)
|
|
63
|
+
downstream = reference.fetch(chrom,right,min(size,right+3))
|
|
64
|
+
# At a contig edge use complete codons nearest the peptide.
|
|
65
|
+
upstream = upstream[len(upstream)%3:]
|
|
66
|
+
else:
|
|
67
|
+
upstream = reverse_complement(reference.fetch(chrom,right,min(size,right+6)))
|
|
68
|
+
downstream = reverse_complement(reference.fetch(chrom,max(0,left-3),left))
|
|
69
|
+
upstream = upstream[len(upstream)%3:]
|
|
70
|
+
return compare_contexts(peptide,peptide,translate(upstream,table),translate(downstream,table))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extend_path(path, lengths, upstream_nt=6, downstream_nt=3):
|
|
74
|
+
"""Add flanking codons only to outer path ends, preserving all introns."""
|
|
75
|
+
chrom,strand,segments,support,motifs,source=path
|
|
76
|
+
segments=list(segments)
|
|
77
|
+
if strand=="+":
|
|
78
|
+
a,b=segments[0];segments[0]=(max(0,a-upstream_nt),b)
|
|
79
|
+
a,b=segments[-1];segments[-1]=(a,min(lengths[chrom],b+downstream_nt))
|
|
80
|
+
else:
|
|
81
|
+
a,b=segments[0];segments[0]=(a,min(lengths[chrom],b+upstream_nt))
|
|
82
|
+
a,b=segments[-1];segments[-1]=(max(0,a-downstream_nt),b)
|
|
83
|
+
return chrom,strand,tuple(segments),support,motifs,source
|
peplocus/discovery.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Optional bounded, annotation-free discovery of single-intron peptide mappings."""
|
|
2
|
+
from array import array
|
|
3
|
+
from bisect import bisect_left, bisect_right
|
|
4
|
+
from collections import Counter, defaultdict
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import ahocorasick
|
|
8
|
+
|
|
9
|
+
from .sequence import canonical, reverse_complement, translate
|
|
10
|
+
from .digestion import genomic_context
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
LOG = logging.getLogger("peplocus")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def discover(fasta, lengths, peptides, peptide_ids, table=1, il_equivalent=True, anchor=4, min_intron=20, max_intron=500000, max_seed_hits=2000, max_pairs=10000, chunk_size=2000000, stats=None, limited=None, cache_mb=256):
|
|
17
|
+
"""Join exact terminal amino-acid seeds and test all nucleotide splice phases.
|
|
18
|
+
|
|
19
|
+
Exhaustive only within the configured single-intron, canonical-motif, anchor,
|
|
20
|
+
intron-length, and repeat/candidate limits. No protein prediction is involved.
|
|
21
|
+
"""
|
|
22
|
+
stats = stats if stats is not None else Counter()
|
|
23
|
+
limited = limited if limited is not None else set()
|
|
24
|
+
targets = [pid for pid in peptide_ids if len(peptides[pid]) >= 2 * anchor]
|
|
25
|
+
stats["novel_eligible_peptides"] = len(targets)
|
|
26
|
+
if not targets:
|
|
27
|
+
return
|
|
28
|
+
seeds = {}
|
|
29
|
+
terminal = {}
|
|
30
|
+
for pid in targets:
|
|
31
|
+
seq = canonical(peptides[pid], il_equivalent)
|
|
32
|
+
left, right = seq[:anchor], seq[-anchor:]
|
|
33
|
+
seeds.setdefault(left, len(seeds))
|
|
34
|
+
seeds.setdefault(right, len(seeds))
|
|
35
|
+
terminal[pid] = (seeds[left], seeds[right])
|
|
36
|
+
automaton = ahocorasick.Automaton()
|
|
37
|
+
for word, sid in seeds.items():
|
|
38
|
+
automaton.add_word(word, sid)
|
|
39
|
+
automaton.make_automaton()
|
|
40
|
+
for chrom, length in lengths.items():
|
|
41
|
+
LOG.info("Novel discovery: collecting seed positions on %s (%s bp)", chrom, length)
|
|
42
|
+
# Small random faidx reads are expensive on shared filesystems. Cache
|
|
43
|
+
# a contig only when the forward/reverse/transient copies fit the budget;
|
|
44
|
+
# larger chromosomes retain indexed on-demand access.
|
|
45
|
+
cached_forward = fasta.fetch(chrom).upper() if length * 3 <= cache_mb * 1024 * 1024 else None
|
|
46
|
+
if cached_forward is not None:
|
|
47
|
+
stats["novel_contigs_cached"] += 1
|
|
48
|
+
positions = defaultdict(lambda: array("Q"))
|
|
49
|
+
capped = set()
|
|
50
|
+
overlap = 3 * anchor - 1
|
|
51
|
+
for start in range(0, length, chunk_size):
|
|
52
|
+
core_end = min(length, start + chunk_size)
|
|
53
|
+
end = min(length, core_end + overlap)
|
|
54
|
+
forward = cached_forward[start:end] if cached_forward is not None else fasta.fetch(chrom, start, end).upper()
|
|
55
|
+
for strand, dna in (("+", forward), ("-", reverse_complement(forward))):
|
|
56
|
+
for frame in range(3):
|
|
57
|
+
protein = canonical(translate(dna[frame:], table), il_equivalent)
|
|
58
|
+
for last, sid in automaton.iter(protein):
|
|
59
|
+
local = frame + 3 * (last - anchor + 1)
|
|
60
|
+
genomic = start + local if strand == "+" else end - local - 3 * anchor
|
|
61
|
+
if not start <= genomic < core_end:
|
|
62
|
+
continue
|
|
63
|
+
key = (strand, sid)
|
|
64
|
+
if max_seed_hits and len(positions[key]) >= max_seed_hits:
|
|
65
|
+
capped.add(key)
|
|
66
|
+
continue
|
|
67
|
+
position = genomic if strand == "+" else length - genomic - 3 * anchor
|
|
68
|
+
positions[key].append(position)
|
|
69
|
+
stats["novel_seed_keys_capped"] += len(capped)
|
|
70
|
+
LOG.info("Novel discovery: %s retained seed positions on %s; %s capped keys", sum(map(len, positions.values())), chrom, len(capped))
|
|
71
|
+
for key, values in positions.items():
|
|
72
|
+
positions[key] = array("Q", sorted(values))
|
|
73
|
+
for strand in ("+", "-"):
|
|
74
|
+
oriented = cached_forward if strand == "+" else reverse_complement(cached_forward) if cached_forward is not None else None
|
|
75
|
+
def fetch(a, b):
|
|
76
|
+
if oriented is not None:
|
|
77
|
+
return oriented[a:b]
|
|
78
|
+
if strand == "+":
|
|
79
|
+
return fasta.fetch(chrom, a, b).upper()
|
|
80
|
+
return reverse_complement(fasta.fetch(chrom, length - b, length - a).upper())
|
|
81
|
+
for pid in targets:
|
|
82
|
+
left_seed, right_seed = terminal[pid]
|
|
83
|
+
if (strand, left_seed) in capped or (strand, right_seed) in capped:
|
|
84
|
+
limited.add(pid)
|
|
85
|
+
lefts = positions.get((strand, left_seed), ())
|
|
86
|
+
rights = positions.get((strand, right_seed), ())
|
|
87
|
+
peptide = peptides[pid]
|
|
88
|
+
nt_length = len(peptide) * 3
|
|
89
|
+
expected = canonical(peptide, il_equivalent)
|
|
90
|
+
# Verify each seed's surrounding peptide context before joining
|
|
91
|
+
# loci. Most random 4-aa anchors cannot extend to a valid splice
|
|
92
|
+
# boundary. This avoids the Cartesian product of such anchors.
|
|
93
|
+
right_groups = defaultdict(list)
|
|
94
|
+
right_windows = {}
|
|
95
|
+
for right in rights:
|
|
96
|
+
end = right + 3 * anchor
|
|
97
|
+
window_start = end - nt_length
|
|
98
|
+
if window_start < 0:
|
|
99
|
+
continue
|
|
100
|
+
window = fetch(window_start, end)
|
|
101
|
+
protein = canonical(translate(window, table), il_equivalent)
|
|
102
|
+
stats["novel_seed_contexts_checked"] += 1
|
|
103
|
+
shared = 0
|
|
104
|
+
for observed, wanted in zip(reversed(protein), reversed(expected)):
|
|
105
|
+
if observed != wanted:
|
|
106
|
+
break
|
|
107
|
+
shared += 1
|
|
108
|
+
minimum_cut = max(3 * anchor, 3 * (len(peptide) - shared) - 2)
|
|
109
|
+
accepted = False
|
|
110
|
+
for cut in range(minimum_cut, nt_length - 3 * anchor + 1):
|
|
111
|
+
acceptor = window[cut-2:cut]
|
|
112
|
+
if acceptor in {"AG", "AC"}:
|
|
113
|
+
right_groups[(cut, acceptor)].append(end)
|
|
114
|
+
accepted = True
|
|
115
|
+
if accepted:
|
|
116
|
+
right_windows[end] = window
|
|
117
|
+
pairs = 0
|
|
118
|
+
exhausted = False
|
|
119
|
+
for start in lefts:
|
|
120
|
+
if start + nt_length > length:
|
|
121
|
+
continue
|
|
122
|
+
left_window = fetch(start, start + nt_length)
|
|
123
|
+
protein = canonical(translate(left_window, table), il_equivalent)
|
|
124
|
+
stats["novel_seed_contexts_checked"] += 1
|
|
125
|
+
shared = 0
|
|
126
|
+
for observed, wanted in zip(protein, expected):
|
|
127
|
+
if observed != wanted:
|
|
128
|
+
break
|
|
129
|
+
shared += 1
|
|
130
|
+
maximum_cut = min(nt_length - 3 * anchor, 3 * shared + 2)
|
|
131
|
+
for cut in range(3 * anchor, maximum_cut + 1):
|
|
132
|
+
donor_motif = left_window[cut:cut+2]
|
|
133
|
+
if donor_motif not in {"GT", "GC", "AT"}:
|
|
134
|
+
continue
|
|
135
|
+
acceptor_motif = "AC" if donor_motif == "AT" else "AG"
|
|
136
|
+
ends = right_groups.get((cut, acceptor_motif), ())
|
|
137
|
+
low = start + nt_length + min_intron
|
|
138
|
+
high = start + nt_length + max_intron
|
|
139
|
+
lo, hi = bisect_left(ends, low), bisect_right(ends, high)
|
|
140
|
+
for end in ends[lo:hi]:
|
|
141
|
+
if max_pairs and pairs >= max_pairs:
|
|
142
|
+
limited.add(pid)
|
|
143
|
+
stats["novel_pair_groups_capped"] += 1
|
|
144
|
+
exhausted = True
|
|
145
|
+
break
|
|
146
|
+
pairs += 1
|
|
147
|
+
stats["novel_anchor_pairs_tested"] += 1
|
|
148
|
+
donor = start + cut
|
|
149
|
+
acceptor = end - (nt_length - cut)
|
|
150
|
+
right_window = right_windows[end]
|
|
151
|
+
matched = translate(left_window[:cut] + right_window[cut:], table)
|
|
152
|
+
if canonical(matched, il_equivalent) != expected:
|
|
153
|
+
continue
|
|
154
|
+
if strand == "+":
|
|
155
|
+
blocks = ((start, donor), (acceptor, end))
|
|
156
|
+
else:
|
|
157
|
+
blocks = ((length - end, length - acceptor), (length - donor, length - start))
|
|
158
|
+
context = genomic_context(fasta,chrom,strand,blocks,matched,table)
|
|
159
|
+
context["context_evidence"] = "novel"
|
|
160
|
+
m = donor_motif + "-" + acceptor_motif
|
|
161
|
+
stats["novel_hits_before_deduplication"] += 1
|
|
162
|
+
yield (pid, chrom, strand, blocks, matched, "novel", 0, m, context)
|
|
163
|
+
if exhausted:
|
|
164
|
+
break
|
|
165
|
+
if exhausted:
|
|
166
|
+
break
|
|
167
|
+
oriented = None
|
|
168
|
+
cached_forward = None
|
|
169
|
+
LOG.info("Novel discovery: finished %s", chrom)
|