Repository: catmaid/catpy

Issue:
NameResolver class
Sometimes people have the string name of a project, stack, or annotation, but not its ID, and that can be awkward to find out. I propose a subclass of `CatmaidClientApplication` which can resolve this.

In my own projects, I've written a few private methods to do this sort of thing, so that my public methods can accept either an integer ID or a string name and either will be resolved (with error handling for ambiguous names). Each of the `resolve` methods on the `NameResolver` could be decorated so that they short-circuit if given an integer, to save unnecessary HTTP calls, and a user's own `CatmaidClientApplication` can just run everything through a member `NameResolver`.

Discussion:
aschampion: Since there's a lot of formatting changes around single-vs-double quotes and newlines, is there a style file that should be in the repo?
clbarnes: Those are all added by `black`, or are added for consistency with it (prefer double outer quotes and single inner quotes in e.g. the `setup.py` requirements), which I intend to enforce at some point (#29 ). But that can come in its own feature branch.

When I do that, I'll add an entry to the CONTRIBUTING file.

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 1fd71f4..6a0161c 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -6,6 +6,7 @@ In progress
 -----------
 
 * Allow networkx 2+ to be used
+* Add NameResolver application
 
 
 0.2.0 (2018-05-30)
diff --git a/README.rst b/README.rst
index fd83010..ebf20c6 100644
--- a/README.rst
+++ b/README.rst
@@ -20,4 +20,4 @@ catpy
         :alt: License: MIT
 
 
-Python client for the `CATMAID <https://catmaid.org>`_ API
+Python client for the `CATMAID <https://catmaid.org>`_ API, plus some helpful tools for working with the data.
diff --git a/catpy/__init__.py b/catpy/__init__.py
index 2874d30..7365394 100644
--- a/catpy/__init__.py
+++ b/catpy/__init__.py
@@ -1,10 +1,17 @@
 # -*- coding: utf-8 -*-
 
-__all__ = ['CatmaidClient', 'CoordinateTransformer', 'CatmaidUrl', 'image', 'export']
-
-
-from catpy.client import CatmaidClient, CoordinateTransformer, CatmaidUrl  # noqa
-from catpy import image  # noqa
-from catpy import export  # noqa
+from catpy.client import CatmaidClient, CoordinateTransformer, CatmaidUrl
 from catpy.version import __version__, __version_info__  # noqa
 from catpy.author import __author__, __email__  # noqa
+from catpy import image
+from catpy import applications
+from catpy import exceptions
+
+__all__ = [
+    "CatmaidClient",
+    "CoordinateTransformer",
+    "CatmaidUrl",
+    "image",
+    "applications",
+    "exceptions",
+]
diff --git a/catpy/applications/__init__.py b/catpy/applications/__init__.py
new file mode 100644
index 0000000..3033c4d
--- /dev/null
+++ b/catpy/applications/__init__.py
@@ -0,0 +1,5 @@
+from catpy.applications.base import CatmaidClientApplication
+from catpy.applications.export import ExportWidget
+from catpy.applications.nameresolver import NameResolver
+
+__all__ = ["CatmaidClientApplication", "ExportWidget", "NameResolver"]
diff --git a/catpy/applications/base.py b/catpy/applications/base.py
new file mode 100644
index 0000000..8b07dc7
--- /dev/null
+++ b/catpy/applications/base.py
@@ -0,0 +1,54 @@
+from abc import ABCMeta
+from functools import wraps
+
+from six import add_metaclass
+
+from catpy.client import CatmaidClient, AbstractCatmaidClient
+
+
+@add_metaclass(ABCMeta)
+class CatmaidClientApplication(AbstractCatmaidClient):
+    """
+    An application which uses the CATMAID interface. Users should subclass this when creating their own applications.
+    """
+
+    def __init__(self, catmaid_client):
+        """
+
+        Parameters
+        ----------
+        catmaid_client : CatmaidClient
+        """
+        self._catmaid = catmaid_client
+
+    @property
+    def base_url(self):
+        return self._catmaid.base_url
+
+    @property
+    def project_id(self):
+        return self._catmaid.project_id
+
+    @wraps(CatmaidClient.fetch)
+    def fetch(self, *args, **kwargs):
+        return self._catmaid.fetch(*args, **kwargs)
+
+    @classmethod
+    def from_json(cls, credentials, *args, **kwargs):
+        """
+        Return a CatmaidClientApplication instance whose underlying CatmaidClient object is instantiated from the JSON
+        file as per its own from_json method.
+
+        Parameters
+        ----------
+        path : str
+            Path to the JSON credentials file
+        args, kwargs
+            Arguments passed to constructor of concrete subclass
+
+        Returns
+        -------
+        CatmaidClient
+            Instance of the API, authenticated with
+        """
+        return cls(CatmaidClient.from_json(credentials), *args, **kwargs)
diff --git a/catpy/export.py b/catpy/applications/export.py
similarity index 79%
rename from catpy/export.py
rename to catpy/applications/export.py
index 616edc7..7067a36 100644
--- a/catpy/export.py
+++ b/catpy/applications/export.py
@@ -6,7 +6,7 @@ from copy import deepcopy
 import networkx as nx
 from networkx.readwrite import json_graph
 
-from catpy.client import CatmaidClientApplication
+from catpy.applications.base import CatmaidClientApplication
 
 
 NX_VERSION_INFO = tuple(int(i) for i in nx.__version__.split('.'))
@@ -53,6 +53,7 @@ def convert_nodelink_data(jso):
 
 
 class ExportWidget(CatmaidClientApplication):
+
     def get_swc(self, skeleton_id, linearize_ids=False):
         """
         Get a single skeleton in SWC format.
@@ -67,8 +68,8 @@ class ExportWidget(CatmaidClientApplication):
         str
         """
         return self.get(
-            (self.project_id, 'skeleton', skeleton_id, 'swc'),
-            {'linearize_ids': 'true' if linearize_ids else 'false'}
+            (self.project_id, "skeleton", skeleton_id, "swc"),
+            {"linearize_ids": "true" if linearize_ids else "false"},
         )
 
     def get_connector_archive(self, *args, **kwargs):
@@ -98,7 +99,10 @@ class ExportWidget(CatmaidClientApplication):
         -------
         dict
         """
-        return self.post((self.project_id, 'graphexport', 'json'), data={'skeleton_list': list(skeleton_ids)})
+        return self.post(
+            (self.project_id, "graphexport", "json"),
+            data={"skeleton_list": list(skeleton_ids)},
+        )
 
     def get_networkx(self, *skeleton_ids):
         """
@@ -138,15 +142,17 @@ class ExportWidget(CatmaidClientApplication):
             NeuroML output string
         """
 
-        data = {'skids': list(skeleton_ids)}
+        data = {"skids": list(skeleton_ids)}
 
         if skeleton_inputs:
             if len(skeleton_ids) > 1:
-                warn('More than one skeleton ID was selected: ignoring skeleton input constraints')
+                warn(
+                    "More than one skeleton ID was selected: ignoring skeleton input constraints"
+                )
             else:
-                data['inputs'] = list(skeleton_inputs)
+                data["inputs"] = list(skeleton_inputs)
 
-        return self.post((self.project_id, 'neuroml', 'neuroml_level3_v181'), data=data)
+        return self.post((self.project_id, "neuroml", "neuroml_level3_v181"), data=data)
 
     def get_treenode_and_connector_geometry(self, *skeleton_ids):
         """
@@ -188,17 +194,16 @@ class ExportWidget(CatmaidClientApplication):
 
         for skeleton_id in skeleton_ids:
 
-            data = self.get('{}/{}/1/0/compact-skeleton'.format(self.project_id, skeleton_id))
+            data = self.get(
+                "{}/{}/1/0/compact-skeleton".format(self.project_id, skeleton_id)
+            )
 
-            skeleton = {
-                'treenodes': dict(),
-                'connectors': dict()
-            }
+            skeleton = {"treenodes": dict(), "connectors": dict()}
 
             for treenode in data[0]:
-                skeleton['treenodes'][int(treenode[0])] = {
-                    'location': treenode[3:6],
-                    'parent_id': None if treenode[1] is None else int(treenode[1])
+                skeleton["treenodes"][int(treenode[0])] = {
+                    "location": treenode[3:6],
+                    "parent_id": None if treenode[1] is None else int(treenode[1]),
                 }
 
             for connector in data[1]:
@@ -206,15 +211,14 @@ class ExportWidget(CatmaidClientApplication):
                     continue
 
                 conn_id = int(connector[1])
-                if conn_id not in skeleton['connectors']:
-                    skeleton['connectors'][conn_id] = {
-                        'presynaptic_to': [],
-                        'postsynaptic_to': []
+                if conn_id not in skeleton["connectors"]:
+                    skeleton["connectors"][conn_id] = {
+                        "presynaptic_to": [], "postsynaptic_to": []
                     }
 
-                skeleton['connectors'][conn_id]['location'] = connector[3:6]
-                relation = 'postsynaptic_to' if connector[2] == 1 else 'presynaptic_to'
-                skeleton['connectors'][conn_id][relation].append(connector[0])
+                skeleton["connectors"][conn_id]["location"] = connector[3:6]
+                relation = "postsynaptic_to" if connector[2] == 1 else "presynaptic_to"
+                skeleton["connectors"][conn_id][relation].append(connector[0])
 
             skeletons[int(skeleton_id)] = skeleton
 
diff --git a/catpy/applications/nameresolver.py b/catpy/applications/nameresolver.py
new file mode 100644
index 0000000..35b7c0f
--- /dev/null
+++ b/catpy/applications/nameresolver.py
@@ -0,0 +1,117 @@
+from __future__ import unicode_literals
+from six import string_types
+
+try:
+    from functools import lru_cache
+except ImportError:
+    from backports.functools_lru_cache import lru_cache
+
+from catpy.applications.base import CatmaidClientApplication
+
+
+def name_to_id(fn):
+
+    def wrapper(instance, id_or_name, *args, **kwargs):
+        if isinstance(id_or_name, int):
+            return id_or_name
+        elif isinstance(id_or_name, string_types):
+            return fn(instance, id_or_name, *args, **kwargs)
+        else:
+            raise TypeError("Argument was neither integer ID nor string name")
+
+    return wrapper
+
+
+class NameResolverException(ValueError):
+    pass
+
+
+class NoMatchingNamesException(NameResolverException):
+    pass
+
+
+class MultipleMatchingNamesException(NameResolverException):
+    pass
+
+
+class NameResolver(CatmaidClientApplication):
+    """Catmaid client application which looks up integer database IDs for string names for various objects.
+
+    For convenience, lookup methods short-circuit if given an int (i.e. you can transparently use either
+    the ID or the name of an object).
+
+    HTTP responses are cached where possible, so there may be a performance benefit to sharing NameResolver instances.
+    Furthermore, subsequent lookups of IDs of the same object type (e.g. stack, user)
+    should be much faster than the first.
+
+    Lookup methods ensure that one object matches the given name/title for the given project,
+    raising a NoMatchingNamesException if there are zero matches,
+    and a MultipleMatchingNamesException if there are more than one,
+    both of which subclass NameResolverException, which subclasses ValueError.
+    """
+
+    def _ensure_one(self, match_set, name, obj):
+        if len(match_set) == 0:
+            raise NoMatchingNamesException(
+                "Zero {} objects found with name {} in project {}".format(
+                    obj, repr(name), self.project_id
+                )
+            )
+        elif len(match_set) == 1:
+            return match_set.pop()
+        else:
+            raise MultipleMatchingNamesException(
+                "Multiple {} objects ({}) found with name {} in project {}".format(
+                    obj,
+                    ", ".join(str(i) for i in sorted(match_set)),
+                    name,
+                    self.project_id,
+                )
+            )
+
+    @lru_cache(1)
+    def _get_stacks(self):
+        return self.get((self.project_id, "stacks"))
+
+    @name_to_id
+    def get_stack_id(self, title):
+        """Get the ID of the stack with the given title.
+
+        Parameters
+        ----------
+        title : str or int
+            Stack title
+
+        Returns
+        -------
+        int
+        """
+        matching_ids = set()
+        for stack in self._get_stacks():
+            if stack["title"] == title:
+                matching_ids.add(stack["id"])
+
+        return self._ensure_one(matching_ids, title, "stack")
+
+    @lru_cache(1)
+    def _get_user_list(self):
+        return self.get("user-list")
+
+    @name_to_id
+    def get_user_id(self, name):
+        """Get the ID of the user with the given login or full name
+
+        Parameters
+        ----------
+        name : str or int
+
+        Returns
+        -------
+        int
+        """
+        matching_ids = set()
+        for user in self._get_user_list():
+            if name in [user["login"], user["full_name"]]:
+                matching_ids.add(user["id"])
+
+        return self._ensure_one(matching_ids, name, "user")
diff --git a/catpy/client.py b/catpy/client.py
index d6d90be..10379e5 100644
--- a/catpy/client.py
+++ b/catpy/client.py
@@ -4,7 +4,6 @@ from __future__ import division, unicode_literals
 
 import json
 import webbrowser
-from functools import wraps
 from abc import ABCMeta, abstractmethod
 from warnings import warn
 
@@ -317,54 +316,6 @@ class CatmaidClient(AbstractCatmaidClient):
             return response.text
 
 
-@add_metaclass(ABCMeta)
-class CatmaidClientApplication(AbstractCatmaidClient):
-    """
-    An application which uses the CATMAID interface. Users should subclass this when creating their own applications.
-    """
-
-    def __init__(self, catmaid_client):
-        """
-
-        Parameters
-        ----------
-        catmaid_client : CatmaidClient
-        """
-        self._catmaid = catmaid_client
-
-    @property
-    def base_url(self):
-        return self._catmaid.base_url
-
-    @property
-    def project_id(self):
-        return self._catmaid.project_id
-
-    @wraps(CatmaidClient.fetch)
-    def fetch(self, *args, **kwargs):
-        return self._catmaid.fetch(*args, **kwargs)
-
-    @classmethod
-    def from_json(cls, credentials, *args, **kwargs):
-        """
-        Return a CatmaidClientApplication instance whose underlying CatmaidClient object is instantiated from the JSON
-        file as per its own from_json method.
-
-        Parameters
-        ----------
-        path : str
-            Path to the JSON credentials file
-        args, kwargs
-            Arguments passed to constructor of concrete subclass
-
-        Returns
-        -------
-        CatmaidClient
-            Instance of the API, authenticated with
-        """
-        return cls(CatmaidClient.from_json(credentials), *args, **kwargs)
-
-
 class CoordinateTransformer(object):
     def __init__(self, resolution=None, translation=None, orientation=StackOrientation.XY, scale_z=False):
         """
@@ -682,7 +633,7 @@ class CatmaidUrl(object):
 
         Parameters
         ----------
-        catmaid_client : CatmaidClient or CatmaidClientApplication
+        catmaid_client : CatmaidClient or catpy.applications.base.CatmaidClientApplication
         stack_group_id : int
         stack_id : int
         scale : float
diff --git a/catpy/exceptions.py b/catpy/exceptions.py
new file mode 100644
index 0000000..14ad025
--- /dev/null
+++ b/catpy/exceptions.py
@@ -0,0 +1,7 @@
+"""Module which collects exceptions defined elsewhere, for convenient access"""
+from catpy.client import WrappedCatmaidException  # noqa
+from catpy.applications.nameresolver import (  # noqa
+    NameResolverException,
+    NoMatchingNamesException,
+    MultipleMatchingNamesException,
+)
diff --git a/requirements/prod.txt b/requirements/prod.txt
index bcf63cb..8ca8ee5 100644
--- a/requirements/prod.txt
+++ b/requirements/prod.txt
@@ -1,5 +1,6 @@
 enum34==1.1.6; python_version < "3.4"
 futures==3.2.0; python_version < "3.3"
+backports.functools_lru_cache==1.5; python_version < "3.3"
 networkx==2.2
 numpy==1.12.1
 Pillow==5.0.0
diff --git a/setup.py b/setup.py
index 7608d28..2cd650d 100644
--- a/setup.py
+++ b/setup.py
@@ -21,6 +21,7 @@ with open(os.path.join(here, 'catpy', 'author.py')) as f:
 requirements = [
     "enum34>=1.1; python_version < '3.4'",
     "futures>=3.2; python_version < '3.3'",
+    "backports.functools_lru_cache>=1.5; python_version < '3.3'",
     'networkx>=1.11',
     'numpy>=1.12',
     'Pillow>=5.0',


Test patch:
diff --git a/tests/test_catmaid_client_application.py b/tests/test_catmaid_client_application.py
index 64e8af1..0d9ea08 100644
--- a/tests/test_catmaid_client_application.py
+++ b/tests/test_catmaid_client_application.py
@@ -5,8 +5,8 @@ except ImportError:
 
 import pytest
 
-from catpy.client import CatmaidClient, CatmaidClientApplication
-
+from catpy.client import CatmaidClient
+from catpy.applications.base import CatmaidClientApplication
 
 PROJECT_ID = 10
 BASE_URL = 'http://not-catmaid.org'
diff --git a/tests/test_export.py b/tests/test_export.py
index 2ba5e82..5e49e1d 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -7,7 +7,7 @@ from networkx.readwrite import json_graph
 
 import pytest
 
-from catpy.export import ExportWidget, convert_nodelink_data
+from catpy.applications.export import ExportWidget, convert_nodelink_data
 
 try:
     from unittest.mock import Mock
diff --git a/tests/test_nameresolver.py b/tests/test_nameresolver.py
new file mode 100644
index 0000000..24f2f5c
--- /dev/null
+++ b/tests/test_nameresolver.py
@@ -0,0 +1,84 @@
+try:
+    from mock import Mock
+except ImportError:
+    from unittest.mock import Mock
+
+import pytest
+
+from catpy.applications.nameresolver import (
+    NameResolver,
+    MultipleMatchingNamesException,
+    NoMatchingNamesException,
+)
+
+
+PROJECT_ID = 1
+
+
+@pytest.fixture
+def stacks_response():
+    return [
+        {"title": "stack two", "id": 2},
+        {"title": "stack five", "id": 5},
+        {"title": "stack multi", "id": 10},
+        {"title": "stack multi", "id": 11},
+    ]
+
+
+@pytest.fixture
+def users_response():
+    return [
+        {"full_name": "User Three", "login": "user_three", "id": 3},
+        {"full_name": "User Four", "login": "user_four", "id": 4},
+    ]
+
+
+@pytest.fixture
+def name_resolver(stacks_response, users_response):
+    catmaid = Mock()
+    catmaid.project_id = PROJECT_ID
+
+    def get_response(url, *args, **kwargs):
+        if url == (PROJECT_ID, "stacks"):
+            return stacks_response
+        elif url == "user-list":
+            return users_response
+        else:
+            return Mock()
+
+    catmaid.fetch = Mock(side_effect=get_response)
+    return NameResolver(catmaid)
+
+
+def test_shortcircuit(name_resolver):
+    assert name_resolver.get_stack_id(2) == 2
+    name_resolver._catmaid.fetch.assert_not_called()
+
+
+def test_nomatch(name_resolver):
+    with pytest.raises(NoMatchingNamesException):
+        name_resolver.get_stack_id("stack one million")
+
+
+def test_multimatch(name_resolver):
+    with pytest.raises(MultipleMatchingNamesException):
+        name_resolver.get_stack_id("stack multi")
+
+
+def test_cache(name_resolver):
+    name_resolver.get_stack_id("stack two")
+    name_resolver.get_stack_id("stack five")
+
+    name_resolver._catmaid.fetch.assert_called_once()
+
+
+def test_get_stack_id(name_resolver):
+    assert name_resolver.get_stack_id("stack two") == 2
+
+
+def test_get_user_id_name(name_resolver):
+    assert name_resolver.get_user_id("User Three") == 3
+
+
+def test_get_user_id_login(name_resolver):
+    assert name_resolver.get_user_id("user_four") == 4