View the Source Code


Run unit tests

views.py

"""
Monomotapa - A Micro CMS
Copyright (C) 2014, Paul Munday.

PO Box 28228, Portland, OR, USA 97228
paul at paulmunday.net

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero  Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.

See http://paulmunday.net/license for licensing details.

Monomotapa:
    a city whose inhabitants are bounded by deep feelings of friendship,
    so thatthey intuit one another's most secret needs and desire. For
    instance, if one dreams that his friend is sad, the friend will perceive
    the distress and rush to the sleepers rescue.

    (Jean de La Fontaine, *Fables choisies, mises en vers*, VIII:11 Paris,
    2nd ed., 1678-9)

cited in :
Alberto Manguel and Gianni Guadalupi, *The Dictionary of Imaginary Places*,
Bloomsbury, London, 1999.

A micro app written using the Flask microframework to manage my personal site.
It is designed so that publishing a page requires no more than dropping a
markdown page in the appropriate directory (though you need to edit a json file
if you want it to appear in the top navigation).

It can also display its own source code and run its own unit tests.

The name 'monomotapa' was chosen more or less at random (it shares an initial
with me) as I didn't want to name it after the site and be typing import
paulmunday, or something similar, as that would be strange.
"""

# pylint: disable=no-name-in-module, redefined-outer-name
from flask import render_template, abort, Markup, escape, request
from flask import make_response

from pygments import highlight
from pygments.lexers import PythonLexer, HtmlDjangoLexer, TextLexer
from pygments.formatters import HtmlFormatter

import markdown

import os.path
import os
import json
import random
import subprocess
import tempfile
from collections import OrderedDict

from monomotapa import app
from monomotapa.config import ConfigError


class MonomotapaError(Exception):
    """create classs for own errors"""
    pass


def get_page_attributes(jsonfile):
    """Returns dictionary of page_attributes.
    Defines additional static page attributes loaded from json file.
    N.B. static pages do not need to have attributes defined there,
    it is sufficient to have a page.md in src for each /page
    possible values are src (name of markdown file to be rendered)
    heading, title, and trusted (i.e. allow embeded html in markdown)"""
    try:
        with open(src_file(jsonfile), 'r') as pagesfile:
            page_attributes = json.load(pagesfile)
    except IOError:
        page_attributes = []
    return page_attributes


def get_page_attribute(attr_src, page, attribute):
    """returns attribute of page if it exists, else None.
    attr_src = dictionary(from get_page_attributes)"""
    if page in attr_src and attribute in attr_src[page]:
        return attr_src[page][attribute]
    else:
        return None


# Navigation
def top_navigation(page):
    """Generates navigation as an OrderedDict from navigation.json.
    Navigation.json consists of a json array(list) "nav_order"
    containing the names of the top navigation elements and
    a json object(dict) called "nav_elements"
    if a page is to show up in the top navigation
    there must be an entry present in nav_order but there need not
    be one in nav_elements. However if there is the key must be the same.
    Possible values for nav_elements are link_text, url and urlfor
    The name  from nav_order will be used to set the link text,
    unless link_text is present in nav_elements.
    url and urlfor are optional, however if ommited the url wil be
    generated in the navigation by  url_for('staticpage', page=[key])
    equivalent to  @app.route"/page"; def page())
    which may not be correct. If a url is supplied  it will be used
    otherwise if urlfor is supplied it the url will be
    generated with url_for(urlfor). url takes precendence so it makes
    no sense to supply both.
    Web Sign-in is supported by adding a "rel": "me" attribute.
    """
    with open(src_file('navigation.json'), 'r') as navfile:
        navigation = json.load(navfile)
    base_nav = OrderedDict({})
    for key in navigation["nav_order"]:
        nav = {}
        nav['base'] = key
        nav['link_text'] = key
        if key in navigation["nav_elements"]:
            elements = navigation["nav_elements"][key]
            nav.update(elements)
        base_nav[key] = nav
    return {'navigation':  base_nav, 'page': page}


