server.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. # -*- coding: utf-8 -*-
  2. """
  3. livereload.server
  4. ~~~~~~~~~~~~~~~~~
  5. WSGI app server for livereload.
  6. :copyright: (c) 2013 - 2015 by Hsiaoming Yang
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. import os
  10. import time
  11. import shlex
  12. import logging
  13. import threading
  14. import webbrowser
  15. from subprocess import Popen, PIPE
  16. from tornado.wsgi import WSGIContainer
  17. from tornado.ioloop import IOLoop
  18. from tornado.autoreload import add_reload_hook
  19. from tornado import web
  20. from tornado import escape
  21. from tornado import httputil
  22. from tornado.log import LogFormatter
  23. from .handlers import LiveReloadHandler, LiveReloadJSHandler
  24. from .handlers import ForceReloadHandler, StaticFileHandler
  25. from .watcher import get_watcher_class
  26. from six import string_types, PY3
  27. import sys
  28. if sys.version_info >= (3, 7) or sys.version_info.major == 2:
  29. import errno
  30. else:
  31. from os import errno
  32. if sys.version_info >= (3, 8) and sys.platform == 'win32':
  33. import asyncio
  34. asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
  35. logger = logging.getLogger('livereload')
  36. HEAD_END = b'</head>'
  37. def set_header(fn, name, value):
  38. """Helper Function to Add HTTP headers to the server"""
  39. def set_default_headers(self, *args, **kwargs):
  40. fn(self, *args, **kwargs)
  41. self.set_header(name, value)
  42. return set_default_headers
  43. def shell(cmd, output=None, mode='w', cwd=None, shell=False):
  44. """Execute a shell command.
  45. You can add a shell command::
  46. server.watch(
  47. 'style.less', shell('lessc style.less', output='style.css')
  48. )
  49. :param cmd: a shell command, string or list
  50. :param output: output stdout to the given file
  51. :param mode: only works with output, mode ``w`` means write,
  52. mode ``a`` means append
  53. :param cwd: set working directory before command is executed.
  54. :param shell: if true, on Unix the executable argument specifies a
  55. replacement shell for the default ``/bin/sh``.
  56. """
  57. if not output:
  58. output = os.devnull
  59. else:
  60. folder = os.path.dirname(output)
  61. if folder and not os.path.isdir(folder):
  62. os.makedirs(folder)
  63. if not isinstance(cmd, (list, tuple)) and not shell:
  64. cmd = shlex.split(cmd)
  65. def run_shell():
  66. try:
  67. p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, cwd=cwd,
  68. shell=shell)
  69. except OSError as e:
  70. logger.error(e)
  71. if e.errno == errno.ENOENT: # file (command) not found
  72. logger.error("maybe you haven't installed %s", cmd[0])
  73. return e
  74. stdout, stderr = p.communicate()
  75. if stderr:
  76. logger.error(stderr)
  77. return stderr
  78. #: stdout is bytes, decode for python3
  79. if PY3:
  80. stdout = stdout.decode()
  81. with open(output, mode) as f:
  82. f.write(stdout)
  83. return run_shell
  84. class LiveScriptInjector(web.OutputTransform):
  85. def __init__(self, request):
  86. super(LiveScriptInjector, self).__init__(request)
  87. def transform_first_chunk(self, status_code, headers, chunk, finishing):
  88. if HEAD_END in chunk:
  89. chunk = chunk.replace(HEAD_END, self.script + HEAD_END)
  90. if 'Content-Length' in headers:
  91. length = int(headers['Content-Length']) + len(self.script)
  92. headers['Content-Length'] = str(length)
  93. return status_code, headers, chunk
  94. class LiveScriptContainer(WSGIContainer):
  95. def __init__(self, wsgi_app, script=''):
  96. self.wsgi_app = wsgi_app
  97. self.script = script
  98. def __call__(self, request):
  99. data = {}
  100. response = []
  101. def start_response(status, response_headers, exc_info=None):
  102. data["status"] = status
  103. data["headers"] = response_headers
  104. return response.append
  105. app_response = self.wsgi_app(
  106. WSGIContainer.environ(request), start_response)
  107. try:
  108. response.extend(app_response)
  109. body = b"".join(response)
  110. finally:
  111. if hasattr(app_response, "close"):
  112. app_response.close()
  113. if not data:
  114. raise Exception("WSGI app did not call start_response")
  115. status_code, reason = data["status"].split(' ', 1)
  116. status_code = int(status_code)
  117. headers = data["headers"]
  118. header_set = set(k.lower() for (k, v) in headers)
  119. body = escape.utf8(body)
  120. if HEAD_END in body:
  121. body = body.replace(HEAD_END, self.script + HEAD_END)
  122. if status_code != 304:
  123. if "content-type" not in header_set:
  124. headers.append((
  125. "Content-Type",
  126. "application/octet-stream; charset=UTF-8"
  127. ))
  128. if "content-length" not in header_set:
  129. headers.append(("Content-Length", str(len(body))))
  130. if "server" not in header_set:
  131. headers.append(("Server", "LiveServer"))
  132. start_line = httputil.ResponseStartLine(
  133. "HTTP/1.1", status_code, reason
  134. )
  135. header_obj = httputil.HTTPHeaders()
  136. for key, value in headers:
  137. if key.lower() == 'content-length':
  138. value = str(len(body))
  139. header_obj.add(key, value)
  140. request.connection.write_headers(start_line, header_obj, chunk=body)
  141. request.connection.finish()
  142. self._log(status_code, request)
  143. class Server(object):
  144. """Livereload server interface.
  145. Initialize a server and watch file changes::
  146. server = Server(wsgi_app)
  147. server.serve()
  148. :param app: a wsgi application instance
  149. :param watcher: A Watcher instance, you don't have to initialize
  150. it by yourself. Under Linux, you will want to install
  151. pyinotify and use INotifyWatcher() to avoid wasted
  152. CPU usage.
  153. """
  154. def __init__(self, app=None, watcher=None):
  155. self.root = None
  156. self.app = app
  157. if not watcher:
  158. watcher_cls = get_watcher_class()
  159. watcher = watcher_cls()
  160. self.watcher = watcher
  161. self.SFH = StaticFileHandler
  162. def setHeader(self, name, value):
  163. """Add or override HTTP headers at the at the beginning of the
  164. request.
  165. Once you have intialized a server, you can add one or more
  166. headers before starting the server::
  167. server.setHeader('Access-Control-Allow-Origin', '*')
  168. server.setHeader('Access-Control-Allow-Methods', '*')
  169. server.serve()
  170. :param name: The name of the header field to be defined.
  171. :param value: The value of the header field to be defined.
  172. """
  173. StaticFileHandler.set_default_headers = set_header(
  174. StaticFileHandler.set_default_headers, name, value)
  175. self.SFH = StaticFileHandler
  176. def watch(self, filepath, func=None, delay=None, ignore=None):
  177. """Add the given filepath for watcher list.
  178. Once you have intialized a server, watch file changes before
  179. serve the server::
  180. server.watch('static/*.stylus', 'make static')
  181. def alert():
  182. print('foo')
  183. server.watch('foo.txt', alert)
  184. server.serve()
  185. :param filepath: files to be watched, it can be a filepath,
  186. a directory, or a glob pattern
  187. :param func: the function to be called, it can be a string of
  188. shell command, or any callable object without
  189. parameters
  190. :param delay: Delay sending the reload message. Use 'forever' to
  191. not send it. This is useful to compile sass files to
  192. css, but reload on changed css files then only.
  193. :param ignore: A function return True to ignore a certain pattern of
  194. filepath.
  195. """
  196. if isinstance(func, string_types):
  197. cmd = func
  198. func = shell(func)
  199. func.name = "shell: {}".format(cmd)
  200. self.watcher.watch(filepath, func, delay, ignore=ignore)
  201. def application(self, port, host, liveport=None, debug=None,
  202. live_css=True):
  203. LiveReloadHandler.watcher = self.watcher
  204. LiveReloadHandler.live_css = live_css
  205. if debug is None and self.app:
  206. debug = True
  207. live_handlers = [
  208. (r'/livereload', LiveReloadHandler),
  209. (r'/forcereload', ForceReloadHandler),
  210. (r'/livereload.js', LiveReloadJSHandler)
  211. ]
  212. # The livereload.js snippet.
  213. # Uses JavaScript to dynamically inject the client's hostname.
  214. # This allows for serving on 0.0.0.0.
  215. live_script = (
  216. '<script type="text/javascript">(function(){'
  217. 'var s=document.createElement("script");'
  218. 'var port=%s;'
  219. 's.src="//"+window.location.hostname+":"+port'
  220. '+ "/livereload.js?port=" + port;'
  221. 'document.head.appendChild(s);'
  222. '})();</script>'
  223. )
  224. if liveport:
  225. live_script = escape.utf8(live_script % liveport)
  226. else:
  227. live_script = escape.utf8(live_script % "(window.location.port || (window.location.protocol == 'https:' ? 443: 80))")
  228. web_handlers = self.get_web_handlers(live_script)
  229. class ConfiguredTransform(LiveScriptInjector):
  230. script = live_script
  231. if not liveport:
  232. handlers = live_handlers + web_handlers
  233. app = web.Application(
  234. handlers=handlers,
  235. debug=debug,
  236. transforms=[ConfiguredTransform]
  237. )
  238. app.listen(port, address=host)
  239. else:
  240. app = web.Application(
  241. handlers=web_handlers,
  242. debug=debug,
  243. transforms=[ConfiguredTransform]
  244. )
  245. app.listen(port, address=host)
  246. live = web.Application(handlers=live_handlers, debug=False)
  247. live.listen(liveport, address=host)
  248. def get_web_handlers(self, script):
  249. if self.app:
  250. fallback = LiveScriptContainer(self.app, script)
  251. return [(r'.*', web.FallbackHandler, {'fallback': fallback})]
  252. return [
  253. (r'/(.*)', self.SFH, {
  254. 'path': self.root or '.',
  255. 'default_filename': 'index.html',
  256. }),
  257. ]
  258. def serve(self, port=5500, liveport=None, host=None, root=None, debug=None,
  259. open_url=False, restart_delay=2, open_url_delay=None,
  260. live_css=True):
  261. """Start serve the server with the given port.
  262. :param port: serve on this port, default is 5500
  263. :param liveport: live reload on this port
  264. :param host: serve on this hostname, default is 127.0.0.1
  265. :param root: serve static on this root directory
  266. :param debug: set debug mode, which autoreloads the app on code changes
  267. via Tornado (and causes polling). Defaults to True when
  268. ``self.app`` is set, otherwise False.
  269. :param open_url_delay: open webbrowser after the delay seconds
  270. :param live_css: whether to use live css or force reload on css.
  271. Defaults to True
  272. """
  273. host = host or '127.0.0.1'
  274. if root is not None:
  275. self.root = root
  276. self._setup_logging()
  277. logger.info('Serving on http://%s:%s' % (host, port))
  278. self.application(
  279. port, host, liveport=liveport, debug=debug, live_css=live_css)
  280. # Async open web browser after 5 sec timeout
  281. if open_url:
  282. logger.error('Use `open_url_delay` instead of `open_url`')
  283. if open_url_delay is not None:
  284. def opener():
  285. time.sleep(open_url_delay)
  286. webbrowser.open('http://%s:%s' % (host, port))
  287. threading.Thread(target=opener).start()
  288. try:
  289. self.watcher._changes.append(('__livereload__', restart_delay))
  290. LiveReloadHandler.start_tasks()
  291. add_reload_hook(lambda: IOLoop.instance().close(all_fds=True))
  292. IOLoop.instance().start()
  293. except KeyboardInterrupt:
  294. logger.info('Shutting down...')
  295. def _setup_logging(self):
  296. logger.setLevel(logging.INFO)
  297. channel = logging.StreamHandler()
  298. channel.setFormatter(LogFormatter())
  299. logger.addHandler(channel)
  300. # need a tornado logging handler to prevent IOLoop._setup_logging
  301. logging.getLogger('tornado').addHandler(channel)