commit 2a21ec5848df9d1d5a520abbb6d5927a804acd09
Author: Damian Johnson <atagar(a)torproject.org>
Date: Mon May 28 17:07:57 2012 -0700
Moving ControlMessage and ControlLine into stem.response
The ControlMessage and the ControlLine instances in it are the... well,
messages that we get from tor. It belongs in stem.response so moving it there.
Functionally this is fine, and I'm happy with the tests in a functional fashion
as well. However, all this splitting and refactoring has made the tests a mess
in terms of what the test functions belong to (some test.unit.response tests
are checking stem.connection functionality, for instance). More cleanup work to
do there...
---
run_tests.py | 8 +-
stem/control.py | 11 +-
stem/response/__init__.py | 359 ++++++++++++++++++++++++++++++++-
stem/response/getinfo.py | 3 +-
stem/response/protocolinfo.py | 3 +-
stem/socket.py | 353 +--------------------------------
test/integ/socket/control_message.py | 2 +-
test/unit/response/__init__.py | 2 +-
test/unit/response/control_line.py | 166 +++++++++++++++
test/unit/response/control_message.py | 184 +++++++++++++++++
test/unit/response/getinfo.py | 3 +-
test/unit/response/protocolinfo.py | 2 +-
test/unit/socket/__init__.py | 6 -
test/unit/socket/control_line.py | 166 ---------------
test/unit/socket/control_message.py | 184 -----------------
15 files changed, 723 insertions(+), 729 deletions(-)
diff --git a/run_tests.py b/run_tests.py
index dea86c3..22adce3 100755
--- a/run_tests.py
+++ b/run_tests.py
@@ -16,11 +16,11 @@ import test.output
import test.runner
import test.check_whitespace
import test.unit.connection.authentication
-import test.unit.socket.control_line
-import test.unit.socket.control_message
import test.unit.descriptor.reader
import test.unit.descriptor.server_descriptor
import test.unit.descriptor.extrainfo_descriptor
+import test.unit.response.control_line
+import test.unit.response.control_message
import test.unit.response.getinfo
import test.unit.response.protocolinfo
import test.unit.util.conf
@@ -100,10 +100,10 @@ UNIT_TESTS = (
test.unit.descriptor.server_descriptor.TestServerDescriptor,
test.unit.descriptor.extrainfo_descriptor.TestExtraInfoDescriptor,
test.unit.version.TestVersion,
+ test.unit.response.control_message.TestControlMessage,
+ test.unit.response.control_line.TestControlLine,
test.unit.response.getinfo.TestGetInfoResponse,
test.unit.response.protocolinfo.TestProtocolInfoResponse,
- test.unit.socket.control_message.TestControlMessage,
- test.unit.socket.control_line.TestControlLine,
test.unit.connection.authentication.TestAuthenticate,
)
diff --git a/stem/control.py b/stem/control.py
index 1f3b767..a6bc3d2 100644
--- a/stem/control.py
+++ b/stem/control.py
@@ -92,7 +92,7 @@ class BaseController:
message (str) - message to be formatted and sent to tor
Returns:
- stem.socket.ControlMessage with the response
+ stem.response.ControlMessage with the response
Raises:
stem.socket.ProtocolError the content from the socket is malformed
@@ -128,7 +128,7 @@ class BaseController:
log.info("Tor provided a malformed message (%s)" % response)
elif isinstance(response, stem.socket.ControllerError):
log.info("Socket experienced a problem (%s)" % response)
- elif isinstance(response, stem.socket.ControlMessage):
+ elif isinstance(response, stem.response.ControlMessage):
log.notice("BUG: the msg() function failed to deliver a response: %s" % response)
except Queue.Empty:
# the empty() method is documented to not be fully reliable so this
@@ -260,7 +260,7 @@ class BaseController:
notified whenever we receive an event from the control socket.
Arguments:
- event_message (stem.socket.ControlMessage) - message received from the
+ event_message (stem.response.ControlMessage) - message received from the
control socket
"""
@@ -471,11 +471,6 @@ class Controller(BaseController):
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))
-
stem.response.convert("GETINFO", response)
# error if we got back different parameters than we requested
diff --git a/stem/response/__init__.py b/stem/response/__init__.py
index ab38747..1f2ed0c 100644
--- a/stem/response/__init__.py
+++ b/stem/response/__init__.py
@@ -2,11 +2,39 @@
Parses replies from the control socket.
convert - translates a ControlMessage into a particular response subclass
+
+ControlMessage - Message that's read from the control socket.
+ |- content - provides the parsed message content
+ |- raw_content - unparsed socket data
+ |- __str__ - content stripped of protocol formatting
+ +- __iter__ - ControlLine entries for the content of the message
+
+ControlLine - String subclass with methods for parsing controller responses.
+ |- remainder - provides the unparsed content
+ |- is_empty - checks if the remaining content is empty
+ |- is_next_quoted - checks if the next entry is a quoted value
+ |- is_next_mapping - checks if the next entry is a KEY=VALUE mapping
+ |- peek_key - provides the key of the next entry
+ |- pop - removes and returns the next entry
+ +- pop_mapping - removes and returns the next entry as a KEY=VALUE mapping
"""
-__all__ = ["getinfo", "protocolinfo"]
+__all__ = ["getinfo", "protocolinfo", "convert", "ControlMessage", "ControlLine"]
+
+import re
+import threading
+
+KEY_ARG = re.compile("^(\S+)=")
-import stem.socket
+# Escape sequences from the 'esc_for_log' function of tor's 'common/util.c'.
+# It's hard to tell what controller functions use this in practice, but direct
+# users are...
+# - 'COOKIEFILE' field of PROTOCOLINFO responses
+# - logged messages about bugs
+# - the 'getinfo_helper_listeners' function of control.c
+
+CONTROL_ESCAPES = {r"\\": "\\", r"\"": "\"", r"\'": "'",
+ r"\r": "\r", r"\n": "\n", r"\t": "\t"}
def convert(response_type, message):
"""
@@ -20,8 +48,8 @@ def convert(response_type, message):
If the response_type isn't recognized then this is leaves it alone.
Arguments:
- response_type (str) - type of tor response to convert to
- message (stem.socket.ControlMessage) - message to be converted
+ response_type (str) - type of tor response to convert to
+ message (stem.response.ControlMessage) - message to be converted
Raises:
stem.socket.ProtocolError the message isn't a proper response of that type
@@ -32,8 +60,8 @@ def convert(response_type, message):
import stem.response.getinfo
import stem.response.protocolinfo
- if not isinstance(message, stem.socket.ControlMessage):
- raise TypeError("Only able to convert stem.socket.ControlMessage instances")
+ if not isinstance(message, ControlMessage):
+ raise TypeError("Only able to convert stem.response.ControlMessage instances")
if response_type == "GETINFO":
response_class = stem.response.getinfo.GetInfoResponse
@@ -44,3 +72,322 @@ def convert(response_type, message):
message.__class__ = response_class
message._parse_message()
+class ControlMessage:
+ """
+ Message from the control socket. This is iterable and can be stringified for
+ individual message components stripped of protocol formatting.
+ """
+
+ def __init__(self, parsed_content, raw_content):
+ self._parsed_content = parsed_content
+ self._raw_content = raw_content
+
+ def is_ok(self):
+ """
+ Checks if all of our lines have a 250 response.
+
+ Returns:
+ True if all lines have a 250 response code, False otherwise
+ """
+
+ for code, _, _ in self._parsed_content:
+ if code != "250": return False
+
+ return True
+
+ def content(self):
+ """
+ Provides the parsed message content. These are entries of the form...
+ (status_code, divider, content)
+
+ * status_code - Three character code for the type of response (defined in
+ section 4 of the control-spec).
+ * divider - Single character to indicate if this is mid-reply, data, or
+ an end to the message (defined in section 2.3 of the
+ control-spec).
+ * content - The following content is the actual payload of the line.
+
+ For data entries the content is the full multi-line payload with newline
+ linebreaks and leading periods unescaped.
+
+ Returns:
+ list of (str, str, str) tuples for the components of this message
+ """
+
+ return list(self._parsed_content)
+
+ def raw_content(self):
+ """
+ Provides the unparsed content read from the control socket.
+
+ Returns:
+ string of the socket data used to generate this message
+ """
+
+ return self._raw_content
+
+ def __str__(self):
+ """
+ Content of the message, stripped of status code and divider protocol
+ formatting.
+ """
+
+ return "\n".join(list(self))
+
+ def __iter__(self):
+ """
+ Provides ControlLine instances for the content of the message. This is
+ stripped of status codes and dividers, for instance...
+
+ 250+info/names=
+ desc/id/* -- Router descriptors by ID.
+ desc/name/* -- Router descriptors by nickname.
+ .
+ 250 OK
+
+ Would provide two entries...
+ 1st - "info/names=
+ desc/id/* -- Router descriptors by ID.
+ desc/name/* -- Router descriptors by nickname."
+ 2nd - "OK"
+ """
+
+ for _, _, content in self._parsed_content:
+ yield ControlLine(content)
+
+class ControlLine(str):
+ """
+ String subclass that represents a line of controller output. This behaves as
+ a normal string with additional methods for parsing and popping entries from
+ a space delimited series of elements like a stack.
+
+ None of these additional methods effect ourselves as a string (which is still
+ immutable). All methods are thread safe.
+ """
+
+ def __new__(self, value):
+ return str.__new__(self, value)
+
+ def __init__(self, value):
+ self._remainder = value
+ self._remainder_lock = threading.RLock()
+
+ def remainder(self):
+ """
+ Provides our unparsed content. This is an empty string after we've popped
+ all entries.
+
+ Returns:
+ str of the unparsed content
+ """
+
+ return self._remainder
+
+ def is_empty(self):
+ """
+ Checks if we have further content to pop or not.
+
+ Returns:
+ True if we have additional content, False otherwise
+ """
+
+ return self._remainder == ""
+
+ def is_next_quoted(self, escaped = False):
+ """
+ Checks if our next entry is a quoted value or not.
+
+ Arguments:
+ escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
+
+ Returns:
+ True if the next entry can be parsed as a quoted value, False otherwise
+ """
+
+ start_quote, end_quote = _get_quote_indeces(self._remainder, escaped)
+ return start_quote == 0 and end_quote != -1
+
+ def is_next_mapping(self, key = None, quoted = False, escaped = False):
+ """
+ Checks if our next entry is a KEY=VALUE mapping or not.
+
+ Arguments:
+ key (str) - checks that the key matches this value, skipping the
+ check if None
+ quoted (bool) - checks that the mapping is to a quoted value
+ escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
+
+ Returns:
+ True if the next entry can be parsed as a key=value mapping, False
+ otherwise
+ """
+
+ remainder = self._remainder # temp copy to avoid locking
+ key_match = KEY_ARG.match(remainder)
+
+ if key_match:
+ if key and key != key_match.groups()[0]:
+ return False
+
+ if quoted:
+ # checks that we have a quoted value and that it comes after the 'key='
+ start_quote, end_quote = _get_quote_indeces(remainder, escaped)
+ return start_quote == key_match.end() and end_quote != -1
+ else:
+ return True # we just needed to check for the key
+ else:
+ return False # doesn't start with a key
+
+ def peek_key(self):
+ """
+ Provides the key of the next entry, providing None if it isn't a key/value
+ mapping.
+
+ Returns:
+ str with the next entry's key
+ """
+
+ remainder = self._remainder
+ key_match = KEY_ARG.match(remainder)
+
+ if key_match:
+ return key_match.groups()[0]
+ else:
+ return None
+
+ def pop(self, quoted = False, escaped = False):
+ """
+ Parses the next space separated entry, removing it and the space from our
+ remaining content. Examples...
+
+ >>> line = ControlLine("\"We're all mad here.\" says the grinning cat.")
+ >>> print line.pop(True)
+ "We're all mad here."
+ >>> print line.pop()
+ "says"
+ >>> print line.remainder()
+ "the grinning cat."
+
+ >>> line = ControlLine("\"this has a \\\" and \\\\ in it\" foo=bar more_data")
+ >>> print line.pop(True, True)
+ "this has a \" and \\ in it"
+
+ Arguments:
+ quoted (bool) - parses the next entry as a quoted value, removing the
+ quotes
+ escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
+
+ Returns:
+ str of the next space separated entry
+
+ Raises:
+ ValueError if quoted is True without the value being quoted
+ IndexError if we don't have any remaining content left to parse
+ """
+
+ with self._remainder_lock:
+ next_entry, remainder = _parse_entry(self._remainder, quoted, escaped)
+ self._remainder = remainder
+ return next_entry
+
+ def pop_mapping(self, quoted = False, escaped = False):
+ """
+ Parses the next space separated entry as a KEY=VALUE mapping, removing it
+ and the space from our remaining content.
+
+ Arguments:
+ quoted (bool) - parses the value as being quoted, removing the quotes
+ escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
+
+ Returns:
+ tuple of the form (key, value)
+
+ Raises:
+ ValueError if this isn't a KEY=VALUE mapping or if quoted is True without
+ the value being quoted
+ """
+
+ with self._remainder_lock:
+ if self.is_empty(): raise IndexError("no remaining content to parse")
+ key_match = KEY_ARG.match(self._remainder)
+
+ if not key_match:
+ raise ValueError("the next entry isn't a KEY=VALUE mapping: " + self._remainder)
+
+ # parse off the key
+ key = key_match.groups()[0]
+ remainder = self._remainder[key_match.end():]
+
+ next_entry, remainder = _parse_entry(remainder, quoted, escaped)
+ self._remainder = remainder
+ return (key, next_entry)
+
+def _parse_entry(line, quoted, escaped):
+ """
+ Parses the next entry from the given space separated content.
+
+ Arguments:
+ line (str) - content to be parsed
+ quoted (bool) - parses the next entry as a quoted value, removing the
+ quotes
+ escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
+
+ Returns:
+ tuple of the form (entry, remainder)
+
+ Raises:
+ ValueError if quoted is True without the next value being quoted
+ IndexError if there's nothing to parse from the line
+ """
+
+ if line == "":
+ raise IndexError("no remaining content to parse")
+
+ next_entry, remainder = "", line
+
+ if quoted:
+ # validate and parse the quoted value
+ start_quote, end_quote = _get_quote_indeces(remainder, escaped)
+
+ if start_quote != 0 or end_quote == -1:
+ raise ValueError("the next entry isn't a quoted value: " + line)
+
+ next_entry, remainder = remainder[1 : end_quote], remainder[end_quote + 1:]
+ else:
+ # non-quoted value, just need to check if there's more data afterward
+ if " " in remainder: next_entry, remainder = remainder.split(" ", 1)
+ else: next_entry, remainder = remainder, ""
+
+ if escaped:
+ for esc_sequence, replacement in CONTROL_ESCAPES.items():
+ next_entry = next_entry.replace(esc_sequence, replacement)
+
+ return (next_entry, remainder.lstrip())
+
+def _get_quote_indeces(line, escaped):
+ """
+ Provides the indices of the next two quotes in the given content.
+
+ Arguments:
+ line (str) - content to be parsed
+ escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
+
+ Returns:
+ tuple of two ints, indices being -1 if a quote doesn't exist
+ """
+
+ indices, quote_index = [], -1
+
+ for _ in range(2):
+ quote_index = line.find("\"", quote_index + 1)
+
+ # if we have escapes then we need to skip any r'\"' entries
+ if escaped:
+ # skip check if index is -1 (no match) or 0 (first character)
+ while quote_index >= 1 and line[quote_index - 1] == "\\":
+ quote_index = line.find("\"", quote_index + 1)
+
+ indices.append(quote_index)
+
+ return tuple(indices)
+
diff --git a/stem/response/getinfo.py b/stem/response/getinfo.py
index 620ca02..6f6cde4 100644
--- a/stem/response/getinfo.py
+++ b/stem/response/getinfo.py
@@ -1,6 +1,7 @@
import stem.socket
+import stem.response
-class GetInfoResponse(stem.socket.ControlMessage):
+class GetInfoResponse(stem.response.ControlMessage):
"""
Reply for a GETINFO query.
diff --git a/stem/response/protocolinfo.py b/stem/response/protocolinfo.py
index 7ead680..ce9a82c 100644
--- a/stem/response/protocolinfo.py
+++ b/stem/response/protocolinfo.py
@@ -1,4 +1,5 @@
import stem.socket
+import stem.response
import stem.version
import stem.util.enum
import stem.util.log as log
@@ -18,7 +19,7 @@ import stem.util.log as log
AuthMethod = stem.util.enum.Enum("NONE", "PASSWORD", "COOKIE", "UNKNOWN")
-class ProtocolInfoResponse(stem.socket.ControlMessage):
+class ProtocolInfoResponse(stem.response.ControlMessage):
"""
Version one PROTOCOLINFO query response.
diff --git a/stem/socket.py b/stem/socket.py
index a9e2068..68cc7e4 100644
--- a/stem/socket.py
+++ b/stem/socket.py
@@ -18,21 +18,6 @@ ControlSocket - Socket wrapper that speaks the tor control protocol.
|- close - shuts down the socket
+- __enter__ / __exit__ - manages socket connection
-ControlMessage - Message that's read from the control socket.
- |- content - provides the parsed message content
- |- raw_content - unparsed socket data
- |- __str__ - content stripped of protocol formatting
- +- __iter__ - ControlLine entries for the content of the message
-
-ControlLine - String subclass with methods for parsing controller responses.
- |- remainder - provides the unparsed content
- |- is_empty - checks if the remaining content is empty
- |- is_next_quoted - checks if the next entry is a quoted value
- |- is_next_mapping - checks if the next entry is a KEY=VALUE mapping
- |- peek_key - provides the key of the next entry
- |- pop - removes and returns the next entry
- +- pop_mapping - removes and returns the next entry as a KEY=VALUE mapping
-
send_message - Writes a message to a control socket.
recv_message - Reads a ControlMessage from a control socket.
send_formatting - Performs the formatting expected from sent messages.
@@ -48,21 +33,10 @@ import re
import socket
import threading
+import stem.response
import stem.util.enum
import stem.util.log as log
-KEY_ARG = re.compile("^(\S+)=")
-
-# Escape sequences from the 'esc_for_log' function of tor's 'common/util.c'.
-# It's hard to tell what controller functions use this in practice, but direct
-# users are...
-# - 'COOKIEFILE' field of PROTOCOLINFO responses
-# - logged messages about bugs
-# - the 'getinfo_helper_listeners' function of control.c
-
-CONTROL_ESCAPES = {r"\\": "\\", r"\"": "\"", r"\'": "'",
- r"\r": "\r", r"\n": "\n", r"\t": "\t"}
-
class ControllerError(Exception):
"Base error for controller communication issues."
@@ -127,7 +101,7 @@ class ControlSocket:
one. For more information see the stem.socket.recv_message function.
Returns:
- stem.socket.ControlMessage for the message received
+ stem.response.ControlMessage for the message received
Raises:
stem.socket.ProtocolError the content from the socket is malformed
@@ -398,325 +372,6 @@ class ControlSocketFile(ControlSocket):
except socket.error, exc:
raise SocketError(exc)
-class ControlMessage:
- """
- Message from the control socket. This is iterable and can be stringified for
- individual message components stripped of protocol formatting.
- """
-
- def __init__(self, parsed_content, raw_content):
- self._parsed_content = parsed_content
- self._raw_content = raw_content
-
- def is_ok(self):
- """
- Checks if all of our lines have a 250 response.
-
- Returns:
- True if all lines have a 250 response code, False otherwise
- """
-
- for code, _, _ in self._parsed_content:
- if code != "250": return False
-
- return True
-
- def content(self):
- """
- Provides the parsed message content. These are entries of the form...
- (status_code, divider, content)
-
- * status_code - Three character code for the type of response (defined in
- section 4 of the control-spec).
- * divider - Single character to indicate if this is mid-reply, data, or
- an end to the message (defined in section 2.3 of the
- control-spec).
- * content - The following content is the actual payload of the line.
-
- For data entries the content is the full multi-line payload with newline
- linebreaks and leading periods unescaped.
-
- Returns:
- list of (str, str, str) tuples for the components of this message
- """
-
- return list(self._parsed_content)
-
- def raw_content(self):
- """
- Provides the unparsed content read from the control socket.
-
- Returns:
- string of the socket data used to generate this message
- """
-
- return self._raw_content
-
- def __str__(self):
- """
- Content of the message, stripped of status code and divider protocol
- formatting.
- """
-
- return "\n".join(list(self))
-
- def __iter__(self):
- """
- Provides ControlLine instances for the content of the message. This is
- stripped of status codes and dividers, for instance...
-
- 250+info/names=
- desc/id/* -- Router descriptors by ID.
- desc/name/* -- Router descriptors by nickname.
- .
- 250 OK
-
- Would provide two entries...
- 1st - "info/names=
- desc/id/* -- Router descriptors by ID.
- desc/name/* -- Router descriptors by nickname."
- 2nd - "OK"
- """
-
- for _, _, content in self._parsed_content:
- yield ControlLine(content)
-
-class ControlLine(str):
- """
- String subclass that represents a line of controller output. This behaves as
- a normal string with additional methods for parsing and popping entries from
- a space delimited series of elements like a stack.
-
- None of these additional methods effect ourselves as a string (which is still
- immutable). All methods are thread safe.
- """
-
- def __new__(self, value):
- return str.__new__(self, value)
-
- def __init__(self, value):
- self._remainder = value
- self._remainder_lock = threading.RLock()
-
- def remainder(self):
- """
- Provides our unparsed content. This is an empty string after we've popped
- all entries.
-
- Returns:
- str of the unparsed content
- """
-
- return self._remainder
-
- def is_empty(self):
- """
- Checks if we have further content to pop or not.
-
- Returns:
- True if we have additional content, False otherwise
- """
-
- return self._remainder == ""
-
- def is_next_quoted(self, escaped = False):
- """
- Checks if our next entry is a quoted value or not.
-
- Arguments:
- escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
-
- Returns:
- True if the next entry can be parsed as a quoted value, False otherwise
- """
-
- start_quote, end_quote = _get_quote_indeces(self._remainder, escaped)
- return start_quote == 0 and end_quote != -1
-
- def is_next_mapping(self, key = None, quoted = False, escaped = False):
- """
- Checks if our next entry is a KEY=VALUE mapping or not.
-
- Arguments:
- key (str) - checks that the key matches this value, skipping the
- check if None
- quoted (bool) - checks that the mapping is to a quoted value
- escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
-
- Returns:
- True if the next entry can be parsed as a key=value mapping, False
- otherwise
- """
-
- remainder = self._remainder # temp copy to avoid locking
- key_match = KEY_ARG.match(remainder)
-
- if key_match:
- if key and key != key_match.groups()[0]:
- return False
-
- if quoted:
- # checks that we have a quoted value and that it comes after the 'key='
- start_quote, end_quote = _get_quote_indeces(remainder, escaped)
- return start_quote == key_match.end() and end_quote != -1
- else:
- return True # we just needed to check for the key
- else:
- return False # doesn't start with a key
-
- def peek_key(self):
- """
- Provides the key of the next entry, providing None if it isn't a key/value
- mapping.
-
- Returns:
- str with the next entry's key
- """
-
- remainder = self._remainder
- key_match = KEY_ARG.match(remainder)
-
- if key_match:
- return key_match.groups()[0]
- else:
- return None
-
- def pop(self, quoted = False, escaped = False):
- """
- Parses the next space separated entry, removing it and the space from our
- remaining content. Examples...
-
- >>> line = ControlLine("\"We're all mad here.\" says the grinning cat.")
- >>> print line.pop(True)
- "We're all mad here."
- >>> print line.pop()
- "says"
- >>> print line.remainder()
- "the grinning cat."
-
- >>> line = ControlLine("\"this has a \\\" and \\\\ in it\" foo=bar more_data")
- >>> print line.pop(True, True)
- "this has a \" and \\ in it"
-
- Arguments:
- quoted (bool) - parses the next entry as a quoted value, removing the
- quotes
- escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
-
- Returns:
- str of the next space separated entry
-
- Raises:
- ValueError if quoted is True without the value being quoted
- IndexError if we don't have any remaining content left to parse
- """
-
- with self._remainder_lock:
- next_entry, remainder = _parse_entry(self._remainder, quoted, escaped)
- self._remainder = remainder
- return next_entry
-
- def pop_mapping(self, quoted = False, escaped = False):
- """
- Parses the next space separated entry as a KEY=VALUE mapping, removing it
- and the space from our remaining content.
-
- Arguments:
- quoted (bool) - parses the value as being quoted, removing the quotes
- escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
-
- Returns:
- tuple of the form (key, value)
-
- Raises:
- ValueError if this isn't a KEY=VALUE mapping or if quoted is True without
- the value being quoted
- """
-
- with self._remainder_lock:
- if self.is_empty(): raise IndexError("no remaining content to parse")
- key_match = KEY_ARG.match(self._remainder)
-
- if not key_match:
- raise ValueError("the next entry isn't a KEY=VALUE mapping: " + self._remainder)
-
- # parse off the key
- key = key_match.groups()[0]
- remainder = self._remainder[key_match.end():]
-
- next_entry, remainder = _parse_entry(remainder, quoted, escaped)
- self._remainder = remainder
- return (key, next_entry)
-
-def _parse_entry(line, quoted, escaped):
- """
- Parses the next entry from the given space separated content.
-
- Arguments:
- line (str) - content to be parsed
- quoted (bool) - parses the next entry as a quoted value, removing the
- quotes
- escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
-
- Returns:
- tuple of the form (entry, remainder)
-
- Raises:
- ValueError if quoted is True without the next value being quoted
- IndexError if there's nothing to parse from the line
- """
-
- if line == "":
- raise IndexError("no remaining content to parse")
-
- next_entry, remainder = "", line
-
- if quoted:
- # validate and parse the quoted value
- start_quote, end_quote = _get_quote_indeces(remainder, escaped)
-
- if start_quote != 0 or end_quote == -1:
- raise ValueError("the next entry isn't a quoted value: " + line)
-
- next_entry, remainder = remainder[1 : end_quote], remainder[end_quote + 1:]
- else:
- # non-quoted value, just need to check if there's more data afterward
- if " " in remainder: next_entry, remainder = remainder.split(" ", 1)
- else: next_entry, remainder = remainder, ""
-
- if escaped:
- for esc_sequence, replacement in CONTROL_ESCAPES.items():
- next_entry = next_entry.replace(esc_sequence, replacement)
-
- return (next_entry, remainder.lstrip())
-
-def _get_quote_indeces(line, escaped):
- """
- Provides the indices of the next two quotes in the given content.
-
- Arguments:
- line (str) - content to be parsed
- escaped (bool) - unescapes the CONTROL_ESCAPES escape sequences
-
- Returns:
- tuple of two ints, indices being -1 if a quote doesn't exist
- """
-
- indices, quote_index = [], -1
-
- for _ in range(2):
- quote_index = line.find("\"", quote_index + 1)
-
- # if we have escapes then we need to skip any r'\"' entries
- if escaped:
- # skip check if index is -1 (no match) or 0 (first character)
- while quote_index >= 1 and line[quote_index - 1] == "\\":
- quote_index = line.find("\"", quote_index + 1)
-
- indices.append(quote_index)
-
- return tuple(indices)
-
def send_message(control_file, message, raw = False):
"""
Sends a message to the control socket, adding the expected formatting for
@@ -782,7 +437,7 @@ def recv_message(control_file):
socket's makefile() method for more information)
Returns:
- stem.socket.ControlMessage read from the socket
+ stem.response.ControlMessage read from the socket
Raises:
stem.socket.ProtocolError the content from the socket is malformed
@@ -848,7 +503,7 @@ def recv_message(control_file):
log_message = raw_content.replace("\r\n", "\n").rstrip()
log.trace("Received from tor:\n" + log_message)
- return ControlMessage(parsed_content, raw_content)
+ return stem.response.ControlMessage(parsed_content, raw_content)
elif divider == "+":
# data entry, all of the following lines belong to the content until we
# get a line with just a period
diff --git a/test/integ/socket/control_message.py b/test/integ/socket/control_message.py
index 72f9174..c148605 100644
--- a/test/integ/socket/control_message.py
+++ b/test/integ/socket/control_message.py
@@ -1,5 +1,5 @@
"""
-Integration tests for the stem.socket.ControlMessage class.
+Integration tests for the stem.response.ControlMessage class.
"""
import re
diff --git a/test/unit/response/__init__.py b/test/unit/response/__init__.py
index 530274c..c53d9ef 100644
--- a/test/unit/response/__init__.py
+++ b/test/unit/response/__init__.py
@@ -2,5 +2,5 @@
Unit tests for stem.response.
"""
-__all__ = ["getinfo", "protocolinfo"]
+__all__ = ["control_message", "control_line", "getinfo", "protocolinfo"]
diff --git a/test/unit/response/control_line.py b/test/unit/response/control_line.py
new file mode 100644
index 0000000..5240a46
--- /dev/null
+++ b/test/unit/response/control_line.py
@@ -0,0 +1,166 @@
+"""
+Unit tests for the stem.response.ControlLine class.
+"""
+
+import unittest
+import stem.response
+
+# response made by having 'DataDirectory /tmp/my data\"dir/' in the torrc
+PROTOCOLINFO_RESPONSE = (
+ 'PROTOCOLINFO 1',
+ 'AUTH METHODS=COOKIE COOKIEFILE="/tmp/my data\\\\\\"dir//control_auth_cookie"',
+ 'VERSION Tor="0.2.1.30"',
+ 'OK',
+)
+
+class TestControlLine(unittest.TestCase):
+ def test_pop_examples(self):
+ """
+ Checks that the pop method's pydoc examples are correct.
+ """
+
+ line = stem.response.ControlLine("\"We're all mad here.\" says the grinning cat.")
+ self.assertEquals(line.pop(True), "We're all mad here.")
+ self.assertEquals(line.pop(), "says")
+ self.assertEquals(line.remainder(), "the grinning cat.")
+
+ line = stem.response.ControlLine("\"this has a \\\" and \\\\ in it\" foo=bar more_data")
+ self.assertEquals(line.pop(True, True), "this has a \" and \\ in it")
+
+ def test_string(self):
+ """
+ Basic checks that we behave as a regular immutable string.
+ """
+
+ line = stem.response.ControlLine(PROTOCOLINFO_RESPONSE[0])
+ self.assertEquals(line, 'PROTOCOLINFO 1')
+ self.assertTrue(line.startswith('PROTOCOLINFO '))
+
+ # checks that popping items doesn't effect us
+ line.pop()
+ self.assertEquals(line, 'PROTOCOLINFO 1')
+ self.assertTrue(line.startswith('PROTOCOLINFO '))
+
+ def test_general_usage(self):
+ """
+ Checks a basic use case for the popping entries.
+ """
+
+ # pops a series of basic, space separated entries
+ line = stem.response.ControlLine(PROTOCOLINFO_RESPONSE[0])
+ self.assertEquals(line.remainder(), 'PROTOCOLINFO 1')
+ self.assertFalse(line.is_empty())
+ self.assertFalse(line.is_next_quoted())
+ self.assertFalse(line.is_next_mapping())
+ self.assertEquals(None, line.peek_key())
+
+ self.assertRaises(ValueError, line.pop_mapping)
+ self.assertEquals(line.pop(), 'PROTOCOLINFO')
+ self.assertEquals(line.remainder(), '1')
+ self.assertFalse(line.is_empty())
+ self.assertFalse(line.is_next_quoted())
+ self.assertFalse(line.is_next_mapping())
+ self.assertEquals(None, line.peek_key())
+
+ self.assertRaises(ValueError, line.pop_mapping)
+ self.assertEquals(line.pop(), '1')
+ self.assertEquals(line.remainder(), '')
+ self.assertTrue(line.is_empty())
+ self.assertFalse(line.is_next_quoted())
+ self.assertFalse(line.is_next_mapping())
+ self.assertEquals(None, line.peek_key())
+
+ self.assertRaises(IndexError, line.pop_mapping)
+ self.assertRaises(IndexError, line.pop)
+ self.assertEquals(line.remainder(), '')
+ self.assertTrue(line.is_empty())
+ self.assertFalse(line.is_next_quoted())
+ self.assertFalse(line.is_next_mapping())
+ self.assertEquals(None, line.peek_key())
+
+ def test_pop_mapping(self):
+ """
+ Checks use cases when parsing KEY=VALUE mappings.
+ """
+
+ # version entry with a space
+ version_entry = 'Tor="0.2.1.30 (0a083b0188cacd2f07838ff0446113bd5211a024)"'
+
+ line = stem.response.ControlLine(version_entry)
+ self.assertEquals(line.remainder(), version_entry)
+ self.assertFalse(line.is_empty())
+ self.assertFalse(line.is_next_quoted())
+ self.assertTrue(line.is_next_mapping())
+ self.assertTrue(line.is_next_mapping(key = "Tor"))
+ self.assertTrue(line.is_next_mapping(key = "Tor", quoted = True))
+ self.assertTrue(line.is_next_mapping(quoted = True))
+ self.assertEquals("Tor", line.peek_key())
+
+ # try popping this as a non-quoted mapping
+ self.assertEquals(line.pop_mapping(), ('Tor', '"0.2.1.30'))
+ self.assertEquals(line.remainder(), '(0a083b0188cacd2f07838ff0446113bd5211a024)"')
+ self.assertFalse(line.is_empty())
+ self.assertFalse(line.is_next_quoted())
+ self.assertFalse(line.is_next_mapping())
+ self.assertRaises(ValueError, line.pop_mapping)
+ self.assertEquals(None, line.peek_key())
+
+ # try popping this as a quoted mapping
+ line = stem.response.ControlLine(version_entry)
+ self.assertEquals(line.pop_mapping(True), ('Tor', '0.2.1.30 (0a083b0188cacd2f07838ff0446113bd5211a024)'))
+ self.assertEquals(line.remainder(), '')
+ self.assertTrue(line.is_empty())
+ self.assertFalse(line.is_next_quoted())
+ self.assertFalse(line.is_next_mapping())
+ self.assertEquals(None, line.peek_key())
+
+ def test_escapes(self):
+ """
+ Checks that we can parse quoted values with escaped quotes in it. This
+ explicitely comes up with the COOKIEFILE attribute of PROTOCOLINFO
+ responses.
+ """
+
+ auth_line = PROTOCOLINFO_RESPONSE[1]
+ line = stem.response.ControlLine(auth_line)
+ self.assertEquals(line, auth_line)
+ self.assertEquals(line.remainder(), auth_line)
+
+ self.assertEquals(line.pop(), "AUTH")
+ self.assertEquals(line.pop_mapping(), ("METHODS", "COOKIE"))
+
+ self.assertEquals(line.remainder(), r'COOKIEFILE="/tmp/my data\\\"dir//control_auth_cookie"')
+ self.assertTrue(line.is_next_mapping())
+ self.assertTrue(line.is_next_mapping(key = "COOKIEFILE"))
+ self.assertTrue(line.is_next_mapping(quoted = True))
+ self.assertTrue(line.is_next_mapping(quoted = True, escaped = True))
+ cookie_file_entry = line.remainder()
+
+ # try a general pop
+ self.assertEquals(line.pop(), 'COOKIEFILE="/tmp/my')
+ self.assertEquals(line.pop(), r'data\\\"dir//control_auth_cookie"')
+ self.assertTrue(line.is_empty())
+
+ # try a general pop with escapes
+ line = stem.response.ControlLine(cookie_file_entry)
+ self.assertEquals(line.pop(escaped = True), 'COOKIEFILE="/tmp/my')
+ self.assertEquals(line.pop(escaped = True), r'data\"dir//control_auth_cookie"')
+ self.assertTrue(line.is_empty())
+
+ # try a mapping pop
+ line = stem.response.ControlLine(cookie_file_entry)
+ self.assertEquals(line.pop_mapping(), ('COOKIEFILE', '"/tmp/my'))
+ self.assertEquals(line.remainder(), r'data\\\"dir//control_auth_cookie"')
+ self.assertFalse(line.is_empty())
+
+ # try a quoted mapping pop (this should trip up on the escaped quote)
+ line = stem.response.ControlLine(cookie_file_entry)
+ self.assertEquals(line.pop_mapping(True), ('COOKIEFILE', '/tmp/my data\\\\\\'))
+ self.assertEquals(line.remainder(), 'dir//control_auth_cookie"')
+ self.assertFalse(line.is_empty())
+
+ # try an escaped quoted mapping pop
+ line = stem.response.ControlLine(cookie_file_entry)
+ self.assertEquals(line.pop_mapping(True, True), ('COOKIEFILE', r'/tmp/my data\"dir//control_auth_cookie'))
+ self.assertTrue(line.is_empty())
+
diff --git a/test/unit/response/control_message.py b/test/unit/response/control_message.py
new file mode 100644
index 0000000..b86fc41
--- /dev/null
+++ b/test/unit/response/control_message.py
@@ -0,0 +1,184 @@
+"""
+Unit tests for the stem.response.ControlMessage parsing and class.
+"""
+
+import socket
+import StringIO
+import unittest
+import stem.socket
+
+OK_REPLY = "250 OK\r\n"
+
+EVENT_BW = "650 BW 32326 2856\r\n"
+EVENT_CIRC_TIMEOUT = "650 CIRC 5 FAILED PURPOSE=GENERAL REASON=TIMEOUT\r\n"
+EVENT_CIRC_LAUNCHED = "650 CIRC 9 LAUNCHED PURPOSE=GENERAL\r\n"
+EVENT_CIRC_EXTENDED = "650 CIRC 5 EXTENDED $A200F527C82C59A25CCA44884B49D3D65B122652=faktor PURPOSE=MEASURE_TIMEOUT\r\n"
+
+GETINFO_VERSION = """250-version=0.2.2.23-alpha (git-b85eb949b528f4d7)
+250 OK
+""".replace("\n", "\r\n")
+
+GETINFO_INFONAMES = """250+info/names=
+accounting/bytes -- Number of bytes read/written so far in the accounting interval.
+accounting/bytes-left -- Number of bytes left to write/read so far in the accounting interval.
+accounting/enabled -- Is accounting currently enabled?
+accounting/hibernating -- Are we hibernating or awake?
+stream-status -- List of current streams.
+version -- The current version of Tor.
+.
+250 OK
+""".replace("\n", "\r\n")
+
+class TestControlMessage(unittest.TestCase):
+ def test_ok_response(self):
+ """
+ Checks the basic 'OK' response that we get for most commands.
+ """
+
+ message = self._assert_message_parses(OK_REPLY)
+ self.assertEquals("OK", str(message))
+
+ contents = message.content()
+ self.assertEquals(1, len(contents))
+ self.assertEquals(("250", " ", "OK"), contents[0])
+
+ def test_event_response(self):
+ """
+ Checks parsing of actual events.
+ """
+
+ # BW event
+ message = self._assert_message_parses(EVENT_BW)
+ self.assertEquals("BW 32326 2856", str(message))
+
+ contents = message.content()
+ self.assertEquals(1, len(contents))
+ self.assertEquals(("650", " ", "BW 32326 2856"), contents[0])
+
+ # few types of CIRC events
+ for circ_content in (EVENT_CIRC_TIMEOUT, EVENT_CIRC_LAUNCHED, EVENT_CIRC_EXTENDED):
+ message = self._assert_message_parses(circ_content)
+ self.assertEquals(circ_content[4:-2], str(message))
+
+ contents = message.content()
+ self.assertEquals(1, len(contents))
+ self.assertEquals(("650", " ", str(message)), contents[0])
+
+ def test_getinfo_response(self):
+ """
+ Checks parsing of actual GETINFO responses.
+ """
+
+ # GETINFO version (basic single-line results)
+ message = self._assert_message_parses(GETINFO_VERSION)
+ self.assertEquals(2, len(list(message)))
+ self.assertEquals(2, len(str(message).splitlines()))
+
+ # manually checks the contents
+ contents = message.content()
+ self.assertEquals(2, len(contents))
+ self.assertEquals(("250", "-", "version=0.2.2.23-alpha (git-b85eb949b528f4d7)"), contents[0])
+ self.assertEquals(("250", " ", "OK"), contents[1])
+
+ # GETINFO info/names (data entry)
+ message = self._assert_message_parses(GETINFO_INFONAMES)
+ self.assertEquals(2, len(list(message)))
+ self.assertEquals(8, len(str(message).splitlines()))
+
+ # manually checks the contents
+ contents = message.content()
+ self.assertEquals(2, len(contents))
+
+ first_entry = (contents[0][0], contents[0][1], contents[0][2][:contents[0][2].find("\n")])
+ self.assertEquals(("250", "+", "info/names="), first_entry)
+ self.assertEquals(("250", " ", "OK"), contents[1])
+
+ def test_no_crlf(self):
+ """
+ Checks that we get a ProtocolError when we don't have both a carrage
+ returna and newline for line endings. This doesn't really check for
+ newlines (since that's what readline would break on), but not the end of
+ the world.
+ """
+
+ # Replaces each of the CRLF entries with just LF, confirming that this
+ # causes a parsing error. This should test line endings for both data
+ # entry parsing and non-data.
+
+ infonames_lines = [line + "\n" for line in GETINFO_INFONAMES.splitlines()]
+
+ for i in range(len(infonames_lines)):
+ # replace the CRLF for the line
+ infonames_lines[i] = infonames_lines[i].rstrip("\r\n") + "\n"
+ test_socket_file = StringIO.StringIO("".join(infonames_lines))
+ self.assertRaises(stem.socket.ProtocolError, stem.socket.recv_message, test_socket_file)
+
+ # puts the CRLF back
+ infonames_lines[i] = infonames_lines[i].rstrip("\n") + "\r\n"
+
+ # sanity check the above test isn't broken due to leaving infonames_lines
+ # with invalid data
+
+ self._assert_message_parses("".join(infonames_lines))
+
+ def test_malformed_prefix(self):
+ """
+ Checks parsing for responses where the header is missing a digit or divider.
+ """
+
+ for i in range(len(EVENT_BW)):
+ # makes test input with that character missing or replaced
+ removal_test_input = EVENT_BW[:i] + EVENT_BW[i + 1:]
+ replacement_test_input = EVENT_BW[:i] + "#" + EVENT_BW[i + 1:]
+
+ if i < 4 or i >= (len(EVENT_BW) - 2):
+ # dropping the character should cause an error if...
+ # - this is part of the message prefix
+ # - this is disrupting the line ending
+
+ self.assertRaises(stem.socket.ProtocolError, stem.socket.recv_message, StringIO.StringIO(removal_test_input))
+ self.assertRaises(stem.socket.ProtocolError, stem.socket.recv_message, StringIO.StringIO(replacement_test_input))
+ else:
+ # otherwise the data will be malformed, but this goes undetected
+ self._assert_message_parses(removal_test_input)
+ self._assert_message_parses(replacement_test_input)
+
+ def test_disconnected_socket(self):
+ """
+ Tests when the read function is given a file derived from a disconnected
+ socket.
+ """
+
+ control_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ control_socket_file = control_socket.makefile()
+ self.assertRaises(stem.socket.SocketClosed, stem.socket.recv_message, control_socket_file)
+
+ def _assert_message_parses(self, controller_reply):
+ """
+ Performs some basic sanity checks that a reply mirrors its parsed result.
+
+ Returns:
+ stem.response.ControlMessage for the given input
+ """
+
+ message = stem.socket.recv_message(StringIO.StringIO(controller_reply))
+
+ # checks that the raw_content equals the input value
+ self.assertEqual(controller_reply, message.raw_content())
+
+ # checks that the contents match the input
+ message_lines = str(message).splitlines()
+ controller_lines = controller_reply.split("\r\n")
+ controller_lines.pop() # the ControlMessage won't have a trailing newline
+
+ while controller_lines:
+ line = controller_lines.pop(0)
+
+ # mismatching lines with just a period are probably data termination
+ if line == "." and (not message_lines or line != message_lines[0]):
+ continue
+
+ self.assertTrue(line.endswith(message_lines.pop(0)))
+
+ return message
+
diff --git a/test/unit/response/getinfo.py b/test/unit/response/getinfo.py
index 6787cff..5e82404 100644
--- a/test/unit/response/getinfo.py
+++ b/test/unit/response/getinfo.py
@@ -4,6 +4,7 @@ Unit tests for the stem.response.getinfo.GetInfoResponse class.
import unittest
+import stem.socket
import stem.response
import stem.response.getinfo
import test.mocking as mocking
@@ -53,7 +54,7 @@ class TestGetInfoResponse(unittest.TestCase):
stem.response.convert("GETINFO", control_message)
# now this should be a GetInfoResponse (ControlMessage subclass)
- self.assertTrue(isinstance(control_message, stem.socket.ControlMessage))
+ self.assertTrue(isinstance(control_message, stem.response.ControlMessage))
self.assertTrue(isinstance(control_message, stem.response.getinfo.GetInfoResponse))
self.assertEqual({}, control_message.entries)
diff --git a/test/unit/response/protocolinfo.py b/test/unit/response/protocolinfo.py
index 1d282ea..c82c8f8 100644
--- a/test/unit/response/protocolinfo.py
+++ b/test/unit/response/protocolinfo.py
@@ -58,7 +58,7 @@ class TestProtocolInfoResponse(unittest.TestCase):
stem.response.convert("PROTOCOLINFO", control_message)
# now this should be a ProtocolInfoResponse (ControlMessage subclass)
- self.assertTrue(isinstance(control_message, stem.socket.ControlMessage))
+ self.assertTrue(isinstance(control_message, stem.response.ControlMessage))
self.assertTrue(isinstance(control_message, stem.response.protocolinfo.ProtocolInfoResponse))
# exercise some of the ControlMessage functionality
diff --git a/test/unit/socket/__init__.py b/test/unit/socket/__init__.py
deleted file mode 100644
index a65ef67..0000000
--- a/test/unit/socket/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""
-Unit tests for stem.socket.
-"""
-
-__all__ = ["control_message", "control_line"]
-
diff --git a/test/unit/socket/control_line.py b/test/unit/socket/control_line.py
deleted file mode 100644
index 7752147..0000000
--- a/test/unit/socket/control_line.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""
-Unit tests for the stem.socket.ControlLine class.
-"""
-
-import unittest
-import stem.socket
-
-# response made by having 'DataDirectory /tmp/my data\"dir/' in the torrc
-PROTOCOLINFO_RESPONSE = (
- 'PROTOCOLINFO 1',
- 'AUTH METHODS=COOKIE COOKIEFILE="/tmp/my data\\\\\\"dir//control_auth_cookie"',
- 'VERSION Tor="0.2.1.30"',
- 'OK',
-)
-
-class TestControlLine(unittest.TestCase):
- def test_pop_examples(self):
- """
- Checks that the pop method's pydoc examples are correct.
- """
-
- line = stem.socket.ControlLine("\"We're all mad here.\" says the grinning cat.")
- self.assertEquals(line.pop(True), "We're all mad here.")
- self.assertEquals(line.pop(), "says")
- self.assertEquals(line.remainder(), "the grinning cat.")
-
- line = stem.socket.ControlLine("\"this has a \\\" and \\\\ in it\" foo=bar more_data")
- self.assertEquals(line.pop(True, True), "this has a \" and \\ in it")
-
- def test_string(self):
- """
- Basic checks that we behave as a regular immutable string.
- """
-
- line = stem.socket.ControlLine(PROTOCOLINFO_RESPONSE[0])
- self.assertEquals(line, 'PROTOCOLINFO 1')
- self.assertTrue(line.startswith('PROTOCOLINFO '))
-
- # checks that popping items doesn't effect us
- line.pop()
- self.assertEquals(line, 'PROTOCOLINFO 1')
- self.assertTrue(line.startswith('PROTOCOLINFO '))
-
- def test_general_usage(self):
- """
- Checks a basic use case for the popping entries.
- """
-
- # pops a series of basic, space separated entries
- line = stem.socket.ControlLine(PROTOCOLINFO_RESPONSE[0])
- self.assertEquals(line.remainder(), 'PROTOCOLINFO 1')
- self.assertFalse(line.is_empty())
- self.assertFalse(line.is_next_quoted())
- self.assertFalse(line.is_next_mapping())
- self.assertEquals(None, line.peek_key())
-
- self.assertRaises(ValueError, line.pop_mapping)
- self.assertEquals(line.pop(), 'PROTOCOLINFO')
- self.assertEquals(line.remainder(), '1')
- self.assertFalse(line.is_empty())
- self.assertFalse(line.is_next_quoted())
- self.assertFalse(line.is_next_mapping())
- self.assertEquals(None, line.peek_key())
-
- self.assertRaises(ValueError, line.pop_mapping)
- self.assertEquals(line.pop(), '1')
- self.assertEquals(line.remainder(), '')
- self.assertTrue(line.is_empty())
- self.assertFalse(line.is_next_quoted())
- self.assertFalse(line.is_next_mapping())
- self.assertEquals(None, line.peek_key())
-
- self.assertRaises(IndexError, line.pop_mapping)
- self.assertRaises(IndexError, line.pop)
- self.assertEquals(line.remainder(), '')
- self.assertTrue(line.is_empty())
- self.assertFalse(line.is_next_quoted())
- self.assertFalse(line.is_next_mapping())
- self.assertEquals(None, line.peek_key())
-
- def test_pop_mapping(self):
- """
- Checks use cases when parsing KEY=VALUE mappings.
- """
-
- # version entry with a space
- version_entry = 'Tor="0.2.1.30 (0a083b0188cacd2f07838ff0446113bd5211a024)"'
-
- line = stem.socket.ControlLine(version_entry)
- self.assertEquals(line.remainder(), version_entry)
- self.assertFalse(line.is_empty())
- self.assertFalse(line.is_next_quoted())
- self.assertTrue(line.is_next_mapping())
- self.assertTrue(line.is_next_mapping(key = "Tor"))
- self.assertTrue(line.is_next_mapping(key = "Tor", quoted = True))
- self.assertTrue(line.is_next_mapping(quoted = True))
- self.assertEquals("Tor", line.peek_key())
-
- # try popping this as a non-quoted mapping
- self.assertEquals(line.pop_mapping(), ('Tor', '"0.2.1.30'))
- self.assertEquals(line.remainder(), '(0a083b0188cacd2f07838ff0446113bd5211a024)"')
- self.assertFalse(line.is_empty())
- self.assertFalse(line.is_next_quoted())
- self.assertFalse(line.is_next_mapping())
- self.assertRaises(ValueError, line.pop_mapping)
- self.assertEquals(None, line.peek_key())
-
- # try popping this as a quoted mapping
- line = stem.socket.ControlLine(version_entry)
- self.assertEquals(line.pop_mapping(True), ('Tor', '0.2.1.30 (0a083b0188cacd2f07838ff0446113bd5211a024)'))
- self.assertEquals(line.remainder(), '')
- self.assertTrue(line.is_empty())
- self.assertFalse(line.is_next_quoted())
- self.assertFalse(line.is_next_mapping())
- self.assertEquals(None, line.peek_key())
-
- def test_escapes(self):
- """
- Checks that we can parse quoted values with escaped quotes in it. This
- explicitely comes up with the COOKIEFILE attribute of PROTOCOLINFO
- responses.
- """
-
- auth_line = PROTOCOLINFO_RESPONSE[1]
- line = stem.socket.ControlLine(auth_line)
- self.assertEquals(line, auth_line)
- self.assertEquals(line.remainder(), auth_line)
-
- self.assertEquals(line.pop(), "AUTH")
- self.assertEquals(line.pop_mapping(), ("METHODS", "COOKIE"))
-
- self.assertEquals(line.remainder(), r'COOKIEFILE="/tmp/my data\\\"dir//control_auth_cookie"')
- self.assertTrue(line.is_next_mapping())
- self.assertTrue(line.is_next_mapping(key = "COOKIEFILE"))
- self.assertTrue(line.is_next_mapping(quoted = True))
- self.assertTrue(line.is_next_mapping(quoted = True, escaped = True))
- cookie_file_entry = line.remainder()
-
- # try a general pop
- self.assertEquals(line.pop(), 'COOKIEFILE="/tmp/my')
- self.assertEquals(line.pop(), r'data\\\"dir//control_auth_cookie"')
- self.assertTrue(line.is_empty())
-
- # try a general pop with escapes
- line = stem.socket.ControlLine(cookie_file_entry)
- self.assertEquals(line.pop(escaped = True), 'COOKIEFILE="/tmp/my')
- self.assertEquals(line.pop(escaped = True), r'data\"dir//control_auth_cookie"')
- self.assertTrue(line.is_empty())
-
- # try a mapping pop
- line = stem.socket.ControlLine(cookie_file_entry)
- self.assertEquals(line.pop_mapping(), ('COOKIEFILE', '"/tmp/my'))
- self.assertEquals(line.remainder(), r'data\\\"dir//control_auth_cookie"')
- self.assertFalse(line.is_empty())
-
- # try a quoted mapping pop (this should trip up on the escaped quote)
- line = stem.socket.ControlLine(cookie_file_entry)
- self.assertEquals(line.pop_mapping(True), ('COOKIEFILE', '/tmp/my data\\\\\\'))
- self.assertEquals(line.remainder(), 'dir//control_auth_cookie"')
- self.assertFalse(line.is_empty())
-
- # try an escaped quoted mapping pop
- line = stem.socket.ControlLine(cookie_file_entry)
- self.assertEquals(line.pop_mapping(True, True), ('COOKIEFILE', r'/tmp/my data\"dir//control_auth_cookie'))
- self.assertTrue(line.is_empty())
-
diff --git a/test/unit/socket/control_message.py b/test/unit/socket/control_message.py
deleted file mode 100644
index 6d246e4..0000000
--- a/test/unit/socket/control_message.py
+++ /dev/null
@@ -1,184 +0,0 @@
-"""
-Unit tests for the stem.socket.ControlMessage parsing and class.
-"""
-
-import socket
-import StringIO
-import unittest
-import stem.socket
-
-OK_REPLY = "250 OK\r\n"
-
-EVENT_BW = "650 BW 32326 2856\r\n"
-EVENT_CIRC_TIMEOUT = "650 CIRC 5 FAILED PURPOSE=GENERAL REASON=TIMEOUT\r\n"
-EVENT_CIRC_LAUNCHED = "650 CIRC 9 LAUNCHED PURPOSE=GENERAL\r\n"
-EVENT_CIRC_EXTENDED = "650 CIRC 5 EXTENDED $A200F527C82C59A25CCA44884B49D3D65B122652=faktor PURPOSE=MEASURE_TIMEOUT\r\n"
-
-GETINFO_VERSION = """250-version=0.2.2.23-alpha (git-b85eb949b528f4d7)
-250 OK
-""".replace("\n", "\r\n")
-
-GETINFO_INFONAMES = """250+info/names=
-accounting/bytes -- Number of bytes read/written so far in the accounting interval.
-accounting/bytes-left -- Number of bytes left to write/read so far in the accounting interval.
-accounting/enabled -- Is accounting currently enabled?
-accounting/hibernating -- Are we hibernating or awake?
-stream-status -- List of current streams.
-version -- The current version of Tor.
-.
-250 OK
-""".replace("\n", "\r\n")
-
-class TestControlMessage(unittest.TestCase):
- def test_ok_response(self):
- """
- Checks the basic 'OK' response that we get for most commands.
- """
-
- message = self._assert_message_parses(OK_REPLY)
- self.assertEquals("OK", str(message))
-
- contents = message.content()
- self.assertEquals(1, len(contents))
- self.assertEquals(("250", " ", "OK"), contents[0])
-
- def test_event_response(self):
- """
- Checks parsing of actual events.
- """
-
- # BW event
- message = self._assert_message_parses(EVENT_BW)
- self.assertEquals("BW 32326 2856", str(message))
-
- contents = message.content()
- self.assertEquals(1, len(contents))
- self.assertEquals(("650", " ", "BW 32326 2856"), contents[0])
-
- # few types of CIRC events
- for circ_content in (EVENT_CIRC_TIMEOUT, EVENT_CIRC_LAUNCHED, EVENT_CIRC_EXTENDED):
- message = self._assert_message_parses(circ_content)
- self.assertEquals(circ_content[4:-2], str(message))
-
- contents = message.content()
- self.assertEquals(1, len(contents))
- self.assertEquals(("650", " ", str(message)), contents[0])
-
- def test_getinfo_response(self):
- """
- Checks parsing of actual GETINFO responses.
- """
-
- # GETINFO version (basic single-line results)
- message = self._assert_message_parses(GETINFO_VERSION)
- self.assertEquals(2, len(list(message)))
- self.assertEquals(2, len(str(message).splitlines()))
-
- # manually checks the contents
- contents = message.content()
- self.assertEquals(2, len(contents))
- self.assertEquals(("250", "-", "version=0.2.2.23-alpha (git-b85eb949b528f4d7)"), contents[0])
- self.assertEquals(("250", " ", "OK"), contents[1])
-
- # GETINFO info/names (data entry)
- message = self._assert_message_parses(GETINFO_INFONAMES)
- self.assertEquals(2, len(list(message)))
- self.assertEquals(8, len(str(message).splitlines()))
-
- # manually checks the contents
- contents = message.content()
- self.assertEquals(2, len(contents))
-
- first_entry = (contents[0][0], contents[0][1], contents[0][2][:contents[0][2].find("\n")])
- self.assertEquals(("250", "+", "info/names="), first_entry)
- self.assertEquals(("250", " ", "OK"), contents[1])
-
- def test_no_crlf(self):
- """
- Checks that we get a ProtocolError when we don't have both a carrage
- returna and newline for line endings. This doesn't really check for
- newlines (since that's what readline would break on), but not the end of
- the world.
- """
-
- # Replaces each of the CRLF entries with just LF, confirming that this
- # causes a parsing error. This should test line endings for both data
- # entry parsing and non-data.
-
- infonames_lines = [line + "\n" for line in GETINFO_INFONAMES.splitlines()]
-
- for i in range(len(infonames_lines)):
- # replace the CRLF for the line
- infonames_lines[i] = infonames_lines[i].rstrip("\r\n") + "\n"
- test_socket_file = StringIO.StringIO("".join(infonames_lines))
- self.assertRaises(stem.socket.ProtocolError, stem.socket.recv_message, test_socket_file)
-
- # puts the CRLF back
- infonames_lines[i] = infonames_lines[i].rstrip("\n") + "\r\n"
-
- # sanity check the above test isn't broken due to leaving infonames_lines
- # with invalid data
-
- self._assert_message_parses("".join(infonames_lines))
-
- def test_malformed_prefix(self):
- """
- Checks parsing for responses where the header is missing a digit or divider.
- """
-
- for i in range(len(EVENT_BW)):
- # makes test input with that character missing or replaced
- removal_test_input = EVENT_BW[:i] + EVENT_BW[i + 1:]
- replacement_test_input = EVENT_BW[:i] + "#" + EVENT_BW[i + 1:]
-
- if i < 4 or i >= (len(EVENT_BW) - 2):
- # dropping the character should cause an error if...
- # - this is part of the message prefix
- # - this is disrupting the line ending
-
- self.assertRaises(stem.socket.ProtocolError, stem.socket.recv_message, StringIO.StringIO(removal_test_input))
- self.assertRaises(stem.socket.ProtocolError, stem.socket.recv_message, StringIO.StringIO(replacement_test_input))
- else:
- # otherwise the data will be malformed, but this goes undetected
- self._assert_message_parses(removal_test_input)
- self._assert_message_parses(replacement_test_input)
-
- def test_disconnected_socket(self):
- """
- Tests when the read function is given a file derived from a disconnected
- socket.
- """
-
- control_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- control_socket_file = control_socket.makefile()
- self.assertRaises(stem.socket.SocketClosed, stem.socket.recv_message, control_socket_file)
-
- def _assert_message_parses(self, controller_reply):
- """
- Performs some basic sanity checks that a reply mirrors its parsed result.
-
- Returns:
- stem.socket.ControlMessage for the given input
- """
-
- message = stem.socket.recv_message(StringIO.StringIO(controller_reply))
-
- # checks that the raw_content equals the input value
- self.assertEqual(controller_reply, message.raw_content())
-
- # checks that the contents match the input
- message_lines = str(message).splitlines()
- controller_lines = controller_reply.split("\r\n")
- controller_lines.pop() # the ControlMessage won't have a trailing newline
-
- while controller_lines:
- line = controller_lines.pop(0)
-
- # mismatching lines with just a period are probably data termination
- if line == "." and (not message_lines or line != message_lines[0]):
- continue
-
- self.assertTrue(line.endswith(message_lines.pop(0)))
-
- return message
-