# -*- 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 = ''' ERROR {} ''' 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 = ''' '''.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('', 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)