# HG changeset patch # User David Barts # Date 1559148032 25200 # Node ID df27cf08c0933d51ebe13bd7277cb408ab387d71 # Parent ee19984ba31dd037a35ba8e2e2fde9490197a294 Add support for serving static files. diff -r ee19984ba31d -r df27cf08c093 launch --- a/launch Tue May 28 20:20:19 2019 -0700 +++ b/launch Wed May 29 09:40:32 2019 -0700 @@ -22,11 +22,12 @@ 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("-s", "--static", action="store_true", help="serve static files") 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, debug=args.debug, - encoding=args.encoding) + encoding=args.encoding, static=args.static) if errors: action = "continuing" if args.force else "aborting" sys.stderr.write("{0}: {1} error{2} detected, {3}\n".format( diff -r ee19984ba31d -r df27cf08c093 tincan.py --- a/tincan.py Tue May 28 20:20:19 2019 -0700 +++ b/tincan.py Wed May 29 09:40:32 2019 -0700 @@ -8,13 +8,16 @@ import ast import binascii from base64 import b16encode, b16decode +import email.utils import functools import importlib from inspect import isclass import io +import mimetypes import py_compile from stat import S_ISDIR, S_ISREG from string import whitespace +import time import traceback import urllib @@ -378,6 +381,81 @@ _FORIG = "tincan.origin" _FTYPE = "tincan.iserror" +class _TinCanStaticRoute(object): + """ + A route to a static file. These are useful for test servers. For + production servers, one is better off using a real web server and + a WSGI plugin, and having that handle static files. Much of this + logic is cribbed from the Bottle source code (we don't call it + directly because it is undocumented and thus subject to change). + """ + def __init__(self, launcher, name, subdir): + self._app = launcher.app + self._fspath = os.path.join(launcher.fsroot, *subdir, name) + self._urlpath = self._urljoin(launcher.urlroot, *subdir, name) + self._type = mimetypes.guess_type(name)[0] + if self._type is None: + self._type = "application/octet-stream" + if self._type.startswith("text/"): + self._encoding = launcher.encoding + self._type += "; charset=" + launcher.encoding + else: + self._encoding = None + + def launch(self): + print("adding static route:", self._urlpath) # debug + self._app.route(self._urlpath, 'GET', self) + + def _urljoin(self, *args): + args = list(args) + if args[0] == '/': + args[0] = '' + return '/'.join(args) + + def _parse_date(self, ims): + """ + Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch. + """ + try: + ts = email.utils.parsedate_tz(ims) + return time.mktime(ts[:8] + (0,)) - (ts[9] or 0) - time.timezone + except (TypeError, ValueError, IndexError, OverflowError): + return None + + def __call__(self): + # Get file contents and time stamp. If we can't, return an + # appropriate HTTP error response. + try: + with open(self._fspath, "rb") as fp: + mtime = os.fstat(fp.fileno()).st_mtime + bytes = fp.read() + except FileNotFoundError as e: + print(e) # debug + return bottle.HTTPError(status=404, exception=e) + except PermissionError as e: + print(e) # debug + return bottle.HTTPError(status=403, exception=e) + except OSError as e: + print(e) # debug + return bottle.HTTPError(status=500, exception=e) + # Establish preliminary standard headers. + headers = { + "Content-Type": self._type, + "Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(mtime)), + } + if self._encoding: + headers["Content-Encoding"] = self._encoding + # Support the If-Modified-Since request header. + ims = bottle.request.environ.get('HTTP_IF_MODIFIED_SINCE') + if ims: + ims = self._parse_date(ims.split(";")[0].strip()) + if ims is not None and ims >= int(stats.st_mtime): + headers["Content-Length"] = "0" + return bottle.HTTPResponse(body=b"", status=304, headers=headers) + # Standard response. + headers["Content-Length"] = str(len(bytes)) + return bottle.HTTPResponse(body=bytes, status=200, headers=headers) + class _TinCanErrorRoute(object): """ A route to an error page. These don't get routes created for them, @@ -425,7 +503,6 @@ self._subdir = subdir self._seen = set() self._app = launcher.app - self._save_loads = launcher.debug self._encoding = launcher.encoding def launch(self): @@ -679,6 +756,7 @@ _WINF = "WEB-INF" _BANNED = set([_WINF]) +_EBANNED = set([_IEXTEN, _TEXTEN, _PEXTEN, _PEXTEN+"c"]) ENCODING = "utf-8" class _Launcher(object): @@ -696,6 +774,7 @@ self.errors = 0 self.debug = False self.encoding = ENCODING + self.static = False def launch(self): """ @@ -737,9 +816,15 @@ etype = os.stat(os.path.join(self.fsroot, *subdir, entry)).st_mode if S_ISREG(etype): ename, eext = os.path.splitext(entry) - if eext != _TEXTEN: - continue # only look at interesting files - route = _TinCanRoute(self, ename, subdir) + if eext == _TEXTEN: + route = _TinCanRoute(self, ename, subdir) + else: + if eext in _EBANNED: + continue + if self.static: + route = _TinCanStaticRoute(self, entry, subdir) + else: + continue try: route.launch() except TinCanError as e: @@ -756,7 +841,7 @@ sys.stderr.write(message) sys.stderr.write('\n') -def launch(fsroot=None, urlroot='/', logger=_logger, debug=False, encoding=ENCODING): +def launch(fsroot=None, urlroot='/', logger=_logger, debug=False, encoding=ENCODING, static=False): """ Launch and return a TinCan webapp. Does not run the app; it is the caller's responsibility to call app.run() @@ -766,6 +851,7 @@ launcher = _Launcher(fsroot, urlroot, logger) launcher.debug = debug launcher.encoding = encoding + launcher.static = static launcher.launch() return launcher.app, launcher.errors