Repository: SynBioDex/tyto (language: python)

Issue:
Cache queries
When TYTO performs a remote query, it takes several seconds.
While this is fine for occasional queries, it can make automation massively slow.
Can you please add caching for queries so that lookup failures can be resolved quickly after the first one?


Discussion / hints:

Fix patch:
diff --git a/setup.py b/setup.py
index 460d032..a6fff73 100644
--- a/setup.py
+++ b/setup.py
@@ -28,14 +28,15 @@
       ],
       # What does your project relate to?
       keywords='ontologies',
-      packages=['tyto'],
+      packages=setuptools.find_packages(),
       package_data={'tyto': ['ontologies/*.owl',
                               'ontologies/*.rdf',
                               'ontologies/*.ttl']},
       include_package_data=True,
       install_requires=[
             'rdflib>=5.0',
-            'SPARQLWrapper'
+            'SPARQLWrapper',
+            'requests'
       ],
       test_suite='test',
       tests_require=[
diff --git a/tyto/__init__.py b/tyto/__init__.py
index 8a525a6..4e7b970 100644
--- a/tyto/__init__.py
+++ b/tyto/__init__.py
@@ -1,1 +1,5 @@
-from .owlet import *
+from .owlet import Ontology, URI, configure_cache_size
+from .sbo import SBO
+from .so import SO
+from .ncit import NCIT
+from .om import OM
diff --git a/tyto/endpoint/__init__.py b/tyto/endpoint/__init__.py
new file mode 100644
index 0000000..d557c44
--- /dev/null
+++ b/tyto/endpoint/__init__.py
@@ -0,0 +1,1 @@
+from .endpoint import *
diff --git a/tyto/endpoint/endpoint.py b/tyto/endpoint/endpoint.py
new file mode 100644
index 0000000..9cc5eb8
--- /dev/null
+++ b/tyto/endpoint/endpoint.py
@@ -0,0 +1,260 @@
+import abc
+import requests
+import urllib.parse
+import json
+from io import StringIO
+
+import rdflib
+from SPARQLWrapper import SPARQLWrapper, JSON
+
+
+class QueryBackend(abc.ABC):
+
+    @abc.abstractmethod
+    def get_term_by_uri(self, ontology: "Ontology", uri: str):
+        return
+
+    @abc.abstractmethod
+    def get_uri_by_term(self, ontology: "Ontology", term: str):
+        return
+
+
+class SPARQLBuilder():
+
+    def get_term_by_uri(self, ontology, uri):
+        '''
+        Get the ontology term (e.g., "promoter") corresponding to the given URI
+        :param uri: The URI for the term
+        :return: str
+        '''
+        query = '''
+            SELECT distinct ?label
+            WHERE
+            {{{{
+                optional
+                {{{{
+                    <{uri}> rdfs:label ?label .
+                     filter langMatches(lang(?label), "en")
+                }}}}
+                optional
+                {{{{
+                    <{uri}> rdfs:label ?label .
+                }}}}
+            }}}}
+            '''.format(uri=uri)
+        error_msg = '{} not found'.format(uri)
+        response = self.query(ontology, query, error_msg)
+        if not response:
+            return None
+        response = response[0]
+        return response
+
+    def get_uri_by_term(self, ontology: "Ontology", term: str) -> str:
+        '''
+        Get the URI assigned to an ontology term (e.g., "promoter")
+        :param term: The ontology term
+        :return: str
+        '''
+
+        # Queries to the sequence ontology require the xsd:string datatype
+        # whereas queries to the systems biology ontology do not, hence the
+        # UNION in the query. Additionally, terms in SBO have spaces rather
+        # than underscores. This creates a problem when looking up terms by
+        # an attribute, e.g., SBO.systems_biology_representation
+        query = '''
+            SELECT distinct ?uri
+            {{from_clause}}
+            WHERE
+            {{{{
+                optional 
+                {{{{
+                    ?uri rdfs:label "{term}"@en
+                }}}}
+                optional
+                {{{{ 
+                    ?uri rdfs:label "{term}"
+                }}}}
+                optional
+                {{{{ 
+                    ?uri rdfs:label "{term}"^^xsd:string
+                }}}}
+            }}}}
+            '''.format(term=term)
+        error_msg = '{} not a valid ontology term'.format(term)
+        response = self.query(ontology, query, error_msg)
+        if not response:
+            return None
+        response = response[0]
+        return response
+
+    def is_subclass_of(self, ontology: "Ontology", subclass_uri: str, superclass_uri: str) -> bool:
+        query = '''
+            SELECT distinct ?subclass 
+            {{from_clause}}
+            WHERE 
+            {{{{
+                ?subclass rdf:type owl:Class .
+                ?subclass rdfs:subClassOf <{}>
+            }}}}
+            '''.format(superclass_uri)
+        error_msg = ''
+        subclasses = self.query(ontology, query, error_msg)
+        return subclass_uri in subclasses
+
+    def get_ontology(self):
+        query = '''
+            SELECT distinct ?ontology_uri
+            WHERE
+              {
+                ?ontology_uri a owl:Ontology
+              }
+            '''
+        error_msg = 'Graph not found'
+        response = self._query(ontology, query, error_msg)[0]
+        return response
+
+
+class Endpoint(QueryBackend, abc.ABC):
+
+    def __init__(self, url):
+        self.url = url
+
+
+class RESTEndpoint(QueryBackend, abc.ABC):
+
+    def __init__(self, url):
+        self.url = url
+
+
+class SPARQLEndpoint(SPARQLBuilder, Endpoint):
+
+    def __init__(self, url):
+        super().__init__(url)
+        self._endpoint = SPARQLWrapper(url)
+        self._endpoint.setReturnFormat(JSON)
+
+    def query(self, ontology, sparql, err_msg):
+        self._endpoint.setQuery(sparql)
+        response = self._endpoint.query()
+        return self.convert(response)
+
+    def convert(self, response):
+        '''
+        Returns standard SPARQL query JSON. This extracts and flattens the queried
+        variables into a list.
+
+        See https://www.w3.org/TR/2013/REC-sparql11-results-json-20130321/
+        '''
+        converted_response = []
+        if response:
+            response = response.convert()  # Convert http response to JSON
+            for var, binding in zip(response['head']['vars'],
+                                    response['results']['bindings']):
+                if var in binding:
+                    converted_response.append(binding[var]['value'])
+        return converted_response
+
+
+class GraphEndpoint(SPARQLBuilder, Endpoint):
+
+    def __init__(self, file_path):
+        self.graph = rdflib.Graph()
+        self.path = file_path
+
+    def is_loaded(self):
+        return bool(self.graph)
+
+    def load(self):
+        self.graph.parse(self.path)
+
+    def query(self, ontology, sparql, err_msg):
+        sparql_final = sparql.format(from_clause='')  # Because only one ontology per file, delete the from clause
+        response = self.graph.query(sparql_final)
+        return self.convert(response)
+
+    def convert(self, response):
+        '''
+        Extracts and flattens queried variables from rdflib response into a list
+        '''
+        return [str(row[0]) for row in response]
+
+
+class OntobeeEndpoint(SPARQLEndpoint):
+
+    def __init__(self):
+        super().__init__('http://sparql.hegroup.org/sparql/')
+
+    def query(self, ontology, sparql, err_msg):
+        # The general naming pattern for an Ontobee graph URI is to transform a PURL
+        # http://purl.obolibrary.org/obo/$foo.owl (note foo must be all lowercase
+        # by OBO conventions) to http://purl.obolibrary.org/obo/merged/uppercase($foo).
+        from_clause = ''
+        if ontology.uri:
+            ontology_uri = ontology.uri
+            if 'http://purl.obolibrary.org/obo/' in ontology_uri:
+                ontology_uri = ontology_uri.replace('http://purl.obolibrary.org/obo/', '')
+                ontology_uri = ontology_uri.replace('.owl', '')
+                ontology_uri = ontology_uri.upper()
+                ontology_uri = 'http://purl.obolibrary.org/obo/merged/' + ontology_uri
+            from_clause = f'FROM <{ontology_uri}>'
+        sparql = sparql.format(from_clause=from_clause)
+        response = super().query(ontology, sparql, err_msg)
+        return response
+
+
+class EBIOntologyLookupServiceAPI(RESTEndpoint):
+
+    def __init__(self):
+        super().__init__('http://www.ebi.ac.uk/ols/api')
+        self.ontology_short_ids = {}  # Set by the _load_ontology method
+
+    def _load_ontology_ids(self):
+        response = requests.get(f'{self.url}/ontologies?size=1')
+        response = response.json()
+        total_ontologies = response['page']['totalElements']
+        response = requests.get(f'{self.url}/ontologies?size={total_ontologies}')
+        response = response.json()
+        for o in response['_embedded']['ontologies']:
+            short_id = o['ontologyId']
+            iri = o['config']['id']
+            self.ontology_short_ids[iri] = short_id
+
+    def get_term_by_uri(self, ontology: "Ontology", uri: str):
+        if not self.ontology_short_ids:
+            self._load_ontology_ids()
+        if ontology.uri not in self.ontology_short_ids:
+            raise LookupError(f'Ontology {ontology.uri} is not available at EBI Ontology Lookup Service')
+        short_id = self.ontology_short_ids[ontology.uri]
+        get_query = f'{self.url}/ontologies/{short_id}/terms/' + urllib.parse.quote_plus(urllib.parse.quote_plus(uri))
+        response = requests.get(get_query)
+        if response.status_code == 200:
+            return response.json()['label']
+        if response.status_code == 404:
+            return None
+        raise urllib.error.HTTPError(get_query, response.status_code, response.reason, response.headers, None)
+
+    def get_uri_by_term(self, ontology: "Ontology", term: str):
+        if not self.ontology_short_ids:
+            self._load_ontology_ids()
+        if ontology.uri not in self.ontology_short_ids:
+            raise LookupError(f'Ontology {ontology.uri} is not available at EBI Ontology Lookup Service')
+        short_id = self.ontology_short_ids[ontology.uri]
+
+        get_query = f'{self.url}/search?q={term}&ontology={short_id}&queryFields=label'
+        response = requests.get(get_query)
+        if response.status_code == 200:
+            response = response.json()
+            if not response or not len(response['response']['docs']):
+                return None
+            return response['response']['docs'][0]['iri']
+        raise urllib.error.HTTPError(get_query, response.status_code, response.reason, response.headers, None)
+
+    def convert(self, response):
+        pass
+
+    def query(self, query):
+        pass
+
+
+Ontobee = OntobeeEndpoint()
+EBIOntologyLookupService = EBIOntologyLookupServiceAPI()
diff --git a/tyto/ncit.py b/tyto/ncit.py
new file mode 100644
index 0000000..e8bb086
--- /dev/null
+++ b/tyto/ncit.py
@@ -0,0 +1,17 @@
+from .owlet import Ontology, Ontobee, multi_replace
+
+
+NCIT = Ontology(path=None,
+                endpoints=[Ontobee],
+                uri='http://purl.obolibrary.org/obo/ncit.owl')
+
+# Convert PURL URIs to identifiers.org
+NCIT._sanitize_uri = lambda uri: multi_replace(uri,
+                                               ['http://identifiers.org/ncit/ncit:',
+                                                'https://identifiers.org/ncit:'],
+                                               'http://purl.obolibrary.org/obo/NCIT_')
+NCIT._reverse_sanitize_uri = lambda uri: uri.replace('http://purl.obolibrary.org/obo/NCIT_',
+                                                     'https://identifiers.org/ncit:')
+
+# Spaces are not allowed because they render an invalid Python symbol
+NCIT._sanitize_term = lambda term: term.replace('_', ' ')
diff --git a/tyto/om.py b/tyto/om.py
new file mode 100644
index 0000000..ac3bdb0
--- /dev/null
+++ b/tyto/om.py
@@ -0,0 +1,9 @@
+from .owlet import Ontology, Ontobee, installation_path
+
+
+OM = Ontology(path=installation_path('ontologies/om-2.0.rdf'),
+              endpoints=None)
+
+# Support American English spellings
+OM._sanitize_term = lambda term: term.replace('liter', 'litre').replace('meter', 'metre').replace('molar', 'molair')
+OM._reverse_sanitize_term = lambda term: term.replace('litre', 'liter').replace('metre', 'meter').replace('molair', 'molar')
diff --git a/tyto/owlet.py b/tyto/owlet.py
index 2f5d48b..d9cec06 100644
--- a/tyto/owlet.py
+++ b/tyto/owlet.py
@@ -1,170 +1,95 @@
-import rdflib
-from SPARQLWrapper import SPARQLWrapper, JSON
-import urllib
 import os
-import posixpath
+import logging
+from functools import lru_cache
 
-class Ontology():
+from .endpoint import Ontobee, EBIOntologyLookupService, GraphEndpoint, Endpoint
 
-    def __init__(self, path=None, endpoint=None, uri=None):
-        if not path and not endpoint:
-            raise Exception('A sparql endpoint or a local path to an ontology must be specified')
-        self.path = path
-        self.graph = rdflib.Graph()
-        self.uri = uri
-        self.endpoint = None
-        if endpoint:
-            self.endpoint = SPARQLWrapper(endpoint)
-            self.endpoint.setReturnFormat(JSON)
 
-    def _query(self, sparql, error_msg):
-        '''
-        This is a generalized back-end for querying ontologies. By default it performs
-        SPARQL queries through a network endpoint, because loading an ontology from a
-        local graph takes a while and uses up memory.  However, once a graph is loaded
-        locally, subsequent queries will be performed directly on the graph.
-        '''
-        response = []
-        if len(self.graph):
-            # If the ontology graph has been loaded locally, query that rather
-            # than querying over the network
-            sparql_final = sparql.format(from_clause='')  # Because only one ontology per file, delete the from clause
-            response = self.graph.query(sparql_final)
-            response = Ontology._convert_rdflib_response(response)
+LOGGER = logging.getLogger(__name__)
+logging.basicConfig(format='tyto: %(levelname)s: %(message)s')
 
-        if self.endpoint:
-            # print('Testing endpoint')
-            # If no ontology graph is located, query the network endpoint
-            # The general naming pattern for an Ontobee graph URI is to transform a PURL 
-            # http://purl.obolibrary.org/obo/$foo.owl (note foo must be all lowercase 
-            # by OBO conventions) to http://purl.obolibrary.org/obo/merged/uppercase($foo).
-            from_clause = ''
-            if self.uri:
-                ontology = self.uri
-                if 'http://purl.obolibrary.org/obo/' in self.uri:
-                    ontology = ontology.replace('http://purl.obolibrary.org/obo/', '')
-                    ontology = ontology.replace('.owl', '')
-                    ontology = ontology.upper()
-                    ontology = 'http://purl.obolibrary.org/obo/merged/' + ontology
-                from_clause = f'FROM <{ontology}>'
-            sparql_final = sparql.format(from_clause=from_clause)
-            self.endpoint.setQuery(sparql_final)
-            try:
-                response = self.endpoint.query()
-            except Exception as e:
-                print(e)
-            response = Ontology._convert_ontobee_response(response)
 
-        else:
-            #print('No endpoint specified. Querying local cache.')
-            pass
+class Ontology():
 
-        # If the connection fails or nothing found, load the ontology locally
-        if not len(response) and self.path:
-            # print('Testing local cache')
-            try:
-                self.graph.parse(self.path)
-                sparql_final = sparql.format(from_clause='')  # Because only one ontology per file, delete the from clause
-                response = self.graph.query(sparql_final)
-                response = Ontology._convert_rdflib_response(response)
-            except Exception as e:
-                print(e)
+    def __init__(self, path=None, endpoints=None, uri=None):
+        if not path and not endpoints:
+            raise Exception('A sparql endpoint or a local path to an ontology must be specified')
+        self.graph = None
+        self.endpoints = None
+        self.uri = uri
+        if endpoints:
+            if not type(endpoints) is list or not all([issubclass(type(e), Endpoint) for e in endpoints]):
+                raise TypeError('The endpoints argument requires a list of Endpoints')
+            self.endpoints = endpoints
+        if path:
+            if not type(path) is str:
+                raise TypeError('Invalid path specified')
+            self.graph = GraphEndpoint(path)
+
+    def __getattr__(self, name):
+        if name in self.__getattribute__('__dict__'):
+            return self.__getattribute__(name)
         else:
-            #print('No path to local cache specified.')
-            pass
+            return self.__getattribute__('get_uri_by_term')(name)
 
-        if not len(response):
-            raise LookupError(error_msg)
-        return response
+    def _handler(self, method_name, exception, *args):
+        response = None
 
-    def _convert_ontobee_response(response):
-        '''
-        Ontobee SPARQL interface returns JSON. This extracts and flattens the queried
-        variables into a list
-        '''
-        converted_response = []
-        if response:
-            response = response.convert()  # Convert http response to JSON
-            for var, binding in zip(response['head']['vars'],
-                                    response['results']['bindings']):
-                if var in binding:
-                    converted_response.append(binding[var]['value'])
-        return converted_response
+        # If the ontology graph has already been loaded locally, query that rather
+        # than querying over the network
+        if self.graph and self.graph.is_loaded():
+            method = getattr(self.graph, method_name)
+            try:
+                response = method(self, *args)
+                if response is not None:
+                    return response
+            except Exception as x:
+                LOGGER.error(x)
+
+        # Try endpoints
+        if self.endpoints:
+            for e in self.endpoints:
+                method = getattr(e, method_name)
+                try:
+                    response = method(self, *args)
+                    if response is not None:
+                        return response
+                except Exception as x:
+                    LOGGER.error(x)
+
+        # If the connection fails or nothing found, fall back and load the ontology locally
+        if self.graph and not self.graph.is_loaded():
+            self.graph.load()
+            method = getattr(self.graph, method_name)
+            try:
+                response = method(self, *args)
+                if response is not None:
+                    return response
+            except Exception as x:
+                LOGGER.error(x)
 
-    def _convert_rdflib_response(response):
-        '''
-        Extracts and flattens queried variables from rdflib response into a list
-        '''
-        return [str(row[0]) for row in response]
+        if exception:
+            raise exception
+        return None
 
     def get_term_by_uri(self, uri):
-        '''
-        Get the ontology term (e.g., "promoter") corresponding to the given URI
-        :param uri: The URI for the term
-        :return: str
-        '''
-        uri = self._from_user(uri)
-        query = '''
-            SELECT distinct ?label
-            WHERE
-            {{{{
-                optional
-                {{{{
-                    <{uri}> rdfs:label ?label .
-                     filter langMatches(lang(?label), "en")
-                }}}}
-                optional
-                {{{{
-                    <{uri}> rdfs:label ?label .
-                }}}}
-            }}}}
-            '''.format(uri=uri)
-        error_msg = '{} not found'.format(uri)
-        response = self._query(query, error_msg)
-        return self._reverse_sanitize_term(response[0])
+        sanitized_uri = self._sanitize_uri(uri)
+        exception = LookupError(f'No matching term found for {uri}')
+        term = self._handler('get_term_by_uri', exception, sanitized_uri)
+        return self._reverse_sanitize_term(term)
 
     def get_uri_by_term(self, term):
-        '''
-        Get the URI assigned to an ontology term (e.g., "promoter")
-        :param term: The ontology term
-        :return: str
-        '''
-        
-        # Queries to the sequence ontology require the xsd:string datatype
-        # whereas queries to the systems biology ontology do not, hence the
-        # UNION in the query. Additionally, terms in SBO have spaces rather
-        # than underscores. This creates a problem when looking up terms by
-        # an attribute, e.g., SBO.systems_biology_representation
-        sanitized_term=self._sanitize_term(term)
-        query = '''
-            SELECT distinct ?uri
-            {{from_clause}}
-            WHERE
-            {{{{
-                optional 
-                {{{{
-                    ?uri rdfs:label "{sanitized_term}"@en
-                }}}}
-                optional
-                {{{{ 
-                    ?uri rdfs:label "{sanitized_term}"
-                }}}}
-                optional
-                {{{{ 
-                    ?uri rdfs:label "{sanitized_term}"^^xsd:string
-                }}}}
-            }}}}
-            '''.format(sanitized_term=sanitized_term)
-        error_msg = '{} not a valid ontology term'.format(term)
-        response = self._query(query, error_msg)[0]
-        return self._to_user(response)
+        sanitized_term = self._sanitize_term(term)
+        exception = LookupError(f'{term} is not a valid ontology term')
+        uri = self._handler('get_uri_by_term', exception, sanitized_term)
+        return URI(self._reverse_sanitize_uri(uri), self)
 
-    def _to_user(self, uri):
+    def _sanitize_uri(self, uri):
         # Some Ontology instances may override this method to translate a URI
         # from purl to identifiers.org namespaces
         return uri
 
-    def _from_user(self, uri):
+    def _reverse_sanitize_uri(self, uri):
         # Some Ontology instances may override this method to translate a URI
         # from purl to identifiers.org namespaces
         return uri
@@ -181,28 +106,25 @@ def _reverse_sanitize_term(self, term):
         # or changing camel-case to snake-case
         return term
 
-    def get_ontology(self):
-        query = '''
-            SELECT distinct ?ontology_uri
-            WHERE
-              {
-                ?ontology_uri a owl:Ontology
-              }
-            '''
-        error_msg = 'Graph not found'
-        response = self._query(query, error_msg)
-        return response[0]
 
-    def __getattr__(self, name):
-        if name in self.__getattribute__('__dict__'):
-            return self.__getattribute__(name)
-        else:
-            return self.__getattribute__('get_uri_by_term')(name)
+class URI(str):
+
+    def __new__(cls, value: str, ontology: Ontology):
+        term = str.__new__(cls, value)
+        term.ontology = ontology
+        return term
+
+    def is_subclass_of(self, superclass_uri: str):
+        subclass_uri = self.ontology._sanitize_uri(self)
+        superclass_uri = self.ontology._sanitize_uri(superclass_uri)
+        return self.ontology._handler('is_subclass_of', None, subclass_uri, superclass_uri)
 
 
+# Utility functions
 def installation_path(relative_path):
-    return posixpath.join(os.path.dirname(os.path.realpath(__file__)),
-                          relative_path)
+    return os.path.join(os.path.dirname(os.path.realpath(__file__)),
+                        relative_path)
+
 
 def multi_replace(target_uri, old_namespaces, new_namespace):
     for ns in old_namespaces:
@@ -210,50 +132,16 @@ def multi_replace(target_uri, old_namespaces, new_namespace):
             return target_uri.replace(ns, new_namespace)
     return target_uri
 
-SO = Ontology(path=installation_path('ontologies/so.owl'),
-              endpoint='http://sparql.hegroup.org/sparql/',
-              uri='http://purl.obolibrary.org/obo/so.owl')
-SO._to_user = lambda uri: uri.replace('http://purl.obolibrary.org/obo/SO_',
-                                      'https://identifiers.org/SO:')
-SO._from_user = lambda uri: multi_replace(uri,
-                                          ['https://identifiers.org/SO:',
-                                           'http://identifiers.org/so/SO:'],
-                                           'http://purl.obolibrary.org/obo/SO_')
-
-SBO = Ontology(path=installation_path('ontologies/SBO_OWL.owl'),
-               endpoint='http://sparql.hegroup.org/sparql/',
-               uri='http://purl.obolibrary.org/obo/sbo.owl')
-SBO = Ontology(path=installation_path('ontologies/SBO_OWL.owl'),
-               endpoint='http://sparql.hegroup.org/sparql/',
-               uri='http://purl.obolibrary.org/obo/sbo.owl')
-SBO._to_user = lambda uri: uri.replace('http://biomodels.net/SBO/SBO_',
-                                      'https://identifiers.org/SBO:')
-SBO._from_user = lambda uri: multi_replace(uri,
-                                          ['http://identifiers.org/sbo/SBO:',
-                                          'https://identifiers.org/SBO:'],
-                                          'http://biomodels.net/SBO/SBO_')
-SBO._sanitize_term = lambda term: term.replace('_', ' ')
-
-NCIT = Ontology(path=None,
-                endpoint='http://sparql.hegroup.org/sparql/',
-                uri='http://purl.obolibrary.org/obo/ncit.owl')
-
-NCIT._to_user = lambda uri: uri.replace('http://purl.obolibrary.org/obo/NCIT_',
-                                      'https://identifiers.org/ncit:')
-NCIT._from_user = lambda uri: multi_replace(uri,
-                                           ['http://identifiers.org/ncit/ncit:',
-                                           'https://identifiers.org/ncit:'],
-                                           'http://purl.obolibrary.org/obo/NCIT_')
-NCIT._sanitize_term = lambda term: term.replace('_', ' ')
-
 
-OM = Ontology(path=installation_path('ontologies/om-2.0.rdf'),
-              endpoint=None)
-OM._sanitize_term = lambda term: term.replace('liter', 'litre').replace('meter', 'metre').replace('molar', 'molair')
-OM._reverse_sanitize_term = lambda term: term.replace('litre', 'liter').replace('metre', 'meter').replace('molair', 'molar')
-'''
-'http://purl.obolibrary.org/obo/SO_0000167'
-'http://biomodels.net/SBO/SBO_0000241'
-'http://purl.obolibrary.org/obo/NCIT_C20865'
-'''
+def configure_cache_size(maxsize=1000):
+    if not '__wrapped__' in Ontology.get_term_by_uri.__dict__:
+        # Initialize cache
+        Ontology.get_term_by_uri = lru_cache(maxsize=maxsize)(Ontology.get_term_by_uri)
+        Ontology.get_uri_by_term = lru_cache(maxsize=maxsize)(Ontology.get_uri_by_term)
+    else:
+        # Reset cache-size if it was previously set
+        Ontology.get_term_by_uri = lru_cache(maxsize=maxsize)(Ontology.get_term_by_uri.__wrapped__)
+        Ontology.get_uri_by_term = lru_cache(maxsize=maxsize)(Ontology.get_uri_by_term.__wrapped__)
 
+# Initialize cache
+configure_cache_size()
diff --git a/tyto/sbo.py b/tyto/sbo.py
new file mode 100644
index 0000000..b064d5c
--- /dev/null
+++ b/tyto/sbo.py
@@ -0,0 +1,15 @@
+from .owlet import Ontology, Ontobee, installation_path, multi_replace
+
+
+SBO = Ontology(path=installation_path('ontologies/SBO_OWL.owl'),
+               endpoints=[Ontobee],
+               uri='http://purl.obolibrary.org/obo/sbo.owl')
+
+# Translate URIs to and from identifiers.org namespace
+SBO._sanitize_uri = lambda uri: multi_replace(uri,
+                                              ['http://identifiers.org/sbo/SBO:',
+                                               'https://identifiers.org/SBO:'],
+                                              'http://biomodels.net/SBO/SBO_')
+SBO._reverse_sanitize_uri = lambda uri: uri.replace('http://biomodels.net/SBO/SBO_',
+                                                    'https://identifiers.org/SBO:')
+SBO._sanitize_term = lambda term: term.replace('_', ' ')
diff --git a/tyto/so.py b/tyto/so.py
new file mode 100644
index 0000000..03ece8b
--- /dev/null
+++ b/tyto/so.py
@@ -0,0 +1,15 @@
+from .owlet import Ontology, Ontobee, installation_path, multi_replace
+
+
+SO = Ontology(path=installation_path('ontologies/so.owl'),
+              endpoints=[Ontobee],
+              uri='http://purl.obolibrary.org/obo/so.owl')
+
+# Translate URIs to and from the identifiers.org namespace
+SO._sanitize_uri = lambda uri: multi_replace(uri,
+                                             ['https://identifiers.org/SO:',
+                                              'http://identifiers.org/so/SO:'],
+                                             'http://purl.obolibrary.org/obo/SO_')
+SO._reverse_sanitize_uri = lambda uri: uri.replace('http://purl.obolibrary.org/obo/SO_',
+                                                   'https://identifiers.org/SO:')
+


Test patch:
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
new file mode 100644
index 0000000..bf860bc
--- /dev/null
+++ b/.github/workflows/python-tests.yml
@@ -0,0 +1,41 @@
+# This is a basic workflow to help you get started with Actions
+
+name: CI
+
+# Controls when the workflow will run
+on:
+  # Triggers the workflow on push or pull request events but only for the master branch
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        # Default builds are on Ubuntu
+        os: [ubuntu-latest]
+        python-version: [3.6, 3.9]
+        include:
+          # Also test on macOS and Windows using latest Python 3
+          - os: macos-latest
+            python-version: 3.x
+          - os: windows-latest
+            python-version: 3.x
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install .
+    - name: Test with unittest
+      run: |
+        python test/test_ontology.py
diff --git a/test/test_ontology.py b/test/test_ontology.py
index e28045a..72c13fc 100644
--- a/test/test_ontology.py
+++ b/test/test_ontology.py
@@ -1,5 +1,6 @@
 import unittest
 from tyto import *
+from tyto.endpoint import EBIOntologyLookupService
 
 
 class TestOntology(unittest.TestCase):
@@ -55,6 +56,36 @@ def test_OM(self):
         self.assertEqual(OM.get_term_by_uri('http://www.ontology-of-units-of-measure.org/resource/om-2/hour'),
                          'hour')
 
+    def test_subclass(self):
+        self.assertTrue(type(SO.promoter) is URI)
+        self.assertTrue(SO.inducible_promoter.is_subclass_of(SO.promoter))
+        self.assertFalse(SO.promoter.is_subclass_of(SO.inducible_promoter))
+
+
+class TestOLS(unittest.TestCase):
+
+    def test_SO(self):
+        restore_endpoints = SO.endpoints
+        restore_graph = SO.graph
+        SO.endpoints = [EBIOntologyLookupService]
+        SO.graph = None
+        uri = 'https://identifiers.org/SO:0000167'
+        self.assertEqual(SO.get_term_by_uri(uri), 'promoter')
+        self.assertEqual(SO.promoter, uri)
+        uri = uri.replace('0000167', 'xxxxxxx')
+        with self.assertRaises(LookupError):
+            self.assertEqual(SO.get_term_by_uri(uri), 'promoter')
+        with self.assertRaises(LookupError):
+            self.assertIsNone(SO.foo)
+        SO.endpoints = restore_endpoints
+        SO.graph = restore_graph
+
+    def test_SBO(self):
+        restore_endpoints = SBO.endpoints
+        restore_graph = SBO.graph
+        uri = 'https://identifiers.org/SBO:0000241'
+        self.assertEqual(SBO.get_term_by_uri(uri), 'functional entity')
+
 
 if __name__ == '__main__':
     unittest.main()