0
|
1 #!/usr/bin/env python3
|
|
2 # -*- coding: utf-8 -*-
|
|
3 # As with Bottle, it's all in one big, ugly file. For now.
|
|
4
|
|
5 # I m p o r t s
|
|
6
|
|
7 import os, sys
|
|
8 import ast
|
|
9 import binascii
|
|
10 from base64 import b16encode, b16decode
|
|
11 import importlib, py_compile
|
|
12 import io
|
|
13
|
|
14 import bottle
|
|
15
|
|
16 # C l a s s e s
|
|
17
|
|
18 # Exceptions
|
|
19
|
|
20 class TinCanException(Exception):
|
|
21 """
|
|
22 The parent class of all exceptions we raise.
|
|
23 """
|
|
24 pass
|
|
25
|
|
26 class TemplateHeaderException(TinCanException):
|
|
27 """
|
|
28 Raised upon encountering a syntax error in the template headers.
|
|
29 """
|
|
30 def __init__(self, message, line):
|
|
31 super().__init__(message, line)
|
|
32 self.message = message
|
|
33 self.line = line
|
|
34
|
|
35 def __str__(self):
|
|
36 return "Line {0}: {1}".format(self.line, self.message)
|
|
37
|
|
38 class ForwardException(TinCanException):
|
|
39 """
|
|
40 Raised to effect the flow control needed to do a forward (server-side
|
|
41 redirect). It is ugly to do this, but other Python frameworks do and
|
|
42 there seems to be no good alternative.
|
|
43 """
|
|
44 def __init__(self, target):
|
|
45 self.target = target
|
|
46
|
|
47 class TinCanError(TinCanException):
|
|
48 """
|
|
49 General-purpose exception thrown by TinCan when things go wrong, often
|
|
50 when attempting to launch webapps.
|
|
51 """
|
|
52 pass
|
|
53
|
|
54 # Template (.pspx) files. These are standard templates for a supported
|
|
55 # template engine, but with an optional set of header lines that begin
|
|
56 # with '#'.
|
|
57
|
|
58 class TemplateFile(object):
|
|
59 """
|
|
60 Parse a template file into a header part and the body part. The header
|
|
61 is always a leading set of lines, each starting with '#', that is of the
|
|
62 same format regardless of the template body. The template body varies
|
|
63 depending on the selected templating engine. The body part has
|
|
64 each header line replaced by a blank line. This preserves the overall
|
|
65 line numbering when processing the body. The added newlines are normally
|
|
66 stripped out before the rendered page is sent back to the client.
|
|
67 """
|
|
68 def __init__(self, raw, encoding='utf-8'):
|
|
69 if isinstance(raw, io.TextIOBase):
|
|
70 self._do_init(raw)
|
|
71 elif isinstance(raw, str):
|
|
72 with open(raw, "r", encoding=encoding) as fp:
|
|
73 self._do_init(fp)
|
|
74 else:
|
|
75 raise TypeError("Expecting a string or Text I/O object.")
|
|
76
|
|
77 def _do_init(self, fp):
|
|
78 self._hbuf = []
|
|
79 self._bbuf = []
|
|
80 self._state = self._header
|
|
81 while True:
|
|
82 line = fp.readline()
|
|
83 if line == '':
|
|
84 break
|
|
85 self._state(line)
|
|
86 self.header = ''.join(self._hbuf)
|
|
87 self.body = ''.join(self._bbuf)
|
|
88
|
|
89 def _header(self, line):
|
|
90 if not line.startswith('#'):
|
|
91 self._state = self._body
|
|
92 self._state(line)
|
|
93 return
|
|
94 self._hbuf.append(line)
|
|
95 self._bbuf.append("\n")
|
|
96
|
|
97 def _body(self, line):
|
|
98 self._bbuf.append(line)
|
|
99
|
|
100 class TemplateHeader(object):
|
|
101 """
|
|
102 Parses and represents a set of header lines.
|
|
103 """
|
|
104 _NAMES = [ "error", "forward", "methods", "python", "template" ]
|
|
105 _FNAMES = [ "hidden" ]
|
|
106
|
|
107 def __init__(self, string):
|
|
108 # Initialize our state
|
|
109 for i in self._NAMES:
|
|
110 setattr(self, i, None)
|
|
111 for i in self._FNAMES:
|
|
112 setattr(self, i, False)
|
|
113 # Parse the string
|
|
114 count = 0
|
|
115 nameset = set(self._NAMES + self._FNAMES)
|
|
116 seen = set()
|
|
117 lines = string.split("\n")
|
|
118 if lines and lines[-1] == "":
|
|
119 del lines[-1]
|
|
120 for line in lines:
|
|
121 # Get line
|
|
122 count += 1
|
|
123 if not line.startswith("#"):
|
|
124 raise TemplateHeaderException("Does not start with '#'.", count)
|
|
125 try:
|
|
126 rna, rpa = line.split(maxsplit=1)
|
|
127 except ValueError:
|
|
128 raise TemplateHeaderException("Missing parameter.", count)
|
|
129 # Get name, ignoring remarks.
|
|
130 name = rna[1:]
|
|
131 if name == "rem":
|
|
132 continue
|
|
133 if name not in nameset:
|
|
134 raise TemplateHeaderException("Invalid directive: {0!r}".format(rna), count)
|
|
135 if name in seen:
|
|
136 raise TemplateHeaderException("Duplicate {0!r} directive.".format(rna), count)
|
|
137 seen.add(name)
|
|
138 # Flags
|
|
139 if name in self._FLAGS:
|
|
140 setattr(self, name, True)
|
|
141 continue
|
|
142 # Get parameter
|
|
143 param = rpa.strip()
|
|
144 for i in [ "'", '"']:
|
|
145 if param.startswith(i) and param.endswith(i):
|
|
146 param = ast.literal_eval(param)
|
|
147 break
|
|
148 # Update this object
|
|
149 setattr(self, name, param)
|
|
150
|
|
151 # Support for Chameleon templates (the kind TinCan uses by default).
|
|
152
|
|
153 class ChameleonTemplate(bottle.BaseTemplate):
|
|
154 def prepare(self, **options):
|
|
155 from chameleon import PageTemplate, PageTemplateFile
|
|
156 if self.source:
|
|
157 self.tpl = chameleon.PageTemplate(self.source,
|
|
158 encoding=self.encoding, **options)
|
|
159 else:
|
|
160 self.tpl = chameleon.PageTemplateFile(self.filename,
|
|
161 encoding=self.encoding, search_path=self.lookup, **options)
|
|
162
|
|
163 def render(self, *args, **kwargs):
|
|
164 for dictarg in args:
|
|
165 kwargs.update(dictarg)
|
|
166 _defaults = self.defaults.copy()
|
|
167 _defaults.update(kwargs)
|
|
168 return self.tpl.render(**_defaults)
|
|
169
|
|
170 chameleon_template = functools.partial(template, template_adapter=ChameleonTemplate)
|
|
171 chameleon_view = functools.partial(view, template_adapter=ChameleonTemplate)
|
|
172
|
|
173 # Utility functions, used in various places.
|
|
174
|
|
175 def _normpath(base, unsplit):
|
|
176 """
|
|
177 Split, normalize and ensure a possibly relative path is absolute. First
|
|
178 argument is a list of directory names, defining a base. Second
|
|
179 argument is a string, which may either be relative to that base, or
|
|
180 absolute. Only '/' is supported as a separator.
|
|
181 """
|
|
182 scratch = unsplit.strip('/').split('/')
|
|
183 if not unsplit.startswith('/'):
|
|
184 scratch = base + scratch
|
|
185 ret = []
|
|
186 for i in scratch:
|
|
187 if i == '.':
|
|
188 continue
|
|
189 if i == '..':
|
|
190 ret.pop() # may raise IndexError
|
|
191 continue
|
|
192 ret.append(i)
|
|
193 return ret
|
|
194
|
|
195 def _mangle(string):
|
|
196 """
|
|
197 Turn a possibly troublesome identifier into a mangled one.
|
|
198 """
|
|
199 first = True
|
|
200 ret = []
|
|
201 for ch in string:
|
|
202 if ch == '_' or not (ch if first else "x" + ch).isidentifier():
|
|
203 ret.append('_')
|
|
204 ret.append(b16encode(ch.encode("utf-8")).decode("us-ascii"))
|
|
205 else:
|
|
206 ret.append(ch)
|
|
207 first = False
|
|
208 return ''.join(ret)
|
|
209
|
|
210 # The TinCan class. Simply a Bottle webapp that contains a forward method, so
|
|
211 # the code-behind can call request.app.forward().
|
|
212
|
|
213 class TinCan(bottle.Bottle):
|
|
214 def forward(self, target):
|
|
215 """
|
|
216 Forward this request to the specified target route.
|
|
217 """
|
|
218 source = bottle.request.environ['PATH_INFO']
|
|
219 base = source.strip('/').split('/')[:-1]
|
|
220 try:
|
|
221 exc = ForwardException('/' + '/'.join(_normpath(base, target)))
|
|
222 except IndexError as e:
|
|
223 raise TinCanError("{0}: invalid forward to {1!r}".format(source, target)) from e
|
|
224 raise exc
|
|
225
|
|
226 # Represents the code-behind of one of our pages. This gets subclassed, of
|
|
227 # course.
|
|
228
|
|
229 class Page(object):
|
|
230 # Non-private things we refuse to export anyhow.
|
|
231 __HIDDEN = set([ "request", "response" ])
|
|
232
|
|
233 def __init__(self, req, resp):
|
|
234 """
|
|
235 Constructor. This is a lightweight operation.
|
|
236 """
|
|
237 self.request = req # app context is request.app in Bottle
|
|
238 self.response = resp
|
|
239
|
|
240 def handle(self):
|
|
241 """
|
|
242 This is the entry point for the code-behind logic. It is intended
|
|
243 to be overridden.
|
|
244 """
|
|
245 pass
|
|
246
|
|
247 def export(self):
|
|
248 """
|
|
249 Export template variables. The default behavior is to export all
|
|
250 non-hidden non-callables that don't start with an underscore,
|
|
251 plus a an export named page that contains this object itself.
|
|
252 This method can be overridden if a different behavior is
|
|
253 desired. It should always return a dict or dict-like object.
|
|
254 """
|
|
255 ret = { "page": self } # feature: will be clobbered if self.page exists
|
|
256 for name in dir(self):
|
|
257 if name in self.__HIDDEN or name.startswith('_'):
|
|
258 continue
|
|
259 value = getattr(self, name)
|
|
260 if callable(value):
|
|
261 continue
|
|
262 ret[name] = value
|
|
263
|
|
264 # Represents a route in TinCan. Our launcher creates these on-the-fly based
|
|
265 # on the files it finds.
|
|
266
|
|
267 class TinCanRoute(object):
|
|
268 """
|
|
269 A route created by the TinCan launcher.
|
|
270 """
|
|
271 def __init__(self, launcher, name, subdir):
|
|
272 self._plib = launcher.plib
|
|
273 self._fsroot = launcher.fsroot
|
|
274 self._urlroot = launcher.urlroot
|
|
275 self._name = name
|
|
276 self._python = name + ".py"
|
|
277 self._content = CONTENT
|
|
278 self._fspath = os.path.join(launcher.fsroot, *subdir, name + EXTENSION)
|
|
279 self._urlpath = self._urljoin(launcher.urlroot, *subdir, name + EXTENSION)
|
|
280 self._origin = self._urlpath
|
|
281 self._subdir = subdir
|
|
282 self._seen = set()
|
|
283 self._class = None
|
|
284 self._tclass = launcher.tclass
|
|
285 self._app = launcher.app
|
|
286
|
|
287 def launch(self, config):
|
|
288 """
|
|
289 Launch a single page.
|
|
290 """
|
|
291 # Build master and header objects, process #forward directives
|
|
292 hidden = None
|
|
293 while True:
|
|
294 self._template = TemplateFile(self._fspath)
|
|
295 self._header = TemplateHeader(self._template.header)
|
|
296 if hidden is None:
|
|
297 hidden = self._header.hidden
|
|
298 if self._header.forward is None:
|
|
299 break
|
|
300 self._redirect()
|
|
301 # If this is a hidden page, we ignore it for now, since hidden pages
|
|
302 # don't get routes made for them.
|
|
303 if hidden:
|
|
304 return
|
|
305 # Get methods for this route
|
|
306 if self._header.methods is None:
|
|
307 methods = [ 'GET' ]
|
|
308 else:
|
|
309 methods = [ i.upper() for i in self._header.methods.split() ]
|
|
310 # Process other header entries
|
|
311 if self._header.python is not None:
|
|
312 if not self._header.python.endswith('.py'):
|
|
313 raise TinCanError("{0}: #python files must end in .py", self._urlpath)
|
|
314 self._python = self._header.python
|
|
315 # Obtain a class object by importing and introspecting a module.
|
|
316 pypath = os.path.normpath(os.path.join(self._fsroot, *self._subdir, self._python))
|
|
317 pycpath = pypath + 'c'
|
|
318 try:
|
|
319 pyctime = os.stat(pycpath).st_mtime
|
|
320 except OSError:
|
|
321 pyctime = 0
|
|
322 if pyctime < os.stat(pypath).st_mtime:
|
|
323 try:
|
|
324 py_compile.compile(pypath, cfile=pycpath)
|
|
325 except Exception as e:
|
|
326 raise TinCanError("{0}: error compiling".format(pypath)) from e
|
|
327 try:
|
|
328 self._mangled = self._manage_module()
|
|
329 spec = importlib.util.spec_from_file_location(_mangle(self._name), cpath)
|
|
330 mod = importlib.util.module_from_spec(spec)
|
|
331 spec.loader.exec_module(mod)
|
|
332 except Exception as e:
|
|
333 raise TinCanError("{0}: error importing".format(pycpath)) from e
|
|
334 self._class = None
|
|
335 for i in dir(mod):
|
|
336 v = getattr(mod, i)
|
|
337 if issubclass(v, Page):
|
|
338 if self._class is not None:
|
|
339 raise TinCanError("{0}: contains multiple Page classes", pypath)
|
|
340 self._class = v
|
|
341 # Build body object (Chameleon template)
|
|
342 self._body = self._tclass(source=self._template.body)
|
|
343 self._body.prepare()
|
|
344 # Register this thing with Bottle
|
|
345 print("adding route:", self._origin) # debug
|
|
346 self._app.route(self._origin, methods, self)
|
|
347
|
|
348 def _splitpath(self, unsplit):
|
|
349 return _normpath(self._subdir, unsplit)
|
|
350
|
|
351 def _redirect(self):
|
|
352 if self._header.forward in self._seen:
|
|
353 raise TinCanError("{0}: #forward loop".format(self._origin))
|
|
354 self._seen.add(self._header.forward)
|
|
355 try:
|
|
356 rlist = self._splitpath(self._header.forward)
|
|
357 rname = rlist.pop()
|
|
358 except IndexError as e:
|
|
359 raise TinCanError("{0}: invalid #forward".format(self._urlpath)) from e
|
|
360 name, ext = os.path.splitext(rname)[1]
|
|
361 if ext != EXTENSION:
|
|
362 raise TinCanError("{0}: invalid #forward".format(self._urlpath))
|
|
363 self._subdir = rlist
|
|
364 self._python = name + ".py"
|
|
365 self._fspath = os.path.join(self._fsroot, *self._subdir, rname)
|
|
366 self._urlpath = self._urljoin(*self._subdir, rname)
|
|
367
|
|
368 def _urljoin(self, *args):
|
|
369 args = list(args)
|
|
370 if args[0] == '/':
|
|
371 args[0] = ''
|
|
372 return '/'.join(args)
|
|
373
|
|
374 def __call__(self, request):
|
|
375 """
|
|
376 This gets called by the framework AFTER the page is launched.
|
|
377 """
|
|
378 ### needs to honor self._header.error if set
|
|
379 mod = importlib.import_module(self._mangled)
|
|
380 cls = getattr(mod, _CLASS)
|
|
381 obj = cls(request)
|
|
382 return Response(self._body.render(**self._mkdict(obj)).lstrip("\n"),
|
|
383 content_type=self._content)
|
|
384
|
|
385 def _mkdict(self, obj):
|
|
386 ret = {}
|
|
387 for name in dir(obj):
|
|
388 if name.startswith('_'):
|
|
389 continue
|
|
390 value = getattr(obj, name)
|
|
391 if not callable(value):
|
|
392 ret[name] = value
|
|
393 return ret
|