├── pronto ├── py.typed ├── utils │ ├── __init__.py │ ├── iter.py │ ├── warnings.py │ ├── pool.py │ ├── io.py │ └── meta.py ├── parsers │ ├── __init__.py │ ├── obo.py │ ├── obojson.py │ └── base.py ├── logic │ ├── __init__.py │ └── lineage.py ├── serializers │ ├── obojson.py │ ├── owx.py │ ├── rdfxml.py │ ├── ofn.py │ ├── __init__.py │ ├── base.py │ └── obo.py ├── entity │ └── attributes.py ├── definition.py ├── __init__.py ├── xref.py ├── pv.py ├── metadata.py └── synonym.py ├── tests ├── test_logic │ ├── __init__.py │ └── test_lineage.py ├── test_parser │ ├── __init__.py │ └── test_obojson.py ├── test_serializer │ ├── __init__.py │ ├── base.py │ └── test_obo.py ├── data │ ├── owen-jones-gen.obo │ ├── mp.obo │ ├── winni-genp.obo │ ├── plana.obo │ ├── go.obo │ ├── obographs │ │ ├── obsoletion_example.json │ │ ├── basic.json │ │ ├── abox.json │ │ └── equivNodeSetTest.json │ └── hp.obo ├── __init__.py ├── test_def.py ├── utils.py ├── test_relationship.py ├── test_xref.py ├── test_documentation.py ├── test_synonym.py ├── test_doctest.py ├── test_pv.py ├── test_issues.py ├── test_ontology.py ├── test_entity.py └── test_term.py ├── .gitattributes ├── docs ├── source │ ├── guide │ │ ├── changes.md │ │ ├── examples │ │ │ └── index.rst │ │ ├── index.rst │ │ ├── install.rst │ │ ├── about.rst │ │ └── updating.rst │ ├── _static │ │ ├── embl.png │ │ ├── ens.png │ │ ├── css │ │ │ └── main.css │ │ ├── json │ │ │ └── switcher.json │ │ └── js │ │ │ └── custom-icon.js │ ├── api │ │ ├── warnings.rst │ │ └── index.rst │ ├── _templates │ │ └── summary.rst │ ├── index.rst │ └── conf.py ├── .gitignore ├── requirements.txt ├── Makefile └── make.bat ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .readthedocs.yaml ├── COPYING ├── .gitignore ├── pyproject.toml └── README.md /pronto/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pronto/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_logic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_parser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_serializer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/data/* linguist-vendored 2 | -------------------------------------------------------------------------------- /docs/source/guide/changes.md: -------------------------------------------------------------------------------- 1 | ../../../CHANGELOG.md -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | build/* 2 | source/changes.md 3 | source/api/ 4 | -------------------------------------------------------------------------------- /tests/data/owen-jones-gen.obo: -------------------------------------------------------------------------------- 1 | format-version: 1.4 2 | 3 | [Term] 4 | id: ONT0:ROOT 5 | name: ° 6 | -------------------------------------------------------------------------------- /docs/source/_static/embl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/althonos/pronto/HEAD/docs/source/_static/embl.png -------------------------------------------------------------------------------- /docs/source/_static/ens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/althonos/pronto/HEAD/docs/source/_static/ens.png -------------------------------------------------------------------------------- /docs/source/guide/examples/index.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | ms 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.insert(0, os.path.realpath(os.path.join(__file__, "..", ".."))) 5 | -------------------------------------------------------------------------------- /tests/data/mp.obo: -------------------------------------------------------------------------------- 1 | format-version: 1.4 2 | ontology: mp 3 | 4 | [Term] 5 | id: MP:0030151 6 | name: abnormal buccinator muscle morphology 7 | xref: https://en.wikipedia.org/wiki/Buccinator_muscle 8 | -------------------------------------------------------------------------------- /pronto/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseParser 2 | from .obo import OboParser 3 | from .obojson import OboJSONParser 4 | from .rdfxml import RdfXMLParser 5 | 6 | __all__ = ["BaseParser", "OboParser", "OboJSONParser", "RdfXMLParser"] 7 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | semantic_version ~=2.8 2 | sphinx >=4.0 3 | recommonmark ~=0.7 4 | pygments-style-monokailight ~=0.4 5 | ipython ~=7.19 6 | pygments ~=2.4 7 | nbsphinx ~=0.8 8 | sphinxcontrib-jquery ~=4.1 9 | sphinx-design 10 | pydata-sphinx-theme 11 | fastobo ~=0.12.0 -------------------------------------------------------------------------------- /docs/source/api/warnings.rst: -------------------------------------------------------------------------------- 1 | Warnings 2 | ======== 3 | 4 | .. currentmodule:: pronto.warnings 5 | 6 | .. autoexception:: ProntoWarning 7 | 8 | .. autoexception:: NotImplementedWarning 9 | 10 | .. autoexception:: SyntaxWarning 11 | 12 | .. autoexception:: UnstableWarning 13 | -------------------------------------------------------------------------------- /docs/source/_templates/summary.rst: -------------------------------------------------------------------------------- 1 | {{ name }} 2 | {{ underline }} 3 | 4 | .. currentmodule:: {{ module }} 5 | 6 | .. autoclass:: {{ name }}() 7 | :special-members: __eq__, __lt__, __gt__, __le__, __ge__, __hash__, __repr__ 8 | :inherited-members: 9 | :members: 10 | :show-inheritance: -------------------------------------------------------------------------------- /tests/data/winni-genp.obo: -------------------------------------------------------------------------------- 1 | format-version: 1.4 2 | 3 | [Term] 4 | id: ONT0:CHILD_0 5 | name: child_0 6 | is_a: ONT0:ROOT ! root 7 | 8 | [Term] 9 | id: ONT0:CHILD_1 10 | name: child_1 11 | is_a: ONT0:ROOT ! root 12 | 13 | [Term] 14 | id: ONT0:CHILD_2 15 | name: child_2 16 | is_a: ONT0:ROOT ! root 17 | 18 | [Term] 19 | id: ONT0:ROOT 20 | name: root 21 | -------------------------------------------------------------------------------- /pronto/logic/__init__.py: -------------------------------------------------------------------------------- 1 | from .lineage import ( 2 | Lineage, 3 | SubclassesIterator, 4 | SubpropertiesIterator, 5 | SuperclassesIterator, 6 | SuperpropertiesIterator, 7 | ) 8 | 9 | __all__ = [ 10 | Lineage.__name__, 11 | SubclassesIterator.__name__, 12 | SubpropertiesIterator.__name__, 13 | SuperclassesIterator.__name__, 14 | SuperpropertiesIterator.__name__, 15 | ] 16 | -------------------------------------------------------------------------------- /pronto/serializers/obojson.py: -------------------------------------------------------------------------------- 1 | from typing import BinaryIO 2 | 3 | import fastobo 4 | 5 | from ._fastobo import FastoboSerializer 6 | from .base import BaseSerializer 7 | 8 | 9 | class OboJSONSerializer(FastoboSerializer, BaseSerializer): 10 | 11 | format = "json" 12 | 13 | def dump(self, file: BinaryIO): 14 | doc = self._to_obodoc(self.ont) 15 | fastobo.dump_graph(doc, file) 16 | -------------------------------------------------------------------------------- /pronto/serializers/owx.py: -------------------------------------------------------------------------------- 1 | from typing import BinaryIO 2 | 3 | import fastobo 4 | 5 | from ._fastobo import FastoboSerializer 6 | from .base import BaseSerializer 7 | 8 | 9 | class OwlXMLSerializer(FastoboSerializer, BaseSerializer): 10 | 11 | format = "owx" 12 | 13 | def dump(self, file: BinaryIO): 14 | doc = self._to_obodoc(self.ont) 15 | fastobo.dump_owl(doc, file, format="owx") 16 | -------------------------------------------------------------------------------- /pronto/serializers/rdfxml.py: -------------------------------------------------------------------------------- 1 | from typing import BinaryIO 2 | 3 | import fastobo 4 | 5 | from ._fastobo import FastoboSerializer 6 | from .base import BaseSerializer 7 | 8 | 9 | class RdfXMLSerializer(FastoboSerializer, BaseSerializer): 10 | 11 | format = "rdf" 12 | 13 | def dump(self, file: BinaryIO): 14 | doc = self._to_obodoc(self.ont) 15 | fastobo.dump_owl(doc, file, format="rdf") 16 | -------------------------------------------------------------------------------- /pronto/serializers/ofn.py: -------------------------------------------------------------------------------- 1 | from typing import BinaryIO 2 | 3 | import fastobo 4 | 5 | from ._fastobo import FastoboSerializer 6 | from .base import BaseSerializer 7 | 8 | 9 | class OwlFunctionalSerializer(FastoboSerializer, BaseSerializer): 10 | 11 | format = "ofn" 12 | 13 | def dump(self, file: BinaryIO): 14 | doc = self._to_obodoc(self.ont) 15 | fastobo.dump_owl(doc, file, format="ofn") 16 | -------------------------------------------------------------------------------- /docs/source/guide/index.rst: -------------------------------------------------------------------------------- 1 | User Guide 2 | ========== 3 | 4 | This section contains guides and documents about Pronto usage. 5 | 6 | .. toctree:: 7 | :maxdepth: 1 8 | :caption: Getting Started 9 | 10 | Installation 11 | Examples 12 | 13 | .. toctree:: 14 | :maxdepth: 1 15 | :caption: Resources 16 | 17 | Update Guide 18 | Changelog 19 | About -------------------------------------------------------------------------------- /pronto/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseSerializer 2 | from .obo import OboSerializer 3 | from .obojson import OboJSONSerializer 4 | from .ofn import OwlFunctionalSerializer 5 | from .owx import OwlXMLSerializer 6 | from .rdfxml import RdfXMLSerializer 7 | 8 | __all__ = [ 9 | "BaseSerializer", 10 | "OboSerializer", 11 | "OboJSONSerializer", 12 | "OwlFunctionalSerializer", 13 | "OwlXMLSerializer", 14 | "RdfXMLSerializer" 15 | ] 16 | -------------------------------------------------------------------------------- /pronto/serializers/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import io 3 | from typing import BinaryIO, ClassVar 4 | 5 | from ..ontology import Ontology 6 | 7 | 8 | class BaseSerializer(abc.ABC): 9 | 10 | format: ClassVar[str] = NotImplemented 11 | 12 | def __init__(self, ont: Ontology): 13 | self.ont = ont 14 | 15 | @abc.abstractmethod 16 | def dump(self, file: BinaryIO) -> None: 17 | return NotImplemented # type: ignore 18 | 19 | def dumps(self) -> str: 20 | s = io.BytesIO() 21 | self.dump(s) 22 | return s.getvalue().decode("utf-8") 23 | -------------------------------------------------------------------------------- /docs/source/_static/css/main.css: -------------------------------------------------------------------------------- 1 | p { 2 | text-align: justify; 3 | } 4 | 5 | /* a.reference strong { 6 | font-weight: bold; 7 | font-size: 90%; 8 | color: #c7254e; 9 | box-sizing: border-box; 10 | font-family: Menlo,Monaco,Consolas,"Courier New",monospace; 11 | } */ 12 | 13 | .field-list a.reference { 14 | font-weight: bold; 15 | font-size: 90%; 16 | color: #c7254e; 17 | box-sizing: border-box; 18 | font-family: Menlo,Monaco,Consolas,"Courier New",monospace; 19 | } 20 | 21 | .class dd { 22 | margin-left: 2% 23 | } 24 | 25 | .exception dd { 26 | margin-left: 2% 27 | } 28 | -------------------------------------------------------------------------------- /tests/test_def.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import unittest 4 | import warnings 5 | 6 | import pronto 7 | 8 | 9 | class TestDefinition(unittest.TestCase): 10 | @classmethod 11 | def setUpClass(cls): 12 | warnings.simplefilter('error') 13 | 14 | @classmethod 15 | def tearDownClass(cls): 16 | warnings.simplefilter(warnings.defaultaction) 17 | 18 | def test_repr(self): 19 | d1 = pronto.Definition("something") 20 | self.assertEqual(repr(d1), "Definition('something')") 21 | d2 = pronto.Definition("something", xrefs={pronto.Xref("Bgee:fbb")}) 22 | self.assertEqual(repr(d2), "Definition('something', xrefs={Xref('Bgee:fbb')})") 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: ipython 11 | versions: 12 | - "> 7.11, < 8" 13 | - dependency-name: pygments 14 | versions: 15 | - "> 2.4, < 3" 16 | - dependency-name: sphinx 17 | versions: 18 | - ">= 3.a, < 4" 19 | - dependency-name: coverage 20 | versions: 21 | - "5.4" 22 | - dependency-name: twine 23 | versions: 24 | - 3.3.0 25 | - dependency-name: fastobo 26 | versions: 27 | - 0.9.3 28 | - dependency-name: codecov 29 | versions: 30 | - 2.1.0 31 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /pronto/utils/iter.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sized 2 | from typing import Generic, Iterator, TypeVar 3 | 4 | __all__ = ["SizedIterator"] 5 | S = TypeVar("S") 6 | T = TypeVar("T") 7 | 8 | 9 | class SizedIterator(Generic[T], Iterator[T], Sized): 10 | """A wrapper for iterators which length is known in advance.""" 11 | 12 | def __init__(self, it: Iterator[T], length: int): 13 | self._it = it 14 | self._length = length 15 | 16 | def __len__(self) -> int: 17 | return self._length 18 | 19 | def __length_hint__(self) -> int: 20 | return self._length 21 | 22 | def __iter__(self: S) -> S: 23 | return self 24 | 25 | def __next__(self) -> T: 26 | val = next(self._it) 27 | self._length -= 1 28 | return val 29 | -------------------------------------------------------------------------------- /pronto/utils/warnings.py: -------------------------------------------------------------------------------- 1 | """Warnings raised by the library. 2 | """ 3 | 4 | 5 | class ProntoWarning(Warning): 6 | """The class for all warnings raised by `pronto`.""" 7 | 8 | pass 9 | 10 | 11 | class NotImplementedWarning(ProntoWarning, NotImplementedError): 12 | """Some part of the code is yet to be implemented.""" 13 | 14 | pass 15 | 16 | 17 | class UnstableWarning(ProntoWarning): 18 | """The behaviour of the executed code might change in the future.""" 19 | 20 | pass 21 | 22 | 23 | class SyntaxWarning(ProntoWarning, SyntaxError): 24 | """The parsed document contains incomplete or unsound constructs.""" 25 | 26 | def __init__(self, *args, **kwargs): 27 | ProntoWarning.__init__(self, *args, **kwargs) 28 | SyntaxError.__init__(self, *args, **kwargs) 29 | -------------------------------------------------------------------------------- /tests/data/plana.obo: -------------------------------------------------------------------------------- 1 | format-version: 1.2 2 | data-version: releases/2019-05-25 3 | synonymtypedef: human "synonym typically used in the context of human anatomy" NARROW 4 | synonymtypedef: vertebrate "synonym typically used in the context of vertebrate anatomy" NARROW 5 | default-namespace: Planarian_Anatomy 6 | treat-xrefs-as-genus-differentia: CARO part_of NCBITaxon:79327; CL part_of NCBITaxon:79327 7 | ontology: plana 8 | 9 | [Term] 10 | id: PLANA:0007518 11 | name: lysosome 12 | def: "The digestive component of the cell. They are homogenous, dense, membrane bound organelles filled with acid hydrolases which break down polymers." [ISBN:0-71677033-4, PMID:4853064] 13 | property_value: seeAlso https://planosphere.stowers.org/ontology/PLANA_0007518 xsd:string 14 | created_by: Steph Nowotarski 15 | creation_date: 2017-05-04T15:35:23Z 16 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.11" 12 | 13 | # VCS submodules configuration. 14 | submodules: 15 | include: all 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/source/conf.py 20 | 21 | # Optional but recommended, declare the Python requirements required 22 | # to build your documentation 23 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 24 | python: 25 | install: 26 | - requirements: docs/requirements.txt 27 | - method: pip 28 | path: . 29 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import importlib 3 | import os 4 | import sys 5 | 6 | # Resources 7 | TESTDIR = os.path.dirname(os.path.abspath(__file__)) 8 | MAINDIR = os.path.dirname(TESTDIR) 9 | DOCSDIR = os.path.join(MAINDIR, "docs") 10 | DATADIR = os.path.join(TESTDIR, "data") 11 | 12 | # Shortcut to try import modules/functions 13 | def try_import(*paths): 14 | for path in paths: 15 | if path is None: 16 | return None 17 | with contextlib.suppress(ImportError, AttributeError): 18 | if ":" in path: 19 | modname, attrname = path.rsplit(":", 1) 20 | return getattr(importlib.import_module(modname), attrname) 21 | else: 22 | return importlib.import_module(path) 23 | raise ImportError(f"could not find any of the following: {', '.join(paths)}") 24 | 25 | 26 | # Force importing the local version of the module 27 | sys.path.insert(0, MAINDIR) 28 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /pronto/utils/pool.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import Callable, Iterable, List 3 | 4 | try: 5 | from multiprocessing.pool import ThreadPool as _ThreadPool 6 | except ImportError: 7 | _ThreadPool = None # type: ignore 8 | 9 | 10 | _T = typing.TypeVar("_T") 11 | _U = typing.TypeVar("_U") 12 | 13 | 14 | class ThreadPool(object): 15 | 16 | def __init__(self, threads: int = 0): 17 | self.threads = threads 18 | self.pool = None if _ThreadPool is None else _ThreadPool(self.threads) 19 | 20 | def __enter__(self) -> "Pool": 21 | if self.pool is not None: 22 | self.pool.__enter__() 23 | return self 24 | 25 | def __exit__(self, exc_val, exc_ty, tb): 26 | if self.pool is not None: 27 | return self.pool.__exit__(exc_val, exc_ty, tb) 28 | return False 29 | 30 | def map(self, func: Callable[[_T], _U], items: Iterable[_T]) -> List[_U]: 31 | if self.pool is None: 32 | return list(map(func, items)) 33 | else: 34 | return self.pool.map(func, items) 35 | 36 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2025 Martin Larralde 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/source/guide/install.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | PyPi 5 | ^^^^ 6 | 7 | ``pronto`` is hosted on GitHub, but the easiest way to install it is to download 8 | the latest release from its `PyPi repository `_. 9 | It will install all dependencies then install the ``pronto`` module: 10 | 11 | .. code:: console 12 | 13 | $ pip install --user pronto 14 | 15 | Conda 16 | ^^^^^ 17 | 18 | Pronto is also available as a `recipe `_ 19 | in the `bioconda `_ channel. To install, simply 20 | use the `conda` installer: 21 | 22 | .. code:: console 23 | 24 | $ conda install -c bioconda pronto 25 | 26 | 27 | GitHub + ``pip`` 28 | ^^^^^^^^^^^^^^^^ 29 | 30 | If, for any reason, you prefer to download the library from GitHub, you can clone 31 | the repository and install the repository by running (with the admin rights): 32 | 33 | .. code:: console 34 | 35 | $ pip install --user git+https://github.com/althonos/pronto/ 36 | 37 | Keep in mind this will install the development version of the library, so not 38 | everything may work as expected compared to a stable versioned release. 39 | 40 | -------------------------------------------------------------------------------- /tests/test_logic/test_lineage.py: -------------------------------------------------------------------------------- 1 | import io 2 | import operator 3 | import os 4 | import unittest 5 | import warnings 6 | import xml.etree.ElementTree as etree 7 | 8 | import pronto 9 | from pronto.logic import lineage 10 | 11 | 12 | class TestLineage(unittest.TestCase): 13 | 14 | def test_eq(self): 15 | self.assertEqual(lineage.Lineage(), lineage.Lineage()) 16 | self.assertEqual(lineage.Lineage(sub={"a"}), lineage.Lineage(sub={"a"})) 17 | self.assertNotEqual(lineage.Lineage(sub={"a"}), lineage.Lineage(sub={"b"})) 18 | self.assertNotEqual(lineage.Lineage(), object()) 19 | 20 | 21 | class TestSubclassesIterator(unittest.TestCase): 22 | 23 | def test_empty(self): 24 | self.assertListEqual(list(lineage.SubclassesIterator().to_set()), []) 25 | self.assertListEqual(list(lineage.SubclassesIterator()), []) 26 | self.assertEqual(operator.length_hint(lineage.SubclassesIterator()), 0) 27 | 28 | 29 | class TestSuperclassesIterator(unittest.TestCase): 30 | 31 | def test_empty(self): 32 | self.assertListEqual(list(lineage.SuperclassesIterator()), []) 33 | self.assertListEqual(list(lineage.SuperclassesIterator().to_set()), []) 34 | self.assertEqual(operator.length_hint(lineage.SuperclassesIterator()), 0) 35 | -------------------------------------------------------------------------------- /docs/source/guide/about.rst: -------------------------------------------------------------------------------- 1 | About 2 | ===== 3 | 4 | Authors 5 | ------- 6 | 7 | **pronto** is developped and maintained by: 8 | 9 | +--------------------------------------+--------------------------------------+ 10 | | | | **Martin Larralde** (`@althonos`_) | 11 | | .. image:: ../_static/embl.png | | PhD Candidate, Zeller Team | 12 | | :class: dark-light | | EMBL Heidelberg, Germany | 13 | | | | martin.larralde@embl.de | 14 | +--------------------------------------+--------------------------------------+ 15 | 16 | .. _`@althonos`: https://github.com/althonos 17 | 18 | 19 | Contributors 20 | ------------ 21 | 22 | The following developers contributed both code and time to this project: 23 | 24 | - Alex Henrie (`@alexhenrie `_) 25 | - Spencer Mitchell (`@smitchell556 `_) 26 | - Tatsuya Sakaguchi (`@ttyskg `_) 27 | - Philipp A. (`@flying-sheep `_) 28 | 29 | 30 | Reference 31 | --------- 32 | 33 | If you wish to cite this library, please make sure you are using 34 | the latest version of the code, and use the DOI shown 35 | on the `Zenodo record `__. 36 | -------------------------------------------------------------------------------- /docs/source/_static/json/switcher.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "v2.7 (stable)", 4 | "version": "2.7.2", 5 | "url": "https://pronto.readthedocs.io/en/v2.7.2/" 6 | }, 7 | { 8 | "name": "v2.6", 9 | "version": "2.6.0", 10 | "url": "https://pronto.readthedocs.io/en/v2.6.0/" 11 | }, 12 | { 13 | "name": "v2.5", 14 | "version": "2.5.8", 15 | "url": "https://pronto.readthedocs.io/en/v2.5.8/" 16 | }, 17 | { 18 | "name": "v2.4", 19 | "version": "2.4.7", 20 | "url": "https://pronto.readthedocs.io/en/v2.4.7/" 21 | }, 22 | { 23 | "name": "v2.3", 24 | "version": "2.3.2", 25 | "url": "https://pronto.readthedocs.io/en/v2.3.2/" 26 | }, 27 | { 28 | "name": "v2.2", 29 | "version": "2.2.4", 30 | "url": "https://pronto.readthedocs.io/en/v2.2.4/" 31 | }, 32 | { 33 | "name": "v2.1", 34 | "version": "2.1.0", 35 | "url": "https://pronto.readthedocs.io/en/v2.1.0/" 36 | }, 37 | { 38 | "name": "v2.0", 39 | "version": "2.0.1", 40 | "url": "https://pronto.readthedocs.io/en/v2.0.1/" 41 | }, 42 | { 43 | "name": "v1.2", 44 | "version": "1.2.0", 45 | "url": "https://pronto.readthedocs.io/en/v1.2.0/" 46 | }, 47 | { 48 | "name": "v0.12", 49 | "version": "0.12.2", 50 | "url": "https://pronto.readthedocs.io/en/v0.12.2/" 51 | } 52 | ] 53 | -------------------------------------------------------------------------------- /tests/test_serializer/base.py: -------------------------------------------------------------------------------- 1 | import io 2 | import tempfile 3 | import textwrap 4 | 5 | import pronto 6 | 7 | 8 | class TestSerializer(object): 9 | 10 | format = NotImplemented 11 | 12 | def assertRoundtrip(self, text): 13 | text = textwrap.dedent(text).lstrip() 14 | ont = pronto.Ontology(io.BytesIO(text.encode('utf-8'))) 15 | doc = ont.dumps(self.format) 16 | self.assertMultiLineEqual(text, doc) 17 | 18 | def test_superclass_add(self): 19 | ont = pronto.Ontology() 20 | t1 = ont.create_term("TST:001") 21 | t2 = ont.create_term("TST:002") 22 | t2.superclasses().add(t1) 23 | self.assertIn(t1, t2.superclasses().to_set()) 24 | self.assertIn(t2, t1.subclasses().to_set()) 25 | 26 | with tempfile.NamedTemporaryFile() as f: 27 | doc = ont.dump(f) 28 | new = pronto.Ontology(f.name) 29 | 30 | self.assertIn(new["TST:001"], new["TST:002"].superclasses().to_set()) 31 | self.assertIn(new["TST:002"], new["TST:001"].subclasses().to_set()) 32 | 33 | def test_subclass_add(self): 34 | ont = pronto.Ontology() 35 | t1 = ont.create_term("TST:001") 36 | t2 = ont.create_term("TST:002") 37 | t1.subclasses().add(t2) 38 | self.assertIn(t1, t2.superclasses().to_set()) 39 | self.assertIn(t2, t1.subclasses().to_set()) 40 | 41 | with tempfile.NamedTemporaryFile() as f: 42 | doc = ont.dump(f) 43 | new = pronto.Ontology(f.name) 44 | 45 | self.assertIn(new["TST:001"], new["TST:002"].superclasses().to_set()) 46 | self.assertIn(new["TST:002"], new["TST:001"].subclasses().to_set()) 47 | -------------------------------------------------------------------------------- /tests/test_relationship.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import os 3 | import unittest 4 | import warnings 5 | import dataclasses 6 | 7 | import pronto 8 | from pronto.relationship import Relationship, RelationshipData 9 | 10 | from .utils import DATADIR 11 | from .test_entity import _TestEntitySet 12 | 13 | 14 | class TestRelationship(unittest.TestCase): 15 | @classmethod 16 | def setUpClass(cls): 17 | warnings.simplefilter('error') 18 | 19 | @classmethod 20 | def tearDownClass(cls): 21 | warnings.simplefilter(warnings.defaultaction) 22 | 23 | def test_properties(self): 24 | """Assert the data stored in data layer can be accessed in the view. 25 | """ 26 | for field in dataclasses.fields(RelationshipData): 27 | self.assertTrue(hasattr(Relationship, field.name), f"no property for {field.name}") 28 | 29 | def test_superproperties(self): 30 | ont = pronto.Ontology() 31 | friend_of = ont.create_relationship("friend_of") 32 | best_friend_of = ont.create_relationship("best_friend_of") 33 | best_friend_of.superproperties().add(friend_of) 34 | self.assertIn(friend_of, sorted(best_friend_of.superproperties())) 35 | 36 | def test_subproperties(self): 37 | ont = pronto.Ontology() 38 | best_friend_of = ont.create_relationship("best_friend_of") 39 | friend_of = ont.create_relationship("friend_of") 40 | friend_of.subproperties().add(best_friend_of) 41 | self.assertIn(best_friend_of, sorted(friend_of.subproperties())) 42 | 43 | 44 | class TestRelationshipSet(_TestEntitySet, unittest.TestCase): 45 | 46 | def create_entity(self, ont, id): 47 | return ont.create_relationship(id) 48 | -------------------------------------------------------------------------------- /pronto/entity/attributes.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import Iterable, Iterator 3 | 4 | from ..utils.meta import typechecked 5 | from . import Entity, EntitySet 6 | 7 | if typing.TYPE_CHECKING: 8 | from ..relationship import Relationship 9 | 10 | _E = typing.TypeVar("_E", bound=Entity) 11 | _S = typing.TypeVar("_S", bound=EntitySet) 12 | 13 | 14 | class Relationships(typing.MutableMapping["Relationship", _S], typing.Generic[_E, _S]): 15 | """A dedicated mutable mapping to manage the relationships of an entity.""" 16 | 17 | def __init__(self, entity: _E): 18 | self._inner = entity._data().relationships 19 | self._entity = entity 20 | self._ontology = entity._ontology() 21 | 22 | def __getitem__(self, item: "Relationship") -> _S: 23 | if item.id not in self._inner: 24 | raise KeyError(item) 25 | s = self._entity._Set() 26 | s._ids = self._inner[item.id] 27 | s._ontology = self._ontology 28 | return s 29 | 30 | def __delitem__(self, item: "Relationship"): 31 | if item.id not in self._inner: 32 | raise KeyError(item) 33 | del self._inner[item.id] 34 | 35 | def __len__(self) -> int: 36 | return len(self._inner) 37 | 38 | def __iter__(self) -> Iterator["Relationship"]: 39 | from ..relationship import Relationship 40 | 41 | return (self._ontology.get_relationship(id_) for id_ in self._inner) 42 | 43 | def __setitem__(self, key: "Relationship", entities: Iterable[_E]): 44 | if key._ontology() is not self._ontology: 45 | raise ValueError("cannot use a relationship from a different ontology") 46 | self._inner[key.id] = {entity.id for entity in entities} 47 | -------------------------------------------------------------------------------- /pronto/parsers/obo.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import fastobo 4 | 5 | from ..utils.meta import typechecked 6 | from ._fastobo import FastoboParser 7 | from .base import BaseParser 8 | 9 | 10 | class OboParser(FastoboParser, BaseParser): 11 | @classmethod 12 | def can_parse(cls, path, buffer): 13 | start = 3 if buffer.startswith((b'\xef\xbb\xbf', b'\xbf\xbb\xef')) else 0 14 | return buffer.lstrip().startswith((b"format-version:", b"[Term", b"[Typedef"), start) 15 | 16 | def parse_from(self, handle, threads=None): 17 | # Load the OBO document through an iterator using fastobo 18 | doc = fastobo.iter(handle, ordered=True) 19 | 20 | # Extract metadata from the OBO header 21 | with typechecked.disabled(): 22 | self.ont.metadata = self.extract_metadata(doc.header()) 23 | 24 | # Resolve imported dependencies 25 | self.ont.imports.update( 26 | self.process_imports( 27 | self.ont.metadata.imports, 28 | self.ont.import_depth, 29 | os.path.dirname(self.ont.path or str()), 30 | self.ont.timeout, 31 | threads=threads, 32 | ) 33 | ) 34 | 35 | # Merge lineage cache from imports 36 | self.import_lineage() 37 | 38 | # Extract frames from the current document. 39 | with typechecked.disabled(): 40 | try: 41 | with self.pool(threads) as pool: 42 | pool.map(self.extract_entity, doc) 43 | except SyntaxError as s: 44 | location = self.ont.path, s.lineno, s.offset, s.text 45 | raise SyntaxError(s.args[0], location) from None 46 | 47 | # Update lineage cache with symmetric of `subClassOf` 48 | self.symmetrize_lineage() 49 | -------------------------------------------------------------------------------- /pronto/serializers/obo.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from ..relationship import Relationship 4 | from ..term import Term 5 | from ._fastobo import FastoboSerializer 6 | from .base import BaseSerializer 7 | 8 | 9 | class OboSerializer(FastoboSerializer, BaseSerializer): 10 | 11 | format = "obo" 12 | 13 | def dump(self, file): 14 | writer = io.TextIOWrapper(file) 15 | try: 16 | # dump the header 17 | if self.ont.metadata: 18 | header = self._to_header_frame(self.ont.metadata) 19 | file.write(str(header).encode("utf-8")) 20 | if self.ont._terms.entities or self.ont._relationships.entities: 21 | file.write(b"\n") 22 | # dump terms 23 | if self.ont._terms.entities: 24 | for i, id in enumerate(sorted(self.ont._terms.entities)): 25 | data = self.ont._terms.entities[id] 26 | frame = self._to_term_frame(Term(self.ont, data)) 27 | file.write(str(frame).encode("utf-8")) 28 | if ( 29 | i < len(self.ont._terms.entities) - 1 30 | or self.ont._relationships.entities 31 | ): 32 | file.write(b"\n") 33 | # dump typedefs 34 | if self.ont._relationships.entities: 35 | for i, id in enumerate(sorted(self.ont._relationships.entities)): 36 | data = self.ont._relationships.entities[id] 37 | frame = self._to_typedef_frame(Relationship(self.ont, data)) 38 | file.write(str(frame).encode("utf-8")) 39 | if i < len(self.ont._relationships.entities) - 1: 40 | file.write(b"\n") 41 | finally: 42 | writer.detach() 43 | -------------------------------------------------------------------------------- /tests/test_xref.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import warnings 3 | 4 | import fastobo 5 | import pronto 6 | 7 | 8 | class TestXref(unittest.TestCase): 9 | @classmethod 10 | def setUpClass(cls): 11 | warnings.simplefilter('error') 12 | 13 | @classmethod 14 | def tearDownClass(cls): 15 | warnings.simplefilter(warnings.defaultaction) 16 | 17 | def setUp(self): 18 | self.x1 = pronto.Xref("PMC:5392374") 19 | self.x2 = pronto.Xref("PMC:5392374") 20 | self.x3 = pronto.Xref("PMC:5706746") 21 | 22 | def test_repr(self): 23 | x1 = pronto.Xref("PMC:5392374") 24 | self.assertEqual(repr(x1), "Xref('PMC:5392374')") 25 | 26 | def test_eq(self): 27 | self.assertTrue(self.x1 == self.x2) 28 | self.assertFalse(self.x1 == self.x3) 29 | self.assertFalse(self.x1 == 1) 30 | 31 | def test_ne(self): 32 | self.assertFalse(self.x1 != self.x2) 33 | self.assertTrue(self.x1 != self.x3) 34 | self.assertTrue(self.x1 != 1) 35 | 36 | def test_lt(self): 37 | self.assertFalse(self.x1 < self.x2) 38 | self.assertTrue(self.x1 < self.x3) 39 | with self.assertRaises(TypeError): 40 | self.x1 < 1 41 | 42 | def test_le(self): 43 | self.assertTrue(self.x1 <= self.x2) 44 | self.assertTrue(self.x1 <= self.x3) 45 | with self.assertRaises(TypeError): 46 | self.x1 <= 1 47 | 48 | def test_gt(self): 49 | self.assertFalse(self.x2 > self.x2) 50 | self.assertTrue(self.x3 > self.x1) 51 | with self.assertRaises(TypeError): 52 | self.x1 > 1 53 | 54 | def test_ge(self): 55 | self.assertTrue(self.x1 >= self.x2) 56 | self.assertTrue(self.x3 >= self.x1) 57 | with self.assertRaises(TypeError): 58 | self.x1 >= 1 59 | 60 | def test_init(self): 61 | with self.assertRaises(ValueError): 62 | pronto.Xref("PMC SOMETHING") 63 | if __debug__: 64 | with self.assertRaises(TypeError): 65 | pronto.Xref(1) 66 | with self.assertRaises(TypeError): 67 | pronto.Xref("PMC:5392374", 1) 68 | -------------------------------------------------------------------------------- /pronto/parsers/obojson.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import fastobo 4 | 5 | from ..logic.lineage import Lineage 6 | from ..utils.meta import typechecked 7 | from ._fastobo import FastoboParser 8 | from .base import BaseParser 9 | 10 | 11 | class OboJSONParser(FastoboParser, BaseParser): 12 | @classmethod 13 | def can_parse(cls, path, buffer): 14 | start = 3 if buffer.startswith((b'\xef\xbb\xbf', b'\xbf\xbb\xef')) else 0 15 | return buffer.lstrip().startswith(b"{", start) 16 | 17 | def parse_from(self, handle, threads=None): 18 | # Load the OBO graph into a syntax tree using fastobo 19 | doc = fastobo.load_graph(handle).compact_ids() 20 | 21 | # Extract metadata from the OBO header 22 | with typechecked.disabled(): 23 | self.ont.metadata = self.extract_metadata(doc.header) 24 | 25 | # Resolve imported dependencies 26 | self.ont.imports.update( 27 | self.process_imports( 28 | self.ont.metadata.imports, 29 | self.ont.import_depth, 30 | os.path.dirname(self.ont.path or str()), 31 | self.ont.timeout, 32 | threads=threads, 33 | ) 34 | ) 35 | 36 | # Merge lineage cache from imports 37 | self.import_lineage() 38 | 39 | # Extract frames from the current document. 40 | with typechecked.disabled(): 41 | try: 42 | with self.pool(threads) as pool: 43 | pool.map(self.extract_entity, doc) 44 | except SyntaxError as err: 45 | location = self.ont.path, err.lineno, err.offset, err.text 46 | raise SyntaxError(err.args[0], location) from None 47 | 48 | # OBOJSON can define classes implicitly using only `is_a` properties 49 | # mapping to unresolved identifiers: in this case, we create the 50 | # term ourselves 51 | for lineage in list(self.ont._terms.lineage.values()): 52 | for superclass in lineage.sup.difference(self.ont._terms.lineage): 53 | self.ont.create_term(superclass) 54 | 55 | # Update lineage cache 56 | self.symmetrize_lineage() 57 | -------------------------------------------------------------------------------- /tests/test_documentation.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import absolute_import 3 | 4 | import io 5 | import unittest 6 | import os 7 | import sys 8 | import warnings 9 | import shutil 10 | from unittest import mock 11 | 12 | from . import utils 13 | import pronto 14 | 15 | 16 | build_main = utils.try_import("sphinx.cmd.build:build_main", None) 17 | within_ci = os.getenv("CI", "false") == "true" 18 | 19 | 20 | class TestProntoDocumentation(unittest.TestCase): 21 | @classmethod 22 | def setUpClass(cls): 23 | cls.build_dir = os.path.join(utils.TESTDIR, "run", "build") 24 | cls.source_dir = os.path.join(utils.DOCSDIR, "source") 25 | os.makedirs(os.path.join(utils.TESTDIR, "run"), exist_ok=True) 26 | 27 | @classmethod 28 | def tearDownClass(cls): 29 | shutil.rmtree(os.path.join(utils.TESTDIR, "run")) 30 | 31 | def assertBuilds(self, format): 32 | with mock.patch("sys.stderr", io.StringIO()) as stderr: 33 | with mock.patch("sys.stdout", io.StringIO()) as stdout: 34 | res = build_main( 35 | [ 36 | "-b{}".format(format), 37 | "-d{}".format(os.path.join(self.build_dir, "doctrees")), 38 | self.source_dir, 39 | os.path.join(self.build_dir, format), 40 | ] 41 | ) 42 | if res != 0: 43 | print(stdout.getvalue()) 44 | print(stderr.getvalue()) 45 | self.assertEqual(res, 0, "sphinx exited with non-zero exit code") 46 | 47 | @unittest.skipUnless(build_main, "sphinx not available") 48 | @unittest.skipUnless(within_ci, "only build docs in CI") 49 | def test_html(self): 50 | self.assertBuilds("html") 51 | 52 | @unittest.skipUnless(build_main, "sphinx not available") 53 | @unittest.skipUnless(within_ci, "only build docs in CI") 54 | def test_json(self): 55 | self.assertBuilds("json") 56 | 57 | @unittest.skipUnless(build_main, "sphinx not available") 58 | @unittest.skipUnless(within_ci, "only build docs in CI") 59 | def test_xml(self): 60 | self.assertBuilds("xml") 61 | 62 | 63 | def setUpModule(): 64 | warnings.simplefilter("ignore") 65 | 66 | 67 | def tearDownModule(): 68 | warnings.simplefilter(warnings.defaultaction) 69 | -------------------------------------------------------------------------------- /pronto/definition.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from typing import Iterable, Optional 3 | 4 | from .utils.meta import roundrepr 5 | from .xref import Xref 6 | 7 | __all__ = ["Definition"] 8 | 9 | 10 | class Definition(str): 11 | """A human-readable text definition of an entity. 12 | 13 | Definitions are human-readable descriptions of an entity in the ontology 14 | graph, with some optional cross-references to support the definition. 15 | 16 | Example: 17 | Simply create a `Definition` instance by giving it a string:: 18 | 19 | >>> def1 = pronto.Definition('a structural anomaly') 20 | 21 | Additional cross-references can be passed as arguments, or added later 22 | to the ``xrefs`` attribute of the `Definition`: 23 | 24 | >>> def2 = pronto.Definition('...', xrefs={pronto.Xref('MGI:Anna')}) 25 | >>> def2.xrefs.add(pronto.Xref('ORCID:0000-0002-3947-4444')) 26 | 27 | The text content of the definition can be accessed by casting the 28 | definition object to a plain string: 29 | 30 | >>> str(def1) 31 | 'a structural anomaly' 32 | 33 | Caution: 34 | A `Definition` compare only based on its textual value, independently 35 | of the `Xref` it may contains: 36 | 37 | >>> def2 == pronto.Definition('...') 38 | True 39 | 40 | Note: 41 | Some ontologies use the xrefs of a description to attribute the 42 | authorship of that definition: 43 | 44 | >>> cio = pronto.Ontology.from_obo_library("cio.obo") 45 | >>> sorted(cio['CIO:0000011'].definition.xrefs) 46 | [Xref('Bgee:fbb')] 47 | 48 | The common usecase however is to refer to the source of a definition 49 | using persistent identifiers like ISBN book numbers or PubMed IDs. 50 | 51 | >>> pl = pronto.Ontology.from_obo_library("plana.obo") 52 | >>> sorted(pl['PLANA:0007518'].definition.xrefs) 53 | [Xref('ISBN:0-71677033-4'), Xref('PMID:4853064')] 54 | 55 | """ 56 | 57 | xrefs: typing.Set[Xref] 58 | 59 | __slots__ = ("__weakref__", "xrefs") 60 | 61 | def __new__(cls, text: str, xrefs=None) -> "Definition": 62 | return super().__new__(cls, text) # type: ignore 63 | 64 | def __init__(self, text: str, xrefs: Optional[Iterable[Xref]] = None) -> None: 65 | self.xrefs = set(xrefs) if xrefs is not None else set() 66 | 67 | def __repr__(self) -> str: 68 | return roundrepr.make("Definition", str(self), xrefs=(self.xrefs, set())) 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | # Edit at https://www.gitignore.io/?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # celery beat schedule file 99 | celerybeat-schedule 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # End of https://www.gitignore.io/api/python 132 | -------------------------------------------------------------------------------- /tests/data/go.obo: -------------------------------------------------------------------------------- 1 | format-version: 1.2 2 | data-version: releases/2019-07-01 3 | subsetdef: gocheck_do_not_annotate "Term not to be used for direct annotation" 4 | subsetdef: gocheck_do_not_manually_annotate "Term not to be used for direct manual annotation" 5 | subsetdef: goslim_agr "AGR slim" 6 | subsetdef: goslim_aspergillus "Aspergillus GO slim" 7 | subsetdef: goslim_candida "Candida GO slim" 8 | subsetdef: goslim_chembl "ChEMBL protein targets summary" 9 | subsetdef: goslim_flybase_ribbon "FlyBase Drosophila GO ribbon slim" 10 | subsetdef: goslim_generic "Generic GO slim" 11 | subsetdef: goslim_metagenomics "Metagenomics GO slim" 12 | subsetdef: goslim_mouse "Mouse GO slim" 13 | subsetdef: goslim_pir "PIR GO slim" 14 | subsetdef: goslim_plant "Plant GO slim" 15 | subsetdef: goslim_pombe "Fission yeast GO slim" 16 | subsetdef: goslim_synapse "synapse GO slim" 17 | subsetdef: goslim_yeast "Yeast GO slim" 18 | synonymtypedef: syngo_official_label "label approved by the SynGO project" 19 | synonymtypedef: systematic_synonym "Systematic synonym" EXACT 20 | default-namespace: gene_ontology 21 | remark: cvs version: $Revision: 38972$ 22 | remark: Includes Ontology(OntologyID(OntologyIRI())) [Axioms: 18 Logical Axioms: 0] 23 | ontology: go 24 | property_value: http://purl.org/dc/elements/1.1/license http://creativecommons.org/licenses/by/4.0/ 25 | 26 | [Term] 27 | id: GO:0048870 28 | name: cell motility 29 | namespace: biological_process 30 | def: "Any process involved in the controlled self-propelled movement of a cell that results in translocation of the cell from one place to another." [GOC:dgh, GOC:dph, GOC:isa_complete, GOC:mlg] 31 | subset: goslim_chembl 32 | subset: goslim_generic 33 | synonym: "cell locomotion" EXACT [] 34 | synonym: "cell movement" RELATED [] 35 | synonym: "movement of a cell" EXACT [] 36 | relationship: part_of GO:0051674 ! localization of cell 37 | 38 | [Term] 39 | id: GO:0051674 40 | name: localization of cell 41 | namespace: biological_process 42 | def: "Any process in which a cell is transported to, and/or maintained in, a specific location." [GOC:ai] 43 | synonym: "cell localization" EXACT [] 44 | synonym: "establishment and maintenance of cell localization" EXACT [] 45 | synonym: "establishment and maintenance of localization of cell" EXACT [] 46 | synonym: "localisation of cell" EXACT [GOC:mah] 47 | 48 | [Typedef] 49 | id: part_of 50 | name: part of 51 | namespace: external 52 | xref: BFO:0000050 53 | is_transitive: true 54 | inverse_of: has_part ! has part 55 | 56 | [Typedef] 57 | id: has_part 58 | name: has part 59 | namespace: external 60 | xref: BFO:0000051 61 | is_transitive: true 62 | inverse_of: part_of ! part of 63 | -------------------------------------------------------------------------------- /tests/data/obographs/obsoletion_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "graphs" : [ { 3 | "nodes" : [ { 4 | "id" : "http://purl.obolibrary.org/obo/IAO_0100001", 5 | "type" : "PROPERTY", 6 | "lbl" : "term replaced by" 7 | }, { 8 | "id" : "http://purl.obolibrary.org/obo/X_1", 9 | "meta" : { 10 | "basicPropertyValues" : [ { 11 | "pred" : "http://www.geneontology.org/formats/oboInOwl#hasOBONamespace", 12 | "val" : "x" 13 | } ] 14 | }, 15 | "type" : "CLASS", 16 | "lbl" : "x1" 17 | }, { 18 | "id" : "http://purl.obolibrary.org/obo/X_2", 19 | "meta" : { 20 | "basicPropertyValues" : [ { 21 | "pred" : "http://purl.obolibrary.org/obo/IAO_0100001", 22 | "val" : "X:1" 23 | }, { 24 | "pred" : "http://www.geneontology.org/formats/oboInOwl#hasOBONamespace", 25 | "val" : "x" 26 | } ], 27 | "deprecated" : true 28 | }, 29 | "type" : "CLASS", 30 | "lbl" : "obsolete x2" 31 | }, { 32 | "id" : "http://purl.obolibrary.org/obo/Y_1", 33 | "meta" : { 34 | "basicPropertyValues" : [ { 35 | "pred" : "http://www.geneontology.org/formats/oboInOwl#hasAlternativeId", 36 | "val" : "Y:2" 37 | }, { 38 | "pred" : "http://www.geneontology.org/formats/oboInOwl#hasOBONamespace", 39 | "val" : "y" 40 | } ] 41 | }, 42 | "type" : "CLASS", 43 | "lbl" : "y1" 44 | }, { 45 | "id" : "http://purl.obolibrary.org/obo/Y_2", 46 | "meta" : { 47 | "basicPropertyValues" : [ { 48 | "pred" : "http://purl.obolibrary.org/obo/IAO_0000231", 49 | "val" : "http://purl.obolibrary.org/obo/IAO_0000227" 50 | }, { 51 | "pred" : "http://purl.obolibrary.org/obo/IAO_0100001", 52 | "val" : "http://purl.obolibrary.org/obo/Y_1" 53 | } ], 54 | "deprecated" : true 55 | }, 56 | "type" : "CLASS" 57 | }, { 58 | "id" : "http://www.geneontology.org/formats/oboInOwl#hasAlternativeId", 59 | "type" : "PROPERTY", 60 | "lbl" : "has_alternative_id" 61 | }, { 62 | "id" : "http://www.geneontology.org/formats/oboInOwl#hasOBONamespace", 63 | "type" : "PROPERTY", 64 | "lbl" : "has_obo_namespace" 65 | } ], 66 | "edges" : [ ], 67 | "id" : "http://purl.obolibrary.org/obo/obsoletion_example.owl", 68 | "meta" : { 69 | "subsets" : [ ], 70 | "xrefs" : [ ], 71 | "basicPropertyValues" : [ ] 72 | }, 73 | "equivalentNodesSets" : [ ], 74 | "logicalDefinitionAxioms" : [ ], 75 | "domainRangeAxioms" : [ ], 76 | "propertyChainAxioms" : [ ] 77 | } ] 78 | } -------------------------------------------------------------------------------- /tests/test_parser/test_obojson.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import warnings 3 | from pathlib import Path 4 | 5 | import pronto 6 | 7 | 8 | class TestOboJsonExamples(unittest.TestCase): 9 | 10 | def setUp(self): 11 | warnings.simplefilter("error") 12 | 13 | def tearDown(self): 14 | warnings.simplefilter(warnings.defaultaction) 15 | 16 | @staticmethod 17 | def get_path(name): 18 | folder = Path(__file__).parent.parent / "data" / "obographs" 19 | return str(folder / f"{name}.json") 20 | 21 | def test_abox(self): 22 | with warnings.catch_warnings(): 23 | warnings.simplefilter("ignore", pronto.warnings.NotImplementedWarning) 24 | ont = pronto.Ontology(self.get_path("abox")) 25 | self.assertEqual(len(ont.terms()), 3) # Person (implicit), Male and Female 26 | 27 | @unittest.expectedFailure 28 | def test_basic(self): 29 | ont = pronto.Ontology(self.get_path("basic")) 30 | self.assertIn("test manus ontology", ont.metadata.remarks) 31 | self.assertIn("UBERON:0002101", ont) 32 | self.assertIn("UBERON:0002470", ont) 33 | self.assertIn("UBERON:0002102", ont) 34 | self.assertIn("UBERON:0002398", ont) 35 | self.assertIn(ont["UBERON:0002398"], ont["UBERON:0002470"].subclasses().to_set()) 36 | self.assertIn(ont["UBERON:0002102"], ont["UBERON:0002101"].subclasses().to_set()) 37 | self.assertIn(ont["UBERON:0002102"], ont["UBERON:0002398"].relationships[ont["part_of"]]) 38 | 39 | def test_equiv_node_set(self): 40 | ont = pronto.Ontology(self.get_path("equivNodeSetTest")) 41 | self.assertIn("DOID:0001816", ont) 42 | self.assertIn("NCIT:C3088", ont) 43 | self.assertIn("Orphanet:263413", ont) 44 | self.assertIn(ont["DOID:0001816"], ont["NCIT:C3088"].equivalent_to) 45 | self.assertIn(ont["NCIT:C3088"], ont["DOID:0001816"].equivalent_to) 46 | self.assertIn(ont["DOID:0001816"], ont["Orphanet:263413"].equivalent_to) 47 | self.assertIn(ont["Orphanet:263413"], ont["DOID:0001816"].equivalent_to) 48 | 49 | def test_obsoletion_example(self): 50 | ont = pronto.Ontology(self.get_path("obsoletion_example")) 51 | self.assertIn("X:1", ont) 52 | self.assertIn("X:2", ont) 53 | self.assertIn("Y:1", ont) 54 | self.assertIn("Y:2", ont) 55 | self.assertTrue(ont["X:2"].obsolete) 56 | self.assertIn(ont["X:1"], ont["X:2"].replaced_by) 57 | self.assertTrue(ont["Y:2"].obsolete) 58 | self.assertTrue(ont["Y:1"], ont["Y:2"].replaced_by) 59 | 60 | def test_nucleus(self): 61 | ont = pronto.Ontology(self.get_path("equivNodeSetTest")) 62 | -------------------------------------------------------------------------------- /pronto/__init__.py: -------------------------------------------------------------------------------- 1 | """A Python frontend to ontologies. 2 | 3 | **pronto** is a Python agnostic library designed to work with ontologies. At 4 | the moment, it can parse ontologies in the OBO, OBO Graphs or OWL in RDF/XML 5 | format, on either the local host or from an network location, and export 6 | ontologies to OBO or OBO Graphs (in JSON format). 7 | 8 | Caution: 9 | Only classes and modules reachable from the top-level package ``pronto`` 10 | are considered public and are guaranteed stable over `Semantic Versioning 11 | `_. Use submodules (other than `~pronto.warnings`) 12 | at your own risk! 13 | 14 | Note: 15 | ``pronto`` implements proper *type checking* for most of the methods and 16 | properties exposed in the public classes. This reproduces the behaviour 17 | of the Python standard library, to avoid common errors. This feature does 18 | however increase overhead, but can be disabled by executing Python in 19 | optimized mode (with the ``-O`` flag). *Parsing performances are not 20 | affected.* 21 | 22 | """ 23 | 24 | from .definition import Definition 25 | from .entity import Entity 26 | from .metadata import Metadata, Subset 27 | from .ontology import Ontology 28 | from .pv import LiteralPropertyValue, PropertyValue, ResourcePropertyValue 29 | from .relationship import Relationship, RelationshipData, RelationshipSet 30 | from .synonym import Synonym, SynonymData, SynonymType 31 | from .term import Term, TermData, TermSet 32 | from .utils import warnings 33 | from .xref import Xref 34 | 35 | # Using `__name__` attribute instead of directly using the name as a string 36 | # so the linter doesn't complaint about unused imports in the top module 37 | __all__ = [ 38 | # modules 39 | "warnings", 40 | # classes 41 | Ontology.__name__, 42 | Entity.__name__, 43 | Term.__name__, 44 | TermData.__name__, 45 | TermSet.__name__, 46 | Metadata.__name__, 47 | Subset.__name__, 48 | Definition.__name__, 49 | Relationship.__name__, 50 | RelationshipData.__name__, 51 | RelationshipSet.__name__, 52 | Synonym.__name__, 53 | SynonymData.__name__, 54 | SynonymType.__name__, 55 | PropertyValue.__name__, 56 | LiteralPropertyValue.__name__, 57 | ResourcePropertyValue.__name__, 58 | Xref.__name__, 59 | ] 60 | 61 | __author__ = "Martin Larralde " 62 | __license__ = "MIT" 63 | __version__ = "2.7.2" 64 | 65 | # Update the docstring with a link to the right version of the documentation 66 | # instead of the latest. 67 | if __doc__ is not None: 68 | __doc__ += f"""See Also: 69 | Online documentation for this version of the library on 70 | `Read The Docs `_ 71 | """ 72 | -------------------------------------------------------------------------------- /tests/data/obographs/basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "graphs" : [ { 3 | "nodes" : [ { 4 | "id" : "http://purl.obolibrary.org/obo/BFO_0000050", 5 | "meta" : { 6 | "xrefs" : [ { 7 | "val" : "BFO:0000050" 8 | } ], 9 | "basicPropertyValues" : [ { 10 | "pred" : "http://www.geneontology.org/formats/oboInOwl#shorthand", 11 | "val" : "part_of" 12 | } ] 13 | }, 14 | "type" : "PROPERTY" 15 | }, { 16 | "id" : "http://purl.obolibrary.org/obo/IAO_0000115", 17 | "type" : "PROPERTY", 18 | "lbl" : "definition" 19 | }, { 20 | "id" : "http://purl.obolibrary.org/obo/UBERON_0002101", 21 | "type" : "CLASS", 22 | "lbl" : "limb" 23 | }, { 24 | "id" : "http://purl.obolibrary.org/obo/UBERON_0002102", 25 | "type" : "CLASS", 26 | "lbl" : "forelimb" 27 | }, { 28 | "id" : "http://purl.obolibrary.org/obo/UBERON_0002398", 29 | "meta" : { 30 | "definition" : { 31 | "val" : ".", 32 | "xrefs" : [ ] 33 | } 34 | }, 35 | "type" : "CLASS", 36 | "lbl" : "manus" 37 | }, { 38 | "id" : "http://purl.obolibrary.org/obo/UBERON_0002470", 39 | "type" : "CLASS", 40 | "lbl" : "autopod region" 41 | }, { 42 | "id" : "http://www.geneontology.org/formats/oboInOwl#hasDbXref", 43 | "type" : "PROPERTY", 44 | "lbl" : "database_cross_reference" 45 | }, { 46 | "id" : "http://www.geneontology.org/formats/oboInOwl#shorthand", 47 | "type" : "PROPERTY", 48 | "lbl" : "shorthand" 49 | } ], 50 | "edges" : [ { 51 | "sub" : "http://purl.obolibrary.org/obo/UBERON_0002102", 52 | "pred" : "is_a", 53 | "obj" : "http://purl.obolibrary.org/obo/UBERON_0002101" 54 | }, { 55 | "sub" : "http://purl.obolibrary.org/obo/UBERON_0002398", 56 | "pred" : "is_a", 57 | "obj" : "http://purl.obolibrary.org/obo/UBERON_0002470" 58 | }, { 59 | "sub" : "http://purl.obolibrary.org/obo/UBERON_0002398", 60 | "pred" : "http://purl.obolibrary.org/obo/BFO_0000050", 61 | "obj" : "http://purl.obolibrary.org/obo/UBERON_0002102" 62 | }, { 63 | "sub" : "http://purl.obolibrary.org/obo/UBERON_0002470", 64 | "pred" : "http://purl.obolibrary.org/obo/BFO_0000050", 65 | "obj" : "http://purl.obolibrary.org/obo/UBERON_0002101" 66 | } ], 67 | "id" : "http://purl.obolibrary.org/obo/test.owl", 68 | "meta" : { 69 | "subsets" : [ ], 70 | "xrefs" : [ ], 71 | "basicPropertyValues" : [ { 72 | "pred" : "http://www.w3.org/2000/01/rdf-schema#comment", 73 | "val" : "test manus ontology" 74 | } ] 75 | }, 76 | "equivalentNodesSets" : [ ], 77 | "logicalDefinitionAxioms" : [ ], 78 | "domainRangeAxioms" : [ ], 79 | "propertyChainAxioms" : [ ] 80 | } ] 81 | } -------------------------------------------------------------------------------- /docs/source/guide/updating.rst: -------------------------------------------------------------------------------- 1 | Updating from earlier versions 2 | ============================== 3 | 4 | From ``v1.*`` 5 | ------------- 6 | 7 | Update from `v1.*` to `v2.*` should be straightforward; the only reason the 8 | major version was updated was because the ``cache`` argument was removed from 9 | the `~pronto.Ontology` constructor. 10 | 11 | 12 | From ``v0.*`` 13 | ------------- 14 | 15 | Render to OBO 16 | ^^^^^^^^^^^^^ 17 | 18 | Exporting an ontology to the OBO (or other supported formats) is now done with 19 | the `~pronto.Ontology.dump` and `~pronto.Ontology.dumps` methods: 20 | 21 | .. code:: python 22 | 23 | # before 24 | print(ontology.obo) 25 | open("out.obo", "w").write(ontology.obo) 26 | 27 | # after 28 | print(ontology.dumps(format="obo")) 29 | ontoloy.dump(open("out.obo", "w"), format="obo") 30 | 31 | 32 | Subclasses and superclasses 33 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 34 | 35 | ``pronto`` is not opinionated about the *direction* of a relationship. Subclassing 36 | relationships are now handled as a special case, following the semantics of the 37 | ``rdfs:subClassOf`` property in the `RDF schema `_. 38 | 39 | Therefore, the code to access subclasses and superclasses of a `~pronto.Term` 40 | has been updated: 41 | 42 | .. code:: python 43 | 44 | # before 45 | children: pronto.TermList = term.rchildren() 46 | parents: pronto.TermList = term.rparents() 47 | 48 | # after 49 | children_iter: Iterable[Term] = term.subclasses() 50 | parents_iter: Iterable[Term] = term.superclasses() 51 | 52 | 53 | Because we follow the RDF semantics, any class is also its own subclass and 54 | superclass; therefore, both of these iterators will yield the term itself as the 55 | first member of the iteration. This behaviour can be annoying, so you can disable it 56 | by giving ``with_self=False`` as an argument to only get *true* subclasses or 57 | superclasses: 58 | 59 | .. code:: python 60 | 61 | children_iter: Iterable[Term] = term.subclasses(with_self=False) 62 | parents_iter: Iterable[Term] = term.superclasses(with_self=False) 63 | 64 | 65 | To only get the direct subclasses or superclasses (i.e., what `Term.children` 66 | and `Term.parents` used to do), pass ``distance=1`` as an argument as well: 67 | 68 | .. code:: python 69 | 70 | children: Iterable[Term] = term.subclasses(with_self=False, distance=1) 71 | parents: Iterable[Term] = term.superclasses(with_self=False, distance=1) 72 | 73 | 74 | Since querying of subclasses and superclasses now gives you an iterator, but your 75 | previous code was expecting a `TermList`, you can use the `~SubclassesIterator.to_set` 76 | method to obtain a `~pronto.TermSet` which hopefully will prevent the rest of 77 | your code to require more update. 78 | 79 | .. code:: python 80 | 81 | # you can use `to_set` to get a `TermSet` from the iterator 82 | children: pronto.TermSet = term.subclasses().to_set() 83 | parents: pronto.TermSet = term.superclasses().to_set() 84 | -------------------------------------------------------------------------------- /docs/source/api/index.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============== 3 | 4 | .. currentmodule:: pronto 5 | 6 | .. automodule:: pronto 7 | 8 | 9 | Ontology 10 | -------- 11 | 12 | An abstraction over a :math:`\mathcal{SHOIN}^\mathcal{(D)}` ontology. 13 | 14 | .. autosummary:: 15 | :nosignatures: 16 | :template: summary.rst 17 | :toctree: 18 | :caption: Ontology 19 | 20 | pronto.Ontology 21 | 22 | 23 | View Layer 24 | ---------- 25 | 26 | The following classes are part of the view layer, and store references to the 27 | ontology/entity they were declared in for verification purposes. For instance, 28 | this let ``pronto`` check that a `Synonym` type can only be changed for a type 29 | declared in the `Ontology` header. 30 | 31 | Because of this reason, none of these classes should be created manually, but 32 | obtained from methods of existing `Ontology` or `Entity` instances, such as 33 | `Ontology.get_term` to get a new `Term`. 34 | 35 | .. autosummary:: 36 | :nosignatures: 37 | :template: summary.rst 38 | :toctree: 39 | :caption: View Layer 40 | 41 | pronto.Entity 42 | pronto.Relationship 43 | pronto.Synonym 44 | pronto.Term 45 | pronto.TermSet 46 | 47 | 48 | View Collections 49 | ---------------- 50 | 51 | The following classes are dedicated collections that are implemented to view 52 | a specific field of entities, such as relationships. These types cannot be 53 | instantiated directly, but are reachable through the right property on `Entity` 54 | instances. 55 | 56 | .. autosummary:: 57 | :nosignatures: 58 | :template: summary.rst 59 | :toctree: 60 | :caption: View Collections 61 | 62 | pronto.entity.attributes.Relationships 63 | 64 | 65 | Model Layer 66 | ----------- 67 | 68 | The following classes are technically part of the data layer, but because they 69 | can be lightweight enough to be instantiated directly, they can also be passed 70 | to certain functions or properties of the view layer. *Basically, these classes 71 | are not worth to implement following the view-model pattern so they can be 72 | accessed and mutated directly.* 73 | 74 | .. autosummary:: 75 | :nosignatures: 76 | :template: summary.rst 77 | :toctree: 78 | :caption: Model Layer 79 | 80 | pronto.Metadata 81 | pronto.Definition 82 | pronto.Subset 83 | pronto.SynonymType 84 | pronto.LiteralPropertyValue 85 | pronto.ResourcePropertyValue 86 | pronto.Xref 87 | 88 | 89 | Data Layer 90 | ---------- 91 | 92 | The following classes are from the data layer, and store the data extracted from 93 | ontology files. There is probably no point in using them directly, with the 94 | exception of custom parser implementations. 95 | 96 | .. autosummary:: 97 | :nosignatures: 98 | :template: summary.rst 99 | :toctree: 100 | :caption: Data Layer 101 | 102 | pronto.RelationshipData 103 | pronto.SynonymData 104 | pronto.TermData 105 | 106 | 107 | Warnings 108 | -------- 109 | 110 | .. toctree:: 111 | :hidden: 112 | :caption: Warnings 113 | 114 | warnings 115 | 116 | .. autosummary:: 117 | :nosignatures: 118 | 119 | pronto.warnings.ProntoWarning 120 | pronto.warnings.NotImplementedWarning 121 | pronto.warnings.SyntaxWarning 122 | pronto.warnings.UnstableWarning 123 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pronto" 7 | dynamic = ["version"] 8 | description = "Python frontend to ontologies." 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | license = { file = "COPYING" } 12 | authors = [ 13 | { name = "Martin Larralde", email = "martin.larralde@embl.de" }, 14 | ] 15 | keywords = ["bioinformatics", "ontology", "OBO", "OWL"] 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "Intended Audience :: Science/Research", 20 | "Intended Audience :: Healthcare Industry", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python :: 3.7", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3.14", 31 | "Programming Language :: Python :: Implementation :: CPython", 32 | "Programming Language :: Python :: Implementation :: PyPy", 33 | "Topic :: Scientific/Engineering :: Bio-Informatics", 34 | "Topic :: Scientific/Engineering :: Medical Science Apps.", 35 | "Topic :: Software Development :: Libraries :: Python Modules", 36 | "Typing :: Typed", 37 | ] 38 | dependencies = [ 39 | "chardet ~=5.0", 40 | "fastobo >=0.13.0,<0.15.0", 41 | "networkx >=2.3,<4.0", 42 | "python-dateutil ~=2.8", 43 | ] 44 | 45 | [project.urls] 46 | "Bug Tracker" = "https://github.com/althonos/pronto/issues" 47 | "Changelog" = "https://pronto.readthedocs.io/en/latest/changes.html" 48 | "Documentation" = "https://pronto.readthedocs.io/en/latest/" 49 | "Coverage" = "https://app.codecov.io/gh/althonos/pronto" 50 | "CI" = "https://github.com/althonos/pronto/actions" 51 | 52 | [tool.setuptools] 53 | include-package-data = false 54 | 55 | [tool.setuptools.packages.find] 56 | namespaces = false 57 | include = ["pronto*"] 58 | exclude = ["docs", "tests"] 59 | 60 | [tool.setuptools.dynamic] 61 | version = {attr = "pronto.__version__"} 62 | 63 | [tool.coverage.report] 64 | include = ["pronto/*"] 65 | omit = ["tests/*"] 66 | show_missing = true 67 | exclude_lines = [ 68 | "pragma: no cover", 69 | "raise NotImplementedError", 70 | "if __name__ == .__main__.:", 71 | "except ImportError:", 72 | "if typing.TYPE_CHECKING:", 73 | "@abc.abstractmethod", 74 | ] 75 | 76 | [tool.coverage.run] 77 | branch = true 78 | source = ["pronto"] 79 | 80 | [tool.pydocstyle] 81 | inherit = false 82 | ignore = ["D100", "D101", "D102", "D105", "D107", "D200", "D203", "D213", "D406", "D407"] 83 | match-dir = "(?!tests)(?!resources)(?!docs)(?!build)[^\\.].*" 84 | match = "(?!test)(?!setup)[^\\._].*\\.py" 85 | 86 | [tool.mypy] 87 | ignore_missing_imports = true 88 | 89 | ["tool.mypy-pronto.*"] 90 | disallow_any_decorated = false 91 | disallow_any_generics = false 92 | disallow_any_unimported = false 93 | disallow_subclassing_any = true 94 | disallow_untyped_calls = false 95 | disallow_untyped_defs = false 96 | ignore_missing_imports = false 97 | warn_unused_ignores = false 98 | warn_return_any = false 99 | -------------------------------------------------------------------------------- /tests/data/hp.obo: -------------------------------------------------------------------------------- 1 | format-version: 1.2 2 | data-version: hp/releases/2020-08-11 3 | saved-by: Peter Robinson, Sebastian Koehler, Sandra Doelken, Chris Mungall, Melissa Haendel, Nicole Vasilevsky, Monarch Initiative, et al. 4 | subsetdef: hposlim_core "Core clinical terminology" 5 | subsetdef: secondary_consequence "Consequence of a disorder in another organ system." 6 | synonymtypedef: abbreviation "abbreviation" 7 | synonymtypedef: layperson "layperson term" 8 | synonymtypedef: obsolete_synonym "discarded/obsoleted synonym" 9 | synonymtypedef: plural_form "plural form" 10 | synonymtypedef: uk_spelling "UK spelling" 11 | default-namespace: human_phenotype 12 | remark: Please see license of HPO at http://www.human-phenotype-ontology.org 13 | ontology: hp.obo 14 | property_value: IAO:0000700 HP:0000001 15 | logical-definition-view-relation: has_part 16 | 17 | [Term] 18 | id: HP:0009381 19 | name: Short finger 20 | alt_id: HP:0004098 21 | alt_id: HP:0006015 22 | def: "Abnormally short finger associated with developmental hypoplasia." [HPO:probinson] 23 | subset: hposlim_core 24 | synonym: "Hypoplastic digits" EXACT [] 25 | synonym: "Hypoplastic fingers" EXACT [] 26 | synonym: "Hypoplastic/small fingers" EXACT [] 27 | synonym: "Short finger" EXACT layperson [] 28 | synonym: "Stubby finger" EXACT layperson [ORCID:0000-0001-5208-3432] 29 | synonym: "Stubby fingers" EXACT plural_form [] 30 | xref: SNOMEDCT_US:249765007 31 | xref: UMLS:C0239594 32 | xref: UMLS:C1844548 33 | created_by: doelkens 34 | creation_date: 2009-01-13T01:07:38Z 35 | 36 | [Term] 37 | id: HP:0009882 38 | name: Short distal phalanx of finger 39 | alt_id: HP:0001198 40 | alt_id: HP:0001202 41 | alt_id: HP:0001221 42 | alt_id: HP:0001229 43 | alt_id: HP:0005669 44 | alt_id: HP:0006075 45 | alt_id: HP:0006076 46 | alt_id: HP:0006132 47 | alt_id: HP:0006199 48 | alt_id: HP:0006223 49 | def: "Short distance from the end of the finger to the most distal interphalangeal crease or the distal interphalangeal joint flexion point. That is, hypoplasia of one or more of the distal phalanx of finger." [HPO:probinson, PMID:19125433] 50 | comment: This term differs from Partial absence of the finger because in that term, the phalanx must be missing, whereas in this term it may be small, but present. Distal phalangeal lengths can be assessed subjectively by comparing that digit segment to the rest of the digit, to other normal digits in that patient, or to typical patients of that age or build. Regarding the subjective definition, for individuals who do not have flexion creases, one may determine this by flexing the DIP joint and estimating the length of the terminal segment of the digit. Alternatively, one may be able to palpate the joint. 51 | subset: hposlim_core 52 | synonym: "Brachytelophalangy" EXACT [] 53 | synonym: "Distal phalangeal hypoplasia" EXACT [] 54 | synonym: "Hypoplasia of the distal phalanges" EXACT [] 55 | synonym: "Hypoplasia of the distal phalanges of the hand" EXACT [] 56 | synonym: "Hypoplasic terminal phalanges" EXACT [] 57 | synonym: "Hypoplastic distal phalanges" EXACT [] 58 | synonym: "Hypoplastic terminal phalanges" EXACT [] 59 | synonym: "Short distal phalanges" EXACT [] 60 | synonym: "Short outermost finger bone" EXACT layperson [ORCID:0000-0001-5208-3432] 61 | synonym: "Terminal phalangeal hypoplasia of hand" EXACT [] 62 | xref: UMLS:C1839829 63 | is_a: HP:0009381 ! Short finger 64 | created_by: doelkens 65 | creation_date: 2009-04-24T04:29:30Z 66 | -------------------------------------------------------------------------------- /tests/test_synonym.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import warnings 3 | 4 | import pronto 5 | from pronto import SynonymType 6 | 7 | 8 | class TestSynonymType(unittest.TestCase): 9 | 10 | @classmethod 11 | def setUpClass(cls): 12 | warnings.simplefilter('error') 13 | 14 | @classmethod 15 | def tearDownClass(cls): 16 | warnings.simplefilter(warnings.defaultaction) 17 | 18 | def assertHashEqual(self, x, y): 19 | self.assertEqual( 20 | hash(x), 21 | hash(y), 22 | "hash({!r}) != hash({!r})".format(x, y) 23 | ) 24 | 25 | def test_init_invalid_scope(self): 26 | with self.assertRaises(ValueError): 27 | _s = SynonymType(id="other", description="", scope="INVALID") 28 | 29 | def test_eq(self): 30 | # self == self 31 | s1 = SynonymType("other", "") 32 | self.assertEqual(s1, s1) 33 | # self == other if self.id == other.id 34 | s2 = SynonymType("other", "something else") 35 | self.assertEqual(s1, s2) 36 | # self != anything not a synonym type 37 | self.assertNotEqual(s1, 1) 38 | 39 | def test_lt(self): 40 | self.assertLess(SynonymType("a", "a"), SynonymType("b", "b")) 41 | self.assertLess(SynonymType("a", "a"), SynonymType("a", "b")) 42 | with self.assertRaises(TypeError): 43 | SynonymType("a", "a") < 2 44 | 45 | def test_hash(self): 46 | s1 = SynonymType("other", "") 47 | s2 = SynonymType("other", "something else") 48 | self.assertEqual(hash(s1), hash(s2), ) 49 | 50 | 51 | class TestSynonym(unittest.TestCase): 52 | @classmethod 53 | def setUpClass(cls): 54 | warnings.simplefilter('error') 55 | 56 | @classmethod 57 | def tearDownClass(cls): 58 | warnings.simplefilter(warnings.defaultaction) 59 | 60 | def test_scope_invalid(self): 61 | ont = pronto.Ontology() 62 | term = ont.create_term("TEST:001") 63 | syn = term.add_synonym("something", scope="EXACT") 64 | with self.assertRaises(ValueError): 65 | syn.scope = "NONSENSE" 66 | 67 | def test_type(self): 68 | ont = pronto.Ontology() 69 | ty1 = pronto.SynonymType("declared", "a declared synonym type") 70 | ont.metadata.synonymtypedefs.add(ty1) 71 | term = ont.create_term("TEST:001") 72 | term.add_synonym("something", type=ty1) 73 | 74 | def test_type_undeclared(self): 75 | ont = pronto.Ontology() 76 | t1 = pronto.SynonymType("undeclared", "an undeclared synonym type") 77 | term = ont.create_term("TEST:001") 78 | with self.assertRaises(ValueError): 79 | term.add_synonym("something", type=t1) 80 | 81 | def test_type_setter(self): 82 | ont = pronto.Ontology() 83 | ty1 = pronto.SynonymType("declared", "a declared synonym type") 84 | ont.metadata.synonymtypedefs.add(ty1) 85 | term = ont.create_term("TEST:001") 86 | syn1 = term.add_synonym("something") 87 | self.assertIsNone(syn1.type) 88 | syn1.type = ty1 89 | self.assertEqual(syn1.type, ty1) 90 | 91 | def test_type_setter_undeclared(self): 92 | ont = pronto.Ontology() 93 | ty1 = pronto.SynonymType("undeclared", "an undeclared synonym type") 94 | term = ont.create_term("TEST:001") 95 | syn1 = term.add_synonym("something") 96 | self.assertIsNone(syn1.type) 97 | with self.assertRaises(ValueError): 98 | syn1.type = ty1 99 | -------------------------------------------------------------------------------- /tests/test_doctest.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """Test doctest contained tests in every file of the module. 3 | """ 4 | 5 | import configparser 6 | import doctest 7 | import importlib 8 | import os 9 | import pkgutil 10 | import re 11 | import sys 12 | import shutil 13 | import types 14 | import warnings 15 | from unittest import mock 16 | 17 | import pronto 18 | import pronto.parsers 19 | 20 | from . import utils 21 | 22 | def get_packages(): 23 | parser = configparser.ConfigParser() 24 | cfg_path = os.path.realpath(os.path.join(__file__, '..', '..', 'setup.cfg')) 25 | parser.read(cfg_path) 26 | return parser.get('options', 'packages').split() 27 | 28 | def _load_tests_from_module(tests, module, globs, setUp=None, tearDown=None): 29 | """Load tests from module, iterating through submodules""" 30 | for attr in (getattr(module, x) for x in dir(module) if not x.startswith("_")): 31 | if isinstance(attr, types.ModuleType) and attr.__name__.startswith("pronto"): 32 | suite = doctest.DocTestSuite(attr, globs, setUp=setUp, tearDown=tearDown, optionflags=+doctest.ELLIPSIS) 33 | tests.addTests(suite) 34 | return tests 35 | 36 | 37 | def load_tests(loader, tests, ignore): 38 | """load_test function used by unittest to find the doctests""" 39 | 40 | def setUp(self): 41 | warnings.simplefilter("ignore") 42 | self.rundir = os.getcwd() 43 | self.datadir = os.path.realpath(os.path.join(__file__, "..", "data")) 44 | os.chdir(self.datadir) 45 | 46 | Ontology = pronto.Ontology 47 | _from_obo_library = Ontology.from_obo_library 48 | _cache = {} 49 | 50 | def from_obo_library(name): 51 | if name not in _cache: 52 | if os.path.exists(os.path.join(utils.DATADIR, name)): 53 | _cache[name] = Ontology(os.path.join(utils.DATADIR, name), threads=1) 54 | else: 55 | _cache[name] = _from_obo_library(name) 56 | return _cache[name] 57 | 58 | self.m = mock.patch("pronto.Ontology.from_obo_library", from_obo_library) 59 | self.m.__enter__() 60 | 61 | def tearDown(self): 62 | self.m.__exit__(None, None, None) 63 | os.chdir(self.rundir) 64 | warnings.simplefilter(warnings.defaultaction) 65 | 66 | # doctests are not compatible with `green`, so we may want to bail out 67 | # early if `green` is running the tests 68 | if sys.argv[0].endswith("green"): 69 | return tests 70 | 71 | # recursively traverse all library submodules and load tests from them 72 | packages = [None, pronto] 73 | for pkg in iter(packages.pop, None): 74 | for (_, subpkgname, subispkg) in pkgutil.walk_packages(pkg.__path__): 75 | # import the submodule and add it to the tests 76 | module = importlib.import_module(".".join([pkg.__name__, subpkgname])) 77 | globs = dict(pronto=pronto, **module.__dict__) 78 | tests.addTests( 79 | doctest.DocTestSuite( 80 | module, 81 | globs=globs, 82 | setUp=setUp, 83 | tearDown=tearDown, 84 | optionflags=+doctest.ELLIPSIS, 85 | ) 86 | ) 87 | # if the submodule is a package, we need to process its submodules 88 | # as well, so we add it to the package queue 89 | if subispkg and subpkgname != "tests": 90 | packages.append(module) 91 | 92 | return tests -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | 10 | jobs: 11 | test_linux: 12 | name: Test (Linux) 13 | runs-on: ubuntu-latest 14 | env: 15 | OS: Linux 16 | strategy: 17 | matrix: 18 | include: 19 | - python-version: 3.8 20 | python-release: v3.8 21 | python-impl: CPython 22 | - python-version: 3.9 23 | python-release: v3.9 24 | python-impl: CPython 25 | - python-version: '3.10' 26 | python-release: v3.10 27 | python-impl: CPython 28 | - python-version: '3.11' 29 | python-release: v3.11 30 | python-impl: CPython 31 | - python-version: '3.12' 32 | python-release: v3.12 33 | python-impl: CPython 34 | - python-version: '3.13' 35 | python-release: v3.13 36 | python-impl: CPython 37 | - python-version: '3.14' 38 | python-release: v3.14 39 | python-impl: CPython 40 | #- python-version: pypy-3.7 41 | # python-release: v3.7 42 | # python-impl: PyPy 43 | steps: 44 | - name: Checkout code 45 | uses: actions/checkout@v4 46 | - name: Setup Python ${{ matrix.python-version }} 47 | uses: actions/setup-python@v5 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | - name: Update pip 51 | run: python -m pip install -U pip wheel setuptools 52 | - name: Install CI requirements 53 | run: python -m pip install 'coverage[toml]' 54 | - name: Install library 55 | run: pip install . 56 | - name: Test in debug mode 57 | run: python -m coverage run -p --source=pronto -m unittest discover -v 58 | - name: Test in optimized mode 59 | run: python -O -m coverage run -p --source=pronto -m unittest discover -v 60 | - name: Combine coverage reports 61 | run: python -m coverage combine 62 | - name: Upload coverage to Codecov 63 | uses: codecov/codecov-action@v4 64 | with: 65 | flags: ${{ matrix.python-impl }},${{ matrix.python-release }},${{ env.OS }} 66 | name: test-python-${{ matrix.python-version }} 67 | fail_ci_if_error: true 68 | token: ${{ secrets.CODECOV_TOKEN }} 69 | package: 70 | needs: test_linux 71 | environment: PyPI 72 | if: "startsWith(github.ref, 'refs/tags/v')" 73 | runs-on: ubuntu-latest 74 | name: Publish Python code 75 | permissions: 76 | id-token: write 77 | steps: 78 | - name: Checkout code 79 | uses: actions/checkout@v4 80 | - name: Set up Python 3.12 81 | uses: actions/setup-python@v5 82 | with: 83 | python-version: 3.12 84 | - name: Install build package 85 | run: python -m pip install -U build 86 | - name: Build a wheel and source tarball 87 | run: python -m build --sdist --wheel --outdir dist 88 | - name: Publish distributions to PyPI 89 | if: "startsWith(github.ref, 'refs/tags')" 90 | uses: pypa/gh-action-pypi-publish@release/v1 91 | release: 92 | environment: GitHub Releases 93 | runs-on: ubuntu-latest 94 | if: "startsWith(github.ref, 'refs/tags/v')" 95 | name: Release 96 | needs: package 97 | permissions: 98 | contents: write 99 | steps: 100 | - name: Checkout code 101 | uses: actions/checkout@v1 102 | - name: Release a Changelog 103 | uses: rasmus-saks/release-a-changelog-action@v1.2.0 104 | with: 105 | github-token: '${{ secrets.GITHUB_TOKEN }}' 106 | -------------------------------------------------------------------------------- /pronto/parsers/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import functools 3 | import operator 4 | import os 5 | import typing 6 | import urllib.parse 7 | from typing import Dict, Optional, Set 8 | 9 | from ..logic.lineage import Lineage 10 | from ..ontology import Ontology 11 | from ..utils.pool import ThreadPool 12 | 13 | 14 | class BaseParser(abc.ABC): 15 | def __init__(self, ont: Ontology): 16 | self.ont: Ontology = ont 17 | 18 | @classmethod 19 | @abc.abstractmethod 20 | def can_parse(cls, path: str, buffer: bytes) -> bool: 21 | """Return `True` if this parser type can parse the given handle.""" 22 | return NotImplemented # type: ignore 23 | 24 | @abc.abstractmethod 25 | def parse_from( 26 | self, handle: typing.BinaryIO, threads: Optional[int] = None 27 | ) -> None: 28 | return NotImplemented 29 | 30 | @classmethod 31 | def pool(cls, threads: int) -> ThreadPool: 32 | return ThreadPool(threads) 33 | 34 | @classmethod 35 | def process_import( 36 | cls, 37 | ref: str, 38 | import_depth: int = -1, 39 | basepath: str = "", 40 | timeout: int = 5, 41 | ) -> Ontology: 42 | s = urllib.parse.urlparse(ref).scheme 43 | if s in {"ftp", "http", "https"} or os.path.exists(ref): 44 | url = ref 45 | else: 46 | for ext in ["", ".obo", ".json", ".owl"]: 47 | if os.path.exists(os.path.join(basepath, f"{ref}{ext}")): 48 | url = os.path.join(basepath, f"{ref}{ext}") 49 | break 50 | else: 51 | id_ = f"{ref}.obo" if not os.path.splitext(ref)[1] else ref 52 | url = f"http://purl.obolibrary.org/obo/{id_}" 53 | return Ontology(url, max(import_depth - 1, -1), timeout) 54 | 55 | @classmethod 56 | def process_imports( 57 | cls, 58 | imports: Set[str], 59 | import_depth: int = -1, 60 | basepath: str = "", 61 | timeout: int = 5, 62 | threads: Optional[int] = None, 63 | ) -> Dict[str, Ontology]: 64 | # check we did not reach the maximum import depth 65 | if import_depth == 0: 66 | return dict() 67 | process = functools.partial( 68 | cls.process_import, 69 | import_depth=import_depth, 70 | basepath=basepath, 71 | timeout=timeout, 72 | ) 73 | with cls.pool(threads) as pool: 74 | return dict(pool.map(lambda i: (i, process(i)), imports)) 75 | 76 | _entities = { 77 | "Term": operator.attrgetter("terms", "_terms"), 78 | "Relationship": operator.attrgetter("relationships", "_relationships"), 79 | } 80 | 81 | def symmetrize_lineage(self): 82 | for getter in self._entities.values(): 83 | entities, graphdata = getter(self.ont) 84 | for entity in entities(): 85 | graphdata.lineage.setdefault(entity.id, Lineage()) 86 | for subentity, lineage in graphdata.lineage.items(): 87 | for superentity in lineage.sup: 88 | graphdata.lineage[superentity].sub.add(subentity) 89 | 90 | def import_lineage(self): 91 | for getter in self._entities.values(): 92 | entities, graphdata = getter(self.ont) 93 | for dep in self.ont.imports.values(): 94 | dep_entities, dep_graphdata = getter(dep) 95 | for entity in dep_entities(): 96 | graphdata.lineage[entity.id] = Lineage() 97 | for id, lineage in dep_graphdata.lineage.items(): 98 | graphdata.lineage[id].sup.update(lineage.sup) 99 | graphdata.lineage[id].sub.update(lineage.sub) 100 | -------------------------------------------------------------------------------- /pronto/xref.py: -------------------------------------------------------------------------------- 1 | """Cross-reference object definition. 2 | """ 3 | 4 | import typing 5 | 6 | import fastobo 7 | 8 | from .utils.meta import roundrepr, typechecked 9 | 10 | 11 | __all__ = ["Xref"] 12 | 13 | 14 | @roundrepr 15 | class Xref(object): 16 | """A cross-reference to another document or resource. 17 | 18 | Cross-references (xrefs for short) can be used to back-up definitions of 19 | entities, synonyms, or to link ontological entities to other resources 20 | they may have been derived from. Although originally intended to provide 21 | links to databases, cross-references in OBO ontologies gained additional 22 | purposes, such as helping for header macros expansion, or being used to 23 | alias external relationships with local unprefixed IDs. 24 | 25 | The OBO format version 1.4 expects references to be proper OBO identifiers 26 | that can be translated to actual IRIs, which is a breaking change from the 27 | previous format. Therefore, cross-references are encouraged to be given as 28 | plain IRIs or as prefixed IDs using an ID from the IDspace mapping defined 29 | in the header. 30 | 31 | Example: 32 | A cross-reference in the Mammalian Phenotype ontology linking a term 33 | to some related Web resource: 34 | 35 | >>> mp = pronto.Ontology.from_obo_library("mp.obo") 36 | >>> mp["MP:0030151"].name 37 | 'abnormal buccinator muscle morphology' 38 | >>> mp["MP:0030151"].xrefs 39 | frozenset({Xref('https://en.wikipedia.org/wiki/Buccinator_muscle')}) 40 | 41 | Caution: 42 | `Xref` instances compare only using their identifiers; this means it 43 | is not possible to have several cross-references with the same 44 | identifier and different descriptions in the same set. 45 | 46 | Todo: 47 | Make sure to resolve header macros for xrefs expansion (such as 48 | ``treat-xrefs-as-is_a``) when creating an ontology, or provide a 49 | method on `~pronto.Ontology` doing so when called. 50 | 51 | """ 52 | 53 | id: str 54 | description: typing.Optional[str] 55 | 56 | __slots__ = ("__weakref__", "id", "description") # noqa: E0602 57 | 58 | @typechecked() 59 | def __init__(self, id: str, description: typing.Optional[str] = None): 60 | """Create a new cross-reference. 61 | 62 | Arguments: 63 | id (str): the identifier of the cross-reference, either as a URL, 64 | a prefixed identifier, or an unprefixed identifier. 65 | description (str or None): a human-readable description of the 66 | cross-reference, if any. 67 | 68 | """ 69 | # check the id is valid using fastobo 70 | if not fastobo.id.is_valid(id): 71 | raise ValueError("invalid identifier: {}".format(id)) 72 | 73 | self.id: str = id 74 | self.description = description 75 | 76 | def __eq__(self, other: object) -> bool: 77 | if isinstance(other, Xref): 78 | return self.id == other.id 79 | return False 80 | 81 | def __gt__(self, other: object) -> bool: 82 | if isinstance(other, Xref): 83 | return self.id > other.id 84 | return NotImplemented 85 | 86 | def __ge__(self, other: object) -> bool: 87 | if isinstance(other, Xref): 88 | return self.id >= other.id 89 | return NotImplemented 90 | 91 | def __lt__(self, other: object) -> bool: 92 | if isinstance(other, Xref): 93 | return self.id < other.id 94 | return NotImplemented 95 | 96 | def __le__(self, other: object) -> bool: 97 | if isinstance(other, Xref): 98 | return self.id <= other.id 99 | return NotImplemented 100 | 101 | def __hash__(self): 102 | return hash(self.id) 103 | -------------------------------------------------------------------------------- /docs/source/_static/js/custom-icon.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Set a custom icon for pypi as it's not available in the fa built-in brands 3 | */ 4 | FontAwesome.library.add( 5 | (faListOldStyle = { 6 | prefix: "fa-custom", 7 | iconName: "pypi", 8 | icon: [ 9 | 17.313, // viewBox width 10 | 19.807, // viewBox height 11 | [], // ligature 12 | "e001", // unicode codepoint - private use area 13 | "m10.383 0.2-3.239 1.1769 3.1883 1.1614 3.239-1.1798zm-3.4152 1.2411-3.2362 1.1769 3.1855 1.1614 3.2369-1.1769zm6.7177 0.00281-3.2947 1.2009v3.8254l3.2947-1.1988zm-3.4145 1.2439-3.2926 1.1981v3.8254l0.17548-0.064132 3.1171-1.1347zm-6.6564 0.018325v3.8247l3.244 1.1805v-3.8254zm10.191 0.20931v2.3137l3.1777-1.1558zm3.2947 1.2425-3.2947 1.1988v3.8254l3.2947-1.1988zm-8.7058 0.45739c0.00929-1.931e-4 0.018327-2.977e-4 0.027485 0 0.25633 0.00851 0.4263 0.20713 0.42638 0.49826 1.953e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36226 0.13215-0.65608-0.073306-0.65613-0.4588-6.28e-5 -0.38556 0.2938-0.80504 0.65613-0.93662 0.068422-0.024919 0.13655-0.038114 0.20156-0.039466zm5.2913 0.78369-3.2947 1.1988v3.8247l3.2947-1.1981zm-10.132 1.239-3.2362 1.1769 3.1883 1.1614 3.2362-1.1769zm6.7177 0.00213-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2439-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.016195v3.8275l3.244 1.1805v-3.8254zm16.9 0.21143-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2432-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.019027v3.8247l3.244 1.1805v-3.8254zm13.485 1.4497-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm2.4018 0.38127c0.0093-1.83e-4 0.01833-3.16e-4 0.02749 0 0.25633 0.0085 0.4263 0.20713 0.42638 0.49826 1.97e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36188 0.1316-0.65525-0.07375-0.65542-0.4588-1.95e-4 -0.38532 0.29328-0.80469 0.65542-0.93662 0.06842-0.02494 0.13655-0.03819 0.20156-0.03947zm-5.8142 0.86403-3.244 1.1805v1.4201l3.244 1.1805z", // svg path (https://simpleicons.org/icons/pypi.svg) 14 | ], 15 | }), 16 | ); 17 | 18 | FontAwesome.library.add( 19 | (faListOldStyle = { 20 | prefix: "fa-custom", 21 | iconName: "sword", 22 | icon: [ 23 | 256, // viewBox width 24 | 256, // viewBox height 25 | [], // ligature 26 | "e002", // unicode codepoint - private use area 27 | "M221.65723,34.34326A8.00246,8.00246,0,0,0,216,32h-.02539l-63.79883.20117A8.00073,8.00073,0,0,0,146.0332,35.106L75.637,120.32275,67.31348,111.999A16.02162,16.02162,0,0,0,44.68555,112L32.001,124.68555A15.99888,15.99888,0,0,0,32,147.31348l20.88672,20.88769L22.94531,198.14258a16.01777,16.01777,0,0,0,.001,22.62695l12.28418,12.28418a16.00007,16.00007,0,0,0,22.62793,0L87.79883,203.1123,108.68652,224.001A16.02251,16.02251,0,0,0,131.31445,224L143.999,211.31445A15.99888,15.99888,0,0,0,144,188.68652l-8.32324-8.32324,85.21679-70.39648a8.00125,8.00125,0,0,0,2.90528-6.14258L224,40.02539A8.001,8.001,0,0,0,221.65723,34.34326Zm-13.84668,65.67822-83.49829,68.97706L111.314,156l54.34327-54.34277a8.00053,8.00053,0,0,0-11.31446-11.31446L100,144.686,87.00195,131.6875,155.97852,48.189l51.99609-.16357Z", // svg path (https://simpleicons.org/icons/pypi.svg) 28 | ], 29 | }), 30 | ); 31 | 32 | FontAwesome.library.add( 33 | (faListOldStyle = { 34 | prefix: "fa-custom", 35 | iconName: "knife", 36 | icon: [ 37 | 256, // viewBox width 38 | 256, // viewBox height 39 | [], // ligature 40 | "e003", // unicode codepoint - private use area 41 | "M231.79883,32.2002a28.05536,28.05536,0,0,0-39.667.06933L18.27441,210.41211a8,8,0,0,0,3.92676,13.38281,155.06019,155.06019,0,0,0,34.957,4.00293c33.4209-.001,66.877-10.86914,98.32813-32.1748,31.74512-21.50391,50.14551-45.79981,50.91406-46.82325a8.00114,8.00114,0,0,0-.74316-10.457L186.919,119.60547l44.97753-47.90332A28.03445,28.03445,0,0,0,231.79883,32.2002ZM189.207,144.52148a225.51045,225.51045,0,0,1-43.10351,38.13184c-34.46973,23.23145-69.999,32.665-105.83887,28.13477l106.29492-108.915,23.30176,23.30175q.208.22852.43847.44434l.082.07617Z", // svg path (https://simpleicons.org/icons/pypi.svg) 42 | ], 43 | }), 44 | ); 45 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | ``pronto`` |Stars| 2 | ================== 3 | 4 | .. |Stars| image:: https://img.shields.io/github/stars/althonos/pronto.svg?style=social&maxAge=3600&label=Star 5 | :target: https://github.com/althonos/pronto/stargazers 6 | 7 | *A Python frontend to ontologies.* 8 | 9 | |Actions| |License| |Source| |Docs| |Coverage| |Sanity| |PyPI| |Bioconda| |Versions| |Wheel| |Changelog| |Issues| |DOI| |Downloads| 10 | 11 | .. |Actions| image:: https://img.shields.io/github/actions/workflow/status/althonos/pronto/test.yml?branch=master&style=flat-square&maxAge=300 12 | :target: https://github.com/althonos/pronto/actions 13 | 14 | .. |License| image:: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square&maxAge=2678400 15 | :target: https://choosealicense.com/licenses/mit/ 16 | 17 | .. |Source| image:: https://img.shields.io/badge/source-GitHub-303030.svg?maxAge=2678400&style=flat-square 18 | :target: https://github.com/althonos/pronto/ 19 | 20 | .. |Docs| image:: https://img.shields.io/readthedocs/pronto?style=flat-square&maxAge=3600 21 | :target: http://pronto.readthedocs.io/en/stable/?badge=stable 22 | 23 | .. |Coverage| image:: https://img.shields.io/codecov/c/gh/althonos/pronto?style=flat-square&maxAge=3600 24 | :target: https://www.codecov.com/gh/althonos/pronto/ 25 | 26 | .. |Sanity| image:: https://img.shields.io/codacy/grade/157b5fd24e5648ea80580f28399e79a4.svg?style=flat-square&maxAge=3600 27 | :target: https://codacy.com/app/althonos/pronto 28 | 29 | .. |PyPI| image:: https://img.shields.io/pypi/v/pronto.svg?style=flat-square&maxAge=3600 30 | :target: https://pypi.python.org/pypi/pronto 31 | 32 | .. |Bioconda| image:: https://img.shields.io/conda/vn/bioconda/pronto?style=flat-square&maxAge=3600 33 | :target: https://anaconda.org/bioconda/pronto 34 | 35 | .. |Versions| image:: https://img.shields.io/pypi/pyversions/pronto.svg?style=flat-square&maxAge=3600 36 | :target: https://pypi.org/project/pronto/#files 37 | 38 | .. |Wheel| image:: https://img.shields.io/pypi/wheel/pronto?style=flat-square&maxAge=3600 39 | :target: https://pypi.org/project/pronto/#files 40 | 41 | .. |Changelog| image:: https://img.shields.io/badge/keep%20a-changelog-8A0707.svg?maxAge=2678400&style=flat-square 42 | :target: https://github.com/althonos/pronto/blob/master/CHANGELOG.md 43 | 44 | .. |Issues| image:: https://img.shields.io/github/issues/althonos/pronto.svg?style=flat-square&maxAge=600 45 | :target: https://github.com/althonos/pronto/issues 46 | 47 | .. |DOI| image:: https://img.shields.io/badge/doi-10.5281%2Fzenodo.595572-purple?style=flat-square&maxAge=2678400 48 | :target: https://doi.org/10.5281/zenodo.595572 49 | 50 | .. |Downloads| image:: https://img.shields.io/pypi/dm/pronto?style=flat-square&color=303f9f&maxAge=86400&label=downloads 51 | :target: https://pepy.tech/project/pronto 52 | 53 | 54 | ``pronto`` is a Python agnostic library designed to work with ontologies. At the 55 | moment, it can parse `OBO`_, `OBO Graphs`_ or 56 | `OWL in RDF/XML format `_, 57 | ntologies on the local host or from an network location, and export 58 | ontologies to `OBO`_ or `OBO Graphs`_ (in `JSON`_ format). 59 | 60 | .. _OBO: https://owlcollab.github.io/oboformat/doc/GO.format.obo-1_4.html 61 | .. _OBO Graphs: https://github.com/geneontology/obographs 62 | .. _JSON: http://www.json.org/ 63 | 64 | Setup 65 | ----- 66 | 67 | Run ``pip install pronto`` in a shell to download the latest release and all 68 | its dependencies from PyPi, or have a look at the 69 | :doc:`Installation page ` to find other ways to install ``pronto``. 70 | 71 | .. note:: 72 | 73 | ``pronto`` requires ``fastobo``, an efficient and faultless parser 74 | for the OBO language implemented in Rust. Most platforms, such as Linux 75 | x86-64, OSX and Windows x86-64 provide precompiled packages, but other 76 | less frequent platforms will require a working Rust toolchain. See the 77 | ``fastobo`` 78 | `Installation page `_ 79 | and the `Rust Forge tutorial `_ 80 | for more information about this topic. 81 | 82 | Library 83 | ------- 84 | 85 | .. toctree:: 86 | :maxdepth: 2 87 | 88 | User Guide 89 | API Reference 90 | 91 | 92 | Indices and tables 93 | ------------------ 94 | 95 | * :ref:`genindex` 96 | * :ref:`search` 97 | -------------------------------------------------------------------------------- /pronto/pv.py: -------------------------------------------------------------------------------- 1 | """Object hierarchy of property-value annotations in OBO files. 2 | """ 3 | 4 | import abc 5 | import functools 6 | 7 | import fastobo 8 | 9 | from .utils.meta import roundrepr, typechecked 10 | 11 | 12 | __all__ = ["PropertyValue", "LiteralPropertyValue", "ResourcePropertyValue"] 13 | 14 | 15 | class PropertyValue(abc.ABC): 16 | """A property-value, which adds annotations to an entity.""" 17 | 18 | property: str 19 | __slots__ = ("property",) 20 | 21 | @abc.abstractmethod 22 | def __eq__(self, other: object): 23 | return NotImplemented 24 | 25 | @abc.abstractmethod 26 | def __lt__(self, other: object): 27 | return NotImplemented 28 | 29 | 30 | @roundrepr 31 | @functools.total_ordering 32 | class LiteralPropertyValue(PropertyValue): 33 | """A property-value which adds a literal annotation to an entity.""" 34 | 35 | literal: str 36 | datatype: str 37 | __slots__ = ("literal", "datatype") 38 | 39 | @typechecked() 40 | def __init__(self, property: str, literal: str, datatype: str = "xsd:string"): 41 | """Create a new `LiteralPropertyValue` instance. 42 | 43 | Arguments: 44 | property (str): The annotation property, as an OBO identifier. 45 | literal (str): The serialized value of the annotation. 46 | datatype (str): The datatype of the annotation property value. 47 | Defaults to `xsd:string`. 48 | 49 | """ 50 | if not fastobo.id.is_valid(property): 51 | raise ValueError("invalid identifier: {}".format(property)) 52 | if not fastobo.id.is_valid(datatype): 53 | raise ValueError("invalid identifier: {}".format(datatype)) 54 | 55 | self.property = property 56 | self.literal = literal 57 | self.datatype = datatype 58 | 59 | def __eq__(self, other: object) -> bool: 60 | if isinstance(other, LiteralPropertyValue): 61 | return ( 62 | self.property == other.property 63 | and self.literal == other.literal 64 | and self.datatype == other.datatype 65 | ) 66 | return False 67 | 68 | def __lt__(self, other: object) -> bool: 69 | if isinstance(other, LiteralPropertyValue): 70 | return (self.property, self.literal, self.datatype) < ( 71 | other.property, 72 | other.literal, 73 | other.datatype, 74 | ) 75 | elif isinstance(other, ResourcePropertyValue): 76 | return self.property < other.property 77 | else: 78 | return NotImplemented 79 | 80 | def __hash__(self) -> int: 81 | return hash((LiteralPropertyValue, self.property, self.literal, self.datatype)) 82 | 83 | 84 | @roundrepr 85 | @functools.total_ordering 86 | class ResourcePropertyValue(PropertyValue): 87 | """A property-value which adds a resource annotation to an entity.""" 88 | 89 | resource: str 90 | __slots__ = ("resource",) 91 | 92 | @typechecked() 93 | def __init__(self, property: str, resource: str): 94 | """Create a new `ResourcePropertyValue` instance. 95 | 96 | Arguments: 97 | property (str): The annotation property, as an OBO identifier. 98 | resource (str): The annotation entity value, as an OBO identifier. 99 | 100 | """ 101 | if not fastobo.id.is_valid(property): 102 | raise ValueError("invalid identifier: {}".format(property)) 103 | if not fastobo.id.is_valid(resource): 104 | raise ValueError("invalid identifier: {}".format(resource)) 105 | 106 | self.property = property 107 | self.resource = resource 108 | 109 | def __eq__(self, other: object) -> bool: 110 | if isinstance(other, ResourcePropertyValue): 111 | return (self.property, self.resource) == (other.property, other.resource) 112 | return False 113 | 114 | def __lt__(self, other: object) -> bool: 115 | if isinstance(other, ResourcePropertyValue): 116 | return (self.property, self.resource) < (other.property, other.resource) 117 | elif isinstance(other, LiteralPropertyValue): 118 | return self.property < other.property 119 | else: 120 | return NotImplemented 121 | 122 | def __hash__(self) -> int: 123 | return hash((LiteralPropertyValue, self.property, self.resource)) 124 | -------------------------------------------------------------------------------- /pronto/utils/io.py: -------------------------------------------------------------------------------- 1 | import bz2 2 | import codecs 3 | import gzip 4 | import io 5 | import lzma 6 | import typing 7 | import urllib.request 8 | import warnings 9 | from http.client import HTTPResponse 10 | from typing import BinaryIO, ByteString, Dict, Optional, Union, cast 11 | 12 | import chardet 13 | 14 | MAGIC_GZIP = bytearray([0x1F, 0x8B]) 15 | MAGIC_LZMA = bytearray([0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, 0x00]) 16 | MAGIC_BZIP2 = bytearray([0x42, 0x5A, 0x68]) 17 | 18 | 19 | class BufferedReader(io.BufferedReader): 20 | """A patch for `io.BufferedReader` supporting `http.client.HTTPResponse`.""" 21 | 22 | def read(self, size: Optional[int] = -1) -> bytes: 23 | try: 24 | return super(BufferedReader, self).read(size) 25 | except ValueError: 26 | if typing.cast(io.BufferedReader, self.closed): 27 | return b"" 28 | raise 29 | 30 | 31 | class EncodedFile(codecs.StreamRecoder): 32 | def __init__( 33 | self, 34 | file: BinaryIO, 35 | data_encoding: str, 36 | file_encoding: Optional[str] = None, 37 | errors: str = "strict", 38 | ): 39 | if file_encoding is None: 40 | file_encoding = data_encoding 41 | data_info = codecs.lookup(data_encoding) 42 | file_info = codecs.lookup(file_encoding) 43 | super().__init__( 44 | file, 45 | data_info.encode, 46 | data_info.decode, 47 | file_info.streamreader, 48 | file_info.streamwriter, 49 | errors, 50 | ) 51 | # Add attributes to simplify introspection 52 | self.data_encoding = data_encoding 53 | self.file_encoding = file_encoding 54 | 55 | def get_handle(path: str, timeout: int = 2) -> BinaryIO: 56 | """Given a path or URL, get a binary handle for that path.""" 57 | try: 58 | return open(path, "rb", buffering=0) 59 | except Exception as err: 60 | headers = {"Keep-Alive": f"timeout={timeout}"} 61 | request = urllib.request.Request(path, headers=headers) 62 | res: HTTPResponse = urllib.request.urlopen(request, timeout=timeout) 63 | if not res.status == 200: 64 | raise ValueError(f"could not open {path}: {res.status} ({res.msg})") 65 | if res.headers.get("Content-Encoding") in {"gzip", "deflate"}: 66 | f = gzip.GzipFile(filename=res.geturl(), mode="rb", fileobj=res) 67 | return typing.cast(BinaryIO, f) 68 | return res 69 | 70 | 71 | def get_location(reader: BinaryIO) -> Optional[str]: 72 | """Given a binary file-handle, try to extract the path/URL to the file.""" 73 | return ( 74 | getattr(reader, "name", None) 75 | or getattr(reader, "url", None) 76 | or getattr(reader, "geturl", lambda: None)() 77 | ) 78 | 79 | 80 | def decompress( 81 | reader: io.RawIOBase, path: Optional[str] = None, encoding: Optional[str] = None 82 | ) -> BinaryIO: 83 | """Given a binary file-handle, decompress it if it is compressed.""" 84 | buffered = BufferedReader(reader) 85 | 86 | # Decompress the stream if it is compressed 87 | if buffered.peek().startswith(MAGIC_GZIP): 88 | decompressed = BufferedReader( 89 | typing.cast( 90 | io.RawIOBase, 91 | gzip.GzipFile(mode="rb", fileobj=typing.cast(BinaryIO, buffered)), 92 | ) 93 | ) 94 | elif buffered.peek().startswith(MAGIC_LZMA): 95 | decompressed = BufferedReader( 96 | typing.cast( 97 | io.RawIOBase, lzma.LZMAFile(typing.cast(BinaryIO, buffered), mode="rb") 98 | ) 99 | ) 100 | elif buffered.peek().startswith(MAGIC_BZIP2): 101 | decompressed = BufferedReader( 102 | typing.cast( 103 | io.RawIOBase, bz2.BZ2File(typing.cast(BinaryIO, buffered), mode="rb") 104 | ) 105 | ) 106 | else: 107 | decompressed = buffered 108 | 109 | # Attempt to detect the encoding and decode the stream 110 | det: Dict[str, Union[str, float]] = chardet.detect(decompressed.peek()) 111 | confidence = 1.0 if encoding is not None else cast(float, det["confidence"]) 112 | encoding = encoding if encoding is not None else cast(str, det["encoding"]) 113 | 114 | if encoding == "ascii": 115 | encoding = "utf-8" 116 | if confidence < 1.0: 117 | warnings.warn( 118 | f"unsound encoding, assuming {encoding} ({confidence:.0%} confidence)", 119 | UnicodeWarning, 120 | stacklevel=3, 121 | ) 122 | 123 | if encoding == "utf-8": 124 | return typing.cast(BinaryIO, decompressed) 125 | else: 126 | return typing.cast( 127 | BinaryIO, 128 | BufferedReader( 129 | typing.cast( 130 | io.RawIOBase, 131 | EncodedFile( 132 | typing.cast(typing.BinaryIO, decompressed), 133 | "utf-8", 134 | typing.cast(str, det["encoding"]), 135 | ), 136 | ) 137 | ), 138 | ) 139 | -------------------------------------------------------------------------------- /tests/test_pv.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import warnings 3 | 4 | import pronto 5 | 6 | 7 | class _SetupMixin(object): 8 | 9 | @classmethod 10 | def setUpClass(cls): 11 | warnings.simplefilter('error') 12 | cls.rpv1 = pronto.ResourcePropertyValue("IAO:0000114", "IAO:0000122") 13 | cls.rpv2 = pronto.ResourcePropertyValue("IAO:0000114", "IAO:0000122") 14 | cls.rpv3 = pronto.ResourcePropertyValue("IAO:0000114", "IAO:0000124") 15 | cls.lpv1 = pronto.LiteralPropertyValue("IAO:0000114", "unknown") 16 | cls.lpv2 = pronto.LiteralPropertyValue("IAO:0000114", "unknown") 17 | cls.lpv3 = pronto.LiteralPropertyValue("IAO:0000114", "other") 18 | cls.lpv4 = pronto.LiteralPropertyValue("IAO:0000112", "an example") 19 | cls.lpv5 = pronto.LiteralPropertyValue( 20 | "creation_date", "2018-09-21T16:43:39Z", "xsd:dateTime" 21 | ) 22 | cls.lpv6 = pronto.LiteralPropertyValue( 23 | "creation_date", "2018-09-21T16:43:39Z", "xsd:string" 24 | ) 25 | cls.lpv7 = pronto.LiteralPropertyValue( 26 | "IAO:0000427", "true", "xsd:boolean" 27 | ) 28 | 29 | @classmethod 30 | def tearDownClass(cls): 31 | warnings.simplefilter(warnings.defaultaction) 32 | 33 | 34 | class TestLiteralPropertyValue(_SetupMixin, unittest.TestCase): 35 | 36 | def test_eq_self(self): 37 | self.assertEqual(self.lpv1, self.lpv1) 38 | self.assertEqual(self.lpv3, self.lpv3) 39 | 40 | def test_eq_literal_identical(self): 41 | self.assertEqual(self.lpv1, self.lpv2) 42 | self.assertIsNot(self.lpv1, self.lpv2) 43 | 44 | def test_eq_literal_different(self): 45 | self.assertNotEqual(self.lpv1, self.lpv3) 46 | self.assertNotEqual(self.lpv1, self.lpv4) 47 | self.assertNotEqual(self.lpv5, self.lpv6) 48 | 49 | def test_eq_resource_same_property(self): 50 | self.assertNotEqual(self.lpv5, self.rpv1) 51 | self.assertNotEqual(self.lpv5, self.rpv3) 52 | 53 | def test_eq_resource_different(self): 54 | self.assertNotEqual(self.lpv1, self.rpv1) 55 | 56 | def test_lt_literal_identical(self): 57 | self.assertFalse(self.lpv1 < self.lpv1) 58 | self.assertFalse(self.lpv1 < self.lpv2) 59 | 60 | def test_lt_literal_different(self): 61 | self.assertTrue(self.lpv4 < self.lpv1) 62 | self.assertTrue(self.lpv3 < self.lpv1) 63 | 64 | def test_lt_type_error(self): 65 | with self.assertRaises(TypeError): 66 | self.lpv1 < 1 67 | 68 | def test_lt_resource_identical(self): 69 | self.assertFalse(self.lpv1 < self.rpv1) 70 | 71 | def test_hash_identical(self): 72 | self.assertEqual(hash(self.lpv1), hash(self.lpv2)) 73 | 74 | def test_hash_different(self): 75 | self.assertNotEqual(hash(self.lpv1), hash(self.lpv3)) 76 | 77 | def test_repr(self): 78 | self.assertEqual(repr(self.lpv4), "LiteralPropertyValue('IAO:0000112', 'an example')") 79 | self.assertEqual( 80 | repr(self.lpv5), 81 | "LiteralPropertyValue('creation_date', '2018-09-21T16:43:39Z', datatype='xsd:dateTime')", 82 | ) 83 | 84 | 85 | class TestResourcePropertyValue(_SetupMixin, unittest.TestCase): 86 | 87 | def test_eq_self(self): 88 | self.assertEqual(self.rpv1, self.rpv1) 89 | self.assertEqual(self.rpv2, self.rpv2) 90 | 91 | def test_eq_other(self): 92 | self.assertNotEqual(self.rpv1, object()) 93 | self.assertNotEqual(self.rpv1, 1) 94 | 95 | def test_eq_resource_identical(self): 96 | self.assertEqual(self.rpv1, self.rpv2) 97 | self.assertIsNot(self.rpv1, self.rpv2) 98 | 99 | def test_eq_resource_different(self): 100 | self.assertNotEqual(self.rpv1, self.rpv3) 101 | self.assertNotEqual(self.rpv2, self.rpv3) 102 | 103 | def test_eq_literal_different(self): 104 | self.assertNotEqual(self.rpv1, self.lpv1) 105 | self.assertNotEqual(self.rpv3, self.lpv1) 106 | 107 | def test_eq_literal_same_property(self): 108 | self.assertNotEqual(self.rpv1, self.lpv5) 109 | self.assertNotEqual(self.rpv3, self.lpv5) 110 | 111 | def test_hash_identical(self): 112 | self.assertEqual(hash(self.rpv1), hash(self.rpv2)) 113 | 114 | def test_hash_different(self): 115 | self.assertNotEqual(hash(self.rpv1), hash(self.rpv3)) 116 | 117 | def test_lt_resource_identical(self): 118 | self.assertFalse(self.rpv1 < self.rpv1) 119 | self.assertFalse(self.rpv1 < self.rpv2) 120 | 121 | def test_lt_resource_different(self): 122 | self.assertTrue(self.rpv1 < self.rpv3) 123 | 124 | def test_lt_literal_identical(self): 125 | self.assertFalse(self.rpv1 < self.lpv1) 126 | 127 | def test_lt_literal_different(self): 128 | self.assertTrue(self.rpv1 < self.lpv7) 129 | 130 | def test_lt_type_error(self): 131 | with self.assertRaises(TypeError): 132 | self.rpv1 < 1 133 | 134 | def test_repr(self): 135 | self.assertEqual( 136 | repr(self.rpv1), 137 | "ResourcePropertyValue('IAO:0000114', 'IAO:0000122')" 138 | ) 139 | -------------------------------------------------------------------------------- /pronto/metadata.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import functools 3 | import typing 4 | from dataclasses import field, astuple 5 | from typing import Dict, List, Optional, Set, Tuple 6 | 7 | from .pv import PropertyValue 8 | from .synonym import SynonymType 9 | from .utils.meta import dataclass, roundrepr, typechecked 10 | 11 | __all__ = ["Subset", "Metadata"] 12 | 13 | 14 | @functools.total_ordering 15 | @dataclass(init=True, slots=True, weakref_slot=True) 16 | class Subset(object): 17 | """A definition of a subset in an ontology. 18 | 19 | Attributes: 20 | name (`str`): The name of the subset, as an OBO short identifier. 21 | description (`str`): A description of the subset, as defined in the 22 | metadata part of the ontology file. 23 | 24 | """ 25 | 26 | name: str = field() 27 | description: str = field() 28 | 29 | if typing.TYPE_CHECKING: 30 | __annotations__: Dict[str, str] 31 | 32 | @typechecked() 33 | def __init__(self, name: str, description: str): 34 | """Create a new subset with the given name and description.""" 35 | self.name: str = name 36 | self.description: str = description 37 | 38 | def __eq__(self, other: object) -> bool: 39 | if isinstance(other, Subset): 40 | return self.name == other.name 41 | return False 42 | 43 | def __lt__(self, other: object) -> bool: 44 | if not isinstance(other, Subset): 45 | return typing.cast(bool, NotImplemented) 46 | return self.name < other.name 47 | 48 | def __hash__(self) -> int: 49 | return hash((Subset, self.name)) 50 | 51 | 52 | @dataclass(init=True, slots=True, weakref_slot=True) 53 | class Metadata(object): 54 | """A mapping containing metadata about the current ontology. 55 | 56 | Attributes: 57 | format_version (`str`): The OBO format version of the referenced 58 | ontology. **1.4** is the default since ``pronto`` can only 59 | parse and write OBO documents of that format version. 60 | data_version (`str` or `None`): The OBO data version of the ontology, 61 | which is then expanded to the ``versionIRI`` if translated to 62 | OWL. 63 | ontology (`str` or `None`): The identifier of the ontology, either as 64 | a short OBO identifier or as a full IRI. 65 | date (`~datetime.datetime` or `None`): The date the ontology was last 66 | modified, if any. 67 | default_namespace (`str` or `None`): The default namespace to use for 68 | entity frames lacking a ``namespace`` clause. 69 | namespace_id_rule (`str` or `None`): The production rule for 70 | identifiers in the current ontology document. *soft-deprecated, 71 | used mostly by OBO-Edit or other outdated tools*. 72 | owl_axioms (`list` of `str`): A list of OWL axioms that cannot be 73 | expressed in OBO language, serialized in OWL2 Functional syntax. 74 | saved_by (`str` or `None`): The name of the person that last saved the 75 | ontology file. 76 | auto_generated_by (`str` or `None`): The name of the software that was 77 | used to generate the file. 78 | subsetdefs (`set` of `str`): A set of ontology subsets declared in the 79 | ontology files. 80 | imports (`set` of `str`): A set of references to other ontologies that 81 | are imported by the current ontology. OBO requires all entities 82 | referenced in the file to be reachable through imports (excluding 83 | databases cross-references). 84 | synonymtypedefs (`set` of `~pronto.SynonymType`): A set of user-defined 85 | synonym types including a description and an optional scope. 86 | idspaces (`dict` of `str` to couple of `str`): A mapping between a 87 | local ID space and a global ID space, with an optional description 88 | of the mapping. 89 | remarks (`set` of `str`): A set of general comments for this file, 90 | which will be preserved by a parser/serializer as opposed to 91 | inline comments using ``!``. 92 | annotations (`set` of `PropertyValue`): A set of annotations relevant 93 | to the whole file. OBO property-values are semantically equivalent 94 | to ``owl:AnnotationProperty`` in OWL2. 95 | unreserved (`dict` of `str` to `set` of `str`): A mapping of 96 | unreserved tags to values found in the ontology header. 97 | 98 | """ 99 | 100 | format_version: Optional[str] = field(default="1.4") 101 | data_version: Optional[str] = field(default=None) 102 | ontology: Optional[str] = field(default=None) 103 | date: Optional[datetime.datetime] = field(default=None) 104 | default_namespace: Optional[str] = field(default=None) 105 | namespace_id_rule: Optional[str] = field(default=None) 106 | owl_axioms: List[str] = field(default_factory=list) 107 | saved_by: Optional[str] = field(default=None) 108 | auto_generated_by: Optional[str] = field(default=None) 109 | subsetdefs: Set[Subset] = field(default_factory=set) 110 | imports: Set[str] = field(default_factory=set) 111 | synonymtypedefs: Set[SynonymType] = field(default_factory=set) 112 | idspaces: Dict[str, Tuple[str, Optional[str]]] = field(default_factory=dict) # FIXME: better typing? 113 | remarks: Set[str] = field(default_factory=set) 114 | annotations: Set[PropertyValue] = field(default_factory=set) 115 | unreserved: Dict[str, Set[str]] = field(default_factory=dict) 116 | 117 | def __bool__(self) -> bool: 118 | """Return `False` if the instance does not contain any metadata.""" 119 | return any(map(bool, astuple(self))) 120 | -------------------------------------------------------------------------------- /tests/test_issues.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import io 3 | import re 4 | import sys 5 | import contextlib 6 | import os 7 | import shutil 8 | import gzip 9 | import warnings 10 | import textwrap 11 | from unittest import mock 12 | 13 | import pronto 14 | 15 | from . import utils 16 | 17 | 18 | # a mock implementation of `BaseParser.process_import` that uses files 19 | # in `utils.DATADIR` instead of querying the OBO library 20 | def process_import(ref, import_depth=-1, basepath="", timeout=5): 21 | ref, _ = os.path.splitext(ref) 22 | return pronto.Ontology(os.path.join(utils.DATADIR, f"{ref}.obo")) 23 | 24 | 25 | class TestIssues(unittest.TestCase): 26 | 27 | CONSISTENCY_SPAN = 4 28 | 29 | @classmethod 30 | def setUpClass(cls): 31 | warnings.simplefilter('error') 32 | 33 | @classmethod 34 | def tearDownClass(cls): 35 | warnings.simplefilter(warnings.defaultaction) 36 | 37 | def test_parser_consistency(self): 38 | """Assert several runs on the same file give the same output. 39 | 40 | See `#4 `_. 41 | Thanks to @winni-genp for issue reporting. 42 | """ 43 | path = os.path.join(utils.DATADIR, "imagingMS.obo") 44 | ims = pronto.Ontology(path, import_depth=0) 45 | expected_keys = set(ims.keys()) 46 | for _ in range(self.CONSISTENCY_SPAN): 47 | tmp_ims = pronto.Ontology(path, import_depth=0) 48 | self.assertEqual(set(tmp_ims.keys()), set(ims.keys())) 49 | 50 | def test_unicode_in_term_names(self): 51 | """Test if unicode characters in term names work. 52 | 53 | See `#6 `_. 54 | Thanks to @owen-jones-gen for issue reporting. 55 | """ 56 | ont = pronto.Ontology(os.path.join(utils.DATADIR, "owen-jones-gen.obo")) 57 | self.assertEqual(str(ont["ONT0:ROOT"]), "Term('ONT0:ROOT', name='°')") 58 | 59 | @mock.patch("pronto.parsers.base.BaseParser.process_import", new=process_import) 60 | def test_nested_imports(self): 61 | """Check an ontology importing an ontology with imports can be opened. 62 | """ 63 | with warnings.catch_warnings(): 64 | warnings.simplefilter('ignore', UnicodeWarning) 65 | warnings.simplefilter('ignore', DeprecationWarning) 66 | path = os.path.join(utils.DATADIR, "imagingMS.obo") 67 | ims = pronto.Ontology(path) 68 | 69 | self.assertIn("MS:1000031", ims) 70 | self.assertIn("UO:0000000", ims) 71 | 72 | self.assertIn("MS:1000040", ims["UO:0000000"].subclasses().to_set().ids) 73 | self.assertIn("UO:0000000", ims["MS:1000040"].superclasses().to_set().ids) 74 | 75 | self.assertEqual(ims.imports["ms"].metadata.ontology, "ms") 76 | self.assertEqual(ims.imports["ms"].imports["uo.obo"].metadata.ontology, "uo") 77 | 78 | def test_alt_id_access(self): 79 | path = os.path.join(utils.DATADIR, "hp.obo") 80 | hp = pronto.Ontology(path, import_depth=0) 81 | self.assertNotIn("HP:0001198", hp) 82 | self.assertIn("HP:0001198", hp["HP:0009882"].alternate_ids) 83 | 84 | @mock.patch("pronto.parsers.base.BaseParser.process_import", new=process_import) 85 | def test_synonym_type_in_import(self): 86 | with warnings.catch_warnings(): 87 | warnings.simplefilter('ignore', UnicodeWarning) 88 | ont = pronto.Ontology(io.BytesIO(b""" 89 | format-version: 1.4 90 | import: hp 91 | """)) 92 | 93 | ty = next(ty for ty in ont.synonym_types() if ty.id == "uk_spelling") 94 | 95 | t1 = ont.create_term("TST:001") 96 | t1.name = "color blindness" 97 | s1 = t1.add_synonym("colour blindness", scope="EXACT", type=ty) 98 | 99 | self.assertEqual(s1.type, ty) 100 | 101 | def test_rdfxml_aliased_typedef(self): 102 | warnings.simplefilter("ignore") 103 | path = os.path.join(__file__, "..", "data", "go-basic.owl") 104 | go_basic = pronto.Ontology(os.path.realpath(path)) 105 | self.assertIn("regulates", go_basic.relationships()) 106 | self.assertIn("negatively_regulates", go_basic.relationships()) 107 | self.assertIn( 108 | go_basic.get_relationship("regulates"), 109 | go_basic.get_relationship("negatively_regulates").superproperties().to_set(), 110 | ) 111 | 112 | def test_idspaces_serialization(self): 113 | ont = pronto.Ontology() 114 | ont.metadata.idspaces["RHEA"] = ("http://identifier.org/rhea/", None) 115 | try: 116 | obo = ont.dumps() 117 | except Exception as err: 118 | self.fail("serialization failed with {}".format(err)) 119 | 120 | def test_metadata_tag_serialization(self): 121 | """Assert relationships which are metadata tag serialize properly. 122 | 123 | See `#164 `_. 124 | """ 125 | ont = pronto.Ontology() 126 | r = ont.create_relationship("TST:001") 127 | r.metadata_tag = True 128 | text = ont.dumps() 129 | self.assertEqual( 130 | text.strip(), 131 | textwrap.dedent(""" 132 | format-version: 1.4 133 | 134 | [Typedef] 135 | id: TST:001 136 | is_metadata_tag: true 137 | """).strip() 138 | ) 139 | 140 | def test_relationship_chains(self): 141 | ont = pronto.Ontology() 142 | r1 = ont.create_relationship("r1") 143 | r2 = ont.create_relationship("r2") 144 | r3 = ont.create_relationship("r3") 145 | r3.holds_over_chain = { (r1, r2) } 146 | r3.equivalent_to_chain = { (r1, r2) } 147 | ont.dumps() 148 | 149 | def test_class_level_clause(self): 150 | ont = pronto.Ontology() 151 | r1 = ont.create_relationship("r1") 152 | r1.class_level = True 153 | ont.dumps() 154 | -------------------------------------------------------------------------------- /tests/test_ontology.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import sys 4 | import pickle 5 | import unittest 6 | import warnings 7 | 8 | import pronto 9 | from pronto.logic.lineage import Lineage 10 | 11 | from .utils import DATADIR 12 | 13 | 14 | class TestOntology(unittest.TestCase): 15 | @classmethod 16 | def setUpClass(cls): 17 | warnings.simplefilter('error') 18 | warnings.simplefilter('ignore', category=UnicodeWarning) 19 | 20 | @classmethod 21 | def tearDownClass(cls): 22 | warnings.simplefilter(warnings.defaultaction) 23 | 24 | def test_repr_new(self): 25 | ont = pronto.Ontology() 26 | self.assertEqual(repr(ont), "Ontology()") 27 | 28 | def test_repr_path(self): 29 | path = os.path.join(DATADIR, "pato.obo") 30 | ont = pronto.Ontology(path) 31 | self.assertEqual(repr(ont), "Ontology({!r})".format(path)) 32 | 33 | def test_repr_path_with_import_depth(self): 34 | path = os.path.join(DATADIR, "pato.obo") 35 | ont = pronto.Ontology(path, import_depth=1) 36 | self.assertEqual(repr(ont), "Ontology({!r}, import_depth=1)".format(path)) 37 | 38 | def test_repr_file(self): 39 | path = os.path.join(DATADIR, "pato.obo") 40 | with open(path, "rb") as src: 41 | ont = pronto.Ontology(src) 42 | self.assertEqual(repr(ont), "Ontology({!r})".format(path)) 43 | 44 | def test_repr_file_handle(self): 45 | path = os.path.join(DATADIR, "pato.obo") 46 | with open(path, "rb") as src: 47 | handle = io.BytesIO(src.read()) 48 | ont = pronto.Ontology(handle) 49 | self.assertEqual(repr(ont), "Ontology({!r})".format(handle)) 50 | 51 | def test_threads_invalid(self): 52 | hp = os.path.join(DATADIR, "hp.obo") 53 | self.assertRaises(ValueError, pronto.Ontology, hp, threads=-1) 54 | self.assertRaises(ValueError, pronto.Ontology, hp, threads=0) 55 | 56 | def test_indexing_relationship_warning(self): 57 | ont = pronto.Ontology() 58 | ont.create_relationship("brother_of") 59 | with warnings.catch_warnings(): 60 | warnings.simplefilter("error") 61 | self.assertRaises(DeprecationWarning, ont.__getitem__, "brother_of") 62 | 63 | 64 | class TestOntologyLineage(unittest.TestCase): 65 | def test_edit_term(self): 66 | ont = pronto.Ontology() 67 | self.assertEqual(ont._terms.lineage, {}) 68 | 69 | t1 = ont.create_term("TST:001") 70 | self.assertEqual(ont._terms.lineage, {t1.id: Lineage()}) 71 | 72 | t2 = ont.create_term("TST:002") 73 | self.assertEqual(ont._terms.lineage, {t1.id: Lineage(), t2.id: Lineage()}) 74 | 75 | t2.superclasses().add(t1) 76 | self.assertEqual(ont._terms.lineage, { 77 | t1.id: Lineage(sub={t2.id}), 78 | t2.id: Lineage(sup={t1.id}) 79 | }) 80 | 81 | t2.superclasses().clear() 82 | self.assertEqual(ont._terms.lineage, {t1.id: Lineage(), t2.id: Lineage()}) 83 | 84 | def test_edit_relationship(self): 85 | ont = pronto.Ontology() 86 | self.assertEqual(ont._relationships.lineage, {}) 87 | 88 | r1 = ont.create_relationship("rel_one") 89 | self.assertEqual(ont._relationships.lineage, {r1.id: Lineage()}) 90 | 91 | r2 = ont.create_relationship("rel_two") 92 | self.assertEqual(ont._relationships.lineage, {r1.id: Lineage(), r2.id: Lineage()}) 93 | 94 | r2.superproperties().add(r1) 95 | self.assertEqual(ont._relationships.lineage, { 96 | r1.id: Lineage(sub={r2.id}), 97 | r2.id: Lineage(sup={r1.id}) 98 | }) 99 | 100 | r2.superproperties().clear() 101 | self.assertEqual(ont._relationships.lineage, {r1.id: Lineage(), r2.id: Lineage()}) 102 | 103 | 104 | class TestPickling(unittest.TestCase): 105 | @classmethod 106 | def setUpClass(cls): 107 | warnings.simplefilter('error') 108 | warnings.simplefilter('ignore', category=UnicodeWarning) 109 | with open(os.path.join(DATADIR, "ms.obo"), "rb") as f: 110 | cls.ms = pronto.Ontology(f) 111 | 112 | @classmethod 113 | def tearDownClass(cls): 114 | warnings.simplefilter(warnings.defaultaction) 115 | 116 | # ------------------------------------------------------------------------ 117 | 118 | def _test_memory_pickling(self, protocol): 119 | ont = pronto.Ontology() 120 | t1 = ont.create_term("TST:001") 121 | t1.name = "first name" 122 | 123 | pickled = pickle.dumps(ont, protocol=protocol) 124 | unpickled = pickle.loads(pickled) 125 | 126 | self.assertEqual(ont.keys(), unpickled.keys()) 127 | self.assertEqual(ont["TST:001"], unpickled["TST:001"]) 128 | self.assertEqual(ont["TST:001"].name, unpickled["TST:001"].name) 129 | 130 | def test_memory_pickling_3(self): 131 | self._test_memory_pickling(3) 132 | 133 | def test_memory_pickling_4(self): 134 | self._test_memory_pickling(4) 135 | 136 | @unittest.skipIf(sys.version_info < (3, 8), "protocol 5 requires Python 3.8+") 137 | def test_memory_pickling_5(self): 138 | self._test_memory_pickling(5) 139 | 140 | # ------------------------------------------------------------------------ 141 | 142 | def _test_file_pickling(self, protocol): 143 | pickled = pickle.dumps(self.ms, protocol=protocol) 144 | unpickled = pickle.loads(pickled) 145 | self.assertEqual(list(self.ms.keys()), list(unpickled.keys())) 146 | for key in self.ms.terms(): 147 | term_ms, term_pickled = self.ms.get_term(key.id)._data(), unpickled.get_term(key.id)._data() 148 | self.assertEqual(term_ms, term_pickled) 149 | 150 | def test_file_pickling_3(self): 151 | self._test_file_pickling(3) 152 | 153 | def test_file_pickling_4(self): 154 | self._test_file_pickling(4) 155 | 156 | @unittest.skipIf(sys.version_info < (3, 8), "protocol 5 requires Python 3.8+") 157 | def test_file_pickling_5(self): 158 | self._test_file_pickling(5) 159 | -------------------------------------------------------------------------------- /tests/data/obographs/abox.json: -------------------------------------------------------------------------------- 1 | { 2 | "graphs" : [ { 3 | "nodes" : [ { 4 | "id" : "http://purl.obolibrary.org/obo/T/f1", 5 | "type" : "INDIVIDUAL" 6 | }, { 7 | "id" : "http://purl.obolibrary.org/obo/T/Male", 8 | "type" : "CLASS" 9 | }, { 10 | "id" : "http://purl.obolibrary.org/obo/T/m1", 11 | "type" : "INDIVIDUAL" 12 | }, { 13 | "id" : "http://purl.obolibrary.org/obo/T/Female", 14 | "type" : "CLASS" 15 | }, { 16 | "id" : "http://purl.obolibrary.org/obo/T/b", 17 | "type" : "INDIVIDUAL" 18 | } ], 19 | "edges" : [ { 20 | "sub" : "http://purl.obolibrary.org/obo/T/Female", 21 | "pred" : "is_a", 22 | "obj" : "http://purl.obolibrary.org/obo/T/Person" 23 | }, { 24 | "sub" : "http://purl.obolibrary.org/obo/T/Male", 25 | "pred" : "is_a", 26 | "obj" : "http://purl.obolibrary.org/obo/T/Person" 27 | }, { 28 | "sub" : "http://purl.obolibrary.org/obo/T/f1", 29 | "pred" : "type", 30 | "obj" : "http://purl.obolibrary.org/obo/T/Male" 31 | }, { 32 | "sub" : "http://purl.obolibrary.org/obo/T/m1", 33 | "pred" : "type", 34 | "obj" : "http://purl.obolibrary.org/obo/T/Female" 35 | }, { 36 | "sub" : "http://purl.obolibrary.org/obo/T/b", 37 | "pred" : "http://purl.obolibrary.org/obo/T/father-of", 38 | "obj" : "http://purl.obolibrary.org/obo/T/b1" 39 | }, { 40 | "sub" : "http://purl.obolibrary.org/obo/T/b", 41 | "pred" : "http://purl.obolibrary.org/obo/T/father-of", 42 | "obj" : "http://purl.obolibrary.org/obo/T/b2" 43 | }, { 44 | "sub" : "http://purl.obolibrary.org/obo/T/f1", 45 | "pred" : "http://purl.obolibrary.org/obo/T/father-of", 46 | "obj" : "http://purl.obolibrary.org/obo/T/b" 47 | }, { 48 | "sub" : "http://purl.obolibrary.org/obo/T/f1", 49 | "pred" : "http://purl.obolibrary.org/obo/T/father-of", 50 | "obj" : "http://purl.obolibrary.org/obo/T/c" 51 | }, { 52 | "sub" : "http://purl.obolibrary.org/obo/T/m1", 53 | "pred" : "http://purl.obolibrary.org/obo/T/mother-of", 54 | "obj" : "http://purl.obolibrary.org/obo/T/b" 55 | }, { 56 | "sub" : "http://purl.obolibrary.org/obo/T/m1", 57 | "pred" : "http://purl.obolibrary.org/obo/T/mother-of", 58 | "obj" : "http://purl.obolibrary.org/obo/T/c" 59 | }, { 60 | "sub" : "http://purl.obolibrary.org/obo/T/ancestor-of", 61 | "pred" : "subPropertyOf", 62 | "obj" : "http://purl.obolibrary.org/obo/T/genealogically-related-to" 63 | }, { 64 | "sub" : "http://purl.obolibrary.org/obo/T/brother-of", 65 | "pred" : "subPropertyOf", 66 | "obj" : "http://purl.obolibrary.org/obo/T/sibling-of" 67 | }, { 68 | "sub" : "http://purl.obolibrary.org/obo/T/child-of", 69 | "pred" : "subPropertyOf", 70 | "obj" : "http://purl.obolibrary.org/obo/T/descendant-of" 71 | }, { 72 | "sub" : "http://purl.obolibrary.org/obo/T/descendant-of", 73 | "pred" : "subPropertyOf", 74 | "obj" : "http://purl.obolibrary.org/obo/T/genealogically-related-to" 75 | }, { 76 | "sub" : "http://purl.obolibrary.org/obo/T/father-of", 77 | "pred" : "subPropertyOf", 78 | "obj" : "http://purl.obolibrary.org/obo/T/parent-of" 79 | }, { 80 | "sub" : "http://purl.obolibrary.org/obo/T/grandfather-of", 81 | "pred" : "subPropertyOf", 82 | "obj" : "http://purl.obolibrary.org/obo/T/grandparent-of" 83 | }, { 84 | "sub" : "http://purl.obolibrary.org/obo/T/grandmother-of", 85 | "pred" : "subPropertyOf", 86 | "obj" : "http://purl.obolibrary.org/obo/T/grandparent-of" 87 | }, { 88 | "sub" : "http://purl.obolibrary.org/obo/T/grandparent-of", 89 | "pred" : "subPropertyOf", 90 | "obj" : "http://purl.obolibrary.org/obo/T/ancestor-of" 91 | }, { 92 | "sub" : "http://purl.obolibrary.org/obo/T/mother-of", 93 | "pred" : "subPropertyOf", 94 | "obj" : "http://purl.obolibrary.org/obo/T/parent-of" 95 | }, { 96 | "sub" : "http://purl.obolibrary.org/obo/T/parent-of", 97 | "pred" : "subPropertyOf", 98 | "obj" : "http://purl.obolibrary.org/obo/T/ancestor-of" 99 | }, { 100 | "sub" : "http://purl.obolibrary.org/obo/T/sibling-of", 101 | "pred" : "subPropertyOf", 102 | "obj" : "http://purl.obolibrary.org/obo/T/genealogically-related-to" 103 | }, { 104 | "sub" : "http://purl.obolibrary.org/obo/T/sister-of", 105 | "pred" : "subPropertyOf", 106 | "obj" : "http://purl.obolibrary.org/obo/T/sibling-of" 107 | }, { 108 | "sub" : "http://purl.obolibrary.org/obo/T/ancestor-of", 109 | "pred" : "inverseOf", 110 | "obj" : "http://purl.obolibrary.org/obo/T/descendant-of" 111 | }, { 112 | "sub" : "http://purl.obolibrary.org/obo/T/child-of", 113 | "pred" : "inverseOf", 114 | "obj" : "http://purl.obolibrary.org/obo/T/parent-of" 115 | } ], 116 | "id" : "http://purl.obolibrary.org/obo/T", 117 | "meta" : { 118 | "subsets" : [ ], 119 | "xrefs" : [ ], 120 | "basicPropertyValues" : [ ] 121 | }, 122 | "equivalentNodesSets" : [ ], 123 | "logicalDefinitionAxioms" : [ ], 124 | "domainRangeAxioms" : [ { 125 | "predicateId" : "http://purl.obolibrary.org/obo/T/brother-of", 126 | "domainClassIds" : [ "http://purl.obolibrary.org/obo/T/Male" ] 127 | }, { 128 | "predicateId" : "http://purl.obolibrary.org/obo/T/father-of", 129 | "domainClassIds" : [ "http://purl.obolibrary.org/obo/T/Male" ] 130 | }, { 131 | "predicateId" : "http://purl.obolibrary.org/obo/T/mother-of", 132 | "domainClassIds" : [ "http://purl.obolibrary.org/obo/T/Female" ] 133 | }, { 134 | "predicateId" : "http://purl.obolibrary.org/obo/T/sister-of", 135 | "domainClassIds" : [ "http://purl.obolibrary.org/obo/T/Female" ] 136 | } ], 137 | "propertyChainAxioms" : [ { 138 | "predicateId" : "http://purl.obolibrary.org/obo/T/sibling-of", 139 | "chainPredicateIds" : [ "http://purl.obolibrary.org/obo/T/child-of", "http://purl.obolibrary.org/obo/T/parent-of" ] 140 | }, { 141 | "predicateId" : "http://purl.obolibrary.org/obo/T/grandmother-of", 142 | "chainPredicateIds" : [ "http://purl.obolibrary.org/obo/T/mother-of", "http://purl.obolibrary.org/obo/T/parent-of" ] 143 | }, { 144 | "predicateId" : "http://purl.obolibrary.org/obo/T/grandfather-of", 145 | "chainPredicateIds" : [ "http://purl.obolibrary.org/obo/T/parent-of", "http://purl.obolibrary.org/obo/T/parent-of" ] 146 | }, { 147 | "predicateId" : "http://purl.obolibrary.org/obo/T/grandparent-of", 148 | "chainPredicateIds" : [ "http://purl.obolibrary.org/obo/T/parent-of", "http://purl.obolibrary.org/obo/T/parent-of" ] 149 | } ] 150 | } ] 151 | } -------------------------------------------------------------------------------- /pronto/synonym.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import functools 4 | import typing 5 | import weakref 6 | from typing import Iterable, Optional, Set 7 | 8 | from .utils.meta import roundrepr, typechecked 9 | from .xref import Xref 10 | 11 | if typing.TYPE_CHECKING: 12 | from .ontology import Ontology 13 | 14 | 15 | _SCOPES = frozenset({"EXACT", "RELATED", "BROAD", "NARROW", None}) 16 | __all__ = ["SynonymType", "SynonymData", "Synonym"] 17 | 18 | 19 | @roundrepr 20 | @functools.total_ordering 21 | class SynonymType(object): 22 | """A user-defined synonym type.""" 23 | 24 | id: str 25 | description: str 26 | scope: Optional[str] 27 | 28 | __slots__ = ("__weakref__", "id", "description", "scope") 29 | 30 | @typechecked() 31 | def __init__(self, id: str, description: str, scope: Optional[str] = None): 32 | if scope not in _SCOPES: 33 | raise ValueError(f"invalid synonym scope: {scope}") 34 | self.id = id 35 | self.description = description 36 | self.scope = scope 37 | 38 | def __eq__(self, other): 39 | if isinstance(other, SynonymType): 40 | return self.id == other.id 41 | return False 42 | 43 | def __lt__(self, other): 44 | if isinstance(other, SynonymType): 45 | if self.id < other.id: 46 | return True 47 | return self.id == other.id and self.description < other.description 48 | return NotImplemented 49 | 50 | def __hash__(self): 51 | return hash((SynonymType, self.id)) 52 | 53 | 54 | @roundrepr 55 | @functools.total_ordering 56 | class SynonymData(object): 57 | """Internal data storage of `Synonym` information.""" 58 | 59 | description: str 60 | scope: Optional[str] 61 | type: Optional[str] 62 | xrefs: Set[Xref] 63 | 64 | __slots__ = ("__weakref__", "description", "type", "xrefs", "scope") 65 | 66 | def __eq__(self, other): 67 | if isinstance(other, SynonymData): 68 | return self.description == other.description and self.scope == other.scope 69 | return False 70 | 71 | def __lt__(self, other): # FIXME? 72 | if not isinstance(other, SynonymData): 73 | return NotImplemented 74 | if self.type is not None and other.type is not None: 75 | return (self.description, self.scope, self.type, frozenset(self.xrefs)) < ( 76 | self.description, 77 | self.scope, 78 | other.type, 79 | frozenset(other.xrefs), 80 | ) 81 | else: 82 | return (self.description, self.scope, frozenset(self.xrefs)) < ( 83 | self.description, 84 | self.scope, 85 | frozenset(other.xrefs), 86 | ) 87 | 88 | def __hash__(self): 89 | return hash((self.description, self.scope)) 90 | 91 | def __init__( 92 | self, 93 | description: str, 94 | scope: Optional[str] = None, 95 | type: Optional[str] = None, 96 | xrefs: Optional[Iterable[Xref]] = None, 97 | ): 98 | if scope not in _SCOPES: 99 | raise ValueError(f"invalid synonym scope: {scope}") 100 | self.description = description 101 | self.scope = scope 102 | self.type = type 103 | self.xrefs = set(xrefs) if xrefs is not None else set() 104 | 105 | 106 | @functools.total_ordering 107 | class Synonym(object): 108 | """A synonym for an entity, with respect to the OBO terminology.""" 109 | 110 | __ontology: "Ontology" 111 | 112 | if typing.TYPE_CHECKING: 113 | 114 | __data: "weakref.ReferenceType[SynonymData]" 115 | 116 | def __init__(self, ontology: "Ontology", data: "SynonymData"): 117 | self.__data = weakref.ref(data) 118 | self.__ontology = ontology 119 | 120 | def _data(self) -> SynonymData: 121 | rdata = self.__data() 122 | if rdata is None: 123 | raise RuntimeError("synonym data was deallocated") 124 | return rdata 125 | 126 | else: 127 | 128 | __slots__: Iterable[str] = ("__weakref__", "__ontology", "_data") 129 | 130 | def __init__(self, ontology: "Ontology", syndata: "SynonymData"): 131 | if syndata.type is not None: 132 | if not any(t.id == syndata.type for t in ontology.synonym_types()): 133 | raise ValueError(f"undeclared synonym type: {syndata.type}") 134 | self._data = weakref.ref(syndata) 135 | self.__ontology = ontology 136 | 137 | def __eq__(self, other: object): 138 | if isinstance(other, Synonym): 139 | return self._data() == other._data() 140 | return False 141 | 142 | def __lt__(self, other: object): 143 | if not isinstance(other, Synonym): 144 | return False 145 | return self._data().__lt__(other._data()) 146 | 147 | def __hash__(self): 148 | return hash(self._data()) 149 | 150 | def __repr__(self): 151 | return roundrepr.make( 152 | "Synonym", 153 | self.description, 154 | scope=(self.scope, None), 155 | type=(self.type, None), 156 | xrefs=(self.xrefs, set()), 157 | ) 158 | 159 | @property 160 | def description(self) -> str: 161 | return self._data().description 162 | 163 | @description.setter # type: ignore 164 | @typechecked(property=True) 165 | def description(self, description: str) -> None: 166 | self._data().description = description 167 | 168 | @property 169 | def type(self) -> Optional[SynonymType]: 170 | ontology, syndata = self.__ontology, self._data() 171 | if syndata.type is not None: 172 | return next(t for t in ontology.synonym_types() if t.id == syndata.type) 173 | return None 174 | 175 | @type.setter # type: ignore 176 | @typechecked(property=True) 177 | def type(self, type_: Optional[SynonymType]) -> None: 178 | synonyms: Iterable[SynonymType] = self.__ontology.synonym_types() 179 | if type_ is not None and not any(type_.id == s.id for s in synonyms): 180 | raise ValueError(f"undeclared synonym type: {type_.id}") 181 | self._data().type = type_.id if type_ is not None else None 182 | 183 | @property 184 | def scope(self) -> Optional[str]: 185 | return self._data().scope 186 | 187 | @scope.setter # type: ignore 188 | @typechecked(property=True) 189 | def scope(self, scope: Optional[str]): 190 | if scope not in _SCOPES: 191 | raise ValueError(f"invalid synonym scope: {scope}") 192 | self._data().scope = scope 193 | 194 | @property 195 | def xrefs(self) -> Set[Xref]: 196 | return self._data().xrefs 197 | 198 | @xrefs.setter 199 | def xrefs(self, xrefs: Iterable[Xref]): 200 | self._data().xrefs = set(xrefs) 201 | -------------------------------------------------------------------------------- /pronto/utils/meta.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import contextlib 3 | import functools 4 | import inspect 5 | import itertools 6 | import sys 7 | import typing 8 | from dataclasses import dataclass as _dataclass 9 | from typing import Callable, Iterator, List, Tuple, Type 10 | 11 | T = typing.TypeVar("T") 12 | F = typing.TypeVar("F", bound=Callable[..., object]) 13 | 14 | 15 | def dataclass(init: bool = True, slots: bool = False, weakref_slot: bool = False): 16 | if sys.version_info >= (3, 11): 17 | return _dataclass(init=init, slots=slots, weakref_slot=weakref_slot) 18 | else: 19 | return _dataclass(init=init) 20 | 21 | 22 | class typechecked(object): 23 | 24 | _disable = 0 25 | 26 | if sys.version_info >= (3, 7): 27 | Set = set 28 | FrozenSet = frozenset 29 | else: 30 | Set = typing.Set 31 | FrozenSet = typing.FrozenSet 32 | 33 | @classmethod 34 | @typing.no_type_check 35 | def check_type(cls, hint: object, value: object) -> Tuple[bool, str]: 36 | # None: check if None 37 | if hint is None.__class__: 38 | return (value is None, "None") 39 | # typing.Any is always true 40 | if typing.cast(str, getattr(hint, "_name", None)) == "typing.Any": 41 | return (True, "any object") 42 | # typing.Set needs to check member types 43 | if typing.cast(object, getattr(hint, "__origin__", None)) is cls.Set: 44 | if not isinstance(value, collections.abc.MutableSet): 45 | return (False, f"set of { hint.__args__ }") 46 | for arg in value: 47 | well_typed, type_name = cls.check_type(hint.__args__[0], arg) 48 | if not well_typed: 49 | return (False, f"set of { type_name }") 50 | return (True, f"set of { hint.__args__ }") 51 | # typing.FrozenSet needs to check member types 52 | if typing.cast(object, getattr(hint, "__origin__", None)) is cls.FrozenSet: 53 | if not isinstance(value, collections.abc.Set): 54 | return (False, f"frozen set of { hint.__args__ }") 55 | for arg in value: 56 | well_typed, type_name = cls.check_type(hint.__args__[0], arg) 57 | if not well_typed: 58 | return (False, f"frozenset of { type_name }") 59 | return (True, f"frozenset of { hint.__args__[0] }") 60 | # typing.Union needs to be a valid type 61 | if getattr(hint, "__origin__", None) is typing.Union: 62 | results = {} 63 | for arg in hint.__args__: 64 | results[arg] = cls.check_type(arg, value) 65 | ok = any(ok for ok, name in results.values()) 66 | return (ok, ", ".join(name for ok, name in results.values())) 67 | # direct type annotation: simply do an instance check 68 | if isinstance(hint, type): 69 | return (isinstance(value, hint), hint.__name__) 70 | return (False, "") 71 | 72 | @classmethod 73 | @contextlib.contextmanager 74 | def disabled(cls) -> Iterator[None]: 75 | cls._disable += 1 76 | try: 77 | yield 78 | finally: 79 | cls._disable -= 1 80 | 81 | def __init__(self, property: bool = False) -> None: 82 | self.property = property 83 | 84 | def __call__(self, func: F) -> F: 85 | if not __debug__: 86 | return func 87 | 88 | hints = typing.get_type_hints(func) 89 | signature = inspect.signature(func) 90 | 91 | @functools.wraps(func) 92 | def newfunc(*args, **kwargs): 93 | if not self._disable: 94 | callargs = signature.bind(*args, **kwargs).arguments 95 | for name, value in callargs.items(): 96 | if name in hints: 97 | well_typed, type_name = self.check_type(hints[name], value) 98 | if not well_typed: 99 | msg = f"'{{}}' must be {type_name}, not {type(value).__name__}" 100 | if self.property: 101 | raise TypeError(msg.format(func.__name__)) 102 | else: 103 | raise TypeError(msg.format(name)) 104 | return func(*args, **kwargs) 105 | 106 | return newfunc # type: ignore 107 | 108 | 109 | class roundrepr(object): 110 | """A class-decorator to build a minimal `__repr__` method that roundtrips.""" 111 | 112 | @staticmethod 113 | def make(class_name: str, *args: object, **kwargs: Tuple[object, object]) -> str: 114 | """Generate a repr string. 115 | 116 | Positional arguments should be the positional arguments used to 117 | construct the class. Keyword arguments should consist of tuples of 118 | the attribute value and default. If the value is the default, then 119 | it won't be rendered in the output. 120 | 121 | Example: 122 | >>> from pronto.utils.meta import roundrepr 123 | >>> class MyClass(object): 124 | ... def __init__(self, name=None): 125 | ... self.name = name 126 | ... def __repr__(self): 127 | ... return roundrepr.make('MyClass', 'foo', name=(self.name, None)) 128 | >>> MyClass('Will') 129 | MyClass('foo', name='Will') 130 | >>> MyClass(None) 131 | MyClass('foo') 132 | 133 | Note: 134 | This functions uses code developed by `Will McGugan `_ 135 | for `PyFilesystem2 `_ 136 | 137 | """ 138 | arguments: List[str] = [repr(arg) for arg in args] 139 | arguments.extend( 140 | [ 141 | "{}={!r}".format(name, value) 142 | for name, (value, default) in sorted(kwargs.items()) 143 | if value != default and value 144 | ] 145 | ) 146 | return "{}({})".format(class_name, ", ".join(arguments)) 147 | 148 | def __new__(self, cls: Type[T]) -> T: # type: ignore 149 | obj = super().__new__(self) 150 | obj.__init__() 151 | if isinstance(cls, type): 152 | return obj(cls) 153 | return obj 154 | 155 | def __call__(self, cls: T) -> T: 156 | 157 | # Extract signature of `__init__` 158 | sig = inspect.signature(cls.__init__) # type: ignore 159 | if not all(p.kind is p.POSITIONAL_OR_KEYWORD for p in sig.parameters.values()): 160 | raise TypeError( 161 | "cannot use `roundrepr` on a class with variadic `__init__`" 162 | ) 163 | 164 | # Derive the __repr__ implementation 165 | def __repr__(self_): 166 | """Return a `repr` string that roundtrips. Computed by @roundrepr.""" 167 | args, kwargs = [], {} 168 | for name, param in itertools.islice(sig.parameters.items(), 1, None): 169 | if param.default is inspect.Parameter.empty: 170 | args.append(getattr(self_, name)) 171 | else: 172 | kwargs[name] = (getattr(self_, name), param.default) 173 | return self.make(cls.__name__, *args, **kwargs) 174 | 175 | # Hotpatch the class and return it 176 | cls.__repr__ = __repr__ # type: ignore 177 | return cls 178 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Imports ----------------------------------------------------------------- 8 | 9 | import datetime 10 | import os 11 | import re 12 | import semantic_version 13 | import shutil 14 | import sys 15 | 16 | # -- Path setup -------------------------------------------------------------- 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | 22 | docssrc_dir = os.path.abspath(os.path.join(__file__, "..")) 23 | project_dir = os.path.dirname(os.path.dirname(docssrc_dir)) 24 | sys.path.insert(0, project_dir) 25 | 26 | # -- Project information ----------------------------------------------------- 27 | 28 | import pronto 29 | 30 | project = pronto.__name__ 31 | author = re.match('(.*) <.*>', pronto.__author__).group(1) 32 | year = datetime.date.today().year 33 | copyright = f'2016-{year}, {author}' 34 | 35 | # The parsed semantic version 36 | semver = semantic_version.Version.coerce(pronto.__version__) 37 | # The short X.Y version 38 | version = "{v.major}.{v.minor}.{v.patch}".format(v=semver) 39 | # The full version, including alpha/beta/rc tags 40 | release = str(semver) 41 | 42 | # -- General configuration --------------------------------------------------- 43 | 44 | # Add any Sphinx extension module names here, as strings. They can be 45 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 46 | # ones. 47 | extensions = [ 48 | "sphinx.ext.autodoc", 49 | "sphinx.ext.autosummary", 50 | "sphinx.ext.doctest", 51 | "sphinx.ext.intersphinx", 52 | "sphinx.ext.napoleon", 53 | "sphinx.ext.todo", 54 | "sphinx.ext.coverage", 55 | "sphinx.ext.mathjax", 56 | "sphinx.ext.ifconfig", 57 | "sphinx.ext.viewcode", 58 | "sphinx.ext.githubpages", 59 | "sphinx_design", 60 | "nbsphinx", 61 | "recommonmark", 62 | "IPython.sphinxext.ipython_console_highlighting", 63 | ] 64 | 65 | # Add any paths that contain templates here, relative to this directory. 66 | templates_path = ['_templates'] 67 | 68 | # The suffix(es) of source filenames. 69 | # You can specify multiple suffix as a list of string: 70 | # 71 | # source_suffix = ['.rst', '.md'] 72 | source_suffix = [".rst", ".md"] 73 | 74 | # The master toctree document. 75 | master_doc = "index" 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | # This pattern also affects html_static_path and html_extra_path . 80 | exclude_patterns = ["_build", "**.ipynb_checkpoints"] 81 | 82 | # The name of the Pygments (syntax highlighting) style to use. 83 | pygments_style = "monokailight" 84 | 85 | # The name of the default role for inline references 86 | default_role = "py:obj" 87 | 88 | 89 | # -- Options for HTML output ------------------------------------------------- 90 | 91 | # The theme to use for HTML and HTML Help pages. See the documentation for 92 | # a list of builtin themes. 93 | # 94 | html_theme = "pydata_sphinx_theme" 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ['_static/js', '_static/bibtex', '_static/css', '_static/json'] 100 | html_js_files = ["custom-icon.js"] 101 | html_css_files = ["custom.css"] 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | # 107 | html_theme_options = { 108 | "show_toc_level": 2, 109 | "use_edit_page_button": True, 110 | "icon_links": [ 111 | { 112 | "name": "GitHub", 113 | "url": "https://github.com/althonos/pronto", 114 | "icon": "fa-brands fa-github", 115 | }, 116 | { 117 | "name": "PyPI", 118 | "url": "https://pypi.org/project/pronto", 119 | "icon": "fa-custom fa-pypi", 120 | }, 121 | ], 122 | "logo": { 123 | "text": "Pronto", 124 | # "image_light": "_images/logo.png", 125 | # "image_dark": "_images/logo.png", 126 | }, 127 | "navbar_start": ["navbar-logo", "version-switcher"], 128 | "navbar_align": "left", 129 | "footer_start": ["copyright"], 130 | "footer_center": ["sphinx-version"], 131 | "switcher": { 132 | "json_url": "https://pronto.readthedocs.io/en/latest/_static/switcher.json", 133 | "version_match": version, 134 | } 135 | } 136 | 137 | html_context = { 138 | "github_user": "althonos", 139 | "github_repo": "pronto", 140 | "github_version": "main", 141 | "doc_path": "docs", 142 | } 143 | 144 | html_favicon = '_images/favicon.ico' 145 | 146 | # -- Options for HTMLHelp output --------------------------------------------- 147 | 148 | # Output file base name for HTML help builder. 149 | htmlhelp_basename = pronto.__name__ 150 | 151 | 152 | # -- Extension configuration ------------------------------------------------- 153 | 154 | # -- Options for imgmath extension ------------------------------------------- 155 | 156 | imgmath_image_format = "svg" 157 | 158 | # -- Options for napoleon extension ------------------------------------------ 159 | 160 | napoleon_include_init_with_doc = True 161 | napoleon_include_special_with_doc = True 162 | napoleon_include_private_with_doc = True 163 | napoleon_use_admonition_for_examples = True 164 | napoleon_use_admonition_for_notes = True 165 | napoleon_use_admonition_for_references = True 166 | napoleon_use_rtype = False 167 | 168 | # -- Options for autodoc extension ------------------------------------------- 169 | 170 | autoclass_content = "class" 171 | autodoc_member_order = 'bysource' 172 | autosummary_generate = ['api/index'] 173 | 174 | # -- Options for intersphinx extension --------------------------------------- 175 | 176 | # Example configuration for intersphinx: refer to the Python standard library. 177 | intersphinx_mapping = { 178 | "python": ("https://docs.python.org/3/", None), 179 | "fastobo": ("https://fastobo.readthedocs.io/en/latest/", None), 180 | } 181 | 182 | # -- Options for todo extension ---------------------------------------------- 183 | 184 | # If true, `todo` and `todoList` produce output, else they produce nothing. 185 | todo_include_todos = True 186 | 187 | # -- Options for recommonmark extension -------------------------------------- 188 | 189 | source_suffix = { 190 | '.rst': 'restructuredtext', 191 | '.txt': 'markdown', 192 | '.md': 'markdown', 193 | } 194 | 195 | # -- Options for nbsphinx extension ------------------------------------------ 196 | 197 | nbsphinx_execute = 'auto' 198 | 199 | # -- Options for extlinks extension ------------------------------------------ 200 | 201 | extlinks = { 202 | 'doi': ('https://doi.org/%s', 'doi:%s'), 203 | 'pmid': ('https://pubmed.ncbi.nlm.nih.gov/%s', 'PMID:%s'), 204 | 'pmc': ('https://www.ncbi.nlm.nih.gov/pmc/articles/PMC%s', 'PMC%s'), 205 | 'isbn': ('https://www.worldcat.org/isbn/%s', 'ISBN:%s'), 206 | 'wiki': ('https://en.wikipedia.org/wiki/%s', '%s'), 207 | } 208 | -------------------------------------------------------------------------------- /tests/data/obographs/equivNodeSetTest.json: -------------------------------------------------------------------------------- 1 | { 2 | "graphs" : [ { 3 | "nodes" : [ { 4 | "id" : "http://purl.obolibrary.org/obo/DOID_0001816", 5 | "meta" : { 6 | "definition" : { 7 | "val" : "A malignant Vascular tumor that results_in rapidly proliferating, extensively infiltrating anaplastic cells derived_from blood vessels and derived_from the lining of irregular blood-filled spaces.", 8 | "xrefs" : [ "url:http://emedicine.medscape.com/article/276512-overview", "url:http://en.wikipedia.org/wiki/Hemangiosarcoma", "url:http://www.ncbi.nlm.nih.gov/pubmed/23327728" ] 9 | }, 10 | "subsets" : [ "http://purl.obolibrary.org/obo/TEMP#NCIthesaurus" ], 11 | "xrefs" : [ { 12 | "val" : "MESH:D006394" 13 | }, { 14 | "val" : "NCIT:C3088" 15 | }, { 16 | "val" : "NCIT:C9275" 17 | }, { 18 | "val" : "Orphanet:263413" 19 | }, { 20 | "val" : "SCTID:33176006" 21 | }, { 22 | "val" : "SCTID:39000009" 23 | }, { 24 | "val" : "SCTID:403977003" 25 | }, { 26 | "val" : "UMLS:C0018923" 27 | }, { 28 | "val" : "UMLS:C0854893" 29 | } ], 30 | "synonyms" : [ { 31 | "pred" : "hasExactSynonym", 32 | "val" : "hemangiosarcoma", 33 | "xrefs" : [ ] 34 | } ], 35 | "basicPropertyValues" : [ { 36 | "pred" : "http://www.geneontology.org/formats/oboInOwl#hasAlternativeId", 37 | "val" : "DOID:267" 38 | }, { 39 | "pred" : "http://www.geneontology.org/formats/oboInOwl#hasAlternativeId", 40 | "val" : "DOID:4508" 41 | } ] 42 | }, 43 | "type" : "CLASS", 44 | "lbl" : "angiosarcoma" 45 | }, { 46 | "id" : "http://purl.obolibrary.org/obo/DOID_267", 47 | "meta" : { 48 | "basicPropertyValues" : [ { 49 | "pred" : "http://purl.obolibrary.org/obo/IAO_0000231", 50 | "val" : "http://purl.obolibrary.org/obo/IAO_0000227" 51 | }, { 52 | "pred" : "http://purl.obolibrary.org/obo/IAO_0100001", 53 | "val" : "http://purl.obolibrary.org/obo/DOID_0001816" 54 | } ], 55 | "deprecated" : true 56 | }, 57 | "type" : "CLASS" 58 | }, { 59 | "id" : "http://purl.obolibrary.org/obo/DOID_4508", 60 | "meta" : { 61 | "basicPropertyValues" : [ { 62 | "pred" : "http://purl.obolibrary.org/obo/IAO_0000231", 63 | "val" : "http://purl.obolibrary.org/obo/IAO_0000227" 64 | }, { 65 | "pred" : "http://purl.obolibrary.org/obo/IAO_0100001", 66 | "val" : "http://purl.obolibrary.org/obo/DOID_0001816" 67 | } ], 68 | "deprecated" : true 69 | }, 70 | "type" : "CLASS" 71 | }, { 72 | "id" : "http://purl.obolibrary.org/obo/IAO_0000115", 73 | "type" : "PROPERTY", 74 | "lbl" : "definition" 75 | }, { 76 | "id" : "http://purl.obolibrary.org/obo/NCIT_C121671", 77 | "meta" : { 78 | "definition" : { 79 | "val" : "An angiosarcoma that arises from the soft tissues, usually in the deep muscles of the lower extremities, retroperitoneum, mediastinum, and mesentery.", 80 | "xrefs" : [ ] 81 | }, 82 | "synonyms" : [ { 83 | "pred" : "hasExactSynonym", 84 | "val" : "Angiosarcoma of Soft Tissue", 85 | "xrefs" : [ ] 86 | } ] 87 | }, 88 | "type" : "CLASS", 89 | "lbl" : "Angiosarcoma of Soft Tissue" 90 | }, { 91 | "id" : "http://purl.obolibrary.org/obo/NCIT_C3088", 92 | "meta" : { 93 | "definition" : { 94 | "val" : "A malignant tumor arising from the endothelial cells of the blood vessels. Microscopically, it is characterized by frequently open vascular anastomosing and branching channels. The malignant cells that line the vascular channels are spindle or epithelioid and often display hyperchromatic nuclei. Angiosarcomas most frequently occur in the skin and breast. Patients with long-standing lymphedema are at increased risk of developing angiosarcoma.", 95 | "xrefs" : [ ] 96 | }, 97 | "synonyms" : [ { 98 | "pred" : "hasExactSynonym", 99 | "val" : "Angiosarcoma", 100 | "xrefs" : [ ] 101 | }, { 102 | "pred" : "hasExactSynonym", 103 | "val" : "HEMANGIOSARCOMA, MALIGNANT", 104 | "xrefs" : [ ] 105 | }, { 106 | "pred" : "hasExactSynonym", 107 | "val" : "Hemangiosarcoma", 108 | "xrefs" : [ ] 109 | }, { 110 | "pred" : "hasExactSynonym", 111 | "val" : "Hemangiosarcoma", 112 | "xrefs" : [ ] 113 | }, { 114 | "pred" : "hasExactSynonym", 115 | "val" : "Malignant Angioendothelioma", 116 | "xrefs" : [ ] 117 | }, { 118 | "pred" : "hasExactSynonym", 119 | "val" : "Malignant Hemangioendothelioma", 120 | "xrefs" : [ ] 121 | }, { 122 | "pred" : "hasExactSynonym", 123 | "val" : "angiosarcoma", 124 | "xrefs" : [ ] 125 | }, { 126 | "pred" : "hasExactSynonym", 127 | "val" : "hemangiosarcoma", 128 | "xrefs" : [ ] 129 | } ] 130 | }, 131 | "type" : "CLASS", 132 | "lbl" : "Angiosarcoma" 133 | }, { 134 | "id" : "http://purl.obolibrary.org/obo/Orphanet_263413", 135 | "meta" : { 136 | "subsets" : [ "http://purl.obolibrary.org/obo/Orphanet_377788" ], 137 | "xrefs" : [ { 138 | "val" : "MedDRA:10002476" 139 | } ] 140 | }, 141 | "type" : "CLASS", 142 | "lbl" : "Angiosarcoma" 143 | }, { 144 | "id" : "http://www.geneontology.org/formats/oboInOwl#hasAlternativeId", 145 | "type" : "PROPERTY", 146 | "lbl" : "has_alternative_id" 147 | }, { 148 | "id" : "http://www.geneontology.org/formats/oboInOwl#hasDbXref", 149 | "type" : "PROPERTY", 150 | "lbl" : "database_cross_reference" 151 | }, { 152 | "id" : "http://www.geneontology.org/formats/oboInOwl#hasExactSynonym", 153 | "type" : "PROPERTY", 154 | "lbl" : "has_exact_synonym" 155 | }, { 156 | "id" : "http://www.geneontology.org/formats/oboInOwl#inSubset", 157 | "type" : "PROPERTY", 158 | "lbl" : "in_subset" 159 | } ], 160 | "edges" : [ { 161 | "sub" : "http://purl.obolibrary.org/obo/DOID_0001816", 162 | "pred" : "is_a", 163 | "obj" : "http://purl.obolibrary.org/obo/DOID_1115" 164 | }, { 165 | "sub" : "http://purl.obolibrary.org/obo/NCIT_C121671", 166 | "pred" : "is_a", 167 | "obj" : "http://purl.obolibrary.org/obo/NCIT_C3088" 168 | }, { 169 | "sub" : "http://purl.obolibrary.org/obo/NCIT_C121671", 170 | "pred" : "is_a", 171 | "obj" : "http://purl.obolibrary.org/obo/NCIT_C9306" 172 | }, { 173 | "sub" : "http://purl.obolibrary.org/obo/NCIT_C3088", 174 | "pred" : "is_a", 175 | "obj" : "http://purl.obolibrary.org/obo/NCIT_C8538" 176 | }, { 177 | "sub" : "http://purl.obolibrary.org/obo/NCIT_C3088", 178 | "pred" : "is_a", 179 | "obj" : "http://purl.obolibrary.org/obo/NCIT_C9118" 180 | }, { 181 | "sub" : "http://purl.obolibrary.org/obo/Orphanet_263413", 182 | "pred" : "is_a", 183 | "obj" : "http://purl.obolibrary.org/obo/Orphanet_3394" 184 | } ], 185 | "id" : "http://purl.obolibrary.org/obo/TEMP", 186 | "meta" : { 187 | "subsets" : [ ], 188 | "xrefs" : [ ], 189 | "basicPropertyValues" : [ ] 190 | }, 191 | "equivalentNodesSets" : [ { 192 | "nodeIds" : [ "http://purl.obolibrary.org/obo/NCIT_C3088", "http://purl.obolibrary.org/obo/DOID_0001816" ] 193 | }, { 194 | "nodeIds" : [ "http://purl.obolibrary.org/obo/Orphanet_263413", "http://purl.obolibrary.org/obo/DOID_0001816" ] 195 | } ], 196 | "logicalDefinitionAxioms" : [ ], 197 | "domainRangeAxioms" : [ ], 198 | "propertyChainAxioms" : [ ] 199 | } ] 200 | } -------------------------------------------------------------------------------- /tests/test_entity.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import gzip 3 | import io 4 | import contextlib 5 | import os 6 | import re 7 | import shutil 8 | import sys 9 | import textwrap 10 | import unittest 11 | import warnings 12 | from unittest import mock 13 | 14 | import pronto 15 | from pronto.entity import EntitySet 16 | 17 | from . import utils 18 | 19 | 20 | class _TestEntitySet(metaclass=abc.ABCMeta): 21 | 22 | @abc.abstractmethod 23 | def create_entity(self, ont): 24 | return NotImplemented 25 | 26 | def setUp(self): 27 | self.ont = ont = pronto.Ontology() 28 | self.t1 = self.create_entity(ont, "TST:001") 29 | self.t2 = self.create_entity(ont, "TST:002") 30 | self.t3 = self.create_entity(ont, "TST:003") 31 | self.t4 = self.create_entity(ont, "TST:004") 32 | 33 | def test_contains(self): 34 | s = EntitySet({self.t1, self.t2}) 35 | self.assertIn(self.t1, s) 36 | self.assertIn(self.t2, s) 37 | self.assertNotIn(self.t3, s) 38 | self.assertIn(self.ont[self.t1.id], s) 39 | self.assertIn(self.ont[self.t2.id], s) 40 | self.assertNotIn(self.ont[self.t3.id], s) 41 | 42 | @unittest.skipUnless(__debug__, "no type checking in optimized mode") 43 | def test_init_typechecked(self): 44 | self.assertRaises(TypeError, EntitySet, {1, 2}) 45 | self.assertRaises(TypeError, EntitySet, {"a", "b"}) 46 | self.assertRaises(TypeError, EntitySet, {self.t1, "a"}) 47 | 48 | def test_add_empty(self): 49 | s1 = EntitySet() 50 | self.assertEqual(len(s1), 0) 51 | 52 | s1.add(self.t1) 53 | s1.add(self.t2) 54 | self.assertEqual(len(s1), 2) 55 | 56 | self.assertIn(s1.pop(), {self.t1, self.t2}) 57 | self.assertIn(s1.pop(), {self.t1, self.t2}) 58 | 59 | def test_and(self): 60 | s1 = EntitySet((self.t2, self.t1)) 61 | s = s1 & EntitySet((self.t2, self.t3)) 62 | self.assertEqual(sorted(s.ids), [self.t2.id]) 63 | self.assertIsNot(s._ontology, None) 64 | 65 | s2 = EntitySet((self.t2, self.t1)) 66 | s = s2 & {self.t2, self.t3} 67 | self.assertEqual(sorted(s.ids), [self.t2.id]) 68 | self.assertIsNot(s._ontology, None) 69 | 70 | s3 = EntitySet() 71 | s = s3 & {} 72 | self.assertEqual(sorted(s.ids), []) 73 | self.assertIs(s._ontology, None) 74 | 75 | def test_or(self): 76 | s1 = EntitySet((self.t2, self.t1)) 77 | s = s1 | EntitySet((self.t2, self.t3)) 78 | self.assertEqual(sorted(s.ids), [self.t1.id, self.t2.id, self.t3.id]) 79 | self.assertIsNot(s._ontology, None) 80 | 81 | s2 = EntitySet((self.t2, self.t1)) 82 | s = s2 | {self.t2, self.t3} 83 | self.assertEqual(sorted(s.ids), [self.t1.id, self.t2.id, self.t3.id]) 84 | self.assertIsNot(s._ontology, None) 85 | 86 | s3 = EntitySet() 87 | s = s3 | EntitySet((self.t2, self.t3)) 88 | self.assertEqual(sorted(s.ids), [self.t2.id, self.t3.id]) 89 | self.assertIsNot(s._ontology, None) 90 | 91 | s4 = EntitySet() 92 | s = s4 | {self.t2, self.t3} 93 | self.assertEqual(sorted(s.ids), [self.t2.id, self.t3.id]) 94 | self.assertIsNot(s._ontology, None) 95 | 96 | def test_sub(self): 97 | s1 = EntitySet((self.t2, self.t1)) 98 | s = s1 - EntitySet((self.t2, self.t3)) 99 | self.assertEqual(sorted(s.ids), [self.t1.id]) 100 | self.assertIsNot(s._ontology, None) 101 | 102 | s2 = EntitySet((self.t2, self.t1)) 103 | s = s2 - {self.t2, self.t3} 104 | self.assertEqual(sorted(s.ids), [self.t1.id]) 105 | self.assertIsNot(s._ontology, None) 106 | 107 | s3 = EntitySet() 108 | s = s3 - EntitySet((self.t2, self.t3)) 109 | self.assertEqual(sorted(s.ids), []) 110 | self.assertIs(s._ontology, None) 111 | 112 | s4 = EntitySet() 113 | s = s4 - {self.t2, self.t3} 114 | self.assertEqual(sorted(s.ids), []) 115 | self.assertIs(s._ontology, None) 116 | 117 | def test_xor(self): 118 | s1 = EntitySet((self.t2, self.t1)) 119 | s = s1 ^ EntitySet((self.t2, self.t3)) 120 | self.assertEqual(sorted(s.ids), [self.t1.id, self.t3.id]) 121 | self.assertIsNot(s._ontology, None) 122 | 123 | s2 = EntitySet((self.t2, self.t1)) 124 | s = s2 ^ {self.t2, self.t3} 125 | self.assertEqual(sorted(s.ids), [self.t1.id, self.t3.id]) 126 | self.assertIsNot(s._ontology, None) 127 | 128 | s3 = EntitySet() 129 | s = s3 ^ EntitySet((self.t2, self.t3)) 130 | self.assertEqual(sorted(s.ids), [self.t2.id, self.t3.id]) 131 | self.assertIsNot(s._ontology, None) 132 | 133 | s4 = EntitySet({self.t2, self.t3}) 134 | s = s4 ^ s4 135 | self.assertEqual(sorted(s.ids), []) 136 | self.assertIs(s._ontology, None) 137 | 138 | def test_inplace_and(self): 139 | s1 = EntitySet((self.t2, self.t1)) 140 | s1 &= EntitySet((self.t2, self.t3)) 141 | self.assertEqual(sorted(s1.ids), [self.t2.id]) 142 | self.assertIsNot(s1._ontology, None) 143 | 144 | s2 = EntitySet((self.t2, self.t1)) 145 | s2 &= {self.t2, self.t3} 146 | self.assertEqual(sorted(s2.ids), [self.t2.id]) 147 | self.assertIsNot(s2._ontology, None) 148 | 149 | s3 = EntitySet() 150 | s3 &= {} 151 | self.assertEqual(sorted(s3.ids), []) 152 | self.assertIs(s3._ontology, None) 153 | 154 | def test_inplace_or(self): 155 | s1 = EntitySet((self.t2, self.t1)) 156 | s1 |= EntitySet((self.t2, self.t3)) 157 | self.assertEqual(sorted(s1.ids), [self.t1.id, self.t2.id, self.t3.id]) 158 | self.assertIsNot(s1._ontology, None) 159 | 160 | s2 = EntitySet((self.t2, self.t1)) 161 | s2 |= {self.t2, self.t3} 162 | self.assertEqual(sorted(s2.ids), [self.t1.id, self.t2.id, self.t3.id]) 163 | self.assertIsNot(s2._ontology, None) 164 | 165 | s3 = EntitySet() 166 | s3 |= EntitySet((self.t2, self.t3)) 167 | self.assertEqual(sorted(s3.ids), [self.t2.id, self.t3.id]) 168 | self.assertIsNot(s3._ontology, None) 169 | 170 | s4 = EntitySet() 171 | s4 |= {self.t2, self.t3} 172 | self.assertEqual(sorted(s4.ids), [self.t2.id, self.t3.id]) 173 | self.assertIsNot(s4._ontology, None) 174 | 175 | def test_inplace_xor(self): 176 | s1 = EntitySet((self.t2, self.t1)) 177 | s = s1 ^ EntitySet((self.t2, self.t3)) 178 | self.assertEqual(sorted(s.ids), [self.t1.id, self.t3.id]) 179 | self.assertIsNot(s._ontology, None) 180 | 181 | s2 = EntitySet((self.t2, self.t1)) 182 | s = s2 ^ {self.t2, self.t3} 183 | self.assertEqual(sorted(s.ids), [self.t1.id, self.t3.id]) 184 | self.assertIsNot(s._ontology, None) 185 | 186 | s3 = EntitySet() 187 | s = s3 ^ EntitySet((self.t2, self.t3)) 188 | self.assertEqual(sorted(s.ids), [self.t2.id, self.t3.id]) 189 | self.assertIsNot(s._ontology, None) 190 | 191 | s4 = EntitySet({self.t2, self.t3}) 192 | s = s4 ^ s4 193 | self.assertEqual(sorted(s.ids), []) 194 | self.assertIs(s._ontology, None) 195 | 196 | def test_inplace_sub(self): 197 | s1 = EntitySet((self.t2, self.t1)) 198 | s1 -= EntitySet((self.t2, self.t3)) 199 | self.assertEqual(sorted(s1.ids), [self.t1.id]) 200 | self.assertIsNot(s1._ontology, None) 201 | 202 | s2 = EntitySet((self.t2, self.t1)) 203 | s2 -= {self.t2, self.t3} 204 | self.assertEqual(sorted(s2.ids), [self.t1.id]) 205 | self.assertIsNot(s2._ontology, None) 206 | 207 | s3 = EntitySet() 208 | s3 -= EntitySet((self.t2, self.t3)) 209 | self.assertEqual(sorted(s3.ids), []) 210 | self.assertIs(s3._ontology, None) 211 | 212 | s4 = EntitySet() 213 | s4 -= {self.t2, self.t3} 214 | self.assertEqual(sorted(s4.ids), []) 215 | self.assertIs(s4._ontology, None) 216 | 217 | 218 | class TestAlternateIDs(unittest.TestCase): 219 | 220 | def test_length(self): 221 | path = os.path.join(utils.DATADIR, "hp.obo") 222 | hp = pronto.Ontology(path, import_depth=0, threads=1) 223 | self.assertEqual(len(hp["HP:0009882"].alternate_ids), 10) 224 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `pronto` [![Stars](https://img.shields.io/github/stars/althonos/pronto.svg?style=social&maxAge=3600&label=Star)](https://github.com/althonos/pronto/stargazers) 2 | 3 | *A Python frontend to ontologies.* 4 | 5 | [![Actions](https://img.shields.io/github/actions/workflow/status/althonos/pronto/test.yml?branch=master&logo=github&style=flat-square&maxAge=300)](https://github.com/althonos/pronto/actions) 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square&maxAge=2678400)](https://choosealicense.com/licenses/mit/) 7 | [![Source](https://img.shields.io/badge/source-GitHub-303030.svg?maxAge=2678400&style=flat-square)](https://github.com/althonos/pronto/) 8 | [![Docs](https://img.shields.io/readthedocs/pronto?style=flat-square&maxAge=3600)](http://pronto.readthedocs.io/en/stable/?badge=stable) 9 | [![Coverage](https://img.shields.io/codecov/c/gh/althonos/pronto?style=flat-square&maxAge=3600)](https://codecov.io/gh/althonos/pronto/) 10 | [![Sanity](https://img.shields.io/codacy/grade/157b5fd24e5648ea80580f28399e79a4.svg?style=flat-square&maxAge=3600)](https://codacy.com/app/althonos/pronto) 11 | [![PyPI](https://img.shields.io/pypi/v/pronto.svg?style=flat-square&maxAge=3600)](https://pypi.python.org/pypi/pronto) 12 | [![Bioconda](https://img.shields.io/conda/vn/bioconda/pronto?style=flat-square&maxAge=3600)](https://anaconda.org/bioconda/pronto) 13 | [![Versions](https://img.shields.io/pypi/pyversions/pronto.svg?style=flat-square&maxAge=3600)](https://pypi.org/project/pronto/#files) 14 | [![Wheel](https://img.shields.io/pypi/wheel/pronto?style=flat-square&maxAge=3600)](https://pypi.org/project/pronto/#files) 15 | [![Changelog](https://img.shields.io/badge/keep%20a-changelog-8A0707.svg?maxAge=2678400&style=flat-square)](https://github.com/althonos/pronto/blob/master/CHANGELOG.md) 16 | [![GitHub issues](https://img.shields.io/github/issues/althonos/pronto.svg?style=flat-square&maxAge=600)](https://github.com/althonos/pronto/issues) 17 | [![DOI](https://img.shields.io/badge/doi-10.5281%2Fzenodo.595572-purple?style=flat-square&maxAge=2678400)](https://doi.org/10.5281/zenodo.595572) 18 | [![Downloads](https://img.shields.io/pypi/dm/pronto?style=flat-square&color=303f9f&maxAge=86400&label=downloads)](https://pepy.tech/project/pronto) 19 | 20 | ## 🚩 Table of Contents 21 | 22 | - [Overview](#%EF%B8%8F-overview) 23 | - [Supported Languages](#%EF%B8%8F-supported-languages) 24 | - [Installing](#-installing) 25 | - [Examples](#-examples) 26 | - [API Reference](#-api-reference) 27 | - [License](#-license) 28 | 29 | ## 🗺️ Overview 30 | 31 | Pronto is a Python library to parse, browse, create, and export 32 | ontologies, supporting several ontology languages and formats. It 33 | implement the specifications of the 34 | [Open Biomedical Ontologies 1.4](http://owlcollab.github.io/oboformat/doc/obo-syntax.html) 35 | in the form of an safe high-level interface. *If you're only interested in 36 | parsing OBO or OBO Graphs document, you may wish to consider 37 | [`fastobo`](https://pypi.org/project/fastobo) instead.* 38 | 39 | 40 | ## 🏳️ Supported Languages 41 | 42 | - [Open Biomedical Ontologies 1.4](http://owlcollab.github.io/oboformat/doc/GO.format.obo-1_4.html). 43 | *Because this format is fairly new, not all OBO ontologies can be parsed at the 44 | moment. See the [OBO Foundry roadmap](https://github.com/orgs/fastobo/projects/2) 45 | listing the compliant ontologies, and don't hesitate to contact their developers 46 | to push adoption forward.* 47 | - [OBO Graphs](https://github.com/geneontology/obographs) in [JSON](http://json.org/) 48 | format. *The format is not yet stabilized to the results may change from file 49 | to file.* 50 | - [Ontology Web Language 2](https://www.w3.org/TR/owl2-overview/) 51 | in [RDF/XML format](https://www.w3.org/TR/2012/REC-owl2-mapping-to-rdf-20121211/). 52 | *OWL2 ontologies are reverse translated to OBO using the mapping defined in the 53 | [OBO 1.4 Semantics](http://owlcollab.github.io/oboformat/doc/obo-syntax.html).* 54 | 55 | ## 🔧 Installing 56 | 57 | 58 | Installing with `pip` is the easiest: 59 | ```console 60 | # pip install pronto # if you have the admin rights 61 | $ pip install pronto --user # install it in a user-site directory 62 | ``` 63 | 64 | There is also a `conda` recipe in the `bioconda` channel: 65 | ```console 66 | $ conda install -c bioconda pronto 67 | ``` 68 | 69 | Finally, a development version can be installed from GitHub 70 | using `setuptools`, provided you have the right dependencies 71 | installed already: 72 | ```console 73 | $ git clone https://github.com/althonos/pronto 74 | $ cd pronto 75 | # python setup.py install 76 | ``` 77 | 78 | ## 💡 Examples 79 | 80 | If you're only reading ontologies, you'll only use the `Ontology` 81 | class, which is the main entry point. 82 | 83 | ```python 84 | >>> from pronto import Ontology 85 | ``` 86 | 87 | It can be instantiated from a path to an ontology in one of the supported 88 | formats, even if the file is compressed: 89 | ```python 90 | >>> go = Ontology("tests/data/go.obo.gz") 91 | ``` 92 | 93 | Loading a file from a persistent URL is also supported, although you may also 94 | want to use the `Ontology.from_obo_library` method if you're using persistent 95 | URLs a lot: 96 | ```python 97 | >>> cl = Ontology("http://purl.obolibrary.org/obo/cl.obo") 98 | >>> stato = Ontology.from_obo_library("stato.owl") 99 | ``` 100 | 101 | ### 🏷️ Get a term by accession 102 | 103 | `Ontology` objects can be used as mappings to access any entity 104 | they contain from their identifier in compact form: 105 | ```python 106 | >>> cl['CL:0002116'] 107 | Term('CL:0002116', name='B220-low CD38-positive unswitched memory B cell') 108 | ``` 109 | 110 | Note that when loading an OWL ontology, URIs will be compacted to CURIEs 111 | whenever possible: 112 | 113 | ```python 114 | >>> aeo = Ontology.from_obo_library("aeo.owl") 115 | >>> aeo["AEO:0000078"] 116 | Term('AEO:0000078', name='lumen of tube') 117 | ``` 118 | 119 | ### 🖊️ Create a new term from scratch 120 | 121 | We can load an ontology, and edit it locally. Here, we add a new protein class 122 | to the Protein Ontology. 123 | ```python 124 | >>> pr = Ontology.from_obo_library("pr.obo") 125 | >>> brh = ms.create_term("PR:XXXXXXXX") 126 | >>> brh.name = "Bacteriorhodopsin" 127 | >>> brh.superclasses().add(pr["PR:000001094"]) # is a rhodopsin-like G-protein 128 | >>> brh.disjoint_from.add(pr["PR:000036194"]) # disjoint from eukaryotic proteins 129 | ``` 130 | 131 | ### ✏️ Convert an OWL ontology to OBO format 132 | 133 | The `Ontology.dump` method can be used to serialize an ontology to any of the 134 | supported formats (currently OBO and OBO JSON): 135 | ```python 136 | >>> edam = Ontology("http://edamontology.org/EDAM.owl") 137 | >>> with open("edam.obo", "wb") as f: 138 | ... edam.dump(f, format="obo") 139 | ``` 140 | 141 | ### 🌿 Find ontology terms without subclasses 142 | 143 | The `terms` method of `Ontology` instances can be used to 144 | iterate over all the terms in the ontology (including the 145 | ones that are imported). We can then use the `is_leaf` 146 | method of `Term` objects to check is the term is a leaf in the 147 | class inclusion graph. 148 | 149 | ```python 150 | >>> ms = Ontology("ms.obo") 151 | >>> for term in ms.terms(): 152 | ... if term.is_leaf(): 153 | ... print(term.id) 154 | MS:0000000 155 | MS:1000001 156 | ... 157 | ``` 158 | 159 | ### 🤫 Silence warnings 160 | 161 | `pronto` is explicit about the parts of the code that are doing 162 | non-standard assumptions, or missing capabilities to handle certain 163 | constructs. It does so by raising warnings with the `warnings` module, 164 | which can get quite verbose. 165 | 166 | If you are fine with the inconsistencies, you can manually disable 167 | warning reports in your consumer code with the `filterwarnings` function: 168 | 169 | ```python 170 | import warnings 171 | import pronto 172 | warnings.filterwarnings("ignore", category=pronto.warnings.ProntoWarning) 173 | ``` 174 | 175 | 176 | 177 | ## 📖 API Reference 178 | 179 | A complete API reference can be found in the 180 | [online documentation](https://pronto.readthedocs.io/en/latest/api.html), or 181 | directly from the command line using `pydoc`: 182 | ```console 183 | $ pydoc pronto.Ontology 184 | ``` 185 | 186 | ## 📜 License 187 | 188 | This library is provided under the open-source 189 | [MIT license](https://choosealicense.com/licenses/mit/). 190 | Please cite this library if you are using it in a scientific 191 | context using the following DOI: 192 | [**10.5281/zenodo.595572**](https://doi.org/10.5281/zenodo.595572) 193 | -------------------------------------------------------------------------------- /tests/test_term.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import os 3 | import unittest 4 | import warnings 5 | import dataclasses 6 | 7 | import pronto 8 | from pronto.term import Term, TermData, TermSet 9 | 10 | from .utils import DATADIR 11 | from .test_entity import _TestEntitySet 12 | 13 | 14 | class TestTerm(unittest.TestCase): 15 | 16 | @classmethod 17 | def setUpClass(cls): 18 | warnings.simplefilter('error') 19 | warnings.simplefilter('ignore', category=UnicodeWarning) 20 | cls.ms = pronto.Ontology(os.path.join(DATADIR, "ms.obo")) 21 | 22 | @classmethod 23 | def tearDownClass(cls): 24 | warnings.simplefilter(warnings.defaultaction) 25 | 26 | def setUp(self): 27 | self.ont = ont = pronto.Ontology() 28 | self.t1 = ont.create_term("TST:001") 29 | self.t2 = ont.create_term("TST:002") 30 | self.t3 = ont.create_term("TST:003") 31 | self.t4 = ont.create_term("TST:004") 32 | self.has_part = ont.create_relationship("has_part") 33 | 34 | def test_annotations(self): 35 | ontology = pronto.Ontology() 36 | term = ontology.create_term("TST:001") 37 | term.annotations = { 38 | pronto.LiteralPropertyValue("http://purl.org/dc/terms/creator", "Martin Larralde") 39 | } 40 | 41 | def test_add_synonym(self): 42 | term = self.ms["MS:1000031"] 43 | s = term.add_synonym('instrument type') 44 | self.assertIn(s, term.synonyms) 45 | 46 | def test_add_synonym_invalid_scope(self): 47 | term = self.ms["MS:1000031"] 48 | with self.assertRaises(ValueError): 49 | s = term.add_synonym('instrument type', scope="NONSENSE") 50 | 51 | def test_add_synonym_invalid_type(self): 52 | term = self.ms["MS:1000031"] 53 | st = pronto.SynonymType("undeclared", "an undeclared synonym type") 54 | with self.assertRaises(ValueError): 55 | s = term.add_synonym('instrument type', type=st) 56 | 57 | def test_properties(self): 58 | for field in dataclasses.fields(TermData): 59 | self.assertTrue(hasattr(Term, field.name), f"no property for {field.name}") 60 | 61 | def test_subclasses(self): 62 | term = self.ms["MS:1003025"] 63 | self.assertSetEqual( 64 | {sub.id for sub in term.subclasses()}, 65 | {"MS:1003025", "MS:1003026", "MS:1003027", "MS:1003021", "MS:1003022"}, 66 | ) 67 | 68 | def test_subclasses_distance(self): 69 | term = self.ms["MS:1000031"] 70 | self.assertSetEqual(set(term.subclasses(0)), {term}) 71 | self.assertSetEqual( 72 | {sub.id for sub in term.subclasses(1)}, 73 | { 74 | "MS:1000031", 75 | "MS:1000490", 76 | "MS:1000495", 77 | "MS:1000122", 78 | "MS:1000491", 79 | "MS:1000488", 80 | "MS:1001800", 81 | "MS:1000121", 82 | "MS:1000124", 83 | "MS:1000483", 84 | "MS:1000489", 85 | "MS:1000126", 86 | }, 87 | ) 88 | 89 | def test_subclasses_uniqueness(self): 90 | for term in itertools.islice(self.ms.terms(), 10): 91 | subclasses = list(term.subclasses()) 92 | self.assertCountEqual(subclasses, set(subclasses)) 93 | 94 | def test_subclasses_without_self(self): 95 | term = self.ms["MS:1003025"] 96 | self.assertSetEqual( 97 | {sub.id for sub in term.subclasses(with_self=False)}, 98 | {"MS:1003026", "MS:1003027", "MS:1003021", "MS:1003022"}, 99 | ) 100 | 101 | def test_superclasses(self): 102 | term = self.ms["MS:1000200"] 103 | self.assertSetEqual( 104 | {sup.id for sup in term.superclasses()}, 105 | {"MS:1000200", "MS:1000123", "MS:1000489", "MS:1000031"}, 106 | ) 107 | 108 | def test_superclasses_distance(self): 109 | term = self.ms["MS:1000200"] 110 | scls = ["MS:1000200", "MS:1000123", "MS:1000489", "MS:1000031"] 111 | for i in range(len(scls)): 112 | self.assertSetEqual( 113 | {sup.id for sup in term.superclasses(i)}, set(scls[: i + 1]) 114 | ) 115 | 116 | def test_superclasses_uniqueness(self): 117 | self.t2.superclasses().add(self.t1) 118 | self.t3.superclasses().add(self.t1) 119 | self.t4.superclasses().add(self.t2) 120 | self.t4.superclasses().add(self.t3) 121 | self.assertEqual( 122 | sorted(self.t4.superclasses(with_self=False)), 123 | [self.t1, self.t2, self.t3] 124 | ) 125 | 126 | def test_superclasses(self): 127 | term = self.ms["MS:1000200"] 128 | self.assertSetEqual( 129 | {sup.id for sup in term.superclasses(with_self=False)}, 130 | {"MS:1000123", "MS:1000489", "MS:1000031"}, 131 | ) 132 | 133 | def test_consider(self): 134 | self.assertEqual(self.t1.consider, TermSet()) 135 | self.t1.consider = {self.t2} 136 | self.assertEqual(self.t1.consider, TermSet({self.t2})) 137 | self.t1.consider.clear() 138 | self.assertEqual(self.t1.consider, TermSet()) 139 | 140 | def test_disjoint_from(self): 141 | self.assertEqual(self.t1.disjoint_from, TermSet()) 142 | self.t1.disjoint_from = {self.t2} 143 | self.assertEqual(self.t1.disjoint_from, TermSet({self.t2})) 144 | 145 | def test_intersection_of(self): 146 | self.assertEqual(self.t1.intersection_of, TermSet()) 147 | self.t1.intersection_of = {self.t2, self.t3} 148 | self.assertEqual(self.t1.intersection_of, {self.t2, self.t3}) 149 | self.t1.intersection_of = {self.t2, (self.has_part, self.t3)} 150 | self.assertEqual(self.t1.intersection_of, {self.t2, (self.has_part, self.t3)}) 151 | 152 | def test_intersection_of_type_error(self): 153 | with self.assertRaises(TypeError): 154 | self.t1.intersection_of = {0} 155 | 156 | def test_union_of(self): 157 | self.t1.union_of = {self.t2, self.t3} 158 | self.assertEqual(self.t1.union_of, TermSet({self.t2, self.t3})) 159 | self.assertEqual(self.t1._data().union_of, {self.t2.id, self.t3.id}) 160 | 161 | @unittest.skipUnless(__debug__, "no type checking in optimized mode") 162 | def test_union_of_typechecked(self): 163 | with self.assertRaises(TypeError): 164 | self.t1.union_of = 1 165 | with self.assertRaises(TypeError): 166 | self.t1.union_of = { 1 } 167 | 168 | def test_union_of_cardinality(self): 169 | with self.assertRaises(ValueError): 170 | self.t2.union_of = { self.t1 } 171 | 172 | def test_replaced_by(self): 173 | self.assertEqual(sorted(self.t1.replaced_by.ids), []) 174 | 175 | self.t1.replaced_by.add(self.t2) 176 | self.assertEqual(sorted(self.t1.replaced_by.ids), [self.t2.id]) 177 | self.assertEqual(sorted(self.ont[self.t1.id].replaced_by.ids), [self.t2.id]) 178 | 179 | self.t1.replaced_by.add(self.t3) 180 | self.assertEqual(sorted(self.t1.replaced_by.ids), [self.t2.id, self.t3.id]) 181 | self.assertEqual(sorted(self.ont[self.t1.id].replaced_by.ids), [self.t2.id, self.t3.id]) 182 | 183 | def test_repr(self): 184 | self.assertEqual(repr(self.t1), f"Term({self.t1.id!r})") 185 | self.t1.name = "test" 186 | self.assertEqual(repr(self.t1), f"Term({self.t1.id!r}, name={self.t1.name!r})") 187 | 188 | 189 | class TestTermSet(_TestEntitySet, unittest.TestCase): 190 | 191 | def create_entity(self, ont, id): 192 | return ont.create_term(id) 193 | 194 | def test_subclasses_uniqueness(self): 195 | self.t2.superclasses().add(self.t1) 196 | self.t3.superclasses().add(self.t2) 197 | 198 | self.assertEqual( 199 | self.t1.subclasses().to_set().ids, {self.t1.id, self.t2.id, self.t3.id} 200 | ) 201 | self.assertEqual( 202 | self.t2.subclasses().to_set().ids, {self.t2.id, self.t3.id} 203 | ) 204 | self.assertEqual( 205 | self.t3.subclasses().to_set().ids, {self.t3.id} 206 | ) 207 | 208 | s = pronto.TermSet({self.t1, self.t2}) 209 | self.assertEqual( 210 | sorted(t.id for t in s.subclasses(with_self=False)), 211 | [self.t3.id] 212 | ) 213 | self.assertEqual( 214 | sorted(t.id for t in s.subclasses(with_self=True)), 215 | [self.t1.id, self.t2.id, self.t3.id] 216 | ) 217 | 218 | def test_superclasses_uniqueness(self): 219 | self.t2.superclasses().add(self.t1) 220 | self.t3.superclasses().add(self.t1) 221 | self.t3.superclasses().add(self.t2) 222 | 223 | s = pronto.TermSet({self.t2, self.t3}) 224 | self.assertEqual( 225 | sorted(t.id for t in s.superclasses(with_self=False)), 226 | [self.t1.id] 227 | ) 228 | self.assertEqual( 229 | sorted(t.id for t in s.superclasses(with_self=True)), 230 | [self.t1.id, self.t2.id, self.t3.id] 231 | ) 232 | 233 | def test_repr(self): 234 | s1 = TermSet({self.t1}) 235 | self.assertEqual(repr(s1), f"TermSet({{Term({self.t1.id!r})}})") 236 | 237 | s2 = TermSet({self.t1, self.t2}) 238 | self.assertIn( 239 | repr(s2), 240 | [ 241 | f"TermSet({{Term({self.t1.id!r}), Term({self.t2.id!r})}})", 242 | f"TermSet({{Term({self.t2.id!r}), Term({self.t1.id!r})}})" 243 | ] 244 | ) 245 | -------------------------------------------------------------------------------- /tests/test_serializer/test_obo.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest 3 | import warnings 4 | import xml.etree.ElementTree as etree 5 | 6 | import pronto 7 | 8 | from .base import TestSerializer 9 | 10 | 11 | class TestOboSerializer(TestSerializer, unittest.TestCase): 12 | 13 | format = "obo" 14 | 15 | # --- Metadata ----------------------------------------------------------- 16 | 17 | def test_metadata_auto_generated_by(self): 18 | self.assertRoundtrip( 19 | f""" 20 | format-version: 1.4 21 | auto-generated-by: pronto v{pronto.__version__} 22 | """ 23 | ) 24 | 25 | def test_metadata_date(self): 26 | self.assertRoundtrip( 27 | """ 28 | format-version: 1.4 29 | date: 28:01:2020 10:29 30 | """ 31 | ) 32 | 33 | def test_metadata_namespace_id_rule(self): 34 | self.assertRoundtrip( 35 | """ 36 | format-version: 1.4 37 | namespace-id-rule: * MS:$sequence(7,0,9999999)$ 38 | """ 39 | ) 40 | 41 | def test_metadata_owl_axioms(self): 42 | self.assertRoundtrip( 43 | """ 44 | format-version: 1.4 45 | owl-axioms: AnnotationAssertion( \\"MS\\"^^xsd:string) 46 | """ 47 | ) 48 | 49 | def test_metadata_saved_by(self): 50 | self.assertRoundtrip( 51 | """ 52 | format-version: 1.4 53 | saved-by: Martin Larralde 54 | """ 55 | ) 56 | 57 | def test_metadata_unreserved(self): 58 | self.assertRoundtrip( 59 | """ 60 | format-version: 1.4 61 | unreserved-thing: very much unreserved 62 | """ 63 | ) 64 | 65 | # --- Relationship ------------------------------------------------------- 66 | 67 | def test_relationship_alt_id(self): 68 | self.assertRoundtrip( 69 | """ 70 | format-version: 1.4 71 | 72 | [Typedef] 73 | id: TST:001 74 | alt_id: TST:002 75 | alt_id: TST:003 76 | """ 77 | ) 78 | 79 | def test_relationship_anonymous(self): 80 | self.assertRoundtrip( 81 | """ 82 | format-version: 1.4 83 | 84 | [Typedef] 85 | id: TST:001 86 | is_anonymous: true 87 | """ 88 | ) 89 | 90 | def test_relationship_anti_symmetric(self): 91 | self.assertRoundtrip( 92 | """ 93 | format-version: 1.4 94 | 95 | [Typedef] 96 | id: part_of 97 | is_anti_symmetric: true 98 | """ 99 | ) 100 | 101 | def test_relationship_asymmetric(self): 102 | self.assertRoundtrip( 103 | """ 104 | format-version: 1.4 105 | 106 | [Typedef] 107 | id: part_of 108 | is_asymmetric: true 109 | """ 110 | ) 111 | 112 | def test_relationship_comment(self): 113 | self.assertRoundtrip( 114 | """ 115 | format-version: 1.4 116 | 117 | [Typedef] 118 | id: TST:001 119 | comment: a very important comment 120 | """ 121 | ) 122 | 123 | def test_relationship_created_by(self): 124 | self.assertRoundtrip( 125 | """ 126 | format-version: 1.4 127 | 128 | [Typedef] 129 | id: part_of 130 | created_by: Martin Larralde 131 | """ 132 | ) 133 | 134 | def test_relationship_cylic(self): 135 | self.assertRoundtrip( 136 | """ 137 | format-version: 1.4 138 | 139 | [Typedef] 140 | id: part_of 141 | is_cyclic: true 142 | """ 143 | ) 144 | 145 | def test_relationship_definition(self): 146 | self.assertRoundtrip( 147 | """ 148 | format-version: 1.4 149 | 150 | [Typedef] 151 | id: TST:001 152 | def: "something something" [RO:0002091, RO:0002092] 153 | """ 154 | ) 155 | 156 | def test_relationship_domain(self): 157 | self.assertRoundtrip( 158 | """ 159 | format-version: 1.4 160 | 161 | [Term] 162 | id: TST:001 163 | 164 | [Typedef] 165 | id: rel 166 | domain: TST:001 167 | """ 168 | ) 169 | 170 | def test_relationship_functional(self): 171 | self.assertRoundtrip( 172 | """ 173 | format-version: 1.4 174 | 175 | [Typedef] 176 | id: has_part 177 | is_functional: true 178 | """ 179 | ) 180 | 181 | def test_relationship_inverse_functional(self): 182 | self.assertRoundtrip( 183 | """ 184 | format-version: 1.4 185 | 186 | [Typedef] 187 | id: has_part 188 | is_inverse_functional: true 189 | """ 190 | ) 191 | 192 | def test_relationship_is_obsolete(self): 193 | self.assertRoundtrip( 194 | """ 195 | format-version: 1.4 196 | 197 | [Typedef] 198 | id: friend_of 199 | is_obsolete: true 200 | """ 201 | ) 202 | 203 | def test_relationship_range(self): 204 | self.assertRoundtrip( 205 | """ 206 | format-version: 1.4 207 | 208 | [Term] 209 | id: TST:001 210 | 211 | [Typedef] 212 | id: rel 213 | range: TST:001 214 | """ 215 | ) 216 | 217 | def test_relationship_reflexive(self): 218 | self.assertRoundtrip( 219 | """ 220 | format-version: 1.4 221 | 222 | [Typedef] 223 | id: has_part 224 | is_reflexive: true 225 | """ 226 | ) 227 | 228 | def test_relationship_relationship(self): 229 | self.assertRoundtrip( 230 | """ 231 | format-version: 1.4 232 | 233 | [Term] 234 | id: TST:003 235 | 236 | [Typedef] 237 | id: TST:001 238 | 239 | [Typedef] 240 | id: TST:002 241 | relationship: TST:001 TST:003 242 | """ 243 | ) 244 | 245 | def test_relationship_replaced_by(self): 246 | self.assertRoundtrip( 247 | """ 248 | format-version: 1.4 249 | 250 | [Typedef] 251 | id: friend_of 252 | 253 | [Typedef] 254 | id: is_friend_of 255 | replaced_by: friend_of 256 | """ 257 | ) 258 | 259 | def test_relationship_subset(self): 260 | self.assertRoundtrip( 261 | """ 262 | format-version: 1.4 263 | subsetdef: goslim_agr "AGR slim" 264 | subsetdef: goslim_aspergillus "Aspergillus GO slim" 265 | subsetdef: goslim_candida "Candida GO slim" 266 | 267 | [Typedef] 268 | id: has_part 269 | subset: goslim_agr 270 | subset: goslim_candida 271 | """ 272 | ) 273 | 274 | def test_relationship_symmetric(self): 275 | self.assertRoundtrip( 276 | """ 277 | format-version: 1.4 278 | 279 | [Typedef] 280 | id: sibling_of 281 | is_symmetric: true 282 | """ 283 | ) 284 | 285 | def test_relationship_transitive(self): 286 | self.assertRoundtrip( 287 | """ 288 | format-version: 1.4 289 | 290 | [Typedef] 291 | id: has_part 292 | is_transitive: true 293 | """ 294 | ) 295 | 296 | def test_relationship_transitive_over(self): 297 | self.assertRoundtrip( 298 | """ 299 | format-version: 1.4 300 | 301 | [Typedef] 302 | id: best_friend_of 303 | is_a: friend_of 304 | 305 | [Typedef] 306 | id: friend_of 307 | transitive_over: best_friend_of 308 | """ 309 | ) 310 | 311 | # --- Term --------------------------------------------------------------- 312 | 313 | def test_term_alt_id(self): 314 | self.assertRoundtrip( 315 | """ 316 | format-version: 1.4 317 | 318 | [Term] 319 | id: TST:001 320 | alt_id: TST:002 321 | alt_id: TST:003 322 | """ 323 | ) 324 | 325 | def test_term_anonymous(self): 326 | self.assertRoundtrip( 327 | """ 328 | format-version: 1.4 329 | 330 | [Term] 331 | id: TST:001 332 | is_anonymous: true 333 | """ 334 | ) 335 | 336 | def test_term_builtin(self): 337 | self.assertRoundtrip( 338 | """ 339 | format-version: 1.4 340 | 341 | [Term] 342 | id: TST:001 343 | builtin: true 344 | """ 345 | ) 346 | 347 | def test_term_comment(self): 348 | self.assertRoundtrip( 349 | """ 350 | format-version: 1.4 351 | 352 | [Term] 353 | id: TST:001 354 | comment: This is a very important comment. 355 | """ 356 | ) 357 | 358 | def test_term_consider(self): 359 | self.assertRoundtrip( 360 | """ 361 | format-version: 1.4 362 | 363 | [Term] 364 | id: TST:001 365 | 366 | [Term] 367 | id: TST:002 368 | consider: TST:001 369 | """ 370 | ) 371 | 372 | def test_term_created_by(self): 373 | self.assertRoundtrip( 374 | """ 375 | format-version: 1.4 376 | 377 | [Term] 378 | id: TST:001 379 | created_by: Martin Larralde 380 | """ 381 | ) 382 | 383 | def test_term_creation_date(self): 384 | self.assertRoundtrip( 385 | """ 386 | format-version: 1.4 387 | 388 | [Term] 389 | id: TST:001 390 | creation_date: 2020-02-11T15:32:35Z 391 | """ 392 | ) 393 | 394 | def test_term_definition(self): 395 | self.assertRoundtrip( 396 | """ 397 | format-version: 1.4 398 | 399 | [Term] 400 | id: GO:0000003 401 | def: "The production of new individuals that contain some portion of genetic material inherited from one or more parent organisms." [GOC:go_curators, GOC:isa_complete, GOC:jl, ISBN:0198506732] 402 | """ 403 | ) 404 | 405 | def test_term_disjoint_from(self): 406 | self.assertRoundtrip( 407 | """ 408 | format-version: 1.4 409 | 410 | [Term] 411 | id: TST:001 412 | 413 | [Term] 414 | id: TST:002 415 | 416 | [Term] 417 | id: TST:003 418 | disjoint_from: TST:001 419 | disjoint_from: TST:002 420 | """ 421 | ) 422 | 423 | def test_term_intersection_of(self): 424 | self.assertRoundtrip( 425 | """ 426 | format-version: 1.4 427 | 428 | [Term] 429 | id: TST:001 430 | 431 | [Term] 432 | id: TST:002 433 | 434 | [Term] 435 | id: TST:003 436 | intersection_of: TST:001 437 | intersection_of: part_of TST:002 438 | 439 | [Typedef] 440 | id: part_of 441 | """ 442 | ) 443 | 444 | def test_term_is_a(self): 445 | self.assertRoundtrip( 446 | """ 447 | format-version: 1.4 448 | 449 | [Term] 450 | id: TST:001 451 | 452 | [Term] 453 | id: TST:002 454 | 455 | [Term] 456 | id: TST:003 457 | is_a: TST:001 458 | is_a: TST:002 459 | """ 460 | ) 461 | 462 | def test_term_is_obsolete(self): 463 | self.assertRoundtrip( 464 | """ 465 | format-version: 1.4 466 | 467 | [Term] 468 | id: TST:001 469 | is_obsolete: true 470 | """ 471 | ) 472 | 473 | def test_term_replaced_by(self): 474 | self.assertRoundtrip( 475 | """ 476 | format-version: 1.4 477 | 478 | [Term] 479 | id: TST:001 480 | 481 | [Term] 482 | id: TST:002 483 | replaced_by: TST:001 484 | """ 485 | ) 486 | 487 | def test_term_union_of(self): 488 | self.assertRoundtrip( 489 | """ 490 | format-version: 1.4 491 | 492 | [Term] 493 | id: TST:001 494 | 495 | [Term] 496 | id: TST:002 497 | 498 | [Term] 499 | id: TST:003 500 | union_of: TST:001 501 | union_of: TST:002 502 | """ 503 | ) 504 | 505 | def test_term_xref(self): 506 | self.assertRoundtrip( 507 | """ 508 | format-version: 1.4 509 | 510 | [Term] 511 | id: TST:001 512 | xref: PMC:135269 513 | """ 514 | ) 515 | -------------------------------------------------------------------------------- /pronto/logic/lineage.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import collections 3 | import typing 4 | import warnings 5 | from typing import AbstractSet, Deque, Optional, Set, Tuple, cast 6 | 7 | from ..utils.meta import roundrepr, typechecked 8 | 9 | if typing.TYPE_CHECKING: 10 | from ..entity import Entity 11 | from ..term import Term, TermSet, TermData 12 | from ..relationship import Relationship, RelationshipData, RelationshipSet 13 | from ..ontology import Ontology, _DataGraph 14 | 15 | _E = typing.TypeVar("_E", bound="Entity") 16 | 17 | # --- Storage ---------------------------------------------------------------- 18 | 19 | 20 | @roundrepr 21 | class Lineage(object): 22 | """An internal type to store the superclasses and subclasses of a term. 23 | 24 | Used in `Ontology` to cache subclassing relationships between terms since 25 | only the superclassing relationships are explicitly declared in source 26 | documents. 27 | """ 28 | 29 | __slots__ = ("sub", "sup") 30 | 31 | def __init__( 32 | self, 33 | sub: Optional[AbstractSet[str]] = None, 34 | sup: Optional[AbstractSet[str]] = None, 35 | ): 36 | self.sub: Set[str] = set(sub if sub is not None else ()) # type: ignore 37 | self.sup: Set[str] = set(sup if sup is not None else ()) # type: ignore 38 | 39 | def __eq__(self, other: object) -> bool: 40 | if isinstance(other, Lineage): 41 | return self.sub == other.sub and self.sup == other.sup 42 | return False 43 | 44 | # `Lineage` is mutable so this is the explicit way to tell it's unhashable 45 | # (see https://docs.python.org/3/reference/datamodel.html#object.__hash__) 46 | __hash__ = None # type: ignore 47 | 48 | # create an empty Lineage object to serve as a default 49 | Lineage._EMPTY: Lineage = Lineage() 50 | Lineage._EMPTY.sub = Lineage._EMPTY.sup = frozenset() 51 | 52 | # --- Abstract handlers ------------------------------------------------------ 53 | 54 | 55 | class LineageHandler(typing.Generic[_E], typing.Iterable[_E]): 56 | def __init__( 57 | self, entity: _E, distance: Optional[int] = None, with_self: bool = False 58 | ): 59 | self.entity = entity 60 | self.distance = distance 61 | self.with_self = with_self 62 | # TODO: API compatibilty with previous iterator (remove for v3.0.0) 63 | self._it: typing.Optional[typing.Iterator[_E]] = None 64 | 65 | def __next__(self) -> _E: 66 | if self._it is None: 67 | ty = type(self.entity).__name__ 68 | warnings.warn( 69 | f"`{ty}.subclasses()` and `{ty}.superclasses()` will not " 70 | "return iterators in next major version, but iterables. " 71 | "Update your code to use `iter(...)` if needed.", 72 | category=DeprecationWarning, 73 | stacklevel=2, 74 | ) 75 | self._it = iter(self) 76 | return next(cast(typing.Iterator[_E], self._it)) 77 | 78 | def _add(self, subclass: _E, superclass: _E): 79 | if superclass._ontology() is not subclass._ontology(): 80 | ty = type(subclass).__name__ 81 | raise ValueError(f"cannot use `{ty}` instances from different ontologies") 82 | lineage = self._get_data().lineage 83 | lineage[subclass.id].sup.add(superclass.id) 84 | lineage[superclass.id].sub.add(subclass.id) 85 | 86 | def _remove(self, subclass: _E, superclass: _E): 87 | if superclass._ontology() is not subclass._ontology(): 88 | ty = type(subclass).__name__ 89 | raise ValueError(f"cannot use `{ty}` instances from different ontologies") 90 | lineage = self._get_data().lineage 91 | lineage[subclass.id].sup.remove(superclass.id) 92 | lineage[superclass.id].sub.remove(subclass.id) 93 | 94 | @abc.abstractmethod 95 | def __iter__(self) -> "LineageIterator[_E]": 96 | return NotImplemented 97 | 98 | @abc.abstractmethod 99 | def _get_data(self) -> "_DataGraph": 100 | return NotImplemented # type: ignore 101 | 102 | @abc.abstractmethod 103 | def add(self, other: _E) -> None: 104 | return NotImplemented # type: ignore 105 | 106 | @abc.abstractmethod 107 | def remove(self, other: _E) -> None: 108 | return NotImplemented # type: ignore 109 | 110 | @abc.abstractmethod 111 | def clear(self) -> None: 112 | return NotImplemented # type: ignore 113 | 114 | 115 | class TermHandler(LineageHandler["Term"]): 116 | @abc.abstractmethod 117 | def __iter__(self) -> "TermIterator": 118 | return NotImplemented 119 | 120 | def _get_data(self) -> "_DataGraph[TermData]": 121 | return self.entity._ontology()._terms 122 | 123 | def to_set(self) -> "TermSet": 124 | return self.__iter__().to_set() 125 | 126 | 127 | class RelationshipHandler(LineageHandler["Relationship"]): 128 | @abc.abstractmethod 129 | def __iter__(self) -> "RelationshipIterator": 130 | return NotImplemented 131 | 132 | def _get_data(self) -> "_DataGraph[RelationshipData]": 133 | return self.entity._ontology()._relationships 134 | 135 | def to_set(self) -> "RelationshipSet": 136 | return self.__iter__().to_set() 137 | 138 | 139 | class SuperentitiesHandler(LineageHandler): 140 | @abc.abstractmethod 141 | def __iter__(self) -> "SuperentitiesIterator": 142 | return NotImplemented 143 | 144 | def add(self, superclass: _E): 145 | self._add(subclass=self.entity, superclass=superclass) 146 | 147 | def remove(self, superclass: _E): 148 | self._remove(subclass=self.entity, superclass=superclass) 149 | 150 | def clear(self): 151 | lineage = self._get_data().lineage 152 | for subclass in lineage[self.entity.id].sup: 153 | lineage[subclass].sub.remove(self.entity.id) 154 | lineage[self.entity.id].sup.clear() 155 | 156 | 157 | class SubentitiesHandler(LineageHandler): 158 | @abc.abstractmethod 159 | def __iter__(self) -> "SubentitiesIterator": 160 | return NotImplemented 161 | 162 | def add(self, subclass: _E): 163 | self._add(superclass=self.entity, subclass=subclass) 164 | 165 | def remove(self, subclass: _E): 166 | self._remove(superclass=self.entity, subclass=subclass) 167 | 168 | def clear(self): 169 | lineage = self._get_data().lineage 170 | for superclass in lineage[self.entity.id].sub: 171 | lineage[superclass].sup.remove(self.entity.id) 172 | lineage[self.entity.id].sub.clear() 173 | 174 | 175 | # --- Concrete handlers ------------------------------------------------------ 176 | 177 | 178 | class SubclassesHandler(SubentitiesHandler, TermHandler): 179 | def __iter__(self) -> "SubclassesIterator": 180 | return SubclassesIterator( 181 | self.entity, distance=self.distance, with_self=self.with_self 182 | ) 183 | 184 | 185 | class SubpropertiesHandler(SubentitiesHandler, RelationshipHandler): 186 | def __iter__(self) -> "SubpropertiesIterator": 187 | return SubpropertiesIterator( 188 | self.entity, distance=self.distance, with_self=self.with_self 189 | ) 190 | 191 | 192 | class SuperclassesHandler(SuperentitiesHandler, TermHandler): 193 | def __iter__(self) -> "SuperclassesIterator": 194 | return SuperclassesIterator( 195 | self.entity, distance=self.distance, with_self=self.with_self 196 | ) 197 | 198 | 199 | class SuperpropertiesHandler(SuperentitiesHandler, RelationshipHandler): 200 | def __iter__(self) -> "SuperpropertiesIterator": 201 | return SuperpropertiesIterator( 202 | self.entity, distance=self.distance, with_self=self.with_self 203 | ) 204 | 205 | 206 | # --- Abstract iterators ----------------------------------------------------- 207 | 208 | 209 | class LineageIterator(typing.Generic[_E], typing.Iterator[_E]): 210 | 211 | _distmax: float 212 | _ontology: Optional["Ontology"] 213 | _linked: Set[str] 214 | _done: Set[str] 215 | _frontier: Deque[Tuple[str, int]] 216 | _queue: Deque[str] 217 | 218 | # --- 219 | 220 | @abc.abstractmethod 221 | def _get_data(self) -> "_DataGraph": 222 | return NotImplemented # type: ignore 223 | 224 | @abc.abstractmethod 225 | def _get_neighbors(self, node: str) -> Set[str]: 226 | return NotImplemented # type: ignore 227 | 228 | @abc.abstractmethod 229 | def _get_entity(self, node: str) -> _E: 230 | return NotImplemented # type: ignore 231 | 232 | @abc.abstractmethod 233 | def _maxlen(self) -> int: 234 | return NotImplemented # type: ignore 235 | 236 | # --- 237 | 238 | def __init__( 239 | self, *entities: _E, distance: Optional[int] = None, with_self: bool = True 240 | ) -> None: 241 | 242 | self._distmax = float("inf") if distance is None else distance 243 | 244 | # if not term is given, `__next__` will raise `StopIterator` on 245 | # the first call without ever accessing `self._ontology`, so it's 246 | # safe not to initialise it here in that case. 247 | if entities: 248 | self._ontology = entities[0]._ontology() 249 | else: 250 | self._ontology = None 251 | 252 | self._linked: Set[str] = set() 253 | self._done: Set[str] = set() 254 | self._frontier: Deque[Tuple[str, int]] = collections.deque() 255 | self._queue: Deque[str] = collections.deque() 256 | 257 | for entity in entities: 258 | self._frontier.append((entity.id, 0)) 259 | self._linked.add(entity.id) 260 | if with_self: 261 | self._queue.append(entity.id) 262 | 263 | def __iter__(self) -> "LineageIterator[_E]": 264 | return self 265 | 266 | def __length_hint__(self) -> int: 267 | """Return an estimate of the number of remaining entities to yield.""" 268 | if self._queue or self._frontier: 269 | return self._maxlen() - len(self._linked) + len(self._queue) 270 | else: 271 | return 0 272 | 273 | def _next_id(self) -> Optional[str]: 274 | while self._frontier or self._queue: 275 | # Return any element currently queued 276 | if self._queue: 277 | return self._queue.popleft() 278 | # Get the next node in the frontier 279 | node, distance = self._frontier.popleft() 280 | self._done.add(node) 281 | # Process its neighbors if they are not too far 282 | neighbors: Set[str] = self._get_neighbors(node) 283 | if neighbors and distance < self._distmax: 284 | for node in sorted(neighbors.difference(self._done)): 285 | self._frontier.append((node, distance + 1)) 286 | for neighbor in sorted(neighbors.difference(self._linked)): 287 | self._linked.add(neighbor) 288 | self._queue.append(neighbor) 289 | # Stop iteration if no more elements to process 290 | return None 291 | 292 | def __next__(self) -> "_E": 293 | id_ = self._next_id() 294 | if id_ is None: 295 | raise StopIteration 296 | return self._get_entity(id_) 297 | 298 | 299 | class TermIterator(LineageIterator["Term"]): 300 | def _maxlen(self): 301 | return len(self._ontology.terms()) 302 | 303 | def _get_entity(self, id): 304 | return self._ontology.get_term(id) 305 | 306 | def _get_data(self): 307 | return self._ontology._terms 308 | 309 | def to_set(self) -> "TermSet": 310 | """Collect all classes into a `~pronto.TermSet`. 311 | 312 | Hint: 313 | This method is useful to query an ontology using a method chaining 314 | syntax, for instance:: 315 | 316 | >>> cio = pronto.Ontology("cio.obo") 317 | >>> sorted(cio['CIO:0000034'].subclasses().to_set().ids) 318 | ['CIO:0000034', 'CIO:0000035', 'CIO:0000036'] 319 | 320 | """ 321 | from ..term import TermSet 322 | 323 | with typechecked.disabled(): 324 | term_set = TermSet() 325 | term_set._ontology = self._ontology 326 | term_set._ids.update(iter(self._next_id, None)) 327 | return term_set 328 | 329 | 330 | class RelationshipIterator(LineageIterator["Relationship"]): 331 | def _maxlen(self): 332 | return len(self._ontology.relationships()) 333 | 334 | def _get_entity(self, id): 335 | return self._ontology.get_relationship(id) 336 | 337 | def _get_data(self): 338 | return self._ontology._relationships 339 | 340 | def to_set(self) -> "RelationshipSet": 341 | """Collect all relationshisp into a `~pronto.RelationshipSet`. 342 | 343 | Hint: 344 | This method is useful to query an ontology using a method chaining 345 | syntax. 346 | 347 | """ 348 | from ..relationship import RelationshipSet 349 | 350 | with typechecked.disabled(): 351 | relationship_set = RelationshipSet() 352 | relationship_set._ontology = self._ontology 353 | relationship_set._ids.update(iter(self._next_id, None)) 354 | return relationship_set 355 | 356 | 357 | class SubentitiesIterator(LineageIterator): 358 | def _get_neighbors(self, node: str) -> Set[str]: 359 | return self._get_data().lineage.get(node, Lineage._EMPTY).sub 360 | 361 | 362 | class SuperentitiesIterator(LineageIterator): 363 | def _get_neighbors(self, node: str) -> Set[str]: 364 | return self._get_data().lineage.get(node, Lineage._EMPTY).sup 365 | 366 | 367 | # --- Concrete iterators ----------------------------------------------------- 368 | 369 | 370 | class SubclassesIterator(SubentitiesIterator, TermIterator): 371 | """An iterator over the subclasses of one or several `~pronto.Term`.""" 372 | 373 | 374 | class SuperclassesIterator(SuperentitiesIterator, TermIterator): 375 | """An iterator over the superclasses of one or several `~pronto.Term`.""" 376 | 377 | 378 | class SubpropertiesIterator(SubentitiesIterator, RelationshipIterator): 379 | """An iterator over the subproperties of one or several `~pronto.Relationship`.""" 380 | 381 | 382 | class SuperpropertiesIterator(SuperentitiesIterator, RelationshipIterator): 383 | """An iterator over the superproperties of one or several `~pronto.Relationship`.""" 384 | --------------------------------------------------------------------------------