diff tincan.py @ 9:75e375b1976a draft

Error pages now can have code-behind.
author David Barts <n5jrn@me.com>
date Mon, 13 May 2019 20:47:51 -0700
parents 9aaa91247b14
children 8037bad7d5a8
line wrap: on
line diff
--- a/tincan.py	Mon May 13 16:27:05 2019 -0700
+++ b/tincan.py	Mon May 13 20:47:51 2019 -0700
@@ -26,7 +26,7 @@
     """
     pass
 
-class TemplateHeaderException(TinCanException):
+class TemplateHeaderError(TinCanException):
     """
     Raised upon encountering a syntax error in the template headers.
     """
@@ -132,7 +132,7 @@
             # Get line
             count += 1
             if not line.startswith("#"):
-                raise TemplateHeaderException("Does not start with '#'.", count)
+                raise TemplateHeaderError("Does not start with '#'.", count)
             try:
                 rna, rpa = line.split(maxsplit=1)
             except ValueError:
@@ -145,9 +145,9 @@
             if name == "end":
                 break
             if name not in nameset:
-                raise TemplateHeaderException("Invalid directive: {0!r}".format(rna), count)
+                raise TemplateHeaderError("Invalid directive: {0!r}".format(rna), count)
             if name in seen:
-                raise TemplateHeaderException("Duplicate {0!r} directive.".format(rna), count)
+                raise TemplateHeaderError("Duplicate {0!r} directive.".format(rna), count)
             seen.add(name)
             # Flags
             if name in self._FNAMES:
@@ -155,7 +155,7 @@
                 continue
             # Get parameter
             if rpa is None:
-                raise TemplateHeaderException("Missing parameter.", count)
+                raise TemplateHeaderError("Missing parameter.", count)
             param = rpa.strip()
             for i in [ "'", '"']:
                 if param.startswith(i) and param.endswith(i):
@@ -235,6 +235,8 @@
         """
         source = bottle.request.environ['PATH_INFO']
         base = source.strip('/').split('/')[:-1]
+        if bottle.request.environ.get(_FTYPE, False):
+            raise TinCanError("{0}: forward from error page".format(source))
         try:
             exc = ForwardException('/' + '/'.join(_normpath(base, target)))
         except IndexError as e:
@@ -246,17 +248,10 @@
 # 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
-
+class BasePage(object):
+    """
+    The parent class of both error and normal pages.
+    """
     def handle(self):
         """
         This is the entry point for the code-behind logic. It is intended
@@ -267,14 +262,13 @@
     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.
+        non-hidden non-callables that don't start with an underscore.
         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
+        ret = { 'page': self }
         for name in dir(self):
-            if name in self.__HIDDEN or name.startswith('_'):
+            if name in self._HIDDEN or name.startswith('_'):
                 continue
             value = getattr(self, name)
             if callable(value):
@@ -282,6 +276,30 @@
             ret[name] = value
         return ret
 
+class Page(BasePage):
+    # 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
+
+class ErrorPage(BasePage):
+    """
+    The code-behind for an error page.
+    """
+    _HIDDEN = set()
+
+    def __init__(self, req, err):
+        """
+        Constructor. This is a lightweight operation.
+        """
+        self.request = req
+        self.error = err
+
 # R o u t e s
 #
 # Represents a route in TinCan. Our launcher creates these on-the-fly based
@@ -293,20 +311,24 @@
 _TEXTEN = ".pspx"
 _FLOOP = "tincan.forwards"
 _FORIG = "tincan.origin"
+_FTYPE = "tincan.iserror"
 
 class _TinCanErrorRoute(object):
     """
-    A route to an error page. These never have code-behind, don't get
-    routes created for them, and are only reached if an error routes them
-    there. Error templates only have two variables available: e (the
-    HTTPError object associated with the error) and request.
+    A route to an error page. These don't get routes created for them,
+    and are only reached if an error routes them there. Unless you create
+    custom code-behind, only two variables are available to your template:
+    request (bottle.Request) and error (bottle.HTTPError).
     """
-    def __init__(self, template):
+    def __init__(self, template, klass):
         self._template = template
         self._template.prepare()
+        self._class = klass
 
     def __call__(self, e):
-        return self._template.render(error=e, request=bottle.request).lstrip('\n')
+        obj = self._class(bottle.request, e)
+        obj.handle()
+        return self._template.render(obj.export()).lstrip('\n')
 
 class _TinCanRoute(object):
     """
@@ -330,16 +352,17 @@
         Launch a single page.
         """
         # Build master and header objects, process #forward directives
-        hidden = None
+        hidden = oerrors = None
         while True:
+            if oerrors is not None and oerrors != self._header.errors:
+                raise TinCanError("{0}: invalid redirect")
             self._template = TemplateFile(self._fspath)
             try:
                 self._header = TemplateHeader(self._template.header)
-            except TemplateHeaderException as e:
+            except TemplateHeaderError as e:
                 raise TinCanError("{0}: {1!s}".format(self._fspath, e)) from e
+            oerrors = self._header.errors
             if hidden is None:
-                if self._header.errors is not None:
-                    break
                 hidden = self._header.hidden
             elif self._header.errors is not None:
                 raise TinCanError("{0}: #forward to #errors not allowed".format(self._origin))
@@ -348,19 +371,8 @@
             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:
+        if hidden and not self._headers.errors:
             return
-        # If this is an #errors page, register it as such.
-        if self._header.errors is not None:
-            self._mkerror()
-            return  # this implies #hidden
-        # Get #methods for this route
-        if self._header.methods is None:
-            methods = [ 'GET' ]
-        else:
-            methods = [ i.upper() for i in self._header.methods.split() ]
-            if not methods:
-                raise TinCanError("{0}: no #methods specified".format(self._urlpath))
         # Get the code-behind #python
         if self._header.python is not None:
             if not self._header.python.endswith(_PEXTEN):
@@ -378,6 +390,17 @@
         else:
             self._body = self._tclass(source=self._template.body)
         self._body.prepare()
+        # If this is an #errors page, register it as such.
+        if self._header.errors is not None:
+            self._mkerror()
+            return  # this implies #hidden
+        # Get #methods for this route
+        if self._header.methods is None:
+            methods = [ 'GET' ]
+        else:
+            methods = [ i.upper() for i in self._header.methods.split() ]
+            if not methods:
+                raise TinCanError("{0}: no #methods specified".format(self._urlpath))
         # Register this thing with Bottle
         print("adding route:", self._origin, '('+','.join(methods)+')')  # debug
         self._app.route(self._origin, methods, self)
@@ -392,7 +415,7 @@
             raise TinCanError("{0}: bad #errors line".format(self._urlpath)) from e
         if not errors:
             errors = range(_ERRMIN, _ERRMAX+1)
-        route = _TinCanErrorRoute(self._tclass(source=self._template.body))
+        route = _TinCanErrorRoute(self._tclass(source=self._template.body), self._class)
         for error in errors:
             if error < _ERRMIN or error > _ERRMAX:
                 raise TinCanError("{0}: bad #errors code".format(self._urlpath))
@@ -404,14 +427,15 @@
         except FileNotFoundError:
             return 0
         except OSError as e:
-            raise TinCanError("{0}: {1}".format(path, e.strerror)) from e
+            raise TinCanError(str(e)) from e
 
     def _getclass(self):
         pypath = os.path.normpath(os.path.join(self._fsroot, *self._splitpath(self._python)))
+        klass = ErrorPage if self._header.errors else Page
         # Give 'em a default code-behind if they don't furnish one
         pytime = self._gettime(pypath)
         if not pytime:
-            self._class = Page
+            self._class = klass
             return
         # Else load the code-behind from a .py file
         pycpath = pypath + 'c'
@@ -432,12 +456,12 @@
         self._class = None
         for i in dir(mod):
             v = getattr(mod, i)
-            if isclass(v) and issubclass(v, Page):
+            if isclass(v) and issubclass(v, klass):
                 if self._class is not None:
-                    raise TinCanError("{0}: contains multiple Page classes".format(pypath))
+                    raise TinCanError("{0}: contains multiple {1} classes".format(pypath, klass.__name__))
                 self._class = v
         if self._class is None:
-            raise TinCanError("{0}: contains no Page classes".format(pypath))
+            raise TinCanError("{0}: contains no {1} classes".format(pypath, klass.__name__))
 
     def _redirect(self):
         try: