PageRenderTime 34ms CodeModel.GetById 24ms RepoModel.GetById 1ms app.codeStats 0ms

/lib/rapidsms/router.py

https://github.com/PichayGuriGuri/rapidsms
Python | 322 lines | 288 code | 15 blank | 19 comment | 6 complexity | c10134a71a74a6cc09534f6413925c6f MD5 | raw file
  1. #!/usr/bin/env python
  2. # vim: ai ts=4 sts=4 et sw=4
  3. import time, datetime, os, heapq
  4. import threading
  5. import traceback
  6. import component
  7. import log
  8. class Router (component.Receiver):
  9. incoming_phases = ('parse', 'handle', 'cleanup')
  10. outgoing_phases = ('outgoing',)
  11. def __init__(self):
  12. component.Receiver.__init__(self)
  13. self.backends = []
  14. self.apps = []
  15. self.events = []
  16. self.running = False
  17. self.logger = None
  18. def log(self, level, msg, *args):
  19. self.logger.write(self, level, msg, *args)
  20. def set_logger(self, level, file):
  21. self.logger = log.Logger(level, file)
  22. def build_component (self, class_template, conf):
  23. """Imports and instantiates an module, given a dict with
  24. the config key/value pairs to pass along."""
  25. # break the class name off the end of the module template
  26. # i.e. "%s.app.App" -> ("%s.app", "App")
  27. module_template, class_name = class_template.rsplit(".",1)
  28. # make a copy of the conf dict so we can delete from it
  29. conf = conf.copy()
  30. # resolve the component name into a real class
  31. module_name = module_template % (conf.pop("type"))
  32. module = __import__(module_name, {}, {}, [''])
  33. component_class = getattr(module, class_name)
  34. # create the component with an instance of this router
  35. # and keep hold of it here, so we can communicate both ways
  36. component = component_class(self)
  37. try:
  38. component._configure(**conf)
  39. except TypeError, e:
  40. # "__init__() got an unexpected keyword argument '...'"
  41. if "unexpected keyword" in e.message:
  42. missing_keyword = e.message.split("'")[1]
  43. raise Exception("Component '%s' does not support a '%s' option."
  44. % (title, missing_keyword))
  45. else:
  46. raise
  47. return component
  48. def add_backend (self, conf):
  49. try:
  50. backend = self.build_component("rapidsms.backends.%s.Backend", conf)
  51. self.info("Added backend: %r" % conf)
  52. self.backends.append(backend)
  53. except:
  54. self.log_last_exception("Failed to add backend: %r" % conf)
  55. def get_backend (self, slug):
  56. '''gets a backend by slug, if it exists'''
  57. for backend in self.backends:
  58. if backend.slug == slug:
  59. return backend
  60. return None
  61. def add_app (self, conf):
  62. try:
  63. app = self.build_component("%s.app.App", conf)
  64. self.info("Added app: %r" % conf)
  65. self.apps.append(app)
  66. except:
  67. self.log_last_exception("Failed to add app: %r" % conf)
  68. def get_app(self, name):
  69. '''gets an app by name, if it exists'''
  70. for app in self.apps:
  71. # this is beyond ugly. i am horribly ashamed of this line
  72. # of code. I just don't understand how the names are supposed
  73. # to work. Someone please fix this.
  74. if name in str(type(app)):
  75. return app
  76. return None
  77. def start_backend (self, backend):
  78. while self.running:
  79. try:
  80. backend.start()
  81. # if backend execution completed
  82. # normally (and did not raise),
  83. # allow the thread to terminate
  84. break
  85. except Exception, e:
  86. # an exception was raised in backend.start()
  87. # sleep for 5 seconds, then loop and restart it
  88. self.log_last_exception("Error in the %s backend" % backend.slug)
  89. # don't bother restarting the backend
  90. # if the router isn't running any more
  91. if not self.running:
  92. break
  93. # TODO: where did the 5.0 constant come from?
  94. # we should probably be doing something more intelligent
  95. # here, rather than just hoping five seconds is enough
  96. time.sleep(5.0)
  97. self.info("Restarting the %s backend" % backend.slug)
  98. def start_all_apps (self):
  99. """Calls the _start_ method of each app registed via
  100. Router.add_app, logging any exceptions raised, but
  101. not allowing them to propagate. Returns True if all
  102. of the apps started without raising."""
  103. raised = False
  104. for app in self.apps:
  105. try:
  106. app.start()
  107. except Exception:
  108. self.log_last_exception("The %s app failed to start" % app.slug)
  109. raised = True
  110. # if any of the apps raised, we'll return
  111. # False, to warn that _something_ is wrong
  112. return not raised
  113. def start_all_backends (self):
  114. """Starts all backends registed via Router.add_backend,
  115. by calling self.start_backend in a new thread for each."""
  116. for backend in self.backends:
  117. worker = threading.Thread(
  118. target=self.start_backend,
  119. args=(backend,))
  120. worker.start()
  121. # attach the worker thread to the backend,
  122. # so we can check that it's still running
  123. backend.thread = worker
  124. def stop_all_backends (self):
  125. """Notifies all backends registered via Router.add_backend
  126. that they should stop. This method cannot guarantee that
  127. backends *will* stop in a timely manner."""
  128. for backend in self.backends:
  129. try:
  130. backend.stop()
  131. timeout = 5
  132. step = 0.1
  133. # wait up to five seconds for the backend's
  134. # worker thread to terminate, or log failure
  135. while(backend.thread.isAlive()):
  136. if timeout <= 0:
  137. raise RuntimeError, "The %s backend's worker thread did not terminate" % backend.slug
  138. else:
  139. time.sleep(step)
  140. timeout -= step
  141. except Exception:
  142. self.log_last_exception("The %s backend failed to stop" % backend.slug)
  143. """
  144. The call_at() method schedules a callback with the router. It takes as its
  145. first argument one of several possible objects:
  146. * if an int or float, the callback is called that many
  147. seconds later.
  148. * if a datetime.datetime object, the callback is called at
  149. the time specified.
  150. * if a datetime.timedelta object, the callback is called
  151. at the timedelta from now.
  152. call_at() takes as its second argument the function to be called
  153. at the scheduled time. All other positional and keyword arguments
  154. are passed directly to the callback when it is called.
  155. If the callback returns a true value that is one of the time objects
  156. understood by call_at(), the callback will be called again at the
  157. specified time with the same arguments.
  158. """
  159. def call_at (self, when, callback, *args, **kwargs):
  160. if isinstance(when, datetime.timedelta):
  161. when = datetime.datetime.now() + when
  162. if isinstance(when, datetime.datetime):
  163. when = time.mktime(when.timetuple())
  164. elif isinstance(when, (int, float)):
  165. when = time.time() + when
  166. else:
  167. self.debug("Call to %s wasn't scheduled with a suitable time: %s",
  168. callback.func_name, when)
  169. return
  170. self.debug("Scheduling call to %s at %s",
  171. callback.func_name, datetime.datetime.fromtimestamp(when).ctime())
  172. heapq.heappush(self.events, (when, callback, args, kwargs))
  173. def start (self):
  174. self.running = True
  175. # dump some debug info for now
  176. self.info("BACKENDS: %r" % (self.backends))
  177. self.info("APPS: %r" % (self.apps))
  178. self.info("SERVING FOREVER...")
  179. self.start_all_backends()
  180. self.start_all_apps()
  181. # wait until we're asked to stop
  182. while self.running:
  183. try:
  184. self.call_scheduled()
  185. self.run()
  186. except KeyboardInterrupt:
  187. break
  188. except SystemExit:
  189. break
  190. self.stop_all_backends()
  191. self.running = False
  192. def stop (self):
  193. self.running = False
  194. def run(self):
  195. msg = self.next_message(timeout=1.0)
  196. if msg is not None:
  197. self.incoming(msg)
  198. def call_scheduled(self):
  199. while self.events and self.events[0][0] < time.time():
  200. when, callback, args, kwargs = heapq.heappop(self.events)
  201. self.info("Calling %s(%s, %s)",
  202. callback.func_name, args, kwargs)
  203. result = callback(*args, **kwargs)
  204. if result:
  205. self.call_at(result, callback, *args, **kwargs)
  206. def __sorted_apps(self):
  207. return sorted(self.apps, key=lambda a: a.priority())
  208. def incoming(self, message):
  209. self.info("Incoming message via %s: %s ->'%s'" %\
  210. (message.connection.backend.slug, message.connection.identity, message.text))
  211. # loop through all of the apps and notify them of
  212. # the incoming message so that they all get a
  213. # chance to do what they will with it
  214. for phase in self.incoming_phases:
  215. for app in self.__sorted_apps():
  216. self.debug('IN' + ' ' + phase + ' ' + app.slug)
  217. responses = len(message.responses)
  218. handled = False
  219. try:
  220. handled = getattr(app, phase)(message)
  221. except Exception, e:
  222. self.error("%s failed on %s: %r\n%s", app, phase, e, traceback.print_exc())
  223. if phase == 'handle':
  224. if handled is True:
  225. self.debug("%s short-circuited handle phase", app.slug)
  226. break
  227. elif responses < len(message.responses):
  228. self.warning("App '%s' shouldn't send responses in %s()!",
  229. app.slug, phase)
  230. # now send the message's responses
  231. message.flush_responses()
  232. # we are no longer interested in
  233. # this message... but some crazy
  234. # synchronous backends might be!
  235. message.processed = True
  236. def outgoing(self, message):
  237. self.info("Outgoing message via %s: %s <- '%s'" %\
  238. (message.connection.backend.slug, message.connection.identity, message.text))
  239. # first notify all of the apps that want to know
  240. # about outgoing messages so that they can do what
  241. # they will before the message is actually sent
  242. for phase in self.outgoing_phases:
  243. continue_sending = True
  244. # call outgoing phases in the opposite order of the
  245. # incoming phases so that, for example, the first app
  246. # called with an incoming message is the last app called
  247. # with an outgoing message
  248. for app in reversed(self.__sorted_apps()):
  249. self.debug('OUT' + ' ' + phase + ' ' + app.slug)
  250. try:
  251. continue_sending = getattr(app, phase)(message)
  252. except Exception, e:
  253. self.error("%s failed on %s: %r\n%s", app, phase, e, traceback.print_exc())
  254. if continue_sending is False:
  255. self.info("App '%s' cancelled outgoing message", app.slug)
  256. return False
  257. # now send the message out
  258. message.connection.backend.send(message)
  259. self.debug("SENT message '%s' to %s via %s" % (message.text,\
  260. message.connection.identity, message.connection.backend.slug))
  261. return True