devserver: display documentation for nested exposed methods

Previously, the /index and /doc/foo URLs would not handle exposed
methods within objects that are nested in the CherryPy root object. This
fixes this shortcoming, properly distinguishing between function and
non-function exposed members, and tracing the latter recursively.

* Discovery/validation of exposed methods properly ensures that an
  exposed member is indeed a function; if it isn't a function, it'll
  check it recursively for nested exposed members.

* Rearranged the relevant functions outside the DevServerRoot class, as
  they now take arbitrary root objects.

BUG=None
TEST=Devserver outputs the desired index, docstrings; passes unit tests

Change-Id: I1bee4401092c1d29f4c5926c293ab54e89c76e33
Reviewed-on: https://gerrit.chromium.org/gerrit/34436
Reviewed-by: Scott Zawalski <scottz@chromium.org>
Reviewed-by: Chris Sosa <sosa@chromium.org>
Commit-Ready: Gilad Arnold <garnold@chromium.org>
Tested-by: Gilad Arnold <garnold@chromium.org>
diff --git a/devserver.py b/devserver.py
index 95bcfb6..2f63f5d 100755
--- a/devserver.py
+++ b/devserver.py
@@ -14,6 +14,7 @@
 import subprocess
 import tempfile
 import threading
+import types
 
 import cherrypy
 
@@ -163,6 +164,71 @@
   _Log('archive dir: %s ready to be used to serve images.' % image_dir)
 
 
+def _GetRecursiveMemberObject(root, member_list):
+  """Returns an object corresponding to a nested member list.
+
+  Args:
+    root: the root object to search
+    member_list: list of nested members to search
+  Returns:
+    An object corresponding to the member name list; None otherwise.
+  """
+  for member in member_list:
+    next_root = root.__class__.__dict__.get(member)
+    if not next_root:
+      return None
+    root = next_root
+  return root
+
+
+def _IsExposed(name):
+  """Returns True iff |name| has an `exposed' attribute and it is set."""
+  return hasattr(name, 'exposed') and name.exposed
+
+
+def _GetExposedMethod(root, nested_member, ignored=[]):
+  """Returns a CherryPy-exposed method, if such exists.
+
+  Args:
+    root: the root object for searching
+    nested_member: a slash-joined path to the nested member
+    ignored: method paths to be ignored
+  Returns:
+    A function object corresponding to the path defined by |member_list| from
+    the |root| object, if the function is exposed and not ignored; None
+    otherwise.
+  """
+  method = (nested_member not in ignored and
+            _GetRecursiveMemberObject(root, nested_member.split('/')))
+  if (method and type(method) == types.FunctionType and _IsExposed(method)):
+    return method
+
+
+def _FindExposedMethods(root, prefix, unlisted=[]):
+  """Finds exposed CherryPy methods.
+
+  Args:
+    root: the root object for searching
+    prefix: slash-joined chain of members leading to current object
+    unlisted: URLs to be excluded regardless of their exposed status
+  Returns:
+    List of exposed URLs that are not unlisted.
+  """
+  method_list = []
+  for member in sorted(root.__class__.__dict__.keys()):
+    prefixed_member = prefix + '/' + member if prefix else member
+    if prefixed_member in unlisted:
+      continue
+    member_obj = root.__class__.__dict__[member]
+    if _IsExposed(member_obj):
+      if type(member_obj) == types.FunctionType:
+        method_list.append(prefixed_member)
+      else:
+        method_list += _FindExposedMethods(
+            member_obj, prefixed_member, unlisted)
+  return method_list
+
+
 class ApiRoot(object):
   """RESTful API for Dev Server information."""
   exposed = True
@@ -456,31 +522,6 @@
     return (downloader.ImagesDownloader(
         updater.static_dir).Download(archive_url, image_types))
 
-  def _get_exposed_method(self, name, unlisted=[]):
-    """Checks whether a method is exposed as CherryPy URL.
-
-    Args:
-      name: method name to check
-      unlisted: methods to be excluded regardless of their exposed status
-    Returns:
-      Function object if method is exposed and not unlisted, None otherwise.
-    """
-    method = name not in unlisted and self.__class__.__dict__.get(name)
-    if method and hasattr(method, 'exposed') and method.exposed:
-      return method
-    return None
-
-  def _find_exposed_methods(self, unlisted=[]):
-    """Finds exposed CherryPy methods.
-
-    Args:
-      unlisted: methods to be excluded regardless of their exposed status
-    Returns:
-      List of exposed methods that are not unlisted.
-    """
-    return [name for name in self.__class__.__dict__.keys()
-                 if self._get_exposed_method(name, unlisted)]
-
   @cherrypy.expose
   def index(self):
     """Presents a welcome message and documentation links."""
@@ -492,8 +533,8 @@
             '%s' %
             '<br>\n'.join(
                 [('<a href=doc/%s>%s</a>' % (name, name))
-                 for name in self._find_exposed_methods(
-                     unlisted=self._UNLISTED_METHODS)]))
+                 for name in _FindExposedMethods(
+                     self, '', unlisted=self._UNLISTED_METHODS)]))
 
   @cherrypy.expose
   def doc(self, *args):
@@ -502,8 +543,8 @@
     Example:
       http://myhost/doc/update
     """
-    name = args[0]
-    method = self._get_exposed_method(name)
+    name = '/'.join(args)
+    method = _GetExposedMethod(self, name)
     if not method:
       raise DevServerError("No exposed method named `%s'" % name)
     if not method.__doc__: