changeset 0:e726fafcffac draft

For backup purposes, UNFINISHED!!
author David Barts <n5jrn@me.com>
date Sun, 12 May 2019 15:26:28 -0700
parents
children 94b36e721500
files README.html tincan.py
diffstat 2 files changed, 486 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README.html	Sun May 12 15:26:28 2019 -0700
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+    <title>Introducing TinCan</title>
+    <style type="text/css">
+      .kbd { font-family: monospace; }
+    </style>
+  </head>
+  <body>
+    <h1>Introducing TinCan, a “Code-Behind” MVC Framework for Bottle</h1>
+    <h2>Introduction</h2>
+    <p>TinCan is a Python 3 code-behind web framework implemented in the Bottle
+      microframework. As with Bottle, all the code is in one module, and there
+      is no direct dependency on anything that is not part of the standard
+      Python library (except of course for Bottle itself).</p>
+    <p>The default templating engine for TinCan is <a href="https://chameleon.readthedocs.io/en/latest/">Chameleon</a>.
+      TinCan adds Chameleon as a fully-supported templating engine for Bottle.
+      Any template engine supported by Bottle can be used to render TinCan
+      Pages.</p>
+    <h2>Why Do This?</h2>
+    <p>In short, there is too much repeating oneself in most all Python web
+      frameworks (and this includes Bottle). One is always saying “this is
+      controller <span class="kbd">foo</span>, whose view is in the template <span
+        class="kbd">foo.pt</span>, at route <span class="kbd">/foo</span>.”</p>
+    <p>That’s a lot more busywork than just writing <span class="kbd">foo.php</span>
+      or <span class="kbd">foo.cgi</span>, so many simple webapps end up being
+      implemented via the latter means. That’s unfortunate, as CGI isn’t very
+      resource-efficient, and there’s much nicer languages to code in than PHP
+      (such as Python :-) ). Worst of all, you now have logic and presentation
+      all scrambled together, never a good idea.</p>
+    <p>What if, instead, you could write <span class="kbd">foo.pspx</span> and
+      <span class="kbd">foo.py</span>, and a framework would automatically
+      create the <span class="kbd">/foo.pspx</span> route for you, much like
+      ASP.NET or JSP would for a <span class="kbd">.aspx</span> or <span class="kbd">.jsp</span>
+      file? The matching code-behind in the <span class="kbd">.py</span> file
+      would be easily detected (same name, different extension) and
+      automatically associated with the template, of course. You could focus on
+      writing pages instead of repeating yourself saying the obvious over and
+      over again. </p>
+    <p>This is what TinCan lets you do.</p>
+    <h2>Hang On, Code-Behind Isn’t MVC!</h2>
+    <p>Why <em>isn’t</em> it? The model, as always, is the data and containing
+      core business logic. The template file defines the view presented to the
+      user, and the code-behind is the intermediary between the two. A
+      controller by any other name…</p>
+    <h2>How Can There Be Multiple Views for One Controller?</h2>
+    <p>Easily. Take a look at the <code>#python</code> header directive. </p>
+    <h2>Multiple Controllers for One View?</h2>
+    <p>Personally, I don’t think this is the best of ideas. Just because two
+      controllers might be able to share a view <em>now</em> does not mean they
+      will continue to in the future. Then you change one (controller, view)
+      pair and another controller someplace else breaks!</p>
+    <p>However, if you insist, check out the <code>#template</code> and <code>#hidden</code>
+      header directives. </p>
+    <h2>But This Causes Less SEO-Friendly Routes!</h2>
+    <p>First, this is not always important. Sometimes, all you want to do is get
+      a small, simple, special-purpose, site up and running with a minimum of
+      busywork. Why should you be forced to do more work just because that extra
+      work benefits someone <em>else</em>?</p>
+    <p>Second, when it is, you can always use <code>RewriteRule</code> (Apache)
+      <code>rewrite</code> (nginx), or the equivalent in your favorite Web
+      server, and code your templates to use the SEO-friendly version of your
+      URL’s. With TinCan sitting behind a good, production-grade web server, you
+      get the best of both worlds: fast, simple deployment when you want it, and
+      SEO-friendly URL’s when you want it. </p>
+    <h2>But What about Routing Things Based on Both Path and Method?</h2>
+    <p>That’s easy enough to do, as TinCan is implemented on top of Bottle. You
+      can add your Bottle routes, using the <code>@route</code> decorator on
+      your controller methods, same as always. Just stick them in the same
+      start-up script you use to launch your TinCan files.</p>
+    <p>If for some reason you don’t want to mess with manually creating routes
+      and associating them with controllers in Bottle (even in cases like this
+      where it arguably makes sense), and want to do <em>everything</em> the
+      TinCan way, you can create a set of hidden (using the <code>#hidden</code>
+      directive) pages and a main dummy page whose code-behind forwards (<code>page.request.app.forward</code>)
+      to the appropriate hidden page depending on request method. </p>
+    <h2>What about Launching Multiple TinCan Webapps?</h2>
+    <p>It works just as well (and just as poorly) as launching multiple Bottle
+      webapps. Note that the big limitation here is Python’s module subsystem;
+      there is only one. Thus, all webapps share the same module path. There is
+      no way to have one webapp using an older version of a given module served
+      by the same server as another using a newer version, save renaming one of
+      the modules. This is a Python issue, not a Bottle issue or a TinCan issue.</p>
+    <p>Note that TinCan bypasses the Python module cache and manages its own
+      importing of code-behind files, so there is no problem if you have
+      multiple webapps using the same relative URL paths. TinCan will keep all
+      those code-behind files straight; it will not confuse one webapp’s <span
+        class="kbd">/index.py</span> with another’s.</p>
+    <h2>What about Bottle Plugins?</h2>
+    <p>I am working on adding support for these.</p>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tincan.py	Sun May 12 15:26:28 2019 -0700
@@ -0,0 +1,393 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# As with Bottle, it's all in one big, ugly file. For now.
+
+# I m p o r t s
+
+import os, sys
+import ast
+import binascii
+from base64 import b16encode, b16decode
+import importlib, py_compile
+import io
+
+import bottle
+
+# C l a s s e s
+
+# Exceptions
+
+class TinCanException(Exception):
+    """
+    The parent class of all exceptions we raise.
+    """
+    pass
+
+class TemplateHeaderException(TinCanException):
+    """
+    Raised upon encountering a syntax error in the template headers.
+    """
+    def __init__(self, message, line):
+        super().__init__(message, line)
+        self.message = message
+        self.line = line
+
+    def __str__(self):
+        return "Line {0}: {1}".format(self.line, self.message)
+
+class ForwardException(TinCanException):
+    """
+    Raised to effect the flow control needed to do a forward (server-side
+    redirect). It is ugly to do this, but other Python frameworks do and
+    there seems to be no good alternative.
+    """
+    def __init__(self, target):
+        self.target = target
+
+class TinCanError(TinCanException):
+    """
+    General-purpose exception thrown by TinCan when things go wrong, often
+    when attempting to launch webapps.
+    """
+    pass
+
+# Template (.pspx) files. These are standard templates for a supported
+# template engine, but with an optional set of header lines that begin
+# with '#'.
+
+class TemplateFile(object):
+    """
+    Parse a template file into a header part and the body part. The header
+    is always a leading set of lines, each starting with '#', that is of the
+    same format regardless of the template body. The template body varies
+    depending on the selected templating engine. The body part has
+    each header line replaced by a blank line. This preserves the overall
+    line numbering when processing the body. The added newlines are normally
+    stripped out before the rendered page is sent back to the client.
+    """
+    def __init__(self, raw, encoding='utf-8'):
+        if isinstance(raw, io.TextIOBase):
+            self._do_init(raw)
+        elif isinstance(raw, str):
+            with open(raw, "r", encoding=encoding) as fp:
+                self._do_init(fp)
+        else:
+            raise TypeError("Expecting a string or Text I/O object.")
+
+    def _do_init(self, fp):
+        self._hbuf = []
+        self._bbuf = []
+        self._state = self._header
+        while True:
+            line = fp.readline()
+            if line == '':
+                break
+            self._state(line)
+        self.header = ''.join(self._hbuf)
+        self.body = ''.join(self._bbuf)
+
+    def _header(self, line):
+        if not line.startswith('#'):
+            self._state = self._body
+            self._state(line)
+            return
+        self._hbuf.append(line)
+        self._bbuf.append("\n")
+
+    def _body(self, line):
+        self._bbuf.append(line)
+
+class TemplateHeader(object):
+    """
+    Parses and represents a set of header lines.
+    """
+    _NAMES = [ "error", "forward", "methods", "python", "template" ]
+    _FNAMES = [ "hidden" ]
+
+    def __init__(self, string):
+        # Initialize our state
+        for i in self._NAMES:
+            setattr(self, i, None)
+        for i in self._FNAMES:
+            setattr(self, i, False)
+        # Parse the string
+        count = 0
+        nameset = set(self._NAMES + self._FNAMES)
+        seen = set()
+        lines = string.split("\n")
+        if lines and lines[-1] == "":
+            del lines[-1]
+        for line in lines:
+            # Get line
+            count += 1
+            if not line.startswith("#"):
+                raise TemplateHeaderException("Does not start with '#'.", count)
+            try:
+                rna, rpa = line.split(maxsplit=1)
+            except ValueError:
+                raise TemplateHeaderException("Missing parameter.", count)
+            # Get name, ignoring remarks.
+            name = rna[1:]
+            if name == "rem":
+                continue
+            if name not in nameset:
+                raise TemplateHeaderException("Invalid directive: {0!r}".format(rna), count)
+            if name in seen:
+                raise TemplateHeaderException("Duplicate {0!r} directive.".format(rna), count)
+            seen.add(name)
+            # Flags
+            if name in self._FLAGS:
+                setattr(self, name, True)
+                continue
+            # Get parameter
+            param = rpa.strip()
+            for i in [ "'", '"']:
+                if param.startswith(i) and param.endswith(i):
+                    param = ast.literal_eval(param)
+                    break
+            # Update this object
+            setattr(self, name, param)
+
+# Support for Chameleon templates (the kind TinCan uses by default).
+
+class ChameleonTemplate(bottle.BaseTemplate):
+    def prepare(self, **options):
+        from chameleon import PageTemplate, PageTemplateFile
+        if self.source:
+            self.tpl = chameleon.PageTemplate(self.source,
+                encoding=self.encoding, **options)
+        else:
+            self.tpl = chameleon.PageTemplateFile(self.filename,
+                encoding=self.encoding, search_path=self.lookup, **options)
+
+    def render(self, *args, **kwargs):
+        for dictarg in args:
+            kwargs.update(dictarg)
+        _defaults = self.defaults.copy()
+        _defaults.update(kwargs)
+        return self.tpl.render(**_defaults)
+
+chameleon_template = functools.partial(template, template_adapter=ChameleonTemplate)
+chameleon_view = functools.partial(view, template_adapter=ChameleonTemplate)
+
+# Utility functions, used in various places.
+
+def _normpath(base, unsplit):
+    """
+    Split, normalize and ensure a possibly relative path is absolute. First
+    argument is a list of directory names, defining a base. Second
+    argument is a string, which may either be relative to that base, or
+    absolute. Only '/' is supported as a separator.
+    """
+    scratch = unsplit.strip('/').split('/')
+    if not unsplit.startswith('/'):
+        scratch = base + scratch
+    ret = []
+    for i in scratch:
+        if i == '.':
+            continue
+        if i == '..':
+            ret.pop()  # may raise IndexError
+            continue
+        ret.append(i)
+    return ret
+
+def _mangle(string):
+    """
+    Turn a possibly troublesome identifier into a mangled one.
+    """
+    first = True
+    ret = []
+    for ch in string:
+        if ch == '_' or not (ch if first else "x" + ch).isidentifier():
+            ret.append('_')
+            ret.append(b16encode(ch.encode("utf-8")).decode("us-ascii"))
+        else:
+            ret.append(ch)
+        first = False
+    return ''.join(ret)
+
+# The TinCan class. Simply a Bottle webapp that contains a forward method, so
+# the code-behind can call request.app.forward().
+
+class TinCan(bottle.Bottle):
+    def forward(self, target):
+        """
+        Forward this request to the specified target route.
+        """
+        source = bottle.request.environ['PATH_INFO']
+        base = source.strip('/').split('/')[:-1]
+        try:
+            exc = ForwardException('/' + '/'.join(_normpath(base, target)))
+        except IndexError as e:
+            raise TinCanError("{0}: invalid forward to {1!r}".format(source, target)) from e
+        raise exc
+
+# Represents the code-behind of one of our pages. This gets subclassed, of
+# course.
+
+class Page(object):
+    # Non-private things we refuse to export anyhow.
+    __HIDDEN = set([ "request", "response" ])
+
+    def __init__(self, req, resp):
+        """
+        Constructor. This is a lightweight operation.
+        """
+        self.request = req  # app context is request.app in Bottle
+        self.response = resp
+
+    def handle(self):
+        """
+        This is the entry point for the code-behind logic. It is intended
+        to be overridden.
+        """
+        pass
+
+    def export(self):
+        """
+        Export template variables. The default behavior is to export all
+        non-hidden non-callables that don't start with an underscore,
+        plus a an export named page that contains this object itself.
+        This method can be overridden if a different behavior is
+        desired. It should always return a dict or dict-like object.
+        """
+        ret = { "page": self }  # feature: will be clobbered if self.page exists
+        for name in dir(self):
+            if name in self.__HIDDEN or name.startswith('_'):
+                continue
+            value = getattr(self, name)
+            if callable(value):
+                continue
+            ret[name] = value
+
+# Represents a route in TinCan. Our launcher creates these on-the-fly based
+# on the files it finds.
+
+class TinCanRoute(object):
+    """
+    A route created by the TinCan launcher.
+    """
+    def __init__(self, launcher, name, subdir):
+        self._plib = launcher.plib
+        self._fsroot = launcher.fsroot
+        self._urlroot = launcher.urlroot
+        self._name = name
+        self._python = name + ".py"
+        self._content = CONTENT
+        self._fspath = os.path.join(launcher.fsroot, *subdir, name + EXTENSION)
+        self._urlpath = self._urljoin(launcher.urlroot, *subdir, name + EXTENSION)
+        self._origin = self._urlpath
+        self._subdir = subdir
+        self._seen = set()
+        self._class = None
+        self._tclass = launcher.tclass
+        self._app = launcher.app
+
+    def launch(self, config):
+        """
+        Launch a single page.
+        """
+        # Build master and header objects, process #forward directives
+        hidden = None
+        while True:
+            self._template = TemplateFile(self._fspath)
+            self._header = TemplateHeader(self._template.header)
+            if hidden is None:
+                hidden = self._header.hidden
+            if self._header.forward is None:
+                break
+            self._redirect()
+        # If this is a hidden page, we ignore it for now, since hidden pages
+        # don't get routes made for them.
+        if hidden:
+            return
+        # Get methods for this route
+        if self._header.methods is None:
+            methods = [ 'GET' ]
+        else:
+            methods = [ i.upper() for i in self._header.methods.split() ]
+        # Process other header entries
+        if self._header.python is not None:
+            if not self._header.python.endswith('.py'):
+                raise TinCanError("{0}: #python files must end in .py", self._urlpath)
+            self._python = self._header.python
+        # Obtain a class object by importing and introspecting a module.
+        pypath = os.path.normpath(os.path.join(self._fsroot, *self._subdir, self._python))
+        pycpath = pypath + 'c'
+        try:
+            pyctime = os.stat(pycpath).st_mtime
+        except OSError:
+            pyctime = 0
+        if pyctime < os.stat(pypath).st_mtime:
+            try:
+                py_compile.compile(pypath, cfile=pycpath)
+            except Exception as e:
+                raise TinCanError("{0}: error compiling".format(pypath)) from e
+        try:
+            self._mangled = self._manage_module()
+            spec = importlib.util.spec_from_file_location(_mangle(self._name), cpath)
+            mod =  importlib.util.module_from_spec(spec)
+            spec.loader.exec_module(mod)
+        except Exception as e:
+            raise TinCanError("{0}: error importing".format(pycpath)) from e
+        self._class = None
+        for i in dir(mod):
+            v = getattr(mod, i)
+            if issubclass(v, Page):
+                if self._class is not None:
+                    raise TinCanError("{0}: contains multiple Page classes", pypath)
+                self._class = v
+        # Build body object (Chameleon template)
+        self._body = self._tclass(source=self._template.body)
+        self._body.prepare()
+        # Register this thing with Bottle
+        print("adding route:", self._origin)  # debug
+        self._app.route(self._origin, methods, self)
+
+    def _splitpath(self, unsplit):
+        return _normpath(self._subdir, unsplit)
+
+    def _redirect(self):
+        if self._header.forward in self._seen:
+            raise TinCanError("{0}: #forward loop".format(self._origin))
+        self._seen.add(self._header.forward)
+        try:
+            rlist = self._splitpath(self._header.forward)
+            rname = rlist.pop()
+        except IndexError as e:
+            raise TinCanError("{0}: invalid #forward".format(self._urlpath)) from e
+        name, ext = os.path.splitext(rname)[1]
+        if ext != EXTENSION:
+            raise TinCanError("{0}: invalid #forward".format(self._urlpath))
+        self._subdir = rlist
+        self._python = name + ".py"
+        self._fspath = os.path.join(self._fsroot, *self._subdir, rname)
+        self._urlpath = self._urljoin(*self._subdir, rname)
+
+    def _urljoin(self, *args):
+        args = list(args)
+        if args[0] == '/':
+            args[0] = ''
+        return '/'.join(args)
+
+    def __call__(self, request):
+        """
+        This gets called by the framework AFTER the page is launched.
+        """
+        ### needs to honor self._header.error if set
+        mod = importlib.import_module(self._mangled)
+        cls = getattr(mod, _CLASS)
+        obj = cls(request)
+        return Response(self._body.render(**self._mkdict(obj)).lstrip("\n"),
+            content_type=self._content)
+
+    def _mkdict(self, obj):
+        ret = {}
+        for name in dir(obj):
+            if name.startswith('_'):
+                continue
+            value = getattr(obj, name)
+            if not callable(value):
+                ret[name] = value
+        return ret