From ccccbb25154e843dc5046e4c2754d026b27955ac Mon Sep 17 00:00:00 2001 From: Charles Tapley Hoyt Date: Mon, 28 Jun 2021 15:39:24 -0400 Subject: [PATCH] Use generic type annotation for nodes in NxOntology (#9) merges https://github.com/related-sciences/nxontology/pull/9 --- nxontology/examples.py | 8 +++--- nxontology/imports.py | 8 +++--- nxontology/ontology.py | 42 +++++++++++++++-------------- nxontology/tests/conftest.py | 4 +-- nxontology/tests/ontology_test.py | 45 ++++++++++++++++--------------- nxontology/tests/viz_test.py | 2 +- nxontology/viz.py | 4 +-- 7 files changed, 58 insertions(+), 55 deletions(-) diff --git a/nxontology/examples.py b/nxontology/examples.py index ea70434..db9c675 100644 --- a/nxontology/examples.py +++ b/nxontology/examples.py @@ -1,7 +1,7 @@ from nxontology import NXOntology -def create_metal_nxo() -> NXOntology: +def create_metal_nxo() -> NXOntology[str]: """ Metals ontology from Fig 1 of "Semantic Similarity Definition" at https://doi.org/10.1016/B978-0-12-809633-8.20401-9. @@ -9,7 +9,7 @@ def create_metal_nxo() -> NXOntology: Note edge direction is opposite of the drawing. Edges go from general to specific. """ - nxo = NXOntology() + nxo: NXOntology[str] = NXOntology() nxo.graph.graph["name"] = "Metals" nxo.set_graph_attributes(node_label_attribute="{node}") edges = [ @@ -27,13 +27,13 @@ def create_metal_nxo() -> NXOntology: return nxo -def create_disconnected_nxo() -> NXOntology: +def create_disconnected_nxo() -> NXOntology[str]: """ Fictitious ontology with disjoint / disconnected components. Has multiple root nodes. Helpful for testing. https://github.com/related-sciences/nxontology/issues/4 """ - nxo = NXOntology() + nxo: NXOntology[str] = NXOntology() nxo.add_node("water") edges = [ ("metal", "precious"), diff --git a/nxontology/imports.py b/nxontology/imports.py index 7de629a..db3d12b 100644 --- a/nxontology/imports.py +++ b/nxontology/imports.py @@ -8,13 +8,13 @@ from nxontology.exceptions import NodeNotFound -def pronto_to_nxontology(onto: Prontology) -> NXOntology: +def pronto_to_nxontology(onto: Prontology) -> NXOntology[str]: """ Create an `NXOntology` from an input `pronto.Ontology`. Obsolete terms are omitted as nodes. Only is_a / subClassOf relationships are used for edges. """ - nxo = NXOntology() + nxo: NXOntology[str] = NXOntology() nxo.pronto = onto # type: ignore [attr-defined] for term in onto.terms(): if term.obsolete: @@ -41,7 +41,7 @@ def pronto_to_nxontology(onto: Prontology) -> NXOntology: return nxo -def from_obo_library(slug: str) -> NXOntology: +def from_obo_library(slug: str) -> NXOntology[str]: """ Read ontology from . Delegates to [`pronto.Ontology.from_obo_library`](https://pronto.readthedocs.io/en/stable/api/pronto.Ontology.html#pronto.Ontology.from_obo_library). @@ -52,7 +52,7 @@ def from_obo_library(slug: str) -> NXOntology: return nxo -def from_file(handle: Union[BinaryIO, str, "PathLike[AnyStr]"]) -> NXOntology: +def from_file(handle: Union[BinaryIO, str, "PathLike[AnyStr]"]) -> NXOntology[str]: """ Read ontology in OBO, OWL, or JSON (OBO Graphs) format via pronto. diff --git a/nxontology/ontology.py b/nxontology/ontology.py index f59f75b..d6fddc0 100644 --- a/nxontology/ontology.py +++ b/nxontology/ontology.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import abc import functools import itertools @@ -7,6 +9,7 @@ Any, Callable, Dict, + Generic, Hashable, Iterable, List, @@ -33,8 +36,7 @@ def frozen(self) -> bool: # Type definitions. networkx does not declare types. # https://github.com/networkx/networkx/issues/3988#issuecomment-639969263 -Node = Hashable -Node_Set = Set[Node] +Node = TypeVar("Node", bound=Hashable) T = TypeVar("T") T_Freezable = TypeVar("T_Freezable", bound=Freezable) @@ -69,7 +71,7 @@ def wrapped(self: T_Freezable) -> T: return wrapped -class NXOntology(Freezable): +class NXOntology(Freezable, Generic[Node]): """ Encapsulate a networkx.DiGraph to represent an ontology. Regarding edge directionality, parent terms should point to child term. @@ -79,7 +81,7 @@ class NXOntology(Freezable): def __init__(self, graph: Optional[nx.DiGraph] = None): self.graph = nx.DiGraph(graph) self.check_is_dag() - self._node_info_cache: Dict[Hashable, Node_Info] = {} + self._node_info_cache: Dict[Node, Node_Info[Node]] = {} def check_is_dag(self) -> None: if not nx.is_directed_acyclic_graph(self.graph): @@ -103,7 +105,7 @@ def write_node_link_json(self, path: str) -> None: json.dump(obj=nld, fp=write_file, indent=2, ensure_ascii=False) @classmethod - def read_node_link_json(cls, path: str) -> "NXOntology": + def read_node_link_json(cls, path: str) -> NXOntology[Node]: """ Retrun a new graph from node-link format as written by `write_node_link_json`. """ @@ -141,7 +143,7 @@ def add_edge(self, u_of_edge: Node, v_of_edge: Node, **attr: Any) -> None: @property # type: ignore [misc] @cache_on_frozen - def roots(self) -> "Node_Set": + def roots(self) -> Set[Node]: """ Return all top-level nodes. """ @@ -155,7 +157,7 @@ def roots(self) -> "Node_Set": @property # type: ignore [misc] @cache_on_frozen - def leaves(self) -> "Node_Set": + def leaves(self) -> Set[Node]: """ Return all bottom-level nodes. """ @@ -185,7 +187,7 @@ def similarity( node_0: Node, node_1: Node, ic_metric: str = "intrinsic_ic_sanchez", - ) -> "SimilarityIC": + ) -> SimilarityIC[Node]: """SimilarityIC instance for the specified nodes""" return SimilarityIC(self, node_0, node_1, ic_metric) @@ -219,7 +221,7 @@ def compute_similarities( metrics = self.similarity_metrics(node_0, node_1, ic_metric=ic_metric) yield metrics - def node_info(self, node: Node) -> "Node_Info": + def node_info(self, node: Node) -> Node_Info[Node]: """ Return Node_Info instance for `node`. If frozen, cache node info in `self._node_info_cache`. @@ -271,7 +273,7 @@ def set_graph_attributes( self.graph.graph["node_url_attribute"] = node_url_attribute -class Node_Info(Freezable): +class Node_Info(Freezable, Generic[Node]): """ Compute metrics and values for a node of an NXOntology. Includes intrinsic information content (IC) metrics. @@ -288,7 +290,7 @@ class Node_Info(Freezable): Each ic_metric has a scaled version accessible by adding a _scaled suffix. """ - def __init__(self, nxo: NXOntology, node: Node): + def __init__(self, nxo: NXOntology[Node], node: Node): if node not in nxo.graph: raise NodeNotFound(f"{node} not in graph.") self.nxo = nxo @@ -337,7 +339,7 @@ def data(self) -> Dict[Any, Any]: @property # type: ignore [misc] @cache_on_frozen - def ancestors(self) -> Node_Set: + def ancestors(self) -> Set[Node]: """ Get ancestors of node in graph, including the node itself. Ancestors refers to more general concepts in an ontology, @@ -350,7 +352,7 @@ def ancestors(self) -> Node_Set: @property # type: ignore [misc] @cache_on_frozen - def descendants(self) -> Node_Set: + def descendants(self) -> Set[Node]: """ Get descendants of node in graph, including the node itself. Descendants refers to more specific concepts in an ontology, @@ -445,7 +447,7 @@ def intrinsic_ic_sanchez_scaled(self) -> float: return self.intrinsic_ic_sanchez / math.log(len(self.nxo.leaves) + 1) -class Similarity(Freezable): +class Similarity(Freezable, Generic[Node]): """ Compute intrinsic similarity metrics for a pair of nodes. """ @@ -461,7 +463,7 @@ class Similarity(Freezable): "batet_log", ] - def __init__(self, nxo: NXOntology, node_0: Node, node_1: Node): + def __init__(self, nxo: NXOntology[Node], node_0: Node, node_1: Node): self.nxo = nxo self.node_0 = node_0 self.node_1 = node_1 @@ -484,12 +486,12 @@ def node_1_subsumes_0(self) -> bool: @property # type: ignore [misc] @cache_on_frozen - def common_ancestors(self) -> "Node_Set": + def common_ancestors(self) -> Set[Node]: return self.info_0.ancestors & self.info_1.ancestors @property # type: ignore [misc] @cache_on_frozen - def union_ancestors(self) -> "Node_Set": + def union_ancestors(self) -> Set[Node]: return self.info_0.ancestors | self.info_1.ancestors @property @@ -531,7 +533,7 @@ def results(self, keys: Optional[List[str]] = None) -> Dict[str, Any]: return {key: getattr(self, key) for key in keys} -class SimilarityIC(Similarity): +class SimilarityIC(Similarity[Node]): """ Compute intrinsic similarity metrics for a pair of nodes, including Information Content (IC) derived metrics. @@ -540,7 +542,7 @@ class SimilarityIC(Similarity): def __init__( self, - graph: NXOntology, + graph: NXOntology[Node], node_0: Node, node_1: Node, ic_metric: str = "intrinsic_ic_sanchez", @@ -565,7 +567,7 @@ def __init__( "jiang_seco", ] - def _get_ic(self, node_info: Node_Info, ic_metric: str) -> float: + def _get_ic(self, node_info: Node_Info[Node], ic_metric: str) -> float: ic = getattr(node_info, ic_metric) assert isinstance(ic, float) return ic diff --git a/nxontology/tests/conftest.py b/nxontology/tests/conftest.py index c903ce6..e67ee58 100644 --- a/nxontology/tests/conftest.py +++ b/nxontology/tests/conftest.py @@ -5,13 +5,13 @@ @pytest.fixture -def metal_nxo() -> NXOntology: +def metal_nxo() -> NXOntology[str]: """Returns a newly created metal ontology for each test.""" return create_metal_nxo() @pytest.fixture(scope="module") -def metal_nxo_frozen() -> NXOntology: +def metal_nxo_frozen() -> NXOntology[str]: """ Frozen metals ontology, scoped such that all tests in this module will receive the same NXOntology instance. diff --git a/nxontology/tests/ontology_test.py b/nxontology/tests/ontology_test.py index 0a0404f..f52d1a9 100644 --- a/nxontology/tests/ontology_test.py +++ b/nxontology/tests/ontology_test.py @@ -12,41 +12,41 @@ from nxontology.ontology import Node_Info, NXOntology, Similarity, SimilarityIC -def test_add_node(metal_nxo: NXOntology) -> None: +def test_add_node(metal_nxo: NXOntology[str]) -> None: assert "brass" not in metal_nxo.graph metal_nxo.add_node("brass", color="#b5a642") assert "brass" in metal_nxo.graph assert metal_nxo.graph.nodes["brass"]["color"] == "#b5a642" -def test_add_node_duplicate(metal_nxo: NXOntology) -> None: +def test_add_node_duplicate(metal_nxo: NXOntology[str]) -> None: with pytest.raises(DuplicateError): metal_nxo.add_node("gold") -def test_add_edge(metal_nxo: NXOntology) -> None: +def test_add_edge(metal_nxo: NXOntology[str]) -> None: metal_nxo.add_edge("metal", "gold", note="already implied") assert metal_nxo.graph.has_edge("metal", "gold") assert metal_nxo.graph.edges["metal", "gold"]["note"] == "already implied" -def test_add_edge_missing_node(metal_nxo: NXOntology) -> None: +def test_add_edge_missing_node(metal_nxo: NXOntology[str]) -> None: assert "brass" not in metal_nxo.graph with pytest.raises(NodeNotFound): metal_nxo.add_edge("coinage", "brass") -def test_add_edge_duplicate(metal_nxo: NXOntology) -> None: +def test_add_edge_duplicate(metal_nxo: NXOntology[str]) -> None: with pytest.raises(DuplicateError): metal_nxo.add_edge("coinage", "gold") def test_nxontology_read_write_node_link_json( - metal_nxo: NXOntology, tmp_path: pathlib.Path + metal_nxo: NXOntology[str], tmp_path: pathlib.Path ) -> None: path = str(tmp_path.joinpath("node-link.json")) metal_nxo.write_node_link_json(path) - metal_nxo_roundtrip = NXOntology.read_node_link_json(path) + metal_nxo_roundtrip: NXOntology[str] = NXOntology.read_node_link_json(path) assert metal_nxo is not metal_nxo_roundtrip assert isinstance(metal_nxo_roundtrip, NXOntology) assert networkx.is_isomorphic(metal_nxo.graph, metal_nxo_roundtrip.graph) @@ -54,7 +54,7 @@ def test_nxontology_read_write_node_link_json( assert list(metal_nxo.graph.nodes) == list(metal_nxo_roundtrip.graph.nodes) -def test_nxontology_check_is_dag(metal_nxo: NXOntology) -> None: +def test_nxontology_check_is_dag(metal_nxo: NXOntology[str]) -> None: metal_nxo.check_is_dag() # add an edge that makes the graph cyclic metal_nxo.graph.add_edge("copper", "metal") @@ -62,17 +62,17 @@ def test_nxontology_check_is_dag(metal_nxo: NXOntology) -> None: metal_nxo.check_is_dag() -def test_nxontology_roots(metal_nxo_frozen: NXOntology) -> None: +def test_nxontology_roots(metal_nxo_frozen: NXOntology[str]) -> None: roots = metal_nxo_frozen.roots assert roots == {"metal"} -def test_nxontology_leaves(metal_nxo_frozen: NXOntology) -> None: +def test_nxontology_leaves(metal_nxo_frozen: NXOntology[str]) -> None: leaves = metal_nxo_frozen.leaves assert leaves == {"copper", "gold", "palladium", "platinum", "silver"} -def test_node_info_root(metal_nxo_frozen: NXOntology) -> None: +def test_node_info_root(metal_nxo_frozen: NXOntology[str]) -> None: """Test metal node_info. Metal is the only root node.""" info = metal_nxo_frozen.node_info("metal") assert info.node == "metal" @@ -81,7 +81,7 @@ def test_node_info_root(metal_nxo_frozen: NXOntology) -> None: assert info.depth == 0 -def test_node_info_gold(metal_nxo_frozen: NXOntology) -> None: +def test_node_info_gold(metal_nxo_frozen: NXOntology[str]) -> None: print(metal_nxo_frozen.graph.graph) gold_info = metal_nxo_frozen.node_info("gold") assert gold_info.node == "gold" @@ -93,7 +93,7 @@ def test_node_info_gold(metal_nxo_frozen: NXOntology) -> None: assert gold_info.depth == 2 -def test_set_graph_attributes(metal_nxo: NXOntology) -> None: +def test_set_graph_attributes(metal_nxo: NXOntology[str]) -> None: assert metal_nxo.name == "Metals" metal_nxo.graph.nodes["gold"]["metal_label"] = "test_label" metal_nxo.graph.nodes["gold"]["metal_identifier"] = 1 @@ -117,12 +117,12 @@ def test_set_graph_attributes(metal_nxo: NXOntology) -> None: assert silver_info.url is None -def test_node_info_not_found(metal_nxo_frozen: NXOntology) -> None: +def test_node_info_not_found(metal_nxo_frozen: NXOntology[str]) -> None: with pytest.raises(NodeNotFound, match="not-a-metal not in graph"): metal_nxo_frozen.node_info("not-a-metal") -def test_intrinsic_ic_unscaled(metal_nxo_frozen: NXOntology) -> None: +def test_intrinsic_ic_unscaled(metal_nxo_frozen: NXOntology[str]) -> None: assert metal_nxo_frozen.n_nodes == 8 # number of descendants per node including self n_descendants = [ @@ -151,7 +151,7 @@ def test_intrinsic_ic_unscaled(metal_nxo_frozen: NXOntology) -> None: ], ) def test_similarity_batet( - metal_nxo_frozen: NXOntology, node_0: str, node_1: str, expected: float + metal_nxo_frozen: NXOntology[str], node_0: str, node_1: str, expected: float ) -> None: sim = Similarity(metal_nxo_frozen, node_0, node_1) assert sim.batet == expected @@ -168,20 +168,21 @@ def test_similarity_batet( ], ) def test_similarity_mica( - metal_nxo_frozen: NXOntology, node_0: str, node_1: str, expected: float + metal_nxo_frozen: NXOntology[str], node_0: str, node_1: str, expected: str ) -> None: sim = SimilarityIC( metal_nxo_frozen, node_0, node_1, ic_metric="intrinsic_ic_sanchez" ) + assert sim.mica is not None assert sim.mica == expected -def test_similarity_unsupported_metric(metal_nxo_frozen: NXOntology) -> None: +def test_similarity_unsupported_metric(metal_nxo_frozen: NXOntology[str]) -> None: with pytest.raises(ValueError, match="not a supported ic_metric"): SimilarityIC(metal_nxo_frozen, "gold", "silver", ic_metric="ic_unsupported") -def test_cache_on_frozen_leaves(metal_nxo: NXOntology) -> None: +def test_cache_on_frozen_leaves(metal_nxo: NXOntology[str]) -> None: # cache disabled leaves = metal_nxo.leaves assert "leaves" not in getattr(metal_nxo, "__method_cache", {}) @@ -195,7 +196,7 @@ def test_cache_on_frozen_leaves(metal_nxo: NXOntology) -> None: assert metal_nxo.leaves is cached_leaves -def test_cache_on_node_info(metal_nxo: NXOntology) -> None: +def test_cache_on_node_info(metal_nxo: NXOntology[str]) -> None: # cache disabled assert not metal_nxo.frozen gold = metal_nxo.node_info("gold") @@ -209,7 +210,7 @@ def test_cache_on_node_info(metal_nxo: NXOntology) -> None: assert metal_nxo.node_info("gold") is cached_gold -def get_similarity_tsv(nxo: NXOntology) -> str: +def get_similarity_tsv(nxo: NXOntology[str]) -> str: """ Returns TSV text for all similarity metrics on the provided ontology. """ @@ -231,7 +232,7 @@ def get_similarity_tsv(nxo: NXOntology) -> str: class Ontology: name: str sim_path: pathlib.Path - ctor: Callable[[], NXOntology] + ctor: Callable[[], NXOntology[str]] directory: pathlib.Path = pathlib.Path(__file__).parent diff --git a/nxontology/tests/viz_test.py b/nxontology/tests/viz_test.py index e7eefc4..8eaa92c 100644 --- a/nxontology/tests/viz_test.py +++ b/nxontology/tests/viz_test.py @@ -22,7 +22,7 @@ def setup_module(): @pytest.mark.parametrize("source,target", [("gold", "silver"), ("palladium", "metal")]) @pytest.mark.parametrize("nodes_str", ["all", "union_ancestors"]) def test_create_graphviz( - metal_nxo_frozen: NXOntology, + metal_nxo_frozen: NXOntology[str], source: str, target: str, nodes_str: str, diff --git a/nxontology/viz.py b/nxontology/viz.py index 57519bd..eee862e 100644 --- a/nxontology/viz.py +++ b/nxontology/viz.py @@ -7,7 +7,7 @@ def create_similarity_graphviz( - sim: SimilarityIC, + sim: SimilarityIC[Node], nodes: Optional[Iterable[Node]] = None, ) -> "AGraph": """ @@ -78,7 +78,7 @@ def create_similarity_graphviz( return gviz -def get_verbose_node_label(info: Node_Info) -> str: +def get_verbose_node_label(info: Node_Info[Node]) -> str: """Return verbose label like 'label (identifier)'.""" verbose_label = info.label assert isinstance(verbose_label, str)