summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/source/changes.rst25
-rw-r--r--docs/source/contextlocals.rst55
-rw-r--r--docs/source/hooks.rst103
-rw-r--r--docs/source/index.rst1
-rw-r--r--docs/source/routing.rst42
-rw-r--r--pecan/__init__.py8
-rw-r--r--pecan/core.py295
-rw-r--r--pecan/decorators.py2
-rw-r--r--pecan/hooks.py17
-rw-r--r--pecan/rest.py142
-rw-r--r--pecan/routing.py39
-rw-r--r--pecan/tests/test_base.py115
-rw-r--r--pecan/tests/test_generic.py25
-rw-r--r--pecan/tests/test_hooks.py72
-rw-r--r--pecan/tests/test_no_thread_locals.py1312
-rw-r--r--setup.py2
-rw-r--r--tox.ini16
17 files changed, 2111 insertions, 160 deletions
diff --git a/docs/source/changes.rst b/docs/source/changes.rst
index 75f22ec..3b01d0e 100644
--- a/docs/source/changes.rst
+++ b/docs/source/changes.rst
@@ -1,3 +1,28 @@
+0.6.0
+=====
+* Added support for disabling the `pecan.request` and `pecan.response`
+ threadlocals at the WSGI application level in favor of explicit reference
+ passing. For more information, see :ref:`contextlocals`.
+* Added better support for hook composition via subclassing and mixins. For
+ more information, see :ref:`attaching_hooks`.
+* Added support for specifying custom request and response implementations at
+ the WSGI application level for people who want to extend the functionality
+ provided by the base classes in `webob`.
+* Pecan controllers may now return an explicit `webob.Response` instance to
+ short-circuit Pecan's template rendering and serialization.
+* For generic methods that return HTTP 405, pecan now generates an `Allow`
+ header to communicate acceptable methods to the client.
+* Fixed a bug in adherence to RFC2616: if an exposed method returns no response
+ body (or namespace), pecan will now enforce an HTTP 204 response (instead of
+ HTTP 200).
+* Fixed a bug in adherence to RFC2616: when pecan responds with HTTP 204 or
+ HTTP 304, the `Content-Type` header is automatically stripped (because these
+ types of HTTP responses do not contain body content).
+* Fixed a bug: now when clients request JSON via an `Accept` header, `webob`
+ HTTP exceptions are serialized as JSON, not their native HTML representation.
+* Fixed a bug that broke applications which specified `default_renderer
+ = json`.
+
0.5.0
=====
* This release adds formal support for pypy.
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/hooks.rst b/docs/source/hooks.rst
index 7796189..1cdb4ef 100644
--- a/docs/source/hooks.rst
+++ b/docs/source/hooks.rst
@@ -87,6 +87,8 @@ no furthur :func:`~pecan.hooks.PecanHook.on_error` hooks will be executed::
if isinstance(exc, SomeExceptionType):
return webob.Response('Custom Error!', status=500)
+.. _attaching_hooks:
+
Attaching Hooks
---------------
@@ -130,9 +132,106 @@ when we run the app and browse the application from our web browser.
about to enter the controller...
DO SOMETHING!
- method: GET
- response: 200 OK
+ method: GET
+ response: 200 OK
+
+Hooks can be inherited from parent class or mixins. Just make sure to
+subclass from :class:`~pecan.hooks.HookController`.
+
+::
+
+ from pecan import expose
+ from pecan.hooks import PecanHook, HookController
+
+ class ParentHook(PecanHook):
+
+ priority = 1
+
+ def before(self, state):
+ print "\nabout to enter the parent controller..."
+
+ class CommonHook(PecanHook):
+
+ priority = 2
+
+ def before(self, state):
+ print "\njust a common hook..."
+
+ class SubHook(PecanHook):
+
+ def before(self, state):
+ print "\nabout to enter the subcontroller..."
+
+ class SubMixin(object):
+ __hooks__ = [SubHook()]
+
+ # We'll use the same instance for both controllers,
+ # to avoid double calls
+ common = CommonHook()
+
+ class SubController(HookController, SubMixin):
+
+ __hooks__ = [common]
+
+ @expose('json')
+ def index(self):
+ print "\nI AM THE SUB!"
+ return dict()
+
+ class RootController(HookController):
+
+ __hooks__ = [common, ParentHook()]
+
+ @expose('json')
+ def index(self):
+ print "\nI AM THE ROOT!"
+ return dict()
+
+ sub = SubController()
+
+Let's see what happens when we run the app.
+First loading the root controller:
+
+::
+
+ pecan serve config.py
+ serving on 0.0.0.0:8080 view at http://127.0.0.1:8080
+
+ GET / HTTP/1.1" 200
+
+ about to enter the parent controller...
+
+ just a common hook
+
+ I AM THE ROOT!
+
+Then loading the sub controller:
+
+::
+
+ pecan serve config.py
+ serving on 0.0.0.0:8080 view at http://127.0.0.1:8080
+
+ GET /sub HTTP/1.1" 200
+
+ about to enter the parent controller...
+
+ just a common hook
+
+ about to enter the subcontroller...
+
+ I AM THE SUB!
+
+.. note::
+
+ Make sure to set proper priority values for nested hooks in order
+ to get them executed in the desired order.
+
+.. warning::
+ Two hooks of the same type will be added/executed twice, if passed as
+ different instances to a parent and a child controller.
+ If passed as one instance variable - will be invoked once for both controllers.
Hooks That Come with Pecan
--------------------------
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/routing.rst b/docs/source/routing.rst
index ec79305..68a46ba 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:`pecan.Request`
+and :class:`pecan.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:
@@ -295,6 +295,34 @@ directly, there may be situations where you want to access them, such as:
* Manually rendering a response body
+Extending Pecan's Request and Response Object
+---------------------------------------------
+
+The request and response implementations provided by WebOb are powerful, but
+at times, it may be useful to extend application-specific behavior onto your
+request and response (such as specialized parsing of request headers or
+customized response body serialization). To do so, define custom classes that
+inherit from ``pecan.Request`` and ``pecan.Response``, respectively::
+
+ class MyRequest(pecan.Request):
+ pass
+
+ class MyResponse(pecan.Response):
+ pass
+
+and modify your application configuration to use them::
+
+ from myproject import MyRequest, MyResponse
+
+ app = {
+ 'root' : 'project.controllers.root.RootController',
+ 'modules' : ['project'],
+ 'static_root' : '%(confdir)s/public',
+ 'template_path' : '%(confdir)s/project/templates',
+ 'request_cls': MyRequest,
+ 'response_cls': MyResponse
+ }
+
Mapping Controller Arguments
----------------------------
diff --git a/pecan/__init__.py b/pecan/__init__.py
index c294572..4c52713 100644
--- a/pecan/__init__.py
+++ b/pecan/__init__.py
@@ -1,6 +1,6 @@
from .core import (
- abort, override_template, Pecan, load_app, redirect, render,
- request, response
+ abort, override_template, Pecan, Request, Response, load_app,
+ redirect, render, request, response
)
from .decorators import expose
from .hooks import RequestViewerHook
@@ -21,8 +21,8 @@ import warnings
__all__ = [
- 'make_app', 'load_app', 'Pecan', 'request', 'response',
- 'override_template', 'expose', 'conf', 'set_config', 'render',
+ 'make_app', 'load_app', 'Pecan', 'Request', 'Response', 'request',
+ 'response', 'override_template', 'expose', 'conf', 'set_config', 'render',
'abort', 'redirect'
]
diff --git a/pecan/core.py b/pecan/core.py
index 6a096b5..fae1502 100644
--- a/pecan/core.py
+++ b/pecan/core.py
@@ -1,7 +1,7 @@
try:
- from simplejson import loads
-except ImportError: # pragma: no cover
- from json import loads # noqa
+ from simplejson import dumps, loads
+except ImportError: # pragma: no cover
+ from json import dumps, loads # noqa
from itertools import chain
from mimetypes import guess_type, add_type
from os.path import splitext
@@ -10,7 +10,8 @@ import operator
import six
-from webob import Request, Response, exc, acceptparse
+from webob import (Request as WebObRequest, Response as WebObResponse, exc,
+ acceptparse)
from .compat import urlparse, unquote_plus, izip
from .secure import handle_security
@@ -27,10 +28,39 @@ 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
+
+
+class Request(WebObRequest):
+ pass
+
+
+class Response(WebObResponse):
+ pass
+
+
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 +117,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 +129,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:`pecan.Request` 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 +158,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 +168,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 +198,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'],
@@ -200,14 +209,14 @@ class Pecan(object):
template_path='templates', hooks=lambda: [],
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)
-
+ context_local_factory=None, request_cls=Request,
+ response_cls=Response, **kw):
if isinstance(root, six.string_types):
root = self.__translate_root__(root)
self.root = root
+ self.request_cls = request_cls
+ self.response_cls = response_cls
self.renderers = RendererFactory(custom_renderers, extra_template_vars)
self.default_renderer = default_renderer
@@ -223,12 +232,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 +262,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 +278,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 +301,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,26 +309,25 @@ 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)
# on_error hooks can choose to return a Response, which will
# be used instead of the standard error pages.
- if hook_type == 'on_error' and isinstance(result, Response):
+ if hook_type == 'on_error' and isinstance(result, WebObResponse):
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 +393,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 +406,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 +491,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 +502,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)
@@ -519,7 +528,7 @@ class Pecan(object):
# care of filling it out
if result is response:
return
- elif isinstance(result, Response):
+ elif isinstance(result, WebObResponse):
state.response = result
return
@@ -529,7 +538,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 +581,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 = self.request_cls(environ)
+ resp = self.response_cls()
+ state = RoutingState(req, resp, self)
+ controller = None
# handle the request
try:
@@ -582,35 +592,162 @@ 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):
+ # if the client asked for JSON, do our best to provide it
+ best_match = acceptparse.MIMEAccept(
+ getattr(req.accept, 'header_value', '*/*')
+ ).best_match(('text/plain', 'text/html', 'application/json'))
state.response = e
+ if best_match == 'application/json':
+ json_body = dumps({
+ 'code': e.status_int,
+ 'title': e.title,
+ 'description': e.detail
+ })
+ if isinstance(json_body, six.text_type):
+ e.text = json_body
+ else:
+ e.body = json_body
+ state.response.content_type = best_match
environ['pecan.original_exception'] = e
# 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):
+ if isinstance(on_error_result, WebObResponse):
state.response = on_error_result
else:
if not isinstance(e, exc.HTTPException):
raise
+
+ # if this is an HTTP 405, attempt to specify an Allow header
+ if isinstance(e, exc.HTTPMethodNotAllowed) and controller:
+ allowed_methods = _cfg(controller).get('allowed_methods', [])
+ if allowed_methods:
+ state.response.allow = sorted(allowed_methods)
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:
- return state.response(environ, start_response)
+ 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.
+ :param request_cls: Can be used to specify a custom `pecan.request` object.
+ Defaults to `pecan.Request`.
+ :param response_cls: Can be used to specify a custom `pecan.response`
+ object. Defaults to `pecan.Response`.
+ '''
+
+ 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:
+ 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/decorators.py b/pecan/decorators.py
index f40d16a..45ad635 100644
--- a/pecan/decorators.py
+++ b/pecan/decorators.py
@@ -16,6 +16,7 @@ def when_for(controller):
expose(**kw)(f)
_cfg(f)['generic_handler'] = True
controller._pecan['generic_handlers'][method.upper()] = f
+ controller._pecan['allowed_methods'].append(method.upper())
return f
return decorate
return when
@@ -56,6 +57,7 @@ def expose(template=None,
if generic:
cfg['generic'] = True
cfg['generic_handlers'] = dict(DEFAULT=f)
+ cfg['allowed_methods'] = []
f.when = when_for(f)
# store the arguments for this controller method
diff --git a/pecan/hooks.py b/pecan/hooks.py
index 4ceeb42..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',
@@ -14,6 +13,10 @@ __all__ = [
def walk_controller(root_class, controller, hooks):
if not isinstance(controller, (int, dict)):
+ for hook in getattr(controller, '__hooks__', []):
+ # Append hooks from controller class definition
+ hooks.add(hook)
+
for name, value in getmembers(controller):
if name == 'controller':
continue
@@ -22,7 +25,7 @@ def walk_controller(root_class, controller, hooks):
if iscontroller(value):
for hook in hooks:
- value._pecan.setdefault('hooks', []).append(hook)
+ value._pecan.setdefault('hooks', set()).add(hook)
elif hasattr(value, '__class__'):
if name.startswith('__') and name.endswith('__'):
continue
@@ -37,7 +40,12 @@ class HookControllerMeta(type):
'''
def __init__(cls, name, bases, dict_):
- walk_controller(cls, cls, dict_.get('__hooks__', []))
+ hooks = set(dict_.get('__hooks__', []))
+ for base in bases:
+ # Add hooks from parent class and mixins
+ for hook in getattr(base, '__hooks__', []):
+ hooks.add(hook)
+ walk_controller(cls, cls, hooks)
HookController = HookControllerMeta(
@@ -334,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..e78d287 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,20 @@ class RestController(object):
)
try:
- result = handler(method, args)
+ if len(getargspec(handler).args) == 3:
+ result = handler(method, args)
+ else:
+ 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 +94,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 +102,10 @@ class RestController(object):
# return the result
return result
- def _handle_lookup(self, args):
+ def _handle_lookup(self, args, request=None):
+ if request is None:
+ self._raise_method_deprecation_warning(self.handle_lookup)
+
# filter empty strings from the arg list
args = list(six.moves.filter(bool, args))
@@ -97,7 +114,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 +127,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,34 +142,39 @@ 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=None):
'''
Routes undefined actions (like RESET) to the appropriate controller.
'''
+ if request is None:
+ self._raise_method_deprecation_warning(self._handle_unknown_method)
+
# try finding a post_{custom} or {custom} method first
controller = self._find_controller('post_%s' % method, method)
if controller:
@@ -164,20 +187,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_get(self, method, remainder):
+ def _handle_get(self, method, remainder, request=None):
'''
Routes ``GET`` actions to the appropriate controller.
'''
+ if request is None:
+ self._raise_method_deprecation_warning(self._handle_get)
+
# route to a get_all or get if no additional parts are available
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 +221,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 +236,21 @@ class RestController(object):
abort(404)
- def _handle_delete(self, method, remainder):
+ def _handle_delete(self, method, remainder, request=None):
'''
Routes ``DELETE`` actions to the appropriate controller.
'''
+ if request is None:
+ self._raise_method_deprecation_warning(self._handle_delete)
+
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 +264,27 @@ 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=None):
'''
Routes ``POST`` requests.
'''
+ if request is None:
+ self._raise_method_deprecation_warning(self._handle_post)
+
# 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 +293,13 @@ class RestController(object):
abort(404)
- def _handle_put(self, method, remainder):
- return self._handle_post(method, remainder)
+ def _handle_put(self, method, remainder, request=None):
+ return self._handle_post(method, remainder, request)
+
+ def _handle_custom_action(self, method, remainder, request=None):
+ if request is None:
+ self._raise_method_deprecation_warning(self._handle_custom_action)
- def _handle_custom_action(self, method, remainder):
remainder = [r for r in remainder if r]
if remainder:
if method in ('put', 'delete'):
@@ -281,8 +318,23 @@ 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.
'''
request.pecan.setdefault('routing_args', []).extend(args)
+
+ def _raise_method_deprecation_warning(self, handler):
+ warnings.warn(
+ (
+ "The function signature for %s.%s.%s is changing "
+ "in the next version of pecan.\nPlease update to: "
+ "`%s(self, method, remainder, request)`." % (
+ self.__class__.__module__,
+ self.__class__.__name__,
+ handler.__name__,
+ handler.__name__
+ )
+ ),
+ DeprecationWarning
+ )
diff --git a/pecan/routing.py b/pecan/routing.py
index ca8c93f..c6abb2e 100644
--- a/pecan/routing.py
+++ b/pecan/routing.py
@@ -1,4 +1,5 @@
import warnings
+from inspect import getargspec
from webob import exc
@@ -23,17 +24,30 @@ class NonCanonicalPath(Exception):
self.remainder = remainder
-def lookup_controller(obj, remainder):
+def lookup_controller(obj, remainder, request=None):
'''
Traverses the requested url path and returns the appropriate controller
object, including default routes.
Handles common errors gracefully.
'''
+ if request is None:
+ warnings.warn(
+ (
+ "The function signature for %s.lookup_controller is changing "
+ "in the next version of pecan.\nPlease update to: "
+ "`lookup_controller(self, obj, remainder, request)`." % (
+ __name__,
+ )
+ ),
+ DeprecationWarning
+ )
+
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 +69,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 +92,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 +129,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..44a6861 100644
--- a/pecan/tests/test_base.py
+++ b/pecan/tests/test_base.py
@@ -8,14 +8,15 @@ else:
import unittest # pragma: nocover
import webob
+from webob.exc import HTTPNotFound
from webtest import TestApp
import six
from six import b as b_
from six.moves import cStringIO as StringIO
from pecan import (
- Pecan, expose, request, response, redirect, abort, make_app,
- override_template, render
+ Pecan, Request, Response, expose, request, response, redirect,
+ abort, make_app, override_template, render
)
from pecan.templating import (
_builtin_renderers as builtin_renderers, error_formatters
@@ -285,7 +286,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:
@@ -760,6 +761,42 @@ class TestControllerArguments(PecanTestCase):
assert r.body == b_('eater: 10, dummy, day=12, month=1')
+class TestDefaultErrorRendering(PecanTestCase):
+
+ def test_plain_error(self):
+ class RootController(object):
+ pass
+
+ app = TestApp(Pecan(RootController()))
+ r = app.get('/', status=404)
+ assert r.status_int == 404
+ assert r.content_type == 'text/plain'
+ assert r.body == b_(HTTPNotFound().plain_body({}))
+
+ def test_html_error(self):
+ class RootController(object):
+ pass
+
+ app = TestApp(Pecan(RootController()))
+ r = app.get('/', headers={'Accept': 'text/html'}, status=404)
+ assert r.status_int == 404
+ assert r.content_type == 'text/html'
+ assert r.body == b_(HTTPNotFound().html_body({}))
+
+ def test_json_error(self):
+ class RootController(object):
+ pass
+
+ app = TestApp(Pecan(RootController()))
+ r = app.get('/', headers={'Accept': 'application/json'}, status=404)
+ assert r.status_int == 404
+ json_resp = json.loads(r.body.decode())
+ assert json_resp['code'] == 404
+ assert json_resp['description'] is None
+ assert json_resp['title'] == 'Not Found'
+ assert r.content_type == 'application/json'
+
+
class TestAbort(PecanTestCase):
def test_abort(self):
@@ -954,6 +991,41 @@ class TestManualResponse(PecanTestCase):
assert r.body == b_('Hello, World!')
+class TestCustomResponseandRequest(PecanTestCase):
+
+ def test_custom_objects(self):
+
+ class CustomRequest(Request):
+
+ @property
+ def headers(self):
+ headers = super(CustomRequest, self).headers
+ headers['X-Custom-Request'] = 'ABC'
+ return headers
+
+ class CustomResponse(Response):
+
+ @property
+ def headers(self):
+ headers = super(CustomResponse, self).headers
+ headers['X-Custom-Response'] = 'XYZ'
+ return headers
+
+ class RootController(object):
+ @expose()
+ def index(self):
+ return request.headers.get('X-Custom-Request')
+
+ app = TestApp(Pecan(
+ RootController(),
+ request_cls=CustomRequest,
+ response_cls=CustomResponse
+ ))
+ r = app.get('/')
+ assert r.body == b_('ABC')
+ assert r.headers.get('X-Custom-Response') == 'XYZ'
+
+
class TestThreadLocalState(PecanTestCase):
def test_thread_local_dir(self):
@@ -1519,3 +1591,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_generic.py b/pecan/tests/test_generic.py
index 879ad26..453f123 100644
--- a/pecan/tests/test_generic.py
+++ b/pecan/tests/test_generic.py
@@ -6,7 +6,7 @@ except:
from six import b as b_
-from pecan import Pecan, expose
+from pecan import Pecan, expose, abort
from pecan.tests import PecanTestCase
@@ -37,3 +37,26 @@ class TestGeneric(PecanTestCase):
r = app.get('/do_get', status=404)
assert r.status_int == 404
+
+ def test_generic_allow_header(self):
+ class RootController(object):
+ @expose(generic=True)
+ def index(self):
+ abort(405)
+
+ @index.when(method='POST', template='json')
+ def do_post(self):
+ return dict(result='POST')
+
+ @index.when(method='GET')
+ def do_get(self):
+ return 'GET'
+
+ @index.when(method='PATCH')
+ def do_patch(self):
+ return 'PATCH'
+
+ app = TestApp(Pecan(RootController()))
+ r = app.delete('/', expect_errors=True)
+ assert r.status_int == 405
+ assert r.headers['Allow'] == 'GET, PATCH, POST'
diff --git a/pecan/tests/test_hooks.py b/pecan/tests/test_hooks.py
index 44963bf..8f1ca39 100644
--- a/pecan/tests/test_hooks.py
+++ b/pecan/tests/test_hooks.py
@@ -339,6 +339,78 @@ class TestHooks(PecanTestCase):
assert run_hook[4] == 'after1'
assert run_hook[5] == 'after2'
+ def test_mixin_hooks(self):
+ run_hook = []
+
+ class HelperHook(PecanHook):
+ priority = 2
+
+ def before(self, state):
+ run_hook.append('helper - before hook')
+
+ # we'll use the same hook instance to avoid duplicate calls
+ helper_hook = HelperHook()
+
+ class LastHook(PecanHook):
+ priority = 200
+
+ def before(self, state):
+ run_hook.append('last - before hook')
+
+ class SimpleHook(PecanHook):
+ priority = 1
+
+ def before(self, state):
+ run_hook.append('simple - before hook')
+
+ class HelperMixin(object):
+ __hooks__ = [helper_hook]
+
+ class LastMixin(object):
+ __hooks__ = [LastHook()]
+
+ class SubController(HookController, HelperMixin):
+ __hooks__ = [LastHook()]
+
+ @expose()
+ def index(self):
+ return "This is sub controller!"
+
+ class RootController(HookController, LastMixin):
+ __hooks__ = [SimpleHook(), helper_hook]
+
+ @expose()
+ def index(self):
+ run_hook.append('inside')
+ return 'Hello, World!'
+
+ sub = SubController()
+
+ papp = make_app(RootController())
+ app = TestApp(papp)
+ response = app.get('/')
+ assert response.status_int == 200
+ assert response.body == b_('Hello, World!')
+
+ assert len(run_hook) == 4
+ assert run_hook[0] == 'simple - before hook', run_hook[0]
+ assert run_hook[1] == 'helper - before hook', run_hook[1]
+ assert run_hook[2] == 'last - before hook', run_hook[2]
+ assert run_hook[3] == 'inside', run_hook[3]
+
+ run_hook = []
+ response = app.get('/sub/')
+ assert response.status_int == 200
+ assert response.body == b_('This is sub controller!')
+
+ assert len(run_hook) == 4, run_hook
+ assert run_hook[0] == 'simple - before hook', run_hook[0]
+ assert run_hook[1] == 'helper - before hook', run_hook[1]
+ assert run_hook[2] == 'last - before hook', run_hook[2]
+ # LastHook is invoked once again -
+ # for each different instance of the Hook in the two Controllers
+ assert run_hook[3] == 'last - before hook', run_hook[3]
+
class TestTransactionHook(PecanTestCase):
def test_transaction_hook(self):
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'
diff --git a/setup.py b/setup.py
index 02cbc6b..c39dab3 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@ import platform
from setuptools import setup, find_packages
-version = '0.5.0'
+version = '0.6.0'
#
# determine requirements
diff --git a/tox.ini b/tox.ini
index 201f1ae..42b8178 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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