From 8886400c30e163f4289d09496d3a013d842c8537 Mon Sep 17 00:00:00 2001 From: Stewart Watkiss Date: Sun, 3 May 2015 12:34:29 +0100 Subject: [PATCH 1/3] Created a Python3 version of scratch.py --- scratch/scratchpy3.py | 258 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 scratch/scratchpy3.py diff --git a/scratch/scratchpy3.py b/scratch/scratchpy3.py new file mode 100644 index 0000000..d07ad2a --- /dev/null +++ b/scratch/scratchpy3.py @@ -0,0 +1,258 @@ +import array +import itertools +import socket +import struct + +class ScratchError(Exception): pass +class ScratchConnectionError(ScratchError): pass + +class Scratch(object): + + prefix_len = 4 + broadcast_prefix_len = prefix_len + len('broadcast ') + sensorupdate_prefix_len = prefix_len + len('sensor-update ') + + msg_types = set(['broadcast', 'sensor-update']) + + def __init__(self, host='localhost', port=42001): + self.host = host + self.port = port + self.socket = None + self.connected = False + self.connect() + + def __repr__(self): + return "Scratch(host=%r, port=%r)" % (self.host, self.port) + + def _pack(self, msg): + """ + Packages msg according to Scratch message specification (encodes and + appends length prefix to msg). Credit to chalkmarrow from the + scratch.mit.edu forums for the prefix encoding code. + """ + n = len(msg) + packstr = chr((n >> 24) & 0xFF) + packstr += chr ((n >> 16) & 0xFF) + packstr += chr ((n >> 8) & 0xFF) + packstr += chr (n & 0xFF) + return packstr + msg + + def _extract_len(self, prefix): + """ + Extracts the length of a Scratch message from the given message prefix. + """ + return struct.unpack(">L", prefix)[0] + + def _get_type(self, s): + """ + Converts a string from Scratch to its proper type in Python. Expects a + string with its delimiting quotes in place. Returns either a string, + int or float. + """ + # TODO: what if the number is bigger than an int or float? + # convert to string (rather than bytes) + s = s.decode(encoding='UTF-8') + if s.startswith("\"") and s.endswith("\""): + return s[1:-1] + elif s.find('.') != -1: + return float(s) + else: + return int(s) + + def _escape(self, msg): + """ + Escapes double quotes by adding another double quote as per the Scratch + protocol. Expects a string without its delimiting quotes. Returns a new + escaped string. + """ + escaped = '' + for c in msg: + escaped += c + if c == '"': + escaped += '"' + return escaped + + def _unescape(self, msg): + """ + Removes double quotes that were used to escape double quotes. Expects + a string without its delimiting quotes, or a number. Returns a new + unescaped string. + """ + if isinstance(msg, (int, float)): + return msg + + unescaped = '' + i = 0 + while i < len(msg): + unescaped += msg[i] + if msg[i] == '"': + i+=1 + i+=1 + return unescaped + + def _is_msg(self, msg): + """ + Returns True if message is a proper Scratch message, else return False. + """ + if not msg or len(msg) < self.prefix_len: + return False + length = self._extract_len(msg[:self.prefix_len]) + msg_type = msg[self.prefix_len:].decode(encoding='UTF-8').split(' ', 1)[0] + if length == len(msg[self.prefix_len:]) and msg_type in self.msg_types: + return True + return False + + def _parse_broadcast(self, msg): + """ + Given a broacast message, returns the message that was broadcast. + """ + # get message, remove surrounding quotes, and unescape + return self._unescape(self._get_type(msg[self.broadcast_prefix_len:])) + + def _parse_sensorupdate(self, msg): + """ + Given a sensor-update message, returns the sensors/variables that were + updated as a dict that maps sensors/variables to their updated values. + """ + update = msg[self.sensorupdate_prefix_len:] + parsed = [] # each element is either a sensor (key) or a sensor value + curr_seg = '' # current segment (i.e. key or value) being formed + numq = 0 # number of double quotes in current segment + for seg in update.split(' ')[:-1]: # last char in update is a space + numq += seg.count('"') + curr_seg += seg + # even number of quotes means we've finished parsing a segment + if numq % 2 == 0: + parsed.append(curr_seg) + curr_seg = '' + numq = 0 + else: # segment has a space inside, so add back it in + curr_seg += ' ' + unescaped = [self._unescape(self._get_type(x)) for x in parsed] + # combine into a dict using iterators (both elements in the list + # inputted to izip have a reference to the same iterator). even + # elements are keys, odd are values + return dict(itertools.izip(*[iter(unescaped)]*2)) + + def _parse(self, msg): + """ + Parses a Scratch message and returns a tuple with the first element + as the message type, and the second element as the message payload. The + payload for a 'broadcast' message is a string, and the payload for a + 'sensor-update' message is a dict whose keys are variables, and values + are updated variable values. Returns None if msg is not a message. + """ + if not self._is_msg(msg): + return None + msg_type = msg[self.prefix_len:].decode(encoding='UTF-8').split(' ')[0] + if msg_type == 'broadcast': + return ('broadcast', self._parse_broadcast(msg)) + else: + return ('sensor-update', self._parse_sensorupdate(msg)) + + def _write(self, data): + """ + Writes string data out to Scratch + """ + total_sent = 0 + length = len(data) + while total_sent < length: + try: + sent = self.socket.send(bytes(data[total_sent:], 'UTF-8')) + except socket.error: + self.connected = False + raise ScratchError from socket.error + if sent == 0: + self.connected = False + raise ScratchConnectionError("Connection broken") + total_sent += sent + + def _send(self, data): + """ + Sends a message to Scratch + """ + self._write(self._pack(data)) + + def _read(self, size): + """ + Reads size number of bytes from Scratch and returns data as a string + """ + data = b'' + while len(data) < size: + try: + chunk = self.socket.recv(size-len(data)) + except socket.error: + self.connected = False + raise ScratchError from socket.error + if chunk == '': + self.connected = False + raise ScratchConnectionError("Connection broken") + data += chunk + return data + + def _recv(self): + """ + Receives and returns a message from Scratch + """ + prefix = self._read(self.prefix_len) + msg = self._read(self._extract_len(prefix)) + return prefix + msg + + def connect(self): + """ + Connects to Scratch. + """ + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.socket.connect((self.host, self.port)) + except socket.error: + self.connected = False + raise ScratchError from socket.error + self.connected = True + + def disconnect(self): + """ + Closes connection to Scratch + """ + try: # connection may already be disconnected, so catch exceptions + self.socket.shutdown(socket.SHUT_RDWR) # a proper disconnect + except socket.error: + pass + self.socket.close() + self.connected = False + + def sensorupdate(self, data): + """ + Given a dict of sensors and values, updates those sensors with the + values in Scratch. + """ + if not isinstance(data, dict): + raise TypeError('Expected a dict') + msg = 'sensor-update ' + for key in data.keys(): + msg += '"%s" "%s" ' % (self._escape(str(key)), + self._escape(str(data[key]))) + self._send(msg) + + def broadcast(self, msg): + """ + Broadcasts msg to Scratch. msg can be a single message or an iterable + (list, tuple, set, generator, etc.) of messages. + """ + #if getattr(msg, '__iter__', False): # iterable + if isinstance(msg, (tuple, list, set)): + for m in msg: + self._send('broadcast "%s"' % self._escape(str(m))) + else: # probably a string or number + self._send('broadcast "%s"' % self._escape(str(msg))) + + def receive(self): + """ + Receives broadcasts and sensor updates from Scratch. Returns a tuple + with the first element as the message type and the second element + as the message payload. broadcast messages have a string as payload, + and the sensor-update messages have a dict as payload. Returns None if + message received could not be parsed. Raises exceptions on connection + errors. + """ + return self._parse(self._recv()) From fad0a76fd2069d8c2fbebb8733c353700b55b289 Mon Sep 17 00:00:00 2001 From: Stewart Watkiss Date: Wed, 19 Aug 2015 19:43:23 +0100 Subject: [PATCH 2/3] Merge Scratch 2 and Scratch 3 versions --- tests/parsing.py | 126 +++++++++++++++++++++++------------------------ 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/tests/parsing.py b/tests/parsing.py index 89db3ca..8db55b2 100644 --- a/tests/parsing.py +++ b/tests/parsing.py @@ -6,79 +6,79 @@ class ParsingTests(unittest.TestCase): Must have Scratch running with remote sensor connections enabled """ def setUp(self): - self.client = Scratch() + self.client = Scratch() def test_broadcast(self): - self.assertEquals( - self.client._parse('\x00\x00\x00\x0fbroadcast "a b"'), - ('broadcast', 'a b') - ) - self.assertEquals( - self.client._parse('\x00\x00\x00\x13broadcast """a b"""'), - ('broadcast', '"a b"') - ) - self.assertEquals( - self.client._parse('\x00\x00\x00\x17broadcast """""a b"""""'), - ('broadcast', '""a b""') - ) - self.assertEquals( - self.client._parse('\x00\x00\x00\x13broadcast """a"" b"'), - ('broadcast', '"a" b') - ) - self.assertEquals( - self.client._parse('\x00\x00\x00\x17broadcast """a"" ""b"""'), - ('broadcast', '"a" "b"') - ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x0fbroadcast "a b"'), + ('broadcast', 'a b') + ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x13broadcast """a b"""'), + ('broadcast', '"a b"') + ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x17broadcast """""a b"""""'), + ('broadcast', '""a b""') + ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x13broadcast """a"" b"'), + ('broadcast', '"a" b') + ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x17broadcast """a"" ""b"""'), + ('broadcast', '"a" "b"') + ) def test_sensorupdate(self): - self.assertEquals( - self.client._parse('\x00\x00\x00\x14sensor-update "a" 0 '), - ('sensor-update', {'a': 0}) - ) - self.assertEquals( - self.client._parse('\x00\x00\x00\x16sensor-update "a" "c" '), - ('sensor-update', {'a': 'c'}) - ) - self.assertEquals( - self.client._parse('\x00\x00\x00\x16sensor-update "a b" 0 '), - ('sensor-update', {'a b': 0}) - ) - self.assertEquals( - self.client._parse('\x00\x00\x00\x1asensor-update """a b""" 0 '), - ('sensor-update', {'"a b"': 0}) - ) - self.assertEquals( - self.client._parse('\x00\x00\x003sensor-update """a b""" "hello hi" "a" "c" "a b" 0 '), - ('sensor-update', {'"a b"': 'hello hi', 'a': 'c', 'a b': 0}) - ) - self.assertEquals( - self.client._parse('\x00\x00\x00\x1esensor-update """a"" ""b""" 0 '), - ('sensor-update', {'"a" "b"': 0}) - ) - self.assertEquals( - self.client._parse('\x00\x00\x00*sensor-update """a"" ""b""" """c"" ""d""" '), - ('sensor-update', {'"a" "b"': '"c" "d"'}) - ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x14sensor-update "a" 0 '), + ('sensor-update', {'a': 0}) + ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x16sensor-update "a" "c" '), + ('sensor-update', {'a': 'c'}) + ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x16sensor-update "a b" 0 '), + ('sensor-update', {'a b': 0}) + ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x1asensor-update """a b""" 0 '), + ('sensor-update', {'"a b"': 0}) + ) + self.assertEquals( + self.client._parse('\x00\x00\x003sensor-update """a b""" "hello hi" "a" "c" "a b" 0 '), + ('sensor-update', {'"a b"': 'hello hi', 'a': 'c', 'a b': 0}) + ) + self.assertEquals( + self.client._parse('\x00\x00\x00\x1esensor-update """a"" ""b""" 0 '), + ('sensor-update', {'"a" "b"': 0}) + ) + self.assertEquals( + self.client._parse('\x00\x00\x00*sensor-update """a"" ""b""" """c"" ""d""" '), + ('sensor-update', {'"a" "b"': '"c" "d"'}) + ) def test_is_msg(self): - self.assertTrue(self.client._is_msg('\x00\x00\x00\x14sensor-update "a" 0 ')) - self.assertTrue(self.client._is_msg('\x00\x00\x00\x0ebroadcast "hi"')) - self.assertFalse(self.client._is_msg('\x00\x00\x00\x14sensor-update "a"')) - self.assertFalse(self.client._is_msg('\x00\x00\x00\x14benbor-update "a" 0 ')) - self.assertFalse(self.client._is_msg('')) - self.assertFalse(self.client._is_msg(None)) - self.assertFalse(self.client._is_msg('\x00\x00\x00\x00')) - self.assertFalse(self.client._is_msg('\x00\x00\x00')) + self.assertTrue(self.client._is_msg('\x00\x00\x00\x14sensor-update "a" 0 ')) + self.assertTrue(self.client._is_msg('\x00\x00\x00\x0ebroadcast "hi"')) + self.assertFalse(self.client._is_msg('\x00\x00\x00\x14sensor-update "a"')) + self.assertFalse(self.client._is_msg('\x00\x00\x00\x14benbor-update "a" 0 ')) + self.assertFalse(self.client._is_msg('')) + self.assertFalse(self.client._is_msg(None)) + self.assertFalse(self.client._is_msg('\x00\x00\x00\x00')) + self.assertFalse(self.client._is_msg('\x00\x00\x00')) def test_escape(self): - self.assertEquals(self.client._escape(''), '') - self.assertEquals(self.client._escape('"a"'), '""a""') + self.assertEquals(self.client._escape(''), '') + self.assertEquals(self.client._escape('"a"'), '""a""') def test_unescape(self): - self.assertEquals(self.client._unescape(''), '') - self.assertEquals(self.client._unescape('a'), 'a') - self.assertEquals(self.client._unescape('""a""'), '"a"') - self.assertEquals(self.client._unescape(0), 0) + self.assertEquals(self.client._unescape(''), '') + self.assertEquals(self.client._unescape('a'), 'a') + self.assertEquals(self.client._unescape('""a""'), '"a"') + self.assertEquals(self.client._unescape(0), 0) if __name__ == '__main__': unittest.main() From 544dfe743e4e1859490b78a791601cb59210ef08 Mon Sep 17 00:00:00 2001 From: Stewart Watkiss Date: Wed, 19 Aug 2015 19:59:20 +0100 Subject: [PATCH 3/3] Move scratch3 to scratch.py --- scratch/scratch.py | 412 +++++++++++++++++++++--------------------- scratch/scratchpy3.py | 258 -------------------------- 2 files changed, 210 insertions(+), 460 deletions(-) delete mode 100644 scratch/scratchpy3.py diff --git a/scratch/scratch.py b/scratch/scratch.py index c7b5686..6696b11 100644 --- a/scratch/scratch.py +++ b/scratch/scratch.py @@ -2,9 +2,14 @@ import itertools import socket import struct +import sys + +# For Scratch 3 handle long as int +if sys.version > '3': + long = int class ScratchError(Exception): pass -class ScratchConnectionError(ScratchError): pass +class ScratchConnectionError(ScratchError): pass class Scratch(object): @@ -15,242 +20,245 @@ class Scratch(object): msg_types = set(['broadcast', 'sensor-update']) def __init__(self, host='localhost', port=42001): - self.host = host - self.port = port - self.socket = None - self.connected = False - self.connect() + self.host = host + self.port = port + self.socket = None + self.connected = False + self.connect() def __repr__(self): - return "Scratch(host=%r, port=%r)" % (self.host, self.port) + return "Scratch(host=%r, port=%r)" % (self.host, self.port) def _pack(self, msg): - """ - Packages msg according to Scratch message specification (encodes and - appends length prefix to msg). Credit to chalkmarrow from the - scratch.mit.edu forums for the prefix encoding code. - """ - n = len(msg) - a = array.array('c') - a.append(chr((n >> 24) & 0xFF)) - a.append(chr((n >> 16) & 0xFF)) - a.append(chr((n >> 8) & 0xFF)) - a.append(chr(n & 0xFF)) - return a.tostring() + msg + """ + Packages msg according to Scratch message specification (encodes and + appends length prefix to msg). Credit to chalkmarrow from the + scratch.mit.edu forums for the prefix encoding code. + """ + n = len(msg) + packstr = chr((n >> 24) & 0xFF) + packstr += chr ((n >> 16) & 0xFF) + packstr += chr ((n >> 8) & 0xFF) + packstr += chr (n & 0xFF) + return packstr + msg def _extract_len(self, prefix): - """ - Extracts the length of a Scratch message from the given message prefix. - """ - return struct.unpack(">L", prefix)[0] + """ + Extracts the length of a Scratch message from the given message prefix. + """ + return struct.unpack(">L", prefix)[0] def _get_type(self, s): - """ - Converts a string from Scratch to its proper type in Python. Expects a - string with its delimiting quotes in place. Returns either a string, - int or float. - """ - # TODO: what if the number is bigger than an int or float? - if s.startswith('"') and s.endswith('"'): - return s[1:-1] - elif s.find('.') != -1: - return float(s) - else: - return int(s) + """ + Converts a string from Scratch to its proper type in Python. Expects a + string with its delimiting quotes in place. Returns either a string, + int or float. + """ + # TODO: what if the number is bigger than an int or float? + + # convert to string (rather than bytes) + s = s.decode(encoding='UTF-8') + if s.startswith("\"") and s.endswith("\""): + return s[1:-1] + elif s.find('.') != -1: + return float(s) + else: + return int(s) def _escape(self, msg): - """ - Escapes double quotes by adding another double quote as per the Scratch - protocol. Expects a string without its delimiting quotes. Returns a new - escaped string. - """ - escaped = '' - for c in msg: - escaped += c - if c == '"': - escaped += '"' - return escaped + """ + Escapes double quotes by adding another double quote as per the Scratch + protocol. Expects a string without its delimiting quotes. Returns a new + escaped string. + """ + escaped = '' + for c in msg: + escaped += c + if c == '"': + escaped += '"' + return escaped def _unescape(self, msg): - """ - Removes double quotes that were used to escape double quotes. Expects - a string without its delimiting quotes, or a number. Returns a new - unescaped string. - """ - if isinstance(msg, (int, float, long)): - return msg + """ + Removes double quotes that were used to escape double quotes. Expects + a string without its delimiting quotes, or a number. Returns a new + unescaped string. + """ + if isinstance(msg, (int, float, long)): + return msg - unescaped = '' - i = 0 - while i < len(msg): - unescaped += msg[i] - if msg[i] == '"': - i+=1 - i+=1 - return unescaped + unescaped = '' + i = 0 + while i < len(msg): + unescaped += msg[i] + if msg[i] == '"': + i+=1 + i+=1 + return unescaped def _is_msg(self, msg): - """ - Returns True if message is a proper Scratch message, else return False. - """ - if not msg or len(msg) < self.prefix_len: - return False - length = self._extract_len(msg[:self.prefix_len]) - msg_type = msg[self.prefix_len:].split(' ', 1)[0] - if length == len(msg[self.prefix_len:]) and msg_type in self.msg_types: - return True - return False + """ + Returns True if message is a proper Scratch message, else return False. + """ + if not msg or len(msg) < self.prefix_len: + return False + length = self._extract_len(msg[:self.prefix_len]) + msg_type = msg[self.prefix_len:].decode(encoding='UTF-8').split(' ', 1)[0] + if length == len(msg[self.prefix_len:]) and msg_type in self.msg_types: + return True + return False def _parse_broadcast(self, msg): - """ - Given a broacast message, returns the message that was broadcast. - """ - # get message, remove surrounding quotes, and unescape - return self._unescape(self._get_type(msg[self.broadcast_prefix_len:])) + """ + Given a broacast message, returns the message that was broadcast. + """ + # get message, remove surrounding quotes, and unescape + return self._unescape(self._get_type(msg[self.broadcast_prefix_len:])) def _parse_sensorupdate(self, msg): - """ - Given a sensor-update message, returns the sensors/variables that were - updated as a dict that maps sensors/variables to their updated values. - """ - update = msg[self.sensorupdate_prefix_len:] - parsed = [] # each element is either a sensor (key) or a sensor value - curr_seg = '' # current segment (i.e. key or value) being formed - numq = 0 # number of double quotes in current segment - for seg in update.split(' ')[:-1]: # last char in update is a space - numq += seg.count('"') - curr_seg += seg - # even number of quotes means we've finished parsing a segment - if numq % 2 == 0: - parsed.append(curr_seg) - curr_seg = '' - numq = 0 - else: # segment has a space inside, so add back it in - curr_seg += ' ' - unescaped = [self._unescape(self._get_type(x)) for x in parsed] - # combine into a dict using iterators (both elements in the list - # inputted to izip have a reference to the same iterator). even - # elements are keys, odd are values - return dict(itertools.izip(*[iter(unescaped)]*2)) + """ + Given a sensor-update message, returns the sensors/variables that were + updated as a dict that maps sensors/variables to their updated values. + """ + update = msg[self.sensorupdate_prefix_len:].decode(encoding='UTF-8') + parsed = [] # each element is either a sensor (key) or a sensor value + curr_seg = '' # current segment (i.e. key or value) being formed + numq = 0 # number of double quotes in current segment + for seg in update.split(' ')[:-1]: # last char in update is a space + numq += seg.count('"') + curr_seg += seg + # even number of quotes means we've finished parsing a segment + if numq % 2 == 0: + parsed.append(curr_seg) + curr_seg = '' + numq = 0 + else: # segment has a space inside, so add back it in + curr_seg += ' ' + unescaped = [self._unescape(self._get_type(x)) for x in parsed] + # combine into a dict using iterators (both elements in the list + # inputted to izip have a reference to the same iterator). even + # elements are keys, odd are values + return dict(itertools.izip(*[iter(unescaped)]*2)) def _parse(self, msg): - """ - Parses a Scratch message and returns a tuple with the first element - as the message type, and the second element as the message payload. The - payload for a 'broadcast' message is a string, and the payload for a - 'sensor-update' message is a dict whose keys are variables, and values - are updated variable values. Returns None if msg is not a message. - """ - if not self._is_msg(msg): - return None - msg_type = msg[self.prefix_len:].split(' ')[0] - if msg_type == 'broadcast': - return ('broadcast', self._parse_broadcast(msg)) - else: - return ('sensor-update', self._parse_sensorupdate(msg)) + """ + Parses a Scratch message and returns a tuple with the first element + as the message type, and the second element as the message payload. The + payload for a 'broadcast' message is a string, and the payload for a + 'sensor-update' message is a dict whose keys are variables, and values + are updated variable values. Returns None if msg is not a message. + """ + if not self._is_msg(msg): + return None + msg_type = msg[self.prefix_len:].decode(encoding='UTF-8').split(' ')[0] + if msg_type == 'broadcast': + return ('broadcast', self._parse_broadcast(msg)) + else: + return ('sensor-update', self._parse_sensorupdate(msg)) def _write(self, data): - """ - Writes string data out to Scratch - """ - total_sent = 0 - length = len(data) - while total_sent < length: - try: - sent = self.socket.send(data[total_sent:]) - except socket.error as (err, msg): - self.connected = False - raise ScratchError("[Errno %d] %s" % (err, msg)) - if sent == 0: - self.connected = False - raise ScratchConnectionError("Connection broken") - total_sent += sent + """ + Writes string data out to Scratch + """ + total_sent = 0 + length = len(data) + while total_sent < length: + try: + sent = self.socket.send(data[total_sent:].encode('UTF-8')) + except socket.error as e: + self.connected = False + raise ScratchError(e) + if sent == 0: + self.connected = False + raise ScratchConnectionError("Connection broken") + total_sent += sent def _send(self, data): - """ - Sends a message to Scratch - """ - self._write(self._pack(data)) + """ + Sends a message to Scratch + """ + self._write(self._pack(data)) def _read(self, size): - """ - Reads size number of bytes from Scratch and returns data as a string - """ - data = '' - while len(data) < size: - try: - chunk = self.socket.recv(size-len(data)) - except socket.error as (err, msg): - self.connected = False - raise ScratchError("[Errno %d] %s" % (err, msg)) - if chunk == '': - self.connected = False - raise ScratchConnectionError("Connection broken") - data += chunk - return data + """ + Reads size number of bytes from Scratch and returns data as a string + """ + data = b'' + while len(data) < size: + try: + chunk = self.socket.recv(size-len(data)) + except socket.error as e: + self.connected = False + raise ScratchError(e) + if chunk == '': + self.connected = False + raise ScratchConnectionError("Connection broken") + data += chunk + return data def _recv(self): - """ - Receives and returns a message from Scratch - """ - prefix = self._read(self.prefix_len) - msg = self._read(self._extract_len(prefix)) - return prefix + msg + """ + Receives and returns a message from Scratch + """ + prefix = self._read(self.prefix_len) + msg = self._read(self._extract_len(prefix)) + return prefix + msg def connect(self): - """ - Connects to Scratch. - """ - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.socket.connect((self.host, self.port)) - except socket.error as (err, msg): - self.connected = False - raise ScratchError("[Errno %d] %s" % (err, msg)) - self.connected = True - + """ + Connects to Scratch. + """ + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.socket.connect((self.host, self.port)) + except socket.error as e: + self.connected = False + raise ScratchError(e) + self.connected = True + def disconnect(self): - """ - Closes connection to Scratch - """ - try: # connection may already be disconnected, so catch exceptions - self.socket.shutdown(socket.SHUT_RDWR) # a proper disconnect - except socket.error: - pass - self.socket.close() - self.connected = False + """ + Closes connection to Scratch + """ + try: # connection may already be disconnected, so catch exceptions + self.socket.shutdown(socket.SHUT_RDWR) # a proper disconnect + except socket.error: + pass + self.socket.close() + self.connected = False def sensorupdate(self, data): - """ - Given a dict of sensors and values, updates those sensors with the - values in Scratch. - """ - if not isinstance(data, dict): - raise TypeError('Expected a dict') - msg = 'sensor-update ' - for key in data.keys(): - msg += '"%s" "%s" ' % (self._escape(str(key)), - self._escape(str(data[key]))) - self._send(msg) + """ + Given a dict of sensors and values, updates those sensors with the + values in Scratch. + """ + if not isinstance(data, dict): + raise TypeError('Expected a dict') + msg = 'sensor-update ' + for key in data.keys(): + msg += '"%s" "%s" ' % (self._escape(str(key)), + self._escape(str(data[key]))) + self._send(msg) def broadcast(self, msg): - """ - Broadcasts msg to Scratch. msg can be a single message or an iterable - (list, tuple, set, generator, etc.) of messages. - """ - if getattr(msg, '__iter__', False): # iterable - for m in msg: - self._send('broadcast "%s"' % self._escape(str(m))) - else: # probably a string or number - self._send('broadcast "%s"' % self._escape(str(msg))) + """ + Broadcasts msg to Scratch. msg can be a single message or an iterable + (list, tuple, set, generator, etc.) of messages. + """ + #if getattr(msg, '__iter__', False): # iterable + if isinstance(msg, (tuple, list, set)): + for m in msg: + self._send('broadcast "%s"' % self._escape(str(m))) + else: # probably a string or number + self._send('broadcast "%s"' % self._escape(str(msg))) def receive(self): - """ - Receives broadcasts and sensor updates from Scratch. Returns a tuple - with the first element as the message type and the second element - as the message payload. broadcast messages have a string as payload, - and the sensor-update messages have a dict as payload. Returns None if - message received could not be parsed. Raises exceptions on connection - errors. - """ - return self._parse(self._recv()) + """ + Receives broadcasts and sensor updates from Scratch. Returns a tuple + with the first element as the message type and the second element + as the message payload. broadcast messages have a string as payload, + and the sensor-update messages have a dict as payload. Returns None if + message received could not be parsed. Raises exceptions on connection + errors. + """ + return self._parse(self._recv()) diff --git a/scratch/scratchpy3.py b/scratch/scratchpy3.py deleted file mode 100644 index d07ad2a..0000000 --- a/scratch/scratchpy3.py +++ /dev/null @@ -1,258 +0,0 @@ -import array -import itertools -import socket -import struct - -class ScratchError(Exception): pass -class ScratchConnectionError(ScratchError): pass - -class Scratch(object): - - prefix_len = 4 - broadcast_prefix_len = prefix_len + len('broadcast ') - sensorupdate_prefix_len = prefix_len + len('sensor-update ') - - msg_types = set(['broadcast', 'sensor-update']) - - def __init__(self, host='localhost', port=42001): - self.host = host - self.port = port - self.socket = None - self.connected = False - self.connect() - - def __repr__(self): - return "Scratch(host=%r, port=%r)" % (self.host, self.port) - - def _pack(self, msg): - """ - Packages msg according to Scratch message specification (encodes and - appends length prefix to msg). Credit to chalkmarrow from the - scratch.mit.edu forums for the prefix encoding code. - """ - n = len(msg) - packstr = chr((n >> 24) & 0xFF) - packstr += chr ((n >> 16) & 0xFF) - packstr += chr ((n >> 8) & 0xFF) - packstr += chr (n & 0xFF) - return packstr + msg - - def _extract_len(self, prefix): - """ - Extracts the length of a Scratch message from the given message prefix. - """ - return struct.unpack(">L", prefix)[0] - - def _get_type(self, s): - """ - Converts a string from Scratch to its proper type in Python. Expects a - string with its delimiting quotes in place. Returns either a string, - int or float. - """ - # TODO: what if the number is bigger than an int or float? - # convert to string (rather than bytes) - s = s.decode(encoding='UTF-8') - if s.startswith("\"") and s.endswith("\""): - return s[1:-1] - elif s.find('.') != -1: - return float(s) - else: - return int(s) - - def _escape(self, msg): - """ - Escapes double quotes by adding another double quote as per the Scratch - protocol. Expects a string without its delimiting quotes. Returns a new - escaped string. - """ - escaped = '' - for c in msg: - escaped += c - if c == '"': - escaped += '"' - return escaped - - def _unescape(self, msg): - """ - Removes double quotes that were used to escape double quotes. Expects - a string without its delimiting quotes, or a number. Returns a new - unescaped string. - """ - if isinstance(msg, (int, float)): - return msg - - unescaped = '' - i = 0 - while i < len(msg): - unescaped += msg[i] - if msg[i] == '"': - i+=1 - i+=1 - return unescaped - - def _is_msg(self, msg): - """ - Returns True if message is a proper Scratch message, else return False. - """ - if not msg or len(msg) < self.prefix_len: - return False - length = self._extract_len(msg[:self.prefix_len]) - msg_type = msg[self.prefix_len:].decode(encoding='UTF-8').split(' ', 1)[0] - if length == len(msg[self.prefix_len:]) and msg_type in self.msg_types: - return True - return False - - def _parse_broadcast(self, msg): - """ - Given a broacast message, returns the message that was broadcast. - """ - # get message, remove surrounding quotes, and unescape - return self._unescape(self._get_type(msg[self.broadcast_prefix_len:])) - - def _parse_sensorupdate(self, msg): - """ - Given a sensor-update message, returns the sensors/variables that were - updated as a dict that maps sensors/variables to their updated values. - """ - update = msg[self.sensorupdate_prefix_len:] - parsed = [] # each element is either a sensor (key) or a sensor value - curr_seg = '' # current segment (i.e. key or value) being formed - numq = 0 # number of double quotes in current segment - for seg in update.split(' ')[:-1]: # last char in update is a space - numq += seg.count('"') - curr_seg += seg - # even number of quotes means we've finished parsing a segment - if numq % 2 == 0: - parsed.append(curr_seg) - curr_seg = '' - numq = 0 - else: # segment has a space inside, so add back it in - curr_seg += ' ' - unescaped = [self._unescape(self._get_type(x)) for x in parsed] - # combine into a dict using iterators (both elements in the list - # inputted to izip have a reference to the same iterator). even - # elements are keys, odd are values - return dict(itertools.izip(*[iter(unescaped)]*2)) - - def _parse(self, msg): - """ - Parses a Scratch message and returns a tuple with the first element - as the message type, and the second element as the message payload. The - payload for a 'broadcast' message is a string, and the payload for a - 'sensor-update' message is a dict whose keys are variables, and values - are updated variable values. Returns None if msg is not a message. - """ - if not self._is_msg(msg): - return None - msg_type = msg[self.prefix_len:].decode(encoding='UTF-8').split(' ')[0] - if msg_type == 'broadcast': - return ('broadcast', self._parse_broadcast(msg)) - else: - return ('sensor-update', self._parse_sensorupdate(msg)) - - def _write(self, data): - """ - Writes string data out to Scratch - """ - total_sent = 0 - length = len(data) - while total_sent < length: - try: - sent = self.socket.send(bytes(data[total_sent:], 'UTF-8')) - except socket.error: - self.connected = False - raise ScratchError from socket.error - if sent == 0: - self.connected = False - raise ScratchConnectionError("Connection broken") - total_sent += sent - - def _send(self, data): - """ - Sends a message to Scratch - """ - self._write(self._pack(data)) - - def _read(self, size): - """ - Reads size number of bytes from Scratch and returns data as a string - """ - data = b'' - while len(data) < size: - try: - chunk = self.socket.recv(size-len(data)) - except socket.error: - self.connected = False - raise ScratchError from socket.error - if chunk == '': - self.connected = False - raise ScratchConnectionError("Connection broken") - data += chunk - return data - - def _recv(self): - """ - Receives and returns a message from Scratch - """ - prefix = self._read(self.prefix_len) - msg = self._read(self._extract_len(prefix)) - return prefix + msg - - def connect(self): - """ - Connects to Scratch. - """ - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.socket.connect((self.host, self.port)) - except socket.error: - self.connected = False - raise ScratchError from socket.error - self.connected = True - - def disconnect(self): - """ - Closes connection to Scratch - """ - try: # connection may already be disconnected, so catch exceptions - self.socket.shutdown(socket.SHUT_RDWR) # a proper disconnect - except socket.error: - pass - self.socket.close() - self.connected = False - - def sensorupdate(self, data): - """ - Given a dict of sensors and values, updates those sensors with the - values in Scratch. - """ - if not isinstance(data, dict): - raise TypeError('Expected a dict') - msg = 'sensor-update ' - for key in data.keys(): - msg += '"%s" "%s" ' % (self._escape(str(key)), - self._escape(str(data[key]))) - self._send(msg) - - def broadcast(self, msg): - """ - Broadcasts msg to Scratch. msg can be a single message or an iterable - (list, tuple, set, generator, etc.) of messages. - """ - #if getattr(msg, '__iter__', False): # iterable - if isinstance(msg, (tuple, list, set)): - for m in msg: - self._send('broadcast "%s"' % self._escape(str(m))) - else: # probably a string or number - self._send('broadcast "%s"' % self._escape(str(msg))) - - def receive(self): - """ - Receives broadcasts and sensor updates from Scratch. Returns a tuple - with the first element as the message type and the second element - as the message payload. broadcast messages have a string as payload, - and the sensor-update messages have a dict as payload. Returns None if - message received could not be parsed. Raises exceptions on connection - errors. - """ - return self._parse(self._recv())