Repository: pybel/pybel
Pull request #417 (2020-04-01 12:33:34)

Add universal load and dump functions
Closes #417 

- [x] Add universal load/dump functions (05e92583)
- [x] Add from bel script gzipped function (05e92583)
- [x] Update docs (8a22cc6e)

codecov[bot]: # [Codecov](https://codecov.io/gh/pybel/pybel/pull/417?src=pr&el=h1) Report
> Merging [#417](https://codecov.io/gh/pybel/pybel/pull/417?src=pr&el=desc) into [master](https://codecov.io/gh/pybel/pybel/commit/167909f2ad44087e2fe5ebe7ee7c0267f6690a61?src=pr&el=desc) will **increase** coverage by `0.15%`.
> The diff coverage is `88.23%`.

[![Impacted file tree graph](https://codecov.io/gh/pybel/pybel/pull/417/graphs/tree.svg?width=650&token=J7joRTRygG&height=150&src=pr)](https://codecov.io/gh/pybel/pybel/pull/417?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #417      +/-   ##
==========================================
+ Coverage   84.11%   84.26%   +0.15%     
==========================================
  Files         156      157       +1     
  Lines        7729     7743      +14     
  Branches     1038     1041       +3     
==========================================
+ Hits         6501     6525      +24     
+ Misses        994      983      -11     
- Partials      234      235       +1
```


| [Impacted Files](https://codecov.io/gh/pybel/pybel/pull/417?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/pybel/io/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/417/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL19faW5pdF9fLnB5) | `100% <ø> (ø)` | :arrow_up: |
| [src/pybel/struct/graph.py](https://codecov.io/gh/pybel/pybel/pull/417/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9ncmFwaC5weQ==) | `97.53% <ø> (+3.92%)` | :arrow_up: |
| [src/pybel/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/417/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL19faW5pdF9fLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [src/pybel/io/lines.py](https://codecov.io/gh/pybel/pybel/pull/417/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL2xpbmVzLnB5) | `92.3% <50%> (-7.7%)` | :arrow_down: |
| [src/pybel/io/api.py](https://codecov.io/gh/pybel/pybel/pull/417/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL2FwaS5weQ==) | `93.1% <93.1%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pybel/pybel/pull/417?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pybel/pybel/pull/417?src=pr&el=footer). Last update [167909f...3a1e0ec](https://codecov.io/gh/pybel/pybel/pull/417?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/docs/source/index.rst b/docs/source/index.rst
index b6f96b62..0e331e72 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -56,7 +56,6 @@ PyBEL is tested on Python 3.5+ on Mac OS and Linux using
    :name: conversion
 
    reference/io
-   reference/hipathia
 
 .. toctree::
    :caption: Database
diff --git a/docs/source/reference/io.rst b/docs/source/reference/io.rst
index a54b210d..79e12439 100644
--- a/docs/source/reference/io.rst
+++ b/docs/source/reference/io.rst
@@ -2,6 +2,9 @@ Input and Output
 ================
 .. automodule:: pybel.io
 
+.. autofunction:: pybel.load
+.. autofunction:: pybel.dump
+
 Import
 ------
 Parsing Modes
diff --git a/setup.cfg b/setup.cfg
index fd8b300f..877e802f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -92,38 +92,40 @@ docs =
 [options.entry_points]
 console_scripts =
     pybel = pybel.cli:main
-pybel.object_importer =
-    nodelink = pybel.io:from_nodelink_json
-    bytes = pybel.io:from_bytes
-    bel = pybel:from_bel_script
-pybel.file_importer:
-    nodelink = pybel.io:from_nodelink_json_file
-    bytes = pybel.io:from_pickle
-    bel = pybel:from_lines
-pybel.object_exporter:
-    nodelink = pybel.io:to_nodelink
-    umbrella_nodelink = pybel.io:to_umbrella_nodelink
-    graphdati = pybel.io:to_graphdati
-    cx = pybel.io:to_cx
-    jgif = pybel.io:to_jgif
-    bytes = pybel.io:to_bytes
-    bel = pybel.canonicalize:to_bel_lines
-pybel.file_exporter:
-    nodelink = pybel.io:to_nodelink_file
-    nodelink_gz = pybel.io:to_nodelink_gz
-    cx = pybel.io:to_cx_file
-    cx_gz = pybel.io:to_cx_gz
-    graphdati = pybel.io:to_graphdati_file
-    graphdati_gz = pybel.io:to_graphdati_gz
-    jgif = pybel.io:to_jgif_file
-    jgif_gz = pybel.io:to_jgif_gz
-    umbrella_nodelink = pybel.io:to_umbrella_nodelink_file
-    umbrella_nodelink_gz = pybel.io:to_umbrella_nodelink_gz
-    bytes = pybel.io:to_pickle
-    bel = pybel.canonicalize:to_bel_script
-    bel_gz = pybel.canonicalize:to_bel_script_gz
-    tsv = pybel.io:to_tsv
-    gsea = pybel.io:to_gsea
+pybel.importer =
+    bel                   = pybel.io.lines:from_bel_script
+    bel.gz                = pybel.io.lines:from_bel_script_gz
+    bel.nodelink.json     = pybel.io.nodelink:from_nodelink_file
+    bel.nodelink.json.gz  = pybel.io.nodelink:from_nodelink_gz
+    bel.cx.json           = pybel.io.cx:from_cx_file
+    bel.cx.json.gz        = pybel.io.cx:from_cx_gz
+    bel.jgif.json         = pybel.io.jgif:from_jgif_file
+    bel.jgif.json.gz      = pybel.io.jgif:from_jgif_gz
+    bel.pickle            = pybel.io.gpickle:from_pickle
+    bel.gpickle           = pybel.io.gpickle:from_pickle
+    bel.pkl               = pybel.io.gpickle:from_pickle
+    indra.json            = pybel.io.indra:from_indra_statements_json_file
+
+pybel.exporter =
+    bel                   = pybel.canonicalize:to_bel_script
+    bel.gz                = pybel.canonicalize:to_bel_script_gz
+    bel.nodelink.json     = pybel.io.nodelink:to_nodelink_file
+    bel.nodelink.json.gz  = pybel.io.nodelink:to_nodelink_gz
+    bel.cx.json           = pybel.io.cx:to_cx_file
+    bel.cx.json.gz        = pybel.io.cx:to_cx_gz
+    bel.graphdati.json    = pybel.io.graphdati:to_graphdati_file
+    bel.graphdati.json.gz = pybel.io.graphdati:to_graphdati_gz
+    bel.jgif.json         = pybel.io.jgif:to_jgif_file
+    bel.jgif.json.gz      = pybel.io.jgif:to_jgif_gz
+    bel.pickle            = pybel.io.gpickle:to_pickle
+    bel.gpickle           = pybel.io.gpickle:to_pickle
+    bel.pkl               = pybel.io.gpickle:to_pickle
+    indra.json            = pybel.io.indra:to_indra_statements_json_file
+    # No re-import
+    bel.unodelink.json    = pybel.io.umbrella_nodelink:to_umbrella_nodelink_file
+    bel.unodelink.json.gz = pybel.io.umbrella_nodelink:to_umbrella_nodelink_gz
+    tsv                   = pybel.io.tsv.api:to_tsv
+    gsea                  = pybel.io.extras:to_gsea
 
 # Importers for PyKEEN
 pykeen.triples.extension_importer =
diff --git a/src/pybel/__init__.py b/src/pybel/__init__.py
index de171f8d..e8ff66aa 100644
--- a/src/pybel/__init__.py
+++ b/src/pybel/__init__.py
@@ -5,6 +5,7 @@
 from .canonicalize import edge_to_bel, to_bel_script, to_bel_script_gz, to_bel_script_lines
 from .dsl import BaseAbundance, BaseEntity
 from .io import *
+from .io.api import dump, load
 from .manager import Manager, from_database, to_database
 from .struct import BELGraph, Pipeline, Query
 from .struct.operations import union
diff --git a/src/pybel/io/__init__.py b/src/pybel/io/__init__.py
index 17652817..570f193b 100644
--- a/src/pybel/io/__init__.py
+++ b/src/pybel/io/__init__.py
@@ -18,8 +18,8 @@ from .graphml import to_graphml
 from .hetionet import from_hetionet_file, from_hetionet_gz, from_hetionet_json, get_hetionet
 from .hipathia import from_hipathia_dfs, from_hipathia_paths, to_hipathia, to_hipathia_dfs
 from .indra import (
-    from_biopax, from_indra_pickle, from_indra_statements, to_indra_statements,
-    to_indra_statements_json_file,
+    from_biopax, from_indra_pickle, from_indra_statements, from_indra_statements_json, from_indra_statements_json_file,
+    to_indra_statements, to_indra_statements_json, to_indra_statements_json_file,
 )
 from .jgif import (
     from_cbn_jgif, from_jgif, from_jgif_file, from_jgif_gz, from_jgif_jsons, post_jgif, to_jgif,
diff --git a/src/pybel/io/api.py b/src/pybel/io/api.py
new file mode 100644
index 00000000..dc404739
--- /dev/null
+++ b/src/pybel/io/api.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+
+"""Contains the main data structure for PyBEL."""
+
+import os
+from typing import TextIO, Union
+
+from networkx.utils import open_file
+from pkg_resources import iter_entry_points
+
+from ..struct import BELGraph
+
+__all__ = [
+    'load',
+    'dump',
+    'InvalidExtensionError',
+]
+
+#: Mapping from extension to importer function
+IMPORTERS = {
+    entry.name: entry.load()
+    for entry in iter_entry_points(group='pybel.importer')
+}
+
+#: Mapping from extension to exporter function
+EXPORTERS = {
+    entry.name: entry.load()
+    for entry in iter_entry_points(group='pybel.exporter')
+}
+
+
+class InvalidExtensionError(ValueError):
+    """Raised when an invalid extension is used."""
+
+    def __init__(self, path):
+        fname = os.path.basename(path)
+        super().__init__('Invalid extension for file: {}'.format(fname))
+
+
+def load(path: str, **kwargs) -> BELGraph:
+    """Read a BEL graph.
+
+    :param path: The path to a BEL graph in any of the formats
+     with extensions described below
+    :param kwargs: The keyword arguments are passed to the importer
+     function
+    :return: A BEL graph.
+
+    This is the universal loader, which means any file
+    path can be given and PyBEL will look up the appropriate
+    load function. Allowed extensions are:
+
+    - bel
+    - bel.nodelink.json
+    - bel.cx.json
+    - bel.jgif.json
+
+    The previous extensions also support gzipping.
+    Other allowed extensions that don't support gzip are:
+
+    - bel.pickle / bel.gpickle / bel.pkl
+    - indra.json
+    """
+    for extension, importer in IMPORTERS.items():
+        if path.endswith(extension):
+            return importer(path, **kwargs)
+    raise InvalidExtensionError(path=path)
+
+
+def dump(graph: BELGraph, path: str, **kwargs) -> None:
+    """Write a BEL graph.
+
+    :param graph: A BEL graph
+    :param path: The path to which the BEL graph is written.
+    :param kwargs: The keyword arguments are passed to the exporter
+     function
+
+    This is the universal loader, which means any file
+    path can be given and PyBEL will look up the appropriate
+    writer function. Allowed extensions are:
+
+    - bel
+    - bel.nodelink.json
+    - bel.unodelink.json
+    - bel.cx.json
+    - bel.jgif.json
+    - bel.graphdati.json
+
+    The previous extensions also support gzipping.
+    Other allowed extensions that don't support gzip are:
+
+    - bel.pickle / bel.gpickle / bel.pkl
+    - indra.json
+    - tsv
+    - gsea
+    """
+    for extension, exporter in EXPORTERS.items():
+        if path.endswith(extension):
+            return exporter(graph, path, **kwargs)
+    raise InvalidExtensionError(path=path)
diff --git a/src/pybel/io/hipathia.py b/src/pybel/io/hipathia.py
index c6b42bce..0a8718cc 100644
--- a/src/pybel/io/hipathia.py
+++ b/src/pybel/io/hipathia.py
@@ -17,6 +17,7 @@ SIF File
 - HiPathia distinguish between two types of nodes: simple and complex.
 
 Simple nodes:
+
 - Simple nodes may include many genes, but only one is needed to perform the
   function of the node. This could correspond to a protein family of enzymes
   that all have the same function - only one of them needs to be present for
@@ -24,6 +25,7 @@ Simple nodes:
 - Node IDs from simple nodes do not include any space, i.e. N-hsa04370-11.
 
 Complex nodes:
+
 - Complex nodes include different simple nodes and represent protein complexes.
   Each simple node within the complex represents one protein in the complex.
   This node requires the presence of all their simple nodes to perform its
@@ -52,6 +54,7 @@ The columns included are:
 10. ``width``: Default width of the node.
 11. ``height``: Default height of the node.
 12. ``genesList``: List of genes included in each node, with EntrezID:
+
   - Simple nodes: EntrezIDs of the genes included, separated by commas (",") and no spaces, i.e. 56848,8877 for
     node N-hsa04370-11.
   - Complex nodes: GenesList of the simple nodes included, separated by a slash ("/") and no spaces, and in the
diff --git a/src/pybel/io/lines.py b/src/pybel/io/lines.py
index 754966e6..c976d183 100644
--- a/src/pybel/io/lines.py
+++ b/src/pybel/io/lines.py
@@ -2,6 +2,7 @@
 
 """This module contains IO functions for BEL scripts."""
 
+import gzip
 import logging
 from typing import TextIO, Union
 
@@ -13,6 +14,7 @@ from ..struct import BELGraph
 
 __all__ = [
     'from_bel_script',
+    'from_bel_script_gz',
     'from_bel_script_url',
 ]
 
@@ -34,6 +36,12 @@ def from_bel_script(path: Union[str, TextIO], **kwargs) -> BELGraph:
     return graph
 
 
+def from_bel_script_gz(path, **kwargs) -> BELGraph:
+    """Parse a BEL graph from a gzipped BEL Script."""
+    with gzip.open(path, 'rt') as file:
+        return from_bel_script(file, **kwargs)
+
+
 def from_bel_script_url(url: str, **kwargs) -> BELGraph:
     """Load a BEL graph from a URL resource.
 
diff --git a/src/pybel/struct/graph.py b/src/pybel/struct/graph.py
index 251c8c71..6ef06e6c 100644
--- a/src/pybel/struct/graph.py
+++ b/src/pybel/struct/graph.py
@@ -10,7 +10,6 @@ from itertools import chain
 from typing import Any, Dict, Hashable, Iterable, List, Mapping, Optional, Set, TextIO, Tuple, Union
 
 import networkx as nx
-from pkg_resources import iter_entry_points
 
 from .operations import left_full_join, left_node_intersection_join, left_outer_join
 from ..canonicalize import edge_to_bel
@@ -848,36 +847,6 @@ class BELGraph(nx.MultiDiGraph):
         """Print a summary of the graph."""
         print(self.summary_str(), file=file)
 
-    def serialize(self, *, fmt: str = 'nodelink', file: Union[None, str, TextIO] = None, **kwargs):
-        """Serialize the graph to an object or file if given.
-
-        For additional I/O, see the :mod:`pybel.io` module.
-        """
-        if file is None:
-            return self._serialize_object(fmt=fmt, **kwargs)
-        elif isinstance(file, str):
-            with open(file, 'w') as file_obj:
-                self._serialize_file(fmt=fmt, file=file_obj, **kwargs)
-        else:
-            self._serialize_file(fmt=fmt, file=file, **kwargs)
-
-    def _serialize_object(self, *, fmt: str, **kwargs):
-        object_exporter = self._get_serialize_entry_point('pybel.object_exporter', fmt)
-        return object_exporter(self, **kwargs)
-
-    def _serialize_file(self, *, fmt: str, file: TextIO, **kwargs):
-        file_exporter = self._get_serialize_entry_point('pybel.file_exporter', fmt)
-        return file_exporter(self, file, **kwargs)
-
-    @staticmethod
-    def _get_serialize_entry_point(group: str, name: str):
-        entry_points = list(iter_entry_points(group=group, name=name))
-
-        if 0 == len(entry_points):
-            raise ValueError('no format {}'.format(name))
-
-        return entry_points[0].load()
-
 
 def _clean_annotations(annotations_dict: AnnotationsHint) -> AnnotationsDict:
     """Fix the formatting of annotation dict."""


Test patch:
diff --git a/tests/test_struct/test_struct_graph.py b/tests/test_struct/test_struct_graph.py
index 8d4a09f4..b2d79686 100644
--- a/tests/test_struct/test_struct_graph.py
+++ b/tests/test_struct/test_struct_graph.py
@@ -2,12 +2,17 @@
 
 """Tests for data structures in PyBEL."""
 
+import os
+import tempfile
 import unittest
 from io import StringIO
 
+import pybel
+import pybel.examples
 from pybel import BELGraph
-from pybel.constants import CITATION_IDENTIFIER, CITATION_DB, CITATION_TYPE_PUBMED
+from pybel.constants import CITATION_DB, CITATION_IDENTIFIER, CITATION_TYPE_PUBMED
 from pybel.dsl import hgvs, protein
+from pybel.io.api import InvalidExtensionError
 from pybel.testing.utils import n
 
 
@@ -190,3 +195,20 @@ class TestGetGraphProperties(unittest.TestCase):
         graph.add_node_from_data(node)
 
         self.assertEqual(2, graph.number_of_nodes())
+
+
+class TestExtensionIO(unittest.TestCase):
+    def test_io(self):
+        with tempfile.TemporaryDirectory() as directory:
+            path = os.path.join(directory, 'ampk.bel.nodelink.json')
+            pybel.dump(pybel.examples.ampk_graph, path)
+            self.assertTrue(os.path.exists(path))
+            new_graph = pybel.load(path)
+            self.assertIsNotNone(new_graph)
+
+    def test_invalid_io(self):
+        with tempfile.TemporaryDirectory() as directory:
+            path = os.path.join(directory, 'ampk.bel.invalid.json')
+            with self.assertRaises(InvalidExtensionError):
+                pybel.dump(pybel.examples.ampk_graph, path)
+            self.assertFalse(os.path.exists(path))