""" Web API (wrapper around WSGI) (from web.py) """ import cgi import pprint import sys import tempfile from http.cookies import CookieError, Morsel, SimpleCookie from io import BytesIO from urllib.parse import quote, unquote, urljoin from .utils import dictadd, intget, safestr, storage, storify, threadeddict __all__ = [ "config", "header", "debug", "input", "data", "setcookie", "cookies", "ctx", "HTTPError", # 200, 201, 202, 204 "OK", "Created", "Accepted", "NoContent", "ok", "created", "accepted", "nocontent", # 301, 302, 303, 304, 307 "Redirect", "Found", "SeeOther", "NotModified", "TempRedirect", "redirect", "found", "seeother", "notmodified", "tempredirect", # 400, 401, 403, 404, 405, 406, 409, 410, 412, 415, 451 "BadRequest", "Unauthorized", "Forbidden", "NotFound", "NoMethod", "NotAcceptable", "Conflict", "Gone", "PreconditionFailed", "UnsupportedMediaType", "UnavailableForLegalReasons", "badrequest", "unauthorized", "forbidden", "notfound", "nomethod", "notacceptable", "conflict", "gone", "preconditionfailed", "unsupportedmediatype", "unavailableforlegalreasons", # 500 "InternalError", "internalerror", ] config = storage() config.__doc__ = """ A configuration object for various aspects of web.py. `debug` : when True, enables reloading, disabled template caching and sets internalerror to debugerror. """ class HTTPError(Exception): def __init__(self, status, headers={}, data=""): ctx.status = status for k, v in headers.items(): header(k, v) self.data = data Exception.__init__(self, status) def _status_code(status, data=None, classname=None, docstring=None): if data is None: data = status.split(" ", 1)[1] classname = status.split(" ", 1)[1].replace( " ", "" ) # 304 Not Modified -> NotModified docstring = docstring or "`%s` status" % status def __init__(self, data=data, headers={}): HTTPError.__init__(self, status, headers, data) # trick to create class dynamically with dynamic docstring. return type( classname, (HTTPError, object), {"__doc__": docstring, "__init__": __init__} ) ok = OK = _status_code("200 OK", data="") created = Created = _status_code("201 Created") accepted = Accepted = _status_code("202 Accepted") nocontent = NoContent = _status_code("204 No Content") class Redirect(HTTPError): """A `301 Moved Permanently` redirect.""" def __init__(self, url, status="301 Moved Permanently", absolute=False): """ Returns a `status` redirect to the new URL. `url` is joined with the base URL so that things like `redirect("about") will work properly. """ newloc = urljoin(ctx.path, url) if newloc.startswith("/"): if absolute: home = ctx.realhome else: home = ctx.home newloc = home + newloc headers = {"Content-Type": "text/html", "Location": newloc} HTTPError.__init__(self, status, headers, "") redirect = Redirect class Found(Redirect): """A `302 Found` redirect.""" def __init__(self, url, absolute=False): Redirect.__init__(self, url, "302 Found", absolute=absolute) found = Found class SeeOther(Redirect): """A `303 See Other` redirect.""" def __init__(self, url, absolute=False): Redirect.__init__(self, url, "303 See Other", absolute=absolute) seeother = SeeOther class NotModified(HTTPError): """A `304 Not Modified` status.""" def __init__(self): HTTPError.__init__(self, "304 Not Modified") notmodified = NotModified class TempRedirect(Redirect): """A `307 Temporary Redirect` redirect.""" def __init__(self, url, absolute=False): Redirect.__init__(self, url, "307 Temporary Redirect", absolute=absolute) tempredirect = TempRedirect class BadRequest(HTTPError): """`400 Bad Request` error.""" message = "bad request" def __init__(self, message=None): status = "400 Bad Request" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) badrequest = BadRequest class Unauthorized(HTTPError): """`401 Unauthorized` error.""" message = "unauthorized" def __init__(self, message=None): status = "401 Unauthorized" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) unauthorized = Unauthorized class Forbidden(HTTPError): """`403 Forbidden` error.""" message = "forbidden" def __init__(self, message=None): status = "403 Forbidden" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) forbidden = Forbidden class _NotFound(HTTPError): """`404 Not Found` error.""" message = "not found" def __init__(self, message=None): status = "404 Not Found" headers = {"Content-Type": "text/html; charset=utf-8"} HTTPError.__init__(self, status, headers, message or self.message) def NotFound(message=None): """Returns HTTPError with '404 Not Found' error from the active application.""" if message: return _NotFound(message) elif ctx.get("app_stack"): return ctx.app_stack[-1].notfound() else: return _NotFound() notfound = NotFound class NoMethod(HTTPError): """A `405 Method Not Allowed` error.""" message = "method not allowed" def __init__(self, cls=None): status = "405 Method Not Allowed" headers = {} headers["Content-Type"] = "text/html" methods = ["GET", "HEAD", "POST", "PUT", "DELETE"] if cls: methods = [method for method in methods if hasattr(cls, method)] headers["Allow"] = ", ".join(methods) HTTPError.__init__(self, status, headers, self.message) nomethod = NoMethod class NotAcceptable(HTTPError): """`406 Not Acceptable` error.""" message = "not acceptable" def __init__(self, message=None): status = "406 Not Acceptable" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) notacceptable = NotAcceptable class Conflict(HTTPError): """`409 Conflict` error.""" message = "conflict" def __init__(self, message=None): status = "409 Conflict" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) conflict = Conflict class Gone(HTTPError): """`410 Gone` error.""" message = "gone" def __init__(self, message=None): status = "410 Gone" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) gone = Gone class PreconditionFailed(HTTPError): """`412 Precondition Failed` error.""" message = "precondition failed" def __init__(self, message=None): status = "412 Precondition Failed" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) preconditionfailed = PreconditionFailed class UnsupportedMediaType(HTTPError): """`415 Unsupported Media Type` error.""" message = "unsupported media type" def __init__(self, message=None): status = "415 Unsupported Media Type" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) unsupportedmediatype = UnsupportedMediaType class _UnavailableForLegalReasons(HTTPError): """`451 Unavailable For Legal Reasons` error.""" message = "unavailable for legal reasons" def __init__(self, message=None): status = "451 Unavailable For Legal Reasons" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) def UnavailableForLegalReasons(message=None): """Returns HTTPError with '415 Unavailable For Legal Reasons' error from the active application.""" if message: return _UnavailableForLegalReasons(message) elif ctx.get("app_stack"): return ctx.app_stack[-1].unavailableforlegalreasons() else: return _UnavailableForLegalReasons() unavailableforlegalreasons = UnavailableForLegalReasons class _InternalError(HTTPError): """500 Internal Server Error`.""" message = "internal server error" def __init__(self, message=None): status = "500 Internal Server Error" headers = {"Content-Type": "text/html"} HTTPError.__init__(self, status, headers, message or self.message) def InternalError(message=None): """Returns HTTPError with '500 internal error' error from the active application.""" if message: return _InternalError(message) elif ctx.get("app_stack"): return ctx.app_stack[-1].internalerror() else: return _InternalError() internalerror = InternalError class cgiFieldStorage(cgi.FieldStorage): """ Subclass cgi.FieldStorage, as read_binary expects fp to return bytes. If the headers do not contain a content-disposition with a filename, cgi.FieldStorage's make_file will create a TemporaryFile with `w+` flags. The write to that temporary file will fail, due to incorrect encoding in Python 3. """ def make_file(self, binary=None): """ For backwards compatibility with Python 2, make_file accepted a binary flag. This was unused, and removed in Python 3. """ return tempfile.TemporaryFile("wb+") def header(hdr, value, unique=False): """ Adds the header `hdr: value` with the response. If `unique` is True and a header with that name already exists, it doesn't add a new one. """ hdr, value = safestr(hdr), safestr(value) # protection against HTTP response splitting attack if "\n" in hdr or "\r" in hdr or "\n" in value or "\r" in value: raise ValueError("invalid characters in header") if unique is True: for h, v in ctx.headers: if h.lower() == hdr.lower(): return ctx.headers.append((hdr, value)) def rawinput(method=None): """Returns storage object with GET or POST arguments.""" method = method or "both" def dictify(fs): # hack to make web.input work with enctype='text/plain. if fs.list is None: fs.list = [] return {k: fs[k] for k in fs} e = ctx.env.copy() a = b = {} if method.lower() in ["both", "post", "put", "patch"]: if e["REQUEST_METHOD"] in ["POST", "PUT", "PATCH"]: if e.get("CONTENT_TYPE", "").lower().startswith("multipart/"): # since wsgi.input is directly passed to cgi.FieldStorage, # it can not be called multiple times. Saving the FieldStorage # object in ctx to allow calling web.input multiple times. a = ctx.get("_fieldstorage") if not a: fp = e["wsgi.input"] a = cgiFieldStorage(fp=fp, environ=e, keep_blank_values=1) ctx._fieldstorage = a else: d = data() if isinstance(d, str): d = d.encode("utf-8") fp = BytesIO(d) a = cgiFieldStorage(fp=fp, environ=e, keep_blank_values=1) a = dictify(a) if method.lower() in ["both", "get"]: e["REQUEST_METHOD"] = "GET" b = dictify(cgiFieldStorage(environ=e, keep_blank_values=1)) def process_fieldstorage(fs): if isinstance(fs, list): return [process_fieldstorage(x) for x in fs] elif fs.filename is None: return fs.value else: return fs return storage([(k, process_fieldstorage(v)) for k, v in dictadd(b, a).items()]) def input(*requireds, **defaults): """ Returns a `storage` object with the GET and POST arguments. See `storify` for how `requireds` and `defaults` work. """ _method = defaults.pop("_method", "both") out = rawinput(_method) try: defaults.setdefault("_unicode", True) # force unicode conversion by default. return storify(out, *requireds, **defaults) except KeyError: raise badrequest() def data(): """Returns the data sent with the request.""" if "data" not in ctx: if ctx.env.get("HTTP_TRANSFER_ENCODING") == "chunked": ctx.data = ctx.env["wsgi.input"].read() else: cl = intget(ctx.env.get("CONTENT_LENGTH"), 0) ctx.data = ctx.env["wsgi.input"].read(cl) return ctx.data def setcookie( name, value, expires="", domain=None, secure=False, httponly=False, path=None, samesite=None, ): """Sets a cookie.""" morsel = Morsel() name, value = safestr(name), safestr(value) morsel.set(name, value, quote(value)) if isinstance(expires, int) and expires < 0: expires = -1000000000 morsel["expires"] = expires morsel["path"] = path or ctx.homepath + "/" if domain: morsel["domain"] = domain if secure: morsel["secure"] = secure if httponly: morsel["httponly"] = True value = morsel.OutputString() if samesite and samesite.lower() in ("strict", "lax", "none"): value += "; SameSite=%s" % samesite header("Set-Cookie", value) def parse_cookies(http_cookie): r"""Parse a HTTP_COOKIE header and return dict of cookie names and decoded values. >>> sorted(parse_cookies('').items()) [] >>> sorted(parse_cookies('a=1').items()) [('a', '1')] >>> sorted(parse_cookies('a=1%202').items()) [('a', '1 2')] >>> sorted(parse_cookies('a=Z%C3%A9Z').items()) [('a', 'Z\xc3\xa9Z')] >>> sorted(parse_cookies('a=1; b=2; c=3').items()) [('a', '1'), ('b', '2'), ('c', '3')] # TODO: cclauss re-enable this test # >>> sorted(parse_cookies('a=1; b=w("x")|y=z; c=3').items()) # [('a', '1'), ('b', 'w('), ('c', '3')] >>> sorted(parse_cookies('a=1; b=w(%22x%22)|y=z; c=3').items()) [('a', '1'), ('b', 'w("x")|y=z'), ('c', '3')] >>> sorted(parse_cookies('keebler=E=mc2').items()) [('keebler', 'E=mc2')] >>> sorted(parse_cookies(r'keebler="E=mc2; L=\"Loves\"; fudge=\012;"').items()) [('keebler', 'E=mc2; L="Loves"; fudge=\n;')] """ # print "parse_cookies" if '"' in http_cookie: # HTTP_COOKIE has quotes in it, use slow but correct cookie parsing cookie = SimpleCookie() try: cookie.load(http_cookie) except CookieError: # If HTTP_COOKIE header is malformed, try at least to load the cookies we can by # first splitting on ';' and loading each attr=value pair separately cookie = SimpleCookie() for attr_value in http_cookie.split(";"): try: cookie.load(attr_value) except CookieError: pass cookies = {k: unquote(v.value) for k, v in cookie.items()} else: # HTTP_COOKIE doesn't have quotes, use fast cookie parsing cookies = {} for key_value in http_cookie.split(";"): key_value = key_value.split("=", 1) if len(key_value) == 2: key, value = key_value cookies[key.strip()] = unquote(value.strip()) return cookies def cookies(*requireds, **defaults): """Returns a `storage` object with all the request cookies in it. See `storify` for how `requireds` and `defaults` work. This is forgiving on bad HTTP_COOKIE input, it tries to parse at least the cookies it can. The values are converted to unicode if _unicode=True is passed. """ # parse cookie string and cache the result for next time. if "_parsed_cookies" not in ctx: http_cookie = ctx.env.get("HTTP_COOKIE", "") ctx._parsed_cookies = parse_cookies(http_cookie) try: return storify(ctx._parsed_cookies, *requireds, **defaults) except KeyError: badrequest() raise StopIteration() def debug(*args): """ Prints a prettyprinted version of `args` to stderr. """ try: out = ctx.environ["wsgi.errors"] except: out = sys.stderr for arg in args: print(pprint.pformat(arg), file=out) return "" def _debugwrite(x): try: out = ctx.environ["wsgi.errors"] except: out = sys.stderr out.write(x) debug.write = _debugwrite ctx = context = threadeddict() ctx.__doc__ = """ A `storage` object containing various information about the request: `environ` (aka `env`) : A dictionary containing the standard WSGI environment variables. `host` : The domain (`Host` header) requested by the user. `home` : The base path for the application. `ip` : The IP address of the requester. `method` : The HTTP method used. `path` : The path request. `query` : If there are no query arguments, the empty string. Otherwise, a `?` followed by the query string. `fullpath` : The full path requested, including query arguments (`== path + query`). ### Response Data `status` (default: "200 OK") : The status code to be used in the response. `headers` : A list of 2-tuples to be used in the response. `output` : A string to be used as the response. """ if __name__ == "__main__": import doctest doctest.testmod()