pytransdecoder 0.2.1__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.
Files changed (63) hide show
  1. pytransdecoder/__init__.py +7 -0
  2. pytransdecoder/__main__.py +6 -0
  3. pytransdecoder/cli.py +781 -0
  4. pytransdecoder/core/__init__.py +1 -0
  5. pytransdecoder/core/base_freqs.py +71 -0
  6. pytransdecoder/core/decoys.py +113 -0
  7. pytransdecoder/core/fasta.py +55 -0
  8. pytransdecoder/core/gene_model.py +143 -0
  9. pytransdecoder/core/gff3_parser.py +58 -0
  10. pytransdecoder/core/homology_starts.py +54 -0
  11. pytransdecoder/core/kozak.py +180 -0
  12. pytransdecoder/core/models.py +123 -0
  13. pytransdecoder/core/orf_finder.py +317 -0
  14. pytransdecoder/core/presets.py +152 -0
  15. pytransdecoder/core/prng.py +54 -0
  16. pytransdecoder/core/pwm.py +174 -0
  17. pytransdecoder/core/sequence.py +19 -0
  18. pytransdecoder/core/translator.py +143 -0
  19. pytransdecoder/core/writers.py +69 -0
  20. pytransdecoder/genome.py +338 -0
  21. pytransdecoder/longorfs.py +180 -0
  22. pytransdecoder/predict.py +1621 -0
  23. pytransdecoder/presets/apicomplexan/hexamer.scores.gz +0 -0
  24. pytransdecoder/presets/apicomplexan/kozak.tsv +9 -0
  25. pytransdecoder/presets/apicomplexan/preset.json +20 -0
  26. pytransdecoder/presets/apicomplexan/thresholds.tsv.gz +0 -0
  27. pytransdecoder/presets/apicomplexan/training_orfs.cds.gz +0 -0
  28. pytransdecoder/presets/ciliate/hexamer.scores.gz +0 -0
  29. pytransdecoder/presets/ciliate/kozak.tsv +9 -0
  30. pytransdecoder/presets/ciliate/preset.json +20 -0
  31. pytransdecoder/presets/ciliate/thresholds.tsv.gz +0 -0
  32. pytransdecoder/presets/ciliate/training_orfs.cds.gz +0 -0
  33. pytransdecoder/presets/fungus/hexamer.scores.gz +0 -0
  34. pytransdecoder/presets/fungus/kozak.tsv +9 -0
  35. pytransdecoder/presets/fungus/preset.json +20 -0
  36. pytransdecoder/presets/fungus/thresholds.tsv.gz +0 -0
  37. pytransdecoder/presets/fungus/training_orfs.cds.gz +0 -0
  38. pytransdecoder/presets/green_alga/hexamer.scores.gz +0 -0
  39. pytransdecoder/presets/green_alga/kozak.tsv +9 -0
  40. pytransdecoder/presets/green_alga/preset.json +20 -0
  41. pytransdecoder/presets/green_alga/thresholds.tsv.gz +0 -0
  42. pytransdecoder/presets/green_alga/training_orfs.cds.gz +0 -0
  43. pytransdecoder/presets/insect/hexamer.scores.gz +0 -0
  44. pytransdecoder/presets/insect/kozak.tsv +9 -0
  45. pytransdecoder/presets/insect/preset.json +20 -0
  46. pytransdecoder/presets/insect/thresholds.tsv.gz +0 -0
  47. pytransdecoder/presets/insect/training_orfs.cds.gz +0 -0
  48. pytransdecoder/presets/mammal/hexamer.scores.gz +0 -0
  49. pytransdecoder/presets/mammal/kozak.tsv +9 -0
  50. pytransdecoder/presets/mammal/preset.json +20 -0
  51. pytransdecoder/presets/mammal/thresholds.tsv.gz +0 -0
  52. pytransdecoder/presets/mammal/training_orfs.cds.gz +0 -0
  53. pytransdecoder/presets/plant/hexamer.scores.gz +0 -0
  54. pytransdecoder/presets/plant/kozak.tsv +9 -0
  55. pytransdecoder/presets/plant/preset.json +20 -0
  56. pytransdecoder/presets/plant/thresholds.tsv.gz +0 -0
  57. pytransdecoder/presets/plant/training_orfs.cds.gz +0 -0
  58. pytransdecoder-0.2.1.dist-info/METADATA +307 -0
  59. pytransdecoder-0.2.1.dist-info/RECORD +63 -0
  60. pytransdecoder-0.2.1.dist-info/WHEEL +5 -0
  61. pytransdecoder-0.2.1.dist-info/entry_points.txt +3 -0
  62. pytransdecoder-0.2.1.dist-info/licenses/LICENSE +28 -0
  63. pytransdecoder-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,338 @@
1
+ """
2
+ Propagate ORFs predicted on transcript sequences to genome coordinates through the
3
+ transcripts' genome structures, producing gene / mRNA / exon / CDS / UTR models.
4
+
5
+ Transcript structures can come from
6
+ - GTF (exon features with gene_id / transcript_id: StringTie, LRAA, Cufflinks, GENCODE ...),
7
+ - alignment GFF3 (cDNA_match / match lines with Target=<transcript> <start> <end>: GMAP,
8
+ PASA, util/gtf_to_alignment_gff3.pl),
9
+ - SAM / BAM spliced alignments (minimap2 -ax splice; primary alignments; BAM read via samtools).
10
+
11
+ For GTF and alignment GFF3 the result is the same as Perl TransDecoder's
12
+ util/cdna_alignment_orf_to_genome_orf.pl, except that each mRNA is named by its own ORF.
13
+ Transcripts of unknown strand ('.', e.g. StringTie single-exon transcripts) are taken to be
14
+ on the strand their sequence was extracted from: '+' for gffread (default), '-' for
15
+ TransDecoder's util/gtf_genome_to_cdna_fasta.pl (unstranded_as='-'). Alignment blocks carry transcript coordinates, so
16
+ transcripts with indels or clipped ends are mapped base-accurately; an ORF whose CDS is not
17
+ entirely within the aligned part of its transcript is not propagated.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import re
24
+ import subprocess
25
+ from collections import OrderedDict, defaultdict
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Dict, Iterable, List, Optional, Tuple
29
+
30
+ from pytransdecoder.core.fasta import read_fasta
31
+ from pytransdecoder.core.gene_model import CodingGene, CodingModel
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # (genome lend, genome rend, transcript start, transcript end); transcript coordinates run in
36
+ # the transcribed direction: on '-' structures they decrease with genome position
37
+ Block = Tuple[int, int, int, int]
38
+
39
+ # alignment blocks separated by at most this many genome bases (an alignment deletion) belong
40
+ # to the same exon; larger gaps are introns
41
+ MAX_INDEL = 20
42
+
43
+
44
+ @dataclass
45
+ class TranscriptStructure:
46
+ transcript_id: str
47
+ seqid: str
48
+ strand: str # orientation of the transcript sequence on the genome
49
+ blocks: List[Block] = field(default_factory=list)
50
+ gene_id: str = ""
51
+ exons: List[Tuple[int, int]] = field(default_factory=list) # merged across indels
52
+
53
+ def finalize(self, transcript_length: Optional[int] = None) -> None:
54
+ """Order blocks; assign transcript coordinates when the source has none (GTF)."""
55
+ self.blocks.sort()
56
+ if self.blocks and self.blocks[0][2] == 0:
57
+ # contiguous transcript: cumulative exon lengths in the transcribed direction
58
+ ordered = self.blocks if self.strand != "-" else self.blocks[::-1]
59
+ pos, blocks = 1, []
60
+ for g0, g1, _, _ in ordered:
61
+ n = g1 - g0 + 1
62
+ blocks.append((g0, g1, pos, pos + n - 1) if self.strand != "-" else (g0, g1, pos + n - 1, pos))
63
+ pos += n
64
+ self.blocks = sorted(blocks)
65
+ if not self.exons:
66
+ self.exons = _merge(((g0, g1) for g0, g1, _, _ in self.blocks), max_gap=MAX_INDEL)
67
+ self.exons = _merge(self.exons)
68
+
69
+ def to_genome(self, tpos: int) -> Optional[int]:
70
+ """Genome position of transcript position tpos (1-based), None if not aligned."""
71
+ for g0, g1, t0, t1 in self.blocks:
72
+ lo, hi = min(t0, t1), max(t0, t1)
73
+ if lo <= tpos <= hi:
74
+ return g0 + (tpos - t0) if t1 >= t0 else g0 + (t0 - tpos)
75
+ return None
76
+
77
+
78
+ def _merge(segments: Iterable[Tuple[int, int]], max_gap: int = 0) -> List[Tuple[int, int]]:
79
+ merged: List[List[int]] = []
80
+ for a, b in sorted(segments):
81
+ if merged and a <= merged[-1][1] + 1 + max_gap:
82
+ merged[-1][1] = max(merged[-1][1], b)
83
+ else:
84
+ merged.append([a, b])
85
+ return [(a, b) for a, b in merged]
86
+
87
+
88
+ # --------------------------------------------------------------------------------------
89
+ # readers
90
+ # --------------------------------------------------------------------------------------
91
+
92
+ def _gtf_attrs(text: str) -> Dict[str, str]:
93
+ attrs = {}
94
+ for part in text.strip().split(";"):
95
+ part = part.strip()
96
+ if not part:
97
+ continue
98
+ key, _, val = part.partition(" ")
99
+ attrs.setdefault(key, val.strip().strip('"'))
100
+ return attrs
101
+
102
+
103
+ def read_gtf(path: Path, unstranded_as: str = "+") -> Dict[str, TranscriptStructure]:
104
+ structs: Dict[str, TranscriptStructure] = OrderedDict()
105
+ with open(path) as fh:
106
+ for line in fh:
107
+ if line.startswith("#"):
108
+ continue
109
+ x = line.rstrip("\n").split("\t")
110
+ if len(x) < 9 or x[2] != "exon":
111
+ continue
112
+ a = _gtf_attrs(x[8])
113
+ tid = a.get("transcript_id")
114
+ if not tid:
115
+ raise ValueError(f"GTF exon without transcript_id: {line.strip()}")
116
+ s = structs.get(tid)
117
+ if s is None:
118
+ strand = x[6] if x[6] in ("+", "-") else unstranded_as
119
+ s = structs[tid] = TranscriptStructure(tid, x[0], strand, gene_id=a.get("gene_id", ""))
120
+ s.blocks.append((int(x[3]), int(x[4]), 0, 0))
121
+ return structs
122
+
123
+
124
+ def read_alignment_gff3(path: Path) -> Dict[str, TranscriptStructure]:
125
+ structs: Dict[str, TranscriptStructure] = OrderedDict()
126
+ target_re = re.compile(r"Target=([^;\s]+)(?:\s+(\d+)\s+(\d+))?")
127
+ with open(path) as fh:
128
+ for line in fh:
129
+ if line.startswith("#") or not line.strip():
130
+ continue
131
+ x = line.rstrip("\n").split("\t")
132
+ if len(x) < 9:
133
+ continue
134
+ m = target_re.search(x[8])
135
+ if not m:
136
+ raise ValueError(f"alignment GFF3 line without Target=: {line.strip()}")
137
+ tid, gene_id = m.group(1), ""
138
+ gt = re.match(r"GENE\^(\S+),TRANS\^(\S+)", tid) # util/gtf_to_alignment_gff3.pl
139
+ if gt:
140
+ gene_id, tid = gt.group(1), gt.group(2)
141
+ s = structs.get(tid)
142
+ if s is None:
143
+ s = structs[tid] = TranscriptStructure(tid, x[0], x[6], gene_id=gene_id)
144
+ g0, g1 = int(x[3]), int(x[4])
145
+ if m.group(2):
146
+ t0, t1 = int(m.group(2)), int(m.group(3))
147
+ t0, t1 = (min(t0, t1), max(t0, t1)) if x[6] != "-" else (max(t0, t1), min(t0, t1))
148
+ s.blocks.append((g0, g1, t0, t1))
149
+ else:
150
+ s.blocks.append((g0, g1, 0, 0))
151
+ return structs
152
+
153
+
154
+ _CIGAR_RE = re.compile(r"(\d+)([MIDNSHP=X])")
155
+
156
+
157
+ def read_sam(path: Path) -> Dict[str, TranscriptStructure]:
158
+ """Primary spliced alignments; BAM input is read through `samtools view`."""
159
+ structs: Dict[str, TranscriptStructure] = OrderedDict()
160
+ if str(path).endswith(".bam"):
161
+ proc = subprocess.Popen(["samtools", "view", "-F", "0x904", str(path)],
162
+ stdout=subprocess.PIPE, text=True)
163
+ lines = proc.stdout
164
+ else:
165
+ proc, lines = None, open(path)
166
+ try:
167
+ for line in lines:
168
+ if line.startswith("@"):
169
+ continue
170
+ x = line.split("\t", 11)
171
+ flag = int(x[1])
172
+ if flag & 0x904 or x[2] == "*":
173
+ continue
174
+ tid, seqid, gpos, cigar = x[0], x[2], int(x[3]), x[5]
175
+ ops = [(int(n), op) for n, op in _CIGAR_RE.findall(cigar)]
176
+ qlen = sum(n for n, op in ops if op in "MIS=XH")
177
+ minus = bool(flag & 16)
178
+ strand = "-" if minus else "+"
179
+ q, g = 0, gpos
180
+ blocks, exons, exon_start = [], [], gpos
181
+ for n, op in ops:
182
+ if op in "M=X":
183
+ t0, t1 = (q + 1, q + n) if not minus else (qlen - q, qlen - q - n + 1)
184
+ blocks.append((g, g + n - 1, t0, t1))
185
+ q += n
186
+ g += n
187
+ elif op in "ISH":
188
+ q += n
189
+ elif op == "D":
190
+ g += n
191
+ elif op == "N":
192
+ exons.append((exon_start, g - 1))
193
+ g += n
194
+ exon_start = g
195
+ exons.append((exon_start, g - 1))
196
+ s = TranscriptStructure(tid, seqid, strand, blocks=blocks, exons=exons)
197
+ structs[tid] = s
198
+ finally:
199
+ if proc is not None:
200
+ proc.stdout.close()
201
+ if proc.wait() != 0:
202
+ raise RuntimeError(f"samtools view {path} failed")
203
+ else:
204
+ lines.close()
205
+ return structs
206
+
207
+
208
+ def read_structures(path: Path, fmt: Optional[str] = None,
209
+ unstranded_as: str = "+") -> Dict[str, TranscriptStructure]:
210
+ path = Path(path)
211
+ if fmt is None:
212
+ name = path.name.lower()
213
+ if name.endswith((".bam", ".sam")):
214
+ fmt = "sam"
215
+ elif name.endswith(".gtf"):
216
+ fmt = "gtf"
217
+ else:
218
+ fmt = "gff3"
219
+ structs = read_gtf(path, unstranded_as) if fmt == "gtf" else \
220
+ {"gff3": read_alignment_gff3, "sam": read_sam}[fmt](path)
221
+ for s in structs.values():
222
+ s.finalize()
223
+ return structs
224
+
225
+
226
+ @dataclass
227
+ class TranscriptOrf:
228
+ transcript_id: str
229
+ model_id: str
230
+ gene_id: str
231
+ name: str
232
+ strand: str
233
+ cds_lend: int
234
+ cds_rend: int
235
+
236
+
237
+ def read_orfs(td_gff3: Path) -> "OrderedDict[str, List[TranscriptOrf]]":
238
+ """ORFs of a TransDecoder transcript-level GFF3 (Perl or PyTransDecoder), by transcript."""
239
+ gene_names: Dict[str, str] = {}
240
+ mrnas: "OrderedDict[str, dict]" = OrderedDict()
241
+ cds: Dict[str, List[int]] = defaultdict(list)
242
+ with open(td_gff3) as fh:
243
+ for line in fh:
244
+ x = line.rstrip("\n").split("\t")
245
+ if line.startswith("#") or len(x) != 9:
246
+ continue
247
+ attrs = dict(kv.split("=", 1) for kv in x[8].split(";") if "=" in kv)
248
+ if x[2] == "gene":
249
+ gene_names[attrs["ID"]] = attrs.get("Name", "").strip('"')
250
+ elif x[2] == "mRNA":
251
+ mrnas[attrs["ID"]] = {"seqid": x[0], "gene": attrs.get("Parent", ""), "strand": x[6]}
252
+ elif x[2] == "CDS":
253
+ cds[attrs["Parent"]] += [int(x[3]), int(x[4])]
254
+ orfs: "OrderedDict[str, List[TranscriptOrf]]" = OrderedDict()
255
+ for model_id, m in mrnas.items():
256
+ if not cds.get(model_id):
257
+ continue
258
+ orf = TranscriptOrf(m["seqid"], model_id, m["gene"], gene_names.get(m["gene"], ""),
259
+ m["strand"], min(cds[model_id]), max(cds[model_id]))
260
+ orfs.setdefault(m["seqid"], []).append(orf)
261
+ return orfs
262
+
263
+
264
+ def read_fasta_lengths(path: Path) -> Dict[str, int]:
265
+ return {record.id: len(record.seq) for record in read_fasta(path)}
266
+
267
+
268
+ # --------------------------------------------------------------------------------------
269
+ # propagation
270
+ # --------------------------------------------------------------------------------------
271
+
272
+ def place_orf(orf: TranscriptOrf, struct: TranscriptStructure, transcript_length: int) -> Optional[CodingModel]:
273
+ """Genome model of one ORF, or None (logged) if it cannot be placed."""
274
+ cds_lend, cds_rend = orf.cds_lend, min(orf.cds_rend, transcript_length)
275
+ tstrand = struct.strand if struct.strand in "+-" else "+"
276
+ if orf.strand == "+":
277
+ strand = tstrand
278
+ else:
279
+ if len(struct.exons) > 1:
280
+ logger.warning("%s: ORF on the opposite strand of spliced transcript %s; skipped",
281
+ orf.model_id, struct.transcript_id)
282
+ return None
283
+ strand = "-" if tstrand == "+" else "+" # single exon: orient by the ORF
284
+ g_a, g_b = struct.to_genome(cds_lend), struct.to_genome(cds_rend)
285
+ if g_a is None or g_b is None:
286
+ logger.warning("%s: CDS %d-%d of %s is not entirely in its aligned part; skipped",
287
+ orf.model_id, cds_lend, cds_rend, struct.transcript_id)
288
+ return None
289
+ return CodingModel(seqid=struct.seqid, strand=strand, exons=list(struct.exons),
290
+ cds_lend=min(g_a, g_b), cds_rend=max(g_a, g_b), model_id=orf.model_id, name=orf.name)
291
+
292
+
293
+ def propagate(orfs_by_transcript, structures: Dict[str, TranscriptStructure],
294
+ transcript_lengths: Dict[str, int]) -> Tuple[List[CodingGene], int, int]:
295
+ """Returns (genes sorted by id, number propagated, total ORFs)."""
296
+ genes: Dict[str, CodingGene] = {}
297
+ n_done = n_total = 0
298
+ for tid in sorted(orfs_by_transcript):
299
+ for orf in orfs_by_transcript[tid]:
300
+ n_total += 1
301
+ struct = structures.get(tid)
302
+ if struct is None:
303
+ logger.warning("%s has no genome structure; skipped", tid)
304
+ continue
305
+ if tid not in transcript_lengths:
306
+ raise ValueError(f"no sequence length for {tid}: provide the transcript FASTA")
307
+ model = place_orf(orf, struct, transcript_lengths[tid])
308
+ if model is None:
309
+ continue
310
+ base_gene = struct.gene_id or re.split(r"~~|::", orf.gene_id)[0]
311
+ # gene ids can repeat at different loci or strands
312
+ gene_key = f"{base_gene}^{model.seqid}^{model.strand}"
313
+ genes.setdefault(gene_key, CodingGene(gene_key)).models.append(model)
314
+ n_done += 1
315
+ return [genes[k] for k in sorted(genes)], n_done, n_total
316
+
317
+
318
+ def run(td_gff3: Path, structures_file: Path, transcripts_fasta: Path, out_gff3: Path,
319
+ structures_format: Optional[str] = None, source: str = "transdecoder",
320
+ unstranded_as: str = "+", out_bed: Optional[Path] = None) -> Tuple[int, int]:
321
+ """Propagate transcript ORFs to the genome; writes GFF3 and BED12 (default: the GFF3 path
322
+ with .gff3 replaced by .bed, as Perl TransDecoder's genome mode)."""
323
+ structures = read_structures(structures_file, structures_format, unstranded_as)
324
+ genes, n_done, n_total = propagate(read_orfs(td_gff3), structures, read_fasta_lengths(transcripts_fasta))
325
+ with open(out_gff3, "w") as out:
326
+ for gene in genes:
327
+ out.write(gene.gff3_text(source))
328
+ if out_bed is None:
329
+ out_bed = Path(str(out_gff3).removesuffix(".gff3") + ".bed")
330
+ with open(out_bed, "w") as out:
331
+ for gene in genes:
332
+ # name as Perl's Gene_obj::to_BED_format: ID=<transcript>;<gene>;<gene Name>
333
+ gene_name = (gene.name or gene.models[0].name).strip("'\"")
334
+ for model in gene.models:
335
+ name = f"ID={model.model_id};{gene.gene_id};{gene_name or model.model_id}"
336
+ out.write(model.bed12(name.replace(" ", "_")) + "\n")
337
+ logger.info("%d / %d transcript ORFs propagated to the genome", n_done, n_total)
338
+ return n_done, n_total
@@ -0,0 +1,180 @@
1
+ """
2
+ TransDecoder.LongOrfs - Extract long ORFs from transcript sequences
3
+
4
+ Phase 1 of TransDecoder pipeline.
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional, Dict
10
+ from collections import defaultdict
11
+ from .core.fasta import read_fasta
12
+ from tqdm import tqdm
13
+
14
+ from .core.orf_finder import ORFFinder
15
+ from .core.models import ORF
16
+ from .core.writers import GFF3Writer, FastaWriter
17
+ from .core.base_freqs import compute_base_counts, write_base_frequencies
18
+
19
+ # Minimum ORF length (aa). Predict's decoy q-values (stratified by ORF length) decide how much coding
20
+ # evidence short ORFs need.
21
+ DEFAULT_MIN_PROTEIN_LENGTH = 50
22
+
23
+
24
+ def run_longorfs(
25
+ transcripts_file: Path,
26
+ min_protein_length: int = DEFAULT_MIN_PROTEIN_LENGTH,
27
+ genetic_code: str = "universal",
28
+ strand_specific: bool = False,
29
+ output_dir: Optional[Path] = None,
30
+ gene_trans_map_file: Optional[Path] = None,
31
+ complete_orfs_only: bool = False,
32
+ verbose: bool = False
33
+ ):
34
+ """
35
+ Extract long ORFs from transcripts (Phase 1).
36
+
37
+ Args:
38
+ transcripts_file: Input FASTA file
39
+ min_protein_length: Minimum protein length in amino acids
40
+ genetic_code: Genetic code name
41
+ strand_specific: Only analyze top strand
42
+ output_dir: Output directory
43
+ gene_trans_map_file: Gene to transcript mapping file
44
+ complete_orfs_only: Only output complete ORFs
45
+ verbose: Verbose output
46
+ """
47
+
48
+ # Set up output directory
49
+ if output_dir is None:
50
+ output_dir = Path.cwd()
51
+ output_dir = Path(output_dir)
52
+
53
+ # Create transdecoder_dir
54
+ workdir = output_dir / f"{transcripts_file.name}.transdecoder_dir"
55
+ workdir.mkdir(parents=True, exist_ok=True)
56
+
57
+ if verbose:
58
+ print(f"Output directory: {workdir}", file=sys.stderr)
59
+
60
+ # Load gene-transcript mapping if provided
61
+ gene_trans_map = {}
62
+ if gene_trans_map_file:
63
+ with open(gene_trans_map_file) as f:
64
+ for line in f:
65
+ parts = line.strip().split('\t')
66
+ if len(parts) >= 2:
67
+ gene_id, trans_id = parts[0], parts[1]
68
+ gene_trans_map[trans_id] = gene_id
69
+
70
+ # LongOrfs settings, for Predict's decoy ORFs (same finder settings on shuffled transcripts)
71
+ import json
72
+ with open(workdir / "longorfs.params.json", "w") as fh:
73
+ json.dump({"min_protein_length": min_protein_length, "genetic_code": genetic_code,
74
+ "strand_specific": bool(strand_specific),
75
+ "complete_orfs_only": bool(complete_orfs_only)}, fh, indent=1)
76
+
77
+ # Step 1: Compute base frequencies
78
+ if verbose:
79
+ print("\n\n-first extracting base frequencies, we'll need them later.\n", file=sys.stderr)
80
+
81
+ base_freqs_file = workdir / "base_freqs.dat"
82
+ base_counts = compute_base_counts(str(transcripts_file), strand_specific)
83
+ write_base_frequencies(base_counts, str(base_freqs_file))
84
+
85
+ # Step 2: Extract ORFs
86
+ if verbose:
87
+ print("\n\n- extracting ORFs from transcripts.\n", file=sys.stderr)
88
+
89
+ # Initialize ORF finder
90
+ orf_finder = ORFFinder(
91
+ min_protein_length=min_protein_length,
92
+ allow_5prime_partial=not complete_orfs_only,
93
+ allow_3prime_partial=not complete_orfs_only,
94
+ forward_strand=True,
95
+ reverse_strand=not strand_specific,
96
+ genetic_code=genetic_code,
97
+ complete_orfs_only=complete_orfs_only
98
+ )
99
+
100
+ # Output files
101
+ prefix = workdir / "longest_orfs"
102
+ cds_file = prefix.with_suffix('.cds')
103
+ gff3_file = prefix.with_suffix('.gff3')
104
+ pep_file = prefix.with_suffix('.pep')
105
+
106
+ # Count total transcripts for progress bar
107
+ total_transcripts = sum(1 for _ in read_fasta(transcripts_file))
108
+
109
+ if verbose:
110
+ print(f"-total transcripts to examine: {total_transcripts}\n", file=sys.stderr)
111
+
112
+ # Process transcripts
113
+ model_counter = 0
114
+ trans_counter = 0
115
+ seen_prot_ids = set()
116
+
117
+ with GFF3Writer(gff3_file) as gff_writer, \
118
+ FastaWriter(cds_file) as cds_writer, \
119
+ FastaWriter(pep_file) as pep_writer:
120
+
121
+ progress = tqdm(
122
+ read_fasta(transcripts_file),
123
+ total=total_transcripts,
124
+ desc="Processing transcripts",
125
+ disable=not verbose,
126
+ file=sys.stderr
127
+ )
128
+
129
+ for record in progress:
130
+ trans_counter += 1
131
+
132
+ acc = record.id
133
+ sequence = str(record.seq)
134
+
135
+ # Find all ORFs
136
+ orfs = orf_finder.find_all_orfs(sequence, acc)
137
+
138
+ # Process each ORF
139
+ for orf in orfs:
140
+ model_counter += 1
141
+
142
+ # Generate unique model ID
143
+ pcounter = 1
144
+ model_id = f"{acc}.p{pcounter}"
145
+ while model_id in seen_prot_ids:
146
+ pcounter += 1
147
+ model_id = f"{acc}.p{pcounter}"
148
+ seen_prot_ids.add(model_id)
149
+
150
+ # Generate gene ID
151
+ if acc in gene_trans_map:
152
+ gene_id = gene_trans_map[acc]
153
+ else:
154
+ gene_id = f"GENE.{acc}"
155
+
156
+ gene_id = f"{gene_id}~~{model_id}"
157
+
158
+ # Update ORF with IDs
159
+ orf.model_id = model_id
160
+ orf.gene_id = gene_id
161
+ orf.description = f"ORF type:{orf.orf_type} ({orf.strand})"
162
+
163
+ # Write outputs
164
+ gff_writer.write_orf(orf)
165
+
166
+ # Write CDS
167
+ cds_header = f">{model_id} type:{orf.orf_type} {acc}:{orf.start}-{orf.end}({orf.strand})"
168
+ cds_writer.write_sequence(cds_header, orf.sequence)
169
+
170
+ # Write protein
171
+ pep_header = f">{model_id} type:{orf.orf_type} gc:{genetic_code} {acc}:{orf.start}-{orf.end}({orf.strand})"
172
+ pep_writer.write_sequence(pep_header, orf.protein)
173
+
174
+ # Print summary
175
+ if verbose:
176
+ print(f"\n\n#################################", file=sys.stderr)
177
+ print(f"### Done preparing long ORFs ###", file=sys.stderr)
178
+ print(f"##################################", file=sys.stderr)
179
+ print(f"\n\tUse file: {pep_file} for Pfam and/or BlastP searches to enable homology-based coding region identification.\n\n", file=sys.stderr)
180
+ print(f"\tThen, run TransDecoder.Predict\n\n", file=sys.stderr)