# HG changeset patch # User David Barts # Date 1559100019 25200 # Node ID ee19984ba31dd037a35ba8e2e2fde9490197a294 # Parent e8b6ee7e5b6b5bdb09ee418590c95e47f9e70823# Parent 336bc2f622e43ff87a3edfa1235a0d49fae08ceb Merge in the header-includes branch to the trunk. diff -r e8b6ee7e5b6b -r ee19984ba31d launch --- 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) diff -r e8b6ee7e5b6b -r ee19984ba31d pspx.html --- 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 @@
#hidden
This is a hidden page; do not create a route for it. The page can only be displayed by a forward.
+
#load
+
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.
#methods
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 @@

Code-behind is optional for both normal and error page templates. If code-behind is not provided, TinCan will use the Page or ErrorPage class as appropriate.

+

Loading Templates

+

The #load directive may be used to load additional + templates, e.g. ones containing macro definitions. Note that the loaded + files are standard Chameleon templates and not TinCan .pspx + files (i.e. they cannot contain any header directives); as such, loaded + files must have the standard .pt extension for Chameleon + template files.

+

In the normal case, typing #load foo.pt will load a file + relative to the same directory as the page containing the #load + directive itself. The loaded template object will be made available as a + template variable matching the file name sans extension (in this case, foo). + 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. #load t=foo.pt. If one places the specification inside + angle brackets (e.g. #load <t=foo.pt>), loaded files + are searched for in WEB-INF/tlib instead.

+

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.

+

Using Loaded Macros

+

Once a template has been loaded, it will be available as a sub-attribute + of the macros attribute of the associated template object. + E.g.:

+
#load foo.pt
<!DOCTYPE html>
<html>
<head>
<title>Macro Example</title>
</head><body>
<p metal:use-macro="foo.macros.bar"></p>
</body>
</html>

The Body

The body begins with the first line that does not start with # (or the first line after the #end directive, whichever comes diff -r e8b6ee7e5b6b -r ee19984ba31d tincan.py --- 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