diff tincan.py @ 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 ce67eac10fc7
children df27cf08c093
line wrap: on
line diff
--- 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