diff --git a/ckan/config/middleware.py b/ckan/config/middleware.py index 75aa4f8a15a..8af2e2c5048 100644 --- a/ckan/config/middleware.py +++ b/ckan/config/middleware.py @@ -20,6 +20,14 @@ from repoze.who.middleware import PluggableAuthenticationMiddleware from fanstatic import Fanstatic +from wsgi_party import WSGIParty, HighAndDry +from flask import Flask +from flask import abort as flask_abort +from flask import request as flask_request +from flask import _request_ctx_stack +from werkzeug.exceptions import HTTPException +from werkzeug.test import create_environ, run_wsgi_app + from ckan.plugins import PluginImplementations from ckan.plugins.interfaces import IMiddleware from ckan.lib.i18n import get_locales_from_config @@ -32,6 +40,19 @@ def make_app(conf, full_stack=True, static_files=True, **app_conf): + + # :::TODO::: like the flask app, make the pylons app respond to invites at + # /__invite__/, and handle can_handle_request requests. + + pylons_app = make_pylons_stack(conf, full_stack, static_files, **app_conf) + flask_app = make_flask_stack(conf) + + app = AskAppDispatcherMiddleware({'pylons_app': pylons_app, 'flask_app': flask_app}) + + return app + + +def make_pylons_stack(conf, full_stack=True, static_files=True, **app_conf): """Create a Pylons WSGI application and return it ``conf`` @@ -74,7 +95,7 @@ def make_app(conf, full_stack=True, static_files=True, **app_conf): app = CacheMiddleware(app, config) # CUSTOM MIDDLEWARE HERE (filtered by error handling middlewares) - #app = QueueLogMiddleware(app) + # app = QueueLogMiddleware(app) if asbool(config.get('ckan.use_pylons_response_cleanup_middleware', True)): app = execute_on_completion(app, config, cleanup_pylons_response_string) @@ -152,7 +173,7 @@ def make_app(conf, full_stack=True, static_files=True, **app_conf): try: os.makedirs(path) except OSError, e: - ## errno 17 is file already exists + # errno 17 is file already exists if e.errno != 17: raise @@ -180,6 +201,155 @@ def make_app(conf, full_stack=True, static_files=True, **app_conf): return app +def make_flask_stack(conf): + """ This has to pass the flask app through all the same middleware that + Pylons used """ + + app = CKANFlask(__name__) + + @app.route('/hello', methods=['GET']) + def hello_world(): + return 'Hello World, this is served by Flask' + + @app.route('/hello', methods=['POST']) + def hello_world_post(): + return 'Hello World, this was posted to Flask' + + return app + + +class CKANFlask(Flask): + + '''Extend the Flask class with a special view to join the 'partyline' + established by AskAppDispatcherMiddleware. + + Also provide a 'can_handle_request' method. + ''' + + def __init__(self, import_name, *args, **kwargs): + super(CKANFlask, self).__init__(import_name, *args, **kwargs) + self.add_url_rule('/__invite__/', endpoint='partyline', + view_func=self.join_party) + self.partyline = None + self.partyline_connected = False + self.invitation_context = None + self.app_name = None # A label for the app handling this request + # (this app). + + def join_party(self, request=flask_request): + # Bootstrap, turn the view function into a 404 after registering. + if self.partyline_connected: + # This route does not exist at the HTTP level. + flask_abort(404) + self.invitation_context = _request_ctx_stack.top + self.partyline = request.environ.get(WSGIParty.partyline_key) + self.app_name = request.environ.get('partyline_handling_app') + self.partyline.connect('can_handle_request', self.can_handle_request) + self.partyline_connected = True + return 'ok' + + def can_handle_request(self, environ): + ''' + Decides whether it can handle a request with the Flask app by + matching the request environ against the route mapper + + Returns (True, 'flask_app') if this is the case. + ''' + + # TODO: identify matching urls as core or extension. This will depend + # on how we setup routing in Flask + + urls = self.url_map.bind_to_environ(environ) + try: + endpoint, args = urls.match() + log.debug('Flask route match, endpoint: {0}, args: {1}'.format( + endpoint, args)) + return (True, self.app_name) + except HTTPException: + raise HighAndDry() + + +class AskAppDispatcherMiddleware(WSGIParty): + + ''' + Establish a 'partyline' to each provided app. Select which app to call + by asking each if they can handle the requested path at PATH_INFO. + + Used to help transition from Pylons to Flask, and should be removed once + Pylons has been deprecated and all app requests are handled by Flask. + + Each app should handle a call to 'can_handle_request(environ)', responding + with a tuple: + (, , []) + where: + `bool` is True if the app can handle the payload url, + `app` is the wsgi app returning the answer + `origin` is an optional string to determine where in the app the url + will be handled, e.g. 'core' or 'extension'. + + Order of precedence if more than one app can handle a url: + Flask Extension > Pylons Extension > Flask Core > Pylons Core + ''' + + def __init__(self, apps=None, invites=(), ignore_missing_services=False): + # Dict of apps managed by this middleware {: , ...} + self.apps = apps or {} + + # A dict of service name => handler mappings. + self.handlers = {} + + # If True, suppress :class:`NoSuchServiceName` errors. Default: False. + self.ignore_missing_services = ignore_missing_services + + self.send_invitations(apps) + + def send_invitations(self, apps): + '''Call each app at the invite route to establish a partyline. Called + on init.''' + PATH = '/__invite__/' + for app_name, app in apps.items(): + environ = create_environ(path=PATH) + environ[self.partyline_key] = self.operator_class(self) + # A reference to the handling app. Used to id the app when + # responding to a handling request. + environ['partyline_handling_app'] = app_name + run_wsgi_app(app, environ) + + def __call__(self, environ, start_response): + '''Determine which app to call by asking each app if it can handle the + url and method defined on the eviron''' + # :::TODO::: Enforce order of precedence for dispatching to apps here. + + app_name = 'pylons_app' # currently defaulting to pylons app + answers = self.ask_around('can_handle_request', environ) + log.debug('Route support answers for {0} {1}: {2}'.format( + environ.get('REQUEST_METHOD'), environ.get('PATH_INFO'), + answers)) + available_handlers = [] + for answer in answers: + if len(answer) == 2: + can_handle, asked_app = answer + origin = 'core' + else: + can_handle, asked_app, origin = answer + if can_handle: + available_handlers.append('{0}_{1}'.format(asked_app, origin)) + + # Enforce order of precedence: + # Flask Extension > Pylons Extension > Flask Core > Pylons Core + if available_handlers: + if 'flask_app_extension' in available_handlers: + app_name = 'flask_app' + elif 'pylons_app_extension' in available_handlers: + app_name = 'pylons_app' + elif 'flask_app_core' in available_handlers: + app_name = 'flask_app' + + log.debug('Serving request via {0} app'.format(app_name)) + environ['ckan.app'] = app_name + return self.apps[app_name](environ, start_response) + + class I18nMiddleware(object): """I18n Middleware selects the language based on the url eg /fr/home is French""" diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 9dfa0573fa6..15a879fe5dd 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -40,9 +40,14 @@ def connect(self, *args, **kw): :type highlight_actions: string ''' + ckan_icon = kw.pop('ckan_icon', None) highlight_actions = kw.pop('highlight_actions', kw.get('action', '')) + ckan_core = kw.pop('ckan_core', None) out = _Mapper.connect(self, *args, **kw) + route = self.matchlist[-1] + if ckan_core is not None: + route._ckan_core = ckan_core if len(args) == 1 or args[0].startswith('_redirect_'): return out # we have a named route @@ -88,16 +93,24 @@ def make_map(): # The ErrorController route (handles 404/500 error pages); it should # likely stay at the top, ensuring it can always be resolved. - map.connect('/error/{action}', controller='error') - map.connect('/error/{action}/{id}', controller='error') + map.connect('/error/{action}', controller='error', ckan_core=True) + map.connect('/error/{action}/{id}', controller='error', ckan_core=True) map.connect('*url', controller='home', action='cors_options', - conditions=OPTIONS) + conditions=OPTIONS, ckan_core=True) # CUSTOM ROUTES HERE for plugin in p.PluginImplementations(p.IRoutes): map = plugin.before_map(map) + # Mark all routes added from extensions on the `before_map` extension point + # as non-core + for route in map.matchlist: + if not hasattr(route, '_ckan_core'): + route._ckan_core = False + + map.connect('invite', '/__invite__/', controller='partyline', action='join_party') + map.connect('home', '/', controller='home', action='index') map.connect('about', '/about', controller='home', action='about') @@ -413,15 +426,26 @@ def make_map(): m.connect('/testing/primer', action='primer') m.connect('/testing/markup', action='markup') + # Mark all unmarked routes added up until now as core routes + for route in map.matchlist: + if not hasattr(route, '_ckan_core'): + route._ckan_core = True + for plugin in p.PluginImplementations(p.IRoutes): map = plugin.after_map(map) + # Mark all routes added from extensions on the `after_map` extension point + # as non-core + for route in map.matchlist: + if not hasattr(route, '_ckan_core'): + route._ckan_core = False + # sometimes we get requests for favicon.ico we should redirect to # the real favicon location. map.redirect('/favicon.ico', config.get('ckan.favicon')) map.redirect('/*(url)/', '/{url}', _redirect_code='301 Moved Permanently') - map.connect('/*url', controller='template', action='view') + map.connect('/*url', controller='template', action='view', ckan_core=True) return map diff --git a/ckan/controllers/partyline.py b/ckan/controllers/partyline.py new file mode 100644 index 00000000000..7998a6b6494 --- /dev/null +++ b/ckan/controllers/partyline.py @@ -0,0 +1,64 @@ +from pylons.controllers import WSGIController +from pylons import config + +import ckan.lib.base as base +from ckan.common import request + +from wsgi_party import WSGIParty, HighAndDry + +import logging +log = logging.getLogger(__name__) + + +class PartylineController(WSGIController): + + '''Handle requests from the WSGI stack 'partyline'. Most importantly, + answers the question, 'can you handle this url?'. ''' + + def __init__(self, *args, **kwargs): + super(PartylineController, self).__init__(*args, **kwargs) + self.app_name = None # A reference to the main pylons app. + self.partyline_connected = False + + def join_party(self): + if self.partyline_connected: + base.abort(404) + self.partyline = request.environ.get(WSGIParty.partyline_key) + self.app_name = request.environ.get('partyline_handling_app') + self.partyline.connect('can_handle_request', self.can_handle_request) + self.partyline_connected = True + return 'ok' + + def can_handle_request(self, environ): + ''' + Decides whether it can handle a request with the Pylons app by + matching the request environ against the route mapper + + Returns (True, 'pylons_app', origin) if this is the case. + + origin can be either 'core' or 'extension' depending on where + the route was defined. + + NOTE: There is currently a catch all route for GET requests to + point arbitrary urls to templates with the same name: + + map.connect('/*url', controller='template', action='view') + + This means that this function will match all GET requests. This + does not cause issues as the Pylons core routes are the last to + take precedence so the current behaviour is kept, but it's worth + keeping in mind. + ''' + + pylons_mapper = config['routes.map'] + match_route = pylons_mapper.routematch(environ=environ) + if match_route: + match, route = match_route + origin = 'core' + if hasattr(route, '_ckan_core') and not route._ckan_core: + origin = 'extension' + log.debug('Pylons route match: {0} Origin: {1}'.format( + match, origin)) + return (True, self.app_name, origin) + else: + raise HighAndDry() diff --git a/ckan/tests/config/test_middleware.py b/ckan/tests/config/test_middleware.py index 35ffc86db6b..630c85e9b28 100644 --- a/ckan/tests/config/test_middleware.py +++ b/ckan/tests/config/test_middleware.py @@ -1,7 +1,13 @@ +import mock +import wsgiref +from nose.tools import assert_equals, assert_not_equals, eq_ +from routes import url_for + +import ckan.plugins as p import ckan.tests.helpers as helpers -from nose.tools import assert_equals, assert_not_equals -from routes import url_for +from ckan.config.middleware import AskAppDispatcherMiddleware, CKANFlask +from ckan.controllers.partyline import PartylineController class TestPylonsResponseCleanupMiddleware(helpers.FunctionalTestBase): @@ -23,3 +29,423 @@ def test_homepage_with_middleware_activated(self): 'response cleared by pylons response cleanup middleware', response.body ) + + +class TestAppDispatcherPlain(object): + ''' + These tests need the test app to be created at specific times to not affect + the mocks, so they don't extend FunctionalTestBase + ''' + + def test_invitations_are_sent(self): + + with mock.patch.object(AskAppDispatcherMiddleware, 'send_invitations') as \ + mock_send_invitations: + + # This will create the whole WSGI stack + helpers._get_test_app() + + assert mock_send_invitations.called + eq_(len(mock_send_invitations.call_args[0]), 1) + + eq_(sorted(mock_send_invitations.call_args[0][0].keys()), + ['flask_app', 'pylons_app']) + + def test_flask_can_handle_request_is_called_with_environ(self): + + with mock.patch.object(CKANFlask, 'can_handle_request') as \ + mock_can_handle_request: + # We need set this otherwise the mock object is returned + mock_can_handle_request.return_value = (False, 'flask_app') + + app = helpers._get_test_app() + # We want our CKAN app, not the WebTest one + ckan_app = app.app + + environ = { + 'PATH_INFO': '/', + } + wsgiref.util.setup_testing_defaults(environ) + start_response = mock.MagicMock() + + ckan_app(environ, start_response) + + assert mock_can_handle_request.called_with(environ) + + def test_pylons_can_handle_request_is_called_with_environ(self): + + with mock.patch.object(PartylineController, 'can_handle_request') as \ + mock_can_handle_request: + + # We need set this otherwise the mock object is returned + mock_can_handle_request.return_value = (True, 'pylons_app', 'core') + + app = helpers._get_test_app() + # We want our CKAN app, not the WebTest one + ckan_app = app.app + + environ = { + 'PATH_INFO': '/', + } + wsgiref.util.setup_testing_defaults(environ) + start_response = mock.MagicMock() + + ckan_app(environ, start_response) + + assert mock_can_handle_request.called_with(environ) + + +class TestAppDispatcher(helpers.FunctionalTestBase): + + @classmethod + def setup_class(cls): + + super(TestAppDispatcher, cls).setup_class() + + # Add a custom route to the Flask app + app = cls._get_test_app() + + flask_app = app.app.apps['flask_app'] + + def test_view(): + return 'This was served from Flask' + + # This endpoint is defined both in Flask and in Pylons core + flask_app.add_url_rule('/about', view_func=test_view) + + # This endpoint is defined both in Flask and a Pylons extension + flask_app.add_url_rule('/pylons_and_flask', view_func=test_view) + + def test_ask_around_is_called(self): + + app = self._get_test_app() + with mock.patch.object(AskAppDispatcherMiddleware, 'ask_around') as \ + mock_ask_around: + app.get('/') + + assert mock_ask_around.called + + def test_ask_around_is_called_with_args(self): + + app = self._get_test_app() + ckan_app = app.app + + environ = {} + start_response = mock.MagicMock() + wsgiref.util.setup_testing_defaults(environ) + + with mock.patch.object(AskAppDispatcherMiddleware, 'ask_around') as \ + mock_ask_around: + + ckan_app(environ, start_response) + assert mock_ask_around.called + mock_ask_around.assert_called_with('can_handle_request', environ) + + def test_ask_around_flask_core_route_get(self): + + app = self._get_test_app() + + # We want our CKAN app, not the WebTest one + app = app.app + + environ = { + 'PATH_INFO': '/hello', + 'REQUEST_METHOD': 'GET', + } + wsgiref.util.setup_testing_defaults(environ) + + answers = app.ask_around('can_handle_request', environ) + + # Even though this route is defined in Flask, there is catch all route + # in Pylons for all requests to point arbitrary urls to templates with + # the same name, so we get two positive answers + eq_(len(answers), 2) + eq_([a[0] for a in answers], [True, True]) + eq_(sorted([a[1] for a in answers]), ['flask_app', 'pylons_app']) + # TODO: check origin (core/extension) when that is in place + + def test_ask_around_flask_core_route_post(self): + + app = self._get_test_app() + + # We want our CKAN app, not the WebTest one + app = app.app + + environ = { + 'PATH_INFO': '/hello', + 'REQUEST_METHOD': 'POST', + } + wsgiref.util.setup_testing_defaults(environ) + + answers = app.ask_around('can_handle_request', environ) + + # Even though this route is defined in Flask, there is catch all route + # in Pylons for all requests to point arbitrary urls to templates with + # the same name, so we get two positive answers + eq_(len(answers), 2) + eq_([a[0] for a in answers], [True, True]) + eq_(sorted([a[1] for a in answers]), ['flask_app', 'pylons_app']) + # TODO: check origin (core/extension) when that is in place + + def test_ask_around_pylons_core_route_get(self): + + app = self._get_test_app() + + # We want our CKAN app, not the WebTest one + app = app.app + + environ = { + 'PATH_INFO': '/dataset', + 'REQUEST_METHOD': 'GET', + } + wsgiref.util.setup_testing_defaults(environ) + + answers = app.ask_around('can_handle_request', environ) + + eq_(len(answers), 1) + eq_(answers[0][0], True) + eq_(answers[0][1], 'pylons_app') + eq_(answers[0][2], 'core') + + def test_ask_around_pylons_core_route_post(self): + + app = self._get_test_app() + + # We want our CKAN app, not the WebTest one + app = app.app + + environ = { + 'PATH_INFO': '/dataset/new', + 'REQUEST_METHOD': 'POST', + } + wsgiref.util.setup_testing_defaults(environ) + + answers = app.ask_around('can_handle_request', environ) + + eq_(len(answers), 1) + eq_(answers[0][0], True) + eq_(answers[0][1], 'pylons_app') + eq_(answers[0][2], 'core') + + def test_ask_around_pylons_extension_route_get_before_map(self): + + if not p.plugin_loaded('test_routing_plugin'): + p.load('test_routing_plugin') + + app = self._get_test_app() + + # We want our CKAN app, not the WebTest one + app = app.app + + environ = { + 'PATH_INFO': '/from_pylons_extension_before_map', + 'REQUEST_METHOD': 'GET', + } + wsgiref.util.setup_testing_defaults(environ) + + answers = app.ask_around('can_handle_request', environ) + + eq_(len(answers), 1) + eq_(answers[0][0], True) + eq_(answers[0][1], 'pylons_app') + eq_(answers[0][2], 'extension') + + p.unload('test_routing_plugin') + + def test_ask_around_pylons_extension_route_post(self): + + if not p.plugin_loaded('test_routing_plugin'): + p.load('test_routing_plugin') + + app = self._get_test_app() + + # We want our CKAN app, not the WebTest one + app = app.app + + environ = { + 'PATH_INFO': '/from_pylons_extension_before_map_post_only', + 'REQUEST_METHOD': 'POST', + } + wsgiref.util.setup_testing_defaults(environ) + + answers = app.ask_around('can_handle_request', environ) + + eq_(len(answers), 1) + eq_(answers[0][0], True) + eq_(answers[0][1], 'pylons_app') + eq_(answers[0][2], 'extension') + + p.unload('test_routing_plugin') + + def test_ask_around_pylons_extension_route_post_using_get(self): + + if not p.plugin_loaded('test_routing_plugin'): + p.load('test_routing_plugin') + + app = self._get_test_app() + + # We want our CKAN app, not the WebTest one + app = app.app + + environ = { + 'PATH_INFO': '/from_pylons_extension_before_map_post_only', + 'REQUEST_METHOD': 'GET', + } + wsgiref.util.setup_testing_defaults(environ) + + answers = app.ask_around('can_handle_request', environ) + + # We are going to get an answer from Pylons, but just because it will + # match the catch-all template route, hence the `core` origin. + eq_(len(answers), 1) + eq_(answers[0][0], True) + eq_(answers[0][1], 'pylons_app') + eq_(answers[0][2], 'core') + + p.unload('test_routing_plugin') + + def test_ask_around_pylons_extension_route_get_after_map(self): + + if not p.plugin_loaded('test_routing_plugin'): + p.load('test_routing_plugin') + + app = self._get_test_app() + + # We want our CKAN app, not the WebTest one + app = app.app + + environ = { + 'PATH_INFO': '/from_pylons_extension_after_map', + 'REQUEST_METHOD': 'GET', + } + wsgiref.util.setup_testing_defaults(environ) + + answers = app.ask_around('can_handle_request', environ) + + eq_(len(answers), 1) + eq_(answers[0][0], True) + eq_(answers[0][1], 'pylons_app') + eq_(answers[0][2], 'extension') + + p.unload('test_routing_plugin') + + def test_ask_around_flask_core_and_pylons_extension_route(self): + + if not p.plugin_loaded('test_routing_plugin'): + p.load('test_routing_plugin') + + app = self._get_test_app() + + # We want our CKAN app, not the WebTest one + app = app.app + + environ = { + 'PATH_INFO': '/pylons_and_flask', + 'REQUEST_METHOD': 'GET', + } + wsgiref.util.setup_testing_defaults(environ) + + answers = app.ask_around('can_handle_request', environ) + answers = sorted(answers, key=lambda a: a[1]) + + eq_(len(answers), 2) + eq_([a[0] for a in answers], [True, True]) + eq_([a[1] for a in answers], ['flask_app', 'pylons_app']) + + # TODO: we still can't distinguish between Flask core and extension + # eq_(answers[0][2], 'extension') + + eq_(answers[1][2], 'extension') + + p.unload('test_routing_plugin') + + def test_flask_core_route_is_served_by_flask(self): + + app = self._get_test_app() + + res = app.get('/hello') + + eq_(res.environ['ckan.app'], 'flask_app') + + # TODO: test flask extension route + + def test_pylons_core_route_is_served_by_pylons(self): + + app = self._get_test_app() + + res = app.get('/dataset') + + eq_(res.environ['ckan.app'], 'pylons_app') + + def test_pylons_extension_route_is_served_by_pylons(self): + + if not p.plugin_loaded('test_routing_plugin'): + p.load('test_routing_plugin') + + app = self._get_test_app() + + res = app.get('/from_pylons_extension_before_map') + + eq_(res.environ['ckan.app'], 'pylons_app') + eq_(res.body, 'Hello World, this is served from a Pylons extension') + + p.unload('test_routing_plugin') + + def test_flask_core_and_pylons_extension_route_is_served_by_pylons(self): + + if not p.plugin_loaded('test_routing_plugin'): + p.load('test_routing_plugin') + + app = self._get_test_app() + + res = app.get('/pylons_and_flask') + + eq_(res.environ['ckan.app'], 'pylons_app') + eq_(res.body, 'Hello World, this is served from a Pylons extension') + + p.unload('test_routing_plugin') + + def test_flask_core_and_pylons_core_route_is_served_by_flask(self): + ''' + This should never happen in core, but just in case + ''' + app = self._get_test_app() + + res = app.get('/about') + + eq_(res.environ['ckan.app'], 'flask_app') + eq_(res.body, 'This was served from Flask') + + +class MockRoutingPlugin(p.SingletonPlugin): + + p.implements(p.IRoutes) + + controller = 'ckan.tests.config.test_middleware:MockPylonsController' + + def before_map(self, _map): + + _map.connect('/from_pylons_extension_before_map', + controller=self.controller, action='view') + + _map.connect('/from_pylons_extension_before_map_post_only', + controller=self.controller, action='view', + conditions={'method': 'POST'}) + # This one conflicts with a core Flask route + _map.connect('/pylons_and_flask', + controller=self.controller, action='view') + + return _map + + def after_map(self, _map): + + _map.connect('/from_pylons_extension_after_map', + controller=self.controller, action='view') + + return _map + + +class MockPylonsController(p.toolkit.BaseController): + + def view(self): + return 'Hello World, this is served from a Pylons extension' diff --git a/requirements.in b/requirements.in index 514716e23bc..dc4418c0520 100644 --- a/requirements.in +++ b/requirements.in @@ -33,3 +33,5 @@ zope.interface==4.1.1 unicodecsv>=0.9 pytz==2012j tzlocal==1.2 +wsgi-party==0.1b1 +Flask==0.10.1 diff --git a/requirements.txt b/requirements.txt index 3858c4c235d..93e775189b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,27 +5,30 @@ # pip-compile requirements.in # argparse==1.4.0 # via ofs -babel==0.9.6 -beaker==1.7.0 +Babel==0.9.6 +Beaker==1.7.0 bleach==1.4.2 -decorator==4.0.4 # via pylons, sqlalchemy-migrate +decorator==4.0.6 # via pylons, sqlalchemy-migrate fanstatic==0.12 -formencode==1.3.0 # via pylons +Flask==0.10.1 +FormEncode==1.3.0 +html5lib==0.9999999 # via bleach +itsdangerous==0.24 # via flask Jinja2==2.6 -mako==1.0.2 # via pylons +Mako==1.0.3 Markdown==2.4 -markupsafe==0.23 # via mako, webhelpers +MarkupSafe==0.23 nose==1.3.7 # via pylons ofs==0.4.1 ordereddict==1.1 Pairtree==0.7.1-T passlib==1.6.2 paste==1.7.5.1 -pastedeploy==1.5.2 # via pastescript, pylons -pastescript==2.0.2 # via pylons -pbr==0.11.0 # via sqlalchemy-migrate +PasteDeploy==1.5.2 +PasteScript==2.0.2 +pbr==0.11.1 # via sqlalchemy-migrate psycopg2==2.4.5 -pygments==2.0.2 # via weberror +Pygments==2.1 Pylons==0.9.7 python-dateutil==1.5 pytz==2012j @@ -34,24 +37,26 @@ repoze.lru==0.6 # via routes repoze.who-friendlyform==1.0.8 repoze.who==2.0 requests==2.7.0 -routes==1.13 +Routes==1.13 simplejson==3.3.1 # via pylons HAND-FIXED FOR NOW #2681 -six==1.10.0 # via pastescript, sqlalchemy-migrate +six==1.10.0 # via bleach, html5lib, pastescript, sqlalchemy-migrate solrpy==0.9.5 sqlalchemy-migrate==0.9.1 -sqlalchemy==0.9.6 +SQLAlchemy==0.9.6 sqlparse==0.1.11 -tempita==0.5.2 # via pylons, sqlalchemy-migrate, weberror +Tempita==0.5.2 tzlocal==1.2 unicodecsv==0.14.1 vdm==0.13 -weberror==0.11 # via pylons -webhelpers==1.3 -webob==1.0.8 -webtest==1.4.3 +WebError==0.11 +WebHelpers==1.3 +WebOb==1.0.8 +WebTest==1.4.3 +Werkzeug==0.11.3 +wsgi-party==0.1b1 zope.interface==4.1.1 # The following packages are commented out because they are # considered to be unsafe in a requirements file: -# pip==7.1.2 # via pbr -# setuptools==18.4 +# pip # via pbr +# setuptools # via repoze.who, zope.interface diff --git a/setup.py b/setup.py index 6fb1bf4f2da..fa3d92124f7 100644 --- a/setup.py +++ b/setup.py @@ -157,6 +157,7 @@ 'sample_datastore_plugin = ckanext.datastore.tests.sample_datastore_plugin:SampleDataStorePlugin', 'test_datastore_view = ckan.tests.lib.test_datapreview:MockDatastoreBasedResourceView', 'test_datapusher_plugin = ckanext.datapusher.tests.test_interfaces:FakeDataPusherPlugin', + 'test_routing_plugin = ckan.tests.config.test_middleware:MockRoutingPlugin', ], 'babel.extractors': [ 'ckan = ckan.lib.extract:extract_ckan',