Mercurial > cgi-bin > hgweb.cgi > tincan
diff tincan.py @ 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 |
line wrap: on
line diff
--- /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