# For pages
class Page(object):
    """Generates  pages as objects"""
    # pylint: disable=too-many-instance-attributes
    def __init__(self, page, **kwargs):
        """Define attributes for  pages (if present).
        Sets self.name, self.title, self.heading, self.trusted etc
        This is done through indirection so we can update the defaults
        (defined  in the 'attributes' dictionary) with values from config.json
        or pages.json easily without lots of if else statements.
        If css is supplied it will overide any default css. To add additional
        style sheets on a per page basis specifiy them in pages.json.
        The same also applies with hlinks.
        css is used to set locally hosted stylesheets only. To specify
        external stylesheets use hlinks: in config.json for
        default values that will apply on all pages unless overidden, set here
        to override the default. Set in pages.json to add after default.
        """
        # set default attributes
        self.page = page.rstrip('/')
        self.defaults = get_page_attributes('defaults.json')
        self.pages = get_page_attributes('pages.json')
        title = page.lower()
        heading = page.capitalize()
        try:
            self.default_template = self.defaults['template']
        except KeyError:
            raise ConfigError('template not found in default.json')
        # will become self.name, self.title, self.heading,
        # self.footer, self.internal_css, self.trusted
        attributes = {
            'name': self.page, 'title': title,
            'navigation': top_navigation(self.page),
            'heading': heading, 'footer': None,
            'css': None, 'hlinks': None, 'internal_css': None,
            'trusted': False
        }
        # set from defaults
        attributes.update(self.defaults)
        # override with kwargs
        attributes.update(kwargs)
        # override attributes if set in pages.json
        if page in self.pages:
            attributes.update(self.pages[page])
        # set attributes (as self.name etc)  using indirection
        for attribute, value in attributes.iteritems():
            setattr(self, attribute, value)
            setattr(self, attribute, value)
        # reset these as we want to append rather than overwrite if supplied
        if 'css' in kwargs:
            self.css = kwargs['css']
        elif 'css' in self.defaults:
            self.css = self.defaults['css']
        if 'hlinks' in kwargs:
            self.hlinks = kwargs['hlinks']
        elif 'hlinks' in self.defaults:
            self.hlinks = self.defaults['hlinks']
        # append hlinks and css from pages.json rather than overwriting
        # if css or hlinks are not supplied they are set to default
        if page in self.pages:
            if 'css' in self.pages[page]:
                self.css = self.css + self.pages[page]['css']
            if 'hlinks' in self.pages[page]:
                self.hlinks = self.hlinks + self.pages[page]['hlinks']
        # append heading to default if set in config
        if app.config['default_title']:
            self.title = app.config['default_title'] + self.title

    def _get_markdown(self):
        """returns rendered markdown or 404 if source does not exist"""
        # pylint: disable=no-member
        src = self.get_page_src(self.page, 'src', 'md')
        if src is None:
            abort(404)
        else:
            return render_markdown(src, self.trusted)

    def get_page_src(self, page, directory=None, ext=None):
        """"return path of file (used to generate page) if it exists,
        or return none.
        Also returns the template used to render that page, defaults
        to static.html.
        It will optionally add an extension, to allow
        specifiying pages by route."""
        # is it stored in a config
        pagename = get_page_attribute(self.pages, page, 'src')
        if not pagename:
            pagename = page + get_extension(ext)
        if os.path.exists(src_file(pagename, directory)):
            return src_file(pagename, directory)
        else:
            return None

    def get_template(self, page):
        """returns the template for the page"""
        pagetemplate = get_page_attribute(self.pages, page, 'template')
        if not pagetemplate:
            pagetemplate = self.default_template
        if os.path.exists(src_file(pagetemplate, 'templates')):
            return pagetemplate
        else:
            raise MonomotapaError("Template: %s not found" % pagetemplate)

    def generate_page(self, contents=None):
        """return a page generator function.
        For static pages written in Markdown under src/.
        contents are automatically rendered.
        N.B. See note above in about headers"""
        if not contents:
            contents = self._get_markdown()
        template = self.get_template(self.page)
        return render_template(
            template,
            contents=Markup(contents),
            **vars(self)
        )


# helper functions

def src_file(name, directory=None):
    """return potential path to file in this app"""
    if not directory:
        return os.path.join('monomotapa', name)
    else:
        return os.path.join('monomotapa', directory, name)


def get_extension(ext):
    '''constructs extension, adding or stripping leading . as needed.
    Return null string for None'''
    if ext is None:
        return ''
    elif ext[0] == '.':
        return ext
    else:
        return '.%s' % ext


def render_markdown(srcfile, trusted=False):
    """Return markdown file rendered as html. Defaults to untrusted:
        html characters (and character entities) are escaped
        so will not be rendered. This departs from markdown spec
        which allows embedded html."""
    try:
        with open(srcfile, 'r') as f:
            src = f.read()
            src = src.decode('utf-8')
            if trusted is True:
                return markdown.markdown(src)
            else:
                return markdown.markdown(escape(src))
    except IOError:
        return None


def render_pygments(srcfile, lexer_type):
    """returns src(file) marked up with pygments"""
    if lexer_type == 'python':
        with open(srcfile, 'r') as f:
            src = f.read()
            contents = highlight(src, PythonLexer(), HtmlFormatter())
    elif lexer_type == 'html':
        with open(srcfile, 'r') as f:
            src = f.read()
            contents = highlight(src, HtmlDjangoLexer(), HtmlFormatter())
    # default to TextLexer for everything else
    else:
        with open(srcfile, 'r') as f:
            src = f.read()
            contents = highlight(src, TextLexer(), HtmlFormatter())
    return contents


