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.
 
 
 

226 lines
7.4 KiB

  1. """Miscellaneous decorators and other helpers for running a CherryPy
  2. server"""
  3. import os
  4. import sys
  5. import json
  6. import decorator
  7. import functools
  8. import threading
  9. import cherrypy
  10. # Helper to parse parameters into booleans
  11. def bool_param(s):
  12. """Return a bool indicating whether parameter 's' was True or False,
  13. supporting a few different types for 's'."""
  14. try:
  15. ss = s.lower()
  16. if ss in ["0", "false", "f", "no", "n"]:
  17. return False
  18. if ss in ["1", "true", "t", "yes", "y"]:
  19. return True
  20. except Exception:
  21. return bool(s)
  22. raise cherrypy.HTTPError("400 Bad Request",
  23. "can't parse parameter: " + ss)
  24. # Decorators
  25. def chunked_response(func):
  26. """Decorator to enable chunked responses."""
  27. # Set this to False to get better tracebacks from some requests
  28. # (/stream/extract, /stream/intervals).
  29. func._cp_config = {'response.stream': True}
  30. return func
  31. def response_type(content_type):
  32. """Return a decorator-generating function that sets the
  33. response type to the specified string."""
  34. def wrapper(func, *args, **kwargs):
  35. cherrypy.response.headers['Content-Type'] = content_type
  36. return func(*args, **kwargs)
  37. return decorator.decorator(wrapper)
  38. def exception_to_httperror(*expected):
  39. """Return a decorator-generating function that catches expected
  40. errors and throws a HTTPError describing it instead.
  41. @exception_to_httperror(NilmDBError, ValueError)
  42. def foo():
  43. pass
  44. """
  45. def wrapper(func, *args, **kwargs):
  46. exc_info = None
  47. try:
  48. return func(*args, **kwargs)
  49. except expected:
  50. # Re-raise it, but maintain the original traceback
  51. exc_info = sys.exc_info()
  52. new_exc = cherrypy.HTTPError("400 Bad Request", str(exc_info[1]))
  53. raise new_exc.with_traceback(exc_info[2])
  54. finally:
  55. del exc_info
  56. # We need to preserve the function's argspecs for CherryPy to
  57. # handle argument errors correctly. Decorator.decorator takes
  58. # care of that.
  59. return decorator.decorator(wrapper)
  60. # Custom CherryPy tools
  61. def CORS_allow(methods):
  62. """This does several things:
  63. Handles CORS preflight requests.
  64. Adds Allow: header to all requests.
  65. Raise 405 if request.method not in method.
  66. It is similar to cherrypy.tools.allow, with the CORS stuff added.
  67. Add this to CherryPy with:
  68. cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow)
  69. """
  70. request = cherrypy.request.headers
  71. response = cherrypy.response.headers
  72. if not isinstance(methods, (tuple, list)):
  73. methods = [methods]
  74. methods = [m.upper() for m in methods if m]
  75. if not methods:
  76. methods = ['GET', 'HEAD']
  77. elif 'GET' in methods and 'HEAD' not in methods:
  78. methods.append('HEAD')
  79. response['Allow'] = ', '.join(methods)
  80. # Allow all origins
  81. if 'Origin' in request:
  82. response['Access-Control-Allow-Origin'] = request['Origin']
  83. # If it's a CORS request, send response.
  84. request_method = request.get("Access-Control-Request-Method", None)
  85. request_headers = request.get("Access-Control-Request-Headers", None)
  86. if (cherrypy.request.method == "OPTIONS" and
  87. request_method and request_headers):
  88. response['Access-Control-Allow-Headers'] = request_headers
  89. response['Access-Control-Allow-Methods'] = ', '.join(methods)
  90. # Try to stop further processing and return a 200 OK
  91. cherrypy.response.status = "200 OK"
  92. cherrypy.response.body = b""
  93. cherrypy.request.handler = lambda: ""
  94. return
  95. # Reject methods that were not explicitly allowed
  96. if cherrypy.request.method not in methods:
  97. raise cherrypy.HTTPError(405)
  98. # Helper for json_in tool to process JSON data into normal request
  99. # parameters.
  100. def json_to_request_params(body):
  101. cherrypy.lib.jsontools.json_processor(body)
  102. if not isinstance(cherrypy.request.json, dict):
  103. raise cherrypy.HTTPError(415)
  104. cherrypy.request.params.update(cherrypy.request.json)
  105. # Used as an "error_page.default" handler
  106. def json_error_page(status, message, traceback, version,
  107. force_traceback=False):
  108. """Return a custom error page in JSON so the client can parse it"""
  109. errordata = {"status": status,
  110. "message": message,
  111. "version": version,
  112. "traceback": traceback}
  113. # Don't send a traceback if the error was 400-499 (client's fault)
  114. code = int(status.split()[0])
  115. if not force_traceback:
  116. if 400 <= code <= 499:
  117. errordata["traceback"] = ""
  118. # Override the response type, which was previously set to text/html
  119. cherrypy.serving.response.headers['Content-Type'] = (
  120. "application/json;charset=utf-8")
  121. # Undo the HTML escaping that cherrypy's get_error_page function applies
  122. # (cherrypy issue 1135)
  123. for k, v in errordata.items():
  124. v = v.replace("&lt;", "<")
  125. v = v.replace("&gt;", ">")
  126. v = v.replace("&amp;", "&")
  127. errordata[k] = v
  128. return json.dumps(errordata, separators=(',', ':'))
  129. class CherryPyExit(SystemExit):
  130. pass
  131. def cherrypy_patch_exit():
  132. # Cherrypy stupidly calls os._exit(70) when it can't bind the port
  133. # and exits. Instead of that, raise a CherryPyExit (derived from
  134. # SystemExit). This exception may not make it back up to the caller
  135. # due to internal thread use in the CherryPy engine, but there should
  136. # be at least some indication that it happened.
  137. bus = cherrypy.process.wspbus.bus
  138. if "_patched_exit" in bus.__dict__:
  139. return
  140. bus._patched_exit = True
  141. def patched_exit(orig):
  142. real_exit = os._exit
  143. def fake_exit(code):
  144. raise CherryPyExit(code)
  145. os._exit = fake_exit
  146. try:
  147. orig()
  148. finally:
  149. os._exit = real_exit
  150. bus.exit = functools.partial(patched_exit, bus.exit)
  151. # A behavior change in Python 3.8 means that some thread exceptions,
  152. # derived from SystemExit, now print tracebacks where they didn't
  153. # used to: https://bugs.python.org/issue1230540
  154. # Install a thread exception hook that ignores CherryPyExit;
  155. # to make this match the behavior where we didn't set
  156. # threading.excepthook, we also need to ignore SystemExit.
  157. def hook(args):
  158. if args.exc_type == CherryPyExit or args.exc_type == SystemExit:
  159. return
  160. sys.excepthook(args.exc_type, args.exc_value,
  161. args.exc_traceback) # pragma: no cover
  162. threading.excepthook = hook
  163. # Start/stop CherryPy standalone server
  164. def cherrypy_start(blocking=False, event=False):
  165. """Start the CherryPy server, handling errors and signals
  166. somewhat gracefully."""
  167. cherrypy_patch_exit()
  168. # Start the server
  169. cherrypy.engine.start()
  170. # Signal that the engine has started successfully
  171. if event is not None:
  172. event.set()
  173. if blocking:
  174. try:
  175. cherrypy.engine.wait(cherrypy.engine.states.EXITING,
  176. interval=0.1, channel='main')
  177. except (KeyboardInterrupt, IOError):
  178. cherrypy.engine.log('Keyboard Interrupt: shutting down')
  179. cherrypy.engine.exit()
  180. except SystemExit:
  181. cherrypy.engine.log('SystemExit raised: shutting down')
  182. cherrypy.engine.exit()
  183. raise
  184. # Stop CherryPy server
  185. def cherrypy_stop():
  186. cherrypy.engine.exit()