[tor-commits] [stem/master] Implementing Controller.get_info

atagar at torproject.org atagar at torproject.org
Mon May 28 03:56:38 UTC 2012


commit 717c5aaecc8e61cee5ca8f1bae80b3eade5f6985
Author: Damian Johnson <atagar at torproject.org>
Date:   Sun May 27 20:29:51 2012 -0700

    Implementing Controller.get_info
    
    Implementation and testing (both unit and a little integ) for GETINFO queries.
    There's still several todo notes to clean up, but the method itself is done.
---
 run_tests.py                            |    2 +
 stem/control.py                         |  129 +++++++++++++++++++++++++++++++
 test/integ/connection/authentication.py |    2 +-
 test/integ/control/controller.py        |   37 +++++++++
 test/integ/socket/control_message.py    |    4 -
 test/runner.py                          |   23 ++++--
 test/unit/control/__init__.py           |    6 ++
 test/unit/control/getinfo.py            |  119 ++++++++++++++++++++++++++++
 8 files changed, 308 insertions(+), 14 deletions(-)

diff --git a/run_tests.py b/run_tests.py
index d123081..13fb027 100755
--- a/run_tests.py
+++ b/run_tests.py
@@ -17,6 +17,7 @@ import test.runner
 import test.check_whitespace
 import test.unit.connection.authentication
 import test.unit.connection.protocolinfo
+import test.unit.control.getinfo
 import test.unit.socket.control_line
 import test.unit.socket.control_message
 import test.unit.descriptor.reader
