changeset 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 84998cd4e123
files pspx.html tincan.py
diffstat 2 files changed, 115 insertions(+), 71 deletions(-) [+]
line wrap: on
line diff
--- a/pspx.html	Mon May 13 16:27:05 2019 -0700
+++ b/pspx.html	Mon May 13 20:47:51 2019 -0700
@@ -4,14 +4,14 @@
     <meta http-equiv="content-type" content="text/html; charset=UTF-8">
     <title>.pspx Template Files</title>
     <style type="text/css">
-      .kbd { font-family: monospace; }
+      var { font-family: monospace; font-style: normal; }
     </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>
+    Files ending in a <var>.pspx</var> 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
@@ -19,20 +19,19 @@
     <dl>
       <dt><code>#end</code></dt>
       <dd>Marks the last line of the headers. Needed only for templating
-        languages where lines often start with <span class="kbd">#</span>, such
-        as Cheetah.</dd>
+        languages where lines often start with <var>#</var>, such as Cheetah.</dd>
       <dt><code>#errors</code></dt>
-      <dd>Ignore other headers and make this is an error page which handles the
-        specified HTTP error codes. See the subsection on error pages below. </dd>
+      <dd>This is an error page which handles the specified HTTP error codes.
+        See the subsection on error pages below. </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>
+        will cause a <var>TinCanError</var> 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>
@@ -43,13 +42,13 @@
       <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>
+        the same name but an extension of <var>.py</var>.</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>.
-        Any headers in the referred template file are ignored.</dd>
+        of the specified file, which must end in <var>.pspx</var>. Any headers
+        in the referred template file are ignored.</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>
@@ -57,22 +56,22 @@
       example, <code>#python "space case.py"</code>.</p>
     <p>Header directives that don't take arguments as a rule simply ignore them.
       For example, <code>#end headers</code> has the same effect as <code>#end</code>.
+    </p>
     <h3>Error Pages</h3>
     <p>Error pages supersede the standard Bottle error handling, and are created
-      by using the <code>#errors</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>error</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>
+      by using the <code>#errors</code> page header. The <code>#hidden</code>
+      and <code>#method</code> header directives are ignored in error pages
+      (error pages are effectively hidden anyhow, by virtue of never having
+      normal routes created for them).</p>
     <p>The <code>#errors</code> directive takes a list of numeric error codes
       (values from 400 to 599 are allowed); the page is created to handle the
       specified errors. If no error codes are specified, the page will handle
       all errors. The behavior of specifying multiple error pages for the same
       error code is undefined; doing so is best avoided.</p>
+    <h3>Templates with No Code-Behind</h3>
+    <p>Code-behind is optional for both normal and error page templates. If
+      code-behind is not provided, TinCan will use the <var>Page</var> or <var>ErrorPage</var>
+      class as appropriate. </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.
@@ -84,5 +83,26 @@
       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>
+    <h3>Default Template Variables</h3>
+    <p>By default, regular pages will have a single template variable available:
+      <var>page</var>, which refers to the <var>Page</var> class that contains
+      the code-behind logic for this page. The pertinent <var>bottle.Request</var>
+      and <var>bottle.Response</var> objects are available to normal, non-error
+      pages as <var>page.request</var> and <var>page.response</var>,
+      respectively.</p>
+    <p>Error pages have their code-behind logic in an <var>ErrorPage</var>
+      class. In addition to the standard <var>page</var> variable, error pages
+      also have <var>request</var> and <var>error</var> variables available by
+      default, which contain copies of the pertinent <var>bottle.Request</var>
+      and <var>bottle.HTTPError</var> objects respectively.</p>
+    <p>The default behavior of the <var>export</var> method (which exports
+      instance variables to template variables) in both the <var>Page</var> and
+      <var>ErrorPage</var> classes is to export, in addition to the default
+      variables, every user-created instance attribute that is not callable and
+      whose name does not start with an underscore. This behavior can be changed
+      if desired by overriding that method.</p>
+    <p>Note that if for some reason you create an instance variable named <var>page</var>,
+      it will overwrite the standard template variable by the same name.</p>
+    <p></p>
   </body>
 </html>
--- 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: