diff options
| author | ianb <devnull@localhost> | 2005-12-13 07:00:20 +0000 |
|---|---|---|
| committer | ianb <devnull@localhost> | 2005-12-13 07:00:20 +0000 |
| commit | 4e73bff9da87e35c7154ab1cc923bb4f9d40711d (patch) | |
| tree | d2e4c92965398700457280d5829dfaa5cdf5b4fb | |
| parent | 55b404e53bc834daf3852069af6de9b1fca4c742 (diff) | |
| download | paste-4e73bff9da87e35c7154ab1cc923bb4f9d40711d.tar.gz | |
Merged changes from cce branch (r3727:HEAD/4008); the branch is now in sync with trunk
| -rw-r--r-- | paste/auth/__init__.py | 1 | ||||
| -rw-r--r-- | paste/auth/basic.py | 67 | ||||
| -rw-r--r-- | paste/auth/cas.py | 94 | ||||
| -rw-r--r-- | paste/auth/cookie.py | 229 | ||||
| -rw-r--r-- | paste/auth/digest.py | 193 | ||||
| -rw-r--r-- | paste/auth/form.py | 73 | ||||
| -rw-r--r-- | paste/auth/multi.py | 81 | ||||
| -rw-r--r-- | paste/debug/__init__.py | 1 | ||||
| -rwxr-xr-x | paste/debug/testserver.py | 97 | ||||
| -rw-r--r-- | paste/exceptions/errormiddleware.py | 76 | ||||
| -rw-r--r-- | paste/httpexceptions.py | 539 | ||||
| -rw-r--r-- | paste/lint.py | 20 | ||||
| -rw-r--r-- | paste/request.py | 214 | ||||
| -rw-r--r-- | paste/transaction.py | 79 | ||||
| -rwxr-xr-x | paste/util/baseserver.py | 127 | ||||
| -rw-r--r-- | paste/util/quoting.py | 10 | ||||
| -rw-r--r-- | paste/wsgilib.py | 295 | ||||
| -rw-r--r-- | tests/test_auth/test_auth_cookie.py | 41 | ||||
| -rw-r--r-- | tests/test_auth/test_auth_digest.py | 86 | ||||
| -rw-r--r-- | tests/test_exceptions/test_error_middleware.py | 30 | ||||
| -rw-r--r-- | tests/test_exceptions/test_formatter.py | 6 | ||||
| -rw-r--r-- | tests/test_exceptions/test_httpexceptions.py | 82 | ||||
| -rw-r--r-- | tests/test_request.py | 7 |
23 files changed, 2074 insertions, 374 deletions
diff --git a/paste/auth/__init__.py b/paste/auth/__init__.py new file mode 100644 index 0000000..792d600 --- /dev/null +++ b/paste/auth/__init__.py @@ -0,0 +1 @@ +# diff --git a/paste/auth/basic.py b/paste/auth/basic.py new file mode 100644 index 0000000..8a7e787 --- /dev/null +++ b/paste/auth/basic.py @@ -0,0 +1,67 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +Basic Authentication + +""" +from paste.httpexceptions import HTTPUnauthorized + +class BasicAuthenticator: + """ Implementation of only 'Basic' authentication in 2617 """ + def __init__(self, realm, userfunc): + """ + realm is a globally unique URI like tag:clarkevans.com,2005:basic + that represents the authenticating authority + userfunc(username, password) -> boolean + """ + self.realm = realm + self.userfunc = userfunc + + def build_authentication(self): + head = [('WWW-Authenticate','Basic realm="%s"' % self.realm)] + return HTTPUnauthorized(headers=head) + + def authenticate(self, authorization): + if not authorization: + return self.build_authentication() + (authmeth, auth) = authorization.split(" ",1) + if 'basic' != authmeth.lower(): + return self.build_authentication() + auth = auth.strip().decode('base64') + username, password = auth.split(':') + if self.userfunc(username, password): + return username + return self.build_authentication() + + __call__ = authenticate + +def AuthBasicHandler(application, realm, userfunc): + authenticator = BasicAuthenticator(realm, userfunc) + def basic_application(environ, start_response): + username = environ.get('REMOTE_USER','') + if not username: + authorization = environ.get('HTTP_AUTHORIZATION','') + result = authenticator(authorization) + if isinstance(result,str): + environ['AUTH_TYPE'] = 'basic' + environ['REMOTE_USER'] = result + else: + return result.wsgi_application(environ, start_response) + return application(environ, start_response) + return basic_application + +middleware = AuthBasicHandler + +__all__ = ['AuthBasicHandler'] + +if '__main__' == __name__: + realm = 'tag:clarkevans.com,2005:basic' + def userfunc(username, password): + return username == password + from paste.wsgilib import dump_environ + from paste.util.baseserver import serve + from paste.httpexceptions import * + serve(HTTPExceptionHandler( + AuthBasicHandler(dump_environ, realm, userfunc))) diff --git a/paste/auth/cas.py b/paste/auth/cas.py new file mode 100644 index 0000000..193b79a --- /dev/null +++ b/paste/auth/cas.py @@ -0,0 +1,94 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +CAS 1.0 Authentication + +The Central Authentication System is a straight-forward single sign-on +mechanism developed by Yale University's ITS department. It has since +enjoyed widespread success and is deployed at many major universities +and some corporations. + + https://clearinghouse.ja-sig.org/wiki/display/CAS/Home + http://www.yale.edu/tp/auth/usingcasatyale.html + +This implementation has the goal of maintaining current path arguments +passed to the system so that it can be used as middleware at any stage +of processing. It has the secondary goal of allowing for other +authentication methods to be used concurrently. +""" +import urllib +from paste.wsgilib import construct_url +from paste.httpexceptions import HTTPSeeOther, HTTPForbidden + +class CASLoginFailure(HTTPForbidden): + """ The exception raised if the authority returns 'no' """ + +class CASAuthenticate(HTTPSeeOther): + """ The exception raised to authenticate the user """ + +def AuthCASHandler(application, authority): + """ + This middleware implements CAS 1.0 Authentication There are several + possible outcomes: + + 0. If the REMOTE_USER environment variable is already populated; + then this middleware is a no-op, and the request is passed along + to the application. + + 1. If a query argument 'ticket' is found, then an attempt to + validate said ticket /w the authentication service done. If the + ticket is not validated; an 403 'Forbidden' exception is raised. + Otherwise, the REMOTE_USER variable is set with the NetID that + was validated and AUTH_TYPE is set to "cas". + + 2. Otherwise, a 303 'See Other' is returned to the client directing + them to login using the CAS service. After logon, the service + will send them back to this same URL, only with a 'ticket' query + argument. + + authority: + This is a fully-qualified URL to a CAS 1.0 service. The URL + should end with a '/' and have the 'login' and 'validate' + sub-paths as described in the CAS 1.0 documentation. + """ + assert authority.endswith("/") and authority.startswith("http") + def cas_application(environ, start_response): + username = environ.get('REMOTE_USER','') + if username: + return application(environ, start_response) + qs = environ.get('QUERY_STRING','').split("&") + if qs and qs[-1].startswith("ticket="): + # assume a response from the authority + ticket = qs.pop().split("=",1)[1] + environ['QUERY_STRING'] = "&".join(qs) + service = construct_url(environ) + args = urllib.urlencode( + {'service': service,'ticket': ticket}) + requrl = authority + "validate?" + args + result = urllib.urlopen(requrl).read().split("\n") + if 'yes' == result[0]: + environ['REMOTE_USER'] = result[1] + environ['AUTH_TYPE'] = 'cas' + return application(environ, start_response) + exce = CASLoginFailure() + else: + service = construct_url(environ) + args = urllib.urlencode({'service': service}) + location = authority + "login?" + args + exce = CASAuthenticate(location) + return exce.wsgi_application(environ, start_response) + return cas_application + +middleware = AuthCASHandler + +__all__ = ['CASLoginFailure', 'CASAuthenticate', 'AuthCASHandler' ] + +if '__main__' == __name__: + authority = "https://secure.its.yale.edu/cas/servlet/" + from paste.wsgilib import dump_environ + from paste.util.baseserver import serve + from paste.httpexceptions import * + serve(HTTPExceptionHandler( + AuthCASHandler(dump_environ, authority))) diff --git a/paste/auth/cookie.py b/paste/auth/cookie.py new file mode 100644 index 0000000..96071d7 --- /dev/null +++ b/paste/auth/cookie.py @@ -0,0 +1,229 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +Cookie "Saved" Authentication + +This Authentication middleware saves the current REMOTE_USER, and any +other environment variables specified, in a cookie so that it can be +retrieved during the next request without requiring re-authentication. +This uses a session cookie on the client side (so it goes away when the +user closes their window) and does server-side expiration. + + NOTE: If you use HTTPFound or other redirections; it is likely that + this module will not work unless it is _before_ the middleware + that converts the exception into a response. Therefore, in your + component stack, put this component darn near the top (before + the exception handler). + +According to the cookie specifications, RFC2068 and RFC2109, browsers +should allow each domain at least 20 cookies; each one with a content +size of at least 4k (4096 bytes). This is rather small; so one should +be parsimonious in your cookie name/sizes. +""" +import sha, base64, random, time, string, warnings +from paste.wsgilib import get_cookies + +def make_time(value): + """ return a human readable timestmp """ + return time.strftime("%Y%m%d%H%M",time.gmtime(value)) +_signature_size = len(sha.sha("").digest()) +_header_size = _signature_size + len(make_time(time.time())) + +# build encode/decode functions to safely pack away values +_encode = [('\\','\\x5c'),('"','\\x22'),('=','\\x3d'),(';','\\x3b')] +_decode = [(v,k) for (k,v) in _encode] +_decode.reverse() +def encode(s, sublist = _encode): + return reduce((lambda a,(b,c): string.replace(a,b,c)), sublist, str(s)) +decode = lambda s: encode(s,_decode) + +class CookieTooLarge(RuntimeError): + def __init__(self, content, cookie): + RuntimeError.__init__("Signed cookie exceeds maximum size of 4096") + self.content = content + self.cookie = cookie + +class CookieSigner: + """ + This class converts content into a timed and digitally signed + cookie, as well as having the facility to reverse this procedure. + If the cookie, after the content is encoded and signed exceeds the + maximum length (4096), then CookieTooLarge exception is raised. + + The timeout of the cookie is handled on the server side for a few + reasons. First, if a 'Expires' directive is added to a cookie, then + the cookie becomes persistent (lasting even after the browser window + has closed). Second, the user's clock may be wrong (perhaps + intentionally). The timeout is specified in minutes; and expiration + date returned is rounded to one second. + """ + def __init__(self, secret = None, timeout = None, maxlen = None): + self.timeout = timeout or 30 + self.maxlen = maxlen or 4096 + self.secret = secret or sha.sha(str(random.random()) + + str(time.time())).digest() + + def sign(self, content): + """ + Sign the content returning a valid cookie (that does not + need to be escaped and quoted). The expiration of this + cookie is handled server-side in the auth() function. + """ + cookie = base64.b64encode( + sha.sha(content+self.secret).digest() + + make_time(time.time()+60*self.timeout) + + content).replace("/","_").replace("=","~") + if len(cookie) > self.maxlen: + raise CookieTooLarge(content,cookie) + return cookie + + def auth(self,cookie): + """ + Authenticate the cooke using the signature, verify that it + has not expired; and return the cookie's content + """ + decode = base64.b64decode( + cookie.replace("_","/").replace("~","=")) + signature = decode[:_signature_size] + expires = decode[_signature_size:_header_size] + content = decode[_header_size:] + if signature == sha.sha(content+self.secret).digest(): + if int(expires) > int(make_time(time.time())): + return content + else: + # This is the normal case of an expired cookie; just + # don't bother doing anything here. + pass + else: + # This case can happen if the server is restarted with a + # different secret; or if the user's IP address changed + # due to a proxy. However, it could also be a break-in + # attempt -- so should it be reported? + pass + +class AuthCookieEnviron(list): + """ + This object is a list of `environ` keys that were restored from or + will be added to the digially signed cookie. This object can be + accessed from an `environ` variable by using this module's name. + + environ['paste.auth.cookie'].append('your.environ.variable') + + This environment-specific object can also be used to access/configure + the base handler for all requests by using: + + environ['paste.auth.cookie'].handler + + """ + def __init__(self, handler, scanlist): + list.__init__(self, scanlist) + self.handler = handler + def append(self, value): + if value in self: + return + list.append(self,str(value)) + +class AuthCookieHandler: + """ + This middleware uses cookies to stash-away a previously authenticated + user (and perhaps other variables) so that re-authentication is not + needed. This does not implement sessions; and therefore N servers + can be syncronized to accept the same saved authentication if they + all use the same cookie_name and secret. + + By default, this handler scans the `environ` for the REMOTE_USER + key; if found, it is stored. It can be configured to scan other + `environ` keys as well -- but be careful not to exceed 2-3k (so that + the encoded and signed cookie does not exceed 4k). You can ask it + to handle other environment variables by doing: + + environ['paste.auth.cookie'].append('your.environ.variable') + + """ + environ_name = 'paste.auth.cookie' + signer_class = CookieSigner + environ_class = AuthCookieEnviron + + def __init__(self, application, cookie_name=None, secret=None, + timeout=None, maxlen=None, signer=None, scanlist = None): + if not signer: + signer = self.signer_class(secret,timeout,maxlen) + self.signer = signer + self.scanlist = scanlist or ('REMOTE_USER',) + self.application = application + self.cookie_name = cookie_name or 'PASTE_AUTH_COOKIE' + + def __call__(self, environ, start_response): + if self.environ_name in environ: + raise AssertionError("AuthCookie already installed!") + scanlist = self.environ_class(self,self.scanlist) + jar = get_cookies(environ) + if jar.has_key(self.cookie_name): + content = self.signer.auth(jar[self.cookie_name].value) + if content: + for pair in content.split(";"): + (k,v) = pair.split("=") + k = decode(k) + if k not in scanlist: + scanlist.append(k) + if k in environ: + continue + environ[k] = decode(v) + if 'REMOTE_USER' == k: + environ['AUTH_TYPE'] = 'cookie' + environ[self.environ_name] = scanlist + if "paste.httpexceptions" in environ: + warnings.warn("Since paste.httpexceptions is hooked in your " + "processing chain before paste.auth.cookie, if an " + "HTTPRedirection is raised, the cookies this module sets " + "will not be included in your response.\n") + + def response_hook(status, response_headers, exc_info=None): + """ + Scan the environment for keys specified in the scanlist, + pack up their values, signs the content and issues a cookie. + """ + scanlist = environ.get(self.environ_name) + assert scanlist and isinstance(scanlist,self.environ_class) + content = [] + for k in scanlist: + v = environ.get(k,None) + if v is not None: + content.append("%s=%s" % (encode(k),encode(v))) + if content: + content = ";".join(content) + content = self.signer.sign(content) + cookie = '%s=%s; Path=/;' % (self.cookie_name, content) + if 'https' == environ['wsgi.url_scheme']: + cookie += ' secure;' + response_headers.append(('Set-Cookie',cookie)) + return start_response(status, response_headers, exc_info) + return self.application(environ, response_hook) + +middleware = AuthCookieHandler + +__all__ = ['AuthCookieHandler'] + +if '__main__' == __name__: + from paste.wsgilib import parse_querystring + def AuthStupidHandler(application): + def authstupid_application(environ, start_response): + args = dict(parse_querystring(environ)) + user = args.get('user','') + if user: + environ['REMOTE_USER'] = user + environ['AUTH_TYPE'] = 'stupid' + test = args.get('test','') + if test: + environ['paste.auth.cookie.test'] = test + environ['paste.auth.cookie'].append('paste.auth.cookie.test') + return application(environ, start_response) + return authstupid_application + from paste.wsgilib import dump_environ + from paste.util.baseserver import serve + from paste.httpexceptions import * + serve(AuthCookieHandler( + HTTPExceptionHandler( + AuthStupidHandler(dump_environ)))) diff --git a/paste/auth/digest.py b/paste/auth/digest.py new file mode 100644 index 0000000..598a103 --- /dev/null +++ b/paste/auth/digest.py @@ -0,0 +1,193 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +HTTP Digest Authentication (RFC 2617) + +NOTE: This has not been audited by a security expert, please use + with caution (or better yet, report security holes). + + At this time, this implementation does not provide for further + challenges, nor does it support Authentication-Info header. It + also uses md5, and an option to use sha would be a good thing. +""" +from paste.httpexceptions import HTTPUnauthorized +import md5, time, random, urllib2 + +def digest_password(username, realm, password): + """ Constructs the appropriate hashcode needed for HTTP Digest """ + return md5.md5("%s:%s:%s" % (username,realm,password)).hexdigest() + +def response(challenge, realm, path, username, password): + """ + Build an authorization response for a given challenge. This + implementation uses urllib2 to do the dirty work. + """ + auth = urllib2.AbstractDigestAuthHandler() + auth.add_password(realm,path,username,password) + (token,challenge) = challenge.split(' ',1) + chal = urllib2.parse_keqv_list(urllib2.parse_http_list(challenge)) + class FakeRequest: + def get_full_url(self): + return path + def has_data(self): + return False + def get_method(self): + return "GET" + get_selector = get_full_url + return "Digest %s" % auth.get_authorization(FakeRequest(), chal) + +class DigestAuthenticator: + """ Simple implementation of RFC 2617 - HTTP Digest Authentication """ + def __init__(self, realm, userfunc): + """ + realm is a globally unique URI, like tag:clarkevans.com,2005:bing + userfunc(realm, username) -> MD5('%s:%s:%s') % (user,realm,pass) + """ + self.nonce = {} # list to prevent replay attacks + self.userfunc = userfunc + self.realm = realm + + def build_authentication(self, stale = ''): + """ raises an authentication exception """ + nonce = md5.md5("%s:%s" % (time.time(),random.random())).hexdigest() + opaque = md5.md5("%s:%s" % (time.time(),random.random())).hexdigest() + self.nonce[nonce] = None + parts = { 'realm': self.realm, 'qop': 'auth', + 'nonce': nonce, 'opaque': opaque } + if stale: + parts['stale'] = 'true' + head = ", ".join(['%s="%s"' % (k,v) for (k,v) in parts.items()]) + head = [("WWW-Authenticate", 'Digest %s' % head)] + return HTTPUnauthorized(headers=head) + + def compute(self, ha1, username, response, method, + path, nonce, nc, cnonce, qop): + """ computes the authentication, raises error if unsuccessful """ + if not ha1: + return self.build_authentication() + ha2 = md5.md5('%s:%s' % (method,path)).hexdigest() + if qop: + chk = "%s:%s:%s:%s:%s:%s" % (ha1,nonce,nc,cnonce,qop,ha2) + else: + chk = "%s:%s:%s" % (ha1,nonce,ha2) + if response != md5.md5(chk).hexdigest(): + if nonce in self.nonce: + del self.nonce[nonce] + return self.build_authentication() + pnc = self.nonce.get(nonce,'00000000') + if nc <= pnc: + if nonce in self.nonce: + del self.nonce[nonce] + return self.build_authentication(stale = True) + self.nonce[nonce] = nc + return username + + def authenticate(self, authorization, path, method): + """ This function takes the value of the 'Authorization' header, + the method used (e.g. GET), and the path of the request + relative to the server. The function either returns an + authenticated user, or it raises an exception. + """ + if not authorization: + return self.build_authentication() + (authmeth, auth) = authorization.split(" ",1) + if 'digest' != authmeth.lower(): + return self.build_authentication() + amap = {} + for itm in auth.split(", "): + (k,v) = [s.strip() for s in itm.split("=",1)] + amap[k] = v.replace('"','') + try: + username = amap['username'] + authpath = amap['uri'] + nonce = amap['nonce'] + realm = amap['realm'] + response = amap['response'] + assert authpath.split("?",1)[0] in path + assert realm == self.realm + qop = amap.get('qop','') + cnonce = amap.get('cnonce','') + nc = amap.get('nc','00000000') + if qop: + assert 'auth' == qop + assert nonce and nc + except: + return self.build_authentication() + ha1 = self.userfunc(realm,username) + return self.compute(ha1, username, response, method, authpath, + nonce, nc, cnonce, qop) + + __call__ = authenticate + +def AuthDigestHandler(application, realm, userfunc): + """ + This middleware implements HTTP Digest authentication (RFC 2617) on + the incoming request. There are several possible outcomes: + + 0. If the REMOTE_USER environment variable is already populated; + then this middleware is a no-op, and the request is passed along + to the application. + + 1. If the HTTP_AUTHORIZATION header was not provided, then a + HTTPUnauthorized exception is raised containing the challenge. + + 2. If the HTTP_AUTHORIZATION header specifies anything other + than digest; the REMOTE_USER is left unset and application + processing continues. + + 3. If the response is malformed or or if the user's credientials + do not pass muster, another HTTPUnauthorized is raised. + + 4. IF all goes well, and the user's credintials pass; then + REMOTE_USER environment variable is filled in and the + AUTH_TYPE is listed as 'digest'. + + Besides the application to delegate requests, this middleware + requires two additional arguments: + + realm: + This is a globally unique identifier used to indicate the + authority that is performing the authentication. The taguri + such as tag:yourdomain.com,2006 is sufficient. + + userfunc: + This is a callback function which performs the actual + authentication; the signature of this callback is: + + userfunc(realm, username) -> hashcode + + This module provides a 'digest_password' helper function which + can help construct the hashcode; it is recommended that the + hashcode is stored in a database, not the user's actual password. + """ + authenticator = DigestAuthenticator(realm, userfunc) + def digest_application(environ, start_response): + username = environ.get('REMOTE_USER','') + if not username: + method = environ['REQUEST_METHOD'] + fullpath = environ['SCRIPT_NAME'] + environ["PATH_INFO"] + authorization = environ.get('HTTP_AUTHORIZATION','') + result = authenticator(authorization, fullpath, method) + if isinstance(result, str): + environ['AUTH_TYPE'] = 'digest' + environ['REMOTE_USER'] = result + else: + return result.wsgi_application(environ, start_response) + return application(environ, start_response) + return digest_application + +middleware = AuthDigestHandler + +__all__ = ['digest_password', 'AuthDigestHandler' ] + +if '__main__' == __name__: + realm = 'tag:clarkevans.com,2005:digest' + def userfunc(realm, username): + return digest_password(username, realm, username) + from paste.wsgilib import dump_environ + from paste.util.baseserver import serve + from paste.httpexceptions import * + serve(HTTPExceptionHandler( + AuthDigestHandler(dump_environ, realm, userfunc))) diff --git a/paste/auth/form.py b/paste/auth/form.py new file mode 100644 index 0000000..b53952f --- /dev/null +++ b/paste/auth/form.py @@ -0,0 +1,73 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +HTTP Form Authentication + +""" +from paste.wsgilib import parse_formvars, construct_url + +template = """\ +<html> + <head><title>Please Login</title></head> + <body> + <h1>Please Login</h1> + <form action="%s" method="post"> + <dl> + <dt>Username:</dt> + <dd><input type="text" name="username"></dd> + <dt>Password:</dt> + <dd><input type="password" name="password"></dd> + </dl> + <input type="submit" name="authform" /> + <hr /> + </form> + </body> +</html> +""" + +def AuthFormHandler(application, userfunc, login_page = None): + """ This causes a HTML form to be returned if REMOTE_USER has not + been provided. This is a really simple implementation, it + requires that the query arguments returned from the form have two + variables "username" and "password". These are then passed to + the userfunc; which should return True if authentication is granted. + """ + login_page = login_page or template + def form_application(environ, start_response): + username = environ.get('REMOTE_USER','') + if username: + return application(environ, start_response) + if 'POST' == environ['REQUEST_METHOD']: + formvars = parse_formvars(environ) + username = formvars.get('username') + password = formvars.get('password') + if username and password: + if userfunc(username,password): + environ['AUTH_TYPE'] = 'form' + environ['REMOTE_USER'] = username + environ['REQUEST_METHOD'] = 'GET' + del environ['paste.parsed_formvars'] + return application(environ, start_response) + start_response("200 OK",(('Content-Type', 'text/html'), + ('Content-Length', len(login_page)))) + if "%s" in login_page: + return [login_page % construct_url(environ) ] + return [login_page] + return form_application + +middleware = AuthFormHandler + +__all__ = ['AuthFormHandler'] + +if '__main__' == __name__: + def userfunc(username, password): + return username == password + from paste.wsgilib import dump_environ + from paste.util.baseserver import serve + from paste.httpexceptions import * + from cookie import AuthCookieHandler + serve(HTTPExceptionHandler( + AuthCookieHandler( + AuthFormHandler(dump_environ, userfunc)))) diff --git a/paste/auth/multi.py b/paste/auth/multi.py new file mode 100644 index 0000000..1b593ae --- /dev/null +++ b/paste/auth/multi.py @@ -0,0 +1,81 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +Multi Authentication + +In some environments, the choice of authentication method to be used +depends upon the environment and is not "fixed". This middleware +allows N authentication methods to be registered along with a goodness +function which determines which method should be used. + +Strictly speaking this is not limited to authentication, but it is a +common requirement in that domain; this is why it isn't named +AuthMultiHandler (for now). +""" + +class MultiHandler: + """ This middleware provides two othogonal facilities: + (a) a way to register any number of middlewares + (b) a way to register predicates which cause one of + the registered middlewares to be used + If none of the predicates returns True, then the + application is invoked directly without middleware + """ + def __init__(self, application): + self.application = application + self.default = application + self.binding = {} + self.predicate = [] + def add_method(self, name, factory, *args, **kwargs): + self.binding[name] = factory(self.application, *args, **kwargs) + def add_predicate(self, name, checker): + self.predicate.append((checker,self.binding[name])) + def set_default(self, name): + """ + This method sets the default middleware to be executed, + if none of the rules apply. + """ + self.default = self.binding[name] + def set_query_argument(self, name, key = '*authmeth', value = None): + """ + This method indicates that the named middleware component should + be executed if the given key/value pair occurs in the query args. + """ + lookfor = "%s=%s" % (key, value or name) + self.add_predicate(name, + lambda environ: lookfor in environ.get('QUERY_STRING','')) + def __call__(self, environ, start_response): + for (checker,binding) in self.predicate: + if checker(environ): + return binding(environ, start_response) + return self.default(environ, start_response) + +middleware = MultiHandler + +__all__ = ['MultiHandler'] + +if '__main__' == __name__: + import basic, digest, cas, cookie, form + from paste.httpexceptions import * + from paste.wsgilib import dump_environ + from paste.util.baseserver import serve + multi = MultiHandler(dump_environ) + multi.add_method('basic',basic.middleware, + 'tag:clarkevans.com,2005:basic', + lambda n,p: n == p ) + multi.set_query_argument('basic') + multi.add_method('digest',digest.middleware, + 'tag:clarkevans.com,2005:digest', + lambda r,u: digest.digest_password(u,r,u)) + multi.set_query_argument('digest') + multi.add_method('form',lambda ap: cookie.middleware( + form.middleware(ap, + lambda n,p: n == p))) + multi.set_query_argument('form') + #authority = "https://secure.its.yale.edu/cas/servlet/" + #multi.add_method('cas',lambda ap: cookie.middleware( + # cas.middleware(ap,authority))) + #multi.set_default('cas') + serve(HTTPExceptionHandler(multi)) diff --git a/paste/debug/__init__.py b/paste/debug/__init__.py new file mode 100644 index 0000000..792d600 --- /dev/null +++ b/paste/debug/__init__.py @@ -0,0 +1 @@ +# diff --git a/paste/debug/testserver.py b/paste/debug/testserver.py new file mode 100755 index 0000000..832142c --- /dev/null +++ b/paste/debug/testserver.py @@ -0,0 +1,97 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +WSGI Test Server + +This builds upon paste.util.baseserver to customize it for regressions +where using raw_interactive won't do. + + +""" +import time +from paste.util.testserver import * + +class WSGIRegressionServer(WSGIServer): + """ + A threaded WSGIServer for use in regression testing. To use this + module, call serve(application, regression=True), and then call + server.accept() to let it handle one request. When finished, use + server.stop() to shutdown the server. Note that all pending requests + are processed before the server shuts down. + """ + defaulttimeout = 10 + def __init__ (self, *args, **kwargs): + WSGIServer.__init__(self, *args, **kwargs) + self.stopping = [] + self.pending = [] + self.timeout = self.defaulttimeout + # this is a local connection, be quick + self.socket.settimeout(2) + def serve_forever(self): + from threading import Thread + thread = Thread(target=self.serve_pending) + thread.start() + def reset_expires(self): + if self.timeout: + self.expires = time.time() + self.timeout + def close_request(self, *args, **kwargs): + WSGIServer.close_request(self, *args, **kwargs) + self.pending.pop() + self.reset_expires() + def serve_pending(self): + self.reset_expires() + while not self.stopping or self.pending: + now = time.time() + if now > self.expires and self.timeout: + # note regression test doesn't handle exceptions in + # threads very well; so we just print and exit + print "\nWARNING: WSGIRegressionServer timeout exceeded\n" + break + if self.pending: + self.handle_request() + time.sleep(.1) + def stop(self): + """ stop the server (called from tester's thread) """ + self.stopping.append(True) + def accept(self, count = 1): + """ accept another request (called from tester's thread) """ + assert not self.stopping + [self.pending.append(True) for x in range(count)] + +def serve(application, host=None, port=None, handler=None): + server = WSGIRegressionServer(application,host,port,handler) + print "serving on %s:%s" % server.server_address + try: + server.serve_forever() + except KeyboardInterrupt: + # allow CTRL+C to shutdown + pass + return server + +if __name__ == '__main__': + import urllib + from paste.wsgilib import dump_environ + server = serve(dump_environ) + baseuri = ("http://%s:%s" % server.server_address) + + def fetch(path): + # tell the server to humor exactly one more request + server.accept(1) + # not needed; but this is what you do if the server + # may not respond in a resonable time period + import socket + socket.setdefaulttimeout(5) + # build a uri, fetch and return + return urllib.urlopen(baseuri + path).read() + + assert "PATH_INFO: /foo" in fetch("/foo") + assert "PATH_INFO: /womble" in fetch("/womble") + + # ok, let's make one more final request... + server.accept(1) + # and then schedule a stop() + server.stop() + # and then... fetch it... + urllib.urlopen(baseuri) diff --git a/paste/exceptions/errormiddleware.py b/paste/exceptions/errormiddleware.py index 0a21cf0..275bfa4 100644 --- a/paste/exceptions/errormiddleware.py +++ b/paste/exceptions/errormiddleware.py @@ -31,46 +31,51 @@ class ErrorMiddleware(object): error_caching_wsgi_app = ErrorMiddleware(wsgi_app) - By setting 'paste.throw_errors' in the request environment to a - true value, this middleware is disabled. This can be useful in a - testing environment where you don't want errors to be caught and - transformed. - Settings: ``debug``: If true, then tracebacks will be shown in the browser. ``error_email``: - An email address (or list of addresses) to send exception reports - to. + an email address (or list of addresses) to send exception + reports to ``error_log``: - A filename to append tracebacks to. + a filename to append tracebacks to ``show_exceptions_in_wsgi_errors``: - If true, then errors will be printed to ``wsgi.errors`` (frequently - a server error log, or stderr). + If true, then errors will be printed to ``wsgi.errors`` + (frequently a server error log, or stderr). ``from_address``, ``smtp_server``, ``error_subject_prefix``: - Variables to control the emailed exception reports. + variables to control the emailed exception reports ``error_message``: When debug mode is off, the error message to show to users. ``xmlhttp_key``: - When this key (default ``_``) is in the request GET variables (not POST!), expect that this is an XMLHttpRequest, and the response should be more minimal; it should not be a complete HTML page. - This also looks for a special key ``'paste.expected_exceptions``, - which should be a list of exception classes. When an exception is - raised, if it is found in this list then it will be re-raised - instead of being caught. This should generally be set by - middleware that may (but probably shouldn't be) installed above - this middleware, and wants to get certain exceptions. + Environment Configuration: + + ``paste.throw_errors``: + If this setting in the request environment is true, then this + middleware is disabled. This can be useful in a testing situation + where you don't want errors to be caught and transformed. + + ``paste.expected_exceptions``: + When this middleware encounters an exception listed in this + environment variable and when the ``start_response`` has not + yet occurred, the exception will be re-raised instead of being + caught. This should generally be set by middleware that may + (but probably shouldn't be) installed above this middleware, + and wants to get certain exceptions. Exceptions raised after + ``start_response`` have been called are always caught since + by definition they are no longer expected. + """ def __init__(self, application, global_conf=None, @@ -124,28 +129,31 @@ class ErrorMiddleware(object): environ['paste.throw_errors'] = True def detect_start_response(status, headers, exc_info=None): - try: - return start_response(status, headers, exc_info) - except: - raise - else: - started.append(True) + started.append(True) + return start_response(status, headers, exc_info) + try: __traceback_supplement__ = Supplement, self, environ app_iter = self.application(environ, detect_start_response) return self.catching_iter(app_iter, environ) except: exc_info = sys.exc_info() - for expected in environ.get('paste.expected_exceptions', []): - if issubclass(exc_info[0], expected): - raise - if not started: - start_response('500 Internal Server Error', - [('content-type', 'text/html')], - exc_info) - # @@: it would be nice to deal with bad content types here - response = self.exception_handler(exc_info, environ) - return [response] + try: + if not started: + # Only delegate expected exceptions if the response + # has not been started. + for expect in environ.get('paste.expected_exceptions', []): + if issubclass(exc_info[0], expect): + raise + start_response('500 Internal Server Error', + [('content-type', 'text/html')], + exc_info) + # @@: it would be nice to deal with bad content types here + response = self.exception_handler(exc_info, environ) + return [response] + finally: + # clean up locals... + exc_info = None def catching_iter(self, app_iter, environ): __traceback_supplement__ = Supplement, self, environ diff --git a/paste/httpexceptions.py b/paste/httpexceptions.py index 2378b2c..c38618c 100644 --- a/paste/httpexceptions.py +++ b/paste/httpexceptions.py @@ -1,57 +1,287 @@ -# (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org) -# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php - +# (c) 2005 Ian Bicking, Clark C. Evans and contributors +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# Some of this code was funded by http://prometheusresearch.com """ -WSGI middleware - -Processes Python exceptions that relate to HTTP exceptions. This -defines a set of extensions, all subclasses of HTTPException, and a -middleware (`middleware`) that catches these exceptions and turns them -into proper responses. - -Note: if ``'paste.debug_suppress_httpexceptions'`` is in the request -and is true, then this middleware will be skipped. +HTTP Exception Middleware + +This module processes Python exceptions that relate to HTTP exceptions +by defining a set of exceptions, all subclasses of HTTPException, and a +request handler (`middleware`) that catches these exceptions and turns +them into proper responses. + +This module defines exceptions according to RFC 2068 [1]: codes with +100-300 are not really errors; 400's are client errors, and 500's are +server errors. According to the WSGI specification [2], the application +can call ``start_response`` more then once only under two conditions: +(a) the response has not yet been sent, or (b) if the second and +subsequent invocations of ``start_response`` have a valid ``exc_info`` +argument obtained from ``sys.exc_info()``. The WSGI specification then +requires the server or gateway to handle the case where content has been +sent and then an exception was encountered. + +Exceptions in the 5xx range and those raised after ``start_response`` +has been called are treated as serious errors and the ``exc_info`` is +filled-in with information needed for a lower level module to generate a +stack trace and log information. + +References: +[1] http://www.python.org/peps/pep-0333.html#error-handling +[2] http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.5 + +Exception + HTTPException + HTTPRedirection + # 300 Multiple Choices + 301 - HTTPMovedPermanently + 302 - HTTPFound + 303 - HTTPSeeOther + 304 - HTTPNotModified + 305 - HTTPUseProxy + # 306 Unused + 307 - HTTPTemporaryRedirect + HTTPError + HTTPClientError + 400 - HTTPBadRequest + 401 - HTTPUnauthorized + # 402 Payment Required + 403 - HTTPForbidden + 404 - HTTPNotFound + 405 - HTTPMethodNotAllowed + 406 - HTTPNotAcceptable + # 407 Proxy Authentication Required + # 408 Request Timeout + 409 - HTTPConfict + 410 - HTTPGone + 411 - HTTPLengthRequired + 412 - HTTPPreconditionFailed + 413 - HTTPRequestEntityTooLarge + 414 - HTTPRequestURITooLong + 415 - HTTPUnsupportedMediaType + 416 - HTTPRequestRangeNotSatisfiable + 417 - HTTPExpectationFailed + HTTPServerError + 500 - HTTPInternalServerError + 501 - HTTPNotImplemented + 502 - HTTPBadGateway + 503 - HTTPServiceUnavailable + 504 - HTTPGatewayTimeout + 505 - HTTPVersionNotSupported """ import types +import sys +from wsgilib import has_header, header_value +from util.quoting import strip_html, html_quote class HTTPException(Exception): + """ + Base class for all HTTP exceptions + + This encapsulates an HTTP response that interrupts normal application + flow; but one which is not necessarly an error condition. For + example, codes in the 300's are exceptions in that they interrupt + normal processing; however, they are not considered errors. + + This class is complicated by 4 factors: + + 1. The content given to the exception may either be plain-text or + as html-text. + + 2. The template may want to have string-substitutions taken from + the current ``environ`` or values from incoming headers. This + is especially troublesome due to case sensitivity. + + 3. The final output may either be text/plain or text/html + mime-type as requested by the client application. + + 4. Each exception has a default explanation, but those who + raise exceptions may want to provide additional detail. + + Attributes: + + ``code`` + the HTTP status code for the exception + + ``title`` + remainder of the status line (stuff after the code) + + ``explanation`` + a plain-text explanation of the error message that is + not subject to environment or header substitutions; + it is accessable in the template via %(explanation)s + + ``detail`` + a plain-text message customization that is not subject + to environment or header substutions; accessable in + the template via %(detail)s + + ``template`` + a content fragment (in HTML) used for environment and + header substution; the default template includes both + the explanation and further detail provided in the + message + + ``required_headers`` + a sequence of headers which are required for proper + construction of the exception + + Parameters: + + ``detail`` a plain-text override of the default ``detail`` + ``headers`` a list of (k,v) header pairs + ``comment`` a plain-text additional information which is + usually stripped/hidden for end-users + + To override the template (which is HTML content) or the plain-text + explanation, one must subclass the given exception; or customize it + after it has been created. This particular breakdown of a message + into explanation, detail and template allows both the creation of + plain-text and html messages for various clients as well as + error-free substution of environment variables and headers. + """ + code = None title = None - message = None - # @@: not currently used: + explanation = '' + detail = '' + comment = '' + template = "%(explanation)s\n<br/>%(detail)s\n<!-- %(comment)s -->" required_headers = () - def __init__(self, message=None, headers=None): - self.headers = headers - if message is not None: - self.message = message - Exception.__init__(self, self.message) + server_name = 'WSGI server' + + def __init__(self, detail=None, headers=None, comment=None): + assert self.code, "Do not directly instantiate abstract exceptions." + assert isinstance(headers, (type(None), list)) + assert isinstance(detail, (type(None), basestring)) + assert isinstance(comment, (type(None), basestring)) + self.headers = headers or tuple() + for req in self.required_headers: + assert has_header(headers, req) + if detail is not None: + self.detail = detail + if comment is not None: + self.comment = comment + Exception.__init__(self,"%s %s\n%s\n%s\n" % ( + self.code, self.title, self.explanation, self.detail)) + + def make_body(self, environ, template, escfunc): + args = {'explanation': escfunc(self.explanation), + 'detail': escfunc(self.detail), + 'comment': escfunc(self.comment)} + if HTTPException.template == self.template: + return template % args + for (k, v) in environ.items(): + args[k] = escfunc(v) + if self.headers: + for (k, v) in self.headers: + args[k.lower()] = escfunc(v) + return template % args + + def plain(self, environ): + """ text/plain representation of the exception """ + noop = lambda _: _ + body = self.make_body(environ, strip_html(self.template), noop) + return ('%s %s\n%s\n' % (self.code, self.title, body)) def html(self, environ): - message = self.message - args = environ.copy() - if self.headers: - args.update(self.headers) - message = message % args + """ text/html representation of the exception """ + body = self.make_body(environ, self.template, html_quote) return ('<html><head><title>%(title)s</title></head>\n' '<body>\n' '<h1>%(title)s</h1>\n' - '<p>%(message)s</p>\n' + '<p>%(body)s</p>\n' '<hr noshade>\n' - '<div align="right">WSGI server</div>\n' + '<div align="right">%(server)s</div>\n' '</body></html>\n' % {'title': self.title, 'code': self.code, - 'message': message}) + 'server': self.server_name, + 'body': body}) + + def wsgi_application(self, environ, start_response, exc_info=None): + """ + This exception as a WSGI application + """ + if 'html' in environ.get('HTTP_ACCEPT',''): + headers = {'content-type': 'text/html'} + content = self.html(environ) + else: + headers = {'content-type': 'text/plain'} + content = self.plain(environ) + if self.headers: + headers.update(self.headers) + if isinstance(content, unicode): + content = content.encode('utf8') + headers['content_type'] += '; charset=utf8' + start_response('%s %s' % (self.code, self.title), + headers.items(), + exc_info) + yield content + def __repr__(self): return '<%s %s; code=%s>' % (self.__class__.__name__, self.title, self.code) -class _HTTPMove(HTTPException): +class HTTPError(HTTPException): + """ + This is an exception which indicates that an error has occured, + and that any work in progress should not be committed. These are + typically results in the 400's and 500's. + """ + +# +# 3xx Redirection +# +# This class of status code indicates that further action needs to be +# taken by the user agent in order to fulfill the request. The action +# required MAY be carried out by the user agent without interaction with +# the user if and only if the method used in the second request is GET or +# HEAD. A client SHOULD detect infinite redirection loops, since such +# loops generate network traffic for each redirection. +# + +class HTTPRedirection(HTTPException): + """ + This is an abstract base class for 3xx redirection. It indicates + that further action needs to be taken by the user agent in order + to fulfill the request. It does not necessarly signal an error + condition. + """ + +class _HTTPMove(HTTPRedirection): + """ + Base class for redirections which require a Location field. + + Since a 'Location' header is a required attribute of 301, 302, 303, + 305 and 307 (but not 304), this base class provides the mechanics to + make this easy. While this has the same parameters as HTTPException, + if a location is not provided in the headers; it is assumed that the + detail _is_ the location (this for backward compatibility, otherwise + we'd add a new attribute). + """ required_headers = ('location',) - message = ('The resource has been moved to <a href="%(location)s">' - '%(location)s</a>; you should be redirected automatically.') + explanation = 'The resource has been moved to' + template = ( + '%(explanation)s <a href="%(location)s">%(location)s</a>;\n' + 'you should be redirected automatically.\n' + '%(detail)s\n') + + def __init__(self, detail=None, headers=None, comment=None): + assert isinstance(headers, (type(None), list)) + headers = headers or [] + location = header_value(headers,'location') + if not location: + location = detail + detail = '' + headers.append(('location', location)) + assert location, ("HTTPRedirection specified neither a " + "location in the headers nor did it " + "provide a detail argument.") + HTTPRedirection.__init__(self, location, headers, comment) + if detail is not None: + self.detail = detail class HTTPMovedPermanently(_HTTPMove): code = 301 @@ -60,6 +290,7 @@ class HTTPMovedPermanently(_HTTPMove): class HTTPFound(_HTTPMove): code = 302 title = 'Found' + explanation = 'The resource was found at' # This one is safe after a POST (the redirected location will be # retrieved with GET): @@ -67,142 +298,206 @@ class HTTPSeeOther(_HTTPMove): code = 303 title = 'See Other' -class HTTPNotModified(HTTPException): +class HTTPNotModified(HTTPRedirection): # @@: but not always (HTTP section 14.18.1)...? required_headers = ('date',) code = 304 title = 'Not Modified' message = '' # @@: should include date header, optionally other headers + # @@: should not return a content body + def plain(self, environ): + return '' + def html(self, environ): + """ text/html representation of the exception """ + return '' class HTTPUseProxy(_HTTPMove): # @@: OK, not a move, but looks a little like one code = 305 title = 'Use Proxy' - message = ('This resource must be accessed through the proxy located ' - 'at <a href="%(location)s">%(location)s</a>') + explanation = ( + 'The resource must be accessed through a proxy ' + 'located at') class HTTPTemporaryRedirect(_HTTPMove): code = 307 title = 'Temporary Redirect' -class HTTPBadRequest(HTTPException): +# +# 4xx Client Error +# +# The 4xx class of status code is intended for cases in which the client +# seems to have erred. Except when responding to a HEAD request, the +# server SHOULD include an entity containing an explanation of the error +# situation, and whether it is a temporary or permanent condition. These +# status codes are applicable to any request method. User agents SHOULD +# display any included entity to the user. +# + +class HTTPClientError(HTTPError): + """ + This is an error condition in which the client is presumed to be + in-error. This is an expected problem, and thus is not considered + a bug. A server-side traceback is not warranted. Unless specialized, + this is a '400 Bad Request' + """ code = 400 title = 'Bad Request' - message = ('The server could not understand your request') + explanation = 'The server could not understand your request.' + +HTTPBadRequest = HTTPClientError -class HTTPUnauthorized(HTTPException): +class HTTPUnauthorized(HTTPClientError): required_headers = ('WWW-Authenticate',) code = 401 title = 'Unauthorized' - # @@: should require WWW-Authenticate header - message = ('Authorization is required to access this resource; ' - 'you must login.') + explanation = ( + 'This server could not verify that you are authorized to\n' + 'access the document you requested. Either you supplied the\n' + 'wrong credentials (e.g., bad password), or your browser\n' + 'does not understand how to supply the credentials required.\n') -class HTTPForbidden(HTTPException): +class HTTPForbidden(HTTPClientError): code = 403 title = 'Forbidden' - message = ('Access was denied to this resource.') + explanation = ('Access was denied to this resource.') -class HTTPNotFound(HTTPException): +class HTTPNotFound(HTTPClientError): code = 404 title = 'Not Found' - message = ('The resource could not be found.') + explanation = ('The resource could not be found.') -class HTTPMethodNotAllowed(HTTPException): +class HTTPMethodNotAllowed(HTTPClientError): required_headers = ('allowed',) code = 405 title = 'Method Not Allowed' - message = ('The method %(REQUEST_METHOD)s is not allowed for this ' - 'resource.') + # override template since we need an environment variable + template = ('The method %(REQUEST_METHOD)s is not allowed for ' + 'this resource.\n%(detail)s') -class HTTPNotAcceptable(HTTPException): +class HTTPNotAcceptable(HTTPClientError): code = 406 title = 'Not Acceptable' - message = ('The resource could not be generated that was acceptable ' - 'to your browser (content of type %(HTTP_ACCEPT)s).') + # override template since we need an environment variable + template = ('The resource could not be generated that was ' + 'acceptable to your browser (content\nof type ' + '%(HTTP_ACCEPT)s).\n%(detail)s') +<<<<<<< .working class HTTPConflict(HTTPException): +======= +class HTTPConflict(HTTPClientError): +>>>>>>> .merge-right.r4008 code = 409 title = 'Conflict' - message = ('There was a conflict when trying to complete your ' - 'request.') + explanation = ('There was a conflict when trying to complete ' + 'your request.') -class HTTPGone(HTTPException): +class HTTPGone(HTTPClientError): code = 410 title = 'Gone' - message = ('This resource is no longer available. No forwarding ' - 'address is aavailable.') + explanation = ('This resource is no longer available. No forwarding ' + 'address is given.') -class HTTPLengthRequired(HTTPException): +class HTTPLengthRequired(HTTPClientError): code = 411 title = 'Length Required' - message = ('Content-Length header required.') + explanation = ('Content-Length header required.') -class HTTPPreconditionFailed(HTTPException): +class HTTPPreconditionFailed(HTTPClientError): code = 412 title = 'Precondition Failed' - message = ('Request precondition failed.') + explanation = ('Request precondition failed.') -class HTTPRequestEntityTooLarge(HTTPException): +class HTTPRequestEntityTooLarge(HTTPClientError): code = 413 title = 'Request Entity Too Large' - message = ('The body of your request was too large for this server.') + explanation = ('The body of your request was too large for this server.') -class HTTPRequestURITooLong(HTTPException): +class HTTPRequestURITooLong(HTTPClientError): code = 414 title = 'Request-URI Too Long' - message = ('The request URI was too long for this server.') + explanation = ('The request URI was too long for this server.') -class HTTPUnsupportedMediaType(HTTPException): +class HTTPUnsupportedMediaType(HTTPClientError): code = 415 title = 'Unsupported Media Type' - message = ('The request media type %(CONTENT_TYPE)s is not ' - 'supported by this server.') + # override template since we need an environment variable + template = ('The request media type %(CONTENT_TYPE)s is not ' + 'supported by this server.\n%(detail)s') -class HTTPRequestRangeNotSatisfiable(HTTPException): +class HTTPRequestRangeNotSatisfiable(HTTPClientError): code = 416 title = 'Request Range Not Satisfiable' - message = ('The Range requested is not available.') + explanation = ('The Range requested is not available.') -class HTTPExpectationFailed(HTTPException): +class HTTPExpectationFailed(HTTPClientError): code = 417 title = 'Expectation Failed' - message = ('Expectation failed.') - -class HTTPServerError(HTTPException): + explanation = ('Expectation failed.') + +# +# 5xx Server Error +# +# Response status codes beginning with the digit "5" indicate cases in +# which the server is aware that it has erred or is incapable of +# performing the request. Except when responding to a HEAD request, the +# server SHOULD include an entity containing an explanation of the error +# situation, and whether it is a temporary or permanent condition. User +# agents SHOULD display any included entity to the user. These response +# codes are applicable to any request method. +# + +class HTTPServerError(HTTPError): + """ + This is an error condition in which the server is presumed to be + in-error. This is usually unexpected, and thus requires a traceback; + ideally, opening a support ticket for the customer. Unless specialized, + this is a '500 Internal Server Error' + """ code = 500 title = 'Internal Server Error' - message = ('An internal server error occurred.') + explanation = ('An internal server error occurred.') +<<<<<<< .working class HTTPNotImplemented(HTTPException): code = 501 +======= +HTTPInternalServerError = HTTPServerError + +class HTTPNotImplemented(HTTPServerError): + code = 501 +>>>>>>> .merge-right.r4008 title = 'Not Implemented' - message = ('The request method %(REQUEST_METHOD)s is not implemented ' - 'for this server.') + # override template since we need an environment variable + template = ('The request method %(REQUEST_METHOD)s is not implemented ' + 'for this server.\n%(detail)s') -class HTTPBadGateway(HTTPException): +class HTTPBadGateway(HTTPServerError): code = 502 title = 'Bad Gateway' - message = ('Bad gateway.') + explanation = ('Bad gateway.') -class HTTPServiceUnavailable(HTTPException): +class HTTPServiceUnavailable(HTTPServerError): code = 503 title = 'Service Unavailable' - message = ('The server is currently unavailable. Please try again ' - 'at a later time.') + explanation = ('The server is currently unavailable. ' + 'Please try again at a later time.') -class HTTPGatewayTimeout(HTTPException): +class HTTPGatewayTimeout(HTTPServerError): code = 504 title = 'Gateway Timeout' - message = ('The gateway has timed out.') + explanation = ('The gateway has timed out.') -class HTTPHttpVersionNotSupported(HTTPException): +class HTTPVersionNotSupported(HTTPServerError): code = 505 title = 'HTTP Version Not Supported' - message = ('The HTTP version is not supported.') + explanation = ('The HTTP version is not supported.') + +# abstract HTTP related exceptions +__all__ = ['HTTPException', 'HTTPRedirection', 'HTTPError' ] -__all__ = [] _exceptions = {} for name, value in globals().items(): if (isinstance(value, (type, types.ClassType)) and @@ -210,6 +505,7 @@ for name, value in globals().items(): value.code): _exceptions[value.code] = value __all__.append(name) + def get_exception(code): return _exceptions[code] @@ -217,40 +513,61 @@ def get_exception(code): ## Middleware implementation: ############################################################ -def middleware(application, global_conf=None): - +class HTTPExceptionHandler: """ This middleware catches any exceptions (which are subclasses of - `HTTPException`) and turns them into proper HTTP responses. + ``HTTPException``) and turns them into proper HTTP responses. + + Attributes: + + ``warning_level`` + This attribute determines for what exceptions a stack + trace is kept for lower level reporting; by default, it + only keeps stack trace for 5xx, HTTPServerError exceptions. + To keep a stack trace for 4xx, HTTPClientError exceptions, + set this to 400. + + + + Note if the headers have already been sent, the stack trace is + always maintained as this indicates a programming error. + """ - def start_application(environ, start_response): - environ.setdefault('paste.expected_exceptions', []).append( - HTTPException) - app_started = [] - def checked_start_response(status, headers, exc_info=None): - app_started.append(None) + def __init__(self, application, global_conf=None, warning_level=None): + assert not warning_level or ( warning_level > 99 and + warning_level < 600) + self.warning_level = warning_level or 500 + self.application = application + + def __call__(self, environ, start_response): + environ['paste.httpexceptions'] = self + environ.setdefault('paste.expected_exceptions', + []).append(HTTPException) + headers_sent = [] + def httpexce_start_response(status, headers, exc_info = None): + headers_sent.append(True) return start_response(status, headers, exc_info) - try: - v = application(environ, checked_start_response) - environ['paste.expected_exceptions'].remove(HTTPException) - return v + result = self.application(environ, httpexce_start_response) + for chunk in result: + yield chunk except HTTPException, e: if environ.get('paste.debug_suppress_httpexceptions'): raise - if app_started: - # They've already started the response, so we can't - # do the right thing anymore. - raise - headers = {'content-type': 'text/html'} - if e.headers: - headers.update(e.headers) - start_response('%s %s' % (e.code, e.title), - headers.items()) - return [e.html(environ)] - - return start_application - -__all__.extend(['middleware', 'get_exception']) + if headers_sent or e.code >= self.warning_level: + exc_info = sys.exc_info() + else: + exc_info = None + try: + result = e.wsgi_application(environ, start_response, exc_info) + finally: + # clean up + exc_info = None + for chunk in result: + yield chunk + +middleware = HTTPExceptionHandler + +__all__.extend(['HTTPExceptionHandler', 'get_exception']) diff --git a/paste/lint.py b/paste/lint.py index b3bee02..916f1af 100644 --- a/paste/lint.py +++ b/paste/lint.py @@ -192,7 +192,8 @@ def check_environ(environ): check_errors(environ['wsgi.errors']) # @@: these need filling out: - assert environ['REQUEST_METHOD'] in ('GET', 'HEAD', 'POST'), ( + assert environ['REQUEST_METHOD'] in ('GET', 'HEAD', 'POST', + 'OPTIONS','PUT','DELETE','TRACE'), ( "Unknown REQUEST_METHOD: %r" % environ['REQUEST_METHOD']) assert (not environ.get('SCRIPT_NAME') @@ -262,16 +263,17 @@ def check_headers(headers): def check_content_type(status, headers): code = int(status.split(None, 1)[0]) - if code == 204: - # 204 No Content is the only code where there's no body, - # and so it doesn't need a content-type header. - # @@: Not 100% sure this is the only case where a content-type - # header can be left out - return + # @@: need one more person to verify this interpretation of RFC 2616 + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html + NO_MESSAGE_BODY = (204,304) for name, value in headers: if name.lower() == 'content-type': - return - assert 0, "No Content-Type header found in headers (%s)" % headers + if code not in NO_MESSAGE_BODY: + return + assert 0, (("Content-Type header found in a %s response, " + "which must not return content.") % code) + if code not in NO_MESSAGE_BODY: + assert 0, "No Content-Type header found in headers (%s)" % headers def check_exc_info(exc_info): assert not exc_info or type(exc_info) is type(()), ( diff --git a/paste/request.py b/paste/request.py new file mode 100644 index 0000000..d09dfee --- /dev/null +++ b/paste/request.py @@ -0,0 +1,214 @@ +# (c) 2005 Ian Bicking and contributors +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +""" +This module provides helper routines with work directly on a WSGI +environment to solve common requirements. + + * get_cookies(environ) + * parse_querystring(environ) + * parse_formvars(environ, all_as_list=False, include_get_vars=True) + * construct_url(environ, with_query_string=True, with_path_info=True, + script_name=None, path_info=None, querystring=None) + * path_info_split(path_info) + * path_info_pop(environ) + +""" +import cgi, string +from Cookie import SimpleCookie + +__all__ = ['get_cookies', 'parse_querystring', 'parse_formvars', + 'construct_url', 'path_info_split', 'path_info_pop'] + +def get_cookies(environ): + """ + Gets a cookie object (which is a dictionary-like object) from the + request environment; caches this value in case get_cookies is + called again for the same request. + """ + header = environ.get('HTTP_COOKIE', '') + if environ.has_key('paste.cookies'): + cookies, check_header = environ['paste.cookies'] + if check_header == header: + return cookies + cookies = SimpleCookie() + cookies.load(header) + environ['paste.cookies'] = (cookies, header) + return cookies + +def parse_querystring(environ): + """ + Parses a query string into a list like ``[(name, value)]``. + Caches this value in case parse_querystring is called again + for the same request. + + You can pass the result to ``dict()``, but be aware that keys that + appear multiple times will be lost (only the last value will be + preserved). + """ + source = environ.get('QUERY_STRING', '') + if not source: + return [] + if 'paste.parsed_querystring' in environ: + parsed, check_source = environ['paste.parsed_querystring'] + if check_source == source: + return parsed + parsed = cgi.parse_qsl(source, keep_blank_values=True, + strict_parsing=False) + environ['paste.parsed_querystring'] = (parsed, source) + return parsed + +def parse_formvars(environ, all_as_list=False, include_get_vars=True): + """ + Parses the request, returning a dictionary of the keys. + + If ``all_as_list`` is true, then all values will be lists. If + not, then only values that show up multiple times will be lists. + + If ``include_get_vars`` is true and this was a POST request, then + GET (query string) variables will also be folded into the + dictionary. + + All values should be strings, except for file uploads which are + left as FieldStorage instances. + """ + source = (environ.get('QUERY_STRING', ''), + environ['wsgi.input'], environ['REQUEST_METHOD'], + all_as_list, include_get_vars) + if 'paste.parsed_formvars' in environ: + parsed, check_source = environ['paste.parsed_formvars'] + if check_source == source: + return parsed + fs = cgi.FieldStorage(fp=environ['wsgi.input'], + environ=environ, + keep_blank_values=1) + formvars = {} + for name in fs.keys(): + values = fs[name] + if not isinstance(values, list): + values = [values] + for value in values: + if not value.filename: + value = value.value + if name in formvars: + if isinstance(formvars[name], list): + formvars[name].append(value) + else: + formvars[name] = [formvars[name], value] + elif all_as_list: + formvars[name] = [value] + else: + formvars[name] = value + if environ['REQUEST_METHOD'] == 'POST' and include_get_vars: + for name, value in parse_querystring(environ): + if name in formvars: + if isinstance(formvars[name], list): + formvars[name].append(value) + else: + formvars[name] = [formvars[name], value] + elif all_as_list: + formvars[name] = [value] + else: + formvars[name] = value + environ['paste.parsed_formvars'] = (formvars, source) + return formvars + +def construct_url(environ, with_query_string=True, with_path_info=True, + script_name=None, path_info=None, querystring=None): + """ + Reconstructs the URL from the WSGI environment. You may override + SCRIPT_NAME, PATH_INFO, and QUERYSTRING with the keyword + arguments. + """ + url = environ['wsgi.url_scheme']+'://' + + if environ.get('HTTP_HOST'): + url += environ['HTTP_HOST'].split(':')[0] + else: + url += environ['SERVER_NAME'] + + if environ['wsgi.url_scheme'] == 'https': + if environ['SERVER_PORT'] != '443': + url += ':' + environ['SERVER_PORT'] + else: + if environ['SERVER_PORT'] != '80': + url += ':' + environ['SERVER_PORT'] + + if script_name is None: + url += environ.get('SCRIPT_NAME','') + else: + url += script_name + if with_path_info: + if path_info is None: + url += environ.get('PATH_INFO','') + else: + url += path_info + if with_query_string: + if querystring is None: + if environ.get('QUERY_STRING'): + url += '?' + environ['QUERY_STRING'] + elif querystring: + url += '?' + querystring + return url + +def path_info_split(path_info): + """ + Splits off the first segment of the path. Returns (first_part, + rest_of_path). first_part can be None (if PATH_INFO is empty), '' + (if PATH_INFO is '/'), or a name without any /'s. rest_of_path + can be '' or a string starting with /. + """ + if not path_info: + return None, '' + assert path_info.startswith('/'), ( + "PATH_INFO should start with /: %r" % path_info) + path_info = path_info.lstrip('/') + if '/' in path_info: + first, rest = path_info.split('/', 1) + return first, '/' + rest + else: + return path_info, '' + +def path_info_pop(environ): + """ + 'Pops' off the next segment of PATH_INFO, pushing it onto + SCRIPT_NAME, and returning that segment. + + For instance:: + + >>> def call_it(script_name, path_info): + ... env = {'SCRIPT_NAME': script_name, 'PATH_INFO': path_info} + ... result = path_info_pop(env) + ... print 'SCRIPT_NAME=%r; PATH_INFO=%r; returns=%r' % ( + ... env['SCRIPT_NAME'], env['PATH_INFO'], result) + >>> call_it('/foo', '/bar') + SCRIPT_NAME='/foo/bar'; PATH_INFO=''; returns='bar' + >>> call_it('/foo/bar', '') + SCRIPT_NAME='/foo/bar'; PATH_INFO=''; returns=None + >>> call_it('/foo/bar', '/') + SCRIPT_NAME='/foo/bar/'; PATH_INFO=''; returns='' + >>> call_it('', '/1/2/3') + SCRIPT_NAME='/1'; PATH_INFO='/2/3'; returns='1' + >>> call_it('', '//1/2') + SCRIPT_NAME='//1'; PATH_INFO='/2'; returns='1' + """ + path = environ.get('PATH_INFO', '') + if not path: + return None + while path.startswith('/'): + environ['SCRIPT_NAME'] += '/' + path = path[1:] + if '/' not in path: + environ['SCRIPT_NAME'] += path + environ['PATH_INFO'] = '' + return path + else: + segment, path = path.split('/', 1) + environ['PATH_INFO'] = '/' + path + environ['SCRIPT_NAME'] += segment + return segment + +if __name__ == '__main__': + import doctest + doctest.testmod() + diff --git a/paste/transaction.py b/paste/transaction.py new file mode 100644 index 0000000..17b434d --- /dev/null +++ b/paste/transaction.py @@ -0,0 +1,79 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +""" +Middleware related to transactions and database connections. + +At this time it is very basic; but will eventually sprout all that +two-phase commit goodness that I don't need. +""" +from paste.httpexceptions import HTTPError, HTTPException + +class ConnectionFactory(object): + """ + Provides a callable interface for connecting to ADBAPI databases in + a WSGI style (using the environment). More advanced connection + factories might use the REMOTE_USER and/or other environment + variables to make the connection returned depend upon the request. + """ + def __init__(self, module, *args, **kwargs): + #assert getattr(module,'threadsaftey',0) > 0 + self.module = module + self.args = args + self.kwargs = kwargs + + # deal with database string quoting issues + self.quote = lambda s: "'%s'" % s.replace("'","''") + if hasattr(self.module,'PgQuoteString'): + self.quote = self.module.PgQuoteString + + def __call__(self, environ): + conn = self.module.connect(*self.args,**self.kwargs) + conn.__dict__['module'] = self.module + conn.__dict__['quote'] = self.quote + return conn + +def BasicTransactionHandler(application, factory): + """ + Provides a simple mechanism for starting a transaction based on the + factory; and for either committing or rolling back the transaction + depending on the result. It checks for the response's current + status code either through the latest call to start_response; or + through a HTTPException's code. If it is a 100, 200, or 300; the + transaction is committed; otherwise it is rolled back. + """ + + def basic_transaction(environ, start_response): + conn = factory(environ) + environ['paste.connection'] = conn + should_commit = [500] + def finalizer(): + if should_commit.pop() < 400: + conn.commit() + else: + conn.rollback() + conn.close() + def basictrans_start_response(status, headers, exc_info = None): + should_commit.append(int(status.split(" ")[0])) + return start_response(status, headers, exc_info) + try: + for chunk in application(environ, basictrans_start_response): + yield chunk + except Exception, e: + if isinstance(e,HTTPException): + should_commit.append(e.code) + finalizer() + raise + finalizer() + return basic_transaction + +__all__ = ['ConnectionFactory','BasicTransactionHandler'] + +if '__main__' == __name__ and False: + from pyPgSQL import PgSQL + factory = ConnectionFactory(PgSQL,database="testing") + conn = factory(None) + curr = conn.cursor() + curr.execute("SELECT now(), %s" % conn.quote("B'n\\'gles")) + (time,bing) = curr.fetchone() + print bing, time diff --git a/paste/util/baseserver.py b/paste/util/baseserver.py new file mode 100755 index 0000000..599dc2d --- /dev/null +++ b/paste/util/baseserver.py @@ -0,0 +1,127 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +# This code was written with funding by http://prometheusresearch.com +""" +WSGI Base Server + +Very minimalistic WSGI server using Python's built-in BaseHTTPServer; it +is intended for use in the regression tests suites using a separate +thread for urlib2 requests. This is probably not a good thing to use in +a production setting; the focus here is transparency, not robustness. +""" + +import BaseHTTPServer, SocketServer +import urlparse, sys, time, socket +try: + from paste.httpexceptions import HTTPServerError +except ImportError: + # so we can run this module independent of paste + HTTPServerError = RuntimeError + +__all__ = ['WSGIServer','WSGIHandler', 'serve'] + +class WSGIHandler(BaseHTTPServer.BaseHTTPRequestHandler): + server_version = 'WSGIHandler/0.1' + protocol_version = 'HTTP/1.0' + + def write_chunk(self, chunk): + if not self.headers_sent: + self.headers_sent = True + (status, headers) = self.curr_headers + code, message = status.split(" ",1) + self.send_response(int(code),message) + for (k,v) in headers: + self.send_header(k,v) + self.end_headers() + self.wfile.write(chunk) + + def start_response(self,status,response_headers,exc_info=None): + if exc_info: + try: + if self.headers_sent: + raise exc_info[0], exc_info[1], exc_info[2] + else: + self.log_error(exc_info) + finally: + exc_info = None + elif self.curr_headers: + assert 0, "Attempt to set headers a second time w/o an exc_info" + self.curr_headers = (status, response_headers) + return self.write_chunk + + def run_application(self, environ): + try: + result = self.server.application(environ, self.start_response) + try: + for chunk in result: + self.write_chunk(chunk) + finally: + if hasattr(result,'close'): + result.close() + except socket.error, exce: + self.log_error("Network Error: %s", exce) + return + except: + if not self.headers_sent: + self.curr_headers = ('500 Internal Server Error', + [('Content-type', 'text/plain')]) + self.write_chunk("Internal Server Error\n") + raise + + def do_GET(self): + (_,_,path,query,fragment) = urlparse.urlsplit(self.path) + (server_name, server_port) = self.server.server_address + env = { 'wsgi.version': (1,0) + ,'wsgi.url_scheme': 'http' + ,'wsgi.input': self.rfile + ,'wsgi.errors': sys.stderr + ,'wsgi.multithread': True + ,'wsgi.multiprocess': False + ,'wsgi.run_once': True + # CGI variables required by PEP-333 + ,'REQUEST_METHOD': self.command + ,'SCRIPT_NAME': '' # application is root of server + ,'PATH_INFO': path + ,'QUERY_STRING': query + ,'CONTENT_TYPE': self.headers.get('Content-Type', '') + ,'CONTENT_LENGTH': self.headers.get('Content-Length', '') + ,'REQUEST_SCHEME': 'http' + ,'SERVER_NAME': server_name + ,'SERVER_PORT': str(server_port) + ,'SERVER_PROTOCOL': self.request_version + # CGI not required by PEP-333 + ,'REMOTE_ADDR': self.client_address[0] + ,'REMOTE_HOST': self.address_string() + } + for k,v in self.headers.items(): + env['HTTP_%s' % k.replace ('-', '_').upper()] = v + self.curr_headers = None + self.headers_sent = False + self.run_application(env) + + do_POST = do_GET + +class WSGIServer(SocketServer.ThreadingMixIn, BaseHTTPServer.HTTPServer): + def __init__ (self, application, host=None, port=None, handler=None): + server_address = (host or "127.0.0.1", port or 8080) + BaseHTTPServer.HTTPServer.__init__ (self,server_address, + handler or WSGIHandler) + self.application = application + +def serve(application, host=None, port=None, handler=None): + server = WSGIServer(application,host,port,handler) + print "serving on %s:%s" % server.server_address + try: + server.serve_forever() + except KeyboardInterrupt: + # allow CTRL+C to shutdown + pass + return server + +if __name__ == '__main__': + # serve exactly 3 requests and then stop, use an external + # program like wget or curl to submit these 3 requests. + import os + from paste.wsgilib import dump_environ + serve(dump_environ) diff --git a/paste/util/quoting.py b/paste/util/quoting.py index d029518..5f42e6a 100644 --- a/paste/util/quoting.py +++ b/paste/util/quoting.py @@ -6,7 +6,8 @@ import htmlentitydefs import urllib import re -__all__ = ['html_quote', 'html_unquote', 'url_quote', 'url_unquote'] +__all__ = ['html_quote', 'html_unquote', 'url_quote', 'url_unquote', + 'strip_html'] default_encoding = 'UTF-8' @@ -59,6 +60,13 @@ def html_unquote(s, encoding=None): s = s.decode(encoding or default_encoding) return _unquote_re.sub(_entity_subber, s) +def strip_html(s): + # should this use html_unquote? + s = re.sub('<.*?>', '', s) + s = s.replace(' ', ' ').replace('<', '<') + s = s.replace('>', '>').replace('&','&') + return s + url_quote = urllib.quote url_unquote = urllib.unquote diff --git a/paste/wsgilib.py b/paste/wsgilib.py index 89efd63..69adda4 100644 --- a/paste/wsgilib.py +++ b/paste/wsgilib.py @@ -5,111 +5,26 @@ A module of many disparate routines. """ +# functions which moved to paste.request +from request import get_cookies, parse_querystring, parse_formvars +from request import construct_url, path_info_split, path_info_pop + from Cookie import SimpleCookie from cStringIO import StringIO import mimetypes import os import cgi import sys +import re +from urlparse import urlsplit +import warnings __all__ = ['get_cookies', 'add_close', 'raw_interactive', 'interactive', 'construct_url', 'error_body_response', 'error_response', 'send_file', 'has_header', 'header_value', 'path_info_split', 'path_info_pop', 'capture_output', - 'catch_errors'] + 'catch_errors', 'dump_environ'] -def get_cookies(environ): - """ - Gets a cookie object (which is a dictionary-like object) from the - request environment; caches this value in case get_cookies is - called again for the same request. - """ - header = environ.get('HTTP_COOKIE', '') - if environ.has_key('paste.cookies'): - cookies, check_header = environ['paste.cookies'] - if check_header == header: - return cookies - cookies = SimpleCookie() - cookies.load(header) - environ['paste.cookies'] = (cookies, header) - return cookies - -def parse_querystring(environ): - """ - Parses a query string into a list like ``[(name, value)]``. - Caches this value in case parse_querystring is called again - for the same request. - - You can pass the result to ``dict()``, but be aware that keys that - appear multiple times will be lost (only the last value will be - preserved). - """ - source = environ.get('QUERY_STRING', '') - if not source: - return [] - if 'paste.parsed_querystring' in environ: - parsed, check_source = environ['paste.parsed_querystring'] - if check_source == source: - return parsed - parsed = cgi.parse_qsl(source, keep_blank_values=True, - strict_parsing=False) - environ['paste.parsed_querystring'] = (parsed, source) - return parsed - -def parse_formvars(environ, all_as_list=False, include_get_vars=True): - """ - Parses the request, returning a dictionary of the keys. - - If ``all_as_list`` is true, then all values will be lists. If - not, then only values that show up multiple times will be lists. - - If ``include_get_vars`` is true and this was a POST request, then - GET (query string) variables will also be folded into the - dictionary. - - All values should be strings, except for file uploads which are - left as FieldStorage instances. - """ - source = (environ.get('QUERY_STRING', ''), - environ['wsgi.input'], environ['REQUEST_METHOD'], - all_as_list, include_get_vars) - if 'paste.parsed_formvars' in environ: - parsed, check_source = environ['paste.parsed_formvars'] - if check_source == source: - return parsed - fs = cgi.FieldStorage(fp=environ['wsgi.input'], - environ=environ, - keep_blank_values=1) - formvars = {} - for name in fs.keys(): - values = fs[name] - if not isinstance(values, list): - values = [values] - for value in values: - if not value.filename: - value = value.value - if name in formvars: - if isinstance(formvars[name], list): - formvars[name].append(value) - else: - formvars[name] = [formvars[name], value] - elif all_as_list: - formvars[name] = [value] - else: - formvars[name] = value - if environ['REQUEST_METHOD'] == 'POST' and include_get_vars: - for name, value in parse_querystring(environ): - if name in formvars: - if isinstance(formvars[name], list): - formvars[name].append(value) - else: - formvars[name] = [formvars[name], value] - elif all_as_list: - formvars[name] = [value] - else: - formvars[name] = value - environ['paste.parsed_formvars'] = (formvars, source) - return formvars class add_close: """ @@ -179,38 +94,64 @@ class _wrap_app_iter(object): self.error_callback(sys.exc_info()) raise -def raw_interactive(application, path_info='', **environ): +def raw_interactive(application, path='', **environ): """ Runs the application in a fake environment. """ + assert "path_info" not in environ, "argument list changed" errors = StringIO() basic_environ = { - 'PATH_INFO': str(path_info), - 'SCRIPT_NAME': '', - 'SERVER_NAME': 'localhost', - 'SERVER_PORT': '80', - 'REQUEST_METHOD': 'GET', - 'HTTP_HOST': 'localhost:80', - 'CONTENT_LENGTH': '0', - 'REMOTE_ADDR': '127.0.0.1', + # mandatory CGI variables + 'REQUEST_METHOD': 'GET', # always mandatory + 'SCRIPT_NAME': '', # may be empty if app is at the root + 'PATH_INFO': '', # may be empty if at root of app + 'SERVER_NAME': 'localhost', # always mandatory + 'SERVER_PORT': '80', # always mandatory + 'SERVER_PROTOCOL': 'HTTP/1.0', + # mandatory wsgi variables + 'wsgi.version': (1, 0), + 'wsgi.url_scheme': 'http', 'wsgi.input': StringIO(''), 'wsgi.errors': errors, - 'wsgi.version': (1, 0), 'wsgi.multithread': False, 'wsgi.multiprocess': False, 'wsgi.run_once': False, - 'wsgi.url_scheme': 'http', } + if path: + (_,_,path_info,query,fragment) = urlsplit(str(path)) + basic_environ['PATH_INFO'] = path_info + if query: + basic_environ['QUERY_STRING'] = query for name, value in environ.items(): name = name.replace('__', '.') basic_environ[name] = value - if isinstance(basic_environ['wsgi.input'], str): - basic_environ['wsgi.input'] = StringIO(basic_environ['wsgi.input']) - output = StringIO() + istream = basic_environ['wsgi.input'] + if isinstance(istream, str): + basic_environ['wsgi.input'] = StringIO(istream) + basic_environ['CONTENT_LENGTH'] = len(istream) data = {} + output = StringIO() + headers_set = [] + headers_sent = [] def start_response(status, headers, exc_info=None): if exc_info: - raise exc_info[0], exc_info[1], exc_info[2] + try: + if headers_sent: + # Re-raise original exception only if headers sent + raise exc_info[0], exc_info[1], exc_info[2] + else: + # We assume that the sender, who is probably setting + # the headers a second time /w a 500 has produced + # a more appropriate response. + pass + finally: + # avoid dangling circular reference + exc_info = None + elif headers_set: + # You cannot set the headers more than once, unless the + # exc_info is provided. + raise AssertionError("Headers already set and no exc_info!") + headers_set.append(True) data['status'] = status data['headers'] = headers return output.write @@ -218,6 +159,9 @@ def raw_interactive(application, path_info='', **environ): try: try: for s in app_iter: + headers_sent.append(True) + if not headers_set: + raise AssertionError("Content sent w/o headers!") output.write(s) except TypeError, e: # Typically "iteration over non-sequence", so we want @@ -249,43 +193,22 @@ def interactive(*args, **kw): return full.getvalue() interactive.proxy = 'raw_interactive' -def construct_url(environ, with_query_string=True, with_path_info=True, - script_name=None, path_info=None, querystring=None): - """ - Reconstructs the URL from the WSGI environment. You may override - SCRIPT_NAME, PATH_INFO, and QUERYSTRING with the keyword - arguments. - """ - url = environ['wsgi.url_scheme']+'://' - - if environ.get('HTTP_HOST'): - url += environ['HTTP_HOST'].split(':')[0] - else: - url += environ['SERVER_NAME'] - - if environ['wsgi.url_scheme'] == 'https': - if environ['SERVER_PORT'] != '443': - url += ':' + environ['SERVER_PORT'] - else: - if environ['SERVER_PORT'] != '80': - url += ':' + environ['SERVER_PORT'] - - if script_name is None: - url += environ.get('SCRIPT_NAME','') - else: - url += script_name - if with_path_info: - if path_info is None: - url += environ.get('PATH_INFO','') - else: - url += path_info - if with_query_string: - if querystring is None: - if environ.get('QUERY_STRING'): - url += '?' + environ['QUERY_STRING'] - elif querystring: - url += '?' + querystring - return url +def dump_environ(environ,start_response): + """ + Application which simply dumps the current environment + variables out as a plain text response. + """ + output = [] + keys = environ.keys() + keys.sort() + for k in keys: + v = str(environ[k]).replace("\n","\n ") + output.append("%s: %s\n" % (k,v)) + output = "".join(output) + headers = [('Content-Type', 'text/plain'), + ('Content-Length', len(output))] + start_response("200 OK",headers) + return [output] def error_body_response(error_code, message): """ @@ -425,62 +348,6 @@ def remove_header(headers, name): i += 1 return result -def path_info_split(path_info): - """ - Splits off the first segment of the path. Returns (first_part, - rest_of_path). first_part can be None (if PATH_INFO is empty), '' - (if PATH_INFO is '/'), or a name without any /'s. rest_of_path - can be '' or a string starting with /. - """ - if not path_info: - return None, '' - assert path_info.startswith('/'), ( - "PATH_INFO should start with /: %r" % path_info) - path_info = path_info.lstrip('/') - if '/' in path_info: - first, rest = path_info.split('/', 1) - return first, '/' + rest - else: - return path_info, '' - -def path_info_pop(environ): - """ - 'Pops' off the next segment of PATH_INFO, pushing it onto - SCRIPT_NAME, and returning that segment. - - For instance:: - - >>> def call_it(script_name, path_info): - ... env = {'SCRIPT_NAME': script_name, 'PATH_INFO': path_info} - ... result = path_info_pop(env) - ... print 'SCRIPT_NAME=%r; PATH_INFO=%r; returns=%r' % ( - ... env['SCRIPT_NAME'], env['PATH_INFO'], result) - >>> call_it('/foo', '/bar') - SCRIPT_NAME='/foo/bar'; PATH_INFO=''; returns='bar' - >>> call_it('/foo/bar', '') - SCRIPT_NAME='/foo/bar'; PATH_INFO=''; returns=None - >>> call_it('/foo/bar', '/') - SCRIPT_NAME='/foo/bar/'; PATH_INFO=''; returns='' - >>> call_it('', '/1/2/3') - SCRIPT_NAME='/1'; PATH_INFO='/2/3'; returns='1' - >>> call_it('', '//1/2') - SCRIPT_NAME='//1'; PATH_INFO='/2'; returns='1' - """ - path = environ.get('PATH_INFO', '') - if not path: - return None - while path.startswith('/'): - environ['SCRIPT_NAME'] += '/' - path = path[1:] - if '/' not in path: - environ['SCRIPT_NAME'] += path - environ['PATH_INFO'] = '' - return path - else: - segment, path = path.split('/', 1) - environ['PATH_INFO'] = '/' + path - environ['SCRIPT_NAME'] += segment - return segment def capture_output(environ, start_response, application): """ @@ -502,6 +369,10 @@ def capture_output(environ, start_response, application): return [body] return replacement_app """ + warnings.warn( + 'wsgilib.capture_output has been deprecated in favor ' + 'of wsgilib.intercept_output', + DeprecationWarning, 1) data = [] output = StringIO() def replacement_start_response(status, headers, exc_info=None): @@ -629,9 +500,29 @@ class ResponseHeaderDict(dict): else: result.append((key, str(self[key]))) return result - +def _warn_deprecated(new_func): + new_name = new_func.func_name + new_path = new_func.func_globals['__name__'] + '.' + new_name + def replacement(*args, **kw): + warnings.warn( + "The function wsgilib.%s has been moved to %s" + % (new_name, new_path), + DeprecationWarning, 2) + return new_func(*args, **kw) + replacement.func_name = new_func.func_name + return replacement + +# Put warnings wrapper in place for all public functions that +# were imported from elsewhere: + +for _name in __all__: + _func = globals()[_name] + if (hasattr(_func, 'func_globals') + and _func.func_globals['__name__'] != __name__): + globals()[_name] = _warn_deprecated(_func) + if __name__ == '__main__': import doctest doctest.testmod() diff --git a/tests/test_auth/test_auth_cookie.py b/tests/test_auth/test_auth_cookie.py new file mode 100644 index 0000000..2bec414 --- /dev/null +++ b/tests/test_auth/test_auth_cookie.py @@ -0,0 +1,41 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php + +from paste.auth import cookie +from paste.wsgilib import raw_interactive, header_value, dump_environ +from paste.httpexceptions import * +from Cookie import SimpleCookie +import urllib2, os + +def build(application,setenv, *args, **kwargs): + def setter(environ, start_response): + save = environ['paste.auth.cookie'].append + for (k,v) in setenv.items(): + save(k) + environ[k] = v + return application(environ, start_response) + return cookie.middleware(setter,*args,**kwargs) + +def test_noop(): + app = build(dump_environ,{}) + (status,headers,content,errors) = \ + raw_interactive(app) + assert not header_value(headers,'Set-Cookie') + +def test_basic(key='key', val='bingles'): + app = build(dump_environ,{key:val}) + (status,headers,content,errors) = \ + raw_interactive(app) + value = header_value(headers,'Set-Cookie') + assert "Path=/;" in value + assert "expires=" not in value + cookie = value.split(";")[0] + (status,headers,content,errors) = \ + raw_interactive(app,{'HTTP_COOKIE': cookie}) + assert ("%s: %s" % (key,val.replace("\n","\n "))) in content + +def test_roundtrip(): + roundtrip = str('').join(map(chr,xrange(256))) + test_basic(roundtrip,roundtrip) + diff --git a/tests/test_auth/test_auth_digest.py b/tests/test_auth/test_auth_digest.py new file mode 100644 index 0000000..1c0ced0 --- /dev/null +++ b/tests/test_auth/test_auth_digest.py @@ -0,0 +1,86 @@ +# (c) 2005 Clark C. Evans +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php + +from paste.auth import digest +from paste.wsgilib import raw_interactive, header_value +from paste.httpexceptions import * +import os + +def application(environ, start_response): + content = environ.get('REMOTE_USER','') + start_response("200 OK",(('Content-Type', 'text/plain'), + ('Content-Length', len(content)))) + return content + +realm = "tag:clarkevans.com,2005:testing" + +def backwords(realm,username): + """ dummy password hash, where user password is just reverse """ + password = list(username) + password.reverse() + password = "".join(password) + return digest.digest_password(username,realm,password) + +application = digest.middleware(application,realm,backwords) +application = HTTPExceptionHandler(application) + +def check(username, password, path="/"): + """ perform two-stage authentication to verify login """ + (status,headers,content,errors) = \ + raw_interactive(application,path, accept='text/html') + assert status.startswith("401") + challenge = header_value(headers,'WWW-Authenticate') + response = digest.response(challenge, realm, path, username, password) + assert "Digest" in response + (status,headers,content,errors) = \ + raw_interactive(application,path, + HTTP_AUTHORIZATION=response) + if status.startswith("200"): + return content + if status.startswith("401"): + return None + assert False, "Unexpected Status: %s" % status + +def test_digest(): + assert 'bing' == check("bing","gnib") + assert check("bing","bad") is None + +# +# The following code uses sockets to test the functionality, +# to enable use: +# +# $ TEST_SOCKET py.test +# + +if os.environ.get("TEST_SOCKET",""): + import urllib2 + from paste.debug.testserver import serve + server = serve(application) + + def authfetch(username,password,path="/",realm=realm): + server.accept(2) + import socket + socket.setdefaulttimeout(5) + uri = ("http://%s:%s" % server.server_address) + path + auth = urllib2.HTTPDigestAuthHandler() + auth.add_password(realm,uri,username,password) + opener = urllib2.build_opener(auth) + result = opener.open(uri) + return result.read() + + def test_success(): + assert "bing" == authfetch('bing','gnib') + + def test_failure(): + # urllib tries 5 more times before it gives up + server.accept(5) + try: + authfetch('bing','wrong') + assert False, "this should raise an exception" + except urllib2.HTTPError, e: + assert e.code == 401 + + def test_shutdown(): + server.stop() + diff --git a/tests/test_exceptions/test_error_middleware.py b/tests/test_exceptions/test_error_middleware.py index 5b1df26..658d887 100644 --- a/tests/test_exceptions/test_error_middleware.py +++ b/tests/test_exceptions/test_error_middleware.py @@ -1,11 +1,11 @@ from paste.fixture import * from paste.exceptions.errormiddleware import ErrorMiddleware from paste import lint - -def strip_html(s): - s = re.sub('<.*?>', '', s) - s = s.replace(' ', ' ').replace('<', '<').replace('>', '>') - return s +from paste.util.quoting import strip_html +# +# For some strange reason, these 4 lines cannot be removed or the regression +# test breaks; is it counting the number of lines in the file somehow? +# def do_request(app, expect_status=500): app = lint.middleware(app) @@ -19,10 +19,16 @@ def do_request(app, expect_status=500): def clear_middleware(app): """ The fixture sets paste.throw_errors, which suppresses exactly what - we want to test in this case. + we want to test in this case. This wrapper also strips exc_info + on the *first* call to start_response (but not the second, or + subsequent calls. """ def clear_throw_errors(environ, start_response): + headers_sent = [] def replacement(status, headers, exc_info=None): + if headers_sent: + return start_response(status, headers, exc_info) + headers_sent.append(True) return start_response(status, headers) if 'paste.throw_errors' in environ: del environ['paste.throw_errors'] @@ -64,7 +70,7 @@ def test_makes_exception(): res = do_request(bad_app) assert '<html' in res res = strip_html(str(res)) - print res + #print res assert 'bad_app() takes no arguments (2 given' in res assert 'iterator = application(environ, start_response_wrapper)' in res assert 'paste.lint' in res @@ -73,21 +79,21 @@ def test_makes_exception(): def test_start_res(): res = do_request(start_response_app) res = strip_html(str(res)) - print res + #print res assert 'ValueError: hi' in res assert 'test_error_middleware' in res - assert ':43 in start_response_app' in res + assert ':49 in start_response_app' in res def test_after_start(): res = do_request(after_start_response_app, 200) res = strip_html(str(res)) - print res + #print res assert 'ValueError: error2' in res - assert ':47' in res + assert ':53' in res def test_iter_app(): res = do_request(iter_app, 200) - print res + #print res assert 'None raises error' in res assert 'yielder' in res diff --git a/tests/test_exceptions/test_formatter.py b/tests/test_exceptions/test_formatter.py index dad8ed7..8e31fff 100644 --- a/tests/test_exceptions/test_formatter.py +++ b/tests/test_exceptions/test_formatter.py @@ -1,5 +1,6 @@ from paste.exceptions import formatter from paste.exceptions import collector +from paste.util.quoting import strip_html import sys import os import difflib @@ -28,11 +29,6 @@ class BadSupplement(Supplement): def getInfo(self): raise ValueError("This supplemental info is buggy") -def strip_html(s): - s = re.sub('<.*?>', '', s) - s = s.replace(' ', ' ').replace('<', '<').replace('>', '>') - return s - def call_error(sup): 1 + 2 __traceback_supplement__ = (sup, ()) diff --git a/tests/test_exceptions/test_httpexceptions.py b/tests/test_exceptions/test_httpexceptions.py new file mode 100644 index 0000000..5b0101b --- /dev/null +++ b/tests/test_exceptions/test_httpexceptions.py @@ -0,0 +1,82 @@ +# (c) 2005 Ian Bicking, Clark C. Evans and contributors +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +""" +WSGI Exception Middleware + +Regression Test Suite +""" +from paste.httpexceptions import * +from paste.wsgilib import header_value +import py + +def test_HTTPMove(): + """ make sure that location is a mandatory attribute of Redirects """ + py.test.raises(AssertionError,HTTPFound) + py.test.raises(AssertionError,HTTPTemporaryRedirect, + headers=[('l0cation','/bing')]) + assert isinstance(HTTPMovedPermanently("This is a message", + headers=[('Location','/bing')]) + ,HTTPRedirection) + assert isinstance(HTTPUseProxy(headers=[('LOCATION','/bing')]) + ,HTTPRedirection) + assert isinstance(HTTPFound('/foobar'),HTTPRedirection) + +def test_badapp(): + """ verify that the middleware handles previously-started responses """ + def badapp(environ, start_response): + start_response("200 OK",[]) + raise HTTPBadRequest("Do not do this at home.") + newapp = HTTPExceptionHandler(badapp) + assert 'Bad Request' in ''.join(newapp({'HTTP_ACCEPT': 'text/html'}, + (lambda a, b, c=None: None))) + +def test_template(): + """ verify that html() and plain() output methods work """ + e = HTTPInternalServerError() + e.template = 'A %(ping)s and <b>%(pong)s</b> message.' + assert str(e).startswith("500 Internal Server Error") + assert e.plain({'ping': 'fun', 'pong': 'happy'}) == ( + '500 Internal Server Error\n' + 'A fun and happy message.\n') + assert '<p>A fun and <b>happy</b> message.</p>' in \ + e.html({'ping': 'fun', 'pong': 'happy'}) + +def test_redapp(): + """ check that redirect returns the correct, expected results """ + saved = [] + def saveit(status, headers, exc_info = None): + saved.append((status,headers)) + def redapp(environ, start_response): + raise HTTPFound("/bing/foo") + app = HTTPExceptionHandler(redapp) + result = list(app({'HTTP_ACCEPT': 'text/html'},saveit)) + assert '<a href="/bing/foo">' in result[0] + assert "302 Found" == saved[0][0] + assert "text/html" == header_value(saved[0][1], 'content-type') + assert "/bing/foo" == header_value(saved[0][1],'location') + result = list(app({'HTTP_ACCEPT': 'text/plain'},saveit)) + print result[0] == ( + '302 Found\n' + 'This resource was found at /bing/foo;\n' + 'you should be redirected automatically.\n') + assert "text/plain" == header_value(saved[1][1],'content-type') + assert "/bing/foo" == header_value(saved[1][1],'location') + +def test_misc(): + assert get_exception(301) == HTTPMovedPermanently + redirect = HTTPFound("/some/path") + assert isinstance(redirect,HTTPException) + assert isinstance(redirect,HTTPRedirection) + assert not isinstance(redirect,HTTPError) + notfound = HTTPNotFound() + assert isinstance(notfound,HTTPException) + assert isinstance(notfound,HTTPError) + assert isinstance(notfound,HTTPClientError) + assert not isinstance(notfound,HTTPServerError) + notimpl = HTTPNotImplemented() + assert isinstance(notimpl,HTTPException) + assert isinstance(notimpl,HTTPError) + assert isinstance(notimpl,HTTPServerError) + assert not isinstance(notimpl,HTTPClientError) + diff --git a/tests/test_request.py b/tests/test_request.py new file mode 100644 index 0000000..12a4ee9 --- /dev/null +++ b/tests/test_request.py @@ -0,0 +1,7 @@ +# (c) 2005 Ian Bicking, Clark C. Evans and contributors +# This module is part of the Python Paste Project and is released under +# the MIT License: http://www.opensource.org/licenses/mit-license.php +from paste.request import * +from py.test import raises + +#@@: regressions needed ;) |
