Mercurial > cgi-bin > hgweb.cgi > tincan
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