comparison tincan.py @ 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 8037bad7d5a8
comparison
equal deleted inserted replaced
8:9aaa91247b14 9:75e375b1976a
24 """ 24 """
25 The parent class of all exceptions we raise. 25 The parent class of all exceptions we raise.
26 """ 26 """
27 pass 27 pass
28 28
29 class TemplateHeaderException(TinCanException): 29 class TemplateHeaderError(TinCanException):
30 """ 30 """
31 Raised upon encountering a syntax error in the template headers. 31 Raised upon encountering a syntax error in the template headers.
32 """ 32 """
33 def __init__(self, message, line): 33 def __init__(self, message, line):
34 super().__init__(message, line) 34 super().__init__(message, line)
130 del lines[-1] 130 del lines[-1]
131 for line in lines: 131 for line in lines:
132 # Get line 132 # Get line
133 count += 1 133 count += 1
134 if not line.startswith("#"): 134 if not line.startswith("#"):
135 raise TemplateHeaderException("Does not start with '#'.", count) 135 raise TemplateHeaderError("Does not start with '#'.", count)
136 try: 136 try:
137 rna, rpa = line.split(maxsplit=1) 137 rna, rpa = line.split(maxsplit=1)
138 except ValueError: 138 except ValueError:
139 rna = line.rstrip() 139 rna = line.rstrip()
140 rpa = None 140 rpa = None
143 if name == "rem": 143 if name == "rem":
144 continue 144 continue
145 if name == "end": 145 if name == "end":
146 break 146 break
147 if name not in nameset: 147 if name not in nameset:
148 raise TemplateHeaderException("Invalid directive: {0!r}".format(rna), count) 148 raise TemplateHeaderError("Invalid directive: {0!r}".format(rna), count)
149 if name in seen: 149 if name in seen:
150 raise TemplateHeaderException("Duplicate {0!r} directive.".format(rna), count) 150 raise TemplateHeaderError("Duplicate {0!r} directive.".format(rna), count)
151 seen.add(name) 151 seen.add(name)
152 # Flags 152 # Flags
153 if name in self._FNAMES: 153 if name in self._FNAMES:
154 setattr(self, name, True) 154 setattr(self, name, True)
155 continue 155 continue
156 # Get parameter 156 # Get parameter
157 if rpa is None: 157 if rpa is None:
158 raise TemplateHeaderException("Missing parameter.", count) 158 raise TemplateHeaderError("Missing parameter.", count)
159 param = rpa.strip() 159 param = rpa.strip()
160 for i in [ "'", '"']: 160 for i in [ "'", '"']:
161 if param.startswith(i) and param.endswith(i): 161 if param.startswith(i) and param.endswith(i):
162 param = ast.literal_eval(param) 162 param = ast.literal_eval(param)
163 break 163 break
233 """ 233 """
234 Forward this request to the specified target route. 234 Forward this request to the specified target route.
235 """ 235 """
236 source = bottle.request.environ['PATH_INFO'] 236 source = bottle.request.environ['PATH_INFO']
237 base = source.strip('/').split('/')[:-1] 237 base = source.strip('/').split('/')[:-1]
238 if bottle.request.environ.get(_FTYPE, False):
239 raise TinCanError("{0}: forward from error page".format(source))
238 try: 240 try:
239 exc = ForwardException('/' + '/'.join(_normpath(base, target))) 241 exc = ForwardException('/' + '/'.join(_normpath(base, target)))
240 except IndexError as e: 242 except IndexError as e:
241 raise TinCanError("{0}: invalid forward to {1!r}".format(source, target)) from e 243 raise TinCanError("{0}: invalid forward to {1!r}".format(source, target)) from e
242 raise exc 244 raise exc
244 # C o d e B e h i n d 246 # C o d e B e h i n d
245 # 247 #
246 # Represents the code-behind of one of our pages. This gets subclassed, of 248 # Represents the code-behind of one of our pages. This gets subclassed, of
247 # course. 249 # course.
248 250
249 class Page(object): 251 class BasePage(object):
250 # Non-private things we refuse to export anyhow. 252 """
251 __HIDDEN = set([ "request", "response" ]) 253 The parent class of both error and normal pages.
252 254 """
253 def __init__(self, req, resp):
254 """
255 Constructor. This is a lightweight operation.
256 """
257 self.request = req # app context is request.app in Bottle
258 self.response = resp
259
260 def handle(self): 255 def handle(self):
261 """ 256 """
262 This is the entry point for the code-behind logic. It is intended 257 This is the entry point for the code-behind logic. It is intended
263 to be overridden. 258 to be overridden.
264 """ 259 """
265 pass 260 pass
266 261
267 def export(self): 262 def export(self):
268 """ 263 """
269 Export template variables. The default behavior is to export all 264 Export template variables. The default behavior is to export all
270 non-hidden non-callables that don't start with an underscore, 265 non-hidden non-callables that don't start with an underscore.
271 plus a an export named page that contains this object itself.
272 This method can be overridden if a different behavior is 266 This method can be overridden if a different behavior is
273 desired. It should always return a dict or dict-like object. 267 desired. It should always return a dict or dict-like object.
274 """ 268 """
275 ret = { "page": self } # feature: will be clobbered if self.page exists 269 ret = { 'page': self }
276 for name in dir(self): 270 for name in dir(self):
277 if name in self.__HIDDEN or name.startswith('_'): 271 if name in self._HIDDEN or name.startswith('_'):
278 continue 272 continue
279 value = getattr(self, name) 273 value = getattr(self, name)
280 if callable(value): 274 if callable(value):
281 continue 275 continue
282 ret[name] = value 276 ret[name] = value
283 return ret 277 return ret
284 278
279 class Page(BasePage):
280 # Non-private things we refuse to export anyhow.
281 _HIDDEN = set([ "request", "response" ])
282
283 def __init__(self, req, resp):
284 """
285 Constructor. This is a lightweight operation.
286 """
287 self.request = req # app context is request.app in Bottle
288 self.response = resp
289
290 class ErrorPage(BasePage):
291 """
292 The code-behind for an error page.
293 """
294 _HIDDEN = set()
295
296 def __init__(self, req, err):
297 """
298 Constructor. This is a lightweight operation.
299 """
300 self.request = req
301 self.error = err
302
285 # R o u t e s 303 # R o u t e s
286 # 304 #
287 # Represents a route in TinCan. Our launcher creates these on-the-fly based 305 # Represents a route in TinCan. Our launcher creates these on-the-fly based
288 # on the files it finds. 306 # on the files it finds.
289 307
291 _ERRMAX = 599 309 _ERRMAX = 599
292 _PEXTEN = ".py" 310 _PEXTEN = ".py"
293 _TEXTEN = ".pspx" 311 _TEXTEN = ".pspx"
294 _FLOOP = "tincan.forwards" 312 _FLOOP = "tincan.forwards"
295 _FORIG = "tincan.origin" 313 _FORIG = "tincan.origin"
314 _FTYPE = "tincan.iserror"
296 315
297 class _TinCanErrorRoute(object): 316 class _TinCanErrorRoute(object):
298 """ 317 """
299 A route to an error page. These never have code-behind, don't get 318 A route to an error page. These don't get routes created for them,
300 routes created for them, and are only reached if an error routes them 319 and are only reached if an error routes them there. Unless you create
301 there. Error templates only have two variables available: e (the 320 custom code-behind, only two variables are available to your template:
302 HTTPError object associated with the error) and request. 321 request (bottle.Request) and error (bottle.HTTPError).
303 """ 322 """
304 def __init__(self, template): 323 def __init__(self, template, klass):
305 self._template = template 324 self._template = template
306 self._template.prepare() 325 self._template.prepare()
326 self._class = klass
307 327
308 def __call__(self, e): 328 def __call__(self, e):
309 return self._template.render(error=e, request=bottle.request).lstrip('\n') 329 obj = self._class(bottle.request, e)
330 obj.handle()
331 return self._template.render(obj.export()).lstrip('\n')
310 332
311 class _TinCanRoute(object): 333 class _TinCanRoute(object):
312 """ 334 """
313 A route created by the TinCan launcher. 335 A route created by the TinCan launcher.
314 """ 336 """
328 def launch(self): 350 def launch(self):
329 """ 351 """
330 Launch a single page. 352 Launch a single page.
331 """ 353 """
332 # Build master and header objects, process #forward directives 354 # Build master and header objects, process #forward directives
333 hidden = None 355 hidden = oerrors = None
334 while True: 356 while True:
357 if oerrors is not None and oerrors != self._header.errors:
358 raise TinCanError("{0}: invalid redirect")
335 self._template = TemplateFile(self._fspath) 359 self._template = TemplateFile(self._fspath)
336 try: 360 try:
337 self._header = TemplateHeader(self._template.header) 361 self._header = TemplateHeader(self._template.header)
338 except TemplateHeaderException as e: 362 except TemplateHeaderError as e:
339 raise TinCanError("{0}: {1!s}".format(self._fspath, e)) from e 363 raise TinCanError("{0}: {1!s}".format(self._fspath, e)) from e
364 oerrors = self._header.errors
340 if hidden is None: 365 if hidden is None:
341 if self._header.errors is not None:
342 break
343 hidden = self._header.hidden 366 hidden = self._header.hidden
344 elif self._header.errors is not None: 367 elif self._header.errors is not None:
345 raise TinCanError("{0}: #forward to #errors not allowed".format(self._origin)) 368 raise TinCanError("{0}: #forward to #errors not allowed".format(self._origin))
346 if self._header.forward is None: 369 if self._header.forward is None:
347 break 370 break
348 self._redirect() 371 self._redirect()
349 # If this is a #hidden page, we ignore it for now, since hidden pages 372 # If this is a #hidden page, we ignore it for now, since hidden pages
350 # don't get routes made for them. 373 # don't get routes made for them.
351 if hidden: 374 if hidden and not self._headers.errors:
352 return 375 return
353 # If this is an #errors page, register it as such.
354 if self._header.errors is not None:
355 self._mkerror()
356 return # this implies #hidden
357 # Get #methods for this route
358 if self._header.methods is None:
359 methods = [ 'GET' ]
360 else:
361 methods = [ i.upper() for i in self._header.methods.split() ]
362 if not methods:
363 raise TinCanError("{0}: no #methods specified".format(self._urlpath))
364 # Get the code-behind #python 376 # Get the code-behind #python
365 if self._header.python is not None: 377 if self._header.python is not None:
366 if not self._header.python.endswith(_PEXTEN): 378 if not self._header.python.endswith(_PEXTEN):
367 raise TinCanError("{0}: #python files must end in {1}".format(self._urlpath, _PEXTEN)) 379 raise TinCanError("{0}: #python files must end in {1}".format(self._urlpath, _PEXTEN))
368 self._python = self._header.python 380 self._python = self._header.python
376 tfile = TemplateFile(tpath) 388 tfile = TemplateFile(tpath)
377 self._body = self._tclass(source=tfile.body) 389 self._body = self._tclass(source=tfile.body)
378 else: 390 else:
379 self._body = self._tclass(source=self._template.body) 391 self._body = self._tclass(source=self._template.body)
380 self._body.prepare() 392 self._body.prepare()
393 # If this is an #errors page, register it as such.
394 if self._header.errors is not None:
395 self._mkerror()
396 return # this implies #hidden
397 # Get #methods for this route
398 if self._header.methods is None:
399 methods = [ 'GET' ]
400 else:
401 methods = [ i.upper() for i in self._header.methods.split() ]
402 if not methods:
403 raise TinCanError("{0}: no #methods specified".format(self._urlpath))
381 # Register this thing with Bottle 404 # Register this thing with Bottle
382 print("adding route:", self._origin, '('+','.join(methods)+')') # debug 405 print("adding route:", self._origin, '('+','.join(methods)+')') # debug
383 self._app.route(self._origin, methods, self) 406 self._app.route(self._origin, methods, self)
384 407
385 def _splitpath(self, unsplit): 408 def _splitpath(self, unsplit):
390 errors = [ int(i) for i in self._header.errors.split() ] 413 errors = [ int(i) for i in self._header.errors.split() ]
391 except ValueError as e: 414 except ValueError as e:
392 raise TinCanError("{0}: bad #errors line".format(self._urlpath)) from e 415 raise TinCanError("{0}: bad #errors line".format(self._urlpath)) from e
393 if not errors: 416 if not errors:
394 errors = range(_ERRMIN, _ERRMAX+1) 417 errors = range(_ERRMIN, _ERRMAX+1)
395 route = _TinCanErrorRoute(self._tclass(source=self._template.body)) 418 route = _TinCanErrorRoute(self._tclass(source=self._template.body), self._class)
396 for error in errors: 419 for error in errors:
397 if error < _ERRMIN or error > _ERRMAX: 420 if error < _ERRMIN or error > _ERRMAX:
398 raise TinCanError("{0}: bad #errors code".format(self._urlpath)) 421 raise TinCanError("{0}: bad #errors code".format(self._urlpath))
399 self._app.error_handler[error] = route # XXX 422 self._app.error_handler[error] = route # XXX
400 423
402 try: 425 try:
403 return os.stat(path).st_mtime 426 return os.stat(path).st_mtime
404 except FileNotFoundError: 427 except FileNotFoundError:
405 return 0 428 return 0
406 except OSError as e: 429 except OSError as e:
407 raise TinCanError("{0}: {1}".format(path, e.strerror)) from e 430 raise TinCanError(str(e)) from e
408 431
409 def _getclass(self): 432 def _getclass(self):
410 pypath = os.path.normpath(os.path.join(self._fsroot, *self._splitpath(self._python))) 433 pypath = os.path.normpath(os.path.join(self._fsroot, *self._splitpath(self._python)))
434 klass = ErrorPage if self._header.errors else Page
411 # Give 'em a default code-behind if they don't furnish one 435 # Give 'em a default code-behind if they don't furnish one
412 pytime = self._gettime(pypath) 436 pytime = self._gettime(pypath)
413 if not pytime: 437 if not pytime:
414 self._class = Page 438 self._class = klass
415 return 439 return
416 # Else load the code-behind from a .py file 440 # Else load the code-behind from a .py file
417 pycpath = pypath + 'c' 441 pycpath = pypath + 'c'
418 pyctime = self._gettime(pycpath) 442 pyctime = self._gettime(pycpath)
419 try: 443 try:
430 except Exception as e: 454 except Exception as e:
431 raise TinCanError("{0}: error importing".format(pycpath)) from e 455 raise TinCanError("{0}: error importing".format(pycpath)) from e
432 self._class = None 456 self._class = None
433 for i in dir(mod): 457 for i in dir(mod):
434 v = getattr(mod, i) 458 v = getattr(mod, i)
435 if isclass(v) and issubclass(v, Page): 459 if isclass(v) and issubclass(v, klass):
436 if self._class is not None: 460 if self._class is not None:
437 raise TinCanError("{0}: contains multiple Page classes".format(pypath)) 461 raise TinCanError("{0}: contains multiple {1} classes".format(pypath, klass.__name__))
438 self._class = v 462 self._class = v
439 if self._class is None: 463 if self._class is None:
440 raise TinCanError("{0}: contains no Page classes".format(pypath)) 464 raise TinCanError("{0}: contains no {1} classes".format(pypath, klass.__name__))
441 465
442 def _redirect(self): 466 def _redirect(self):
443 try: 467 try:
444 rlist = self._splitpath(self._header.forward) 468 rlist = self._splitpath(self._header.forward)
445 forw = '/' + '/'.join(rlist) 469 forw = '/' + '/'.join(rlist)