Skip to content

Instantly share code, notes, and snippets.

@tzengerink
Last active December 3, 2015 10:48
Show Gist options
  • Save tzengerink/c5f693c5086fd6e437f5 to your computer and use it in GitHub Desktop.
Save tzengerink/c5f693c5086fd6e437f5 to your computer and use it in GitHub Desktop.
Build static websites using Jinja2 templates and YAML data descriptions
#!/usr/bin/env python
# -*- coding: utf-8 -*-
'''
STATICBUILDER
=============
Build static websites using Jinja2 templates and YAML data descriptions.
The default directory structure that is needed to build a static site looks
like follows:
project/
├─ data/
├─ static/
├─ templates/
└─ staticbuilder
*Data*
This directory contains YAML files that contain the data that is used by the
templates. Each files becomes a variable that will be passed to each
template upon rendering.
*Static*
Files and folders containing static files, like JavaScript and CSS.
*Templates*
Templates that are to be rendered. Files ending in `.layout.html` and
`.partial.html` will be ignored when rendering pages for the final site.
*staticbuilder*
This file that should be executable.
- - -
Copyright (c) 2015 Teun Zengerink
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.
'''
import argparse
import glob
import logging
import os
import shutil
import SimpleHTTPServer
import SocketServer
import sys
import time
import yaml
from jinja2 import Environment, FileSystemLoader
from multiprocessing import Process
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
DATA_DIR = 'data'
BUILD_DIR = '_build'
SERVER_HOST = '0.0.0.0'
SERVER_PORT = 5000
STATIC_DIR = 'static'
TEMPLATE_DIR = 'templates'
LOG = logging.getLogger(__name__)
class Data(dict):
'''Responsible for reading all YAML files in a directory. Each filename
becomes a key containing the parsed content of the file as value.
Args:
directory (str): The directory to read all YAML files from.
'''
def __init__(self, directory):
for path in glob.glob(os.path.join(directory, '*.yaml')):
name = os.path.splitext(os.path.basename(path))[0]
with open(path) as f:
dict.__setitem__(self, name, yaml.load(f.read()))
class CommandEventHandler(FileSystemEventHandler):
'''Event handler for that registers file changes and executes the provided
command.
Args:
command (callable): Callable that must be executed when an event is
fired.
'''
def __init__(self, command):
self.command = command
def on_any_event(self, event):
'''Execute the command.
Args:
event (watchdog.events.FileSystemEvent): The fired event.
'''
LOG.info('{} {}'.format(event.src_path, event.event_type))
self.command()
class RequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
'''Handler that logs HTTP requests using the configured logging instance.
'''
def log_request(self, code='', size=''):
LOG.info('{} {}'.format(self.requestline, code))
class BuildCommand(object):
'''Command that builds the static site.
Args:
build_dir (str): Directory containing the static site.
template_dir (str): Directory containing the Jinja2 template files.
static_dir (str): Directory containing the static files.
data_dir (str): Directory containing the YAML data files.
'''
def __init__(
self,
build_dir=BUILD_DIR,
template_dir=TEMPLATE_DIR,
static_dir=STATIC_DIR,
data_dir=DATA_DIR):
self.build_dir = build_dir
self.static_dir = static_dir
self.env = Environment(loader=FileSystemLoader(template_dir))
self.data = Data(data_dir)
LOG.info('Building the static pages in {}'.format(self.build_dir))
self._clean_build_dir()
self._render_templates()
self._copy_static_files()
def _clean_build_dir(self):
'''Clean the build dir by removing all files.
'''
for item in os.listdir(self.build_dir):
path = os.path.join(self.build_dir, item)
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
def _copy_static_files(self):
'''Copy all static files to the build directory.
'''
for item in os.listdir(self.static_dir):
src = os.path.join(self.static_dir, item)
dst = os.path.join(self.build_dir, item)
shutil.copytree(src, dst)
def _filter_templates(self, template_name):
'''Filter the templates that should be excluded from the static site.
Args:
template_name (str): Template name to evaluate.
Returns:
bool: True if template should be included, Fals otherwise.
'''
for exclude in ['.layout.html', '.partial.html']:
if exclude in template_name:
return False
return True
def _render_templates(self):
'''Render a all templates and write them to the build directory.
'''
names = self.env.list_templates(filter_func=self._filter_templates)
for name in names:
with open(os.path.join(self.build_dir, name), 'w') as f:
f.write(self._render_template(name))
def _render_template(self, name):
'''Render a single template.
Args:
name (str): Name of the template to render.
Returns:
str: Rendered template.
'''
return self.env.get_template(name)\
.render(template=name, data=self.data)\
.encode('utf-8')
class ServeCommand(object):
'''Serves the build site and watches for changes in the appropriate
directories. When changes occur it rebuilds the static pages.
Args:
host (str): Host to use when serving the pages.
port (int): Port to use when serving the pages.
dirs (iterable): List of directories to watch for changes.
build_dir (str): Build directory containing final static pages.
'''
build = BuildCommand
def __init__(
self,
host=SERVER_HOST,
port=SERVER_PORT,
dirs=[TEMPLATE_DIR, STATIC_DIR],
build_dir=BUILD_DIR):
self.host = host
self.port = port
self.dirs = dirs
self.build_dir = build_dir
processes = []
for fn in [self._watch, self._serve]:
p = Process(target=fn)
p.start()
processes.append(p)
try:
for process in processes:
process.join()
except KeyboardInterrupt:
pass
def _serve(self):
'''Serve the static pages in the build directory.
'''
os.chdir(self.build_dir)
httpd = SocketServer.TCPServer((self.host, self.port), RequestHandler)
try:
LOG.info('Starting server at {}:{}'.format(self.host, self.port))
httpd.serve_forever()
except KeyboardInterrupt:
print('') # This way the ^C printed on screen looks better
LOG.info('Stopping server')
httpd.shutdown()
def _watch(self):
'''Watch the appropriate directories for any changes, execute the build
command when a change occurs.
'''
self.build()
handler = CommandEventHandler(self.build)
observer = Observer()
for dir in self.dirs:
observer.schedule(handler, dir, True)
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
class Runner(object):
'''Script runner that executes the requested command.
'''
def __init__(self):
args = self.parse_args()
loglevel = logging.ERROR if args.silent else logging.INFO
logging.basicConfig(level=loglevel, format='%(asctime)s - %(message)s')
globals()['{}{}'.format(args.command.capitalize(), 'Command')]()
def parse_args(self):
'''Parse the arguments that are passed when running the script.
'''
parser = argparse.ArgumentParser(
usage='{} <command>'.format(sys.argv[0]))
parser.add_argument('command', help='command to execute')
parser.add_argument(
'-s', '--silent', action='store_true', help='no logging to stdout')
return parser.parse_args(sys.argv[1:])
if __name__ == '__main__':
Runner()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment