Mercurial > cgi-bin > hgweb.cgi > tincan
changeset 2:ca6f8ca38cf2 draft
Another backup commit.
author | David Barts <n5jrn@me.com> |
---|---|
date | Sun, 12 May 2019 22:51:34 -0700 |
parents | 94b36e721500 |
children | c6902cded64d |
files | pspx.html tincan.py |
diffstat | 2 files changed, 183 insertions(+), 31 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pspx.html Sun May 12 22:51:34 2019 -0700 @@ -0,0 +1,80 @@ +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8"> + <title>.pspx Template Files</title> + <style type="text/css"> + .kbd { font-family: monospace; } + </style> + </head> + <body> + <h1>.pspx Template Files</h1> + <h2>Introduction</h2> + Files ending in a <span class="kbd">.pspx</span> extension define both + routes and templates. The files have two parts: a header and a body.<br> + <h2>The Header</h2> + <p>The header is optional and consists of lines starting with an octothorpe + (#) character. With the exception of the <code>#rem </code>header, all + header lines may appear only once in a given file.</p> + <dl> + <dt><code>#errors</code></dt> + <dd>This is an error page which handles the specified HTTP error codes. + The codes are specified in numeric form, separated by whitespace. If no + error codes are specified, this page handles all possible HTTP error + codes.</dd> + <dt><code>#forward</code></dt> + <dd>Ignore everything else in this template (and any code-behind + associated with it), using the specified route to serve it instead. The + route specified with <code>#forward</code> may itself contain a <code>#forward</code>, + but attempts to create a <code>#forward</code> loop are not allowed and + will cause a <span class="kbd">TinCanError</span> to be raised. Note + that unlike calls to <code>app.forward()</code>, the <code>#forward</code> + header is resolved at route-creation time; no extra processing will + happen at request time.</dd> + <dt><code>#hidden</code></dt> + <dd>This is a hidden page; do not create a route for it. The page can only + be displayed by a forward.</dd> + <dt><code>#methods</code></dt> + <dd>A list of HTTP request methods, separated by whitespace, follows. The route will + allow all specified methods. Not specifying this line is equivalent to + specifying <code>#methods GET</code>.</dd> + <dt><code>#python</code></dt> + <dd>What follows is the name of the Python file containing the code-behind + for this route. If not specified, the code-behind will be in a file with + the same name but an extension of <span class="kbd">.py</span>.</dd> + <dt><code>#rem</code></dt> + <dd>The rest of the line is treated as a remark (comment) and is ignored.</dd> + <dt><code>#template</code></dt> + <dd>Ignore the body of this file and instead use the template in the body + of the specified file, which must end in <span class="kbd">.pspx</span>.</dd> + </dl> + <p>It is possible to include whitespace and special characters in arguments + to the <code>#forward</code>, <code>#python</code>, and <code>#template</code> + headers by using standard Python string quoting and escaping methods. For + example, <code>#python "space case.py"</code>.</p> + <h3>Error Pages</h3> + <p>Error pages supersede the standard Bottle error handling, and are created + by using the <code>#error</code> page header. <em>Error pages have no + associated code-behind;</em> they consist of templates only. Error page + templates are provided with two variables when rendering:</p> + <dl> + <dt><code>e</code></dt> + <dd>The <code>bottle.HTTPError</code> object associated with this error.</dd> + <dt><code>request</code></dt> + <dd>The <code>bottle.Request</code> object associated with this error.</dd> + </dl> + <p>The behavior of specifying multiple error pages for the same error code + is undefined; doing so is best avoided.</p> + <h2>The Body</h2> + <p>The body begins with the first line that <em>does not</em> start with <code>#</code> + and has the exact same syntax that the templates are in for this webapp. + By default, Chameleon templates are used. Cheetah, Jinja2, Mako, and + Bottle SimpleTemplate templates are also supported, provided the webapp + was launched to use them. (Only one template style per webapp is + supported.)</p> + <p>In order to make line numbers match file line numbers for reported + errors, the template engine will be passed a blank line for each header + line encountered. TinCan will strip out all leading blank lines when + rendering its responses.</p> + </body> +</html>
--- a/tincan.py Sun May 12 19:19:40 2019 -0700 +++ b/tincan.py Sun May 12 22:51:34 2019 -0700 @@ -8,14 +8,14 @@ import ast import binascii from base64 import b16encode, b16decode -import importlib, py_compile +import importlib import io +import py_compile +from stat import S_ISDIR, S_ISREG import bottle -# C l a s s e s - -# Exceptions +# E x c e p t i o n s class TinCanException(Exception): """ @@ -51,6 +51,8 @@ """ pass +# T e m p l a t e s +# # Template (.pspx) files. These are standard templates for a supported # template engine, but with an optional set of header lines that begin # with '#'. @@ -101,7 +103,7 @@ """ Parses and represents a set of header lines. """ - _NAMES = [ "error", "forward", "methods", "python", "template" ] + _NAMES = [ "errors", "forward", "methods", "python", "template" ] _FNAMES = [ "hidden" ] def __init__(self, string): @@ -148,6 +150,8 @@ # Update this object setattr(self, name, param) +# C h a m e l e o n +# # Support for Chameleon templates (the kind TinCan uses by default). class ChameleonTemplate(bottle.BaseTemplate): @@ -170,7 +174,7 @@ chameleon_template = functools.partial(template, template_adapter=ChameleonTemplate) chameleon_view = functools.partial(view, template_adapter=ChameleonTemplate) -# Utility functions, used in various places. +# U t i l i t i e s def _normpath(base, unsplit): """ @@ -223,6 +227,8 @@ raise TinCanError("{0}: invalid forward to {1!r}".format(source, target)) from e raise exc +# C o d e B e h i n d +# # Represents the code-behind of one of our pages. This gets subclassed, of # course. @@ -261,15 +267,15 @@ continue ret[name] = value +# R o u t e s +# # Represents a route in TinCan. Our launcher creates these on-the-fly based # on the files it finds. -EXTENSION = ".pspx" -CONTENT = "text/html" -_WINF = "WEB-INF" -_BANNED = set([_WINF]) -_CODING = "utf-8" -_CLASS = "Page" +_ERRMIN = 400 +_ERRMAX = 599 +_PEXTEN = ".py" +_TEXTEN = ".pspx" _FLOOP = "tincan.forwards" _FORIG = "tincan.origin" @@ -292,22 +298,20 @@ 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._python = name + _PEXTEN self._content = CONTENT - self._fspath = os.path.join(launcher.fsroot, *subdir, name + EXTENSION) - self._urlpath = self._urljoin(launcher.urlroot, *subdir, name + EXTENSION) + self._fspath = os.path.join(launcher.fsroot, *subdir, name + _TEXTEN) + self._urlpath = self._urljoin(launcher.urlroot, *subdir, name + _TEXTEN) 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): + def launch(self): """ Launch a single page. """ @@ -326,15 +330,20 @@ if hidden: return # If this is an error page, register it as such. - if self._header.error is not None: + if self._header.errors is not None: + if self._header.template is not None: + tpath = os.path.join(self._fsroot, *self._splitpath(self._header.template)) + self._template = try: - errors = [ int(i) for i in self._header.error.split() ] + errors = [ int(i) for i in self._header.errors.split() ] except ValueError as e: - raise TinCanError("{0}: bad #error line".format(self._urlpath)) from e + raise TinCanError("{0}: bad #errors line".format(self._urlpath)) from e if not errors: - errors = range(400, 600) + errors = range(_ERRMIN, _ERRMAX+1) route = TinCanErrorRoute(self._tclass(source=self._template.body)) for error in errors: + if error < _ERRMIN or error > _ERRMAX: + raise TinCanError("{0}: bad #errors code".format(self._urlpath)) self._app.error(code=error, callback=route) return # this implies #hidden # Get methods for this route @@ -344,8 +353,8 @@ 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) + if not self._header.python.endswith(_PEXTEN): + raise TinCanError("{0}: #python files must end in {1}", self._urlpath, _PEXTEN) 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)) @@ -360,7 +369,6 @@ 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) @@ -398,10 +406,10 @@ except IndexError as e: raise TinCanError("{0}: invalid #forward".format(self._urlpath)) from e name, ext = os.path.splitext(rname)[1] - if ext != EXTENSION: + if ext != _TEXTEN: raise TinCanError("{0}: invalid #forward".format(self._urlpath)) self._subdir = rlist - self._python = name + ".py" + self._python = name + _PEXTEN self._fspath = os.path.join(self._fsroot, *self._subdir, rname) self._urlpath = self._urljoin(*self._subdir, rname) @@ -416,10 +424,10 @@ This gets called by the framework AFTER the page is launched. """ target = None + obj = self._class(bottle.request, bottle.response) try: - obj = self._class(bottle.request, bottle.response) obj.handle() - return self._body.render(obj.export()) + return self._body.render(obj.export()).lstrip('\n') except ForwardException as fwd: target = fwd.target if target is None: @@ -427,10 +435,10 @@ # We get here if we are doing a server-side programmatic # forward. environ = bottle.request.environ - if _FLOOP not in environ: - environ[_FLOOP] = set() if _FORIG not in environ: environ[_FORIG] = self._urlpath + if _FLOOP not in environ: + environ[_FLOOP] = set([self._urlpath]) elif target in environ[_FLOOP]: TinCanError("{0}: forward loop detected".format(environ[_FORIG])) environ[_FLOOP].add(target) @@ -450,3 +458,67 @@ if not callable(value): ret[name] = value return ret + +# L a u n c h e r + +_WINF = "WEB-INF" +_BANNED = set([_WINF]) + +class _Launcher(object): + """ + Helper class for launching webapps. + """ + def __init__(self, fsroot, urlroot, tclass): + """ + Lightweight constructor. The real action happens in .launch() below. + """ + self.fsroot = fsroot + self.urlroot = urlroot + self.tclass = tclass + self.app = None + + def launch(self): + """ + Does the actual work of launching something. XXX - modifies sys.path + and never un-modifies it. + """ + # Sanity checks + if not self.urlroot.startswith("/"): + raise TinCanError("urlroot must be absolute") + if not os.path.isdir(self.fsroot): + raise TinCanError("no such directory: {0!r}".format(self.fsroot)) + # Make WEB-INF, if needed + winf = os.path.join(self.fsroot, _WINF) + lib = os.path.join(winf, "lib") + for i in [ winf, lib ]: + if not os.path.isdir(i): + os.mkdir(i) + # Add our private lib directory to sys.path + sys.path.insert(1, os.path.abspath(lib)) + # Do what we gotta do + self.app = TinCan() + self._launch([]) + return self.app + + def _launch(self, subdir): + for entry in os.listdir(os.path.join(self.fsroot, *subdir)): + if not subdir and entry in _BANNED: + continue + etype = os.stat(os.path.join(self.fsroot, *subdir, entry)).st_mode + if S_ISREG(etype): + ename, eext = os.path.splitext(entry) + if eext != _TEXTEN: + continue # only look at interesting files + route = TinCanRoute(self, ename, subdir) + page.launch() + elif S_ISDIR(etype): + self._launch(subdir + [entry]) + +def launch(fsroot=None, urlroot='/', tclass=ChameleonTemplate): + """ + Launch and return a TinCan webapp. Does not run the app; it is the + caller's responsibility to call app.run() + """ + if fsroot is None: + fsroot = os.getcwd() + return _Launcher(fsroot, urlroot, tclass).launch()