Funktionierender Prototyp des Serious Games zur Vermittlung von Wissen zu Software-Engineering-Arbeitsmodellen.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

component.py 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. ###############################################################################
  2. #
  3. # The MIT License (MIT)
  4. #
  5. # Copyright (c) typedef int GmbH
  6. #
  7. # Permission is hereby granted, free of charge, to any person obtaining a copy
  8. # of this software and associated documentation files (the "Software"), to deal
  9. # in the Software without restriction, including without limitation the rights
  10. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11. # copies of the Software, and to permit persons to whom the Software is
  12. # furnished to do so, subject to the following conditions:
  13. #
  14. # The above copyright notice and this permission notice shall be included in
  15. # all copies or substantial portions of the Software.
  16. #
  17. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  23. # THE SOFTWARE.
  24. #
  25. ###############################################################################
  26. import asyncio
  27. import ssl
  28. import signal
  29. from functools import wraps
  30. import txaio
  31. from autobahn.asyncio.websocket import WampWebSocketClientFactory
  32. from autobahn.asyncio.rawsocket import WampRawSocketClientFactory
  33. from autobahn.wamp import component
  34. from autobahn.wamp.exception import TransportLost
  35. from autobahn.asyncio.wamp import Session
  36. from autobahn.wamp.serializer import create_transport_serializers, create_transport_serializer
  37. __all__ = ('Component', 'run')
  38. def _unique_list(seq):
  39. """
  40. Return a list with unique elements from sequence, preserving order.
  41. """
  42. seen = set()
  43. return [x for x in seq if x not in seen and not seen.add(x)]
  44. def _camel_case_from_snake_case(snake):
  45. parts = snake.split('_')
  46. return parts[0] + ''.join(s.capitalize() for s in parts[1:])
  47. def _create_transport_factory(loop, transport, session_factory):
  48. """
  49. Create a WAMP-over-XXX transport factory.
  50. """
  51. if transport.type == 'websocket':
  52. serializers = create_transport_serializers(transport)
  53. factory = WampWebSocketClientFactory(
  54. session_factory,
  55. url=transport.url,
  56. serializers=serializers,
  57. proxy=transport.proxy, # either None or a dict with host, port
  58. )
  59. elif transport.type == 'rawsocket':
  60. serializer = create_transport_serializer(transport.serializers[0])
  61. factory = WampRawSocketClientFactory(session_factory, serializer=serializer)
  62. else:
  63. assert(False), 'should not arrive here'
  64. # set the options one at a time so we can give user better feedback
  65. for k, v in transport.options.items():
  66. try:
  67. factory.setProtocolOptions(**{k: v})
  68. except (TypeError, KeyError):
  69. # this allows us to document options as snake_case
  70. # until everything internally is upgraded from
  71. # camelCase
  72. try:
  73. factory.setProtocolOptions(
  74. **{_camel_case_from_snake_case(k): v}
  75. )
  76. except (TypeError, KeyError):
  77. raise ValueError(
  78. "Unknown {} transport option: {}={}".format(transport.type, k, v)
  79. )
  80. return factory
  81. class Component(component.Component):
  82. """
  83. A component establishes a transport and attached a session
  84. to a realm using the transport for communication.
  85. The transports a component tries to use can be configured,
  86. as well as the auto-reconnect strategy.
  87. """
  88. log = txaio.make_logger()
  89. session_factory = Session
  90. """
  91. The factory of the session we will instantiate.
  92. """
  93. def _is_ssl_error(self, e):
  94. """
  95. Internal helper.
  96. """
  97. return isinstance(e, ssl.SSLError)
  98. def _check_native_endpoint(self, endpoint):
  99. if isinstance(endpoint, dict):
  100. if 'tls' in endpoint:
  101. tls = endpoint['tls']
  102. if isinstance(tls, (dict, bool)):
  103. pass
  104. elif isinstance(tls, ssl.SSLContext):
  105. pass
  106. else:
  107. raise ValueError(
  108. "'tls' configuration must be a dict, bool or "
  109. "SSLContext instance"
  110. )
  111. else:
  112. raise ValueError(
  113. "'endpoint' configuration must be a dict or IStreamClientEndpoint"
  114. " provider"
  115. )
  116. # async function
  117. def _connect_transport(self, loop, transport, session_factory, done):
  118. """
  119. Create and connect a WAMP-over-XXX transport.
  120. """
  121. factory = _create_transport_factory(loop, transport, session_factory)
  122. # XXX the rest of this should probably be factored into its
  123. # own method (or three!)...
  124. if transport.proxy:
  125. timeout = transport.endpoint.get('timeout', 10) # in seconds
  126. if type(timeout) != int:
  127. raise ValueError('invalid type {} for timeout in client endpoint configuration'.format(type(timeout)))
  128. # do we support HTTPS proxies?
  129. f = loop.create_connection(
  130. protocol_factory=factory,
  131. host=transport.proxy['host'],
  132. port=transport.proxy['port'],
  133. )
  134. time_f = asyncio.ensure_future(asyncio.wait_for(f, timeout=timeout))
  135. return self._wrap_connection_future(transport, done, time_f)
  136. elif transport.endpoint['type'] == 'tcp':
  137. version = transport.endpoint.get('version', 4)
  138. if version not in [4, 6]:
  139. raise ValueError('invalid IP version {} in client endpoint configuration'.format(version))
  140. host = transport.endpoint['host']
  141. if type(host) != str:
  142. raise ValueError('invalid type {} for host in client endpoint configuration'.format(type(host)))
  143. port = transport.endpoint['port']
  144. if type(port) != int:
  145. raise ValueError('invalid type {} for port in client endpoint configuration'.format(type(port)))
  146. timeout = transport.endpoint.get('timeout', 10) # in seconds
  147. if type(timeout) != int:
  148. raise ValueError('invalid type {} for timeout in client endpoint configuration'.format(type(timeout)))
  149. tls = transport.endpoint.get('tls', None)
  150. tls_hostname = None
  151. # create a TLS enabled connecting TCP socket
  152. if tls:
  153. if isinstance(tls, dict):
  154. for k in tls.keys():
  155. if k not in ["hostname", "trust_root"]:
  156. raise ValueError("Invalid key '{}' in 'tls' config".format(k))
  157. hostname = tls.get('hostname', host)
  158. if type(hostname) != str:
  159. raise ValueError('invalid type {} for hostname in TLS client endpoint configuration'.format(hostname))
  160. cert_fname = tls.get('trust_root', None)
  161. tls_hostname = hostname
  162. tls = True
  163. if cert_fname is not None:
  164. tls = ssl.create_default_context(
  165. purpose=ssl.Purpose.SERVER_AUTH,
  166. cafile=cert_fname,
  167. )
  168. elif isinstance(tls, ssl.SSLContext):
  169. # tls=<an SSLContext> is valid
  170. tls_hostname = host
  171. elif tls in [False, True]:
  172. if tls:
  173. tls_hostname = host
  174. else:
  175. raise RuntimeError('unknown type {} for "tls" configuration in transport'.format(type(tls)))
  176. f = loop.create_connection(
  177. protocol_factory=factory,
  178. host=host,
  179. port=port,
  180. ssl=tls,
  181. server_hostname=tls_hostname,
  182. )
  183. time_f = asyncio.ensure_future(asyncio.wait_for(f, timeout=timeout))
  184. return self._wrap_connection_future(transport, done, time_f)
  185. elif transport.endpoint['type'] == 'unix':
  186. path = transport.endpoint['path']
  187. timeout = int(transport.endpoint.get('timeout', 10)) # in seconds
  188. f = loop.create_unix_connection(
  189. protocol_factory=factory,
  190. path=path,
  191. )
  192. time_f = asyncio.ensure_future(asyncio.wait_for(f, timeout=timeout))
  193. return self._wrap_connection_future(transport, done, time_f)
  194. else:
  195. assert(False), 'should not arrive here'
  196. def _wrap_connection_future(self, transport, done, conn_f):
  197. def on_connect_success(result):
  198. # async connect call returns a 2-tuple
  199. transport, proto = result
  200. # in the case where we .abort() the transport / connection
  201. # during setup, we still get on_connect_success but our
  202. # transport is already closed (this will happen if
  203. # e.g. there's an "open handshake timeout") -- I don't
  204. # know if there's a "better" way to detect this? #python
  205. # doesn't know of one, anyway
  206. if transport.is_closing():
  207. if not txaio.is_called(done):
  208. reason = getattr(proto, "_onclose_reason", "Connection already closed")
  209. txaio.reject(done, TransportLost(reason))
  210. return
  211. # if e.g. an SSL handshake fails, we will have
  212. # successfully connected (i.e. get here) but need to
  213. # 'listen' for the "connection_lost" from the underlying
  214. # protocol in case of handshake failure .. so we wrap
  215. # it. Also, we don't increment transport.success_count
  216. # here on purpose (because we might not succeed).
  217. # XXX double-check that asyncio behavior on TLS handshake
  218. # failures is in fact as described above
  219. orig = proto.connection_lost
  220. @wraps(orig)
  221. def lost(fail):
  222. rtn = orig(fail)
  223. if not txaio.is_called(done):
  224. # asyncio will call connection_lost(None) in case of
  225. # a transport failure, in which case we create an
  226. # appropriate exception
  227. if fail is None:
  228. fail = TransportLost("failed to complete connection")
  229. txaio.reject(done, fail)
  230. return rtn
  231. proto.connection_lost = lost
  232. def on_connect_failure(err):
  233. transport.connect_failures += 1
  234. # failed to establish a connection in the first place
  235. txaio.reject(done, err)
  236. txaio.add_callbacks(conn_f, on_connect_success, None)
  237. # the errback is added as a second step so it gets called if
  238. # there as an error in on_connect_success itself.
  239. txaio.add_callbacks(conn_f, None, on_connect_failure)
  240. return conn_f
  241. # async function
  242. def start(self, loop=None):
  243. """
  244. This starts the Component, which means it will start connecting
  245. (and re-connecting) to its configured transports. A Component
  246. runs until it is "done", which means one of:
  247. - There was a "main" function defined, and it completed successfully;
  248. - Something called ``.leave()`` on our session, and we left successfully;
  249. - ``.stop()`` was called, and completed successfully;
  250. - none of our transports were able to connect successfully (failure);
  251. :returns: a Future which will resolve (to ``None``) when we are
  252. "done" or with an error if something went wrong.
  253. """
  254. if loop is None:
  255. self.log.warn("Using default loop")
  256. loop = asyncio.get_event_loop()
  257. return self._start(loop=loop)
  258. def run(components, start_loop=True, log_level='info'):
  259. """
  260. High-level API to run a series of components.
  261. This will only return once all the components have stopped
  262. (including, possibly, after all re-connections have failed if you
  263. have re-connections enabled). Under the hood, this calls
  264. XXX fixme for asyncio
  265. -- if you wish to manage the loop yourself, use the
  266. :meth:`autobahn.asyncio.component.Component.start` method to start
  267. each component yourself.
  268. :param components: the Component(s) you wish to run
  269. :type components: instance or list of :class:`autobahn.asyncio.component.Component`
  270. :param start_loop: When ``True`` (the default) this method
  271. start a new asyncio loop.
  272. :type start_loop: bool
  273. :param log_level: a valid log-level (or None to avoid calling start_logging)
  274. :type log_level: string
  275. """
  276. # actually, should we even let people "not start" the logging? I'm
  277. # not sure that's wise... (double-check: if they already called
  278. # txaio.start_logging() what happens if we call it again?)
  279. if log_level is not None:
  280. txaio.start_logging(level=log_level)
  281. loop = asyncio.get_event_loop()
  282. if loop.is_closed():
  283. asyncio.set_event_loop(asyncio.new_event_loop())
  284. loop = asyncio.get_event_loop()
  285. txaio.config.loop = loop
  286. log = txaio.make_logger()
  287. # see https://github.com/python/asyncio/issues/341 asyncio has
  288. # "odd" handling of KeyboardInterrupt when using Tasks (as
  289. # run_until_complete does). Another option is to just resture
  290. # default SIGINT handling, which is to exit:
  291. # import signal
  292. # signal.signal(signal.SIGINT, signal.SIG_DFL)
  293. async def nicely_exit(signal):
  294. log.info("Shutting down due to {signal}", signal=signal)
  295. try:
  296. tasks = asyncio.Task.all_tasks()
  297. except AttributeError:
  298. # this changed with python >= 3.7
  299. tasks = asyncio.all_tasks()
  300. for task in tasks:
  301. # Do not cancel the current task.
  302. try:
  303. current_task = asyncio.Task.current_task()
  304. except AttributeError:
  305. current_task = asyncio.current_task()
  306. if task is not current_task:
  307. task.cancel()
  308. def cancel_all_callback(fut):
  309. try:
  310. fut.result()
  311. except asyncio.CancelledError:
  312. log.debug("All task cancelled")
  313. except Exception as e:
  314. log.error("Error while shutting down: {exception}", exception=e)
  315. finally:
  316. loop.stop()
  317. fut = asyncio.gather(*tasks)
  318. fut.add_done_callback(cancel_all_callback)
  319. try:
  320. loop.add_signal_handler(signal.SIGINT, lambda: asyncio.ensure_future(nicely_exit("SIGINT")))
  321. loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.ensure_future(nicely_exit("SIGTERM")))
  322. except NotImplementedError:
  323. # signals are not available on Windows
  324. pass
  325. def done_callback(loop, arg):
  326. loop.stop()
  327. # returns a future; could run_until_complete() but see below
  328. component._run(loop, components, done_callback)
  329. if start_loop:
  330. try:
  331. loop.run_forever()
  332. # this is probably more-correct, but then you always get
  333. # "Event loop stopped before Future completed":
  334. # loop.run_until_complete(f)
  335. except asyncio.CancelledError:
  336. pass
  337. # finally:
  338. # signal.signal(signal.SIGINT, signal.SIG_DFL)
  339. # signal.signal(signal.SIGTERM, signal.SIG_DFL)
  340. # Close the event loop at the end, otherwise an exception is
  341. # thrown. https://bugs.python.org/issue23548
  342. loop.close()