commit 717c5aaecc8e61cee5ca8f1bae80b3eade5f6985
Author: Damian Johnson <atagar(a)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)
+