diff --git a/.gitignore b/.gitignore index 7bbc71c..0338b58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,101 +1,6 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +.idea +*.pyc +*.egg-info -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# dotenv -.env - -# virtualenv -.venv -venv/ -ENV/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ +htmlcov +.coverage \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7727735 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: python + +python: + - "2.7" + - "3.2" + - "3.3" + - "3.4" + - "3.5" + - "3.6" + - "pypy" # PyPy2 2.5.0 + - "pypy3" # Pypy3 2.4.0 + - "pypy-5.3.1" + +install: "pip install -r requirements.txt" + +# command to run tests +script: python -m unittest discover + diff --git a/README.md b/README.md deleted file mode 100644 index 9348dd0..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# obo_parser -Parses .obo files such the one containing the Human Phenotype Ontology (HPO) and converts them to a .tsv table which is easier to work with. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..164b559 --- /dev/null +++ b/README.rst @@ -0,0 +1,27 @@ +Parses ontologies in .obo format (such as the Human Phenotype Ontology) and converts them to a .tsv table. + +.. image:: https://travis-ci.org/macarthur-lab/obo_parser.svg?branch=master + :target: https://travis-ci.org/macarthur-lab/obo_parser + + +**Install** + +.. code:: bash + + git clone https://github.com/macarthur-lab/obo_parser.git + +**Test** + +.. code:: bash + + python -m unittest discover + +**Run** + +Examples: + +.. code:: bash + + python obo_parser.py --help + python obo_parser.py -r HP:0000118 http://purl.obolibrary.org/obo/hp.obo -o hpo.tsv + python obo_parser.py -c -r HP:0000118 http://purl.obolibrary.org/obo/hp.obo | cut -f 1,2,4,5,10,11 > hp.tsv diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/obo_parser.py b/obo_parser.py new file mode 100644 index 0000000..fe1a2b6 --- /dev/null +++ b/obo_parser.py @@ -0,0 +1,384 @@ +""" +This module provides utility functions for parsing data in the .obo (Open Biomedical Ontologies) +format and writing it out as a .tsv table for easier analysis. + +The .obo format spec can be found here: +http://owlcollab.github.io/oboformat/doc/GO.format.obo-1_2.html +""" + +import argparse +import collections +import contextlib +import logging +import os +import re +import sys +import tqdm +import urllib + +from builtins import dict +if sys.version_info >= (3, 0): + basestring = str + +logging.basicConfig(format='%(asctime)s %(levelname)-8s %(message)s') +logger = logging.getLogger(__name__) + +# regex used to parse records +TAG_AND_VALUE_REGEX = "(?P[^:]+):(?P[^!]+)" + +# column groups and re-mappings +ONLY_ONE_ALLOWED_PER_STANZA = set(["id", "name", "def", "comment"]) +EXCLUDE_FROM_TSV = set(["consider", "replaced_by", "property_value", "is_obsolete", "is_anonymous"]) +RENAME_COLUMNS = { + 'is_a': 'parent_ids', + 'def': 'definition', +} + + +def convert_obo_to_tsv(input_path, output_path=None, root_id=None, add_category_column=False): + """Main entry point for parsing an .obo file and converting it to a .tsv table. + + Args: + input_path (str): .obo file url or local file path + output_path (str): path where to write the .tsv file. Defaults to "-" which is standard out. + root_id (str): If specified, ignore ontology terms that are not either descendants of the + given id or have this id themselves. For example, 'HP:0000118'. + add_category_column (bool): Whether to add a 'category' column to the output .tsv file + which lists each term's top-level category. A top-level category is a term that's a + direct child of the ontology's root term. + """ + + # read in data + logger.info("Parsing %s", input_path) + with _open_input_stream(input_path) as input_stream: + obo_records_dict = parse_obo_format(input_stream) + + # find root term + if root_id is None: + root_id = _compute_root_id(obo_records_dict) + + _confirm_id_is_valid(root_id, obo_records_dict, label="root_id") + + # add 'category' columns to records + if add_category_column: + compute_category_column(obo_records_dict, root_id=root_id) + + # print stats and output .tsv + print_stats(obo_records_dict, input_path) + + if output_path is None: + write_tsv(obo_records_dict, output_stream=sys.stdout, root_id=root_id) + else: + with open(output_path, "w") as output_stream: + write_tsv(obo_records_dict, output_stream, root_id=root_id) + + logger.info("Done") + + +def parse_obo_format(lines): + """Parses .obo-formatted text. + + Args: + lines (iter): Iterator over lines of text in .obo format. + Returns: + dict: .obo records, keyed by term id. Each record is a dictionary where the keys are tags + such as "id", "name", "is_a", and values are strings (for tags that can only occur once + - such as "id"), or lists (for tags that can appear multiple times per stanza - such as + "xref") + """ + + obo_records_dict = collections.OrderedDict() + current_stanza_type = None + current_record = None + if logger.isEnabledFor(logging.INFO): + lines = tqdm.tqdm(lines, unit=" lines") + + for line in lines: + if line.startswith("["): + current_stanza_type = line.strip("[]\n") + continue + + # skip header lines and stanzas that aren't "Terms" + if current_stanza_type != "Term": + continue + + # remove new-line character and any comments + line = line.strip().split("!")[0] + if len(line) == 0: + continue + + match = re.match(TAG_AND_VALUE_REGEX, line) + if not match: + raise ValueError("Unexpected line format: %s" % str(line)) + + tag = match.group("tag") + value = match.group("value").strip() + + if tag == "id": + current_record = collections.defaultdict(list) + obo_records_dict[value] = current_record + + if tag in ONLY_ONE_ALLOWED_PER_STANZA: + if tag in current_record: + raise ValueError("More than one '%s' found in %s stanza: %s" % ( + tag, current_stanza_type, ", ".join([current_record[tag], value]))) + + current_record[tag] = value + else: + current_record[tag].append(value) + + # add a 'children' key and list of child ids to all records that have children + _compute_children_column(obo_records_dict) + + return obo_records_dict + + +def print_stats(obo_records_dict, input_path): + """Print various summary stats about the given .obo records. + + Args: + obo_records_dict (dict): data structure returned by parse_obo_format(..) + input_path (str): source path of .obo data. + """ + + if not logger.isEnabledFor(logging.INFO): + return + + tag_counter = collections.defaultdict(int) + value_counter = collections.defaultdict(int) + for term_id, record in obo_records_dict.items(): + for tag, value in record.items(): + tag_counter[tag] += 1 + if isinstance(value, list): + value_counter[tag] += len(value) + + logger.info("Parsed %s terms from %s", len(obo_records_dict), input_path) + total_records = len(obo_records_dict) + for tag, records_with_tag in sorted(tag_counter.items(), key=lambda t: t[1], reverse=True): + percent_with_tag = 100*records_with_tag/float(total_records) if total_records > 0 else 0 + + message = "%(records_with_tag)s out of %(total_records)s (%(percent_with_tag)0.1f%%) " \ + "records have a %(tag)s tag" + if tag in value_counter: + values_per_record = value_counter[tag] / float(records_with_tag) + message += ", and have, on average, %(values_per_record)0.1f values per record." + logger.info(message % locals()) + + +def _compute_root_id(obo_records_dict): + """Finds the top-level term in the heirarchy. + NOTE: this implementation assumes the ontology has a single root term, and doesn't have cycles. + """ + + if not obo_records_dict: + return None + + # start with a random id and walk up the heirarchy to find a term that doesn't have a parent + term_id = next(iter(obo_records_dict)) + while True: + parent_ids = obo_records_dict[term_id].get("is_a") + if parent_ids is None or len(parent_ids) == 0: + return term_id + + _confirm_id_is_valid(parent_ids[0], obo_records_dict, label="%s's parent id" % term_id) + + term_id = parent_ids[0] + + +def get_substree(obo_records_dict, root_id, skip_record=None): + """Generates .obo records that are either descendants of the given root_id or the root record + itself. + + Args: + obo_records_dict (dict): data structure returned by parse_obo_format(..) + root_id (str): Only ontology terms that are either descendants of the + given id or have this id themselves are returned. For example, 'HP:0000118'. + skip_record (function): A function which takes a record and returns True if the record (and + it's descendants) should be skipped. + Yields: + dict: .obo records + """ + + _confirm_id_is_valid(root_id, obo_records_dict, label='root_id') + + ids_to_process = collections.deque([root_id]) + processed_ids = set() + while ids_to_process: + next_id = ids_to_process.popleft() + record = obo_records_dict[next_id] + if next_id in processed_ids or (skip_record is not None and skip_record(record)): + continue + + yield record + + processed_ids.add(next_id) + child_ids = record.get('children', []) + ids_to_process.extend(child_ids) + + +def _compute_children_column(obo_records_dict): + """For each record that has child terms, compute a list of child term ids and store it in the + record under a new 'children' attribute. + """ + + for term_id, current_record in obo_records_dict.items(): + if "is_a" not in current_record: + continue + + for parent_id in current_record["is_a"]: + if parent_id not in obo_records_dict: + logger.warn("%s has a parent id %s which is not in the ontology" % ( + term_id, parent_id)) + continue + + parent_record = obo_records_dict[parent_id] + if 'children' not in parent_record: + parent_record['children'] = [] + + parent_record['children'].append(term_id) + + +def compute_category_column( + obo_records_dict, + root_id, + add_category_id_column=True, + add_category_name_column=True): + """Adds a "category_id" and/or "category_name" column to each record that's a descendant of the + root term. + + Args: + obo_records_dict (dict): data structure returned by parse_obo_format(..) + root_id (str): Only ontology terms that are either descendants of the + given id or have this id themselves are returned. For example, 'HP:0000118'. + add_category_id_column (bool): Whether to add a "category_id" to each record. + add_category_name_column (bool): Whether to add a "category_name" to each record. + """ + + _confirm_id_is_valid(root_id, obo_records_dict, label='root_id') + + root_record = obo_records_dict[root_id] + root_child_ids = root_record.get('children', []) + + if not root_child_ids: + logger.warn("root term has no child terms") + return + + for category_id in root_child_ids: + category_name = obo_records_dict[category_id].get("name", "") + + def is_category_already_assigned(record): + return 'category_id' in record or 'category_name' in record + + category_subtree = get_substree( + obo_records_dict, + root_id=category_id, + skip_record=is_category_already_assigned + ) + + for record in category_subtree: + if add_category_id_column: + record['category_id'] = category_id + if add_category_name_column: + record['category_name'] = category_name + + +def _open_input_stream(path): + """Returns an open stream for iterating over lines in the given path. + + Args: + path (str): url or local file path + Return: + iter: iterator over file handle + """ + if not isinstance(path, basestring): + raise ValueError("Unexpected path type: %s" % type(path)) + + is_url = path.startswith("http") + if is_url: + line_iterator = contextlib.closing(urllib.urlopen(path)) + else: + if not os.path.isfile(path): + raise ValueError("File not found: %s" % path) + + line_iterator = open(path) + + return line_iterator + + +def _compute_tsv_header(obo_records): + """Compute .tsv file header as a list of strings containing all tags in the given obo_records + + Args: + obo_records (iter): iterator over .obo records + """ + all_tags = set() + for record in obo_records: + for tag in record: + all_tags.add(tag) + + header = ['id', 'name'] + other_columns = sorted(list(all_tags - set(EXCLUDE_FROM_TSV) - set(header))) + header.extend(other_columns) + + return header + + +def write_tsv(obo_records_dict, output_stream, root_id=None, separator=", "): + """Write obo_records_dict to the given output_stream. + + Args: + obo_records_dict (dict): data structure returned by parse_obo_format(..) + output_stream (file): output stream where to write the .tsv file + root_id (str): Only ontology terms that are either descendants of the + given id or have this id themselves are returned. For example, 'HP:0000118'. + separator (str): separator for concatenating multiple values in a single column + """ + + header = _compute_tsv_header(obo_records_dict.values()) + output_stream.write("\t".join([RENAME_COLUMNS.get(column, column) for column in header])) + output_stream.write("\n") + for record in get_substree(obo_records_dict, root_id): + row = [] + for tag in header: + value = record.get(tag) + if value is None: + row.append("") + elif isinstance(value, list): + row.append(separator.join(map(str, value))) + else: + row.append(str(value)) + output_stream.write("\t".join(row)) + output_stream.write("\n") + + +def _confirm_id_is_valid(term_id, obo_records_dict, label="id"): + """Raises an exception if the given term id doesn't exist in the given obo_records_dict.""" + + if term_id not in obo_records_dict: + raise ValueError("%s '%s' not found in ontology" % (label, term_id)) + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Parse an .obo file and write out a .tsv table") + p.add_argument("-o", "--output-path", help="output .tsv file path. Defaults to standard out.") + p.add_argument("-r", "--root-id", help="If specified, ignore ontology terms that are not " + "either descendants of the given id or have this id themselves. For example: 'HP:0000118'.") + p.add_argument("-c", "--add-category-column", action="store_true", help="add a 'category' " + "column to the output .tsv file which lists each term's top-level category. A top-level " + "category is a term that's a direct child of the ontology's root term.") + p.add_argument("input_path", help=".obo file url or local file path. For example: " + "http://purl.obolibrary.org/obo/hp.obo") + p.add_argument("-v", "--verbose", action="store_true", help="Print stats and other info") + args = p.parse_args() + + if args.verbose: + logger.setLevel(logging.INFO) + else: + logger.setLevel(logging.WARN) + + convert_obo_to_tsv( + args.input_path, + output_path=args.output_path, + root_id=args.root_id, + add_category_column=args.add_category_column, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8ba39ef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +future +tqdm==4.11.2 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a1d4b24 --- /dev/null +++ b/setup.py @@ -0,0 +1,66 @@ +import os +import sys + + +try: + from setuptools import setup +except ImportError: + print("WARNING: setuptools not installed. Will try using distutils instead..") + from distutils.core import setup + + +command = sys.argv[-1] +if command == 'publish': + os.system('python setup.py sdist upload') + sys.exit() +elif command == "coverage": + try: + import coverage + except: + sys.exit("coverage.py not installed (pip install --user coverage)") + setup_py_path = os.path.abspath(__file__) + os.system('coverage run -m unittest discover') + os.system('coverage html') + os.system('open htmlcov/index.html') + print("Done computing coverage") + sys.exit() + +long_description = '' +if command not in ['test', 'coverage']: + long_description = open('README.rst').read() + +setup( + name='obo_parser', + version="0.9", + description='.obo format parser', + long_description=long_description, + author='Ben Weisburd', + author_email='weisburd@broadinstitute.org', + url='https://github.com/macarthur-lab/obo_parser', + py_modules=['obo_parser'], + include_package_data=True, + zip_safe=False, + install_requires=[ + 'tqdm', + 'future', + ], + license="MIT", + keywords='obo, bioinformatics, parser', + classifiers=[ + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Natural Language :: English', + "Programming Language :: Python :: 2", + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + ], + + test_suite='tests', +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/hpo_subset.obo b/tests/data/hpo_subset.obo new file mode 100644 index 0000000..6d43733 --- /dev/null +++ b/tests/data/hpo_subset.obo @@ -0,0 +1,198 @@ +format-version: 1.2 +data-version: releases/2017-04-13 +saved-by: Peter Robinson, Sebastian Koehler, Sandra Doelken, Chris Mungall, Melissa Haendel, Nicole Vasilevsky, Monarch Initiative, et al. +subsetdef: hposlim_core "Core clinical terminology" +subsetdef: secondary_consequence "Consequence of a disorder in another organ system." +synonymtypedef: HP:0045076 "UK spelling" +synonymtypedef: HP:0045077 "abbreviation" +synonymtypedef: HP:0045078 "plural form" +synonymtypedef: layperson "layperson term" +default-namespace: human_phenotype +ontology: hp +property_value: http://purl.org/dc/elements/1.1/contributor "Chris Mungall" xsd:string +property_value: http://purl.org/dc/elements/1.1/contributor "Courtney Hum" xsd:string +property_value: http://purl.org/dc/elements/1.1/contributor "Joie Davis" xsd:string +property_value: http://purl.org/dc/elements/1.1/contributor "Mark Engelstad" xsd:string +property_value: http://purl.org/dc/elements/1.1/contributor "Melissa Haendel" xsd:string +property_value: http://purl.org/dc/elements/1.1/contributor "Nicole Vasilevsky" xsd:string +property_value: http://purl.org/dc/elements/1.1/contributor "Sandra Doelken" xsd:string +property_value: http://purl.org/dc/elements/1.1/creator "Peter N Robinson" xsd:string +property_value: http://purl.org/dc/elements/1.1/creator "Sebastian Koehler" xsd:string +property_value: http://purl.org/dc/elements/1.1/creator "The Human Phenotype Ontology Consortium" xsd:string +property_value: http://purl.org/dc/elements/1.1/creator "The Monarch Initiative" xsd:string +property_value: http://purl.org/dc/elements/1.1/license "see http://www.human-phenotype-ontology.org" xsd:string +property_value: http://purl.org/dc/elements/1.1/rights "Peter Robinson, Sebastian Koehler, The Human Phenotype Ontology Consortium, and The Monarch Initiative" xsd:string +property_value: http://purl.org/dc/elements/1.1/subject "Phenotypic abnormalities encountered in human disease" xsd:string +owl-axioms: Prefix(owl:=)\nPrefix(rdf:=)\nPrefix(xml:=)\nPrefix(xsd:=)\nPrefix(rdfs:=)\n\n\nOntology(\nAnnotationAssertion( \"\")\nAnnotationAssertion( \"\")\nAnnotationAssertion( \"\")\nAnnotationAssertion( \"\")\nAnnotationAssertion(rdfs:comment \"\")\nAnnotationAssertion( \"\"^^xsd:string)\n) +logical-definition-view-relation: has_part + +[Term] +id: HP:0000001 +name: All +comment: Root of all terms in the Human Phenotype Ontology. +xref: UMLS:C0444868 + +[Term] +id: HP:0000118 +name: Phenotypic abnormality +def: "A phenotypic abnormality." [HPO:probinson] +comment: This is the root of the phenotypic abnormality subontology of the HPO. +synonym: "Organ abnormality" EXACT [] +xref: UMLS:C4021819 +is_a: HP:0000001 ! All + +[Term] +id: HP:0012374 +name: Abnormality of the globe +def: "An anomaly of the eyeball." [HPO:probinson] +comment: This term is used to separate anomalies of the eye proper from the ocular adnexa such as the eyelid and the tear glands. +subset: hposlim_core +xref: UMLS:C4022923 +is_a: HP:0012372 ! Abnormal eye morphology +created_by: peter +creation_date: 2013-10-13T03:50:35Z + +[Term] +id: HP:0012372 +name: Abnormal eye morphology +def: "A structural anomaly of the eye." [HPO:probinson] +synonym: "Abnormal eye morphology" EXACT layperson [] +synonym: "Abnormally shaped eye" EXACT layperson [orcid.org/0000-0001-5208-3432] +xref: UMLS:C4022925 +is_a: HP:0000478 ! Abnormality of the eye +created_by: peter +creation_date: 2013-10-13T03:44:43Z + +[Term] +id: HP:0004329 +name: Abnormality of the posterior segment of the globe +comment: The posterior segment comprises the anterior hyaloid membrane and all of the optical structures behind it: the vitreous humor, retina, choroid, and optic nerve. +synonym: "Abnormality of the posterior segment of the eye" EXACT [] +synonym: "Abnormality of the posterior segment of the eyeball" EXACT [] +xref: UMLS:C4025354 +is_a: HP:0012374 ! Abnormality of the globe +created_by: peter +creation_date: 2008-02-27T04:25:00Z + +[Term] +id: HP:0001098 +name: Abnormality of the fundus +xref: UMLS:C4025804 +is_a: HP:0004329 ! Abnormality of the posterior segment of the globe + +[Term] +id: HP:0000478 +name: Abnormality of the eye +def: "Any abnormality of the eye, including location, spacing, and intraocular abnormalities." [HPO:probinson] +subset: hposlim_core +synonym: "Abnormal eye" EXACT layperson [HPO:skoehler] +synonym: "Abnormality of the eye" EXACT layperson [] +synonym: "Eye disease" RELATED layperson [] +xref: MSH:D005124 +xref: MSH:D005128 +xref: SNOMEDCT_US:19416009 +xref: SNOMEDCT_US:371405004 +xref: SNOMEDCT_US:371409005 +xref: UMLS:C0015393 +xref: UMLS:C0015397 +is_a: HP:0000118 ! Phenotypic abnormality + +[Term] +id: HP:0000479 +name: Abnormality of the retina +def: "An abnormality of the retina." [HPO:probinson] +subset: hposlim_core +synonym: "Abnormal retina" EXACT [HPO:skoehler] +synonym: "Anomaly of the retina" EXACT [] +synonym: "Retinal disease" RELATED [] +xref: MSH:D012164 +xref: SNOMEDCT_US:29555009 +xref: UMLS:C0035300 +xref: UMLS:C0035309 +is_a: HP:0001098 ! Abnormality of the fundus + +[Term] +id: HP:0000480 +name: Retinal coloboma +def: "A notch or cleft of the retina." [HPO:probinson] +subset: hposlim_core +xref: SNOMEDCT_US:39302008 +xref: UMLS:C0240896 +is_a: HP:0000479 ! Abnormality of the retina +is_a: HP:0000589 ! Coloboma + +[Term] +id: HP:0000589 +name: Coloboma +alt_id: HP:0007767 +alt_id: HP:0007995 +def: "A developmental defect characterized by a cleft of some portion of the eye or ocular adnexa." [HPO:probinson] +synonym: "Ocular coloboma" EXACT [] +synonym: "Ocular colobomas" EXACT [] +xref: MSH:D003103 +xref: SNOMEDCT_US:92828000 +xref: SNOMEDCT_US:93390002 +xref: UMLS:C0009363 +is_a: HP:0000315 ! Abnormality of the orbital region + +[Term] +id: HP:0000315 +name: Abnormality of the orbital region +alt_id: HP:0000284 +synonym: "Abnormality of the eye region" EXACT layperson [orcid.org/0000-0001-5889-4463] +synonym: "Abnormality of the region around the eyes" EXACT layperson [orcid.org/0000-0001-5889-4463] +synonym: "Anomaly of the orbital region of the face" NARROW [orcid.org/0000-0001-5889-4463] +synonym: "Deformity of the orbital region of the face" NARROW [orcid.org/0000-0001-5889-4463] +synonym: "Malformation of the orbital region of the face" NARROW [orcid.org/0000-0001-5889-4463] +xref: UMLS:C4025863 +is_a: HP:0000271 ! Abnormality of the face + +[Term] +id: HP:0000271 +name: Abnormality of the face +def: "An abnormality of the face." [HPO:probinson] +subset: hposlim_core +synonym: "Abnormal face" EXACT layperson [HPO:skoehler] +synonym: "Abnormality of the countenance" BROAD [orcid.org/0000-0001-5889-4463] +synonym: "Abnormality of the face" EXACT layperson [] +synonym: "Abnormality of the physiognomy" BROAD [orcid.org/0000-0001-5889-4463] +synonym: "Abnormality of the visage" BROAD [orcid.org/0000-0001-5889-4463] +synonym: "Anomaly of face" RELATED [orcid.org/0000-0001-5889-4463] +synonym: "Anomaly of the face" RELATED [orcid.org/0000-0001-5889-4463] +synonym: "Disorder of face" BROAD layperson [orcid.org/0000-0001-5889-4463] +synonym: "Disorder of the face" NARROW layperson [orcid.org/0000-0001-5889-4463] +synonym: "Facial abnormality" EXACT layperson [orcid.org/0000-0001-5889-4463] +synonym: "Facial anomaly" RELATED [orcid.org/0000-0001-5889-4463] +xref: SNOMEDCT_US:118930001 +xref: SNOMEDCT_US:32003007 +xref: SNOMEDCT_US:398206004 +xref: SNOMEDCT_US:398302004 +xref: UMLS:C0266617 +xref: UMLS:C1290857 +xref: UMLS:C4025871 +is_a: HP:0000234 ! Abnormality of the head + +[Term] +id: HP:0000234 +name: Abnormality of the head +def: "An abnormality of the head." [HPO:probinson] +synonym: "Abnormal head" EXACT layperson [HPO:skoehler] +synonym: "Abnormality of the head" EXACT layperson [] +synonym: "Head abnormality" EXACT layperson [] +xref: UMLS:C4021812 +is_a: HP:0000152 ! Abnormality of head or neck + +[Term] +id: HP:0000152 +name: Abnormality of head or neck +def: "An abnormality of head and neck." [HPO:probinson] +synonym: "Abnormality of head or neck" EXACT layperson [] +synonym: "Head and neck abnormality" EXACT layperson [] +xref: UMLS:C4021817 +is_a: HP:0000118 ! Phenotypic abnormality + +[Term] +id: HP:0007808 +name: Bilateral retinal coloboma +xref: UMLS:C4024797 +is_a: HP:0000480 ! Retinal coloboma diff --git a/tests/test_other_functions.py b/tests/test_other_functions.py new file mode 100644 index 0000000..6a8eb54 --- /dev/null +++ b/tests/test_other_functions.py @@ -0,0 +1,122 @@ +import logging +import os +import sys +import unittest + +if sys.version_info >= (3, 0): + from io import StringIO +else: + from StringIO import StringIO + + +from obo_parser import _open_input_stream, parse_obo_format, _compute_tsv_header, \ + compute_category_column, _compute_root_id, get_substree, \ + _confirm_id_is_valid, print_stats, write_tsv, logger + +OBO_FILE_PATH = os.path.join(os.path.dirname(__file__), "data/hpo_subset.obo") + + +class ParserTests(unittest.TestCase): + + def setUp(self): + with _open_input_stream(OBO_FILE_PATH) as input_stream: + self.obo_records_dict = parse_obo_format(input_stream) + + def test_compute_root_id(self): + self.obo_records_dict + + def test_compute_tsv_header(self): + self.assertListEqual(_compute_tsv_header([]), ['id', 'name']) + + self.assertListEqual(_compute_tsv_header(self.obo_records_dict.values()), [ + 'id', 'name', 'alt_id', 'comment', 'created_by', 'creation_date', 'def', 'is_a', + 'subset', 'synonym', 'xref' + ]) + + def test_compute_children_column(self): + self.assertTrue('children' in self.obo_records_dict['HP:0000118']) + self.assertTrue('children' in self.obo_records_dict['HP:0000480']) + self.assertTrue('children' in self.obo_records_dict['HP:0000479']) + + self.assertListEqual( + self.obo_records_dict['HP:0000118']['children'], + ['HP:0000478', 'HP:0000152'] + ) + + def test_compute_category_column(self): + compute_category_column(self.obo_records_dict, root_id='HP:0000118') + + self.assertTrue('category_id' not in self.obo_records_dict['HP:0000118']) + self.assertTrue('category_id' in self.obo_records_dict['HP:0000480']) + self.assertTrue('category_id' in self.obo_records_dict['HP:0000479']) + self.assertTrue('category_id' in self.obo_records_dict['HP:0007808']) + + self.assertEqual('HP:0000478', self.obo_records_dict['HP:0000480']['category_id']) + self.assertEqual('HP:0000478', self.obo_records_dict['HP:0007808']['category_id']) + self.assertEqual('HP:0000478', self.obo_records_dict['HP:0007808']['category_id']) + self.assertEqual('HP:0000478', self.obo_records_dict['HP:0000478']['category_id']) + self.assertEqual('HP:0000478', self.obo_records_dict['HP:0000479']['category_id']) + + self.assertEqual('HP:0000152', self.obo_records_dict['HP:0000152']['category_id']) + self.assertEqual('HP:0000152', self.obo_records_dict['HP:0000234']['category_id']) + self.assertEqual('HP:0000152', self.obo_records_dict['HP:0000271']['category_id']) + + def test_compute_tsv_header(self): + self.assertListEqual(_compute_tsv_header([]), ['id', 'name']) + self.assertListEqual(_compute_tsv_header(self.obo_records_dict.values()), [ + 'id', 'name', 'alt_id', 'children', + 'comment', 'created_by', 'creation_date', 'def', 'is_a', 'subset', 'synonym', 'xref' + ]) + + compute_category_column(self.obo_records_dict, root_id='HP:0000118') + + self.assertListEqual(_compute_tsv_header(self.obo_records_dict.values()), [ + 'id', 'name', 'alt_id', 'category_id', 'category_name', 'children', + 'comment', 'created_by', 'creation_date', 'def', 'is_a', 'subset', 'synonym', 'xref' + ]) + + def test_print_stats(self): + # just test that the code runs without crashing + logger.setLevel(logging.INFO) + + print_stats({}, 'input_path.obo') + + print_stats(self.obo_records_dict, 'input_path.obo') + + def test_get_subtree(self): + subtree = { + record['id']: record for record in get_substree(self.obo_records_dict, 'HP:0000118') + } + self.assertFalse('HP:000001' in subtree) + self.assertTrue('HP:0000118' in subtree) + self.assertTrue('HP:0000118' in subtree) + + def test_compute_root_id(self): + root_id = _compute_root_id(self.obo_records_dict) + self.assertEqual('HP:0000001', root_id) + + for root_id in ['HP:0000118', 'HP:0000479']: + subtree = { + record['id']: record for record in get_substree(self.obo_records_dict, root_id) + } + + # unlink subtree from parent tree + subtree[root_id]['is_a'] = None + + computed_root_id = _compute_root_id(subtree) + self.assertEqual(root_id, computed_root_id) + + def test_confirm_id_is_valid(self): + self.assertRaises(ValueError, lambda: _confirm_id_is_valid('HP:000ABC', self.obo_records_dict)) + + def test_write_tsv(self): + output_stream = StringIO() + + write_tsv(self.obo_records_dict, output_stream, root_id="HP:0000480") + + lines = output_stream.getvalue().rstrip('\n').split('\n') + self.assertEqual(3, len(lines)) + self.assertEqual(lines[0], "id name alt_id children comment created_by creation_date definition parent_ids subset synonym xref") + self.assertEqual(lines[1], 'HP:0000480 Retinal coloboma HP:0007808 "A notch or cleft of the retina." [HPO:probinson] HP:0000479, HP:0000589 hposlim_core SNOMEDCT_US:39302008, UMLS:C0240896') + self.assertEqual(lines[2], "HP:0007808 Bilateral retinal coloboma HP:0000480 UMLS:C4024797") + diff --git a/tests/test_parser_functions.py b/tests/test_parser_functions.py new file mode 100644 index 0000000..b8769cf --- /dev/null +++ b/tests/test_parser_functions.py @@ -0,0 +1,42 @@ +import os +import unittest + +from obo_parser import _open_input_stream, parse_obo_format + +OBO_FILE_PATH = os.path.join(os.path.dirname(__file__), "data/hpo_subset.obo") + + +class ParserTests(unittest.TestCase): + + def test_open_input_stream(self): + self.assertRaises(ValueError, lambda: _open_input_stream(None)) + self.assertRaises(ValueError, lambda: _open_input_stream("dir/missing_file.obo")) + + with _open_input_stream(OBO_FILE_PATH) as input_stream: + content = input_stream.read() + file_size = len(content) + self.assertGreater(file_size, 2000) + + def test_parse_obo_format(self): + with _open_input_stream(OBO_FILE_PATH) as input_stream: + obo_records_dict = parse_obo_format(input_stream) + + self.assertListEqual(list(obo_records_dict.keys()), [ + 'HP:0000001', 'HP:0000118', 'HP:0012374', 'HP:0012372', 'HP:0004329', + 'HP:0001098', 'HP:0000478', 'HP:0000479', 'HP:0000480', 'HP:0000589', + 'HP:0000315', 'HP:0000271', 'HP:0000234', 'HP:0000152', 'HP:0007808', + ]) + + self.assertEqual(obo_records_dict['HP:0000480'].get('name'), "Retinal coloboma") + self.assertEqual(obo_records_dict['HP:0000118'].get('name'), "Phenotypic abnormality") + self.assertEqual(obo_records_dict['HP:0000234'].get('name'), "Abnormality of the head") + self.assertEqual(obo_records_dict['HP:0000152'].get('name'), "Abnormality of head or neck") + self.assertEqual(obo_records_dict['HP:0007808'].get('name'), "Bilateral retinal coloboma") + + self.assertEqual( + obo_records_dict['HP:0000480'].get('def'), + '"A notch or cleft of the retina." [HPO:probinson]') + + self.assertListEqual( + obo_records_dict['HP:0000480'].get('is_a'), + ['HP:0000479', 'HP:0000589'])