Source code for mavis.main

#!python
import argparse
import os
import re
import subprocess
import sys
import time

import pysam
from shortuuid import uuid

from . import __version__
from .annotate.base import BioInterval
from .annotate.constants import DEFAULTS as ANNOTATION_DEFAULTS
from .annotate.file_io import load_annotations, load_masking_regions, load_reference_genome, load_templates
from .annotate.main import main as annotate_main
from .bam.read import get_samtools_version
from .blat import get_blat_version
from .checker import check_completion
from .cluster.constants import DEFAULTS as CLUSTER_DEFAULTS
from .cluster.main import main as cluster_main
from .config import augment_parser, MavisConfig, generate_config, CustomHelpFormatter, RangeAppendAction
from .constants import SUBCOMMAND, PROTOCOL
from .error import DrawingFitError
from .illustrate.constants import DEFAULTS as ILLUSTRATION_DEFAULTS, DiagramSettings
from .illustrate.diagram import draw_multi_transcript_overlay
from .illustrate.scatter import ScatterPlot
from .interval import Interval
from .pairing.constants import DEFAULTS as PAIRING_DEFAULTS
from .pairing.main import main as pairing_main
from .submit import SubmissionScript, SCHEDULER_CONFIG
from .submit import STD_OPTIONS as STD_SUBMIT_OPTIONS
from .summary.constants import DEFAULTS as SUMMARY_DEFAULTS
from .summary.main import main as summary_main
from .tools import convert_tool_output, SUPPORTED_TOOL
from .util import bash_expands, log, log_arguments, MavisNamespace, mkdirp, output_tabbed_file
from .validate.constants import DEFAULTS as VALIDATION_DEFAULTS
from .validate.main import main as validate_main


VALIDATION_PASS_PATTERN = '*.validation-passed.tab'
ANNOTATION_PASS_PATTERN = 'annotations.tab'
PROGNAME = 'mavis'
EXIT_OK = 0
EXIT_ERROR = 1


