aboutsummaryrefslogtreecommitdiffstats
path: root/nikola/plugins/command/auto/__init__.py
diff options
context:
space:
mode:
Diffstat (limited to 'nikola/plugins/command/auto/__init__.py')
-rw-r--r--nikola/plugins/command/auto/__init__.py366
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)