Felix departed
[galtack.git] / galtack / net_client_base.py
bloba49eb6af297f98fd5f713900f276d70c4f9484d5
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3 """
4 GalTacK networking - basic client protocol classes.
5 """
6 # Copyright (C) 2007 Felix Rabe <public@felixrabe.textdriven.com>
7 # Copyright (C) 2007 Michael Carter
9 # Permission is hereby granted, free of charge, to any person obtaining a
10 # copy of this software and associated documentation files (the
11 # "Software"), to deal in the Software without restriction, including
12 # without limitation the rights to use, copy, modify, merge, publish,
13 # distribute, sublicense, and/or sell copies of the Software, and to permit
14 # persons to whom the Software is furnished to do so, subject to the
15 # following conditions:
17 # The above copyright notice and this permission notice shall be included
18 # in all copies or substantial portions of the Software.
20 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
21 # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
22 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
23 # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
24 # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
25 # OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
26 # THE USE OR OTHER DEALINGS IN THE SOFTWARE.
28 # Recommended line length or text width: 75 characters.
30 from twisted.internet.protocol import DatagramProtocol
31 from galtack.net_common import *
34 class GaltackClientBase(DatagramProtocol, object):
35 """
36 GalTacK network client base class (Galcon-compatible).
37 """
39 def __init__(self, options, user_info, server_info, *a, **kw):
40 """
41 Initialize.
43 The arguments are a galtack.net_common.UserInfo and a
44 galtack.net_common.ServerInfo instance.
45 """
46 super(GaltackClientBase, self).__init__(*a, **kw)
47 self._options = options
48 self._user_info = user_info
49 self._server_info = server_info
51 def startProtocol(self):
52 """
53 Called by twisted when a transport is connected to this protocol.
54 """
55 self.transport.connect(self._server_info.addr,
56 self._server_info.port)
58 def datagramReceived(self, datagram, host):
59 """
60 Called by twisted when a datagram is received.
61 """
62 return self._commands_received(self, datagram)
64 def _commands_received(self, commands):
65 """
66 Called with the commands received.
68 Fill this method with meaning in the mixin classes -- and only
69 there, please.
70 """
72 @classmethod
73 def _modify_option_parser(cls, option_parser):
74 """
75 Modify the specified option_parser (an optparse.OptionParser).
77 This can be used e.g. to add mixin-specific options.
78 """
79 return option_parser
81 @classmethod
82 def _mkopt(cls, option_name):
83 """
84 Helper method for _modify_option_parser().
85 """
86 return "_%s__%s" % (cls.__name__, option_name)
88 def _getopt(self, option_name, kw, kw_default = -1):
89 """
90 Helper method for __init__() to set an option value.
91 """
92 value = kw.pop(option_name, kw_default)
93 if value is kw_default:
94 name = "_%s__%s" % (self.__class__.__name__, option_name)
95 value = getattr(self._options, name)
96 return value
99 class GaltackClientSendCmdBaseMixin(GaltackClientBase):
101 GaltackClient with a send_commands() method.
103 This only provides the general infrastructure for sending GalTacK
104 commands. Sequence number adding and [ACK] handling is done by other
105 mixin classes.
108 _HEADER = ("[HEADER]\tpasswd\t%s\n" +
109 "[HEADER]\tsecret\t%s\n" +
110 "[HEADER]\tname\t%s\n")
112 def __init__(self, *a, **kw):
113 super(GaltackClientSendCmdBaseMixin, self).__init__(*a, **kw)
114 self.__can_send_commands = False
116 def startProtocol(self):
117 super(GaltackClientSendCmdBaseMixin, self).startProtocol()
118 self.__can_send_commands = True
120 def stopProtocol(self):
121 super(GaltackClientSendCmdBaseMixin, self).stopProtocol()
122 self.__can_send_commands = False
124 def _pre_send_command_hook(self, command, **kw):
126 Process a command before sending, e.g. by prepending the sequence
127 number.
129 The 'command' argument is a tuple created by something like
130 tuple(cmd_line.split("\t")).
132 Remember to call this method using super() from subclasses
133 overriding this hook, and to return its (possibly modified) return
134 value. Even though this method is empty.
136 return command
138 def _pre_send_commands_hook(self, *commands, **kw):
140 Process the specified commands before sending.
142 Remember to call this method using super() from subclasses
143 overriding this hook, and to return its (possibly modified) return
144 value.
146 try:
147 f = lambda cmd: self._pre_send_command_hook(cmd, **kw)
148 return map(f, map(tuple, commands))
149 except:
150 import traceback
151 traceback.print_exc()
152 return commands
154 def _post_send_datagram_hook(self, datagram, **kw):
156 Post-process the sent datagram.
158 return datagram
160 def send_commands(self, *commands, **kw):
162 Send the specified commands.
164 Any keyword arguments given are passed on to the hooks.
166 if not self.__can_send_commands:
167 raise Warning, ("unable to send commands since " +
168 "the protocol has not been started yet")
169 if not commands: # no cmds to send, e.g. when they were generated
170 return
172 header = self._HEADER % (self._server_info.passwd,
173 self._server_info.secret,
174 self._user_info.name)
176 cmds = self._pre_send_commands_hook(*commands, **kw)
177 cmds_str = "\n".join("\t".join(map(str, c)) for c in cmds)
179 datagram = header + cmds_str + '\n'
180 self.transport.write(datagram)
182 self._post_send_datagram_hook(datagram, **kw)
184 return cmds
187 class GaltackClientIgnoreResentCmdsMixin(GaltackClientBase):
189 GaltackClient that ignores resent commands from the server.
191 The server might resend a command since it didn't get our [ACK]
192 response (done manually or using GaltackClientAckSenderMixin), in
193 which case only the first instance of that command is important to us
194 for post-[ACK]-sending-processing.
196 As it gets increasingly expensive to keep track of really *all*
197 commands (or their sequence numbers) that got ever sent to us, this
198 class by default keeps a ring buffer of the sequence numbers of the
199 last 100 commands received.
201 If you want to be really strict and don't care about performance at
202 all, pass __init__(ignore_resent_cmds_ring_bufsize = None).
205 def __init__(self, *a, **kw):
206 self.__command_callbacks = {}
207 self.__seq_nr_buffer = []
208 bufsize = kw.pop("ignore_resent_cmds_ring_bufsize", 100)
209 self.__seq_nr_buffer_size = bufsize
210 super(GaltackClientIgnoreResentCmdsMixin, self).__init__(*a, **kw)
212 def __seq_nr_buffer_truncate(self):
214 Truncate self.__seq_nr_buffer to self.__seq_nr_buffer_size.
216 bufsize = self.__seq_nr_buffer_size
217 if bufsize is None: return None
218 self.__seq_nr_buffer = self.__seq_nr_buffer[-bufsize:]
220 def datagramReceived(self, datagram, host):
221 commands = tuple( tuple(line.split("\t"))
222 for line in datagram.split("\n") )
223 # drop empty lines
224 commands = filter(lambda c: c != ("",), commands)
225 new_commands = []
226 for command in commands:
227 try: seq_nr, want_ack = map(int, command[:2])
228 except: continue # in case the server would send [HEADER]
229 if seq_nr == 1: # numbers got reset, we reset too
230 self.__seq_nr_buffer = []
231 if seq_nr in self.__seq_nr_buffer: # command already received
232 continue
233 new_commands.append(command)
234 self.__seq_nr_buffer.append(seq_nr)
235 self.__seq_nr_buffer_truncate()
236 return self._commands_received("\n".join( map("\t".join,
237 new_commands) ))
240 class GaltackClientBackCallerMixin(GaltackClientIgnoreResentCmdsMixin):
242 GaltackClient that allows callbacks to be registered to respond to
243 certain GalTacK commands received from a server.
246 def __init__(self, *a, **kw):
247 self.__command_callbacks = {}
248 super(GaltackClientBackCallerMixin, self).__init__(*a, **kw)
250 def register_command_callback(self, cmd_name = None, callback = None):
252 Register a command with a callback.
254 Example:
255 galtack_client.register_command_callback("message",
256 cmd_message)
258 Callbacks registered to no command name (cmd_name = None) are
259 implicitly registered to all command names (individually).
260 Example:
261 galtack_client.register_command_callback(callback =
262 cmd_catchall)
264 Callbacks registered to cmd_name = False are registered to the
265 whole command *chunks* that arrive in a UDP packet.
267 The signature of a callback function (add self for a method) looks
268 like this:
269 def callback(command)
270 The 'command' argument is a tuple created by something like
271 tuple(cmd_line.split("\t")), or, in the case of a callback
272 registered to cmd_name = False, a tuple of those tuples.
274 # command argument is optional - callback is not:
275 if callback is None:
276 raise TypeError, ("register_command_callback() requires the " +
277 "callback argument")
278 callback_list = self.__command_callbacks.get(cmd_name, [])
279 callback_list.append(callback)
280 self.__command_callbacks[cmd_name] = callback_list
282 def unregister_command_callback(self, cmd_name = None,
283 callback = None):
285 Unregister a callback.
287 Works like register_command_callback(), just reversed.
289 callback_list = self.__command_callbacks[cmd_name]
290 # Do it this way to remove the _last_ (added) callback:
291 callback_list.reverse()
292 callback_list.remove(callback)
293 callback_list.reverse()
295 def call_command_callbacks(self, cmd_name = None, *a, **kw):
297 Run the callbacks registered to cmd_name with the given arguments.
299 Calling this method can be used to simulate commands received from
300 the server.
302 Alternatively, you can use it to "invent" commands.
304 cb_list = []
305 if kw.pop("run_common", False):
306 cb_list = self.__command_callbacks.get(None, [])
307 cb_list += self.__command_callbacks.get(cmd_name, [])
308 for callback in cb_list:
309 callback(*a, **kw)
311 def _commands_received(self, commands):
313 Call the registered callbacks for the received commands.
315 commands = tuple( tuple(line.split("\t"))
316 for line in commands.split("\n") )
317 commands = filter(lambda c: c, commands) # drop empty lines
319 self.call_command_callbacks(False, commands)
321 common_callbacks = self.__command_callbacks.get(None, [])
322 for command in commands:
323 try: map(int, command[:2])
324 except: continue # in case the server would send [HEADER]
325 name = command[2]
326 self.call_command_callbacks(name, command, run_common = True)
328 # Call parent class' _commands_received() method:
329 Cls = GaltackClientBackCallerMixin
330 return super(Cls, self)._commands_received(commands)
333 class GaltackClientPrependSeqNrMixin(GaltackClientBackCallerMixin,
334 GaltackClientSendCmdBaseMixin):
336 GaltackClient that adds the sequence number to each command before
337 sending.
340 def __init__(self, *a, **kw):
341 super(GaltackClientPrependSeqNrMixin, self).__init__(*a, **kw)
342 self.__seq_nr = 0
343 self.register_command_callback("[RESET]", self.__cmd_reset)
345 def _pre_send_command_hook(self, command, **kw):
347 To disable this hook, call:
348 send_command(..., prepend_seq_nr = False)
350 prepend_seq_nr = kw.pop("prepend_seq_nr", True)
351 Cls = GaltackClientPrependSeqNrMixin
352 command = super(Cls, self)._pre_send_command_hook(command, **kw)
353 if prepend_seq_nr:
354 self.__seq_nr += 1
355 command = (self.__seq_nr,) + command
356 return command
358 def __cmd_reset(self, command):
359 if int(command[0]) == 1:
360 self.__seq_nr = 0
363 class GaltackClientAckSenderMixin(GaltackClientPrependSeqNrMixin,
364 GaltackClientIgnoreResentCmdsMixin):
366 GaltackClient that sends out [ACK] commands when needed.
368 This intercepts datagrams *before* GaltackClientIgnoreResentCmdsMixin
369 gets a chance to process them.
372 def __init__(self, *a, **kw):
373 super(GaltackClientAckSenderMixin, self).__init__(*a, **kw)
374 self.__debug_log = file("/tmp/acksend.log", "w")
376 def datagramReceived(self, datagram, host):
377 self.__debug_log.write("\n* datagramReceived:\n\n%s\n\n" % datagram)
378 Cls = GaltackClientAckSenderMixin
379 super(Cls, self).datagramReceived(datagram, host)
380 self.__debug_log.write("\n* Received\n")
381 commands = tuple( tuple(line.split("\t"))
382 for line in datagram.split("\n") )
383 seq_nr_list = []
384 for command in commands:
385 try:
386 seq_nr, want_ack = map(int, command[0:2])
387 except: continue # in case the server would send [HEADER]
388 if not want_ack: continue
389 seq_nr_list.append(seq_nr)
390 self.send_commands(*((0, "[ACK]", sn) for sn in seq_nr_list))