@@ -103,6 +104,7 @@ UNIT_TESTS = (
   test.unit.socket.control_line.TestControlLine,
   test.unit.connection.authentication.TestAuthenticate,
   test.unit.connection.protocolinfo.TestProtocolInfoResponse,
+  test.unit.control.getinfo.TestGetInfoResponse,
 )
 
 INTEG_TESTS = (
diff --git a/stem/control.py b/stem/control.py
index e2d3d90..ccb57f4 100644
--- a/stem/control.py
+++ b/stem/control.py
@@ -36,6 +36,12 @@ import stem.util.log as log
 
 State = stem.util.enum.Enum("INIT", "RESET", "CLOSED")
 
+# Constant to indicate an undefined argument default. Usually we'd use None for
+# this, but users will commonly provide None as the argument so need something
+# else very, very unique...
+
+UNDEFINED = "<Undefined>" * 10
+
 class BaseController:
   """
   Controller for the tor process. This is a minimal base class for other
@@ -429,4 +435,127 @@ class Controller(BaseController):
   
   from_port = staticmethod(from_port)
   from_socket_file = staticmethod(from_socket_file)
+  
+  def get_info(self, param, default = UNDEFINED):
+    """
+    Queries the control socket for the given GETINFO option. If provided a
+    default then that's returned if the GETINFO option is undefined or the
+    call fails for any reason (error response, control port closed, initiated,
+    etc).
+    
+    Arguments:
+      param (str, list) - GETINFO option or options to be queried
+      default (object)  - response if the query fails
+    
+    Returns:
+      Response depends upon how we were called as follows...
+      - str with the response if our param was a str
+      - dict with the param => response mapping if our param was a list
+      - default if one was provided and our call failed
+    
+    Raises:
+      stem.socket.ControllerError if the call fails, and we weren't provided a
+      default response
+    """
+    
+    # TODO: add caching?
+    # TODO: special geoip handling?
+    # TODO: add logging, including call runtime
+    
+    if isinstance(param, str):
+      is_multiple = False
+      param = [param]
+    else:
+      is_multiple = True
+    
+    try:
+      response = self.msg("GETINFO %s" % " ".join(param))
+      
+      # TODO: replace with is_ok() check when we've merged it in
+      if response.content()[0][0] != "250":
+        raise stem.socket.ControllerError(str(response))
+      
+      GetInfoResponse.convert(response)
+      
+      # error if we got back different parameters than we requested
+      requested_params = set(param)
+      reply_params = set(response.values.keys())
+      
+      if requested_params != reply_params:
+        requested_label = ", ".join(requested_params)
+        reply_label = ", ".join(reply_params)
+        
+        raise stem.socket.ProtocolError("GETINFO reply doesn't match the parameters that we requested. Queried '%s' but got '%s'." % (requested_label, reply_label))
+      
+      if is_multiple:
+        return response.values
+      else:
+        return response.values[param[0]]
+    except stem.socket.ControllerError, exc:
+      if default == UNDEFINED: raise exc
+      else: return default
+
+class GetInfoResponse(stem.socket.ControlMessage):
+  """
+  Reply for a GETINFO query.
+  
+  Attributes:
+    values (dict) - mapping between the queried options and their values
+  """
+  
+  def convert(control_message):
+    """
+    Parses a ControlMessage, performing an in-place conversion of it into a
+    GetInfoResponse.
+    
+    Arguments:
+      control_message (stem.socket.ControlMessage) -
+        message to be parsed as a GETINFO reply
+    
+    Raises:
+      stem.socket.ProtocolError the message isn't a proper GETINFO response
+      TypeError if argument isn't a ControlMessage
+    """
+    
+    if isinstance(control_message, stem.socket.ControlMessage):
+      control_message.__class__ = GetInfoResponse
+      control_message._parse_message()
+      return control_message
+    else:
+      raise TypeError("Only able to convert stem.socket.ControlMessage instances")
+  
+  convert = staticmethod(convert)
+  
+  def _parse_message(self):
+    # Example:
+    # 250-version=0.2.3.11-alpha-dev (git-ef0bc7f8f26a917c)
+    # 250+config-text=
+    # ControlPort 9051
+    # DataDirectory /home/atagar/.tor
+    # ExitPolicy reject *:*
+    # Log notice stdout
+    # Nickname Unnamed
+    # ORPort 9050
+    # .
+    # 250 OK
+    
+    self.values = {}
+    
+    for line in self:
+      if line == "OK": break
+      elif not "=" in line:
+        raise stem.socket.ProtocolError("GETINFO replies should only contain parameter=value mappings: %s" % line)
+      
+      key, value = line.split("=", 1)
+      
+      # if the value is a multiline value then it *must* be of the form
+      # '<key>=\n<value>'
+      
+      if "\n" in value:
+        if value.startswith("\n"):
+          value = value[1:]
+        else:
+          raise stem.socket.ProtocolError("GETINFO response contained a multiline value that didn't start with a newline: %s" % line)
+      
+      self.values[key] = value
 
diff --git a/test/integ/connection/authentication.py b/test/integ/connection/authentication.py
index 9d912b3..5875a80 100644
--- a/test/integ/connection/authentication.py
+++ b/test/integ/connection/authentication.py
@@ -274,7 +274,7 @@ class TestAuthenticate(unittest.TestCase):
     """
     
     auth_type = stem.connection.AuthMethod.COOKIE
-    auth_value = test.runner.get_runner().get_torrc_path()
+    auth_value = test.runner.get_runner().get_torrc_path(True)
     
     if os.path.getsize(auth_value) == 32:
       # Weird coincidence? Fail so we can pick another file to check against.
diff --git a/test/integ/control/controller.py b/test/integ/control/controller.py
index 4bf8284..1b743e1 100644
--- a/test/integ/control/controller.py
+++ b/test/integ/control/controller.py
@@ -33,4 +33,41 @@ class TestController(unittest.TestCase):
         self.assertTrue(isinstance(controller, stem.control.Controller))
     else:
       self.assertRaises(stem.socket.SocketError, stem.control.Controller.from_socket_file, test.runner.CONTROL_SOCKET_PATH)
+  
+  def test_getinfo(self):
+    """
+    Exercises GETINFO with valid and invalid queries.
+    """
+    
+    runner = test.runner.get_runner()
+    
+    with runner.get_tor_controller() as controller:
+      # successful single query
+      
+      torrc_path = runner.get_torrc_path()
+      self.assertEqual(torrc_path, controller.get_info("config-file"))
+      self.assertEqual(torrc_path, controller.get_info("config-file", "ho hum"))
+      
+      expected = {"config-file": torrc_path}
+      self.assertEqual(expected, controller.get_info(["config-file"]))
+      self.assertEqual(expected, controller.get_info(["config-file"], "ho hum"))
+      
+      # successful batch query, we don't know the values so just checking for
+      # the keys
+      
+      getinfo_params = set(["version", "config-file", "config/names"])
+      self.assertEqual(getinfo_params, set(controller.get_info(["version", "config-file", "config/names"]).keys()))
+      
+      # non-existant option
+      
+      self.assertRaises(stem.socket.ControllerError, controller.get_info, "blarg")
+      self.assertEqual("ho hum", controller.get_info("blarg", "ho hum"))
+      
+      # empty input
+      
+      self.assertRaises(stem.socket.ControllerError, controller.get_info, "")
+      self.assertEqual("ho hum", controller.get_info("", "ho hum"))
+      
+      self.assertEqual({}, controller.get_info([]))
+      self.assertEqual({}, controller.get_info([], {}))
 
diff --git a/test/integ/socket/control_message.py b/test/integ/socket/control_message.py
index 255e66d..72f9174 100644
--- a/test/integ/socket/control_message.py
+++ b/test/integ/socket/control_message.py
@@ -86,10 +86,6 @@ class TestControlMessage(unittest.TestCase):
     
     runner = test.runner.get_runner()
     torrc_dst = runner.get_torrc_path()
-    chroot_path = runner.get_chroot()
-    
-    if chroot_path and torrc_dst.startswith(chroot_path):
-      torrc_dst = torrc_dst[len(chroot_path):]
     
     with runner.get_tor_socket() as control_socket:
       control_socket.send("GETINFO config-file")
diff --git a/test/runner.py b/test/runner.py
index 286d4a8..ebbd55b 100644
--- a/test/runner.py
+++ b/test/runner.py
@@ -148,10 +148,7 @@ def exercise_controller(test_case, controller):
   """
   
   runner = get_runner()
-  torrc_path, chroot_path = runner.get_torrc_path(), runner.get_chroot()
-  
-  if chroot_path and torrc_path.startswith(chroot_path):
-    torrc_path = torrc_path[len(chroot_path):]
+  torrc_path = runner.get_torrc_path()
   
   if isinstance(controller, stem.socket.ControlSocket):
     controller.send("GETINFO config-file")
@@ -384,10 +381,14 @@ class Runner:
     else:
       return self._get("_test_dir")
   
-  def get_torrc_path(self):
+  def get_torrc_path(self, ignore_chroot = False):
     """
     Provides the absolute path for where our testing torrc resides.
     
+    Arguments:
+      ignore_chroot (bool) - provides the real path, rather than the one that
+                             tor expects if True
+    
     Returns:
       str with our torrc path
     
@@ -396,7 +397,12 @@ class Runner:
     """
     
     test_dir = self._get("_test_dir")
-    return os.path.join(test_dir, "torrc")
+    torrc_path = os.path.join(test_dir, "torrc")
+    
+    if not ignore_chroot and self._chroot_path and torrc_path.startswith(self._chroot_path):
+      torrc_path = torrc_path[len(self._chroot_path):]
+    
+    return torrc_path
   
   def get_torrc_contents(self):
     """
@@ -492,15 +498,14 @@ class Runner:
       authenticate (bool) - if True then the socket is authenticated
     
     Returns:
-      stem.socket.BaseController connected with our testing instance
+      stem.socket.Controller connected with our testing instance
     
     Raises:
       TorInaccessable if tor can't be connected to
     """
     
-    # TODO: replace with our general controller when we have one
     control_socket = self.get_tor_socket(authenticate)
-    return stem.control.BaseController(control_socket)
+    return stem.control.Controller(control_socket)
   
   def get_tor_version(self):
     """
diff --git a/test/unit/control/__init__.py b/test/unit/control/__init__.py
new file mode 100644
index 0000000..448a597
--- /dev/null
+++ b/test/unit/control/__init__.py
@@ -0,0 +1,6 @@
+"""
+Unit tests for stem.control.
+"""
+
+__all__ = ["controller"]
+
diff --git a/test/unit/control/getinfo.py b/test/unit/control/getinfo.py
new file mode 100644
index 0000000..3fc9fd3
--- /dev/null
+++ b/test/unit/control/getinfo.py
@@ -0,0 +1,119 @@
+"""
+Unit tests for the stem.control.GetInfoResponse class.
+"""
+
+import unittest
+
+import stem.connection
+import test.mocking as mocking
+
+EMPTY_RESPONSE = "250 OK"
+
+SINGLE_RESPONSE = """\
+250-version=0.2.3.11-alpha-dev
+250 OK"""
+
+BATCH_RESPONSE = """\
+250-version=0.2.3.11-alpha-dev
+250-address=67.137.76.214
+250-fingerprint=5FDE0422045DF0E1879A3738D09099EB4A0C5BA0
+250 OK"""
+
+MULTILINE_RESPONSE = """\
+250-version=0.2.3.11-alpha-dev (git-ef0bc7f8f26a917c)
+250+config-text=
+ControlPort 9051
+DataDirectory /home/atagar/.tor
+ExitPolicy reject *:*
+Log notice stdout
+Nickname Unnamed
+ORPort 9050
+.
+250 OK"""
+
+NON_KEY_VALUE_ENTRY = """\
+250-version=0.2.3.11-alpha-dev
+250-address 67.137.76.214
+250 OK"""
+
+MISSING_MULTILINE_NEWLINE = """\
+250+config-text=ControlPort 9051
+DataDirectory /home/atagar/.tor
+.
+250 OK"""
+
+class TestGetInfoResponse(unittest.TestCase):
+  def test_empty_response(self):
+    """
+    Parses a GETINFO reply without options (just calling "GETINFO").
+    """
+    
+    control_message = mocking.get_message(EMPTY_RESPONSE)
+    stem.control.GetInfoResponse.convert(control_message)
+    
+    # now this should be a GetInfoResponse (ControlMessage subclass)
+    self.assertTrue(isinstance(control_message, stem.socket.ControlMessage))
+    self.assertTrue(isinstance(control_message, stem.control.GetInfoResponse))
+    
+    self.assertEqual({}, control_message.values)
+  
+  def test_single_response(self):
+    """
+    Parses a GETINFO reply response for a single parameter.
+    """
+    
+    control_message = mocking.get_message(SINGLE_RESPONSE)
+    stem.control.GetInfoResponse.convert(control_message)
+    self.assertEqual({"version": "0.2.3.11-alpha-dev"}, control_message.values)
+  
+  def test_batch_response(self):
+    """
+    Parses a GETINFO reply for muiltiple parameters.
+    """
+    
+    control_message = mocking.get_message(BATCH_RESPONSE)
+    stem.control.GetInfoResponse.convert(control_message)
+    
+    expected = {
+      "version": "0.2.3.11-alpha-dev",
+      "address": "67.137.76.214",
+      "fingerprint": "5FDE0422045DF0E1879A3738D09099EB4A0C5BA0",
+    }
+    
+    self.assertEqual(expected, control_message.values)
+  
+  def test_multiline_response(self):
+    """
+    Parses a GETINFO reply for multiple parameters including a multi-line
+    value.
+    """
+    
+    control_message = mocking.get_message(MULTILINE_RESPONSE)
+    stem.control.GetInfoResponse.convert(control_message)
+    
+    expected = {
+      "version": "0.2.3.11-alpha-dev (git-ef0bc7f8f26a917c)",
+      "config-text": "\n".join(MULTILINE_RESPONSE.splitlines()[2:8]),
+    }
+    
+    self.assertEqual(expected, control_message.values)
+  
+  def test_invalid_non_mapping_content(self):
+    """
+    Parses a malformed GETINFO reply containing a line that isn't a key=value
+    entry.
+    """
+    
+    control_message = mocking.get_message(NON_KEY_VALUE_ENTRY)
+    self.assertRaises(stem.socket.ProtocolError, stem.control.GetInfoResponse.convert, control_message)
+  
+  def test_invalid_multiline_content(self):
+    """
+    Parses a malformed GETINFO reply with a multi-line entry missing a newline
+    between its key and value. This is a proper controller message, but
+    malformed according to the GETINFO's spec.
+    """
+    
+    control_message = mocking.get_message(MISSING_MULTILINE_NEWLINE)
+    self.assertRaises(stem.socket.ProtocolError, stem.control.GetInfoResponse.convert, control_message)
+



More information about the tor-commits mailing list