GPatch 0.3.1__tar.gz → 0.3.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: GPatch
3
- Version: 0.3.1
3
+ Version: 0.3.4
4
4
  Summary: Assemble contigs into a chromosome-scalse pseudo-assembly using alignments to a reference sequence.
5
5
  Home-page: https://github.com/adadiehl/GPatch
6
6
  Author: Adam Diehl
@@ -1,8 +1,12 @@
1
1
  import sys
2
+ from glob import glob
3
+ from os.path import basename, splitext
4
+ from pathlib import Path
2
5
 
3
6
  if sys.version_info > (3, ) and sys.version_info < (3, 7):
4
7
  sys.exit("ERROR: GPatch requires Python 3.7 or greater")
5
8
 
9
+ package_dir = {'':'src'}
6
10
  try:
7
11
  from setuptools import setup, find_packages
8
12
  except ImportError:
@@ -25,14 +29,18 @@ def main():
25
29
 
26
30
  metadata = dict(
27
31
  name="GPatch",
28
- version="0.3.1",
32
+ version="0.3.4",
29
33
  author="Adam Diehl",
30
34
  author_email="adadiehl@umich.edu",
31
35
  description="Assemble contigs into a chromosome-scalse pseudo-assembly using alignments to a reference sequence.",
32
36
  long_description=readme(),
33
37
  long_description_content_type="text/markdown",
34
38
  url="https://github.com/adadiehl/GPatch",
35
- packages = find_packages(),
39
+ packages = find_packages('src'),
40
+ package_dir = {'':'src'},
41
+ py_modules = [splitext(basename(path))[0] for path in glob('src/GPatch/*.py')],
42
+ include_package_data = True,
43
+ zip_safe = False,
36
44
  package_data = {
37
45
  'GPatch': [
38
46
  "LICENSE",
@@ -63,8 +71,6 @@ def main():
63
71
  "Natural Language :: English"
64
72
  ],
65
73
  keywords = "genomics, genome assembly",
66
- include_package_data=True,
67
- zip_safe=False,
68
74
  )
69
75
 
70
76
  setup(**metadata)
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import sys, os, re
4
+ from argparse import ArgumentParser
5
+ from Bio import SeqIO
6
+ from Bio.Seq import Seq
7
+ import pysam
8
+ import random
9
+
10
+ __version__ = "0.3.4"
11
+
12
+ """
13
+ Given a name-sorted BAM file, cluster mapped reads by name and
14
+ use the maximal interval defined by the primary alignment's
15
+ cigar string to infer the contig break-points in the reference
16
+ genome.
17
+ """
18
+
19
+ def create_primary_alignments_list(query_bam, min_qual_score):
20
+ """
21
+ Extract all primary alignments from the BAM file and return
22
+ as a list.
23
+ """
24
+ ret = []
25
+ for aln in query_bam:
26
+ if aln.is_unmapped:
27
+ # Skip unmapped contigs
28
+ continue
29
+ if aln.mapping_quality < min_qual_score:
30
+ # Skip low-quality mappings
31
+ continue
32
+ if not (aln.is_supplementary or aln.is_secondary):
33
+ ret.append(aln)
34
+ return ret
35
+
36
+
37
+ def contig_breakpoints_from_cigar(primary_alignments):
38
+ """
39
+ Infer the contig breakpoints using soft-clips on the primary
40
+ alignments.
41
+ """
42
+ contig_breakpoints = {}
43
+ for contig in primary_alignments:
44
+ # Default to mapped coordinates
45
+ chrom = contig.reference_name
46
+ start = contig.reference_start
47
+ end = contig.reference_end
48
+ # Check for left and right soft-clips
49
+ if contig.cigartuples[0][0] == 4:
50
+ # left soft-clip
51
+ start -= contig.cigartuples[0][1]
52
+ if contig.cigartuples[-1][0] == 4:
53
+ # right soft-clip
54
+ end += contig.cigartuples[-1][1]
55
+ contig_breakpoints[contig.query_name] = [chrom, start, end, contig.query_name]
56
+
57
+ return contig_breakpoints
58
+
59
+
60
+ def sorted_bam_from_aln_list(aln_list, bam_header, sorted_bam_name=None):
61
+ """
62
+ Given a list of pysam AlignedSegment objects, sort by position with
63
+ a pysam system call. Returns the name of the sorted file.
64
+ """
65
+ tmp_root = ''.join(random.sample('0123456789', 10))
66
+ tmp_bam_name = tmp_root + '.tmp.bam'
67
+ if sorted_bam_name is None:
68
+ sorted_bam_name = tmp_root + '.tmp.sorted.bam'
69
+ tmp_bam = pysam.AlignmentFile(tmp_bam_name, "wb", header=bam_header)
70
+ for aln in aln_list:
71
+ tmp_bam.write(aln)
72
+ tmp_bam.close()
73
+ pysam.sort("-o", sorted_bam_name, tmp_bam_name)
74
+ os.remove(tmp_bam_name)
75
+ return sorted_bam_name
76
+
77
+
78
+ def whitelist_filter(sorted_alignments_bam, whitelist, sorted_bam_name=None):
79
+ """
80
+ Given a sorted BAM file, use a system call to SAMTools to
81
+ extract only alignments overlapping intervals within the
82
+ whitelist BED.
83
+ """
84
+ tmp_root = ''.join(random.sample('0123456789', 10))
85
+ tmp_bam_name = tmp_root + '.tmp.bam'
86
+ if sorted_bam_name is None:
87
+ sorted_bam_name = tmp_root + '.tmp.sorted.bam'
88
+ # Because pysam.view is buggy about creating output files with -o, we need
89
+ # to touch the output file before calling pysam view.
90
+ pb = open(tmp_bam_name, 'w')
91
+ pb.close()
92
+ pysam.view("-L", whitelist, "-h", "-b", "-o", tmp_bam_name, sorted_alignments_bam, catch_stdout=False)
93
+ # Sort and index the result. Not sure if this is really necessary,
94
+ # since input is already sorted. In principle, sort order may not be
95
+ # maintainted, though -- not sure this is guaranteed.
96
+ pysam.sort("-o", sorted_bam_name, tmp_bam_name)
97
+ pysam.index(sorted_bam_name)
98
+ os.remove(tmp_bam_name)
99
+ return sorted_bam_name
100
+
101
+
102
+ def contigs_to_sorted_interval_list(contigs, contig_breakpoints):
103
+ """
104
+ Given an iterator over contig alignments from pysam.fetch,
105
+ generate a list of intervals, sorted on position. Alignments are
106
+ assumed to be on the same chromosome.
107
+ """
108
+ # Push contig alignment space start, end, and name to a list
109
+ ints = []
110
+ for contig in contigs:
111
+ start = contig_breakpoints[contig.query_name][1]
112
+ end = contig_breakpoints[contig.query_name][2]
113
+ name = contig.query_name
114
+ ints.append((start, end, name))
115
+ # Sort the list by position
116
+ ret = sorted(ints, key=lambda x: (x[0], x[1]))
117
+ return ret
118
+
119
+
120
+ def cluster_contig_positions(sorted_contig_intervals, drop_nested=False):
121
+ """
122
+ Given a list of contig intervals, sorted on start then end
123
+ position, cluster intervals based on linear overlap and
124
+ return a list of clusters, optionally leaving out any nested
125
+ intervals.
126
+ """
127
+ clusters = []
128
+ for contig in sorted_contig_intervals:
129
+ #sys.stderr.write("{0}\n".format(contig))
130
+ merged = False
131
+ for cluster in clusters:
132
+ #sys.stderr.write("\t%s\n" % (cluster))
133
+ if contig[0] <= cluster[-1][1]:
134
+ if drop_nested:
135
+ if not contig[1] <= cluster[-1][1]:
136
+ cluster.append(contig)
137
+ else:
138
+ #sys.stderr.write("\tnested\n")
139
+ pass
140
+ else:
141
+ cluster.append(contig)
142
+ #sys.stderr.write("\tmerged\n")
143
+ merged = True
144
+ break
145
+ if not merged:
146
+ clusters.append([contig])
147
+ return clusters
148
+
149
+
150
+ def main():
151
+ parser = ArgumentParser(description="Starting with alignments of contigs to a reference genome, produce a chromosome-scale pseudoassembly by patching gaps between mapped contigs with sequences from the reference.")
152
+ parser.add_argument('-q', '--query_bam', metavar='SAM/BAM', type=str,
153
+ required=True, help='Path to SAM/BAM file containing non-overlapping contig mappings to the reference genome.')
154
+ parser.add_argument('-r', '--reference_fasta', metavar='FASTA', type=str,
155
+ required=True, help='Path to reference genome fasta.')
156
+ parser.add_argument('-x', '--prefix', metavar='STR', type=str,
157
+ required=False, default="",
158
+ help='Prefix to add to output file names. Default=None')
159
+ parser.add_argument('-b', '--store_final_bam', metavar='FILENAME', type=str,
160
+ required=False, default=None,
161
+ help='Store the final set of primary contig alignments to the given file name. Default: Do not store the final BAM.')
162
+ parser.add_argument('-m', '--min_qual_score', metavar='N', type=int,
163
+ required=False, default=30,
164
+ help='Minimum mapping quality score to retain an alignment. Default=30')
165
+ parser.add_argument('-w', '--whitelist', metavar='PATH', type=str,
166
+ required=False, default=None,
167
+ help='Path to BED file containing whitelist regions: i.e., the inverse of blacklist regions. Supplying this will have the effect of excluding alignments that fall entirely within blacklist regions. Default=None')
168
+ parser.add_argument('-d', '--drop_missing', type=bool,
169
+ required=False, default=False,
170
+ help='Omit unpatched reference chromosome records from the output if no contigs map to them. Default: Unpatched chromosomes are printed to output unchanged.')
171
+
172
+ args = parser.parse_args()
173
+
174
+ query_bam = pysam.AlignmentFile(args.query_bam)
175
+
176
+ # Load up the BAM into a dict, keyed on contig name.
177
+ sys.stderr.write("Loading alignments...\n")
178
+ primary_alignments = create_primary_alignments_list(query_bam, args.min_qual_score)
179
+
180
+ # Determine insertion break-points for each contig based
181
+ # on the cigar string for the primary alignment.
182
+ sys.stderr.write("Locating contig breakpoints...\n")
183
+ contig_breakpoints = contig_breakpoints_from_cigar(primary_alignments)
184
+
185
+ # Sort the useful primary alignments by position via pysam sort.
186
+ sys.stderr.write("Sorting primary alignments...\n")
187
+ sorted_primary_alignments_bam = sorted_bam_from_aln_list(primary_alignments, query_bam.header, sorted_bam_name=args.store_final_bam)
188
+ pysam.index(sorted_primary_alignments_bam)
189
+
190
+ # Exclude alignments entirely within blacklist regions if requested.
191
+ if args.whitelist is not None:
192
+ sys.stderr.write("Filtering against the whitelist...\n")
193
+ filtered_bam = whitelist_filter(sorted_primary_alignments_bam, args.whitelist)
194
+ # Swap the filtered bam in for the sorted primary alignments bam
195
+ os.remove(sorted_primary_alignments_bam)
196
+ os.remove(sorted_primary_alignments_bam + ".bai")
197
+ os.rename(filtered_bam, sorted_primary_alignments_bam)
198
+ os.rename(filtered_bam + '.bai', sorted_primary_alignments_bam + ".bai")
199
+
200
+ # Check for nested/overlapping alignment spaces among the remaining contigs.
201
+ sys.stderr.write("Checking for nested or overlapping contigs...\n")
202
+ sorted_primary_alignments = pysam.AlignmentFile(sorted_primary_alignments_bam, "rb")
203
+ final_alignments = []
204
+ for ref_seq in SeqIO.parse(args.reference_fasta, "fasta"):
205
+ # Select all contigs mapped to this sequence.
206
+ contigs = list(sorted_primary_alignments.fetch(ref_seq.id))
207
+
208
+ # Convert the list of contigs into a list of tuples: (start, end, name),
209
+ # representing the inferred contig breakpoints, sorted by start, then end position.
210
+ sorted_contig_intervals = contigs_to_sorted_interval_list(contigs, contig_breakpoints)
211
+
212
+ # Cluster the linear intervals on overlap, leaving out nested contigs
213
+ clusters = cluster_contig_positions(sorted_contig_intervals, drop_nested=True)
214
+
215
+ # Next thing to do is to push all retained contigs across clusters into
216
+ # a final list of useful contigs. Since pysam.fetch does not allow
217
+ # retrieval by alignment name, the easiest way to do this is through a
218
+ # dict of names that will allow us to make a single pass through the
219
+ # contig list to select what we need.
220
+ retained_alignments = {}
221
+ for cluster in clusters:
222
+ for interval in cluster:
223
+ retained_alignments[interval[2]] = interval
224
+ for contig in contigs:
225
+ if contig.query_name in retained_alignments:
226
+ final_alignments.append(contig)
227
+
228
+ sorted_primary_alignments.close()
229
+
230
+ # Write the final set of alignments to a sorted BAM file
231
+ sorted_primary_alignments_bam = sorted_bam_from_aln_list(final_alignments, query_bam.header, sorted_bam_name=args.store_final_bam)
232
+ pysam.index(sorted_primary_alignments_bam)
233
+
234
+ # Set up output streams for fasta, patches.bed, and contigs.bed
235
+ sys.stderr.write("Patching the genome...\n")
236
+ pf_fname = "patched.fasta"
237
+ pb_fname = "patches.bed"
238
+ cb_fname = "contigs.bed"
239
+ if args.prefix != "":
240
+ pf_fname = args.prefix + '.' + pf_fname
241
+ pb_fname = args.prefix + '.' + pb_fname
242
+ cb_fname = args.prefix + '.' + cb_fname
243
+ patched_fasta = open(pf_fname, "w") # Patched fasta
244
+ patches_bed = open(pb_fname, "w") # Reference-frame patch coordinates
245
+ contigs_bed = open(cb_fname, "w") # Patched genome frame contig coordinates
246
+
247
+ # Open up the sorted primary alignments bam for reading.
248
+ sorted_primary_alignments = pysam.AlignmentFile(sorted_primary_alignments_bam, "rb")
249
+
250
+ for ref_seq in SeqIO.parse(args.reference_fasta, "fasta"):
251
+ # Select all contigs mapped to this sequence.
252
+ contigs = list(sorted_primary_alignments.fetch(ref_seq.id))
253
+
254
+ # If no contigs map to this sequence, either print it unchanged
255
+ # or omit it, depending on args.
256
+ if len(contigs) == 0:
257
+ if args.drop_missing:
258
+ sys.stderr.write("No contigs map to %s. Omitting from output.\n" % (ref_seq.id))
259
+ else:
260
+ sys.stderr.write("No contigs map to %s. Printing the reference sequence to output unchanged.\n" % (ref_seq.id))
261
+ patched_fasta.write("%s\n" % (ref_seq.format("fasta")))
262
+ continue
263
+
264
+ pos = 0 # Tracks position on the reference chromosome
265
+ patched_seq = ""
266
+ for contig in contigs:
267
+ rstart = contig_breakpoints[contig.query_name][1]
268
+ if rstart > 0:
269
+ rstart -= 1
270
+
271
+ # Check for overlapping/bookended contig mappings.
272
+ qstart = 0 # Start of contig interval to append
273
+ if rstart > pos:
274
+ # No overlap. Append the patch, in all lower-case
275
+ # for easy identification.
276
+ patched_seq = patched_seq + ref_seq.seq[pos:rstart].lower()
277
+ # Write patch coordinates in reference frame to patches_bed
278
+ patches_bed.write("%s\t%d\t%d\n" % (ref_seq.id, pos, rstart))
279
+ else:
280
+ # Handle overlapping contig ends by trimming the 5' end of
281
+ # this contig sequence by the length of the overlap.
282
+ qstart = pos - rstart
283
+
284
+ # Append the contig sequence to the patched sequence string
285
+ contig_start = len(patched_seq)
286
+ # All patch sequences are printed in upper case.
287
+ # Overlaps with the previous contig are handled by truncating
288
+ # the 3' end of this contig sequence.
289
+ patched_seq = patched_seq + Seq(contig.query_sequence)[qstart:len(Seq(contig.query_sequence))].upper()
290
+
291
+ # Gather what we need to write BED coordinates for this contig
292
+ qstrand = "+"
293
+ if contig.is_reverse:
294
+ # Since BAM sequence is already reverse-complemented, we don't have to!
295
+ qstrand = "-"
296
+
297
+ # Write contig coordinates in patched-genome frame to contigs_bed
298
+ contigs_bed.write("%s\t%d\t%d\t%s\t.\t%s\t%d\t%d\n" % (ref_seq.id, contig_start, len(patched_seq), contig.query_name, qstrand, qstart, len(Seq(contig.query_sequence))))
299
+
300
+ # Update the current position in the reference sequence if the
301
+ # end position of the current contig is 3' of the current pos.
302
+ if contig_breakpoints[contig.query_name][2] > pos:
303
+ # This should always evaluate true in the absence of nested contigs.
304
+ pos = contig_breakpoints[contig.query_name][2]
305
+
306
+ # Once the above loop finishes, we need to add the terminal segment
307
+ # from the reference genome.
308
+ patched_seq = patched_seq + ref_seq.seq[pos:len(ref_seq.seq)]
309
+ patches_bed.write("%s\t%d\t%d\n" % (ref_seq.id, pos, len(ref_seq.seq)))
310
+
311
+ # Swap in the patched sequence for the reference sequence and print the result
312
+ ref_seq.seq = patched_seq
313
+ patched_fasta.write("%s\n" % (ref_seq.format("fasta")))
314
+
315
+ # Close open file handles, etc.
316
+ query_bam.close()
317
+ sorted_primary_alignments.close()
318
+ patched_fasta.close()
319
+ patches_bed.close()
320
+ contigs_bed.close()
321
+
322
+ # Clean up any leftover files as needed.
323
+ if args.store_final_bam is None:
324
+ os.remove(sorted_primary_alignments_bam)
325
+ os.remove(sorted_primary_alignments_bam + '.bai')
326
+
327
+ sys.stderr.write("Done!\n")
328
+
329
+ if __name__ == '__main__':
330
+ main()
331
+ exit(0)
File without changes
@@ -0,0 +1,11 @@
1
+ """
2
+ Entrypoint module, in case you use `python -mnameless`.
3
+ Why does this file exist, and why __main__? For more info, read:
4
+ - https://www.python.org/dev/peps/pep-0338/
5
+ - https://docs.python.org/2/using/cmdline.html#cmdoption-m
6
+ - https://docs.python.org/3/using/cmdline.html#cmdoption-m
7
+ """
8
+ from GPatch.GPatch import main
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: GPatch
3
- Version: 0.3.1
3
+ Version: 0.3.4
4
4
  Summary: Assemble contigs into a chromosome-scalse pseudo-assembly using alignments to a reference sequence.
5
5
  Home-page: https://github.com/adadiehl/GPatch
6
6
  Author: Adam Diehl
@@ -0,0 +1,17 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ ./CODE_OF_CONDUCT.md
6
+ ./LICENSE
7
+ ./requirements.txt
8
+ src/GPatch/GPatch.py
9
+ src/GPatch/__init__.py
10
+ src/GPatch/__main__.py
11
+ src/GPatch.egg-info/PKG-INFO
12
+ src/GPatch.egg-info/SOURCES.txt
13
+ src/GPatch.egg-info/dependency_links.txt
14
+ src/GPatch.egg-info/entry_points.txt
15
+ src/GPatch.egg-info/not-zip-safe
16
+ src/GPatch.egg-info/requires.txt
17
+ src/GPatch.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ GPatch
2
+ __init__
3
+ __main__
@@ -1,15 +0,0 @@
1
- LICENSE
2
- MANIFEST.in
3
- README.md
4
- pyproject.toml
5
- setup.py
6
- ./CODE_OF_CONDUCT.md
7
- ./LICENSE
8
- ./requirements.txt
9
- GPatch.egg-info/PKG-INFO
10
- GPatch.egg-info/SOURCES.txt
11
- GPatch.egg-info/dependency_links.txt
12
- GPatch.egg-info/entry_points.txt
13
- GPatch.egg-info/not-zip-safe
14
- GPatch.egg-info/requires.txt
15
- GPatch.egg-info/top_level.txt
@@ -1 +0,0 @@
1
-
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes