changeset 39:ee19984ba31d draft

Merge in the header-includes branch to the trunk.
author David Barts <n5jrn@me.com>
date Tue, 28 May 2019 20:20:19 -0700
parents e8b6ee7e5b6b (current diff) 336bc2f622e4 (diff)
children df27cf08c093
files
diffstat 3 files changed, 148 insertions(+), 37 deletions(-) [+]
line wrap: on
line diff
--- a/launch	Tue May 21 18:01:44 2019 -0700
+++ b/launch	Tue May 28 20:20:19 2019 -0700
@@ -7,7 +7,7 @@
 
 import os, sys
 from argparse import ArgumentParser
-from tincan import launch
+from tincan import launch, ENCODING
 
 # V a r i a b l e s
 
@@ -17,14 +17,19 @@
 
 parser = ArgumentParser(prog=sys.argv[0], usage="%(prog)s [options] [directory [path]]")
 opt = parser.add_argument
-opt("-b", "--bind", default="localhost", help="address to bind to")
-opt("-p", "--port", default=8080, help="port to listen on")
+opt("-b", "--bind", default="localhost", help="address to bind to (default: localhost)")
+opt("-d", "--debug", action="store_true", help="enable debug mode")
+opt("-e", "--encoding", default=ENCODING, help="encoding to use (default {0})".format(ENCODING))
+opt("-f", "--force", action="store_true", help="do not abort on errors")
+opt("-p", "--port", default=8080, help="port to listen on (default: 8080)")
 opt("directory", default=".", help="directory to serve", nargs='?')
 opt("path", default="/", help="URL path to serve", nargs='?')
 args = parser.parse_args(sys.argv[1:])
-app, errors = launch(fsroot=args.directory, urlroot=args.path)
+app, errors = launch(fsroot=args.directory, urlroot=args.path, debug=args.debug,
+    encoding=args.encoding)
 if errors:
-    sys.stderr.write("{0}: {1} error{2} detected, aborting\n".format(
-        MYNAME, errors, "" if errors == 1 else "s"))
-    sys.exit(1)
+    action = "continuing" if args.force else "aborting"
+    sys.stderr.write("{0}: {1} error{2} detected, {3}\n".format(
+        MYNAME, errors, "" if errors == 1 else "s", action))
+    if not args.force: sys.exit(1)
 app.run(host=args.bind, port=args.port)
--- a/pspx.html	Tue May 21 18:01:44 2019 -0700
+++ b/pspx.html	Tue May 28 20:20:19 2019 -0700
@@ -37,6 +37,11 @@
       <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>#load</code></dt>
+      <dd>Load the specified Chameleon template file and make the loaded
+        template available as a template variable. Useful for importing and
+        invoking macros. See the section on this directive below for more
+        information.</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
@@ -74,6 +79,30 @@
     <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>
+    <h3>Loading Templates</h3>
+    <p>The <code>#load</code> directive may be used to load additional
+      templates, e.g. ones containing macro definitions. Note that the loaded
+      files are standard Chameleon templates and <em>not</em> TinCan <code>.pspx</code>
+      files (i.e. they cannot contain any header directives); as such, loaded
+      files must have the standard <var>.pt</var> extension for Chameleon
+      template files.</p>
+    <p>In the normal case, typing <code>#load foo.pt</code> will load a file
+      relative to the same directory as the page containing the <code>#load</code>
+      directive itself. The loaded template object will be made available as a
+      template variable matching the file name sans extension (in this case, <samp>foo</samp>).
+      One can change the name of the variable created by prefixing the file
+      specification with a variable name followed by an equals sign,
+      e.g. <code>#load t=foo.pt</code>. If one places the specification inside
+      angle brackets (e.g. <code>#load &lt;t=foo.pt&gt;</code>), loaded files
+      are searched for in <code>WEB-INF/tlib</code> instead.</p>
+    <p>Finally, as is allowed for all arguments to header directives, one may
+      enclose the argument to #load inside single or double quotes and use the
+      normal Python backslash escaping.</p>
+    <h3>Using Loaded Macros</h3>
+    <p>Once a template has been loaded, it will be available as a sub-attribute
+      of the <var>macros</var> attribute of the associated template object.
+      E.g.:</p>
+    <pre>#load foo.pt<br>&lt;!DOCTYPE html&gt;<br>&lt;html&gt;<br>  &lt;head&gt;<br>    &lt;title&gt;Macro Example&lt;/title&gt;<br>  &lt;/head&gt;&lt;body&gt;<br>    &lt;p metal:use-macro="foo.macros.bar"&gt;&lt;/p&gt;<br>  &lt;/body&gt;<br>&lt;/html&gt;</pre>
     <h2>The Body</h2>
     <p>The body begins with the first line that <em>does not</em> start with <code>#</code>
       (or the first line after the <code>#end</code> directive, whichever comes
--- a/tincan.py	Tue May 21 18:01:44 2019 -0700
+++ b/tincan.py	Tue May 28 20:20:19 2019 -0700
@@ -40,6 +40,19 @@
     def __str__(self):
         return "line {0}: {1}".format(self.line, self.message)
 
+class LoadError(TinCanException):
+    """
+    Raised when we run into problems #load'ing something, usually
+    because it doesn't exist.
+    """
+    def __init__(self, message, source):
+        super().__init__(message, source)
+        self.message = message
+        self.source = source
+
+    def __str__(self):
+        return "{0}: #load error: {1}".format(self.source, self.message)
+
 class ForwardException(TinCanException):
     """
     Raised to effect the flow control needed to do a forward (server-side
@@ -116,6 +129,7 @@
     """
     _NAMES = [ "errors", "forward", "methods", "python", "template" ]
     _FNAMES = [ "hidden" ]
+    _ANAMES = [ "load" ]
 
     def __init__(self, string):
         # Initialize our state
@@ -123,9 +137,11 @@
             setattr(self, i, None)
         for i in self._FNAMES:
             setattr(self, i, False)
+        for i in self._ANAMES:
+            setattr(self, i, [])
         # Parse the string
         count = 0
-        nameset = set(self._NAMES + self._FNAMES)
+        nameset = set(self._NAMES + self._FNAMES + self._ANAMES)
         seen = set()
         lines = string.split("\n")
         if lines and lines[-1] == "":
@@ -150,7 +166,8 @@
                 raise TemplateHeaderError("Invalid directive: {0!r}".format(rna), count)
             if name in seen:
                 raise TemplateHeaderError("Duplicate {0!r} directive.".format(rna), count)
-            seen.add(name)
+            if name not in self._ANAMES:
+                seen.add(name)
             # Flags
             if name in self._FNAMES:
                 setattr(self, name, True)
@@ -164,21 +181,25 @@
                     param = ast.literal_eval(param)
                     break
             # Update this object
-            setattr(self, name, param)
+            if name in self._ANAMES:
+                getattr(self, name).append(param)
+            else:
+                setattr(self, name, param)
 
 # C h a m e l e o n
 #
-# Support for Chameleon templates (the kind TinCan uses by default).
+# Support for Chameleon templates (the kind TinCan uses).
 
 class ChameleonTemplate(bottle.BaseTemplate):
     def prepare(self, **options):
         from chameleon import PageTemplate, PageTemplateFile
         if self.source:
-            self.tpl = PageTemplate(self.source, encoding=self.encoding,
-                **options)
+            self.tpl = PageTemplate(self.source, **options)
         else:
             self.tpl = PageTemplateFile(self.filename, encoding=self.encoding,
                 search_path=self.lookup, **options)
+            # XXX - work around broken Chameleon decoding
+            self.tpl.default_encoding = self.encoding
 
     def render(self, *args, **kwargs):
         for dictarg in args:
@@ -305,6 +326,44 @@
         self.request = req
         self.error = err
 
+# I n c l u s i o n
+#
+# Most processing is in the TinCanRoute class; this just interprets and
+# represents arguments to the #load header directive.
+
+class _LoadedFile(object):
+    def __init__(self, raw):
+        if raw.startswith('<') and raw.endswith('>'):
+            raw = raw[1:-1]
+            self.in_lib = True
+        else:
+            self.in_lib = False
+        equals = raw.find('=')
+        if equals < 0:
+            self.vname = os.path.splitext(os.path.basename(raw))[0]
+            self.fname = raw
+        else:
+            self.vname = raw[:equals]
+            self.fname = raw[equals+1:]
+        if self.vname == "":
+            raise ValueError("empty variable name")
+        if self.fname == "":
+            raise ValueError("empty file name")
+        if not self.fname.endswith(_IEXTEN):
+            raise ValueError("file does not end in {0}".format(_IEXTEN))
+
+# Using a cache is likely to help efficiency a lot, since many pages
+# will typically #load the same standard stuff.
+_tcache = {}
+def _get_template(name, direct, coding):
+    aname = os.path.abspath(os.path.join(direct, name))
+    if aname not in _tcache:
+        tmpl = ChameleonTemplate(name=name, lookup=[direct], encoding=coding)
+        tmpl.prepare()
+        assert aname == tmpl.filename
+        _tcache[aname] = tmpl
+    return _tcache[aname]
+
 # R o u t e s
 #
 # Represents a route in TinCan. Our launcher creates these on-the-fly based
@@ -312,6 +371,7 @@
 
 _ERRMIN = 400
 _ERRMAX = 599
+_IEXTEN = ".pt"
 _PEXTEN = ".py"
 _TEXTEN = ".pspx"
 _FLOOP = "tincan.forwards"
@@ -325,9 +385,10 @@
     custom code-behind, only two variables are available to your template:
     request (bottle.Request) and error (bottle.HTTPError).
     """
-    def __init__(self, template, klass):
+    def __init__(self, template, loads, klass):
         self._template = template
         self._template.prepare()
+        self._loads = loads
         self._class = klass
 
     def __call__(self, e):
@@ -335,7 +396,9 @@
         try:
             obj = self._class(bottle.request, e)
             obj.handle()
-            return self._template.render(obj.export()).lstrip('\n')
+            tvars = self._loads.copy()
+            tvars.update(obj.export())
+            return self._template.render(tvars).lstrip('\n')
         except bottle.HTTPResponse as e:
             return e
         except Exception as e:
@@ -361,8 +424,9 @@
         self._origin = self._urlpath
         self._subdir = subdir
         self._seen = set()
-        self._tclass = launcher.tclass
         self._app = launcher.app
+        self._save_loads = launcher.debug
+        self._encoding = launcher.encoding
 
     def launch(self):
         """
@@ -406,21 +470,38 @@
             self._python_specified = True
         # Obtain a class object by importing and introspecting a module.
         self._getclass()
-        # Build body object (#template)
+        # Build body object (#template) and obtain #loads.
         if self._header.template is not None:
             if not self._header.template.endswith(_TEXTEN):
                 raise TinCanError("{0}: #template files must end in {1}".format(self._urlpath, _TEXTEN))
             try:
-                tpath = os.path.normpath(os.path.join(self._fsroot, *self._splitpath(self._header.template)))
+                rtpath = self._splitpath(self._header.template)
+                tpath = os.path.normpath(os.path.join(self._fsroot, *rtpath))
                 tfile = TemplateFile(tpath)
             except OSError as e:
                 raise TinCanError("{0}: invalid #template: {1!s}".format(self._urlpath, e)) from e
             except IndexError as e:
                 raise TinCanError("{0}: invalid #template".format(self._urlpath)) from e
-            self._body = self._tclass(source=tfile.body)
+            self._body = ChameleonTemplate(source=tfile.body, encoding=self._encoding)
         else:
-            self._body = self._tclass(source=self._template.body)
+            self._body = ChameleonTemplate(source=self._template.body, encoding=self._encoding)
         self._body.prepare()
+        # Process loads
+        self._loads = {}
+        for load in self._header.load:
+            try:
+                load = _LoadedFile(load)
+            except ValueError as e:
+                raise TinCanError("{0}: bad #load: {1!s}".format(self._urlpath, e)) from e
+            if load.in_lib:
+                fdir = os.path.join(self._fsroot, _WINF, "tlib")
+            else:
+                fdir = os.path.join(self._fsroot, *self._subdir)
+            try:
+                tmpl = _get_template(load.fname, fdir, self._encoding)
+            except Exception as e:
+                raise TinCanError("{0}: bad #load: {1!s}".format(self._urlpath, e)) from e
+            self._loads[load.vname] = tmpl.tpl
         # If this is an #errors page, register it as such.
         if oheader.errors is not None:
             self._mkerror(oheader.errors)
@@ -446,7 +527,9 @@
             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), self._class)
+        route = _TinCanErrorRoute(
+            ChameleonTemplate(source=self._template.body, encoding=self._encoding),
+            self._loads, self._class)
         for error in errors:
             if error < _ERRMIN or error > _ERRMAX:
                 raise TinCanError("{0}: bad #errors code".format(self._urlpath))
@@ -559,7 +642,9 @@
         try:
             obj = self._class(bottle.request, bottle.response)
             obj.handle()
-            return self._body.render(obj.export()).lstrip('\n')
+            tvars = self._loads.copy()
+            tvars.update(obj.export())
+            return self._body.render(tvars).lstrip('\n')
         except ForwardException as fwd:
             target = fwd.target
         except bottle.HTTPResponse as e:
@@ -590,36 +675,27 @@
         environ['route.url_args'] = args
         return route.call(**args)
 
-    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
-
 # L a u n c h e r
 
 _WINF = "WEB-INF"
 _BANNED = set([_WINF])
+ENCODING = "utf-8"
 
 class _Launcher(object):
     """
     Helper class for launching webapps.
     """
-    def __init__(self, fsroot, urlroot, tclass, logger):
+    def __init__(self, fsroot, urlroot, logger):
         """
         Lightweight constructor. The real action happens in .launch() below.
         """
         self.fsroot = fsroot
         self.urlroot = urlroot
-        self.tclass = tclass
         self.logger = logger
         self.app = None
         self.errors = 0
         self.debug = False
+        self.encoding = ENCODING
 
     def launch(self):
         """
@@ -680,15 +756,16 @@
     sys.stderr.write(message)
     sys.stderr.write('\n')
 
-def launch(fsroot=None, urlroot='/', tclass=ChameleonTemplate, logger=_logger):
+def launch(fsroot=None, urlroot='/', logger=_logger, debug=False, encoding=ENCODING):
     """
     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()
-    launcher = _Launcher(fsroot, urlroot, tclass, logger)
-    # launcher.debug = True
+    launcher = _Launcher(fsroot, urlroot, logger)
+    launcher.debug = debug
+    launcher.encoding = encoding
     launcher.launch()
     return launcher.app, launcher.errors