view abjbrowse2.py @ 13:1d86925dbb4c draft

planemo upload for repository https://github.com/galaxyproject/tools-iuc/tree/master/tools/jbrowse2 commit 873a12803692b0a84814a6dc08331d772d0e5492-dirty
author fubar
date Mon, 22 Jan 2024 11:52:19 +0000
parents
children 7c2e28e144f3
line wrap: on
line source

#!/usr/bin/env python
import argparse
import binascii
import datetime
import hashlib
import json
import logging
import os
import re
import shutil
import struct
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from collections import defaultdict

logging.basicConfig(level=logging.INFO)
log = logging.getLogger('jbrowse')
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
GALAXY_INFRASTRUCTURE_URL = None


class ColorScaling(object):

    COLOR_FUNCTION_TEMPLATE = """
    function(feature, variableName, glyphObject, track) {{
        var score = {score};
        {opacity}
        return 'rgba({red}, {green}, {blue}, ' + opacity + ')';
    }}
    """

    COLOR_FUNCTION_TEMPLATE_QUAL = r"""
    function(feature, variableName, glyphObject, track) {{
        var search_up = function self(sf, attr){{
            if(sf.get(attr) !== undefined){{
                return sf.get(attr);
            }}
            if(sf.parent() === undefined) {{
                return;
            }}else{{
                return self(sf.parent(), attr);
            }}
        }};

        var search_down = function self(sf, attr){{
            if(sf.get(attr) !== undefined){{
                return sf.get(attr);
            }}
            if(sf.children() === undefined) {{
                return;
            }}else{{
                var kids = sf.children();
                for(var child_idx in kids){{
                    var x = self(kids[child_idx], attr);
                    if(x !== undefined){{
                        return x;
                    }}
                }}
                return;
            }}
        }};

        var color = ({user_spec_color} || search_up(feature, 'color') || search_down(feature, 'color') || {auto_gen_color});
        var score = (search_up(feature, 'score') || search_down(feature, 'score'));
        {opacity}
        if(score === undefined){{ opacity = 1; }}
        var result = /^#?([a-f\d]{{2}})([a-f\d]{{2}})([a-f\d]{{2}})$/i.exec(color);
        var red = parseInt(result[1], 16);
        var green = parseInt(result[2], 16);
        var blue = parseInt(result[3], 16);
        if(isNaN(opacity) || opacity < 0){{ opacity = 0; }}
        return 'rgba(' + red + ',' + green + ',' + blue + ',' + opacity + ')';
    }}
    """

    OPACITY_MATH = {
        'linear': """
            var opacity = (score - ({min})) / (({max}) - ({min}));
        """,
        'logarithmic': """
            var opacity = Math.log10(score - ({min})) / Math.log10(({max}) - ({min}));
        """,
        'blast': """
            var opacity = 0;
            if(score == 0.0) {{
                opacity = 1;
            }} else {{
                opacity = (20 - Math.log10(score)) / 180;
            }}
        """
    }

    BREWER_COLOUR_IDX = 0
    BREWER_COLOUR_SCHEMES = [
        (166, 206, 227),
        (31, 120, 180),
        (178, 223, 138),
        (51, 160, 44),
        (251, 154, 153),
        (227, 26, 28),
        (253, 191, 111),
        (255, 127, 0),
        (202, 178, 214),
        (106, 61, 154),
        (255, 255, 153),
        (177, 89, 40),
        (228, 26, 28),
        (55, 126, 184),
        (77, 175, 74),
        (152, 78, 163),
        (255, 127, 0),
    ]

    BREWER_DIVERGING_PALLETES = {
        'BrBg': ("#543005", "#003c30"),
        'PiYg': ("#8e0152", "#276419"),
        'PRGn': ("#40004b", "#00441b"),
        'PuOr': ("#7f3b08", "#2d004b"),
        'RdBu': ("#67001f", "#053061"),
        'RdGy': ("#67001f", "#1a1a1a"),
        'RdYlBu': ("#a50026", "#313695"),
        'RdYlGn': ("#a50026", "#006837"),
        'Spectral': ("#9e0142", "#5e4fa2"),
    }

    def __init__(self):
        self.brewer_colour_idx = 0

    def rgb_from_hex(self, hexstr):
        # http://stackoverflow.com/questions/4296249/how-do-i-convert-a-hex-triplet-to-an-rgb-tuple-and-back
        return struct.unpack('BBB', binascii.unhexlify(hexstr))

    def min_max_gff(self, gff_file):
        min_val = None
        max_val = None
        with open(gff_file, 'r') as handle:
            for line in handle:
                try:
                    value = float(line.split('\t')[5])
                    min_val = min(value, (min_val or value))
                    max_val = max(value, (max_val or value))

                    if value < min_val:
                        min_val = value

                    if value > max_val:
                        max_val = value
                except Exception:
                    pass
        return min_val, max_val

    def hex_from_rgb(self, r, g, b):
        return '#%02x%02x%02x' % (r, g, b)

    def _get_colours(self):
        r, g, b = self.BREWER_COLOUR_SCHEMES[self.brewer_colour_idx % len(self.BREWER_COLOUR_SCHEMES)]
        self.brewer_colour_idx += 1
        return r, g, b

    def parse_menus(self, track):
        trackConfig = {'menuTemplate': [{}, {}, {}, {}]}

        if 'menu' in track['menus']:
            menu_list = [track['menus']['menu']]
            if isinstance(track['menus']['menu'], list):
                menu_list = track['menus']['menu']

            for m in menu_list:
                tpl = {
                    'action': m['action'],
                    'label': m.get('label', '{name}'),
                    'iconClass': m.get('iconClass', 'dijitIconBookmark'),
                }
                if 'url' in m:
                    tpl['url'] = m['url']
                if 'content' in m:
                    tpl['content'] = m['content']
                if 'title' in m:
                    tpl['title'] = m['title']

                trackConfig['menuTemplate'].append(tpl)

        return trackConfig

    def parse_colours(self, track, trackFormat, gff3=None):
        # Wiggle tracks have a bicolor pallete
        trackConfig = {'style': {}}
        if trackFormat == 'wiggle':

            trackConfig['style']['pos_color'] = track['wiggle']['color_pos']
            trackConfig['style']['neg_color'] = track['wiggle']['color_neg']

            if trackConfig['style']['pos_color'] == '__auto__':
                trackConfig['style']['neg_color'] = self.hex_from_rgb(*self._get_colours())
                trackConfig['style']['pos_color'] = self.hex_from_rgb(*self._get_colours())

            # Wiggle tracks can change colour at a specified place
            bc_pivot = track['wiggle']['bicolor_pivot']
            if bc_pivot not in ('mean', 'zero'):
                # The values are either one of those two strings
                # or a number
                bc_pivot = float(bc_pivot)
            trackConfig['bicolor_pivot'] = bc_pivot
        elif 'scaling' in track:
            if track['scaling']['method'] == 'ignore':
                if track['scaling']['scheme']['color'] != '__auto__':
                    trackConfig['style']['color'] = track['scaling']['scheme']['color']
                else:
                    trackConfig['style']['color'] = self.hex_from_rgb(*self._get_colours())
            else:
                # Scored method
                algo = track['scaling']['algo']
                # linear, logarithmic, blast
                scales = track['scaling']['scales']
                # type __auto__, manual (min, max)
                scheme = track['scaling']['scheme']
                # scheme -> (type (opacity), color)
                # ==================================
                # GENE CALLS OR BLAST
                # ==================================
                if trackFormat == 'blast':
                    red, green, blue = self._get_colours()
                    color_function = self.COLOR_FUNCTION_TEMPLATE.format(**{
                        'score': "feature._parent.get('score')",
                        'opacity': self.OPACITY_MATH['blast'],
                        'red': red,
                        'green': green,
                        'blue': blue,
                    })
                    trackConfig['style']['color'] = color_function.replace('\n', '')
                elif trackFormat == 'gene_calls':
                    # Default values, based on GFF3 spec
                    min_val = 0
                    max_val = 1000
                    # Get min/max and build a scoring function since JBrowse doesn't
                    if scales['type'] == 'automatic' or scales['type'] == '__auto__':
                        min_val, max_val = self.min_max_gff(gff3)
                    else:
                        min_val = scales.get('min', 0)
                        max_val = scales.get('max', 1000)

                    if scheme['color'] == '__auto__':
                        user_color = 'undefined'
                        auto_color = "'%s'" % self.hex_from_rgb(*self._get_colours())
                    elif scheme['color'].startswith('#'):
                        user_color = "'%s'" % self.hex_from_rgb(*self.rgb_from_hex(scheme['color'][1:]))
                        auto_color = 'undefined'
                    else:
                        user_color = 'undefined'
                        auto_color = "'%s'" % self.hex_from_rgb(*self._get_colours())

                    color_function = self.COLOR_FUNCTION_TEMPLATE_QUAL.format(**{
                        'opacity': self.OPACITY_MATH[algo].format(**{'max': max_val, 'min': min_val}),
                        'user_spec_color': user_color,
                        'auto_gen_color': auto_color,
                    })

                    trackConfig['style']['color'] = color_function.replace('\n', '')
        return trackConfig