[docs]def build_validate_command(config, libconf, inputfile, outputdir): """ returns the mavis command for running the validate step """ args = { 'masking': config.reference.masking_filename, 'reference_genome': config.reference.reference_genome_filename, 'aligner_reference': config.reference.aligner_reference, 'annotations': config.reference.annotations_filename, 'library': libconf.library, 'bam_file': libconf.bam_file, 'protocol': libconf.protocol, 'read_length': libconf.read_length, 'stdev_fragment_size': libconf.stdev_fragment_size, 'median_fragment_size': libconf.median_fragment_size, 'strand_specific': libconf.strand_specific } args.update(config.validate.items()) args.update({k: v for k, v in libconf.items() if k in args}) command = ['{} {}'.format(PROGNAME, SUBCOMMAND.VALIDATE)] for argname, value in args.items(): if isinstance(value, str): command.append('--{} "{}"'.format(argname, value)) else: command.append('--{} {}'.format(argname, value)) command.append('--input {}'.format(inputfile)) command.append('--output {}'.format(outputdir)) return ' \\\n\t'.join(command) + '\n'
[docs]def build_annotate_command(config, libconf, inputfile, outputdir): """ returns the mavis command for running the annotate step """ args = { 'reference_genome': config.reference.reference_genome_filename, 'annotations': config.reference.annotations_filename, 'template_metadata': config.reference.template_metadata_filename, 'masking': config.reference.masking_filename, 'min_orf_size': config.annotate.min_orf_size, 'max_orf_cap': config.annotate.max_orf_cap, 'library': libconf.library, 'protocol': libconf.protocol, 'min_domain_mapping_match': config.annotate.min_domain_mapping_match, 'domain_name_regex_filter': config.illustrate.domain_name_regex_filter, 'max_proximity': config.cluster.max_proximity } args.update(config.annotate.items()) args.update({k: v for k, v in libconf.items() if k in args}) command = ['{} {}'.format(PROGNAME, SUBCOMMAND.ANNOTATE)] for argname, value in args.items(): if isinstance(value, str): command.append('--{} "{}"'.format(argname, value)) else: command.append('--{} {}'.format(argname, value)) command.append('--inputs {}'.format(inputfile)) command.append('--output {}'.format(outputdir)) return ' \\\n\t'.join(command) + '\n'
[docs]def run_conversion(config, libconf, conversion_dir, assume_no_untemplated=True): """ Converts files if not already converted. Returns a list of filenames """ inputs = [] # run the conversions for input_file in libconf.inputs: output_filename = os.path.join(conversion_dir, input_file + '.tab') if input_file in config.convert: if not os.path.exists(output_filename): command = config.convert[input_file] if command[0] == 'convert_tool_output': log('converting input command:', command) output_tabbed_file(convert_tool_output(*command[1:], log=log, assume_no_untemplated=assume_no_untemplated), output_filename) else: command = ' '.join(command) + ' -o {}'.format(output_filename) log('converting input command:') log('>>>', command, time_stamp=False) subprocess.check_output(command, shell=True) inputs.append(output_filename) else: inputs.append(input_file) return inputs
[docs]def main_pipeline(config): """ runs the pipeline subcommand. Runs clustering (or just splitting if clustering is skipped) and sets up submission scripts for the other pipeline stages/steps """ batch_id = 'batch-' + str(uuid()) conversion_dir = mkdirp(os.path.join(config.output, 'converted_inputs')) pairing_inputs = [] submitall = [] jobid_var_index = 0 config.output = os.path.abspath(config.output) def get_prefix(filename): return re.sub(r'\.[^\.]+$', '', os.path.basename(filename)) job_name_by_output = {} for libconf in config.libraries.values(): base = os.path.join(config.output, '{}_{}_{}'.format(libconf.library, libconf.disease_status, libconf.protocol)) log('setting up the directory structure for', libconf.library, 'as', base) libconf.inputs = run_conversion(config, libconf, conversion_dir) # run the cluster stage cluster_output = mkdirp(os.path.join(base, SUBCOMMAND.CLUSTER)) # creates the output dir merge_args = {'batch_id': batch_id, 'output': cluster_output} merge_args['split_only'] = SUBCOMMAND.CLUSTER in config.skip_stage merge_args.update(config.reference.items()) merge_args.update(config.cluster.items()) merge_args.update(libconf.items()) log('clustering', '(split only)' if merge_args['split_only'] else '') inputs = cluster_main(log_args=True, **merge_args) for inputfile in inputs: prefix = get_prefix(inputfile) # will be batch id + job number dependency = '' if SUBCOMMAND.VALIDATE not in config.skip_stage: outputdir = mkdirp(os.path.join(base, SUBCOMMAND.VALIDATE, prefix)) command = build_validate_command(config, libconf, inputfile, outputdir) # build the submission script options = {k: config.schedule[k] for k in STD_SUBMIT_OPTIONS} options['stdout'] = outputdir options['jobname'] = 'MV_{}_{}'.format(libconf.library, prefix) if libconf.is_trans(): options['memory_limit'] = config.schedule.trans_validation_memory else: options['memory_limit'] = config.schedule.validation_memory script = SubmissionScript(command, config.schedule.scheduler, **options) scriptname = script.write(os.path.join(outputdir, 'submit.sh')) submitall.append('vjob{}=$({} {})'.format(jobid_var_index, SCHEDULER_CONFIG[config.schedule.scheduler].submit, scriptname)) # for setting up subsequent jobs and holds outputfile = os.path.join(outputdir, VALIDATION_PASS_PATTERN) job_name_by_output[outputfile] = options['jobname'] inputfile = outputfile dependency = SCHEDULER_CONFIG[config.schedule.scheduler].dependency('${{vjob{}##* }}'.format(jobid_var_index)) # annotation cannot be skipped outputdir = mkdirp(os.path.join(base, SUBCOMMAND.ANNOTATE, prefix)) command = build_annotate_command(config, libconf, inputfile, outputdir) options = {k: config.schedule[k] for k in STD_SUBMIT_OPTIONS} options['stdout'] = outputdir options['jobname'] = 'MA_{}_{}'.format(libconf.library, prefix) options['memory_limit'] = config.schedule.annotation_memory script = SubmissionScript(command, config.schedule.scheduler, **options) scriptname = script.write(os.path.join(outputdir, 'submit.sh')) submitall.append('ajob{}=$({} {} {})'.format( jobid_var_index, SCHEDULER_CONFIG[config.schedule.scheduler].submit, dependency, scriptname)) outputfile = os.path.join(outputdir, ANNOTATION_PASS_PATTERN) pairing_inputs.append(outputfile) job_name_by_output[outputfile] = options['jobname'] jobid_var_index += 1 # set up scripts for the pairing held on all of the annotation jobs outputdir = mkdirp(os.path.join(config.output, SUBCOMMAND.PAIR)) args = config.pairing.flatten() args.update({ 'output': outputdir, 'annotations': config.reference.annotations_filename }) command = ['{} {}'.format(PROGNAME, SUBCOMMAND.PAIR)] for arg, value in sorted(args.items()): if isinstance(value, str): command.append('--{} "{}"'.format(arg, value)) else: command.append('--{} {}'.format(arg, value)) command.append('--inputs {}'.format(' \\\n\t'.join(pairing_inputs))) command = ' \\\n\t'.join(command) options = {k: config.schedule[k] for k in STD_SUBMIT_OPTIONS} options['stdout'] = outputdir options['jobname'] = 'MP_{}'.format(batch_id) script = SubmissionScript(command, config.schedule.scheduler, **options) scriptname = script.write(os.path.join(outputdir, 'submit.sh')) submitall.append('jobid=$({} {} {})'.format( SCHEDULER_CONFIG[config.schedule.scheduler].submit, SCHEDULER_CONFIG[config.schedule.scheduler].dependency( ':'.join(['${{ajob{}##* }}'.format(i) for i in range(0, jobid_var_index)])), scriptname)) # set up scripts for the summary held on the pairing job outputdir = mkdirp(os.path.join(config.output, SUBCOMMAND.SUMMARY)) args = dict( output=outputdir, flanking_call_distance=config.pairing.flanking_call_distance, split_call_distance=config.pairing.split_call_distance, contig_call_distance=config.pairing.contig_call_distance, spanning_call_distance=config.pairing.spanning_call_distance, dgv_annotation=config.reference.dgv_annotation_filename, annotations=config.reference.annotations_filename, inputs=os.path.join(config.output, 'pairing/mavis_paired*.tab') ) args.update(config.summary.items()) command = ['{} {}'.format(PROGNAME, SUBCOMMAND.SUMMARY)] for arg, value in sorted(args.items()): if isinstance(value, str): command.append('--{} "{}"'.format(arg, value)) else: command.append('--{} {}'.format(arg, value)) command = ' \\\n\t'.join(command) options = {k: config.schedule[k] for k in STD_SUBMIT_OPTIONS} options['stdout'] = outputdir options['jobname'] = 'MS_{}'.format(batch_id) script = SubmissionScript(command, config.schedule.scheduler, **options) scriptname = script.write(os.path.join(outputdir, 'submit.sh')) submitall.append('{} {} {}'.format( SCHEDULER_CONFIG[config.schedule.scheduler].submit, SCHEDULER_CONFIG[config.schedule.scheduler].dependency('${jobid##* }'), scriptname)) # now write a script at the top level to submit all submitallfile = os.path.join(config.output, 'submit_pipeline_{}.sh'.format(batch_id)) log('writing:', submitallfile) with open(submitallfile, 'w') as fh: for line in submitall: fh.write(line + '\n')
[docs]def parse_overlay_args(parser, required, optional): """ parse the overlay options and check the formatting """ required.add_argument('gene_name', help='Gene ID or gene alias to be drawn') augment_parser(['annotations'], required, optional) augment_parser(['drawing_width_iter_increase', 'max_drawing_retries', 'width'], optional) optional.add_argument( '--buffer_length', default=0, type=int, help='minimum genomic length to plot on either side of the target gene') optional.add_argument( '--read_depth_plot', dest='read_depth_plots', metavar='<axis name STR> <bam FILEPATH> [bin size INT]', nmin=2, nmax=3, help='bam file to use as data for plotting read_depth', action=RangeAppendAction) optional.add_argument( '--marker', dest='markers', metavar='<label STR> <start INT> [end INT]', nmin=2, nmax=3, help='Marker on the diagram given by genomic position, May be a single position or a range. ' 'The label should be a short descriptor to avoid overlapping labels on the diagram', action=RangeAppendAction) args = MavisNamespace(**parser.parse_args().__dict__) # check complex options for marker in args.markers: if len(marker) < 3: marker.append(marker[-1]) try: marker[1] = int(marker[1]) marker[2] = int(marker[2]) except ValueError: parser.error('argument --marker: start and end must be integers: {}'.format(marker)) for plot in args.read_depth_plots: if len(plot) < 3: plot.append(1) if not os.path.exists(plot[1]): parser.error('argument --read_depth_plots: the bam file given does not exist: {}'.format(plot[1])) try: plot[2] = int(plot[2]) except ValueError: parser.error('argument --read_depth_plots: bin size must be an integer: {}'.format(plot[2])) return args
[docs]def overlay_main( gene_name, output, buffer_length, read_depth_plots, markers, annotations, annotations_filename, drawing_width_iter_increase, max_drawing_retries, **kwargs ): """ generates an overlay diagram """ # check options formatting gene_to_draw = None for chrom in annotations: for gene in annotations[chrom]: if gene_name in gene.aliases or gene_name == gene.name: gene_to_draw = gene log('Found target gene: {}(aka. {}) {}:{}-{}'.format(gene.name, gene.aliases, gene.chr, gene.start, gene.end)) break if gene_to_draw is None: raise KeyError('Could not find gene alias or id in annotations file', gene_name) settings = DiagramSettings(**kwargs) x_start = max(gene_to_draw.start - buffer_length, 1) x_end = gene_to_draw.end + buffer_length plots = [] for axis_name, bam_file, bin_size in read_depth_plots: # one plot per bam log('reading:', bam_file) samfile = pysam.AlignmentFile(bam_file, 'rb') try: points = [] for pileupcolumn in samfile.pileup(gene_to_draw.chr, x_start, x_end): points.append((pileupcolumn.pos, pileupcolumn.n)) temp = [x for x in range(0, len(points), bin_size)] temp.append(None) avg_points = [] for st, end in zip(temp[0::], temp[1::]): pos = [x for x, y in points[st:end]] pos = Interval(min(pos), max(pos)) cov = [y for x, y in points[st:end]] cov = Interval(sum(cov) / len(cov)) avg_points.append((pos, cov)) log('scatter plot {} has {} points'.format(axis_name, len(avg_points))) plots.append(ScatterPlot( avg_points, axis_name, ymin=0, ymax=max([y.start for x, y in avg_points] + [100]) )) finally: samfile.close() for i, (marker_name, marker_start, marker_end) in enumerate(markers): markers[i] = BioInterval(gene_to_draw.chr, marker_start, marker_end, name=marker_name) canvas = None attempts = 1 while True: try: canvas = draw_multi_transcript_overlay(settings, gene_to_draw, vmarkers=markers, plots=plots) break except DrawingFitError as err: if attempts > max_drawing_retries: raise err log('Drawing fit: extending window', drawing_width_iter_increase) settings.width += drawing_width_iter_increase attempts += 1 svg_output_file = os.path.join(output, '{}_{}_overlay.svg'.format(gene_to_draw.name, gene_name)) log('writing:', svg_output_file) canvas.saveas(svg_output_file)
[docs]def convert_main(inputs, outputfile, file_type, strand_specific=False, assume_no_untemplated=True): bpp_results = [] for filename in inputs: bpp_results.extend(convert_tool_output(filename, file_type, strand_specific, log, True, assume_no_untemplated=assume_no_untemplated)) if os.path.dirname(outputfile): mkdirp(os.path.dirname(outputfile)) output_tabbed_file(bpp_results, outputfile)
[docs]def main(): def usage(err=None, detail=False): umsg = '\nusage: {} {{{}}} [-h] [-v]'.format(PROGNAME, ','.join(sorted(SUBCOMMAND.values()))) helpmenu = """ required arguments: pipeline_step specifies which step in the pipeline or which subprogram should be run. See possible input values above optional arguments: -h, --help bring up this help menu -v, --version output the version number To bring up individual help menus for a given pipeline step use the -h/--help option >>> {} <pipeline step> -h """.format(PROGNAME) print(umsg) if detail: print(helpmenu) if err: print('{}: error:'.format(PROGNAME), err, '\n') return EXIT_ERROR return EXIT_OK start_time = int(time.time()) if len(sys.argv) < 2: return usage('the <pipeline step> argument is required') elif sys.argv[1] in ['-h', '--help']: return usage(detail=True) elif sys.argv[1] in ['-v', '--version']: print('{} version {}'.format('mavis', __version__)) return EXIT_OK pstep = sys.argv.pop(1) sys.argv[0] = '{} {}'.format(sys.argv[0], pstep) parser = argparse.ArgumentParser(formatter_class=CustomHelpFormatter, add_help=False) required = parser.add_argument_group('required arguments') optional = parser.add_argument_group('optional arguments') augment_parser(['help', 'version'], optional) if pstep == SUBCOMMAND.CONFIG: generate_config(parser, required, optional, log=log) return EXIT_OK elif pstep == SUBCOMMAND.CONVERT: required.add_argument('-n', '--inputs', nargs='+', help='path to the input files', required=True, metavar='FILEPATH') required.add_argument( '--file_type', choices=sorted([t for t in SUPPORTED_TOOL.values() if t != 'mavis']), required=True, help='Indicates the input file type to be parsed') augment_parser(['strand_specific', 'assume_no_untemplated'], optional) required.add_argument('--outputfile', '-o', required=True, help='path to the outputfile', metavar='FILEPATH') else: required.add_argument('-o', '--output', help='path to the output directory', required=True) if pstep == SUBCOMMAND.PIPELINE: required.add_argument('config', help='path to the input pipeline configuration file', metavar='FILEPATH') optional.add_argument( '--skip_stage', choices=[SUBCOMMAND.CLUSTER, SUBCOMMAND.VALIDATE], action='append', default=[], help='Use flag once per stage to skip. Can skip clustering or validation or both') elif pstep == SUBCOMMAND.CLUSTER: required.add_argument('-n', '--inputs', nargs='+', help='path to the input files', required=True, metavar='FILEPATH') augment_parser(['library', 'protocol', 'strand_specific', 'disease_status', 'annotations', 'masking'], required, optional) augment_parser(CLUSTER_DEFAULTS.keys(), optional) elif pstep == SUBCOMMAND.VALIDATE: required.add_argument('-n', '--input', help='path to the input file', required=True, metavar='FILEPATH') augment_parser( ['library', 'protocol', 'bam_file', 'read_length', 'stdev_fragment_size', 'median_fragment_size'] + ['strand_specific', 'annotations', 'reference_genome', 'aligner_reference', 'masking'], required, optional ) augment_parser(VALIDATION_DEFAULTS.keys(), optional) elif pstep == SUBCOMMAND.ANNOTATE: required.add_argument('-n', '--inputs', nargs='+', help='path to the input files', required=True, metavar='FILEPATH') augment_parser( ['library', 'protocol', 'annotations', 'reference_genome', 'masking', 'template_metadata'], required, optional ) augment_parser(['max_proximity'], optional) augment_parser(list(ANNOTATION_DEFAULTS.keys()) + list(ILLUSTRATION_DEFAULTS.keys()), optional) elif pstep == SUBCOMMAND.PAIR: required.add_argument('-n', '--inputs', nargs='+', help='path to the input files', required=True, metavar='FILEPATH') optional.add_argument( '-f', '--product_sequence_files', nargs='+', help='paths to fasta files with product sequences', metavar='FILEPATH', required=False, default=[]) augment_parser(['annotations'], required, optional) augment_parser(['max_proximity'] + list(PAIRING_DEFAULTS.keys()), optional) elif pstep == SUBCOMMAND.SUMMARY: required.add_argument('-n', '--inputs', nargs='+', help='path to the input files', required=True, metavar='FILEPATH') augment_parser( ['annotations', 'dgv_annotation', 'flanking_call_distance', 'split_call_distance', 'contig_call_distance', 'spanning_call_distance'], required, optional ) augment_parser(SUMMARY_DEFAULTS.keys(), optional) elif pstep == SUBCOMMAND.CHECKER: args = parser.parse_args() success_flag = check_completion(args.output) return EXIT_OK if success_flag else EXIT_ERROR elif pstep != SUBCOMMAND.OVERLAY: raise NotImplementedError('invalid value for <pipeline step>', pstep) if pstep == SUBCOMMAND.OVERLAY: args = parse_overlay_args(parser, required, optional) else: args = MavisNamespace(**parser.parse_args().__dict__) if pstep == SUBCOMMAND.VALIDATE: args.samtools_version = get_samtools_version() args.blat_version = get_blat_version() log('MAVIS: {}'.format(__version__)) log_arguments(args) rargs = args if pstep == SUBCOMMAND.PIPELINE: # load the configuration file config = MavisConfig.read(args.config) config.output = args.output config.skip_stage = args.skip_stage rargs = config.reference args = config # set all reference files to their absolute paths to make tracking them down later easier for arg in ['output', 'reference_genome', 'template_metadata', 'annotations', 'masking', 'aligner_reference', 'dgv_annotation']: try: rargs[arg] = os.path.abspath(rargs[arg]) if arg != 'output' and not os.path.isfile(rargs[arg]): raise OSError('input reference file does not exist', arg, rargs[arg]) except AttributeError: pass # try checking the input files exist try: for fname in args.inputs: if len(bash_expands(fname)) < 1: raise OSError('input file does not exist', fname) except AttributeError: pass try: if len(bash_expands(args.input)) < 1: raise OSError('input file does not exist', args.input) except AttributeError: pass # load the reference files if they have been given and reset the arguments to hold the original file name and the # loaded data try: if any([ pstep == SUBCOMMAND.CLUSTER and args.uninformative_filter, pstep == SUBCOMMAND.PIPELINE and config.cluster.uninformative_filter, pstep == SUBCOMMAND.VALIDATE and args.protocol == PROTOCOL.TRANS, pstep == SUBCOMMAND.PIPELINE and config.has_transcriptome() and SUBCOMMAND.VALIDATE not in config.skip_stage, pstep == SUBCOMMAND.PAIR or pstep == SUBCOMMAND.ANNOTATE or pstep == SUBCOMMAND.SUMMARY, pstep == SUBCOMMAND.OVERLAY ]): log('loading:', rargs.annotations) rargs.annotations_filename = rargs.annotations rargs.annotations = load_annotations(rargs.annotations) else: rargs.annotations_filename = rargs.annotations rargs.annotations = None except AttributeError as err: pass # reference genome try: if pstep in [SUBCOMMAND.VALIDATE, SUBCOMMAND.ANNOTATE]: log('loading:', rargs.reference_genome) rargs.reference_genome_filename = rargs.reference_genome rargs.reference_genome = load_reference_genome(rargs.reference_genome) else: rargs.reference_genome_filename = rargs.reference_genome rargs.reference_genome = None except AttributeError: pass # masking file try: if pstep in [SUBCOMMAND.VALIDATE, SUBCOMMAND.CLUSTER, SUBCOMMAND.PIPELINE]: log('loading:', rargs.masking) rargs.masking_filename = rargs.masking rargs.masking = load_masking_regions(rargs.masking) else: rargs.masking_filename = rargs.masking rargs.masking = None except AttributeError: pass # dgv annotation try: if pstep == SUBCOMMAND.SUMMARY: log('loading:', rargs.dgv_annotation) rargs.dgv_annotation_filename = rargs.dgv_annotation rargs.dgv_annotation = load_masking_regions(rargs.dgv_annotation) else: rargs.dgv_annotation_filename = rargs.dgv_annotation rargs.dgv_annotation = None except AttributeError: pass # template metadata try: if pstep == SUBCOMMAND.ANNOTATE: log('loading:', rargs.template_metadata) rargs.template_metadata_filename = rargs.template_metadata rargs.template_metadata = load_templates(rargs.template_metadata) else: rargs.template_metadata_filename = rargs.template_metadata rargs.template_metadata = None except AttributeError: pass # decide which main function to execute if pstep == SUBCOMMAND.CLUSTER: cluster_main(**args, start_time=start_time) elif pstep == SUBCOMMAND.VALIDATE: validate_main(**args, start_time=start_time) elif pstep == SUBCOMMAND.ANNOTATE: annotate_main(**args, start_time=start_time) elif pstep == SUBCOMMAND.PAIR: pairing_main(**args, start_time=start_time) elif pstep == SUBCOMMAND.SUMMARY: summary_main(**args, start_time=start_time) elif pstep == SUBCOMMAND.CONVERT: convert_main(**args) elif pstep == SUBCOMMAND.OVERLAY: overlay_main(**args) else: # PIPELINE main_pipeline(args) duration = int(time.time()) - start_time hours = duration - duration % 3600 minutes = duration - hours - (duration - hours) % 60 seconds = duration - hours - minutes log( 'run time (hh/mm/ss): {}:{:02d}:{:02d}'.format(hours // 3600, minutes // 60, seconds), time_stamp=False) log('run time (s): {}'.format(duration), time_stamp=False) return EXIT_OK
if __name__ == '__main__': exit(main())