brucellaid 1.0.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.
- brucellaid/__init__.py +3 -0
- brucellaid/__main__.py +6 -0
- brucellaid/cli.py +233 -0
- brucellaid/data/README.md +34 -0
- brucellaid/data/mlst_alleles.fasta +2605 -0
- brucellaid/data/mlst_profiles.tsv +123 -0
- brucellaid/data/references.msh +0 -0
- brucellaid/data/references.tsv +22 -0
- brucellaid/errors.py +5 -0
- brucellaid/mash.py +231 -0
- brucellaid/mlst.py +224 -0
- brucellaid/pipeline.py +148 -0
- brucellaid/references.py +140 -0
- brucellaid/report.py +235 -0
- brucellaid/samples.py +129 -0
- brucellaid/seal.py +293 -0
- brucellaid-1.0.0.dist-info/METADATA +163 -0
- brucellaid-1.0.0.dist-info/RECORD +22 -0
- brucellaid-1.0.0.dist-info/WHEEL +5 -0
- brucellaid-1.0.0.dist-info/entry_points.txt +2 -0
- brucellaid-1.0.0.dist-info/licenses/LICENSE +21 -0
- brucellaid-1.0.0.dist-info/top_level.txt +1 -0
brucellaid/__init__.py
ADDED
brucellaid/__main__.py
ADDED
brucellaid/cli.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from argparse import ArgumentParser, ArgumentTypeError, RawDescriptionHelpFormatter
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .errors import BrucellaIDError
|
|
15
|
+
from .mash import DEFAULT_MIN_HASHES, check_mash, sketch_references
|
|
16
|
+
from .mlst import DEFAULT_MIN_READS, alleles_fasta, load_profiles
|
|
17
|
+
from .pipeline import analyse_samples
|
|
18
|
+
from .references import compare_with_sketch, load_references, reference_sketch
|
|
19
|
+
from .report import print_table, run_info, write_reports
|
|
20
|
+
from .samples import find_samples, single_sample
|
|
21
|
+
from .seal import DEFAULT_KMER_SIZE, DEFAULT_MISMATCHES, auto_memory, check_seal
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger('brucellaid')
|
|
24
|
+
|
|
25
|
+
# Expected errors are reported without a traceback
|
|
26
|
+
USER_ERRORS = (BrucellaIDError, OSError)
|
|
27
|
+
|
|
28
|
+
EPILOG = '''examples:
|
|
29
|
+
brucellaid -r1 sample_R1.fastq.gz -r2 sample_R2.fastq.gz -o results/
|
|
30
|
+
brucellaid -r1 sample.fastq.gz -o results/
|
|
31
|
+
brucellaid -i folder_of_fastq/ -o results/
|
|
32
|
+
brucellaid -i folder_of_fastq/ -o results/ -p 4 -t 16 --xlsx
|
|
33
|
+
|
|
34
|
+
documentation: https://github.com/duceppemo/BrucellaID/wiki'''
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def setup_logging(verbose=False):
|
|
38
|
+
"""Configure the package logger only, so importing brucellaid never alters the root logger."""
|
|
39
|
+
for handler in list(log.handlers):
|
|
40
|
+
log.removeHandler(handler)
|
|
41
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
42
|
+
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)-7s %(message)s', datefmt='%H:%M:%S'))
|
|
43
|
+
log.addHandler(handler)
|
|
44
|
+
log.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def positive_int(value):
|
|
48
|
+
try:
|
|
49
|
+
value = int(value)
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise ArgumentTypeError('"{}" is not an integer'.format(value)) from None
|
|
52
|
+
if value < 1:
|
|
53
|
+
raise ArgumentTypeError('must be >= 1')
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def java_memory(value):
|
|
58
|
+
if not value[:-1].isdigit() or value[-1].lower() not in 'mg':
|
|
59
|
+
raise ArgumentTypeError('"{}" is not a Java memory size such as 500m or 2g'.format(value))
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def available_cpus():
|
|
64
|
+
try:
|
|
65
|
+
return len(os.sched_getaffinity(0)) # Respects cgroup/taskset limits on Linux (e.g. SLURM jobs)
|
|
66
|
+
except AttributeError:
|
|
67
|
+
return os.cpu_count() or 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_elapsed(seconds):
|
|
71
|
+
"""A readable duration: "1h2m3s"."""
|
|
72
|
+
minutes, seconds = divmod(int(round(seconds)), 60)
|
|
73
|
+
hours, minutes = divmod(minutes, 60)
|
|
74
|
+
days, hours = divmod(hours, 24)
|
|
75
|
+
periods = [('d', days), ('h', hours), ('m', minutes), ('s', seconds)]
|
|
76
|
+
return ''.join('{}{}'.format(value, name) for name, value in periods if value) or '0s'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_parser():
|
|
80
|
+
max_cpu = available_cpus()
|
|
81
|
+
parser = ArgumentParser(prog='brucellaid', formatter_class=RawDescriptionHelpFormatter, epilog=EPILOG,
|
|
82
|
+
description='Identify the Brucella species of sequencing reads by their MASH distance\n'
|
|
83
|
+
'to 16 reference genomes, and type them with the nine-locus MLST scheme.')
|
|
84
|
+
inputs = parser.add_argument_group('input (one sample with -r1, or a folder of samples with -i)')
|
|
85
|
+
source = inputs.add_mutually_exclusive_group(required=True)
|
|
86
|
+
source.add_argument('-r1', '--r1', metavar='FILE',
|
|
87
|
+
help='Reads: single-end, or R1 of paired-end. fastq, gzipped or not.')
|
|
88
|
+
source.add_argument('-i', '--input', metavar='FOLDER',
|
|
89
|
+
help='Folder of fastq files, searched recursively. Each single-end fastq file or pair of '
|
|
90
|
+
'R1/R2 fastq files is a sample.')
|
|
91
|
+
inputs.add_argument('-r2', '--r2', metavar='FILE',
|
|
92
|
+
help='R2 reads, for paired-end fastq files.')
|
|
93
|
+
inputs.add_argument('-s', '--sample', metavar='NAME',
|
|
94
|
+
help='With -r1: sample name used in the reports. Default: the input file name, without '
|
|
95
|
+
'extension and read suffix (_R1, _1, ...).')
|
|
96
|
+
|
|
97
|
+
output = parser.add_argument_group('output')
|
|
98
|
+
output.add_argument('-o', '--output', metavar='FOLDER', required=True,
|
|
99
|
+
help='Folder to hold the reports. Created if needed.')
|
|
100
|
+
output.add_argument('--xlsx', action='store_true',
|
|
101
|
+
help='Also write the legacy vSNP-style report, stat_alignment_summary_<date>.xlsx. Needs '
|
|
102
|
+
'the xlsxwriter package.')
|
|
103
|
+
|
|
104
|
+
calling = parser.add_argument_group('identification and typing')
|
|
105
|
+
calling.add_argument('--min-hashes', metavar='N', type=int, default=DEFAULT_MIN_HASHES,
|
|
106
|
+
help='Minimum number of the reference\'s 1000 min-hashes the reads must share for a '
|
|
107
|
+
'species to be reported. Default: {}.'.format(DEFAULT_MIN_HASHES))
|
|
108
|
+
calling.add_argument('--min-reads', metavar='N', type=int, default=DEFAULT_MIN_READS,
|
|
109
|
+
help='Minimum number of reads assigned to an MLST allele for the allele to be called. '
|
|
110
|
+
'Default: {}.'.format(DEFAULT_MIN_READS))
|
|
111
|
+
calling.add_argument('--no-mlst', action='store_true',
|
|
112
|
+
help='Identify the species only, and skip the MLST typing.')
|
|
113
|
+
calling.add_argument('-k', '--kmer-size', metavar='N', type=positive_int, default=DEFAULT_KMER_SIZE,
|
|
114
|
+
help='K-mer size used to assign the reads to the MLST alleles. '
|
|
115
|
+
'Default: {}.'.format(DEFAULT_KMER_SIZE))
|
|
116
|
+
calling.add_argument('-m', '--mismatch', metavar='N', type=int, default=DEFAULT_MISMATCHES,
|
|
117
|
+
help='Mismatches allowed between an allele k-mer and a read (maximum Hamming distance). '
|
|
118
|
+
'Default: {}, as the alleles of a locus differ by a few bases.'
|
|
119
|
+
.format(DEFAULT_MISMATCHES))
|
|
120
|
+
|
|
121
|
+
data = parser.add_argument_group('reference data (the package ships all three)')
|
|
122
|
+
data.add_argument('--sketch', metavar='FILE',
|
|
123
|
+
help='MASH sketch of the reference genomes, instead of the one of the package.')
|
|
124
|
+
data.add_argument('--references', metavar='FILE', dest='references',
|
|
125
|
+
help='Table of the reference genomes and their species, instead of the one of the package.')
|
|
126
|
+
data.add_argument('--alleles', metavar='FILE',
|
|
127
|
+
help='Fasta file of the MLST alleles, instead of the one of the package.')
|
|
128
|
+
data.add_argument('--profiles', metavar='FILE',
|
|
129
|
+
help='Table of the MLST profiles, instead of the one of the package.')
|
|
130
|
+
|
|
131
|
+
other = parser.add_argument_group('performance and other options')
|
|
132
|
+
other.add_argument('-t', '--threads', metavar='N', type=positive_int, default=max_cpu,
|
|
133
|
+
help='Number of threads. Default: all available ({}).'.format(max_cpu))
|
|
134
|
+
other.add_argument('-p', '-j', '--parallel', '--jobs', metavar='N', type=positive_int, default=1, dest='parallel',
|
|
135
|
+
help='With -i: number of samples analysed at the same time, sharing the threads. Default: 1.')
|
|
136
|
+
other.add_argument('--memory', metavar='SIZE', type=java_memory,
|
|
137
|
+
help='Memory for Seal (Java heap size), per job, e.g. 2g. Default: from the size of the '
|
|
138
|
+
'allele file, within 85%% of the machine.')
|
|
139
|
+
other.add_argument('--verbose', action='store_true', help='Show debug messages, including the commands run.')
|
|
140
|
+
other.add_argument('-v', '--version', action='version', version='%(prog)s ' + __version__)
|
|
141
|
+
return parser
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def check_arguments(parser, args):
|
|
145
|
+
if args.input and (args.r2 or args.sample):
|
|
146
|
+
parser.error('-r2 and --sample are only used with -r1')
|
|
147
|
+
if args.r1 and args.parallel > 1:
|
|
148
|
+
parser.error('--parallel is only used with -i')
|
|
149
|
+
if args.min_hashes < 1:
|
|
150
|
+
parser.error('--min-hashes must be >= 1')
|
|
151
|
+
if args.min_reads < 1:
|
|
152
|
+
parser.error('--min-reads must be >= 1')
|
|
153
|
+
if args.mismatch < 0:
|
|
154
|
+
parser.error('--mismatch cannot be negative')
|
|
155
|
+
if args.mismatch >= args.kmer_size:
|
|
156
|
+
parser.error('--mismatch ({}) must be smaller than --kmer-size ({})'.format(args.mismatch, args.kmer_size))
|
|
157
|
+
if args.sample is not None and (not args.sample.strip() or '/' in args.sample or args.sample in ('.', '..')):
|
|
158
|
+
parser.error('--sample must be a name, not a path: "{}"'.format(args.sample))
|
|
159
|
+
if args.sample is not None and re.search(r'[\t\r\n]', args.sample):
|
|
160
|
+
parser.error('--sample cannot contain tabs or line breaks (they would break the table)')
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def file_bases(path):
|
|
164
|
+
"""Rough number of bases in a fasta file: its size, which is close enough to size the Seal heap."""
|
|
165
|
+
try:
|
|
166
|
+
return Path(path).stat().st_size
|
|
167
|
+
except OSError:
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv=None):
|
|
172
|
+
parser = build_parser()
|
|
173
|
+
args = parser.parse_args(argv)
|
|
174
|
+
check_arguments(parser, args)
|
|
175
|
+
setup_logging(args.verbose)
|
|
176
|
+
command = shlex.join([parser.prog, *(sys.argv[1:] if argv is None else argv)])
|
|
177
|
+
log.debug('brucellaid %s: %s', __version__, command)
|
|
178
|
+
started = datetime.now().astimezone()
|
|
179
|
+
start = time.monotonic()
|
|
180
|
+
output = Path(args.output).expanduser()
|
|
181
|
+
mlst = not args.no_mlst
|
|
182
|
+
try:
|
|
183
|
+
check_mash()
|
|
184
|
+
if mlst:
|
|
185
|
+
check_seal()
|
|
186
|
+
sketch = reference_sketch(args.sketch)
|
|
187
|
+
references = load_references(args.references)
|
|
188
|
+
alleles = alleles_fasta(args.alleles)
|
|
189
|
+
loci = load_profiles(args.profiles)[0] if mlst else ()
|
|
190
|
+
log.info('%d reference genome(s) in %s', len(sketch_references(sketch)), sketch)
|
|
191
|
+
for message in compare_with_sketch(sketch_references(sketch), references):
|
|
192
|
+
log.warning('%s', message)
|
|
193
|
+
if mlst:
|
|
194
|
+
log.info('%d MLST locus/loci: %s', len(loci), ', '.join(loci))
|
|
195
|
+
if args.input:
|
|
196
|
+
samples = find_samples(args.input, exclude=[output])
|
|
197
|
+
log.info('%d sample(s) found in %s', len(samples), args.input)
|
|
198
|
+
else:
|
|
199
|
+
samples = [single_sample(args.r1, args.r2, name=args.sample)]
|
|
200
|
+
jobs = min(args.parallel, len(samples))
|
|
201
|
+
memory = args.memory or auto_memory(file_bases(alleles), jobs=jobs)
|
|
202
|
+
results = analyse_samples(samples, jobs=jobs, threads=args.threads, sketch=sketch, references=references,
|
|
203
|
+
min_hashes=args.min_hashes, min_reads=args.min_reads, kmer_size=args.kmer_size,
|
|
204
|
+
mismatch=args.mismatch, memory=memory, alleles=args.alleles,
|
|
205
|
+
profiles=args.profiles, mlst=mlst)
|
|
206
|
+
run = run_info(command, {'Input': ' '.join(os.path.abspath(f) for f in (args.input, args.r1, args.r2) if f),
|
|
207
|
+
'Output folder': str(output.resolve()),
|
|
208
|
+
'Reference sketch': os.path.abspath(sketch),
|
|
209
|
+
'MLST alleles': os.path.abspath(alleles) if mlst else None,
|
|
210
|
+
'Minimum shared hashes': args.min_hashes,
|
|
211
|
+
'Minimum reads per allele': args.min_reads if mlst else None,
|
|
212
|
+
'K-mer size': args.kmer_size, 'Mismatches': args.mismatch,
|
|
213
|
+
'MLST': mlst, 'Threads': args.threads, 'Parallel samples': jobs,
|
|
214
|
+
'Seal memory': memory}, started)
|
|
215
|
+
rows, reports = write_reports(results, references, output, started, command=command, run=run,
|
|
216
|
+
xlsx=args.xlsx)
|
|
217
|
+
except USER_ERRORS as e:
|
|
218
|
+
log.error('%s', e)
|
|
219
|
+
log.debug('Traceback:', exc_info=True) # Shown with --verbose
|
|
220
|
+
sys.exit(1)
|
|
221
|
+
except KeyboardInterrupt:
|
|
222
|
+
log.error('Interrupted')
|
|
223
|
+
sys.exit(130)
|
|
224
|
+
|
|
225
|
+
# The results go to stdout, the messages to stderr: "brucellaid ... > results.tsv" works
|
|
226
|
+
print_table(rows)
|
|
227
|
+
for report in reports:
|
|
228
|
+
log.info('Report saved in %s', report)
|
|
229
|
+
log.info('Done in %s', format_elapsed(time.monotonic() - start))
|
|
230
|
+
failed = [r.sample for r in results if r.error]
|
|
231
|
+
if failed:
|
|
232
|
+
log.error('%d of %d sample(s) failed: %s', len(failed), len(results), ', '.join(failed))
|
|
233
|
+
sys.exit(1)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Reference data
|
|
2
|
+
|
|
3
|
+
Four files, about 290 kB. `brucellaid --sketch/--references/--alleles/--profiles` take files of your own
|
|
4
|
+
instead of these.
|
|
5
|
+
|
|
6
|
+
| File | What it is | Where it comes from |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| `references.msh` | MASH sketch of 16 *Brucella* reference genomes (k = 21, 1000 hashes) | The *Brucella* part of [vSNP](https://github.com/USDA-VS/vsnp)'s reference set, as `vsnp_reference.msh` |
|
|
9
|
+
| `references.tsv` | What each genome of the sketch is: vSNP species code, species, biovar, strain, NCBI accessions | Written for BrucellaID, from the sketch and vSNP's `species_accessions.csv` |
|
|
10
|
+
| `mlst_alleles.fasta` | 309 alleles of the nine MLST loci | [PubMLST](https://pubmlst.org/organisms/brucella-spp), "MLST (9 locus)", downloaded 2026-10-31 |
|
|
11
|
+
| `mlst_profiles.tsv` | 122 sequence types | The same scheme, the same day |
|
|
12
|
+
|
|
13
|
+
The sketch ID of a genome is the name of the file it was built from, which is what `mash dist` reports and
|
|
14
|
+
what `references.tsv` keys on.
|
|
15
|
+
|
|
16
|
+
Two genomes of the sketch, `Bceti1Cudo.fasta` (*B. ceti* strain Cudo) and `B-REF-BS4-40.fasta` (*B. suis*
|
|
17
|
+
bv. 4 strain 40), are distributed by vSNP under its own names rather than as accessions, so their
|
|
18
|
+
`accessions` column is empty and `scripts/make_reference_data.py` cannot download them.
|
|
19
|
+
|
|
20
|
+
## Rebuilding
|
|
21
|
+
```bash
|
|
22
|
+
python3 scripts/make_mlst_data.py # the two MLST files, from PubMLST
|
|
23
|
+
python3 scripts/make_reference_data.py --genomes /tmp/refs --out /tmp/references.msh # the sketch
|
|
24
|
+
```
|
|
25
|
+
PubMLST serves the data submitted up to a cut-off date unless you authenticate; the script prints the date
|
|
26
|
+
it was given. The MLST data here was downloaded on 2026-10-31, with data submitted up to 2024-12-31.
|
|
27
|
+
|
|
28
|
+
The scheme is Whatmore, Perrett and MacMillan (2007),
|
|
29
|
+
[doi:10.1186/1471-2180-7-34](https://doi.org/10.1186/1471-2180-7-34). PubMLST is maintained at the
|
|
30
|
+
University of Oxford; publications using its data should cite Jolley, Bray and Maiden (2018),
|
|
31
|
+
[doi:10.12688/wellcomeopenres.14826.1](https://doi.org/10.12688/wellcomeopenres.14826.1).
|
|
32
|
+
|
|
33
|
+
A sketch must keep MASH's defaults (k = 21, 1000 hashes): the threshold of 150 shared hashes
|
|
34
|
+
(`--min-hashes`) is read against that sketch size.
|