diff options
Diffstat (limited to 'nikola/plugins/command/auto/__init__.py')
| -rw-r--r-- | nikola/plugins/command/auto/__init__.py | 366 |
1 files changed, 366 insertions, 0 deletions
diff --git a/nikola/plugins/command/auto/__init__.py b/nikola/plugins/command/auto/__init__.py new file mode 100644 index 0000000..c25ef8a --- /dev/null +++ b/nikola/plugins/command/auto/__init__.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- + +# Copyright © 2012-2015 Roberto Alsina and others. + +# Permission is hereby granted, free of charge, to any +# person obtaining a copy of this software and associated +# documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice +# shall be included in all copies or substantial portions of +# the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import print_function + +import json +import mimetypes +import os +import re +import subprocess +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse # NOQA +import webbrowser +from wsgiref.simple_server import make_server +import wsgiref.util + +from blinker import signal +try: + from ws4py.websocket import WebSocket + from ws4py.server.wsgirefserver import WSGIServer, WebSocketWSGIRequestHandler + from ws4py.server.wsgiutils import WebSocketWSGIApplication + from ws4py.messaging import TextMessage +except ImportError: + WebSocket = object +try: + import watchdog + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler, PatternMatchingEventHandler +except ImportError: + watchdog = None + FileSystemEventHandler = object + PatternMatchingEventHandler = object + + +from nikola.plugin_categories import Command +from nikola.utils import req_missing, get_logger, get_theme_path +LRJS_PATH = os.path.join(os.path.dirname(__file__), 'livereload.js') +error_signal = signal('error') +refresh_signal = signal('refresh') + +ERROR_N = '''<html> +<head> +</head> +<boody> +ERROR {} +</body> +</html> +''' + + +class CommandAuto(Command): + """Start debugging console.""" + name = "auto" + logger = None + doc_purpose = "builds and serves a site; automatically detects site changes, rebuilds, and optionally refreshes a browser" + cmd_options = [ + { + 'name': 'port', + 'short': 'p', + 'long': 'port', + 'default': 8000, + 'type': int, + 'help': 'Port nummber (default: 8000)', + }, + { + 'name': 'address', + 'short': 'a', + 'long': 'address', + 'type': str, + 'default': '127.0.0.1', + 'help': 'Address to bind (default: 127.0.0.1 – localhost)', + }, + { + 'name': 'browser', + 'short': 'b', + 'long': 'browser', + 'type': bool, + 'help': 'Start a web browser.', + 'default': False, + }, + { + 'name': 'ipv6', + 'short': '6', + 'long': 'ipv6', + 'default': False, + 'type': bool, + 'help': 'Use IPv6', + }, + ] + + def _execute(self, options, args): + """Start the watcher.""" + + self.logger = get_logger('auto', self.site.loghandlers) + LRSocket.logger = self.logger + + if WebSocket is object and watchdog is None: + req_missing(['ws4py', 'watchdog'], 'use the "auto" command') + elif WebSocket is object: + req_missing(['ws4py'], 'use the "auto" command') + elif watchdog is None: + req_missing(['watchdog'], 'use the "auto" command') + + self.cmd_arguments = ['nikola', 'build'] + if self.site.configuration_filename != 'conf.py': + self.cmd_arguments = ['--conf=' + self.site.configuration_filename] + self.cmd_arguments + + # Run an initial build so we are up-to-date + subprocess.call(self.cmd_arguments) + + port = options and options.get('port') + self.snippet = '''<script>document.write('<script src="http://' + + (location.host || 'localhost').split(':')[0] + + ':{0}/livereload.js?snipver=1"></' + + 'script>')</script> + </head>'''.format(port) + + # Do not duplicate entries -- otherwise, multiple rebuilds are triggered + watched = set([ + 'templates/', + ] + [get_theme_path(name) for name in self.site.THEMES]) + for item in self.site.config['post_pages']: + watched.add(os.path.dirname(item[0])) + for item in self.site.config['FILES_FOLDERS']: + watched.add(item) + for item in self.site.config['GALLERY_FOLDERS']: + watched.add(item) + for item in self.site.config['LISTINGS_FOLDERS']: + watched.add(item) + + out_folder = self.site.config['OUTPUT_FOLDER'] + if options and options.get('browser'): + browser = True + else: + browser = False + + if options['ipv6']: + dhost = '::' + else: + dhost = None + + host = options['address'].strip('[').strip(']') or dhost + + # Instantiate global observer + observer = Observer() + # Watch output folders and trigger reloads + observer.schedule(OurWatchHandler(self.do_refresh), out_folder, recursive=True) + + # Watch input folders and trigger rebuilds + for p in watched: + if os.path.exists(p): + observer.schedule(OurWatchHandler(self.do_rebuild), p, recursive=True) + + # Watch config file (a bit of a hack, but we need a directory) + _conf_fn = os.path.abspath(self.site.configuration_filename or 'conf.py') + _conf_dn = os.path.dirname(_conf_fn) + observer.schedule(ConfigWatchHandler(_conf_fn, self.do_rebuild), _conf_dn, recursive=False) + + observer.start() + + parent = self + + class Mixed(WebSocketWSGIApplication): + """A class that supports WS and HTTP protocols in the same port.""" + def __call__(self, environ, start_response): + if environ.get('HTTP_UPGRADE') is None: + return parent.serve_static(environ, start_response) + return super(Mixed, self).__call__(environ, start_response) + + ws = make_server( + host, port, server_class=WSGIServer, + handler_class=WebSocketWSGIRequestHandler, + app=Mixed(handler_cls=LRSocket) + ) + ws.initialize_websockets_manager() + self.logger.info("Serving HTTP on {0} port {1}...".format(host, port)) + if browser: + if options['ipv6'] or '::' in host: + server_url = "http://[{0}]:{1}/".format(host, port) + else: + server_url = "http://{0}:{1}/".format(host, port) + + self.logger.info("Opening {0} in the default web browser...".format(server_url)) + # Yes, this is racy + webbrowser.open('http://{0}:{1}'.format(host, port)) + + try: + ws.serve_forever() + except KeyboardInterrupt: + self.logger.info("Server is shutting down.") + observer.stop() + observer.join() + + def do_rebuild(self, event): + self.logger.info('REBUILDING SITE (from {0})'.format(event.src_path)) + p = subprocess.Popen(self.cmd_arguments, stderr=subprocess.PIPE) + if p.wait() != 0: + error = p.stderr.read() + self.logger.error(error) + error_signal.send(error=error) + else: + error = p.stderr.read() + print(error) + + def do_refresh(self, event): + self.logger.info('REFRESHING: {0}'.format(event.src_path)) + p = os.path.relpath(event.src_path, os.path.abspath(self.site.config['OUTPUT_FOLDER'])) + refresh_signal.send(path=p) + + def serve_static(self, environ, start_response): + """Trivial static file server.""" + uri = wsgiref.util.request_uri(environ) + p_uri = urlparse(uri) + f_path = os.path.join(self.site.config['OUTPUT_FOLDER'], *p_uri.path.split('/')) + mimetype = mimetypes.guess_type(uri)[0] or 'text/html' + + if os.path.isdir(f_path): + f_path = os.path.join(f_path, self.site.config['INDEX_FILE']) + + if p_uri.path == '/robots.txt': + start_response('200 OK', [('Content-type', 'text/plain')]) + return ['User-Agent: *\nDisallow: /\n'] + elif os.path.isfile(f_path): + with open(f_path, 'rb') as fd: + start_response('200 OK', [('Content-type', mimetype)]) + return [self.inject_js(mimetype, fd.read())] + elif p_uri.path == '/livereload.js': + with open(LRJS_PATH, 'rb') as fd: + start_response('200 OK', [('Content-type', mimetype)]) + return [self.inject_js(mimetype, fd.read())] + start_response('404 ERR', []) + return [self.inject_js('text/html', ERROR_N.format(404).format(uri))] + + def inject_js(self, mimetype, data): + """Inject livereload.js in HTML files.""" + if mimetype == 'text/html': + data = re.sub('</head>', self.snippet, data.decode('utf8'), 1, re.IGNORECASE) + data = data.encode('utf8') + return data + + +pending = [] + + +class LRSocket(WebSocket): + """Speak Livereload protocol.""" + + def __init__(self, *a, **kw): + refresh_signal.connect(self.notify) + error_signal.connect(self.send_error) + super(LRSocket, self).__init__(*a, **kw) + + def received_message(self, message): + message = json.loads(message.data.decode('utf8')) + self.logger.info('<--- {0}'.format(message)) + response = None + if message['command'] == 'hello': # Handshake + response = { + 'command': 'hello', + 'protocols': [ + 'http://livereload.com/protocols/official-7', + ], + 'serverName': 'nikola-livereload', + } + elif message['command'] == 'info': # Someone connected + self.logger.info('****** Browser connected: {0}'.format(message.get('url'))) + self.logger.info('****** sending {0} pending messages'.format(len(pending))) + while pending: + msg = pending.pop() + self.logger.info('---> {0}'.format(msg.data)) + self.send(msg, msg.is_binary) + else: + response = { + 'command': 'alert', + 'message': 'HEY', + } + if response is not None: + response = json.dumps(response) + self.logger.info('---> {0}'.format(response)) + response = TextMessage(response) + self.send(response, response.is_binary) + + def notify(self, sender, path): + """Send reload requests to the client.""" + p = os.path.join('/', path) + message = { + 'command': 'reload', + 'liveCSS': True, + 'path': p, + } + response = json.dumps(message) + self.logger.info('---> {0}'.format(p)) + response = TextMessage(response) + if self.stream is None: # No client connected or whatever + pending.append(response) + else: + self.send(response, response.is_binary) + + def send_error(self, sender, error=None): + """Send reload requests to the client.""" + if self.stream is None: # No client connected or whatever + return + message = { + 'command': 'alert', + 'message': error, + } + response = json.dumps(message) + response = TextMessage(response) + if self.stream is None: # No client connected or whatever + pending.append(response) + else: + self.send(response, response.is_binary) + + +class OurWatchHandler(FileSystemEventHandler): + + """A Nikola-specific handler for Watchdog.""" + + def __init__(self, function): + """Initialize the handler.""" + self.function = function + super(OurWatchHandler, self).__init__() + + def on_any_event(self, event): + """Call the provided function on any event.""" + self.function(event) + + +class ConfigWatchHandler(FileSystemEventHandler): + + """A Nikola-specific handler for Watchdog that handles the config file (as a workaround).""" + + def __init__(self, configuration_filename, function): + """Initialize the handler.""" + self.configuration_filename = configuration_filename + self.function = function + + def on_any_event(self, event): + """Call the provided function on any event.""" + if event._src_path == self.configuration_filename: + self.function(event) |
