Repository: lifeomic/termlink

Issue:
Add Support for Generic OBO Files
Add support for generic OBO files.

`termlink generic --obo`

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1191879..4a83a13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+
+## Added
+
+- Adds a new sub-command: `common`
+- Adds support for .obo file types to `common` sub-command
+- Adds support for .owl file types to `common` sub-command
+
+### Changes
+
+- Improved performance of internal JSON serialization.
+
 ## 0.5.0
 
 ### Added
diff --git a/termlink/__main__.py b/termlink/__main__.py
index a865881..b6ac477 100644
--- a/termlink/__main__.py
+++ b/termlink/__main__.py
@@ -8,6 +8,7 @@ from termlink.configuration import Config
 
 from termlink.rxnorm import Command as RxNormCommand
 from termlink.hpo import Command as HPOCommand
+from termlink.common import Command as CommonCommand
 
 configuration = Config()
 logger = configuration.logger
@@ -23,9 +24,33 @@ subparsers = parser.add_subparsers(
     metavar=""
 )
 
+parser_common = subparsers.add_parser(
+    "common",
+    help="Converts a common format ontology",
+    description="""
+    Converts an ontology represented in a common format. The following file 
+    types are supported: .obo and .owl. 
+    """
+)
+
+parser_common.add_argument(
+    "uri",
+    metavar="URI",
+    help="resource identifier for files"
+)
+
+parser_common.add_argument(
+    "-s",
+    "--system",
+    help="identity of the terminology system",
+    required=True
+)
+
+parser_common.set_defaults(execute=CommonCommand.execute)
+
 parser_hpo = subparsers.add_parser(
     "hpo",
-    help="The Human Phenotype Ontology (HPO) project provides an ontology of medically relevant phenotypes, disease-phenotype annotations, and the algorithms that operate on these.",
+    help="Converts the 'Human Phenotype Ontology'",
     description="""
     The Human Phenotype Ontology (HPO) project provides an ontology of medically relevant phenotypes, disease-phenotype annotations, and the algorithms that operate on these. The HPO can be used to support differential diagnostics, translational research, and a number of applications in computational biology by providing the means to compute over the clinical phenotype. The HPO is being used for computational deep phenotyping and precision medicine as well as integration of clinical data into translational research. Deep phenotyping can be defined as the precise and comprehensive analysis of phenotypic abnormalities in which the individual components of the phenotype are observed and described. The HPO is being increasingly adopted as a standard for phenotypic abnormalities by diverse groups such as international rare disease organizations, registries, clinical labs, biomedical resources, and clinical software tools and will thereby contribute toward nascent efforts at global data exchange for identifying disease etiologies (Köhler et al, 2017). [1]
     """,
@@ -61,7 +86,7 @@ parser_hpo.set_defaults(skip_synonyms=False)
 
 parser_rxnorm = subparsers.add_parser(
     "rxnorm",
-    help="RxNorm provides normalized names for clinical drugs.",
+    help="Converts the 'RxNorm' code system",
     description="""
     RxNorm provides normalized names for clinical drugs and links its names to
     many of the drug vocabularies commonly used in pharmacy management and drug
diff --git a/termlink/common.py b/termlink/common.py
new file mode 100644
index 0000000..ca7e555
--- /dev/null
+++ b/termlink/common.py
@@ -0,0 +1,104 @@
+"""Handles conversions of common ontology formats
+
+This module provides support for converting common ontology formats. The 
+following file types are supported:
+
+- .obo: https://owlcollab.github.io/oboformat/doc/GO.format.obo-1_4.html
+- .owl: https://www.w3.org/OWL/
+
+"""
+
+import json
+
+from urllib.parse import urlparse
+
+from pronto import Ontology
+
+from termlink.commands import SubCommand
+from termlink.models import Coding, Relationship, RelationshipSchema
+
+
+def _to_coding(term, system):
+    """Converts a term into a `Coding`.
+
+    Args:
+        term: A `pronto.Term`
+
+    Returns:
+        a `termlink.models.Coding`
+    """
+    if ':' in term.id:
+        code = term.id.split(':')[1]
+    else:
+        code = term.id
+
+    return Coding(
+        system=system,
+        code=code,
+        display=term.name
+    )
+
+
+def _to_relationship(source, equivalence, target, system):
+    """Converts a source and target `pronto.Term` into a JSON object.
+
+    Args:
+        source: a `pronto.Term`
+        equivalence: a concept map equivalence
+        target: a `pronto.Term`
+
+    Returns:
+        a 'termlink.models.Relationship` in JSON form
+    """
+    source = _to_coding(source, system)
+    target = _to_coding(target, system)
+    return Relationship(equivalence, source, target)
+
+
+class Command(SubCommand):
+    "A command executor for generic ontology files."
+    @staticmethod
+    def execute(args):
+        uri = urlparse(args.uri)
+        system = args.system
+        service = Service(uri, system)
+        relationships = service.get_relationships()
+        schema = RelationshipSchema()
+        relationships = [schema.dump(relationship)
+                         for relationship in relationships]
+        print(json.dumps(relationships))
+
+
+class Service:
+    "Converts the Human Phenotype Ontology"
+
+    def __init__(self, uri, system):
+        """Bootstraps a service
+
+        Args:
+            uri: URI to the file location
+            system: The code system identifer
+        """
+        if uri.scheme != 'file':
+            raise ValueError("'uri.scheme' %s not supported" % uri.scheme)
+
+        self.uri = uri
+        self.system = system
+
+    def get_relationships(self):
+        """Parses a list of `Relationship` objects
+
+        Returns:
+            yields `Relationship`s in JSON form
+        """
+        ontology = Ontology(self.uri.path)
+
+        # child to parent relationships
+        for term in ontology:
+            for child in term.children:
+                yield _to_relationship(child, "subsumes", term, self.system)
+
+        # parent to child relationships
+        for term in ontology:
+            for parent in term.parents:
+                yield _to_relationship(parent, "specializes", term, self.system)
diff --git a/termlink/hpo.py b/termlink/hpo.py
index 0e5a084..7a2b721 100644
--- a/termlink/hpo.py
+++ b/termlink/hpo.py
@@ -4,12 +4,14 @@ This module provides methods to extract and transform the HPO (Human Phenotype O
 
 The download files for HPO are provided at https://hpo.jax.org/app/download/ontology
 """
+import json
+
 from urllib.parse import urlparse
 
 from pronto import Ontology, Term
 
 from termlink.commands import SubCommand
-from termlink.models import Coding, Relationship
+from termlink.models import Coding, Relationship, RelationshipSchema
 from termlink.services import RelationshipService
 
 
@@ -68,7 +70,7 @@ def _to_coding(term):
     )
 
 
-def _to_json(source, equivalence, target):
+def _to_relationship(source, equivalence, target):
     """Converts a source and target `pronto.Term` into a JSON object.
 
     Args:
@@ -81,8 +83,7 @@ def _to_json(source, equivalence, target):
     """
     source = _to_coding(source)
     target = _to_coding(target)
-    relationship = Relationship(equivalence, source, target)
-    return relationship.to_json()
+    return Relationship(equivalence, source, target)
 
 
 class Command(SubCommand):
@@ -96,8 +97,10 @@ class Command(SubCommand):
         service = Service(uri, skip_alt_ids, skip_synonyms)
 
         relationships = service.get_relationships()
-        for relationship in relationships:
-            print(relationship)
+        schema = RelationshipSchema()
+        relationships = [schema.dump(relationship)
+                         for relationship in relationships]
+        print(json.dumps(relationships))
 
 
 class Service(RelationshipService):
@@ -129,12 +132,12 @@ class Service(RelationshipService):
         # child to parent relationships
         for term in ontology:
             for child in term.children:
-                yield _to_json(child, "subsumes", term)
+                yield _to_relationship(child, "subsumes", term)
 
         # parent to child relationships
         for term in ontology:
             for parent in term.parents:
-                yield _to_json(parent, "specializes", term)
+                yield _to_relationship(parent, "specializes", term)
 
         # alt_id relationships:
         if not self.skip_alt_ids:
@@ -147,7 +150,7 @@ class Service(RelationshipService):
                                 name=term.name,
                                 desc=term.desc
                             )
