diff options
author | Allan Saddi <allan@saddi.com> | 2005-04-15 03:35:05 +0000 |
---|---|---|
committer | Allan Saddi <allan@saddi.com> | 2005-04-15 03:35:05 +0000 |
commit | 4f2b800f98e41d53ab0ea5ca0ce16e5e48eb3ae4 (patch) | |
tree | 942d43587f39b348676fc7cbdeb8c96dff95e393 | |
parent | 700071bc26f40727331651b4f273465a4faa0c7d (diff) | |
download | flup-4f2b800f98e41d53ab0ea5ca0ce16e5e48eb3ae4.tar.gz |
Add publisher stuff to package.
-rw-r--r-- | flup/middleware/__init__.py | 1 | ||||
-rw-r--r-- | flup/publisher/__init__.py | 3 | ||||
-rw-r--r-- | flup/publisher/publisher.py | 763 | ||||
-rw-r--r-- | flup/resolver/__init__.py | 3 | ||||
-rw-r--r-- | flup/resolver/complex.py | 112 | ||||
-rw-r--r-- | flup/resolver/function.py | 46 | ||||
-rw-r--r-- | flup/resolver/importingmodule.py | 129 | ||||
-rw-r--r-- | flup/resolver/module.py | 83 | ||||
-rw-r--r-- | flup/resolver/nopathinfo.py | 57 | ||||
-rw-r--r-- | flup/resolver/objectpath.py | 153 | ||||
-rw-r--r-- | flup/resolver/resolver.py | 79 |
11 files changed, 1427 insertions, 2 deletions
diff --git a/flup/middleware/__init__.py b/flup/middleware/__init__.py new file mode 100644 index 0000000..792d600 --- /dev/null +++ b/flup/middleware/__init__.py @@ -0,0 +1 @@ +# diff --git a/flup/publisher/__init__.py b/flup/publisher/__init__.py index 792d600..46f89ca 100644 --- a/flup/publisher/__init__.py +++ b/flup/publisher/__init__.py @@ -1 +1,2 @@ -# +from publisher import * +del publisher diff --git a/flup/publisher/publisher.py b/flup/publisher/publisher.py new file mode 100644 index 0000000..6328a50 --- /dev/null +++ b/flup/publisher/publisher.py @@ -0,0 +1,763 @@ +# Copyright (c) 2002, 2005 Allan Saddi <allan@saddi.com> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +# $Id$ + +__author__ = 'Allan Saddi <allan@saddi.com>' +__version__ = '$Revision$' + +import os +import inspect +import cgi +import types +from Cookie import SimpleCookie + +__all__ = ['Request', + 'Response', + 'Transaction', + 'Publisher', + 'File', + 'Action', + 'Redirect', + 'InternalRedirect', + 'getexpected', + 'trimkw'] + +class NoDefault(object): + """Sentinel object so we can distinguish None in keyword arguments.""" + pass + +class Request(object): + """ + Encapsulates data about the HTTP request. + + Supported attributes that originate from here: (The attributes are + read-only, however you may modify the contents of environ.) + transaction - Enclosing Transaction object. + environ - Environment variables, as passed from WSGI adapter. + method - Request method. + publisherScriptName - SCRIPT_NAME of Publisher. + scriptName - SCRIPT_NAME for request. + pathInfo - PATH_INFO for request. + form - Dictionary containing data from query string and/or POST request. + cookie - Cookie from request. + """ + def __init__(self, transaction, environ): + self._transaction = transaction + + self._environ = environ + self._publisherScriptName = environ.get('SCRIPT_NAME', '') + + self._form = {} + self._parseFormData() + + # Is this safe? Will it ever raise exceptions? + self._cookie = SimpleCookie(environ.get('HTTP_COOKIE', None)) + + def _parseFormData(self): + """ + Fills self._form with data from a FieldStorage. May be overidden to + provide custom form processing. (Like no processing at all!) + """ + # Parse query string and/or POST data. + form = FieldStorage(fp=self._environ['wsgi.input'], + environ=self._environ, keep_blank_values=1) + + # Collapse FieldStorage into a simple dict. + for field in form.list: + # Wrap uploaded files + if field.filename: + val = File(field) + else: + val = field.value + + # Add File/value to args, constructing a list if there are + # multiple values. + if self._form.has_key(field.name): + self._form[field.name].append(val) + else: + self._form[field.name] = [val] + + # Unwrap lists with a single item. + for name,val in self._form.items(): + if len(val) == 1: + self._form[name] = val[0] + + def _get_transaction(self): + return self._transaction + transaction = property(_get_transaction, None, None, + "Transaction associated with this Request") + + def _get_environ(self): + return self._environ + environ = property(_get_environ, None, None, + "Environment variables passed from adapter") + + def _get_method(self): + return self._environ['REQUEST_METHOD'] + method = property(_get_method, None, None, + "Request method") + + def _get_publisherScriptName(self): + return self._publisherScriptName + publisherScriptName = property(_get_publisherScriptName, None, None, + 'SCRIPT_NAME of Publisher') + + def _get_scriptName(self): + return self._environ.get('SCRIPT_NAME', '') + scriptName = property(_get_scriptName, None, None, + "SCRIPT_NAME of request") + + def _get_pathInfo(self): + return self._environ.get('PATH_INFO', '') + pathInfo = property(_get_pathInfo, None, None, + "PATH_INFO of request") + + def _get_form(self): + return self._form + form = property(_get_form, None, None, + "Parsed GET/POST data") + + def _get_cookie(self): + return self._cookie + cookie = property(_get_cookie, None, None, + "Cookie received from client") + +class Response(object): + """ + Encapsulates response-related data. + + Supported attributes: + transaction - Enclosing Transaction object. + status - Response status code (and message). + headers - Response headers. + cookie - Response cookie. + contentType - Content type of body. + body - Response body. Must be an iterable that yields strings. + + Since there are multiple ways of passing response info back to + Publisher, here is their defined precedence: + status - headers['Status'] first, then status + cookie - Any values set in this cookie are added to the headers in + addition to existing Set-Cookie headers. + contentType - headers['Content-Type'] first, then contentType. If + neither are specified, defaults to 'text/html'. + body - Return of function takes precedence. If function returns None, + body is used instead. + """ + _initialHeaders = { + 'Pragma': 'no-cache', + 'Cache-Control': 'no-cache' + } + + def __init__(self, transaction): + self._transaction = transaction + + self._status = '200 OK' + + # Initial response headers. + self._headers = header_dict(self._initialHeaders) + + self._cookie = SimpleCookie() + + self.body = [] + + def _get_transaction(self): + return self._transaction + transaction = property(_get_transaction, None, None, + "Transaction associated with this Response") + + def _get_status(self): + status = self._headers.get('Status') + if status is not None: + return status + return self._status + def _set_status(self, value): + if self._headers.has_key('Status'): + self._headers['Status'] = value + else: + self._status = value + status = property(_get_status, _set_status, None, + 'Response status') + + def _get_headers(self): + return self._headers + headers = property(_get_headers, None, None, + "Headers to send in response") + + def _get_cookie(self): + return self._cookie + cookie = property(_get_cookie, None, None, + "Cookie to send in response") + + def _get_contentType(self): + return self._headers.get('Content-Type', 'text/html') + def _set_contentType(self, value): + self._headers['Content-Type'] = value + contentType = property(_get_contentType, _set_contentType, None, + 'Content-Type of the response body') + +class Transaction(object): + """ + Encapsulates objects associated with a single transaction (Request, + Response, and possibly a Session). + + Public attributes: (all read-only) + request - Request object. + response - Response object. + + If Publisher sits on top of SessionMiddleware, the public API of + SessionService is also available through the Transaction object. + """ + _requestClass = Request + _responseClass = Response + + def __init__(self, publisher, environ): + self._publisher = publisher + self._request = self._requestClass(self, environ) + self._response = self._responseClass(self) + + # Session support. + self._sessionService = environ.get('com.saddi.service.session') + if self._sessionService is not None: + self.encodeURL = self._sessionService.encodeURL + + def _get_request(self): + return self._request + request = property(_get_request, None, None, + "Request associated with this Transaction") + + def _get_response(self): + return self._response + response = property(_get_response, None, None, + "Response associated with this Transaction") + + # Export SessionService API + + def _get_session(self): + assert self._sessionService is not None, 'No session service found' + return self._sessionService.session + session = property(_get_session, None, None, + 'Returns the Session object associated with this ' + 'client') + + def _get_hasSession(self): + assert self._sessionService is not None, 'No session service found' + return self._sessionService.hasSession + hasSession = property(_get_hasSession, None, None, + 'True if a Session currently exists for this client') + + def _get_isSessionNew(self): + assert self._sessionService is not None, 'No session service found' + return self._sessionService.isSessionNew + isSessionNew = property(_get_isSessionNew, None, None, + 'True if the Session was created in this ' + 'transaction') + + def _get_hasSessionExpired(self): + assert self._sessionService is not None, 'No session service found' + return self._sessionService.hasSessionExpired + hasSessionExpired = property(_get_hasSessionExpired, None, None, + 'True if the client was associated with a ' + 'non-existent Session') + + def _get_encodesSessionInURL(self): + assert self._sessionService is not None, 'No session service found' + return self._sessionService.encodesSessionInURL + def _set_encodesSessionInURL(self, value): + assert self._sessionService is not None, 'No session service found' + self._sessionService.encodesSessionInURL = value + encodesSessionInURL = property(_get_encodesSessionInURL, + _set_encodesSessionInURL, None, + 'True if the Session ID should be encoded ' + 'in the URL') + + def prepare(self): + """ + Called before resolved function is invoked. If overridden, + super's prepare() MUST be called and it must be called first. + """ + # Pass form values as keyword arguments. + args = dict(self._request.form) + + # Pass Transaction to function if it wants it. + args['transaction'] = args['trans'] = self + + self._args = args + + def call(self, func, args): + """ + May be overridden to provide custom exception handling and/or + per-request additions, e.g. opening a database connection, + starting a transaction, etc. + """ + # Trim down keywords to only what the callable expects. + expected, varkw = getexpected(func) + trimkw(args, expected, varkw) + + return func(**args) + + def run(self, func): + """ + Execute the function, doing any Action processing and also + post-processing the function/Action's return value. + """ + try: + # Call the function. + result = self.call(func, self._args) + except Action, a: + # Caught an Action, have it do whatever it does + result = a.run(self) + + response = self._response + headers = response.headers + + if result is not None: + if type(result) in types.StringTypes: + assert type(result) is str, 'result cannot be unicode!' + + # Set Content-Length, if needed + if not headers.has_key('Content-Length'): + headers['Content-Length'] = str(len(result)) + + # Wrap in list for WSGI + response.body = [result] + else: + if __debug__: + try: + iter(result) + except TypeError: + raise AssertionError, 'result not iterable!' + response.body = result + + # If result was None, assume response.body was appropriately set. + + def finish(self): + """ + Called after resolved function returns, but before response is + sent. If overridden, super's finish() should be called last. + """ + response = self._response + headers = response.headers + + # Copy cookie to headers. + items = response.cookie.items() + items.sort() + for name,morsel in items: + headers.add('Set-Cookie', morsel.OutputString()) + + # If there is a Status header, transfer its value to response.status. + # It must not remain in the headers! + status = headers.get('Status', NoDefault) + if status is not NoDefault: + del headers['Status'] + response.status = status + + code = int(response.status[:3]) + # Check if it's a response that must not include a body. + # (See 4.3 in RFC2068.) + if code / 100 == 1 or code in (204, 304): + # Remove any trace of the response body, if that's the case. + for header,value in headers.items(): + if header.lower().startswith('content-'): + del headers[header] + response.body = [] + else: + if self._request.method == 'HEAD': + # HEAD reponse must not return a body (but the headers must be + # kept intact). + response.body = [] + + # Add Content-Type, if missing. + if not headers.has_key('Content-Type'): + headers['Content-Type'] = response.contentType + + # If we have a close() method, ensure that it is called. + if hasattr(self, 'close'): + response.body = _addClose(response.body, self.close) + +class Publisher(object): + """ + WSGI application that publishes Python functions as web pages. Constructor + takes an instance of a concrete subclass of Resolver, which is responsible + for mapping requests to functions. + + Query string/POST data values are passed to the function as keyword + arguments. If the function does not support variable keywords (e.g. + does not have a ** parameter), the function will only be passed + keywords which it expects. It is recommended that all keyword parameters + have defaults so that missing form data does not raise an exception. + + A Transaction instance is always passed to the function via the + "transaction" or "trans" keywords. See the Transaction, Request, and + Response classes. + + Valid return types for the function are: a string, None, or an iterable + that yields strings. If returning None, it is expected that Response.body + has been appropriately set. (It must be an iterable that yields strings.) + + An instance of Publisher itself is the WSGI application. + """ + _transactionClass = Transaction + + def __init__(self, resolver, transactionClass=None): + self._resolver = resolver + + if transactionClass is not None: + self._transactionClass = transactionClass + + def _get_resolver(self): + return self._resolver + resolver = property(_get_resolver, None, None, + 'Associated Resolver for this Publisher') + + def __call__(self, environ, start_response): + """ + WSGI application interface. Creates a Transaction (which does most + of the work) and sends the response. + """ + # Set up a Transaction. + transaction = self._transactionClass(self, environ) + + # Make any needed preparations. + transaction.prepare() + + redirect = False + + while True: + # Attempt to resolve the function. + func = self._resolver.resolve(transaction.request, + redirect=redirect) + if func is None: + return self._error404(environ, start_response) + + try: + # Call the function. + transaction.run(func) + except InternalRedirect, r: + # Internal redirect. Set up environment and resolve again. + environ['SCRIPT_NAME'] = transaction.request.publisherScriptName + environ['PATH_INFO'] = r.pathInfo + redirect = True + else: + break + + # Give Transaction a chance to do modify/add to the response. + transaction.finish() + + # Transform headers into a list. (Need to pay attention to + # multiple values.) + responseHeaders = [] + for key,value in transaction.response.headers.items(): + if type(value) is list: + for v in value: + responseHeaders.append((key, v)) + else: + responseHeaders.append((key, value)) + + start_response(transaction.response.status, responseHeaders) + return transaction.response.body + + def _error404(self, environ, start_response): + """Error page to display when resolver fails.""" + start_response('404 Not Found', [('Content-Type', 'text/html')]) + request_uri = environ.get('REQUEST_URI') + if request_uri is None: + request_uri = environ.get('SCRIPT_NAME', '') + \ + environ.get('PATH_INFO', '') + return ["""<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"> +<html><head> +<title>404 Not Found</title> +</head><body> +<h1>Not Found</h1> +The requested URL %s was not found on this server.<p> +<hr> +%s</body></html> +""" % (request_uri, environ.get('SERVER_SIGNATURE', ''))] + +class File(object): + """ + Wrapper so we can identify uploaded files. + """ + def __init__(self, field): + self.filename = field.filename + self.file = field.file + self.type = field.type + self.type_options = field.type_options + self.headers = field.headers + +class Action(Exception): + """ + Abstract base class for 'Actions', which are just exceptions. + Within Publisher, Actions have no inherent meaning and are used + as a shortcut to perform specific actions where it's ok for a + function to abruptly halt. (Like redirects or displaying an + error page.) + + I don't know if using exceptions this way is good form (something + tells me no ;) Their usage isn't really required, but they're + convenient in some situations. + """ + def run(self, transaction): + """Override to perform your action.""" + raise NotImplementedError, self.__class__.__name__ + '.run' + +class Redirect(Action): + """ + Redirect to the given URL. + """ + def __init__(self, url, permanent=False): + self._url = url + self._permanent = permanent + + def run(self, transaction): + response = transaction.response + response.status = self._permanent and '301 Moved Permanently' or \ + '302 Moved Temporarily' + response.headers.reset() + response.headers['Location'] = self._url + response.contentType = 'text/html' + return ["""<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"> +<html><head> +<title>%s</title> +</head><body> +<h1>Found</h1> +<p>The document has moved <a href="%s">here</a>.</p> +<hr> +%s</body></html> +""" % (response.status, self._url, + transaction.request.environ.get('SERVER_SIGNATURE', ''))] + +class InternalRedirect(Exception): + """ + An internal redirect using a new PATH_INFO (relative to Publisher's + SCRIPT_NAME). + + When handling an InternalRedirect, the behavior of all included + Resolvers is to expose a larger set of callables (that would normally + be hidden). Therefore, it is important that you set pathInfo securely - + preferably, it should not depend on any data from the request. Ideally, + pathInfo should be a constant string. + """ + def __init__(self, pathInfo): + self.pathInfo = pathInfo + +class FieldStorage(cgi.FieldStorage): + def __init__(self, *args, **kw): + """ + cgi.FieldStorage only parses the query string during a GET or HEAD + request. Fix this. + """ + cgi.FieldStorage.__init__(self, *args, **kw) + + environ = kw.get('environ') or os.environ + method = environ.get('REQUEST_METHOD', 'GET').upper() + if method not in ('GET', 'HEAD'): # cgi.FieldStorage already parsed? + qs = environ.get('QUERY_STRING') + if qs: + if self.list is None: + self.list = [] + for key,value in cgi.parse_qsl(qs, self.keep_blank_values, + self.strict_parsing): + self.list.append(cgi.MiniFieldStorage(key, value)) + +class header_dict(dict): + """ + This is essentially a case-insensitive dictionary, with some additions + geared towards supporting HTTP headers (like __str__(), add(), and + reset()). + """ + def __init__(self, val=None): + """ + If initialized with an existing dictionary, calling reset() will + reset our contents back to that initial dictionary. + """ + dict.__init__(self) + self._keymap = {} + if val is None: + val = {} + self.update(val) + self._reset_state = dict(val) + + def __contains__(self, key): + return key.lower() in self._keymap + + def __delitem__(self, key): + lower_key = key.lower() + real_key = self._keymap.get(lower_key) + if real_key is None: + raise KeyError, key + del self._keymap[lower_key] + dict.__delitem__(self, real_key) + + def __getitem__(self, key): + lower_key = key.lower() + real_key = self._keymap.get(lower_key) + if real_key is None: + raise KeyError, key + return dict.__getitem__(self, real_key) + + def __str__(self): + """Output as HTTP headers.""" + s = '' + for k,v in self.items(): + if type(v) is list: + for i in v: + s += '%s: %s\n' % (k, i) + else: + s += '%s: %s\n' % (k, v) + return s + + def __setitem__(self, key, value): + lower_key = key.lower() + real_key = self._keymap.get(lower_key) + if real_key is None: + self._keymap[lower_key] = key + dict.__setitem__(self, key, value) + else: + dict.__setitem__(self, real_key, value) + + def clear(self): + self._keymap.clear() + dict.clear(self) + + def copy(self): + c = self.__class__(self) + c._reset_state = self._reset_state + return c + + def get(self, key, failobj=None): + lower_key = key.lower() + real_key = self._keymap.get(lower_key) + if real_key is None: + return failobj + return dict.__getitem__(self, real_key) + + def has_key(self, key): + return key.lower() in self._keymap + + def setdefault(self, key, failobj=None): + lower_key = key.lower() + real_key = self._keymap.get(lower_key) + if real_key is None: + self._keymap[lower_key] = key + dict.__setitem__(self, key, failobj) + return failobj + else: + return dict.__getitem__(self, real_key) + + def update(self, d): + for k,v in d.items(): + self[k] = v + + def add(self, key, value): + """ + Add a new header value. Does not overwrite previous value of header + (in contrast to __setitem__()). + """ + if self.has_key(key): + if type(self[key]) is list: + self[key].append(value) + else: + self[key] = [self[key], value] + else: + self[key] = value + + def reset(self): + """Reset contents to that at the time this instance was created.""" + self.clear() + self.update(self._reset_state) + +def _addClose(appIter, closeFunc): + """ + Wraps an iterator so that its close() method calls closeFunc. Respects + the existence of __len__ and the iterator's own close() method. + + Need to use metaclass magic because __len__ and next are not + recognized unless they're part of the class. (Can't assign at + __init__ time.) + """ + class metaIterWrapper(type): + def __init__(cls, name, bases, clsdict): + super(metaIterWrapper, cls).__init__(name, bases, clsdict) + if hasattr(appIter, '__len__'): + cls.__len__ = appIter.__len__ + cls.next = iter(appIter).next + if hasattr(appIter, 'close'): + def _close(self): + appIter.close() + closeFunc() + cls.close = _close + else: + cls.close = closeFunc + + class iterWrapper(object): + __metaclass__ = metaIterWrapper + def __iter__(self): + return self + + return iterWrapper() + +# Utilities which may be useful outside of Publisher? Perhaps for decorators... + +def getexpected(func): + """ + Returns as a 2-tuple the passed in object's expected arguments and + whether or not it accepts variable keywords. + """ + assert callable(func), 'object not callable' + + if not inspect.isclass(func): + # At this point, we assume func is either a function, method, or + # callable instance. + if not inspect.isfunction(func) and not inspect.ismethod(func): + func = getattr(func, '__call__') # When would this fail? + + argspec = inspect.getargspec(func) + expected, varkw = argspec[0], argspec[2] is not None + if inspect.ismethod(func): + expected = expected[1:] + else: + # A class. Try to figure out the calling conventions of the + # constructor. + init = getattr(func, '__init__', None) + # Sigh, this is getting into the realm of black magic... + if init is not None and inspect.ismethod(init): + argspec = inspect.getargspec(init) + expected, varkw = argspec[0], argspec[2] is not None + expected = expected[1:] + else: + expected, varkw = [], False + + return expected, varkw + +def trimkw(kw, expected, varkw): + """ + If necessary, trims down a dictionary of keyword arguments to only + what's expected. + """ + if not varkw: # Trimming only necessary if it doesn't accept variable kw + for name in kw.keys(): + if name not in expected: + del kw[name] diff --git a/flup/resolver/__init__.py b/flup/resolver/__init__.py index 792d600..bc9c7bf 100644 --- a/flup/resolver/__init__.py +++ b/flup/resolver/__init__.py @@ -1 +1,2 @@ -# +from resolver import * +del resolver diff --git a/flup/resolver/complex.py b/flup/resolver/complex.py new file mode 100644 index 0000000..c3016ea --- /dev/null +++ b/flup/resolver/complex.py @@ -0,0 +1,112 @@ +# Copyright (c) 2005 Allan Saddi <allan@saddi.com> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +# $Id$ + +__author__ = 'Allan Saddi <allan@saddi.com>' +__version__ = '$Revision$' + +import re + +from resolver import * + +__all__ = ['ComplexResolver'] + +class ComplexResolver(Resolver): + """ + A meta-Resolver that allows you to "graft" different resolvers at various + points in your URL space. + + It works as follows: given a PATH_INFO, it will try all matching + resolvers, starting with the most specific. The first function returned + by a resolver is returned as the result. + + If no matching resolvers return a function, then the search is + considered a failure. + + Assumes that none of the registered resolvers modify environ when + they fail to resolve. + + Upon successful resolution, SCRIPT_NAME will contain the path up to + and including the resolved function (as determined by the resolver) and + PATH_INFO will contain all remaining components. + """ + _slashRE = re.compile(r'''/{2,}''') + + def __init__(self): + self.resolverMap = {} + + def _canonicalUrl(self, url): + if not url: # Represents default + return url + + # Get rid of adjacent slashes + url = self._slashRE.sub('/', url) + + # No trailing slash + if url.endswith('/'): + url = url[:-1] + + # Make sure it starts with a slash + if not url.startswith('/'): + url = '/' + url + + return url + + def addResolver(self, url, resolver): + """ + Registers a resolver at a particular URL. The empty URL '' + represents the default resolver. It will be matched when no + other matching resolvers are found. + """ + url = self._canonicalUrl(url) + self.resolverMap[url] = resolver + + def removeResolver(self, url): + """Removes the resolver at a particular URL.""" + url = self._canonicalUrl(url) + del self.resolverMap[url] + + def resolve(self, request, redirect=False): + orig_script_name = request.scriptName + orig_path_info = path_info = request.pathInfo + path_info = path_info.split(';')[0] + path_info = path_info.split('/') + + assert len(path_info) > 0 + assert not path_info[0] + + while path_info: + try_path_info = '/'.join(path_info) + resolver = self.resolverMap.get(try_path_info) + if resolver is not None: + self._updatePath(request, len(path_info) - 1) + func = resolver.resolve(request, redirect) + if func is not None: + return func + request.environ['SCRIPT_NAME'] = orig_script_name + request.environ['PATH_INFO'] = orig_path_info + path_info.pop() + + return None diff --git a/flup/resolver/function.py b/flup/resolver/function.py new file mode 100644 index 0000000..537caa3 --- /dev/null +++ b/flup/resolver/function.py @@ -0,0 +1,46 @@ +# Copyright (c) 2005 Allan Saddi <allan@saddi.com> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +# $Id$ + +__author__ = 'Allan Saddi <allan@saddi.com>' +__version__ = '$Revision$' + +from resolver import * + +__all__ = ['FunctionResolver'] + +class FunctionResolver(Resolver): + """ + Very basic, almost brain-dead Resolver. Simply resolves to the passed + in function, no matter what. :) + + Can be used as a decorator and might actually have uses when used with + the ComplexResolver. + """ + def __init__(self, func): + self._func = func + + def resolve(self, request, redirect=False): + return self._func diff --git a/flup/resolver/importingmodule.py b/flup/resolver/importingmodule.py new file mode 100644 index 0000000..ba2c18c --- /dev/null +++ b/flup/resolver/importingmodule.py @@ -0,0 +1,129 @@ +# Copyright (c) 2005 Allan Saddi <allan@saddi.com> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +# $Id$ + +__author__ = 'Allan Saddi <allan@saddi.com>' +__version__ = '$Revision$' + +import sys +import os +import imp + +from resolver import * + +__all__ = ['ImportingModuleResolver'] + +class ImportingModuleResolver(Resolver): + """ + Constructor takes a directory name or a list of directories. Interprets + the first two components of PATH_INFO as 'module/function'. Modules + are imported as needed and must reside in the directories specified. + Module and function names beginning with underscore are ignored. + + If the 'module' part of PATH_INFO is missing, it is assumed to be + self.default_module. + + If the 'function' part of PATH_INFO is missing, it is assumed to be + self.index_page. + + Upon successful resolution, appends the module and function names to + SCRIPT_NAME and updates PATH_INFO as the remaining components of the path. + + NB: I would recommend explicitly setting all modules' __all__ list. + Otherwise, be sure all the names of module-level callables that you + don't want exported begin with underscore. + """ + # No default module by default. + default_module = None + index_page = 'index' + + def __init__(self, path, defaultModule=NoDefault, index=NoDefault): + self.path = path + if defaultModule is not NoDefault: + self.default_module = defaultModule + if index is not NoDefault: + self.index_page = index + + def resolve(self, request, redirect=False): + path_info = request.pathInfo.split(';')[0] + path_info = path_info.split('/') + + assert len(path_info) > 0 + assert not path_info[0] + + while len(path_info) < 3: + path_info.append('') + + module_name, func_name = path_info[1:3] + + if not module_name: + module_name = self.default_module + + if not func_name: + func_name = self.index_page + + module = None + if module_name and (module_name[0] != '_' or redirect) and \ + not module_name.count('.'): + try: + module = _import_module(module_name, path=self.path) + except: + pass + + if module is not None: + if func_name and (func_name[0] != '_' or redirect): + module_all = getattr(module, '__all__', None) + if module_all is None or func_name in module_all or redirect: + func = getattr(module, func_name, None) + if callable(func): + self._updatePath(request, 2) + return func + + return None + +def _import_module(name, path=None): + """ + Imports a module. If path is None, module will be searched for in + sys.path. If path is given (which may be a single string or a list), + the module will only be searched for in those directories. + """ + if path is not None and type(path) is not list: + path = [path] + + module = sys.modules.get(name) + if module is not None: + module_file = getattr(module, '__file__') + if module_file is None or \ + (path is not None and os.path.dirname(module_file) not in path): + return None + + return module + + fp, pathname, description = imp.find_module(name, path) + try: + return imp.load_module(name, fp, pathname, description) + finally: + if fp: + fp.close() diff --git a/flup/resolver/module.py b/flup/resolver/module.py new file mode 100644 index 0000000..e0117a8 --- /dev/null +++ b/flup/resolver/module.py @@ -0,0 +1,83 @@ +# Copyright (c) 2005 Allan Saddi <allan@saddi.com> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +# $Id$ + +__author__ = 'Allan Saddi <allan@saddi.com>' +__version__ = '$Revision$' + +from resolver import * + +__all__ = ['ModuleResolver'] + +class ModuleResolver(Resolver): + """ + Exposes all top-level callables within a module. The module's __all__ + attribute is respected, if it exists. Names beginning with underscore + are ignored. + + Uses the first component of PATH_INFO as the callable's name and if + empty, will instead use self.index_page. + + Upon successful resolution, appends the callable's name to SCRIPT_NAME + and updates PATH_INFO as the remaining components of the path. + + NB: I would recommend explicitly setting the module's __all__ list. + Otherwise, be sure all the names of module-level callables that you + don't want exported begin with underscore. + """ + index_page = 'index' + + def __init__(self, module, index=NoDefault): + self.module = module + if index is not NoDefault: + self.index_page = index + + def resolve(self, request, redirect=False): + path_info = request.pathInfo.split(';')[0] + path_info = path_info.split('/') + + assert len(path_info) > 0 + assert not path_info[0] + + if len(path_info) < 2: + path_info.append('') + + func_name = path_info[1] + + if func_name: + if func_name[0] == '_' and not redirect: + func_name = None + else: + func_name = self.index_page + + if func_name: + module_all = getattr(self.module, '__all__', None) + if module_all is None or func_name in module_all or redirect: + func = getattr(self.module, func_name, None) + if callable(func): + self._updatePath(request, 1) + return func + + return None diff --git a/flup/resolver/nopathinfo.py b/flup/resolver/nopathinfo.py new file mode 100644 index 0000000..e0f3182 --- /dev/null +++ b/flup/resolver/nopathinfo.py @@ -0,0 +1,57 @@ +# Copyright (c) 2005 Allan Saddi <allan@saddi.com> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +# $Id$ + +__author__ = 'Allan Saddi <allan@saddi.com>' +__version__ = '$Revision$' + +from resolver import * + +__all__ = ['NoPathInfoResolver'] + +class NoPathInfoResolver(Resolver): + """ + Another meta-resolver. Disallows the existence of PATH_INFO (beyond + what's needed to resolve the function). Optionally allows a trailing + slash. + """ + def __init__(self, resolver, allowTrailingSlash=False): + self._resolver = resolver + self._allowTrailingSlash = allowTrailingSlash + + def resolve(self, request, redirect=False): + orig_script_name, orig_path_info = request.scriptName, request.pathInfo + func = self._resolver.resolve(request, redirect) + try: + if func is not None: + path_info = request.pathInfo.split(';')[0] + if path_info and \ + (not self._allowTrailingSlash or path_info != '/'): + func = None + return func + finally: + if func is None: + request.environ['SCRIPT_NAME'] = orig_script_name + request.environ['PATH_INFO'] = orig_path_info diff --git a/flup/resolver/objectpath.py b/flup/resolver/objectpath.py new file mode 100644 index 0000000..aa1b21a --- /dev/null +++ b/flup/resolver/objectpath.py @@ -0,0 +1,153 @@ +# Copyright (c) 2005 Allan Saddi <allan@saddi.com> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +# $Id$ + +__author__ = 'Allan Saddi <allan@saddi.com>' +__version__ = '$Revision$' + +import re + +from resolver import * + +__all__ = ['ObjectPathResolver', 'expose'] + +class ObjectPathResolver(Resolver): + """ + Inspired by CherryPy <http://www.cherrypy.org/>. :) For an explanation + of how this works, see the excellent tutorial at + <http://www.cherrypy.org/wiki/CherryPyTutorial>. We support the index and + default methods, though the calling convention for the default method + is different - we do not pass PATH_INFO as positional arguments. (It + is passed through the request/environ as normal.) + + Also, we explicitly block certain function names. See below. I don't + know if theres really any harm in letting those attributes be followed, + but I'd rather not take the chance. And unfortunately, this solution + is pretty half-baked as well (I'd rather only allow certain object + types to be traversed, rather than disallow based on names.) Better + than nothing though... + """ + index_page = 'index' + default_page = 'default' + + def __init__(self, root, index=NoDefault, default=NoDefault, + favorIndex=True): + """ + root is the root object of your URL hierarchy. In CherryPy, this + would be cpg.root. + + When the last component of a path has an index method and some + object along the path has a default method, favorIndex determines + which method is called when the URL has a trailing slash. If + True, the index method will be called. Otherwise, the default method. + """ + self.root = root + if index is not NoDefault: + self.index_page = index + if default is not NoDefault: + self.default_page = default + self._favorIndex = favorIndex + + # Certain names should be disallowed for safety. If one of your pages + # is showing up unexpectedly as a 404, make sure the function name doesn't + # begin with one of these prefixes. + _disallowed = re.compile(r'''(?:_|im_|func_|tb_|f_|co_).*''') + + def _exposed(self, obj, redirect): + # If redirecting, allow non-exposed objects as well. + return callable(obj) and (getattr(obj, 'exposed', False) or redirect) + + def resolve(self, request, redirect=False): + path_info = request.pathInfo.split(';')[0] + path_info = path_info.split('/') + + assert len(path_info) > 0 + assert not path_info[0] + + current = self.root + current_default = None + i = 0 + for i in range(1, len(path_info)): + component = path_info[i] + + # See if we have an index page (needed for index/default + # disambiguation, unfortunately). + current_index = None + if self.index_page: + current_index = getattr(current, self.index_page, None) + if not self._exposed(current_index, redirect): + current_index = None + + if self.default_page: + # Remember the last default page we've seen. + new_default = getattr(current, self.default_page, None) + if self._exposed(new_default, redirect): + current_default = (i - 1, new_default) + + # Test for trailing slash. + if not component and current_index is not None and \ + (self._favorIndex or current_default is None): + # Breaking out of the loop here favors index over default. + break + + # Respect __all__ attribute. (Ok to generalize to all objects?) + all = getattr(current, '__all__', None) + + current = getattr(current, component, None) + # Path doesn't exist + if current is None or self._disallowed.match(component) or \ + (all is not None and component not in all and not redirect): + # Use path up to latest default page. + if current_default is not None: + i, current = current_default + break + # No default at all, so we fail. + return None + + func = None + if self._exposed(current, redirect): # Exposed? + func = current + else: + # If not, see if it as an exposed index page + if self.index_page: + index = getattr(current, self.index_page, None) + if self._exposed(index, redirect): func = index + # How about a default page? + if func is None and self.default_page: + default = getattr(current, self.default_page, None) + if self._exposed(default, redirect): func = default + # Lastly, see if we have an ancestor's default page to fall back on. + if func is None and current_default is not None: + i, func = current_default + + if func is not None: + self._updatePath(request, i) + + return func + +def expose(func): + """Decorator to expose functions.""" + func.exposed = True + return func diff --git a/flup/resolver/resolver.py b/flup/resolver/resolver.py new file mode 100644 index 0000000..4a61d7d --- /dev/null +++ b/flup/resolver/resolver.py @@ -0,0 +1,79 @@ +# Copyright (c) 2005 Allan Saddi <allan@saddi.com> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. +# +# $Id$ + +__author__ = 'Allan Saddi <allan@saddi.com>' +__version__ = '$Revision$' + +__all__ = ['Resolver'] + +class Resolver(object): + """ + Abstract base class for 'Resolver' objects. (An instance of which is + passed to Publisher's constructor.) + + Given a Request, either return a callable (Publisher expects it to + be a function, method, class, or callable instance), or return None. + Typically Request.pathInfo is used to resolve the function. + Request.environ may be modified by the Resolver, for example, to re-adjust + SCRIPT_NAME/PATH_INFO after successful resolution. It is NOT recommended + that it be modified if resolution fails. + + When resolving an InternalRedirect, redirect will be True. + """ + def resolve(self, request, redirect=False): + raise NotImplementedError, self.__class__.__name__ + '.resolve' + + def _updatePath(self, request, num): + """ + Utility function to update SCRIPT_NAME and PATH_INFO in a sane + manner. Transfers num components from PATH_INFO to SCRIPT_NAME. + Keeps URL path parameters intact. + """ + assert num >= 0 + if not num: + return # Nothing to do + numScriptName = len(request.scriptName.split('/')) + totalPath = request.scriptName + request.pathInfo + if __debug__: + origTotalPath = totalPath + # Extract and save params + i = totalPath.find(';') + if i >= 0: + params = totalPath[i:] + totalPath = totalPath[:i] + else: + params = '' + totalPath = totalPath.split('/') + scriptName = '/'.join(totalPath[:numScriptName + num]) + pathInfo = '/'.join([''] + totalPath[numScriptName + num:]) + # SCRIPT_NAME shouldn't have trailing slash + if scriptName.endswith('/'): + scriptName = scriptName[:-1] + # Transfer to PATH_INFO (most likely empty, but just to be safe...) + pathInfo = '/' + pathInfo + request.environ['SCRIPT_NAME'] = scriptName + request.environ['PATH_INFO'] = pathInfo + params + assert request.scriptName + request.pathInfo == origTotalPath |