diff options
-rw-r--r-- | docs/source/contextlocals.rst | 55 | ||||
-rw-r--r-- | docs/source/index.rst | 1 | ||||
-rw-r--r-- | docs/source/logging.rst | 1 | ||||
-rw-r--r-- | docs/source/routing.rst | 14 | ||||
-rw-r--r-- | pecan/core.py | 242 | ||||
-rw-r--r-- | pecan/hooks.py | 4 | ||||
-rw-r--r-- | pecan/rest.py | 106 | ||||
-rw-r--r-- | pecan/routing.py | 27 | ||||
-rw-r--r-- | pecan/tests/test_base.py | 39 | ||||
-rw-r--r-- | pecan/tests/test_no_thread_locals.py | 1312 | ||||
-rw-r--r-- | tox.ini | 18 |
11 files changed, 1678 insertions, 141 deletions
diff --git a/docs/source/contextlocals.rst b/docs/source/contextlocals.rst new file mode 100644 index 0000000..d97ef7e --- /dev/null +++ b/docs/source/contextlocals.rst @@ -0,0 +1,55 @@ +.. _contextlocals: + + +Context/Thread-Locals vs. Explicit Argument Passing +=================================================== +In any pecan application, the module-level ``pecan.request`` and +``pecan.response`` are proxy objects that always refer to the request and +response being handled in the current thread. + +This `thread locality` ensures that you can safely access a global reference to +the current request and response in a multi-threaded environment without +constantly having to pass object references around in your code; it's a feature +of pecan that makes writing traditional web applications easier and less +verbose. + +Some people feel thread-locals are too implicit or magical, and that explicit +reference passing is much clearer and more maintainable in the long run. +Additionally, the default implementation provided by pecan uses +:func:`threading.local` to associate these context-local proxy objects with the +`thread identifier` of the current server thread. In asynchronous server +models - where lots of tasks run for short amounts of time on +a `single` shared thread - supporting this mechanism involves monkeypatching +:func:`threading.local` to behave in a greenlet-local manner. + +Disabling Thread-Local Proxies +------------------------------ + +If you're certain that you `do not` want to utilize context/thread-locals in +your project, you can do so by passing the argument +``use_context_locals=False`` in your application's configuration file:: + + app = { + 'root': 'project.controllers.root.RootController', + 'modules': ['project'], + 'static_root': '%(confdir)s/public', + 'template_path': '%(confdir)s/project/templates', + 'debug': True, + 'use_context_locals': False + } + +Additionally, you'll need to update **all** of your pecan controllers to accept +positional arguments for the current request and response:: + + class RootController(object): + + @pecan.expose('json') + def index(self, req, resp): + return dict(method=req.method) # path: / + + @pecan.expose() + def greet(self, req, resp, name): + return name # path: /greet/joe + +It is *imperative* that the request and response arguments come **after** +``self`` and before any positional form arguments. diff --git a/docs/source/index.rst b/docs/source/index.rst index 8a4ef37..520d0f7 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -38,6 +38,7 @@ Narrative Documentation secure_controller.rst hooks.rst jsonify.rst + contextlocals.rst commands.rst development.rst deployment.rst diff --git a/docs/source/logging.rst b/docs/source/logging.rst index 7494a2e..940c7af 100644 --- a/docs/source/logging.rst +++ b/docs/source/logging.rst @@ -72,6 +72,7 @@ code. Using the logging framework is very simple. # myapp/myapp/controllers/root.py from pecan import expose + import logging logger = logging.getLogger(__name__) diff --git a/docs/source/routing.rst b/docs/source/routing.rst index ec79305..be8e987 100644 --- a/docs/source/routing.rst +++ b/docs/source/routing.rst @@ -270,11 +270,11 @@ a :func:`_route` method will enable you to have total control. Interacting with the Request and Response Object ------------------------------------------------ -For every HTTP request, Pecan maintains a thread-local reference to the request -and response object, ``pecan.request`` and ``pecan.response``. These are -instances of :class:`webob.request.BaseRequest` and -:class:`webob.response.Response`, respectively, and can be interacted with from -within Pecan controller code:: +For every HTTP request, Pecan maintains a :ref:`thread-local reference +<contextlocals>` to the request and response object, ``pecan.request`` and +``pecan.response``. These are instances of :class:`webob.request.BaseRequest` +and :class:`webob.response.Response`, respectively, and can be interacted with +from within Pecan controller code:: @pecan.expose() def login(self): @@ -282,8 +282,8 @@ within Pecan controller code:: username = pecan.request.POST.get('username') password = pecan.request.POST.get('password') - pecan.response.status_int = 403 - pecan.response.body = 'Bad Login!' + pecan.response.status = 403 + pecan.response.text = 'Bad Login!' While Pecan abstracts away much of the need to interact with these objects directly, there may be situations where you want to access them, such as: diff --git a/pecan/core.py b/pecan/core.py index 6a096b5..eb3fe69 100644 --- a/pecan/core.py +++ b/pecan/core.py @@ -27,10 +27,31 @@ state = None logger = logging.getLogger(__name__) +class RoutingState(object): + + def __init__(self, request, response, app, hooks=[], controller=None): + self.request = request + self.response = response + self.app = app + self.hooks = hooks + self.controller = controller + + def proxy(key): class ObjectProxy(object): + + explanation_ = AttributeError( + "`pecan.state` is not bound to a context-local context.\n" + "Ensure that you're accessing `pecan.request` or `pecan.response` " + "from within the context of a WSGI `__call__` and that " + "`use_context_locals` = True." + ) + def __getattr__(self, attr): - obj = getattr(state, key) + try: + obj = getattr(state, key) + except AttributeError: + raise self.explanation_ return getattr(obj, attr) def __setattr__(self, attr, value): @@ -87,7 +108,7 @@ def abort(status_code=None, detail='', headers=None, comment=None, **kw): def redirect(location=None, internal=False, code=None, headers={}, - add_slash=False): + add_slash=False, request=None): ''' Perform a redirect, either internal or external. An internal redirect performs the redirect server-side, while the external redirect utilizes @@ -99,12 +120,14 @@ def redirect(location=None, internal=False, code=None, headers={}, :param code: The HTTP status code to use for the redirect. Defaults to 302. :param headers: Any HTTP headers to send with the response, as a dictionary. + :param request: The :class:`webob.request.BaseRequest` instance to use. ''' + request = request or state.request if add_slash: if location is None: - split_url = list(urlparse.urlsplit(state.request.url)) - new_proto = state.request.environ.get( + split_url = list(urlparse.urlsplit(request.url)) + new_proto = request.environ.get( 'HTTP_X_FORWARDED_PROTO', split_url[0] ) split_url[0] = new_proto @@ -126,7 +149,7 @@ def redirect(location=None, internal=False, code=None, headers={}, raise exc.status_map[code](location=location, headers=headers) -def render(template, namespace): +def render(template, namespace, app=None): ''' Render the specified template using the Pecan rendering framework with the specified template namespace as a dictionary. Useful in a @@ -136,9 +159,10 @@ def render(template, namespace): ``@expose``. :param namespace: The namespace to use for rendering the template, as a dictionary. + :param app: The instance of :class:`pecan.Pecan` to use ''' - - return state.app.render(template, namespace) + app = app or state.app + return app.render(template, namespace) def load_app(config, **kwargs): @@ -165,31 +189,7 @@ def load_app(config, **kwargs): ) -class Pecan(object): - ''' - Base Pecan application object. Generally created using ``pecan.make_app``, - rather than being created manually. - - Creates a Pecan application instance, which is a WSGI application. - - :param root: A string representing a root controller object (e.g., - "myapp.controller.root.RootController") - :param default_renderer: The default template rendering engine to use. - Defaults to mako. - :param template_path: A relative file system path (from the project root) - where template files live. Defaults to 'templates'. - :param hooks: A callable which returns a list of - :class:`pecan.hooks.PecanHook` - :param custom_renderers: Custom renderer objects, as a dictionary keyed - by engine name. - :param extra_template_vars: Any variables to inject into the template - namespace automatically. - :param force_canonical: A boolean indicating if this project should - require canonical URLs. - :param guess_content_type_from_ext: A boolean indicating if this project - should use the extension in the URL for guessing - the content type to return. - ''' +class PecanBase(object): SIMPLEST_CONTENT_TYPES = ( ['text/html'], @@ -201,9 +201,6 @@ class Pecan(object): custom_renderers={}, extra_template_vars={}, force_canonical=True, guess_content_type_from_ext=True, context_local_factory=None, **kw): - - self.init_context_local(context_local_factory) - if isinstance(root, six.string_types): root = self.__translate_root__(root) @@ -223,12 +220,6 @@ class Pecan(object): self.force_canonical = force_canonical self.guess_content_type_from_ext = guess_content_type_from_ext - def init_context_local(self, local_factory): - global state - if local_factory is None: - from threading import local as local_factory - state = local_factory() - def __translate_root__(self, item): ''' Creates a root controller instance from a string root, e.g., @@ -259,10 +250,9 @@ class Pecan(object): :param node: The node, such as a root controller object. :param path: The path to look up on this node. ''' - path = path.split('/')[1:] try: - node, remainder = lookup_controller(node, path) + node, remainder = lookup_controller(node, path, req) return node, remainder except NonCanonicalPath as e: if self.force_canonical and \ @@ -276,7 +266,7 @@ class Pecan(object): (req.pecan['routing_path'], req.pecan['routing_path']) ) - redirect(code=302, add_slash=True) + redirect(code=302, add_slash=True, request=req) return e.controller, e.remainder def determine_hooks(self, controller=None): @@ -299,7 +289,7 @@ class Pecan(object): ) return self.hooks - def handle_hooks(self, hook_type, *args): + def handle_hooks(self, hooks, hook_type, *args): ''' Processes hooks of the specified type. @@ -307,10 +297,8 @@ class Pecan(object): ``on_error``, and ``on_route``. :param \*args: Arguments to pass to the hooks. ''' - if hook_type in ['before', 'on_route']: - hooks = state.hooks - else: - hooks = reversed(state.hooks) + if hook_type not in ['before', 'on_route']: + hooks = reversed(hooks) for hook in hooks: result = getattr(hook, hook_type)(*args) @@ -319,14 +307,15 @@ class Pecan(object): if hook_type == 'on_error' and isinstance(result, Response): return result - def get_args(self, pecan_state, all_params, remainder, argspec, im_self): + def get_args(self, state, all_params, remainder, argspec, im_self): ''' Determines the arguments for a controller based upon parameters passed the argument specification for the controller. ''' args = [] kwargs = dict() - valid_args = argspec[0][1:] + valid_args = argspec.args[1:] # pop off `self` + pecan_state = state.request.pecan def _decode(x): return unquote_plus(x) if isinstance(x, six.string_types) \ @@ -392,13 +381,12 @@ class Pecan(object): template = template.split(':')[1] return renderer.render(template, namespace) - def handle_request(self, req, resp): + def find_controller(self, state): ''' The main request handler for Pecan applications. ''' - # get a sorted list of hooks, by priority (no controller hooks yet) - state.hooks = self.hooks + req = state.request pecan_state = req.pecan # store the routing path for the current application to allow hooks to @@ -406,7 +394,7 @@ class Pecan(object): pecan_state['routing_path'] = path = req.encget('PATH_INFO') # handle "on_route" hooks - self.handle_hooks('on_route', state) + self.handle_hooks(self.hooks, 'on_route', state) # lookup the controller, respecting content-type as requested # by the file extension on the URI @@ -491,11 +479,8 @@ class Pecan(object): ) raise exc.HTTPNotFound - # get a sorted list of hooks, by priority - state.hooks = self.determine_hooks(controller) - # handle "before" hooks - self.handle_hooks('before', state) + self.handle_hooks(self.determine_hooks(controller), 'before', state) # fetch any parameters if req.method == 'GET': @@ -505,13 +490,25 @@ class Pecan(object): # fetch the arguments for the controller args, kwargs = self.get_args( - pecan_state, + state, params, remainder, cfg['argspec'], im_self ) + return controller, args, kwargs + + def invoke_controller(self, controller, args, kwargs, state): + ''' + The main request handler for Pecan applications. + ''' + cfg = _cfg(controller) + content_types = cfg.get('content_types', {}) + req = state.request + resp = state.response + pecan_state = req.pecan + # get the result from the controller result = controller(*args, **kwargs) @@ -529,7 +526,9 @@ class Pecan(object): template = content_types.get(pecan_state['content_type']) # check if for controller override of template - template = pecan_state.get('override_template', template) + template = pecan_state.get('override_template', template) or ( + 'json' if self.default_renderer == 'json' else None + ) pecan_state['content_type'] = pecan_state.get( 'override_content_type', pecan_state['content_type'] @@ -570,11 +569,10 @@ class Pecan(object): ''' # create the request and response object - state.request = req = Request(environ) - state.response = resp = Response() - state.hooks = [] - state.app = self - state.controller = None + req = Request(environ) + resp = Response() + state = RoutingState(req, resp, self) + controller = None # handle the request try: @@ -582,7 +580,8 @@ class Pecan(object): req.context = environ.get('pecan.recursive.context', {}) req.pecan = dict(content_type=None) - self.handle_request(req, resp) + controller, args, kwargs = self.find_controller(state) + self.invoke_controller(controller, args, kwargs, state) except Exception as e: # if this is an HTTP Exception, set it as the response if isinstance(e, exc.HTTPException): @@ -592,7 +591,12 @@ class Pecan(object): # if this is not an internal redirect, run error hooks on_error_result = None if not isinstance(e, ForwardRequestException): - on_error_result = self.handle_hooks('on_error', state, e) + on_error_result = self.handle_hooks( + self.determine_hooks(state.controller), + 'on_error', + state, + e + ) # if the on_error handler returned a Response, use it. if isinstance(on_error_result, Response): @@ -602,15 +606,111 @@ class Pecan(object): raise finally: # handle "after" hooks - self.handle_hooks('after', state) + self.handle_hooks( + self.determine_hooks(state.controller), 'after', state + ) # get the response + return state.response(environ, start_response) + + +class ExplicitPecan(PecanBase): + + def get_args(self, state, all_params, remainder, argspec, im_self): + # When comparing the argspec of the method to GET/POST params, + # ignore the implicit (req, resp) at the beginning of the function + # signature + signature_error = TypeError( + 'When `use_context_locals` is `False`, pecan passes an explicit ' + 'reference to the request and response as the first two arguments ' + 'to the controller.\nChange the `%s.%s.%s` signature to accept ' + 'exactly 2 initial arguments (req, resp)' % ( + state.controller.__self__.__class__.__module__, + state.controller.__self__.__class__.__name__, + state.controller.__name__ + ) + ) + try: + positional = argspec.args[:] + positional.pop(1) # req + positional.pop(1) # resp + argspec = argspec._replace(args=positional) + except IndexError: + raise signature_error + + args, kwargs = super(ExplicitPecan, self).get_args( + state, all_params, remainder, argspec, im_self + ) + args = [state.request, state.response] + args + return args, kwargs + + +class Pecan(PecanBase): + ''' + Pecan application object. Generally created using ``pecan.make_app``, + rather than being created manually. + + Creates a Pecan application instance, which is a WSGI application. + + :param root: A string representing a root controller object (e.g., + "myapp.controller.root.RootController") + :param default_renderer: The default template rendering engine to use. + Defaults to mako. + :param template_path: A relative file system path (from the project root) + where template files live. Defaults to 'templates'. + :param hooks: A callable which returns a list of + :class:`pecan.hooks.PecanHook` + :param custom_renderers: Custom renderer objects, as a dictionary keyed + by engine name. + :param extra_template_vars: Any variables to inject into the template + namespace automatically. + :param force_canonical: A boolean indicating if this project should + require canonical URLs. + :param guess_content_type_from_ext: A boolean indicating if this project + should use the extension in the URL for guessing + the content type to return. + :param use_context_locals: When `True`, `pecan.request` and + `pecan.response` will be available as + thread-local references. + ''' + + def __new__(cls, *args, **kw): + if kw.get('use_context_locals') is False: + self = super(Pecan, cls).__new__(ExplicitPecan, *args, **kw) + self.__init__(*args, **kw) + return self + return super(Pecan, cls).__new__(cls) + + def __init__(self, *args, **kw): + self.init_context_local(kw.get('context_local_factory')) + super(Pecan, self).__init__(*args, **kw) + + def __call__(self, environ, start_response): try: - return state.response(environ, start_response) + state.hooks = [] + state.app = self + state.controller = None + return super(Pecan, self).__call__(environ, start_response) finally: - # clean up state del state.hooks del state.request del state.response del state.controller del state.app + + def init_context_local(self, local_factory): + global state + if local_factory is None: + from threading import local as local_factory + state = local_factory() + + def find_controller(self, _state): + state.request = _state.request + state.response = _state.response + controller, args, kw = super(Pecan, self).find_controller(_state) + state.controller = controller + return controller, args, kw + + def handle_hooks(self, hooks, *args, **kw): + state.hooks = hooks + return super(Pecan, self).handle_hooks(hooks, *args, **kw) diff --git a/pecan/hooks.py b/pecan/hooks.py index 27dc872..57392d7 100644 --- a/pecan/hooks.py +++ b/pecan/hooks.py @@ -4,7 +4,6 @@ from inspect import getmembers from webob.exc import HTTPFound from .util import iscontroller, _cfg -from .routing import lookup_controller __all__ = [ 'PecanHook', 'TransactionHook', 'HookController', @@ -343,8 +342,7 @@ class RequestViewerHook(PecanHook): Specific to Pecan (not available in the request object) ''' path = state.request.pecan['routing_path'].split('/')[1:] - controller, reminder = lookup_controller(state.app.root, path) - return controller.__str__().split()[2] + return state.controller.__str__().split()[2] def format_hooks(self, hooks): ''' diff --git a/pecan/rest.py b/pecan/rest.py index db955c5..9cc8b35 100644 --- a/pecan/rest.py +++ b/pecan/rest.py @@ -1,9 +1,10 @@ from inspect import getargspec, ismethod +import warnings from webob import exc import six -from .core import abort, request +from .core import abort from .decorators import expose from .routing import lookup_controller, handle_lookup_traversal from .util import iscontroller @@ -26,31 +27,41 @@ class RestController(object): ''' _custom_actions = {} + def _get_args_for_controller(self, controller): + """ + Retrieve the arguments we actually care about. For Pecan applications + that utilize thread locals, we should truncate the first argument, + `self`. For applications that explicitly pass request/response + references as the first controller arguments, we should truncate the + first three arguments, `self, req, resp`. + """ + argspec = getargspec(controller) + from pecan import request + try: + request.path + except AttributeError: + return argspec.args[3:] + return argspec.args[1:] + @expose() - def _route(self, args): + def _route(self, args, request=None): ''' Routes a request to the appropriate controller and returns its result. Performs a bit of validation - refuses to route delete and put actions via a GET request). ''' + if request is None: + from pecan import request # convention uses "_method" to handle browser-unsupported methods - if request.environ.get('pecan.validation_redirected', False) is True: - # - # If the request has been internally redirected due to a validation - # exception, we want the request method to be enforced as GET, not - # the `_method` param which may have been passed for REST support. - # - method = request.method.lower() - else: - method = request.params.get('_method', request.method).lower() + method = request.params.get('_method', request.method).lower() # make sure DELETE/PUT requests don't use GET if request.method == 'GET' and method in ('delete', 'put'): abort(405) # check for nested controllers - result = self._find_sub_controllers(args) + result = self._find_sub_controllers(args, request) if result: return result @@ -62,17 +73,17 @@ class RestController(object): ) try: - result = handler(method, args) + result = handler(method, args, request) # # If the signature of the handler does not match the number # of remaining positional arguments, attempt to handle # a _lookup method (if it exists) # - argspec = getargspec(result[0]) - num_args = len(argspec[0][1:]) + argspec = self._get_args_for_controller(result[0]) + num_args = len(argspec) if num_args < len(args): - _lookup_result = self._handle_lookup(args) + _lookup_result = self._handle_lookup(args, request) if _lookup_result: return _lookup_result except exc.HTTPNotFound: @@ -80,7 +91,7 @@ class RestController(object): # If the matching handler results in a 404, attempt to handle # a _lookup method (if it exists) # - _lookup_result = self._handle_lookup(args) + _lookup_result = self._handle_lookup(args, request) if _lookup_result: return _lookup_result raise @@ -88,7 +99,7 @@ class RestController(object): # return the result return result - def _handle_lookup(self, args): + def _handle_lookup(self, args, request): # filter empty strings from the arg list args = list(six.moves.filter(bool, args)) @@ -97,7 +108,8 @@ class RestController(object): if args and iscontroller(lookup): result = handle_lookup_traversal(lookup, args) if result: - return lookup_controller(*result) + obj, remainder = result + return lookup_controller(obj, remainder, request) def _find_controller(self, *args): ''' @@ -109,7 +121,7 @@ class RestController(object): return obj return None - def _find_sub_controllers(self, remainder): + def _find_sub_controllers(self, remainder, request): ''' Identifies the correct controller to route to by analyzing the request URI. @@ -124,31 +136,33 @@ class RestController(object): return # get the args to figure out how much to chop off - args = getargspec(getattr(self, method)) - fixed_args = len(args[0][1:]) - len( + args = self._get_args_for_controller(getattr(self, method)) + fixed_args = len(args) - len( request.pecan.get('routing_args', []) ) - var_args = args[1] + var_args = getargspec(getattr(self, method)).varargs # attempt to locate a sub-controller if var_args: for i, item in enumerate(remainder): controller = getattr(self, item, None) if controller and not ismethod(controller): - self._set_routing_args(remainder[:i]) - return lookup_controller(controller, remainder[i + 1:]) + self._set_routing_args(request, remainder[:i]) + return lookup_controller(controller, remainder[i + 1:], + request) elif fixed_args < len(remainder) and hasattr( self, remainder[fixed_args] ): controller = getattr(self, remainder[fixed_args]) if not ismethod(controller): - self._set_routing_args(remainder[:fixed_args]) + self._set_routing_args(request, remainder[:fixed_args]) return lookup_controller( controller, - remainder[fixed_args + 1:] + remainder[fixed_args + 1:], + request ) - def _handle_unknown_method(self, method, remainder): + def _handle_unknown_method(self, method, remainder, request): ''' Routes undefined actions (like RESET) to the appropriate controller. ''' @@ -164,11 +178,12 @@ class RestController(object): abort(405) sub_controller = getattr(self, remainder[0], None) if sub_controller: - return lookup_controller(sub_controller, remainder[1:]) + return lookup_controller(sub_controller, remainder[1:], + request) abort(404) - def _handle_get(self, method, remainder): + def _handle_get(self, method, remainder, request): ''' Routes ``GET`` actions to the appropriate controller. ''' @@ -176,8 +191,8 @@ class RestController(object): if not remainder or remainder == ['']: controller = self._find_controller('get_all', 'get') if controller: - argspec = getargspec(controller) - fixed_args = len(argspec.args[1:]) - len( + argspec = self._get_args_for_controller(controller) + fixed_args = len(argspec) - len( request.pecan.get('routing_args', []) ) if len(remainder) < fixed_args: @@ -194,13 +209,13 @@ class RestController(object): if controller: return controller, remainder[:-1] - match = self._handle_custom_action(method, remainder) + match = self._handle_custom_action(method, remainder, request) if match: return match controller = getattr(self, remainder[0], None) if controller and not ismethod(controller): - return lookup_controller(controller, remainder[1:]) + return lookup_controller(controller, remainder[1:], request) # finally, check for the regular get_one/get requests controller = self._find_controller('get_one', 'get') @@ -209,18 +224,18 @@ class RestController(object): abort(404) - def _handle_delete(self, method, remainder): + def _handle_delete(self, method, remainder, request): ''' Routes ``DELETE`` actions to the appropriate controller. ''' if remainder: - match = self._handle_custom_action(method, remainder) + match = self._handle_custom_action(method, remainder, request) if match: return match controller = getattr(self, remainder[0], None) if controller and not ismethod(controller): - return lookup_controller(controller, remainder[1:]) + return lookup_controller(controller, remainder[1:], request) # check for post_delete/delete requests first controller = self._find_controller('post_delete', 'delete') @@ -234,23 +249,24 @@ class RestController(object): abort(405) sub_controller = getattr(self, remainder[0], None) if sub_controller: - return lookup_controller(sub_controller, remainder[1:]) + return lookup_controller(sub_controller, remainder[1:], + request) abort(404) - def _handle_post(self, method, remainder): + def _handle_post(self, method, remainder, request): ''' Routes ``POST`` requests. ''' # check for custom POST/PUT requests if remainder: - match = self._handle_custom_action(method, remainder) + match = self._handle_custom_action(method, remainder, request) if match: return match controller = getattr(self, remainder[0], None) if controller and not ismethod(controller): - return lookup_controller(controller, remainder[1:]) + return lookup_controller(controller, remainder[1:], request) # check for regular POST/PUT requests controller = self._find_controller(method) @@ -259,10 +275,10 @@ class RestController(object): abort(404) - def _handle_put(self, method, remainder): - return self._handle_post(method, remainder) + def _handle_put(self, method, remainder, request): + return self._handle_post(method, remainder, request) - def _handle_custom_action(self, method, remainder): + def _handle_custom_action(self, method, remainder, request): remainder = [r for r in remainder if r] if remainder: if method in ('put', 'delete'): @@ -281,7 +297,7 @@ class RestController(object): if controller: return controller, remainder - def _set_routing_args(self, args): + def _set_routing_args(self, request, args): ''' Sets default routing arguments. ''' diff --git a/pecan/routing.py b/pecan/routing.py index ca8c93f..17a7e40 100644 --- a/pecan/routing.py +++ b/pecan/routing.py @@ -1,4 +1,5 @@ import warnings +from inspect import getargspec from webob import exc @@ -23,7 +24,7 @@ class NonCanonicalPath(Exception): self.remainder = remainder -def lookup_controller(obj, remainder): +def lookup_controller(obj, remainder, request): ''' Traverses the requested url path and returns the appropriate controller object, including default routes. @@ -33,7 +34,8 @@ def lookup_controller(obj, remainder): notfound_handlers = [] while True: try: - obj, remainder = find_object(obj, remainder, notfound_handlers) + obj, remainder = find_object(obj, remainder, notfound_handlers, + request) handle_security(obj) return obj, remainder except (exc.HTTPNotFound, PecanNotFound): @@ -55,7 +57,8 @@ def lookup_controller(obj, remainder): and len(obj._pecan['argspec'].args) > 1 ): raise exc.HTTPNotFound - return lookup_controller(*result) + obj_, remainder_ = result + return lookup_controller(obj_, remainder_, request) else: raise exc.HTTPNotFound @@ -77,7 +80,7 @@ def handle_lookup_traversal(obj, args): ) -def find_object(obj, remainder, notfound_handlers): +def find_object(obj, remainder, notfound_handlers, request): ''' 'Walks' the url path in search of an action for which a controller is implemented and returns that controller object along with what's left @@ -114,7 +117,21 @@ def find_object(obj, remainder, notfound_handlers): route = getattr(obj, '_route', None) if iscontroller(route): - next_obj, next_remainder = route(remainder) + if len(getargspec(route).args) == 2: + warnings.warn( + ( + "The function signature for %s.%s._route is changing " + "in the next version of pecan.\nPlease update to: " + "`def _route(self, args, request)`." % ( + obj.__class__.__module__, + obj.__class__.__name__ + ) + ), + DeprecationWarning + ) + next_obj, next_remainder = route(remainder) + else: + next_obj, next_remainder = route(remainder, request) cross_boundary(route, next_obj) return next_obj, next_remainder diff --git a/pecan/tests/test_base.py b/pecan/tests/test_base.py index f4bd64b..c076139 100644 --- a/pecan/tests/test_base.py +++ b/pecan/tests/test_base.py @@ -285,7 +285,7 @@ class TestControllerArguments(PecanTestCase): ) @expose() - def _route(self, args): + def _route(self, args, request): if hasattr(self, args[0]): return getattr(self, args[0]), args[1:] else: @@ -1519,3 +1519,40 @@ class TestEngines(PecanTestCase): r = app.get('/') assert r.status_int == 200 assert b_("<h1>Hello, Jonathan!</h1>") in r.body + + def test_default_json_renderer(self): + + class RootController(object): + @expose() + def index(self, name='Bill'): + return dict(name=name) + + app = TestApp(Pecan(RootController(), default_renderer='json')) + r = app.get('/') + assert r.status_int == 200 + result = dict(json.loads(r.body.decode())) + assert result == {'name': 'Bill'} + + +class TestDeprecatedRouteMethod(PecanTestCase): + + @property + def app_(self): + class RootController(object): + + @expose() + def index(self, *args): + return ', '.join(args) + + @expose() + def _route(self, args): + return self.index, args + + return TestApp(Pecan(RootController())) + + def test_required_argument(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r = self.app_.get('/foo/bar/') + assert r.status_int == 200 + assert b_('foo, bar') in r.body diff --git a/pecan/tests/test_no_thread_locals.py b/pecan/tests/test_no_thread_locals.py new file mode 100644 index 0000000..e9fcf75 --- /dev/null +++ b/pecan/tests/test_no_thread_locals.py @@ -0,0 +1,1312 @@ +from json import dumps +import warnings + +from webtest import TestApp +from six import b as b_ +from six import u as u_ +import webob +import mock + +from pecan import Pecan, expose, abort +from pecan.rest import RestController +from pecan.hooks import PecanHook, HookController +from pecan.tests import PecanTestCase + + +class TestThreadingLocalUsage(PecanTestCase): + + @property + def root(self): + class RootController(object): + @expose() + def index(self, req, resp): + assert isinstance(req, webob.BaseRequest) + assert isinstance(resp, webob.Response) + return 'Hello, World!' + + return RootController + + def test_locals_are_not_used(self): + with mock.patch('threading.local', side_effect=AssertionError()): + + app = TestApp(Pecan(self.root(), use_context_locals=False)) + r = app.get('/') + assert r.status_int == 200 + assert r.body == b_('Hello, World!') + + self.assertRaises(AssertionError, Pecan, self.root) + + +class TestIndexRouting(PecanTestCase): + + @property + def app_(self): + class RootController(object): + @expose() + def index(self, req, resp): + assert isinstance(req, webob.BaseRequest) + assert isinstance(resp, webob.Response) + return 'Hello, World!' + + return TestApp(Pecan(RootController(), use_context_locals=False)) + + def test_empty_root(self): + r = self.app_.get('/') + assert r.status_int == 200 + assert r.body == b_('Hello, World!') + + def test_index(self): + r = self.app_.get('/index') + assert r.status_int == 200 + assert r.body == b_('Hello, World!') + + def test_index_html(self): + r = self.app_.get('/index.html') + assert r.status_int == 200 + assert r.body == b_('Hello, World!') + + +class TestManualResponse(PecanTestCase): + + def test_manual_response(self): + + class RootController(object): + @expose() + def index(self, req, resp): + resp = webob.Response(resp.environ) + resp.body = b_('Hello, World!') + return resp + + app = TestApp(Pecan(RootController(), use_context_locals=False)) + r = app.get('/') + assert r.body == b_('Hello, World!'), r.body + + +class TestDispatch(PecanTestCase): + + @property + def app_(self): + class SubSubController(object): + @expose() + def index(self, req, resp): + assert isinstance(req, webob.BaseRequest) + assert isinstance(resp, webob.Response) + return '/sub/sub/' + + @expose() + def deeper(self, req, resp): + assert isinstance(req, webob.BaseRequest) + assert isinstance(resp, webob.Response) + return '/sub/sub/deeper' + + class SubController(object): + @expose() + def index(self, req, resp): + assert isinstance(req, webob.BaseRequest) + assert isinstance(resp, webob.Response) + return '/sub/' + + @expose() + def deeper(self, req, resp): + assert isinstance(req, webob.BaseRequest) + assert isinstance(resp, webob.Response) + return '/sub/deeper' + + sub = SubSubController() + + class RootController(object): + @expose() + def index(self, req, resp): + assert isinstance(req, webob.BaseRequest) + assert isinstance(resp, webob.Response) + return '/' + + @expose() + def deeper(self, req, resp): + assert isinstance(req, webob.BaseRequest) + assert isinstance(resp, webob.Response) + return '/deeper' + + sub = SubController() + + return TestApp(Pecan(RootController(), use_context_locals=False)) + + def test_index(self): + r = self.app_.get('/') + assert r.status_int == 200 + assert r.body == b_('/') + + def test_one_level(self): + r = self.app_.get('/deeper') + assert r.status_int == 200 + assert r.body == b_('/deeper') + + def test_one_level_with_trailing(self): + r = self.app_.get('/sub/') + assert r.status_int == 200 + assert r.body == b_('/sub/') + + def test_two_levels(self): + r = self.app_.get('/sub/deeper') + assert r.status_int == 200 + assert r.body == b_('/sub/deeper') + + def test_two_levels_with_trailing(self): + r = self.app_.get('/sub/sub/') + assert r.status_int == 200 + + def test_three_levels(self): + r = self.app_.get('/sub/sub/deeper') + assert r.status_int == 200 + assert r.body == b_('/sub/sub/deeper') + + +class TestLookups(PecanTestCase): + + @property + def app_(self): + class LookupController(object): + def __init__(self, someID): + self.someID = someID + + @expose() + def index(self, req, resp): + return '/%s' % self.someID + + @expose() + def name(self, req, resp): + return '/%s/name' % self.someID + + class RootController(object): + @expose() + def index(self, req, resp): + return '/' + + @expose() + def _lookup(self, someID, *remainder): + return LookupController(someID), remainder + + return TestApp(Pecan(RootController(), use_context_locals=False)) + + def test_index(self): + r = self.app_.get('/') + assert r.status_int == 200 + assert r.body == b_('/') + + def test_lookup(self): + r = self.app_.get('/100/') + assert r.status_int == 200 + assert r.body == b_('/100') + + def test_lookup_with_method(self): + r = self.app_.get('/100/name') + assert r.status_int == 200 + assert r.body == b_('/100/name') + + def test_lookup_with_wrong_argspec(self): + class RootController(object): + @expose() + def _lookup(self, someID): + return 'Bad arg spec' # pragma: nocover + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + app = TestApp(Pecan(RootController(), use_context_locals=False)) + r = app.get('/foo/bar', expect_errors=True) + assert r.status_int == 404 + + +class TestCanonicalLookups(PecanTestCase): + + @property + def app_(self): + class LookupController(object): + def __init__(self, someID): + self.someID = someID + + @expose() + def index(self, req, resp): + return self.someID + + class UserController(object): + @expose() + def _lookup(self, someID, *remainder): + return LookupController(someID), remainder + + class RootController(object): + users = UserController() + + return TestApp(Pecan(RootController(), use_context_locals=False)) + + def test_canonical_lookup(self): + assert self.app_.get('/users', expect_errors=404).status_int == 404 + assert self.app_.get('/users/', expect_errors=404).status_int == 404 + assert self.app_.get('/users/100').status_int == 302 + assert self.app_.get('/users/100/').body == b_('100') + + +class TestControllerArguments(PecanTestCase): + + @property + def app_(self): + class RootController(object): + @expose() + def index(self, req, resp, id): + return 'index: %s' % id + + @expose() + def multiple(self, req, resp, one, two): + return 'multiple: %s, %s' % (one, two) + + @expose() + def optional(self, req, resp, id=None): + return 'optional: %s' % str(id) + + @expose() + def multiple_optional(self, req, resp, one=None, two=None, + three=None): + return 'multiple_optional: %s, %s, %s' % (one, two, three) + + @expose() + def variable_args(self, req, resp, *args): + return 'variable_args: %s' % ', '.join(args) + + @expose() + def variable_kwargs(self, req, resp, **kwargs): + data = [ + '%s=%s' % (key, kwargs[key]) + for key in sorted(kwargs.keys()) + ] + return 'variable_kwargs: %s' % ', '.join(data) + + @expose() + def variable_all(self, req, resp, *args, **kwargs): + data = [ + '%s=%s' % (key, kwargs[key]) + for key in sorted(kwargs.keys()) + ] + return 'variable_all: %s' % ', '.join(list(args) + data) + + @expose() + def eater(self, req, resp, id, dummy=None, *args, **kwargs): + data = [ + '%s=%s' % (key, kwargs[key]) + for key in sorted(kwargs.keys()) + ] + return 'eater: %s, %s, %s' % ( + id, + dummy, + ', '.join(list(args) + data) + ) + + @expose() + def _route(self, args, request): + if hasattr(self, args[0]): + return getattr(self, args[0]), args[1:] + else: + return self.index, args + + return TestApp(Pecan(RootController(), use_context_locals=False)) + + def test_required_argument(self): + try: + r = self.app_.get('/') + assert r.status_int != 200 # pragma: nocover + except Exception as ex: + assert type(ex) == TypeError + assert ex.args[0] in ( + "index() takes exactly 4 arguments (3 given)", + "index() missing 1 required positional argument: 'id'" + ) # this messaging changed in Python 3.3 + + def test_single_argument(self): + r = self.app_.get('/1') + assert r.status_int == 200 + assert r.body == b_('index: 1') + + def test_single_argument_with_encoded_url(self): + r = self.app_.get('/This%20is%20a%20test%21') + assert r.status_int == 200 + assert r.body == b_('index: This is a test!') + + def test_two_arguments(self): + r = self.app_.get('/1/dummy', status=404) + assert r.status_int == 404 + + def test_keyword_argument(self): + r = self.app_.get('/?id=2') + assert r.status_int == 200 + assert r.body == b_('index: 2') + + def test_keyword_argument_with_encoded_url(self): + r = self.app_.get('/?id=This%20is%20a%20test%21') + assert r.status_int == 200 + assert r.body == b_('index: This is a test!') + + def test_argument_and_keyword_argument(self): + r = self.app_.get('/3?id=three') + assert r.status_int == 200 + assert r.body == b_('index: 3') + + def test_encoded_argument_and_keyword_argument(self): + r = self.app_.get('/This%20is%20a%20test%21?id=three') + assert r.status_int == 200 + assert r.body == b_('index: This is a test!') + + def test_explicit_kwargs(self): + r = self.app_.post('/', {'id': '4'}) + assert r.status_int == 200 + assert r.body == b_('index: 4') + + def test_path_with_explicit_kwargs(self): + r = self.app_.post('/4', {'id': 'four'}) + assert r.status_int == 200 + assert r.body == b_('index: 4') + + def test_multiple_kwargs(self): + r = self.app_.get('/?id=5&dummy=dummy') + assert r.status_int == 200 + assert r.body == b_('index: 5') + + def test_kwargs_from_root(self): + r = self.app_.post('/', {'id': '6', 'dummy': 'dummy'}) + assert r.status_int == 200 + assert r.body == b_('index: 6') + + # multiple args + + def test_multiple_positional_arguments(self): + r = self.app_.get('/multiple/one/two') + assert r.status_int == 200 + assert r.body == b_('multiple: one, two') + + def test_multiple_positional_arguments_with_url_encode(self): + r = self.app_.get('/multiple/One%20/Two%21') + assert r.status_int == 200 + assert r.body == b_('multiple: One , Two!') + + def test_multiple_positional_arguments_with_kwargs(self): + r = self.app_.get('/multiple?one=three&two=four') + assert r.status_int == 200 + assert r.body == b_('multiple: three, four') + + def test_multiple_positional_arguments_with_url_encoded_kwargs(self): + r = self.app_.get('/multiple?one=Three%20&two=Four%20%21') + assert r.status_int == 200 + assert r.body == b_('multiple: Three , Four !') + + def test_positional_args_with_dictionary_kwargs(self): + r = self.app_.post('/multiple', {'one': 'five', 'two': 'six'}) + assert r.status_int == 200 + assert r.body == b_('multiple: five, six') + + def test_positional_args_with_url_encoded_dictionary_kwargs(self): + r = self.app_.post('/multiple', {'one': 'Five%20', 'two': 'Six%20%21'}) + assert r.status_int == 200 + assert r.body == b_('multiple: Five%20, Six%20%21') + + # optional arg + def test_optional_arg(self): + r = self.app_.get('/optional') + assert r.status_int == 200 + assert r.body == b_('optional: None') + + def test_multiple_optional(self): + r = self.app_.get('/optional/1') + assert r.status_int == 200 + assert r.body == b_('optional: 1') + + def test_multiple_optional_url_encoded(self): + r = self.app_.get('/optional/Some%20Number') + assert r.status_int == 200 + assert r.body == b_('optional: Some Number') + + def test_multiple_optional_missing(self): + r = self.app_.get('/optional/2/dummy', status=404) + assert r.status_int == 404 + + def test_multiple_with_kwargs(self): + r = self.app_.get('/optional?id=2') + assert r.status_int == 200 + assert r.body == b_('optional: 2') + + def test_multiple_with_url_encoded_kwargs(self): + r = self.app_.get('/optional?id=Some%20Number') + assert r.status_int == 200 + assert r.body == b_('optional: Some Number') + + def test_multiple_args_with_url_encoded_kwargs(self): + r = self.app_.get('/optional/3?id=three') + assert r.status_int == 200 + assert r.body == b_('optional: 3') + + def test_url_encoded_positional_args(self): + r = self.app_.get('/optional/Some%20Number?id=three') + assert r.status_int == 200 + assert r.body == b_('optional: Some Number') + + def test_optional_arg_with_kwargs(self): + r = self.app_.post('/optional', {'id': '4'}) + assert r.status_int == 200 + assert r.body == b_('optional: 4') + + def test_optional_arg_with_url_encoded_kwargs(self): + r = self.app_.post('/optional', {'id': 'Some%20Number'}) + assert r.status_int == 200 + assert r.body == b_('optional: Some%20Number') + + def test_multiple_positional_arguments_with_dictionary_kwargs(self): + r = self.app_.post('/optional/5', {'id': 'five'}) + assert r.status_int == 200 + assert r.body == b_('optional: 5') + + def test_multiple_positional_url_encoded_arguments_with_kwargs(self): + r = self.app_.post('/optional/Some%20Number', {'id': 'five'}) + assert r.status_int == 200 + assert r.body == b_('optional: Some Number') + + def test_optional_arg_with_multiple_kwargs(self): + r = self.app_.get('/optional?id=6&dummy=dummy') + assert r.status_int == 200 + assert r.body == b_('optional: 6') + + def test_optional_arg_with_multiple_url_encoded_kwargs(self): + r = self.app_.get('/optional?id=Some%20Number&dummy=dummy') + assert r.status_int == 200 + assert r.body == b_('optional: Some Number') + + def test_optional_arg_with_multiple_dictionary_kwargs(self): + r = self.app_.post('/optional', {'id': '7', 'dummy': 'dummy'}) + assert r.status_int == 200 + assert r.body == b_('optional: 7') + + def test_optional_arg_with_multiple_url_encoded_dictionary_kwargs(self): + r = self.app_.post('/optional', { + 'id': 'Some%20Number', + 'dummy': 'dummy' + }) + assert r.status_int == 200 + assert r.body == b_('optional: Some%20Number') + + # multiple optional args + + def test_multiple_optional_positional_args(self): + r = self.app_.get('/multiple_optional') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: None, None, None') + + def test_multiple_optional_positional_args_one_arg(self): + r = self.app_.get('/multiple_optional/1') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: 1, None, None') + + def test_multiple_optional_positional_args_one_url_encoded_arg(self): + r = self.app_.get('/multiple_optional/One%21') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: One!, None, None') + + def test_multiple_optional_positional_args_all_args(self): + r = self.app_.get('/multiple_optional/1/2/3') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: 1, 2, 3') + + def test_multiple_optional_positional_args_all_url_encoded_args(self): + r = self.app_.get('/multiple_optional/One%21/Two%21/Three%21') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: One!, Two!, Three!') + + def test_multiple_optional_positional_args_too_many_args(self): + r = self.app_.get('/multiple_optional/1/2/3/dummy', status=404) + assert r.status_int == 404 + + def test_multiple_optional_positional_args_with_kwargs(self): + r = self.app_.get('/multiple_optional?one=1') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: 1, None, None') + + def test_multiple_optional_positional_args_with_url_encoded_kwargs(self): + r = self.app_.get('/multiple_optional?one=One%21') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: One!, None, None') + + def test_multiple_optional_positional_args_with_string_kwargs(self): + r = self.app_.get('/multiple_optional/1?one=one') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: 1, None, None') + + def test_multiple_optional_positional_args_with_encoded_str_kwargs(self): + r = self.app_.get('/multiple_optional/One%21?one=one') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: One!, None, None') + + def test_multiple_optional_positional_args_with_dict_kwargs(self): + r = self.app_.post('/multiple_optional', {'one': '1'}) + assert r.status_int == 200 + assert r.body == b_('multiple_optional: 1, None, None') + + def test_multiple_optional_positional_args_with_encoded_dict_kwargs(self): + r = self.app_.post('/multiple_optional', {'one': 'One%21'}) + assert r.status_int == 200 + assert r.body == b_('multiple_optional: One%21, None, None') + + def test_multiple_optional_positional_args_and_dict_kwargs(self): + r = self.app_.post('/multiple_optional/1', {'one': 'one'}) + assert r.status_int == 200 + assert r.body == b_('multiple_optional: 1, None, None') + + def test_multiple_optional_encoded_positional_args_and_dict_kwargs(self): + r = self.app_.post('/multiple_optional/One%21', {'one': 'one'}) + assert r.status_int == 200 + assert r.body == b_('multiple_optional: One!, None, None') + + def test_multiple_optional_args_with_multiple_kwargs(self): + r = self.app_.get('/multiple_optional?one=1&two=2&three=3&four=4') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: 1, 2, 3') + + def test_multiple_optional_args_with_multiple_encoded_kwargs(self): + r = self.app_.get( + '/multiple_optional?one=One%21&two=Two%21&three=Three%21&four=4' + ) + assert r.status_int == 200 + assert r.body == b_('multiple_optional: One!, Two!, Three!') + + def test_multiple_optional_args_with_multiple_dict_kwargs(self): + r = self.app_.post( + '/multiple_optional', + {'one': '1', 'two': '2', 'three': '3', 'four': '4'} + ) + assert r.status_int == 200 + assert r.body == b_('multiple_optional: 1, 2, 3') + + def test_multiple_optional_args_with_multiple_encoded_dict_kwargs(self): + r = self.app_.post( + '/multiple_optional', + { + 'one': 'One%21', + 'two': 'Two%21', + 'three': 'Three%21', + 'four': '4' + } + ) + assert r.status_int == 200 + assert r.body == b_('multiple_optional: One%21, Two%21, Three%21') + + def test_multiple_optional_args_with_last_kwarg(self): + r = self.app_.get('/multiple_optional?three=3') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: None, None, 3') + + def test_multiple_optional_args_with_last_encoded_kwarg(self): + r = self.app_.get('/multiple_optional?three=Three%21') + assert r.status_int == 200 + assert r.body == b_('multiple_optional: None, None, Three!') + + def test_multiple_optional_args_with_middle_arg(self): + r = self.app_.get('/multiple_optional', {'two': '2'}) + assert r.status_int == 200 + assert r.body == b_('multiple_optional: None, 2, None') + + def test_variable_args(self): + r = self.app_.get('/variable_args') + assert r.status_int == 200 + assert r.body == b_('variable_args: ') + + def test_multiple_variable_args(self): + r = self.app_.get('/variable_args/1/dummy') + assert r.status_int == 200 + assert r.body == b_('variable_args: 1, dummy') + + def test_multiple_encoded_variable_args(self): + r = self.app_.get('/variable_args/Testing%20One%20Two/Three%21') + assert r.status_int == 200 + assert r.body == b_('variable_args: Testing One Two, Three!') + + def test_variable_args_with_kwargs(self): + r = self.app_.get('/variable_args?id=2&dummy=dummy') + assert r.status_int == 200 + assert r.body == b_('variable_args: ') + + def test_variable_args_with_dict_kwargs(self): + r = self.app_.post('/variable_args', {'id': '3', 'dummy': 'dummy'}) + assert r.status_int == 200 + assert r.body == b_('variable_args: ') + + def test_variable_kwargs(self): + r = self.app_.get('/variable_kwargs') + assert r.status_int == 200 + assert r.body == b_('variable_kwargs: ') + + def test_multiple_variable_kwargs(self): + r = self.app_.get('/variable_kwargs/1/dummy', status=404) + assert r.status_int == 404 + + def test_multiple_variable_kwargs_with_explicit_kwargs(self): + r = self.app_.get('/variable_kwargs?id=2&dummy=dummy') + assert r.status_int == 200 + assert r.body == b_('variable_kwargs: dummy=dummy, id=2') + + def test_multiple_variable_kwargs_with_explicit_encoded_kwargs(self): + r = self.app_.get( + '/variable_kwargs?id=Two%21&dummy=This%20is%20a%20test' + ) + assert r.status_int == 200 + assert r.body == b_('variable_kwargs: dummy=This is a test, id=Two!') + + def test_multiple_variable_kwargs_with_dict_kwargs(self): + r = self.app_.post('/variable_kwargs', {'id': '3', 'dummy': 'dummy'}) + assert r.status_int == 200 + assert r.body == b_('variable_kwargs: dummy=dummy, id=3') + + def test_multiple_variable_kwargs_with_encoded_dict_kwargs(self): + r = self.app_.post( + '/variable_kwargs', + {'id': 'Three%21', 'dummy': 'This%20is%20a%20test'} + ) + assert r.status_int == 200 + result = 'variable_kwargs: dummy=This%20is%20a%20test, id=Three%21' + assert r.body == b_(result) + + def test_variable_all(self): + r = self.app_.get('/variable_all') + assert r.status_int == 200 + assert r.body == b_('variable_all: ') + + def test_variable_all_with_one_extra(self): + r = self.app_.get('/variable_all/1') + assert r.status_int == 200 + assert r.body == b_('variable_all: 1') + + def test_variable_all_with_two_extras(self): + r = self.app_.get('/variable_all/2/dummy') + assert r.status_int == 200 + assert r.body == b_('variable_all: 2, dummy') + + def test_variable_mixed(self): + r = self.app_.get('/variable_all/3?month=1&day=12') + assert r.status_int == 200 + assert r.body == b_('variable_all: 3, day=12, month=1') + + def test_variable_mixed_explicit(self): + r = self.app_.get('/variable_all/4?id=four&month=1&day=12') + assert r.status_int == 200 + assert r.body == b_('variable_all: 4, day=12, id=four, month=1') + + def test_variable_post(self): + r = self.app_.post('/variable_all/5/dummy') + assert r.status_int == 200 + assert r.body == b_('variable_all: 5, dummy') + + def test_variable_post_with_kwargs(self): + r = self.app_.post('/variable_all/6', {'month': '1', 'day': '12'}) + assert r.status_int == 200 + assert r.body == b_('variable_all: 6, day=12, month=1') + + def test_variable_post_mixed(self): + r = self.app_.post( + '/variable_all/7', + {'id': 'seven', 'month': '1', 'day': '12'} + ) + assert r.status_int == 200 + assert r.body == b_('variable_all: 7, day=12, id=seven, month=1') + + def test_no_remainder(self): + try: + r = self.app_.get('/eater') + assert r.status_int != 200 # pragma: nocover + except Exception as ex: + assert type(ex) == TypeError + assert ex.args[0] in ( + "eater() takes at least 4 arguments (3 given)", + "eater() missing 1 required positional argument: 'id'" + ) # this messaging changed in Python 3.3 + + def test_one_remainder(self): + r = self.app_.get('/eater/1') + assert r.status_int == 200 + assert r.body == b_('eater: 1, None, ') + + def test_two_remainders(self): + r = self.app_.get('/eater/2/dummy') + assert r.status_int == 200 + assert r.body == b_('eater: 2, dummy, ') + + def test_many_remainders(self): + r = self.app_.get('/eater/3/dummy/foo/bar') + assert r.status_int == 200 + assert r.body == b_('eater: 3, dummy, foo, bar') + + def test_remainder_with_kwargs(self): + r = self.app_.get('/eater/4?month=1&day=12') + assert r.status_int == 200 + assert r.body == b_('eater: 4, None, day=12, month=1') + + def test_remainder_with_many_kwargs(self): + r = self.app_.get('/eater/5?id=five&month=1&day=12&dummy=dummy') + assert r.status_int == 200 + assert r.body == b_('eater: 5, dummy, day=12, month=1') + + def test_post_remainder(self): + r = self.app_.post('/eater/6') + assert r.status_int == 200 + assert r.body == b_('eater: 6, None, ') + + def test_post_three_remainders(self): + r = self.app_.post('/eater/7/dummy') + assert r.status_int == 200 + assert r.body == b_('eater: 7, dummy, ') + + def test_post_many_remainders(self): + r = self.app_.post('/eater/8/dummy/foo/bar') + assert r.status_int == 200 + assert r.body == b_('eater: 8, dummy, foo, bar') + + def test_post_remainder_with_kwargs(self): + r = self.app_.post('/eater/9', {'month': '1', 'day': '12'}) + assert r.status_int == 200 + assert r.body == b_('eater: 9, None, day=12, month=1') + + def test_post_many_remainders_with_many_kwargs(self): + r = self.app_.post( + '/eater/10', + {'id': 'ten', 'month': '1', 'day': '12', 'dummy': 'dummy'} + ) + assert r.status_int == 200 + assert r.body == b_('eater: 10, dummy, day=12, month=1') + + +class TestRestController(PecanTestCase): + + @property + def app_(self): + + class OthersController(object): + + @expose() + def index(self, req, resp): + return 'OTHERS' + + @expose() + def echo(self, req, resp, value): + return str(value) + + class ThingsController(RestController): + data = ['zero', 'one', 'two', 'three'] + + _custom_actions = {'count': ['GET'], 'length': ['GET', 'POST']} + + others = OthersController() + + @expose() + def get_one(self, req, resp, id): + return self.data[int(id)] + + @expose('json') + def get_all(self, req, resp): + return dict(items=self.data) + + @expose() + def length(self, req, resp, id, value=None): + length = len(self.data[int(id)]) + if value: + length += len(value) + return str(length) + + @expose() + def post(self, req, resp, value): + self.data.append(value) + resp.status = 302 + return 'CREATED' + + @expose() + def edit(self, req, resp, id): + return 'EDIT %s' % self.data[int(id)] + + @expose() + def put(self, req, resp, id, value): + self.data[int(id)] = value + return 'UPDATED' + + @expose() + def get_delete(self, req, resp, id): + return 'DELETE %s' % self.data[int(id)] + + @expose() + def delete(self, req, resp, id): + del self.data[int(id)] + return 'DELETED' + + @expose() + def reset(self, req, resp): + return 'RESET' + + @expose() + def post_options(self, req, resp): + return 'OPTIONS' + + @expose() + def options(self, req, resp): + abort(500) + + @expose() + def other(self, req, resp): + abort(500) + + class RootController(object): + things = ThingsController() + + # create the app + return TestApp(Pecan(RootController(), use_context_locals=False)) + + def test_get_all(self): + r = self.app_.get('/things') + assert r.status_int == 200 + assert r.body == b_(dumps(dict(items=['zero', 'one', 'two', 'three']))) + + def test_get_one(self): + for i, value in enumerate(['zero', 'one', 'two', 'three']): + r = self.app_.get('/things/%d' % i) + assert r.status_int == 200 + assert r.body == b_(value) + + def test_post(self): + r = self.app_.post('/things', {'value': 'four'}) + assert r.status_int == 302 + assert r.body == b_('CREATED') + + def test_custom_action(self): + r = self.app_.get('/things/3/edit') + assert r.status_int == 200 + assert r.body == b_('EDIT three') + + def test_put(self): + r = self.app_.put('/things/3', {'value': 'THREE!'}) + assert r.status_int == 200 + assert r.body == b_('UPDATED') + + def test_put_with_method_parameter_and_get(self): + r = self.app_.get('/things/3?_method=put', {'value': 'X'}, status=405) + assert r.status_int == 405 + + def test_put_with_method_parameter_and_post(self): + r = self.app_.post('/things/3?_method=put', {'value': 'THREE!'}) + assert r.status_int == 200 + assert r.body == b_('UPDATED') + + def test_get_delete(self): + r = self.app_.get('/things/3/delete') + assert r.status_int == 200 + assert r.body == b_('DELETE three') + + def test_delete_method(self): + r = self.app_.delete('/things/3') + assert r.status_int == 200 + assert r.body == b_('DELETED') + + def test_delete_with_method_parameter(self): + r = self.app_.get('/things/3?_method=DELETE', status=405) + assert r.status_int == 405 + + def test_delete_with_method_parameter_and_post(self): + r = self.app_.post('/things/3?_method=DELETE') + assert r.status_int == 200 + assert r.body == b_('DELETED') + + def test_custom_method_type(self): + r = self.app_.request('/things', method='RESET') + assert r.status_int == 200 + assert r.body == b_('RESET') + + def test_custom_method_type_with_method_parameter(self): + r = self.app_.get('/things?_method=RESET') + assert r.status_int == 200 + assert r.body == b_('RESET') + + def test_options(self): + r = self.app_.request('/things', method='OPTIONS') + assert r.status_int == 200 + assert r.body == b_('OPTIONS') + + def test_options_with_method_parameter(self): + r = self.app_.post('/things', {'_method': 'OPTIONS'}) + assert r.status_int == 200 + assert r.body == b_('OPTIONS') + + def test_other_custom_action(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r = self.app_.request('/things/other', method='MISC', status=405) + assert r.status_int == 405 + + def test_other_custom_action_with_method_parameter(self): + r = self.app_.post('/things/other', {'_method': 'MISC'}, status=405) + assert r.status_int == 405 + + def test_nested_controller_with_trailing_slash(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r = self.app_.request('/things/others/', method='MISC') + assert r.status_int == 200 + assert r.body == b_('OTHERS') + + def test_nested_controller_without_trailing_slash(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + r = self.app_.request('/things/others', method='MISC', status=302) + assert r.status_int == 302 + + def test_invalid_custom_action(self): + r = self.app_.get('/things?_method=BAD', status=404) + assert r.status_int == 404 + + def test_named_action(self): + # test custom "GET" request "length" + r = self.app_.get('/things/1/length') + assert r.status_int == 200 + assert r.body == b_(str(len('one'))) + + def test_named_nested_action(self): + # test custom "GET" request through subcontroller + r = self.app_.get('/things/others/echo?value=test') + assert r.status_int == 200 + assert r.body == b_('test') + + def test_nested_post(self): + # test custom "POST" request through subcontroller + r = self.app_.post('/things/others/echo', {'value': 'test'}) + assert r.status_int == 200 + assert r.body == b_('test') + + +class TestHooks(PecanTestCase): + + def test_basic_single_hook(self): + run_hook = [] + + class RootController(object): + @expose() + def index(self, req, resp): + run_hook.append('inside') + return 'Hello, World!' + + class SimpleHook(PecanHook): + def on_route(self, state): + run_hook.append('on_route') + + def before(self, state): + run_hook.append('before') + + def after(self, state): + run_hook.append('after') + + def on_error(self, state, e): + run_hook.append('error') + + app = TestApp(Pecan( + RootController(), + hooks=[SimpleHook()], + use_context_locals=False + )) + response = app.get('/') + assert response.status_int == 200 + assert response.body == b_('Hello, World!') + + assert len(run_hook) == 4 + assert run_hook[0] == 'on_route' + assert run_hook[1] == 'before' + assert run_hook[2] == 'inside' + assert run_hook[3] == 'after' + + def test_basic_multi_hook(self): + run_hook = [] + + class RootController(object): + @expose() + def index(self, req, resp): + run_hook.append('inside') + return 'Hello, World!' + + class SimpleHook(PecanHook): + def __init__(self, id): + self.id = str(id) + + def on_route(self, state): + run_hook.append('on_route' + self.id) + + def before(self, state): + run_hook.append('before' + self.id) + + def after(self, state): + run_hook.append('after' + self.id) + + def on_error(self, state, e): + run_hook.append('error' + self.id) + + app = TestApp(Pecan(RootController(), hooks=[ + SimpleHook(1), SimpleHook(2), SimpleHook(3) + ], use_context_locals=False)) + response = app.get('/') + assert response.status_int == 200 + assert response.body == b_('Hello, World!') + + assert len(run_hook) == 10 + assert run_hook[0] == 'on_route1' + assert run_hook[1] == 'on_route2' + assert run_hook[2] == 'on_route3' + assert run_hook[3] == 'before1' + assert run_hook[4] == 'before2' + assert run_hook[5] == 'before3' + assert run_hook[6] == 'inside' + assert run_hook[7] == 'after3' + assert run_hook[8] == 'after2' + assert run_hook[9] == 'after1' + + def test_partial_hooks(self): + run_hook = [] + + class RootController(object): + @expose() + def index(self, req, resp): + run_hook.append('inside') + return 'Hello World!' + + @expose() + def causeerror(self, req, resp): + return [][1] + + class ErrorHook(PecanHook): + def on_error(self, state, e): + run_hook.append('error') + + class OnRouteHook(PecanHook): + def on_route(self, state): + run_hook.append('on_route') + + app = TestApp(Pecan(RootController(), hooks=[ + ErrorHook(), OnRouteHook() + ], use_context_locals=False)) + + response = app.get('/') + assert response.status_int == 200 + assert response.body == b_('Hello World!') + + assert len(run_hook) == 2 + assert run_hook[0] == 'on_route' + assert run_hook[1] == 'inside' + + run_hook = [] + try: + response = app.get('/causeerror') + except Exception as e: + assert isinstance(e, IndexError) + + assert len(run_hook) == 2 + assert run_hook[0] == 'on_route' + assert run_hook[1] == 'error' + + def test_on_error_response_hook(self): + run_hook = [] + + class RootController(object): + @expose() + def causeerror(self, req, resp): + return [][1] + + class ErrorHook(PecanHook): + def on_error(self, state, e): + run_hook.append('error') + + r = webob.Response() + r.text = u_('on_error') + + return r + + app = TestApp(Pecan(RootController(), hooks=[ + ErrorHook() + ], use_context_locals=False)) + + response = app.get('/causeerror') + + assert len(run_hook) == 1 + assert run_hook[0] == 'error' + assert response.text == 'on_error' + + def test_prioritized_hooks(self): + run_hook = [] + + class RootController(object): + @expose() + def index(self, req, resp): + run_hook.append('inside') + return 'Hello, World!' + + class SimpleHook(PecanHook): + def __init__(self, id, priority=None): + self.id = str(id) + if priority: + self.priority = priority + + def on_route(self, state): + run_hook.append('on_route' + self.id) + + def before(self, state): + run_hook.append('before' + self.id) + + def after(self, state): + run_hook.append('after' + self.id) + + def on_error(self, state, e): + run_hook.append('error' + self.id) + + papp = Pecan(RootController(), hooks=[ + SimpleHook(1, 3), SimpleHook(2, 2), SimpleHook(3, 1) + ], use_context_locals=False) + app = TestApp(papp) + response = app.get('/') + assert response.status_int == 200 + assert response.body == b_('Hello, World!') + + assert len(run_hook) == 10 + assert run_hook[0] == 'on_route3' + assert run_hook[1] == 'on_route2' + assert run_hook[2] == 'on_route1' + assert run_hook[3] == 'before3' + assert run_hook[4] == 'before2' + assert run_hook[5] == 'before1' + assert run_hook[6] == 'inside' + assert run_hook[7] == 'after1' + assert run_hook[8] == 'after2' + assert run_hook[9] == 'after3' + + def test_basic_isolated_hook(self): + run_hook = [] + + class SimpleHook(PecanHook): + def on_route(self, state): + run_hook.append('on_route') + + def before(self, state): + run_hook.append('before') + + def after(self, state): + run_hook.append('after') + + def on_error(self, state, e): + run_hook.append('error') + + class SubSubController(object): + @expose() + def index(self, req, resp): + run_hook.append('inside_sub_sub') + return 'Deep inside here!' + + class SubController(HookController): + __hooks__ = [SimpleHook()] + + @expose() + def index(self, req, resp): + run_hook.append('inside_sub') + return 'Inside here!' + + sub = SubSubController() + + class RootController(object): + @expose() + def index(self, req, resp): + run_hook.append('inside') + return 'Hello, World!' + + sub = SubController() + + app = TestApp(Pecan(RootController(), use_context_locals=False)) + response = app.get('/') + assert response.status_int == 200 + assert response.body == b_('Hello, World!') + + assert len(run_hook) == 1 + assert run_hook[0] == 'inside' + + run_hook = [] + + response = app.get('/sub/') + assert response.status_int == 200 + assert response.body == b_('Inside here!') + + assert len(run_hook) == 3 + assert run_hook[0] == 'before' + assert run_hook[1] == 'inside_sub' + assert run_hook[2] == 'after' + + run_hook = [] + response = app.get('/sub/sub/') + assert response.status_int == 200 + assert response.body == b_('Deep inside here!') + + assert len(run_hook) == 3 + assert run_hook[0] == 'before' + assert run_hook[1] == 'inside_sub_sub' + assert run_hook[2] == 'after' + + def test_isolated_hook_with_global_hook(self): + run_hook = [] + + class SimpleHook(PecanHook): + def __init__(self, id): + self.id = str(id) + + def on_route(self, state): + run_hook.append('on_route' + self.id) + + def before(self, state): + run_hook.append('before' + self.id) + + def after(self, state): + run_hook.append('after' + self.id) + + def on_error(self, state, e): + run_hook.append('error' + self.id) + + class SubController(HookController): + __hooks__ = [SimpleHook(2)] + + @expose() + def index(self, req, resp): + run_hook.append('inside_sub') + return 'Inside here!' + + class RootController(object): + @expose() + def index(self, req, resp): + run_hook.append('inside') + return 'Hello, World!' + + sub = SubController() + + app = TestApp(Pecan( + RootController(), + hooks=[SimpleHook(1)], + use_context_locals=False + )) + response = app.get('/') + assert response.status_int == 200 + assert response.body == b_('Hello, World!') + + assert len(run_hook) == 4 + assert run_hook[0] == 'on_route1' + assert run_hook[1] == 'before1' + assert run_hook[2] == 'inside' + assert run_hook[3] == 'after1' + + run_hook = [] + + response = app.get('/sub/') + assert response.status_int == 200 + assert response.body == b_('Inside here!') + + assert len(run_hook) == 6 + assert run_hook[0] == 'on_route1' + assert run_hook[1] == 'before2' + assert run_hook[2] == 'before1' + assert run_hook[3] == 'inside_sub' + assert run_hook[4] == 'after1' + assert run_hook[5] == 'after2' @@ -140,20 +140,20 @@ changedir = {envdir}/src/wsme commands = nosetests -v tests/pecantest [testenv:ceilometer-stable] -basepython = python2.6 +basepython = python2.7 deps = -egit+http://git.openstack.org/cgit/openstack/ceilometer@stable/icehouse#egg=ceilometer changedir = {envdir}/src/ceilometer -commands = tox -e py26 --notest # ensure a virtualenv is built - {envdir}/src/ceilometer/.tox/py26/bin/pip install -U {toxinidir} # install pecan-dev - tox -e py26 +commands = tox -e py27 --notest # ensure a virtualenv is built + {envdir}/src/ceilometer/.tox/py27/bin/pip install -U {toxinidir} # install pecan-dev + tox -e py27 [testenv:ceilometer-tip] -basepython = python2.6 +basepython = python2.7 deps = -egit+http://git.openstack.org/cgit/openstack/ceilometer#egg=ceilometer changedir = {envdir}/src/ceilometer -commands = tox -e py26 --notest # ensure a virtualenv is built - {envdir}/src/ceilometer/.tox/py26/bin/pip install -U {toxinidir} # install pecan-dev - tox -e py26 +commands = tox -e py27 --notest # ensure a virtualenv is built + {envdir}/src/ceilometer/.tox/py27/bin/pip install -U {toxinidir} # install pecan-dev + tox -e py27 [testenv:ironic-stable] basepython = python2.7 @@ -173,7 +173,7 @@ commands = tox -e py27 --notest # ensure a virtualenv is built [testenv:barbican-tip] basepython = python2.7 -deps = -egit+http://git.openstack.org/cgit/stackforge/barbican#egg=barbican +deps = -egit+http://git.openstack.org/cgit/openstack/barbican#egg=barbican changedir = {envdir}/src/barbican commands = tox -e py27 --notest # ensure a virtualenv is built {envdir}/src/barbican/.tox/py27/bin/pip install -U {toxinidir} # install pecan-dev |