-                            yield _to_json(term, "equal", target)
+                            yield _to_relationship(term, "equal", target)
 
         # synonym relationships
         if not self.skip_synonyms:
@@ -159,7 +162,7 @@ class Service(RelationshipService):
                         desc=term.desc
                     )
                     equivalence = _to_equivalence_from_scope(synonym.scope)
-                    yield _to_json(term, equivalence, target)
+                    yield _to_relationship(term, equivalence, target)
 
         # todo - lookup concept based on system and code
         # xref relationships
diff --git a/termlink/models.py b/termlink/models.py
index 5498c64..31f6961 100644
--- a/termlink/models.py
+++ b/termlink/models.py
@@ -1,7 +1,8 @@
 """Model representations of API interfaces"""
 
 from dataclasses import dataclass
-from dataclasses_json import DataClassJsonMixin
+
+from marshmallow import Schema, fields
 
 from termlink.configuration import Config
 
@@ -9,16 +10,34 @@ _configuration = Config()
 _logger = _configuration.logger
 
 
+class CodingSchema(Schema):
+    """A `Coding` schema
+
+    Attributes:
+        type:       string field
+        system:     string field
+        version:    string field
+        code:       string field
+        display     string field
+    """
+    type = fields.Str()
+    system = fields.Str()
+    version = fields.Str()
+    code = fields.Str()
+    display = fields.Str()
+
+
 @dataclass(frozen=True)