def etree_to_dict(t):
    if t is None:
        return {}

    d = {t.tag: {} if t.attrib else None}
    children = list(t)
    if children:
        dd = defaultdict(list)
        for dc in map(etree_to_dict, children):
            for k, v in dc.items():
                dd[k].append(v)
        d = {t.tag: {k: v[0] if len(v) == 1 else v for k, v in dd.items()}}
    if t.attrib:
        d[t.tag].update(('@' + k, v) for k, v in t.attrib.items())
    if t.text:
        text = t.text.strip()
        if children or t.attrib:
            if text:
                d[t.tag]['#text'] = text
        else:
            d[t.tag] = text
    return d


# score comes from feature._parent.get('score') or feature.get('score')

INSTALLED_TO = os.path.dirname(os.path.realpath(__file__))


def metadata_from_node(node):
    metadata = {}
    try:
        if len(node.findall('dataset')) != 1:
            # exit early
            return metadata
    except Exception:
        return {}

    for (key, value) in node.findall('dataset')[0].attrib.items():
        metadata['dataset_%s' % key] = value

    for (key, value) in node.findall('history')[0].attrib.items():
        metadata['history_%s' % key] = value

    for (key, value) in node.findall('metadata')[0].attrib.items():
        metadata['metadata_%s' % key] = value

    for (key, value) in node.findall('tool')[0].attrib.items():
        metadata['tool_%s' % key] = value

    # Additional Mappings applied:
    metadata['dataset_edam_format'] = '<a target="_blank" href="http://edamontology.org/{0}">{1}</a>'.format(metadata['dataset_edam_format'], metadata['dataset_file_ext'])
    metadata['history_user_email'] = '<a href="mailto:{0}">{0}</a>'.format(metadata['history_user_email'])
    metadata['history_display_name'] = '<a target="_blank" href="{galaxy}/history/view/{encoded_hist_id}">{hist_name}</a>'.format(
        galaxy=GALAXY_INFRASTRUCTURE_URL,
        encoded_hist_id=metadata['history_id'],
        hist_name=metadata['history_display_name']
    )
    metadata['tool_tool'] = '<a target="_blank" href="{galaxy}/datasets/{encoded_id}/show_params">{tool_id}</a>'.format(
        galaxy=GALAXY_INFRASTRUCTURE_URL,
        encoded_id=metadata['dataset_id'],
        tool_id=metadata['tool_tool_id'],
        # tool_version=metadata['tool_tool_version'],
    )
    return metadata


class JbrowseConnector(object):

    def __init__(self, jbrowse, outdir, genomes):
        self.cs = ColorScaling()
        self.jbrowse = jbrowse
        self.outdir = outdir
        self.genome_paths = genomes
        self.tracksToIndex = []

        # This is the id of the current assembly
        self.assembly_ids = {}
        self.current_assembly_id = []

        # If upgrading, look at the existing data
        self.check_existing(self.outdir)

        self.clone_jbrowse(self.jbrowse, self.outdir)

        self.process_genomes()

    def subprocess_check_call(self, command, output=None):
        if output:
            log.debug('cd %s && %s >  %s', self.outdir, ' '.join(command), output)
            subprocess.check_call(command, cwd=self.outdir, stdout=output)
        else:
            log.debug('cd %s && %s', self.outdir, ' '.join(command))
            subprocess.check_call(command, cwd=self.outdir)

    def subprocess_popen(self, command):
        log.debug('cd %s && %s', self.outdir, command)
        p = subprocess.Popen(command, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        output, err = p.communicate()
        retcode = p.returncode
        if retcode != 0:
            log.error('cd %s && %s', self.outdir, command)
            log.error(output)
            log.error(err)
            raise RuntimeError("Command failed with exit code %s" % (retcode))

    def subprocess_check_output(self, command):
        log.debug('cd %s && %s', self.outdir, ' '.join(command))
        return subprocess.check_output(command, cwd=self.outdir)

    def symlink_or_copy(self, src, dest):
        if 'GALAXY_JBROWSE_SYMLINKS' in os.environ and bool(os.environ['GALAXY_JBROWSE_SYMLINKS']):
            cmd = ['ln', '-s', src, dest]
        else:
            cmd = ['cp', src, dest]

        return self.subprocess_check_call(cmd)

    def symlink_or_copy_load_action(self):
        if 'GALAXY_JBROWSE_SYMLINKS' in os.environ and bool(os.environ['GALAXY_JBROWSE_SYMLINKS']):
            return 'symlink'
        else:
            return 'copy'

    def check_existing(self, destination):
        existing = os.path.join(destination, 'data', "config.json")
        if os.path.exists(existing):
            with open(existing, 'r') as existing_conf:
                conf = json.load(existing_conf)
                if 'assemblies' in conf:
                    for assembly in conf['assemblies']:
                        if 'name' in assembly:
                            self.assembly_ids[assembly['name']] = None

    def process_genomes(self):
        for genome_node in self.genome_paths:
            # We only expect one input genome per run. This for loop is just
            # easier to write than the alternative / catches any possible
            # issues.
            self.add_assembly(genome_node['path'], genome_node['label'])

    def add_assembly(self, path, label, default=True):
        # Find a non-existing filename for the new genome
        # (to avoid colision when upgrading an existing instance)
        rel_seq_path = os.path.join('data', 'assembly')
        seq_path = os.path.join(self.outdir, rel_seq_path)
        fn_try = 1
        while (os.path.exists(seq_path + '.fasta') or os.path.exists(seq_path + '.fasta.gz')
               or os.path.exists(seq_path + '.fasta.gz.fai') or os.path.exists(seq_path + '.fasta.gz.gzi')):
            rel_seq_path = os.path.join('data', 'assembly%s' % fn_try)
            seq_path = os.path.join(self.outdir, rel_seq_path)
            fn_try += 1

        # Find a non-existing label for the new genome
        # (to avoid colision when upgrading an existing instance)
        lab_try = 1
        uniq_label = label
        while uniq_label in self.assembly_ids:
            uniq_label = label + str(lab_try)
            lab_try += 1

        # Find a default scaffold to display
        # TODO this may not be necessary in the future, see https://github.com/GMOD/jbrowse-components/issues/2708
        with open(path, 'r') as fa_handle:
            fa_header = fa_handle.readline()[1:].strip().split(' ')[0]

        self.assembly_ids[uniq_label] = fa_header
        if default:
            self.current_assembly_id = uniq_label

        copied_genome = seq_path + '.fasta'
        shutil.copy(path, copied_genome)

        # Compress with bgzip
        cmd = ['bgzip', copied_genome]
        self.subprocess_check_call(cmd)

        # FAI Index
        cmd = ['samtools', 'faidx', copied_genome + '.gz']
        self.subprocess_check_call(cmd)

        self.subprocess_check_call([
            'jbrowse', 'add-assembly',
            '--load', 'inPlace',
            '--name', uniq_label,
            '--type', 'bgzipFasta',
            '--target', os.path.join(self.outdir, 'data'),
            '--skipCheck',
            rel_seq_path + '.fasta.gz'])

        return uniq_label

    def text_index(self):
        # Index tracks
        args = [
            'jbrowse', 'text-index',
            '--target', os.path.join(self.outdir, 'data'),
            '--assemblies', self.current_assembly_id,
        ]

        tracks = ','.join(self.tracksToIndex)
        if tracks:
            args += ['--tracks', tracks]

            self.subprocess_check_call(args)

    def _blastxml_to_gff3(self, xml, min_gap=10):
        gff3_unrebased = tempfile.NamedTemporaryFile(delete=False)
        cmd = ['python', os.path.join(INSTALLED_TO, 'blastxml_to_gapped_gff3.py'),
               '--trim', '--trim_end', '--include_seq', '--min_gap', str(min_gap), xml]
        log.debug('cd %s && %s > %s', self.outdir, ' '.join(cmd), gff3_unrebased.name)
        subprocess.check_call(cmd, cwd=self.outdir, stdout=gff3_unrebased)
        gff3_unrebased.close()
        return gff3_unrebased.name

    def _prepare_track_style(self, xml_conf):

        style_data = {
            "type": "LinearBasicDisplay"
        }

        if 'display' in xml_conf['style']:
            style_data['type'] = xml_conf['style']['display']
            del xml_conf['style']['display']

        style_data['displayId'] = "%s_%s" % (xml_conf['label'], style_data['type'])

        style_data.update(xml_conf['style'])

        return {'displays': [style_data]}

    def add_blastxml(self, data, trackData, blastOpts, **kwargs):
        gff3 = self._blastxml_to_gff3(data, min_gap=blastOpts['min_gap'])

        if 'parent' in blastOpts and blastOpts['parent'] != 'None':
            gff3_rebased = tempfile.NamedTemporaryFile(delete=False)
            cmd = ['python', os.path.join(INSTALLED_TO, 'gff3_rebase.py')]
            if blastOpts.get('protein', 'false') == 'true':
                cmd.append('--protein2dna')
            cmd.extend([os.path.realpath(blastOpts['parent']), gff3])
            log.debug('cd %s && %s > %s', self.outdir, ' '.join(cmd), gff3_rebased.name)
            subprocess.check_call(cmd, cwd=self.outdir, stdout=gff3_rebased)
            gff3_rebased.close()

            # Replace original gff3 file
            shutil.copy(gff3_rebased.name, gff3)
            os.unlink(gff3_rebased.name)

        rel_dest = os.path.join('data', trackData['label'] + '.gff')
        dest = os.path.join(self.outdir, rel_dest)

        self._sort_gff(gff3, dest)
        os.unlink(gff3)

        style_json = self._prepare_track_style(trackData)

        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest + '.gz', config=style_json)

    def add_bigwig(self, data, trackData, wiggleOpts, **kwargs):

        rel_dest = os.path.join('data', trackData['label'] + '.bw')
        dest = os.path.join(self.outdir, rel_dest)
        self.symlink_or_copy(os.path.realpath(data), dest)

        style_json = self._prepare_track_style(trackData)

        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, config=style_json)

    # Anything ending in "am" (Bam or Cram)
    def add_xam(self, data, trackData, xamOpts, index=None, ext="bam", **kwargs):

        index_ext = "bai"
        if ext == "cram":
            index_ext = "crai"

        rel_dest = os.path.join('data', trackData['label'] + '.%s' % ext)
        dest = os.path.join(self.outdir, rel_dest)

        self.symlink_or_copy(os.path.realpath(data), dest)

        if index is not None and os.path.exists(os.path.realpath(index)):
            # xai most probably made by galaxy and stored in galaxy dirs, need to copy it to dest
            self.subprocess_check_call(['cp', os.path.realpath(index), dest + '.%s' % index_ext])
        else:
            # Can happen in exotic condition
            # e.g. if bam imported as symlink with datatype=unsorted.bam, then datatype changed to bam
            #      => no index generated by galaxy, but there might be one next to the symlink target
            #      this trick allows to skip the bam sorting made by galaxy if already done outside
            if os.path.exists(os.path.realpath(data) + '.%s' % index_ext):
                self.symlink_or_copy(os.path.realpath(data) + '.%s' % index_ext, dest + '.%s' % index_ext)
            else:
                log.warn('Could not find a bam index (.%s file) for %s', (index_ext, data))

        style_json = self._prepare_track_style(trackData)

        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, config=style_json)

    def add_vcf(self, data, trackData, vcfOpts={}, zipped=False, **kwargs):

        if zipped:
            rel_dest = os.path.join('data', trackData['label'] + '.vcf.gz')
            dest = os.path.join(self.outdir, rel_dest)
            shutil.copy(os.path.realpath(data), dest)
        else:
            rel_dest = os.path.join('data', trackData['label'] + '.vcf')
            dest = os.path.join(self.outdir, rel_dest)
            shutil.copy(os.path.realpath(data), dest)

            cmd = ['bgzip', dest]
            self.subprocess_check_call(cmd)
            cmd = ['tabix', dest + '.gz']
            self.subprocess_check_call(cmd)

            rel_dest = os.path.join('data', trackData['label'] + '.vcf.gz')

        style_json = self._prepare_track_style(trackData)

        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, config=style_json)

    def add_gff(self, data, format, trackData, gffOpts, **kwargs):
        rel_dest = os.path.join('data', trackData['label'] + '.gff')
        dest = os.path.join(self.outdir, rel_dest)

        self._sort_gff(data, dest)

        style_json = self._prepare_track_style(trackData)

        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest + '.gz', config=style_json)

    def add_bed(self, data, format, trackData, gffOpts, **kwargs):
        rel_dest = os.path.join('data', trackData['label'] + '.bed')
        dest = os.path.join(self.outdir, rel_dest)

        self._sort_bed(data, dest)

        style_json = self._prepare_track_style(trackData)

        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest + '.gz', config=style_json)

    def add_paf(self, data, trackData, pafOpts, **kwargs):
        rel_dest = os.path.join('data', trackData['label'] + '.paf')
        dest = os.path.join(self.outdir, rel_dest)

        self.symlink_or_copy(os.path.realpath(data), dest)

        added_assembly = self.add_assembly(pafOpts['genome'], pafOpts['genome_label'], default=False)

        style_json = self._prepare_track_style(trackData)

        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, assemblies=[self.current_assembly_id, added_assembly], config=style_json)

    def add_hic(self, data, trackData, hicOpts, **kwargs):
        rel_dest = os.path.join('data', trackData['label'] + '.hic')
        dest = os.path.join(self.outdir, rel_dest)

        self.symlink_or_copy(os.path.realpath(data), dest)

        style_json = self._prepare_track_style(trackData)

        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, config=style_json)

    def add_sparql(self, url, query, query_refnames, trackData):

        json_track_data = {
            "type": "FeatureTrack",
            "trackId": id,
            "name": trackData['label'],
            "adapter": {
                "type": "SPARQLAdapter",
                "endpoint": {
                    "uri": url,
                    "locationType": "UriLocation"
                },
                "queryTemplate": query
            },
            "category": [
                trackData['category']
            ],
            "assemblyNames": [
                self.current_assembly_id
            ]
        }

        if query_refnames:
            json_track_data['adapter']['refNamesQueryTemplate']: query_refnames

        self.subprocess_check_call([
            'jbrowse', 'add-track-json',
            '--target', os.path.join(self.outdir, 'data'),
            json_track_data])

        # Doesn't work as of 1.6.4, might work in the future
        # self.subprocess_check_call([
        #     'jbrowse', 'add-track',
        #     '--trackType', 'sparql',
        #     '--name', trackData['label'],
        #     '--category', trackData['category'],
        #     '--target', os.path.join(self.outdir, 'data'),
        #     '--trackId', id,
        #     '--config', '{"queryTemplate": "%s"}' % query,
        #     url])

    def _add_track(self, id, label, category, path, assemblies=[], config=None):

        assemblies_opt = self.current_assembly_id
        if assemblies:
            assemblies_opt = ','.join(assemblies)

        cmd = [
            'jbrowse', 'add-track',
            '--load', 'inPlace',
            '--name', label,
            '--category', category,
            '--target', os.path.join(self.outdir, 'data'),
            '--trackId', id,
            '--assemblyNames', assemblies_opt
        ]

        if config:
            cmd.append('--config')
            cmd.append(json.dumps(config))

        cmd.append(path)

        self.subprocess_check_call(cmd)

    def _sort_gff(self, data, dest):
        # Only index if not already done
        if not os.path.exists(dest):
            cmd = "gff3sort.pl --precise '%s' | grep -v \"^$\" > '%s'" % (data, dest)
            self.subprocess_popen(cmd)

            self.subprocess_check_call(['bgzip', '-f', dest])
            self.subprocess_check_call(['tabix', '-f', '-p', 'gff', dest + '.gz'])

    def _sort_bed(self, data, dest):
        # Only index if not already done
        if not os.path.exists(dest):
            cmd = ['sort', '-k1,1', '-k2,2n', data]
            with open(dest, 'w') as handle:
                self.subprocess_check_call(cmd, output=handle)

            self.subprocess_check_call(['bgzip', '-f', dest])
            self.subprocess_check_call(['tabix', '-f', '-p', 'bed', dest + '.gz'])

    def process_annotations(self, track):

        category = track['category'].replace('__pd__date__pd__', TODAY)
        outputTrackConfig = {
            'category': category,
        }

        mapped_chars = {
            '>': '__gt__',
            '<': '__lt__',
            "'": '__sq__',
            '"': '__dq__',
            '[': '__ob__',
            ']': '__cb__',
            '{': '__oc__',
            '}': '__cc__',
            '@': '__at__',
            '#': '__pd__',
            "": '__cn__'
        }

        for i, (dataset_path, dataset_ext, track_human_label, extra_metadata) in enumerate(track['trackfiles']):
            # Unsanitize labels (element_identifiers are always sanitized by Galaxy)
            for key, value in mapped_chars.items():
                track_human_label = track_human_label.replace(value, key)

            log.info('Processing track %s / %s (%s)', category, track_human_label, dataset_ext)
            outputTrackConfig['key'] = track_human_label
            # We add extra data to hash for the case of REST + SPARQL.
            if 'conf' in track and 'options' in track['conf'] and 'url' in track['conf']['options']:
                rest_url = track['conf']['options']['url']
            else:
                rest_url = ''

            # I chose to use track['category'] instead of 'category' here. This
            # is intentional. This way re-running the tool on a different date
            # will not generate different hashes and make comparison of outputs
            # much simpler.
            hashData = [str(dataset_path), track_human_label, track['category'], rest_url, self.current_assembly_id]
            hashData = '|'.join(hashData).encode('utf-8')
            outputTrackConfig['label'] = hashlib.md5(hashData).hexdigest() + '_%s' % i
            outputTrackConfig['metadata'] = extra_metadata

            outputTrackConfig['style'] = track['style']

            if 'menus' in track['conf']['options']:
                menus = self.cs.parse_menus(track['conf']['options'])
                outputTrackConfig.update(menus)

            if dataset_ext in ('gff', 'gff3'):
                self.add_gff(dataset_path, dataset_ext, outputTrackConfig,
                             track['conf']['options']['gff'])
            elif dataset_ext == 'bed':
                self.add_bed(dataset_path, dataset_ext, outputTrackConfig,
                             track['conf']['options']['gff'])
            elif dataset_ext == 'bigwig':
                self.add_bigwig(dataset_path, outputTrackConfig,
                                track['conf']['options']['wiggle'])
            elif dataset_ext == 'bam':
                real_indexes = track['conf']['options']['pileup']['bam_indices']['bam_index']
                if not isinstance(real_indexes, list):
                    # <bam_indices>
                    #  <bam_index>/path/to/a.bam.bai</bam_index>
                    # </bam_indices>
                    #
                    # The above will result in the 'bam_index' key containing a
                    # string. If there are two or more indices, the container
                    # becomes a list. Fun!
                    real_indexes = [real_indexes]

                self.add_xam(dataset_path, outputTrackConfig,
                             track['conf']['options']['pileup'],
                             index=real_indexes[i], ext="bam")
            elif dataset_ext == 'cram':
                real_indexes = track['conf']['options']['cram']['cram_indices']['cram_index']
                if not isinstance(real_indexes, list):
                    # <bam_indices>
                    #  <bam_index>/path/to/a.bam.bai</bam_index>
                    # </bam_indices>
                    #
                    # The above will result in the 'bam_index' key containing a
                    # string. If there are two or more indices, the container
                    # becomes a list. Fun!
                    real_indexes = [real_indexes]

                self.add_xam(dataset_path, outputTrackConfig,
                             track['conf']['options']['cram'],
                             index=real_indexes[i], ext="cram")
            elif dataset_ext == 'blastxml':
                self.add_blastxml(dataset_path, outputTrackConfig, track['conf']['options']['blast'])
            elif dataset_ext == 'vcf':
                self.add_vcf(dataset_path, outputTrackConfig)
            elif dataset_ext == 'vcf_bgzip':
                self.add_vcf(dataset_path, outputTrackConfig, zipped=True)
            elif dataset_ext == 'rest':
                self.add_rest(track['conf']['options']['rest']['url'], outputTrackConfig)
            elif dataset_ext == 'synteny':
                self.add_paf(dataset_path, outputTrackConfig,
                             track['conf']['options']['synteny'])
            elif dataset_ext == 'hic':
                self.add_hic(dataset_path, outputTrackConfig,
                             track['conf']['options']['hic'])
            elif dataset_ext == 'sparql':
                sparql_query = track['conf']['options']['sparql']['query']
                for key, value in mapped_chars.items():
                    sparql_query = sparql_query.replace(value, key)
                sparql_query_refnames = track['conf']['options']['sparql']['query_refnames']
                for key, value in mapped_chars.items():
                    sparql_query_refnames = sparql_query_refnames.replace(value, key)
                self.add_sparql(track['conf']['options']['sparql']['url'], sparql_query, sparql_query_refnames, outputTrackConfig)
            else:
                log.warn('Do not know how to handle %s', dataset_ext)

            # Return non-human label for use in other fields
            yield outputTrackConfig['label']

    def add_default_session(self, data):
        """
            Add some default session settings: set some assemblies/tracks on/off
        """
        tracks_data = []

        # TODO using the default session for now, but check out session specs in the future https://github.com/GMOD/jbrowse-components/issues/2708

        # We need to know the track type from the config.json generated just before
        config_path = os.path.join(self.outdir, 'data', 'config.json')
        track_types = {}
        with open(config_path, 'r') as config_file:
            config_json = json.load(config_file)

        for track_conf in config_json['tracks']:
            track_types[track_conf['trackId']] = track_conf['type']

        for on_track in data['visibility']['default_on']:
            # TODO several problems with this currently
            # - we are forced to copy the same kind of style config as the per track config from _prepare_track_style (not exactly the same though)
            # - we get an error when refreshing the page
            # - this could be solved by session specs, see https://github.com/GMOD/jbrowse-components/issues/2708
            style_data = {
                "type": "LinearBasicDisplay",
                "height": 100
            }

            if on_track in data['style']:
                if 'display' in data['style'][on_track]:
                    style_data['type'] = data['style'][on_track]['display']
                    del data['style'][on_track]['display']

                style_data.update(data['style'][on_track])

            if on_track in data['style_labels']:
                # TODO fix this: it should probably go in a renderer block (SvgFeatureRenderer) but still does not work
                # TODO move this to per track displays?
                style_data['labels'] = data['style_labels'][on_track]

            tracks_data.append({
                "type": track_types[on_track],
                "configuration": on_track,
                "displays": [
                    style_data
                ]
            })

        # The view for the assembly we're adding
        view_json = {
            "type": "LinearGenomeView",
            "tracks": tracks_data
        }

        refName = None
        if data.get('defaultLocation', ''):
            loc_match = re.search(r'^(\w+):(\d+)\.+(\d+)$', data['defaultLocation'])
            if loc_match:
                refName = loc_match.group(1)
                start = int(loc_match.group(2))
                end = int(loc_match.group(3))
        elif self.assembly_ids[self.current_assembly_id] is not None:
            refName = self.assembly_ids[self.current_assembly_id]
            start = 0
            end = 1000000  # Booh, hard coded! waiting for https://github.com/GMOD/jbrowse-components/issues/2708

        if refName is not None:
            # TODO displayedRegions is not just zooming to the region, it hides the rest of the chromosome
            view_json['displayedRegions'] = [{
                "refName": refName,
                "start": start,
                "end": end,
                "reversed": False,
                "assemblyName": self.current_assembly_id
            }]

        session_name = data.get('session_name', "New session")
        if not session_name:
            session_name = "New session"

        # Merge with possibly existing defaultSession (if upgrading a jbrowse instance)
        session_json = {}
        if 'defaultSession' in config_json:
            session_json = config_json['defaultSession']

        session_json["name"] = session_name

        if 'views' not in session_json:
            session_json['views'] = []

        session_json['views'].append(view_json)

        config_json['defaultSession'] = session_json

        with open(config_path, 'w') as config_file:
            json.dump(config_json, config_file, indent=2)

    def add_general_configuration(self, data):
        """
            Add some general configuration to the config.json file
        """

        config_path = os.path.join(self.outdir, 'data', 'config.json')
        with open(config_path, 'r') as config_file:
            config_json = json.load(config_file)

        config_data = {}

        config_data['disableAnalytics'] = data.get('analytics', 'false') == 'true'

        config_data['theme'] = {
            "palette": {
                "primary": {
                    "main": data.get('primary_color', '#0D233F')
                },
                "secondary": {
                    "main": data.get('secondary_color', '#721E63')
                },
                "tertiary": {
                    "main": data.get('tertiary_color', '#135560')
                },
                "quaternary": {
                    "main": data.get('quaternary_color', '#FFB11D')
                },
            },
            "typography": {
                "fontSize": int(data.get('font_size', 10))
            },
        }

        config_json['configuration'].update(config_data)

        with open(config_path, 'w') as config_file:
            json.dump(config_json, config_file, indent=2)

    def clone_jbrowse(self, jbrowse_dir, destination):
        """Clone a JBrowse directory into a destination directory.
        """

        copytree(jbrowse_dir, destination)

        try:
            shutil.rmtree(os.path.join(destination, 'test_data'))
        except OSError as e:
            log.error("Error: %s - %s." % (e.filename, e.strerror))

        if not os.path.exists(os.path.join(destination, 'data')):
            # It can already exist if upgrading an instance
            os.makedirs(os.path.join(destination, 'data'))
            log.info("makedir %s" % (os.path.join(destination, 'data')))

        os.symlink('./data/config.json', os.path.join(destination, 'config.json'))