def get_pygments_css(style=None):
    """returns css for pygments, use as internal_css"""
    if style is None:
        style = 'friendly'
    return HtmlFormatter(style=style).get_style_defs('.highlight')


def heading(text, level):
    """return as html heading at h[level]"""
    hltag = 'h{}'.format(str(level))
    return '\n<{}>{}</{}>\n'.format(hltag, text, hltag)


def name_obfuscator(name):
    """returns name plus a random six character string joined by a .
    characters are a..z or 0..9"""
    container = [name, '.']
    for _ in range(6):
        # 97 = asci a
        randint = random.randint(97, 132)
        # 122 = z, if over that  subtracting 75 converts it to  0..9
        if randint > 122:
            randint = randint - 75
        # add asci character
        container.append(chr(randint))
    return "".join(container)


# Define routes


@app.errorhandler(404)
def page_not_found(e):
    """ provides basic 404 page"""
    # pylint: disable=unused-argument
    defaults = get_page_attributes('defaults.json')
    try:
        css = defaults['css']
    except KeyError:
        css = None
    pages = get_page_attributes('pages.json')
    if '404' in pages:
        if'css' in pages['404']:
            css = pages['404']['css']
    return render_template(
        'static.html',
        title="404::page not found",
        heading="Page Not Found",
        navigation=top_navigation('404'),
        css=css,
        contents=Markup("This page is not there, try somewhere else.")
    ), 404


@app.route("/")
def index():
    """provides index page"""
    index = Page('index')
    return index.generate_page()


# default route is it doe not exist elsewhere
@app.route("/<path:page>")
def staticpage(page):
    """ display a static page rendered from markdown in src
    i.e. displays /page or /page/ as long as src/page.md exists.
    srcfile, title and heading may be set in the pages global
    (ordered) dictionary but are not required"""
    static_page = Page(page)
    return static_page.generate_page()


# specialized pages
@app.route("/library/<path:isbn>")
def catalogue_card(isbn):
    """Book specific pages"""
    catalogue_card = Page('catalogue_card', title=isbn, heading=isbn)
    page_src = catalogue_card.get_page_src(isbn, 'library', 'md')
    contents = render_markdown(page_src, True)
    return catalogue_card.generate_page(contents=contents)


@app.route("/source")
def source():
    """Display source files used to render a page"""
    source_page = Page(
        'source', title="view the source code",
        heading="View the Source Code",
        internal_css=get_pygments_css()
    )
    page = request.args.get('page')
    # get source for markdown if any. 404's for non-existant markdown
    # unless special page eg source
    pagesrc = source_page.get_page_src(page, 'src', 'md')
    special_pages = ['source', 'unit-tests', '404']
    if page not in special_pages and pagesrc is None:
        abort(404)
    # set enable_unit_tests  to true  in config.json to allow
    #  unit tests to be run  through the source page
    if app.config['enable_unit_tests']:
        contents = '''<p><a href="/unit-tests" class="button">Run unit tests
    </a></p>'''
        # render tests.py if needed
        if page == 'unit-tests':
            contents += heading('tests.py', 2)
            contents += render_pygments('tests.py', 'python')
    else:
        contents = ''
    # render views.py
    contents += heading('views.py', 2)
    contents += render_pygments(
        source_page.get_page_src('views.py'), 'python'
    )
    # render markdown if present
    if pagesrc:
        contents += heading(os.path.basename(pagesrc), 2)
        contents += render_pygments(pagesrc, 'markdown')
    # render jinja templates
    contents += heading('base.html', 2)
    contents += render_pygments(
        source_page.get_page_src('base.html', 'templates'), 'html'
    )
    template = source_page.get_template(page)
    contents += heading(template, 2)
    contents += render_pygments(
        source_page.get_page_src(template, 'templates'), 'html'
    )
    return source_page.generate_page(contents)


@app.route("/unit-tests")
def unit_tests():
    """display results of unit tests"""
    unittests = Page(
        'unit-tests', heading="Test Results",
        internal_css=get_pygments_css()
    )
    # exec unit tests in subprocess, capturing stderr
    capture = subprocess.Popen(
        ["python", "tests.py"],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE
    )
    output = capture.communicate()
    results = output[1]
    contents = '''<p>
    <a href="/unit-tests" class="button">Run unit tests</a>
    </p><br>\n
    <div class="output" style="background-color:'''
    if 'OK' in results:
        color = "#ddffdd"
        result = "TESTS PASSED"
    else:
        color = "#ffaaaa"
        result = "TESTS FAILING"
    contents += ('''%s">\n<strong>%s</strong>\n<pre>%s</pre>\n</div>\n'''
                 % (color, result, results))
    # render test.py
    contents += heading('tests.py', 2)
    contents += render_pygments('tests.py', 'python')
    return unittests.generate_page(contents)