-class Coding(DataClassJsonMixin):
+class Coding():
     """
     A 'Coding' object as defined by the API.
 
     Attributes:
-        system (str):   Identity of the terminology system
-        version (str):  Version of the system
-        code (str):     Symbol in syntax defined by the system
-        display (str):  Representation defined by the system
+        type (str):     the type of object
+        system (str):   identity of the terminology system
+        version (str):  version of the system
+        code (str):     symbol in syntax defined by the system
+        display (str):  representation defined by the system
     """
     type: str = 'coding'
     system: str = None
@@ -27,8 +46,21 @@ class Coding(DataClassJsonMixin):
     display: str = None
 
 
-@dataclass
-class Relationship(DataClassJsonMixin):
+class RelationshipSchema(Schema):
+    """A `Relationship` schema
+
+    Attributes:
+        system: string field
+        source: `CodingSchema` field
+        target: `CodingSchema` field
+    """
+    equivalence = fields.Str()
+    source = fields.Nested(CodingSchema())
+    target = fields.Nested(CodingSchema())
+
+
+@dataclass(frozen=True)
+class Relationship():
     """
     A 'Relationship' object as defined by the API.
 


Test patch:
diff --git a/tests/adapter_test.py b/tests/adapter_test.py
index e00bd9b..742b17c 100644
--- a/tests/adapter_test.py
+++ b/tests/adapter_test.py
@@ -6,11 +6,13 @@ from urllib3.util.retry import Retry
 
 from termlink.adapter import build
 
+
 def test_build():
     """Checks that build works be default"""
     adapter = build()
     ok_(adapter)
 
+
 def test_build_with_custom_retry_policy():
     """Checks that an adapter can be built with a custom retry policy"""
     retry_policy = Retry()
diff --git a/tests/common_test.py b/tests/common_test.py
new file mode 100644
index 0000000..a91c8e6
--- /dev/null
+++ b/tests/common_test.py
@@ -0,0 +1,52 @@
+"""Verifies the 'common.py' module"""
+
+from nose.tools import eq_
+
+from pronto import Term
+
+from termlink.common import _to_coding, _to_relationship
+from termlink.models import Coding, Relationship
+
+
+def test_to_coding():
+    """Checks that a term is properly converted to a coding"""
+
+    system = "http://snomed.info/sct"
+    term = Term(id='SNOMEDCT_US:25064002', name='Headache')
+
+    res = _to_coding(term, system)
+
+    exp = Coding(
+        system=system,
+        code='25064002',
+        display='Headache'
+    )
+
+    eq_(exp, res)
+
+
+def test_to_json():
+    """Checks that a source, equivalence and target and properly converted"""
+
+    system = "http://snomed.info/sct"
+    source = Term(id='SNOMEDCT_US:735938006', name='Acute headache')
+    equivalence = 'subsumes'
+    target = Term(id='SNOMEDCT_US:25064002', name='Headache')
+
+    res = _to_relationship(source, equivalence, target, system)
+
+    exp = Relationship(
+        equivalence='subsumes',
+        source=Coding(
+            system=system,
+            code='735938006',
+            display='Acute headache'
+        ),
+        target=Coding(
+            system=system,
+            code='25064002',
+            display='Headache'
+        )
+    )
+
+    eq_(exp, res)
diff --git a/tests/hpo_test.py b/tests/hpo_test.py
index 2ebd002..84b0b61 100644
--- a/tests/hpo_test.py
+++ b/tests/hpo_test.py
@@ -1,15 +1,14 @@
 """Verifies the 'hpo.py' module"""
 
-import json
 from urllib.parse import urlparse
 
-from nose.tools import ok_, raises
+from nose.tools import ok_, raises, eq_
 
 from parameterized import parameterized
 
 from pronto import Term
 
-from termlink.hpo import Service, _to_equivalence_from_scope, _to_system, _to_coding, _to_json
+from termlink.hpo import Service, _to_equivalence_from_scope, _to_system, _to_coding, _to_relationship
 from termlink.models import Coding, Relationship
 
 
@@ -75,14 +74,14 @@ def test_to_coding():
     ok_(exp == res)
 
 
-def test_to_json():
+def test_to_relationship():
     """Checks that a source, equivalence and target and properly converted"""
 
     source = Term(id='HP:0000107', name='Renal cyst')
     equivalence = 'subsumes'
     target = Term(id='HP:0000003', name='Multicystic kidney dysplasia')
 
-    res = _to_json(source, equivalence, target)
+    res = _to_relationship(source, equivalence, target)
 
     exp = Relationship(
         equivalence='subsumes',
@@ -96,6 +95,6 @@ def test_to_json():
             code='0000003',
             display='Multicystic kidney dysplasia'
         )
-    ).to_json()
+    )
 
-    ok_(json.loads(exp) == json.loads(res))
+    eq_(exp, res)
diff --git a/tests/models_test.py b/tests/models_test.py
index b573401..ebcd6f8 100644
--- a/tests/models_test.py
+++ b/tests/models_test.py
@@ -1,10 +1,8 @@
 """Verifies the 'models.py' module"""
 
-import json
-
 from nose.tools import eq_
 
-from termlink.models import Coding, Relationship
+from termlink.models import Coding, CodingSchema, Relationship, RelationshipSchema
 
 
 def test_convert_coding_to_json():
@@ -24,15 +22,16 @@ def test_convert_coding_to_json():
 
     exp = {
         'type': 'coding',
-        system: system,
-        version: version,
-        code: code,
-        display: display
+        'system': system,
+        'version': version,
+        'code': code,
+        'display': display
     }
 
-    res = coding.to_json()
+    schema = CodingSchema()
+    res = schema.dump(coding)
 
-    eq_(json.dumps(exp), res)
+    eq_(exp, res)
 
 
 def test_convert_empty_coding_to_json():
@@ -47,9 +46,11 @@ def test_convert_empty_coding_to_json():
         'code': None,
         'display': None
     }
-    res = coding.to_json()
 
-    eq_(json.dumps(exp), res)
+    schema = CodingSchema()
+    res = schema.dump(coding)
+
+    eq_(exp, res)
 
 
 def test_convert_partial_coding_to_json():
@@ -67,9 +68,10 @@ def test_convert_partial_coding_to_json():
         'display': None
     }
 
-    res = coding.to_json()
+    schema = CodingSchema()
+    res = schema.dump(coding)
 
-    eq_(json.dumps(exp), res)
+    eq_(exp, res)
 
 
 def test_convert_relationship_to_json():
@@ -115,6 +117,7 @@ def test_convert_relationship_to_json():
         }
     }
 
-    res = relationship.to_json()
+    schema = RelationshipSchema()
+    res = schema.dump(relationship)
 
-    eq_(json.dumps(exp), res)
+    eq_(exp, res)