def copytree(src, dst, symlinks=False, ignore=None):
    for item in os.listdir(src):
        s = os.path.join(src, item)
        d = os.path.join(dst, item)
        if os.path.isdir(s):
            shutil.copytree(s, d, symlinks, ignore)
        else:
            shutil.copy2(s, d)


def parse_style_conf(item):
    if 'type' in item.attrib and item.attrib['type'] in ['boolean', 'integer']:
        if item.attrib['type'] == 'boolean':
            return item.text in ("yes", "true", "True")
        elif item.attrib['type'] == 'integer':
            return int(item.text)
    else:
        return item.text


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description="", epilog="")
    parser.add_argument('xml', type=argparse.FileType('r'), help='Track Configuration')

    parser.add_argument('--jbrowse', help='Folder containing a jbrowse release')
    parser.add_argument('--outdir', help='Output directory', default='out')
    parser.add_argument('--version', '-V', action='version', version="%(prog)s 0.8.0")
    args = parser.parse_args()

    tree = ET.parse(args.xml.name)
    root = tree.getroot()

    # This should be done ASAP
    GALAXY_INFRASTRUCTURE_URL = root.find('metadata/galaxyUrl').text
    # Sometimes this comes as `localhost` without a protocol
    if not GALAXY_INFRASTRUCTURE_URL.startswith('http'):
        # so we'll prepend `http://` and hope for the best. Requests *should*
        # be GET and not POST so it should redirect OK
        GALAXY_INFRASTRUCTURE_URL = 'http://' + GALAXY_INFRASTRUCTURE_URL

    jc = JbrowseConnector(
        jbrowse=args.jbrowse,
        outdir=args.outdir,
        genomes=[
            {
                'path': os.path.realpath(x.attrib['path']),
                'meta': metadata_from_node(x.find('metadata')),
                'label': x.attrib['label']
            }
            for x in root.findall('metadata/genomes/genome')
        ]
    )

    default_session_data = {
        'visibility': {
            'default_on': [],
            'default_off': [],
        },
        'style': {},
        'style_labels': {}
    }

    # TODO add metadata to tracks
    for track in root.findall('tracks/track'):
        track_conf = {}
        track_conf['trackfiles'] = []

        trackfiles = track.findall('files/trackFile')
        if trackfiles:
            for x in track.findall('files/trackFile'):
                if trackfiles:
                    metadata = metadata_from_node(x.find('metadata'))

                    track_conf['trackfiles'].append((
                        os.path.realpath(x.attrib['path']),
                        x.attrib['ext'],
                        x.attrib['label'],
                        metadata
                    ))
        else:
            # For tracks without files (rest, sparql)
            track_conf['trackfiles'].append((
                '',  # N/A, no path for rest or sparql
                track.attrib['format'],
                track.find('options/label').text,
                {}
            ))

        track_conf['category'] = track.attrib['cat']
        track_conf['format'] = track.attrib['format']
        track_conf['style'] = {item.tag: parse_style_conf(item) for item in track.find('options/style')}

        track_conf['style'] = {item.tag: parse_style_conf(item) for item in track.find('options/style')}

        track_conf['style_labels'] = {item.tag: parse_style_conf(item) for item in track.find('options/style_labels')}

        track_conf['conf'] = etree_to_dict(track.find('options'))
        keys = jc.process_annotations(track_conf)

        for key in keys:
            default_session_data['visibility'][track.attrib.get('visibility', 'default_off')].append(key)

        default_session_data['style'][key] = track_conf['style']  # TODO do we need this anymore?
        default_session_data['style_labels'][key] = track_conf['style_labels']

    default_session_data['defaultLocation'] = root.find('metadata/general/defaultLocation').text
    default_session_data['session_name'] = root.find('metadata/general/session_name').text

    general_data = {
        'analytics': root.find('metadata/general/analytics').text,
        'primary_color': root.find('metadata/general/primary_color').text,
        'secondary_color': root.find('metadata/general/secondary_color').text,
        'tertiary_color': root.find('metadata/general/tertiary_color').text,
        'quaternary_color': root.find('metadata/general/quaternary_color').text,
        'font_size': root.find('metadata/general/font_size').text,
    }

    jc.add_default_session(default_session_data)
    jc.add_general_configuration(general_data)
    jc.text_index()