Last active
December 3, 2015 10:48
-
-
Save tzengerink/c5f693c5086fd6e437f5 to your computer and use it in GitHub Desktop.
Build static websites using Jinja2 templates and YAML data descriptions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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