@app.route("/resume")
def resume():
    """resume page with footer"""
    # pylint: disable=attribute-defined-outside-init
    resume = Page('resume')
    name = name_obfuscator('paul')
    footer_content = '''<span style="font-size: 1em">
    Download this resume as a <a href="/resume.pdf">PDF</a>.<br>
    Contact me: <script>address("%s","paulmunday",2,"")</script>''' % name
    resume.footer = Markup(footer_content)
    return resume.generate_page()


@app.route("/resume.pdf")
def resume_pdf():
    """renders up to date  resume as pdf from markdown"""
    # we need to use actual files here are pdf rendering is
    # done by call to external util
    tmpfile = tempfile.NamedTemporaryFile(delete=False)
    resume = src_file('resume.md', 'src')
    output = src_file('resume.pdf', 'src')
    name = name_obfuscator('paul')
    domain = 'paulmunday.net'
    header = "#Paul Munday\nwww.{}&nbsp;&nbsp;{}@{}\n\n".format(
        domain, name, domain)
    with open(tmpfile.name, 'a') as f:
        f.write(header)
        with open(resume, 'r') as rfile:
            contents = rfile.read()
        f.write(contents)
    # render pdf and remove temp file
    subprocess.call(["pandoc", tmpfile.name, "-S", "-o", output])
    os.unlink(tmpfile.name)
    # read pdf in as string and create flask response
    with open(output, 'rb') as pdfile:
        pdf = pdfile.read()
    response = make_response(pdf, 200)
    response.headers["Content-Type"] = "Application/pdf"
    return response

home.md

I am a [Python](http://www.python.org) developer living in Portland, Oregon and  a [FLOSS](http://fsf.org) and [Indieweb](http://indiewebcamp.com) supporter.

base.html

<!DOCTYPE html>
<!--

Monomotapa - A Micro CMS
Copyright (C) 2014, Paul Munday.

PO Box 28228, Portland, OR, USA 97228
paul at paulmunday.net

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero  Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.

There should also be a copy of the AGPL in src/license.md that should be
accessible by going to <a href ="/license">/license<a> on this site.
-->
<html>
<head>
<title>{% if title -%}{{title}}{% endif %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{%- if css -%}
{%- for file in css %}
    <link href="{{ url_for('static', filename=file) }}" rel="stylesheet" type="text/css"  />
    {%- endfor -%}
{%- endif %}
{% if internal_css %}
<style type="text/css">
    {{internal_css}}
</style>
{% endif %}
{%- if hlinks -%}
{%- for item in hlinks -%}
<link 
        {%- if item.href %} href="{{item.href}}"{% endif -%}
        {%- if item.rel %} rel="{{item.rel}}"{% endif -%} 
        {%- if item.type %} type="{{item.type}}"{% endif -%}
        {%- if item.media %} type="{{item.media}}"{% endif -%}
        {%- if item.hreflang %} type="{{item.hreflang}}"{% endif -%}
        {%- if item.charset %} type="{{item.charset}}"{% endif -%}
        >
{% endfor %}
{%- endif -%}
</head>
<body>
<div id="wrap">
    <div id="main">
        <div id="nav">
           <ul id="nav">
            {%- for item in navigation.navigation.itervalues() -%}
                <li><a href="
                {%- if item.url -%}{{item.url}}
                {%- elif item.urlfor -%}
                    {%- if item.urlfor == "source" -%}
                        {{ url_for(item.urlfor, page=navigation.page) }}
                    {%- else -%}
                        {{ url_for(item.urlfor) }}
                    {%- endif -%}
                {%- else -%}
                    {{ url_for('staticpage', page=item.base) }}
                {%- endif -%}
                
                {%- if item.rel -%}
                         " rel="{{item.rel}} 
                    {%- endif -%}
                ">{{item.link_text}}</a></li>
                {% endfor -%}
            </ul>
        </div>
        {% block content %}{% endblock %}
           </div>
</div>
<div id="footer">
    <p id="footer">
    <!-- footer goes here -->
    {% if footer %}
        {{footer}}
    {% endif %}

    </p>
</div>
</body>
</html>

home.html

{% extends "base.html" %}
    {% block content %}  
    <div> 
            {% if heading %}<h1>{{heading}}</h1>{% endif %}
        </div> 
        <div id="messages">
            <hr>
            {% with messages = get_flashed_messages() %}
                {% if messages %}
                    {% for message in messages %}
                        {{ message }}
                    {% endfor %}
                {% endif %}
            {% endwith %}
        </div>
    <div id="home">
        {{contents}}
    </div>
    {% endblock %}