source: orange-bioinformatics/_bioinformatics/obiGeneAtlas.py @ 1654:61fbdcb67aab

Revision 1654:61fbdcb67aab, 23.8 KB checked in by mitar, 2 years ago (diff)

Merge.

Line 
1"""
2========================================
3Gene Expression Atlas (``obiGeneAtlas``)
4========================================
5
6Interface to Gene Expression Atlas.
7
8`Gene Expression Atlas <http://www.ebi.ac.uk/gxa/>`_ is a curated subset of
9gene expression experiments in Array Express Archive.
10
11.. autofunction:: gene_expression_atlas
12
13.. autofunction:: default_gene_matcher
14
15.. autofunction:: to_taxid
16
17"""
18
19from __future__ import absolute_import
20
21import os, shelve, sys
22from collections import defaultdict, namedtuple
23from contextlib import closing
24
25from Orange.orng import orngServerFiles
26from Orange.utils import serverfiles
27
28from . import obiGene
29
30GeneResults = namedtuple("GeneResults", "id name synonyms expressions")
31ExpressionResults = namedtuple("ExpressionResults", "ef efv up down experiments")
32ExperimentExpression = namedtuple("ExperimentExpression", "accession expression pvalue")
33
34##
35GeneAtlasResult = GeneResults
36AtlasExpressions = ExpressionResults
37AtlasExperiment = ExperimentExpression
38##
39
40CACHE_VERSION = 1
41
42
43def _cache(name="AtlasGeneResult.shelve"):
44    """ Return a open cache instance (a shelve object).
45    """
46    if not os.path.exists(orngServerFiles.localpath("GeneAtlas")):
47        try:
48            os.makedirs(orngServerFiles.localpath("GeneAtlas"))
49        except OSError:
50            pass
51    cache = shelve.open(orngServerFiles.localpath("GeneAtlas", name))
52    if cache.get(name + "__CACHE_VERSION__", None) == CACHE_VERSION:
53        return cache
54    else:
55        cache = shelve.open(orngServerFiles.localpath("GeneAtlas", name), "n")
56        cache[name + "__CACHE_VERSION__"] = CACHE_VERSION
57        return cache
58
59
60SLEEP_TIME_MULTIPLIER = 3.0
61
62def gene_expression_atlas(genes, progress_callback=None):
63    """ Return GeneResults instances for genes (genes must be valid ensembl ids).
64    """
65    import time
66    genes = list(genes)
67    result_dict = {}
68    genes_not_cached = []
69    # See which genes are already cached
70    with closing(_cache()) as cache:
71        for gene in genes:
72            if str(gene) in cache:
73                result_dict[gene] = cache[str(gene)]
74            else:
75                genes_not_cached.append(gene)
76   
77    batch_size = 10
78    start = 0
79    res = []
80    while start < len(genes_not_cached):
81        batch = genes_not_cached[start: start + batch_size]
82        start += batch_size
83        start_time = time.time()
84        batch_res = batch_gene_atlas_expression(batch)
85        # Cache the new results.
86        # TODO: handle genes without any results.
87        genes_with_no_results = set(batch) - set(r.id for r in batch_res) 
88        with closing(_cache()) as cache:
89            for atlas_res in batch_res:
90                cache[str(atlas_res.id)] = atlas_res
91                result_dict[atlas_res.id] = atlas_res
92            for g in genes_with_no_results:
93                cache[str(g)] = None
94        res.extend(batch_res)
95        # Sleep
96        if start % (batch_size * 10) == 0:
97            # every 10 batches wait one minute before continuing.
98            time.sleep(60)
99        else:
100            time.sleep(min(20.0, SLEEP_TIME_MULTIPLIER*(time.time() - start_time)))
101           
102        if progress_callback:
103            progress_callback(100.0 * start / len(genes_not_cached))
104   
105    return [result_dict.get(g, None) for g in genes]
106
107   
108def batch_gene_atlas_expression(genes):
109    cond = GenePropertyCondition("Ensgene", "Is", genes)
110    res = run_query(cond, format="json")
111    results = res["results"]
112    results_genes = []
113    for one_result in results:
114        gene = one_result["gene"]
115        id = gene["id"]
116        name = gene["name"]
117        synonyms = gene.get("synonyms", [])
118        expressions = one_result["expressions"]
119        result_expressions = []
120        for expression in expressions:
121            ef = expression["ef"]
122            efv = expression["efv"]
123            up = expression["upExperiments"]
124            down = expression["downExperiments"]
125            experiments = expression["experiments"]
126            result_experiment = []
127            for exp in experiments:
128                if "accession" in exp:
129                    exp_accession = exp["accession"]
130                elif "experimentAccession" in exp:
131                    exp_accession = exp["experimentAccession"]
132                else:
133                    raise KeyError()
134                if "expression" in exp:
135                    updown = exp["expression"]
136                elif "updn" in exp:
137                    updown = exp["updn"]
138                else:
139                    raise KeyError
140                pval = exp["pvalue"]
141                result_experiment.append(ExperimentExpression(exp_accession, updown, pval))
142            result_expressions.append(ExpressionResults(ef, efv, up, down, result_experiment))
143        results_genes.append(GeneResults(id, name, synonyms, result_expressions))
144    return results_genes
145
146
147def default_gene_matcher(organism):
148    """ Return a default gene matcher for organism
149    (targeting Ensembl gene ids).
150   
151    """
152    taxid = to_taxid(organism)
153    matcher = obiGene.matcher([obiGene.GMEnsembl(taxid),
154                               obiGene.GMNCBI(taxid)])
155    matcher.set_targets(obiGene.EnsembleGeneInfo(taxid).keys())
156    return matcher
157
158from Orange.utils import lru_cache
159
160@lru_cache(maxsize=3)
161def _cached_default_gene_matcher(organism): 
162    return default_gene_matcher(organism)
163   
164
165def get_atlas_summary(genes, organism, gene_matcher=None,
166                      progress_callback=None):
167    """ Return 3 dictionaries containing a summary of atlas information
168    about three experimental factors:
169   
170        - Organism Part (OP)
171        - Disease State (DS)
172        - Cell type (CT)
173   
174    Each dictionary contains query genes as keys. Values are dictionaries
175    mapping factor values to a 2-tuple containig the count of up regulated
176    and down regulated experiments.
177   
178    Example ::
179   
180        >>> get_atlas_summary(["RUNX1"], "Homo sapiens")
181        ({u'RUNX1': ...
182       
183    """
184    if gene_matcher is None:
185        gene_matcher = _cached_default_gene_matcher(organism)
186       
187    matched, unmatched = [], []
188    for gene, match in zip(genes, map(gene_matcher.umatch, genes)):
189        if match:
190            matched.append(match)
191        else:
192            unmatched.append(gene)
193    if unmatched:
194        import warnings
195        warnings.warn("Unmatched genes " + "," .join(["%r" % g for g in unmatched]))
196   
197    results = gene_expression_atlas(matched, progress_callback=progress_callback)
198   
199    def collect_ef_summary(result, ef, summary):
200        for exp in result.expressions:
201            if exp.ef == ef:
202                if any([exp.up, exp.down]):
203                    summary[result.name][exp.efv] = (exp.up, exp.down)
204       
205           
206    op, ds, ct = defaultdict(dict), defaultdict(dict), defaultdict(dict)
207    for res in results:
208        if res:
209            collect_ef_summary(res, "organism_part", op)
210            collect_ef_summary(res, "disease_state", ds)
211            collect_ef_summary(res, "cell_type", ct)
212       
213    return dict(op), dict(ds), dict(ct)
214
215def drop_none(iter):
216    """ Drop all ``None`` from the iterator.
217    """
218    for e in iter:
219        if e is not None:
220            yield e
221           
222def construct_atlas_gene_sets(genes, organism, factors=["organism_part",
223                                    "disease_state", "cell_type"],
224                              max_pvalue=1e-5):
225    """ Construct gene sets for atlas experimental factor values in
226    ``factors``.
227    """
228    results = gene_expression_atlas(genes)
229    sets = defaultdict(list)
230   
231    for res in drop_none(results):
232        for exp in res.expressions:
233            if exp.ef not in factors:
234                continue
235            diff_exp = [e for e in exp.experiments \
236                        if e.pvalue <= max_pvalue]
237            if diff_exp:
238                sets[exp.ef, exp.efv].append(res.id)
239
240    organism = "+".join(organism.lower().split())
241    from .obiGeneSets import GeneSets, GeneSet
242   
243    def display_string(name):
244        return name.capitalize().replace("_", " ")
245   
246    gene_sets = []
247    for (ef, efv), genes in sets.items():
248        ef_display = display_string(ef)
249        gs = GeneSet(genes, "Diff. expressed in %s=%s." % (ef_display, efv), id=ef + ":" + efv,
250                     description="Diff. expressed in %s=%s" % (ef_display, efv),
251                     link="http://www.ebi.ac.uk/gxa/qrs?specie_0={organism}&gprop_0=&gnot_0=&gval_0=&fact_1=&fexp_1=UPDOWN&fmex_1=&fval_1=%22{efv}%22+&view=hm".format( \
252                            organism=organism, efv="+".join(efv.lower().split())),
253                     hierarchy=("Tissues", ef_display))
254        gene_sets.append(gs)
255    return GeneSets(gene_sets)
256
257
258# Mapping for common taxids from obiTaxonomy
259TAXID_TO_ORG = {"": "Anopheles gambiae",
260                "3702": "Arabidopsis thaliana",
261                "9913": "Bos taurus",
262                "6239": "Caenorhabditis elegans",
263                "7955": "Danio rerio",
264                "7227": "Drosophila melanogaster",
265                "": "Epstein barr virus",
266                "": "Gallus gallus",
267                "9606": "Homo sapiens",
268                "": "Human cytomegalovirus",
269                "": "Kaposi sarcoma-associated herpesvirus",
270                "10090": "Mus musculus",
271                "10116": "Rattus norvegicus",
272                "4932": "Saccharomyces cerevisiae",
273                "4896": "Schizosaccharomyces pombe",
274                "8355": "Xenopus laevis"
275     }
276
277def to_taxid(name):
278    dd = dict((v, k) for k, v in TAXID_TO_ORG.items())
279    if name in dd:
280        return dd[name]
281    else:
282        from . import obiTaxonomy as tax
283        ids = tax.to_taxid(name, mapTo=TAXID_TO_ORG.keys())
284        if ids:
285            return ids.pop()
286        else:
287            raise ValueError("Unknown organism.")
288
289
290__doc__ += """\
291Low level REST query interface
292------------------------------
293
294Use `query_atlas_simple` for simple querys.
295
296Example (query human genes for experiments in which they are up regulated) ::
297
298    >>> run_simple_query(genes=["SORL1", "PSIP1", "CDKN1C"], regulation="up", organism="Homo sapiens")
299    {u'...
300   
301Or use the `AtlasCondition` subclasses in this module to construct a more
302advanced query and use the `run_query` function.
303
304Example (query human genes annotated to the GO term 'transporter activity'
305that are up regulated in the liver in at least three experiments) ::
306
307    >>> go_cond = GenePropertyCondition("Goterm", "Is", "transporter activity")
308    >>> liver_cond = ExperimentalFactorCondition("Organism_part", "up", 3, "liver")
309    >>> org_cond = OrganismCondition("Homo sapiens")
310    >>> cond_list = ConditionList([go_cond, liver_cond, org_cond])
311    >>> run_query(cond_list)
312    {u'...
313
314"""
315
316import urllib2
317from  StringIO import StringIO
318import json
319from xml.etree.ElementTree import ElementTree
320
321parse_json = json.load
322
323
324def parse_xml(stream):
325    """ Parse an xml stream into an instance of xml.etree.ElementTree.ElementTree.
326    """
327    return ElementTree(file=stream)
328
329
330class GeneExpressionAtlasConenction(object):
331    """ A connection to Gene Expression Atlas database.
332    """
333    DEFAULT_ADDRESS = "http://www.ebi.ac.uk:80/gxa/"
334    DEFAULT_CACHE = orngServerFiles.localpath("GeneAtlas", "GeneAtlasConnectionCache.shelve")
335    def __init__(self, address=None, timeout=30, cache=None):
336        """ Initialize the conenction.
337       
338        :param address: Address of the server.
339        :param timeout: Socket timeout.
340        :param cache : A dict like object to use as a cache.
341       
342        """
343        self.address = address if address is not None else self.DEFAULT_ADDRESS
344        self.timeout = timeout
345        self.cache = cache if cache is not None else self.DEFAULT_CACHE
346   
347    def query(self, condition, format="json", start=None, rows=None, indent=False):
348        url = self.address + "api/vx?" + condition.rest()
349        if start is not None and rows is not None:
350            url += "&start={0}&rows={1}".format(start, rows)
351        url += "&format={0}".format(format)
352        if indent:
353            url += "&indent"
354#        print url
355        if self.cache is not None:
356            return self._query_cached(url, format)
357        else:
358            return urllib2.urlopen(url)
359        return response
360   
361    def _query_cached(self, url, format):
362        if self.cache is not None:
363            with self.open_cache() as cache:
364                cached = url in cache
365           
366            if not cached:
367                response = urllib2.urlopen(url)
368                contents = response.read()
369                # Test if the contents is a valid json or xml string (some
370                # times the stream just stops in the middle, so this makes
371                # sure we don't cache an invalid response
372                # TODO: what about errors (e.g. 'cannot handle the
373                # query in a timely fashion'
374                if format == "json":
375                    parse_json(StringIO(contents))
376                else:
377                    parse_xml(StringIO(contents))
378                   
379                with self.open_cache() as cache:
380                    cache[url] = contents
381            else:
382                with self.open_cache() as cache:
383                    contents = cache[url]
384            return StringIO(contents)
385        else:
386            return urllib2.urlopen(url)
387       
388    def open_cache(self):
389        if isinstance(self.cache, basestring):
390            return closing(shelve.open(self.cache))
391        elif hasattr(self.cache, "close"):
392            return closing(self.cache)
393        elif self.cache is None:
394            return fake_closing({})
395        else:
396            return fake_closing(self.cache)
397       
398       
399from contextlib import contextmanager
400@contextmanager
401def fake_closing(obj):
402    yield obj
403   
404   
405# Names of all Gene Property filter names
406GENE_FILTERS = \
407    ["Name", # Gene name
408     "Goterm", #Gene Ontology Term
409     "Interproterm", #InterPro Term
410     "Disease", #Gene-Disease Assocation
411     "Keyword", #Gene Keyword
412     "Protein", #Protein
413
414     "Dbxref", #Other Database Cross-Refs
415     "Embl", #EMBL-Bank ID
416     "Ensfamily", #Ensembl Family
417     "Ensgene", #Ensembl Gene ID
418
419     "Ensprotein", #Ensembl Protein ID
420     "Enstranscript", #Ensembl Transcript ID
421     "Goid", #Gene Ontology ID
422     "Image", #IMAGE ID
423     "Interproid", #InterPro ID
424     "Locuslink", #Entrez Gene ID
425
426     "Omimid", #OMIM ID
427     "Orf", #ORF
428     "Refseq", #RefSeq ID
429     "Unigene", #UniGene ID
430     "Uniprot", #UniProt Accession
431
432     "Hmdb", #HMDB ID
433     "Chebi", #ChEBI ID
434     "Cas", #CAS
435     "Uniprotmetenz", #Uniprotmetenz
436     "Gene", #Gene Name or Identifier
437     "Synonym", #Gene Synonym
438     ]
439   
440# Valid Gene Property filter qualifiers
441GENE_FILTER_QUALIFIERS =\
442    ["Is",
443     "IsNot"
444     ]
445
446# Organisms in the Atlas
447ATLAS_ORGANISMS = \
448    ["Anopheles gambiae",
449     "Arabidopsis thaliana",
450     "Bos taurus",
451     "Caenorhabditis elegans",
452     "Danio rerio",
453     "Drosophila melanogaster",
454     "Epstein barr virus",
455     "Gallus gallus",
456     "Homo sapiens",
457     "Human cytomegalovirus",
458     "Kaposi sarcoma-associated herpesvirus",
459     "Mus musculus",
460     "Rattus norvegicus",
461     "Saccharomyces cerevisiae",
462     "Schizosaccharomyces pombe",
463#     "Unknown",
464     "Xenopus laevis"
465     ]
466   
467def ef_ontology():
468    """ Return the `EF <http://www.ebi.ac.uk/efo/>`_ (Experimental Factor) ontology
469    """
470    from . import obiOntology
471    from . import orngServerFiles
472    # Should this be in the OBOFoundry (Ontology) domain
473    try:
474        file = open(orngServerFiles.localpath_download("ArrayExpress", "efo.obo"), "rb")
475    except urllib2.HTTPError:
476        file = urllib2.urlopen("http://efo.svn.sourceforge.net/svnroot/efo/trunk/src/efoinobo/efo.obo")
477    return obiOntology.OBOOntology(file)
478
479
480class Condition(object):
481    """ Base class for Gene Expression Atlas query condition
482    """
483    def validate(self):
484        """ Validate condition in a subclass.
485        """
486        raise NotImplementedError
487   
488    def rest(self):
489        """ Return a REST query part in a subclass.
490        """
491        raise NotImplementedError
492   
493   
494class ConditionList(list, Condition):
495    """ A list of AtlasCondition instances.
496    """ 
497    def validate(self):
498        for item in self:
499            item.validate()
500       
501    def rest(self):
502        return "&".join(cond.rest() for cond in self)
503
504
505class GenePropertyCondition(Condition):
506    """ An atlas gene filter condition.
507   
508    :param property: Property of the gene. If None or "" all properties
509        will be searched.
510    :param qualifier: Qualifier can be 'Is' or 'IsNot'
511    :param value: The value to search for.
512   
513    Example ::
514   
515        >>> # Condition on a gene name
516        >>> condition = GenePropertyCondition("Name", "Is", "AS3MT")
517        >>> # Condition on genes from a GO Term
518        >>> condition = GenePropertyCondition("Goterm", "Is", "p53 binding")
519        >>> # Condition on disease association
520        >>> condition = GenePropertyCondition("Disease", "Is", "cancer")
521       
522    """
523    def __init__(self, property, qualifier, value):
524        self.property = property or ""
525        self.qualifier = qualifier
526        if isinstance(value, basestring):
527            self.value = value.replace(" ", "+")
528        elif isinstance(value, list):
529            self.value = "+".join(value)
530        else:
531            raise ValueError(value)
532       
533        self.validate()
534       
535    def validate(self):
536        assert(self.property in GENE_FILTERS + [""])
537        assert(self.qualifier in GENE_FILTER_QUALIFIERS + [""])
538       
539    def rest(self):
540        return "gene{property}{qualifier}={value}".format(**self.__dict__)
541       
542       
543class ExperimentalFactorCondition(Condition):
544    """ An atlas experimental factor filter condition.
545   
546    :param factor: EFO experiamntal factor
547    :param regulation: "up", "down", "updown", "any" or "none"
548    :param n: Minimum number of of experimants with this condition
549    :param value: Experimantal factor value
550   
551    Example ::
552   
553        >>> # Any genes up regulated in at least 3 experiments involving cancer.
554        >>> condition = ExperimentalFactorCondition("", "up", 3, "cancer")
555        >>> # Only genes which are up/down regulated in the heart in at least one experiment.
556        >>> condition = ExperimentalFactorCondition("Organism_part", "updown", 1, "heart")
557       
558    """
559    def __init__(self, factor, regulation, n, value):
560        self.factor = factor
561        self.regulation = regulation
562        self.n = n
563        self.value = value
564        self.validate()
565       
566    def validate(self):
567        # TODO: validate the factor and value
568#        assert(self.factor in ef_ontology())
569        assert(self.regulation in ["up", "down", "updown"])
570       
571    def rest(self):
572        return "{regulation}{n}In{factor}={value}".format(**self.__dict__)
573       
574       
575class OrganismCondition(Condition):
576    """ Condition on organism.
577    """
578    def __init__(self, organism):
579        self.organism = organism
580        self.validate()
581       
582    def validate(self):
583        assert(self.organism in ATLAS_ORGANISMS)
584       
585    def rest(self):
586        return "species={0}".format(self.organism.replace(" ", "+").lower())
587       
588       
589class ExperimentCondition(Condition):
590    """ Condition on experiement
591   
592    :param property: Property of the experiment. If None or "" all properties
593        will be searched.
594    :param qualifier: Qualifier can be 'Has' or 'HasNot'
595    :param value: The value to search for.
596   
597    Example ::
598   
599        >>> # Condition on a experiemnt acession
600        >>> condition = ExperimentCondition("", "", "E-GEOD-24283")
601        >>> # Condition on experiments involving lung
602        >>> condition = ExperimentCondition("Organism_part", "Has", "lung")
603       
604    """
605    EXPERIMENT_FILTER_QUALIFIERS = [
606                "Has",
607                "HasNot"]
608   
609    def __init__(self, property, qualifier, value):
610        self.property = property
611        self.qualifier = qualifier
612        if isinstance(value, basestring):
613            self.value = value.replace(" ", "+")
614        elif isinstance(value, list):
615            self.value = "+".join(value)
616        else:
617            raise ValueError(value)
618       
619        self.validate()
620       
621    def validate(self):
622        # TODO: check to EFO factors
623#        assert(self.property in EXPERIMENT_FILTERS + [""])
624        assert(self.qualifier in self.EXPERIMENT_FILTER_QUALIFIERS + [""])
625       
626    def rest(self):
627        return "experiment{property}{qualifier}={value}".format(**self.__dict__)
628       
629       
630class GeneExpressionAtlasError(Exception):
631    """ An error response from the Atlas server.
632    """
633    pass
634   
635   
636def __check_atlas_error_json(response):
637    if "error" in response:
638        raise GeneExpressionAtlasError(response["error"])
639    return response
640 
641     
642def __check_atlas_error_xml(response):
643    error = response.find("error")
644    if error is not None:
645        raise GeneExpressionAtlasError(error.text)
646    return response
647   
648       
649def run_simple_query(genes=None, regulation=None, organism=None,
650                     condition=None, format="json", start=None,
651                     rows=None):
652    """ A simple Gene Atlas query.
653   
654    :param genes: A list of gene names to search for.
655    :param regulation: Search for experiments in which `genes` are "up",
656        "down", "updown" or "none" regulated. If None all experiments
657        are searched.
658    :param organism: Search experiments for organism. If None all experiments
659        are searched.
660    :param condition: An EFO factor value (e.g. "brain")
661   
662    Example ::
663       
664        >>> run_simple_query(genes=['Pou5f1', 'Dppa3'], organism="Mus musculus")
665        {u'...
666       
667        >>> run_simple_query(genes=['Pou5f1', 'Dppa3'], regulation="up", organism="Mus musculus")
668        {u'...
669       
670        >>> run_simple_query(genes=['Pou5f1', 'Dppa3'], regulation="up", condition="liver", organism="Mus musculus")
671        {u'...
672       
673    """
674    conditions = ConditionList()
675    if genes:
676        conditions.append(GenePropertyCondition("Gene", "Is", genes))
677    if regulation or condition:
678        regulation = "any" if regulation is None else regulation
679        condition = "" if condition is None else condition
680        conditions.append(ExperimentalFactorCondition("", regulation, 1, condition))
681    if organism:
682        conditions.append(OrganismCondition(organism))
683       
684    connection = GeneExpressionAtlasConenction()
685    results = connection.query(conditions, format=format, start=start,
686                               rows=rows)
687    if format == "json":
688        return parse_json(results)
689    else:
690        return parse_xml(results)
691
692"""\
693.. todo:: can this be implemented query_atlas(organism="...", Locuslink="...", Chebi="...", up3InCompound="..." downInEFO="...")
694      Need a full list of accepted factors
695"""
696
697def run_query(condition, format="json", start=None, rows=None, indent=False, connection=None):
698    """ Query Atlas based on a `condition` (instance of :class:`Condition`)
699   
700    Example ::
701       
702        >>> condition1 = GenePropertyCondition("Goterm", "Is", "p53 binding")
703        >>> condition2 = ExperimentalFactorCondition("Organism_part", "up", 3, "heart")
704        >>> condition = ConditionList([condition1, condition2])
705        >>> run_query(condition)
706        {u'...
707       
708    """
709    if connection is None:
710        connection = GeneExpressionAtlasConenction()
711    results = connection.query(condition, format=format, start=start,
712                               rows=rows, indent=indent)
713    if format == "json":
714        response = parse_json(results)
715        return __check_atlas_error_json(response)
716    else:
717        response = parse_xml(results)
718        return __check_atlas_error_xml(response)
719   
720def test():
721    from pprint import pprint   
722    pprint(get_atlas_summary(['Pou5f1', 'Dppa3'], 'Mus musculus'))
723       
724    pprint(get_atlas_summary(['PDLIM5', 'FGFR2' ], 'Homo sapiens'))
725    import doctest 
726    doctest.testmod(optionflags=doctest.ELLIPSIS)
727   
728if __name__ == "__main__":
729    test()
Note: See TracBrowser for help on using the repository browser.