comparison tincan.py @ 40:df27cf08c093 draft

Add support for serving static files.
author David Barts <n5jrn@me.com>
date Wed, 29 May 2019 09:40:32 -0700
parents ce67eac10fc7
children 9335865ae0bb
comparison
equal deleted inserted replaced
39:ee19984ba31d 40:df27cf08c093
6 6
7 import os, sys 7 import os, sys
8 import ast 8 import ast
9 import binascii 9 import binascii
10 from base64 import b16encode, b16decode 10 from base64 import b16encode, b16decode
11 import email.utils
11 import functools 12 import functools
12 import importlib 13 import importlib
13 from inspect import isclass 14 from inspect import isclass
14 import io 15 import io
16 import mimetypes
15 import py_compile 17 import py_compile
16 from stat import S_ISDIR, S_ISREG 18 from stat import S_ISDIR, S_ISREG
17 from string import whitespace 19 from string import whitespace
20 import time
18 import traceback 21 import traceback
19 import urllib 22 import urllib
20 23
21 import bottle 24 import bottle
22 25
376 _TEXTEN = ".pspx" 379 _TEXTEN = ".pspx"
377 _FLOOP = "tincan.forwards" 380 _FLOOP = "tincan.forwards"
378 _FORIG = "tincan.origin" 381 _FORIG = "tincan.origin"
379 _FTYPE = "tincan.iserror" 382 _FTYPE = "tincan.iserror"
380 383
384 class _TinCanStaticRoute(object):
385 """
386 A route to a static file. These are useful for test servers. For
387 production servers, one is better off using a real web server and
388 a WSGI plugin, and having that handle static files. Much of this
389 logic is cribbed from the Bottle source code (we don't call it
390 directly because it is undocumented and thus subject to change).
391 """
392 def __init__(self, launcher, name, subdir):
393 self._app = launcher.app
394 self._fspath = os.path.join(launcher.fsroot, *subdir, name)
395 self._urlpath = self._urljoin(launcher.urlroot, *subdir, name)
396 self._type = mimetypes.guess_type(name)[0]
397 if self._type is None:
398 self._type = "application/octet-stream"
399 if self._type.startswith("text/"):
400 self._encoding = launcher.encoding
401 self._type += "; charset=" + launcher.encoding
402 else:
403 self._encoding = None
404
405 def launch(self):
406 print("adding static route:", self._urlpath) # debug
407 self._app.route(self._urlpath, 'GET', self)
408
409 def _urljoin(self, *args):
410 args = list(args)
411 if args[0] == '/':
412 args[0] = ''
413 return '/'.join(args)
414
415 def _parse_date(self, ims):
416 """
417 Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch.
418 """
419 try:
420 ts = email.utils.parsedate_tz(ims)
421 return time.mktime(ts[:8] + (0,)) - (ts[9] or 0) - time.timezone
422 except (TypeError, ValueError, IndexError, OverflowError):
423 return None
424
425 def __call__(self):
426 # Get file contents and time stamp. If we can't, return an
427 # appropriate HTTP error response.
428 try:
429 with open(self._fspath, "rb") as fp:
430 mtime = os.fstat(fp.fileno()).st_mtime
431 bytes = fp.read()
432 except FileNotFoundError as e:
433 print(e) # debug
434 return bottle.HTTPError(status=404, exception=e)
435 except PermissionError as e:
436 print(e) # debug
437 return bottle.HTTPError(status=403, exception=e)
438 except OSError as e:
439 print(e) # debug
440 return bottle.HTTPError(status=500, exception=e)
441 # Establish preliminary standard headers.
442 headers = {
443 "Content-Type": self._type,
444 "Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(mtime)),
445 }
446 if self._encoding:
447 headers["Content-Encoding"] = self._encoding
448 # Support the If-Modified-Since request header.
449 ims = bottle.request.environ.get('HTTP_IF_MODIFIED_SINCE')
450 if ims:
451 ims = self._parse_date(ims.split(";")[0].strip())
452 if ims is not None and ims >= int(stats.st_mtime):
453 headers["Content-Length"] = "0"
454 return bottle.HTTPResponse(body=b"", status=304, headers=headers)
455 # Standard response.
456 headers["Content-Length"] = str(len(bytes))
457 return bottle.HTTPResponse(body=bytes, status=200, headers=headers)
458
381 class _TinCanErrorRoute(object): 459 class _TinCanErrorRoute(object):
382 """ 460 """
383 A route to an error page. These don't get routes created for them, 461 A route to an error page. These don't get routes created for them,
384 and are only reached if an error routes them there. Unless you create 462 and are only reached if an error routes them there. Unless you create
385 custom code-behind, only two variables are available to your template: 463 custom code-behind, only two variables are available to your template:
423 self._urlpath = self._urljoin(launcher.urlroot, *subdir, name + _TEXTEN) 501 self._urlpath = self._urljoin(launcher.urlroot, *subdir, name + _TEXTEN)
424 self._origin = self._urlpath 502 self._origin = self._urlpath
425 self._subdir = subdir 503 self._subdir = subdir
426 self._seen = set() 504 self._seen = set()
427 self._app = launcher.app 505 self._app = launcher.app
428 self._save_loads = launcher.debug
429 self._encoding = launcher.encoding 506 self._encoding = launcher.encoding
430 507
431 def launch(self): 508 def launch(self):
432 """ 509 """
433 Launch a single page. 510 Launch a single page.
677 754
678 # L a u n c h e r 755 # L a u n c h e r
679 756
680 _WINF = "WEB-INF" 757 _WINF = "WEB-INF"
681 _BANNED = set([_WINF]) 758 _BANNED = set([_WINF])
759 _EBANNED = set([_IEXTEN, _TEXTEN, _PEXTEN, _PEXTEN+"c"])
682 ENCODING = "utf-8" 760 ENCODING = "utf-8"
683 761
684 class _Launcher(object): 762 class _Launcher(object):
685 """ 763 """
686 Helper class for launching webapps. 764 Helper class for launching webapps.
694 self.logger = logger 772 self.logger = logger
695 self.app = None 773 self.app = None
696 self.errors = 0 774 self.errors = 0
697 self.debug = False 775 self.debug = False
698 self.encoding = ENCODING 776 self.encoding = ENCODING
777 self.static = False
699 778
700 def launch(self): 779 def launch(self):
701 """ 780 """
702 Does the actual work of launching something. XXX - modifies sys.path 781 Does the actual work of launching something. XXX - modifies sys.path
703 and never un-modifies it. 782 and never un-modifies it.
735 if not subdir and entry in _BANNED: 814 if not subdir and entry in _BANNED:
736 continue 815 continue
737 etype = os.stat(os.path.join(self.fsroot, *subdir, entry)).st_mode 816 etype = os.stat(os.path.join(self.fsroot, *subdir, entry)).st_mode
738 if S_ISREG(etype): 817 if S_ISREG(etype):
739 ename, eext = os.path.splitext(entry) 818 ename, eext = os.path.splitext(entry)
740 if eext != _TEXTEN: 819 if eext == _TEXTEN:
741 continue # only look at interesting files 820 route = _TinCanRoute(self, ename, subdir)
742 route = _TinCanRoute(self, ename, subdir) 821 else:
822 if eext in _EBANNED:
823 continue
824 if self.static:
825 route = _TinCanStaticRoute(self, entry, subdir)
826 else:
827 continue
743 try: 828 try:
744 route.launch() 829 route.launch()
745 except TinCanError as e: 830 except TinCanError as e:
746 self.logger(str(e)) 831 self.logger(str(e))
747 if self.debug: 832 if self.debug:
754 839
755 def _logger(message): 840 def _logger(message):
756 sys.stderr.write(message) 841 sys.stderr.write(message)
757 sys.stderr.write('\n') 842 sys.stderr.write('\n')
758 843
759 def launch(fsroot=None, urlroot='/', logger=_logger, debug=False, encoding=ENCODING): 844 def launch(fsroot=None, urlroot='/', logger=_logger, debug=False, encoding=ENCODING, static=False):
760 """ 845 """
761 Launch and return a TinCan webapp. Does not run the app; it is the 846 Launch and return a TinCan webapp. Does not run the app; it is the
762 caller's responsibility to call app.run() 847 caller's responsibility to call app.run()
763 """ 848 """
764 if fsroot is None: 849 if fsroot is None:
765 fsroot = os.getcwd() 850 fsroot = os.getcwd()
766 launcher = _Launcher(fsroot, urlroot, logger) 851 launcher = _Launcher(fsroot, urlroot, logger)
767 launcher.debug = debug 852 launcher.debug = debug
768 launcher.encoding = encoding 853 launcher.encoding = encoding
854 launcher.static = static
769 launcher.launch() 855 launcher.launch()
770 return launcher.app, launcher.errors 856 return launcher.app, launcher.errors
771 857
772 # XXX - We cannot implement a command-line launcher here; see the 858 # XXX - We cannot implement a command-line launcher here; see the
773 # launcher script for why. 859 # launcher script for why.