From 60d171abdbcf98b8b2141a26211e48089419f2ad Mon Sep 17 00:00:00 2001 From: Yoann Roman Date: Sat, 12 Mar 2011 16:14:56 -0500 Subject: [PATCH 1/7] Adding initial version of py.test plugin --- .../project/+package+/tests/test_root.py_tmpl | 15 +- pecan/templates/project/setup.cfg | 2 + pecan/testing.py | 219 ++++++++++++++++++ 3 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 pecan/templates/project/setup.cfg create mode 100644 pecan/testing.py diff --git a/pecan/templates/project/+package+/tests/test_root.py_tmpl b/pecan/templates/project/+package+/tests/test_root.py_tmpl index 0fd16f0..84d38e8 100644 --- a/pecan/templates/project/+package+/tests/test_root.py_tmpl +++ b/pecan/templates/project/+package+/tests/test_root.py_tmpl @@ -1,25 +1,18 @@ from unittest import TestCase from webtest import TestApp -from pecan import make_app -from ${package}.controllers.root import RootController +import py.test class TestRootController(TestCase): def setUp(self): - - self.app = TestApp( - make_app( - RootController(), - template_path = '${package}/templates' - ) - ) - + self.app = TestApp(py.test.wsgi_app) + def test_get(self): response = self.app.get('/') assert response.status_int == 200 - + def test_get_not_found(self): response = self.app.get('/a/bogus/url', expect_errors=True) assert response.status_int == 404 diff --git a/pecan/templates/project/setup.cfg b/pecan/templates/project/setup.cfg new file mode 100644 index 0000000..aad259c --- /dev/null +++ b/pecan/templates/project/setup.cfg @@ -0,0 +1,2 @@ +[pytest] +addopts = -p pecan.testing --with-config=./config.py diff --git a/pecan/testing.py b/pecan/testing.py new file mode 100644 index 0000000..2e64faa --- /dev/null +++ b/pecan/testing.py @@ -0,0 +1,219 @@ +""" +Plugin for py.test that sets up the app. + +App configuration inspired by the Pylons nose equivalent: +https://github.com/Pylons/pylons/blob/master/pylons/test.py + +Handling of multiprocessing inspired by pytest-cov. +""" +from pecan import conf, set_config +from tempfile import mkdtemp + +import py +import py.test +import os +import shutil +import socket +import sys + + +def pytest_addoption(parser): + """ + Adds the custom "with-config" option to take in the config file. + """ + group = parser.getgroup('pecan') + group._addoption('--with-config', + dest='config_file', + metavar='path', + default='./test.py', + action='store', + type='string', + help='configuration file for pecan tests') + + +def pytest_configure(config): + """ + Loads the Pecan plugin if using a configuration file. + """ + if config.getvalue('config_file'): + config.pluginmanager.register(PecanPlugin(config), '_pecan') + + +class PecanPlugin(object): + """ + Plugin for a Pecan application. Sets up and tears down the + WSGI application based on the configuration and session type. + """ + + def __init__(self, config): + self.config = config + self.impl = None + + def pytest_namespace(self): + """ + Add the session variables to the namespace. + """ + return { + 'temp_dir': None, + 'wsgi_app': None + } + + def pytest_sessionstart(self, session): + """ + Set up the testing session. + """ + self.impl = PecanPluginImpl.create_from_session(session) + self.impl.sessionstart(session) + + def pytest_configure_node(self, node): + """ + Configures a new slave node. + """ + if self.impl: + self.impl.configure_node(node) + + def pytest_testnodedown(self, node, error): + """ + Tears down an exiting node. + """ + if self.impl: + self.impl.testnodedown(node, error) + + def pytest_sessionfinish(self, session, exitstatus): + """ + Cleans up the testing session. + """ + if self.impl: + self.impl.sessionfinish(session, exitstatus) + + +class PecanPluginImpl(object): + """ + Actual implementation of the Pecan plugin. This ensures the proper + environment is configured for each session type. + """ + + def __init__(self, config): + self.config = config + self.log = py.log.Producer('pecan-%s' % self.name) + if not config.option.debug: + py.log.setconsumer(self.log._keywords, None) + self.log('Created %s instance' % self.__class__.__name__) + + @property + def name(self): + return 'main' + + def _setup_app(self): + self.log('Invoking setup_app') + path = os.getcwd() + if path not in sys.path: + sys.path.insert(0, path) + set_config(self.config.getvalue('config_file')) + py.test.wsgi_app = self._load_app(conf) + + def _get_package_names(self, config): + if not hasattr(config.app, 'modules'): + return [] + return [module.__name__ for module in config.app.modules if hasattr(module, '__name__')] + + def _can_import(self, name): + try: + __import__(name) + return True + except ImportError: + return False + + def _load_app(self, config): + for package_name in self._get_package_names(config): + module_name = '%s.app' % package_name + if self._can_import(module_name): + module = sys.modules[module_name] + if hasattr(module, 'setup_app'): + return module.setup_app(config) + raise RuntimeError('No app.setup_app found in any of the configured app.modules') + + def _create_temp_directory(self): + temp_dir = mkdtemp() + self.log('Created temporary directory %s' % temp_dir) + py.test.temp_dir = temp_dir + + def _delete_temp_directory(self): + if py.test.temp_dir and os.path.exists(py.test.temp_dir): + self.log('Removing temporary directory %s' % py.test.temp_dir) + shutil.rmtree(py.test.temp_dir) + + def sessionstart(self, session): + self.log('Starting session') + self._setup_app() + self._create_temp_directory() + + def configure_node(self, node): + pass + + def testnodedown(self, node, error): + pass + + def sessionfinish(self, session, exitstatus): + self.log('Stopping session') + self._delete_temp_directory() + + @staticmethod + def create_from_session(session): + if session.config.option.dist != 'no': + impl_cls = MasterPecanPluginImpl + elif getattr(session.config, 'slaveinput', {}).get('slaveid'): + impl_cls = SlavePecanPluginImpl + else: + impl_cls = PecanPluginImpl + return impl_cls(session.config) + + +class MasterPecanPluginImpl(PecanPluginImpl): + """ + Plugin implementation for distributed master. + """ + + def sessionstart(self, session): + self.log('Starting master session') + self._setup_app() + self._create_temp_directory() + + def configure_node(self, node): + self.log('Configuring slave node %s' % node.gateway.id) + node.slaveinput['pecan_master_host'] = socket.gethostname() + node.slaveinput['pecan_temp_dir'] = py.test.temp_dir + + def sessionfinish(self, session, exitstatus): + self.log('Stopping master session') + self._delete_temp_directory() + + +class SlavePecanPluginImpl(PecanPluginImpl): + """ + Plugin implementation for distributed slaves. + """ + + @property + def name(self): + return self.config.slaveinput['slaveid'] + + def _is_collocated(self, session): + return (socket.gethostname() == session.config.slaveinput['pecan_master_host']) + + def _set_temp_directory(self, session): + self.log('Setting temporary directory to %s' % session.config.slaveinput['pecan_temp_dir']) + py.test.temp_dir = session.config.slaveinput['pecan_temp_dir'] + + def sessionstart(self, session): + self.log('Starting slave session') + self._setup_app() + if self._is_collocated(session): + self._set_temp_directory(session) + else: + self._create_temp_directory() + + def sessionfinish(self, session, exitstatus): + self.log('Stopping slave session') + if not self._is_collocated(session): + self._delete_temp_directory() From 08376bc22c18c98bf915b3b976b22c1bec865dc5 Mon Sep 17 00:00:00 2001 From: Yoann Roman Date: Mon, 18 Apr 2011 11:56:54 -0400 Subject: [PATCH 2/7] Revising the RestController docs --- docs/source/rest.rst | 139 ++++++++++++++++++++++++++++++++++++++++ docs/source/routing.rst | 69 -------------------- 2 files changed, 139 insertions(+), 69 deletions(-) diff --git a/docs/source/rest.rst b/docs/source/rest.rst index e69de29..20447c3 100644 --- a/docs/source/rest.rst +++ b/docs/source/rest.rst @@ -0,0 +1,139 @@ +.. _rest: + +REST Controller +=============== + +If you need to write controllers to interact with objects, using the +``RestController`` may help speed things up. It follows the Representational +State Transfer Protocol, also known as REST, by routing the standard HTTP +verbs of GET, POST, PUT, and DELETE to individual methods:: + + from pecan import expose + from pecan.rest import RestController + + from mymodel import Book + + class BooksController(RestController): + + @expose() + def get(self, id): + book = Book.get(id) + if not book: + abort(404) + return book.title + +URL Mapping +----------- + +By default, the ``RestController`` routes as follows: + ++-----------------+--------------------------------------------------------------+--------------------------------------------+ +| Method | Description | Example Method(s) / URL(s) | ++=================+==============================================================+============================================+ +| get_one | Display one record. | GET /books/1 | ++-----------------+--------------------------------------------------------------+--------------------------------------------+ +| get_all | Display all records in a resource. | GET /books/ | ++-----------------+--------------------------------------------------------------+--------------------------------------------+ +| get | A combo of get_one and get_all. | GET /books/ | +| | +--------------------------------------------+ +| | | GET /books/1 | ++-----------------+--------------------------------------------------------------+--------------------------------------------+ +| new | Display a page to create a new resource. | GET /books/new | ++-----------------+--------------------------------------------------------------+--------------------------------------------+ +| edit | Display a page to edit an existing resource. | GET /books/1/edit | ++-----------------+--------------------------------------------------------------+--------------------------------------------+ +| post | Create a new record. | POST /books/ | ++-----------------+--------------------------------------------------------------+--------------------------------------------+ +| put | Update an existing record. | POST /books/1?_method=put | +| | +--------------------------------------------+ +| | | PUT /books/1 | ++-----------------+--------------------------------------------------------------+--------------------------------------------+ +| get_delete | Display a delete confirmation page. | GET /books/1/delete | ++-----------------+--------------------------------------------------------------+--------------------------------------------+ +| delete | Delete an existing record. | POST /books/1?_method=delete | +| | +--------------------------------------------+ +| | | DELETE /books/1 | ++-----------------+--------------------------------------------------------------+--------------------------------------------+ + +Pecan's ``RestController`` uses the de-facto standard ``?_method=`` query +string hack to work around the lack of PUT/DELETE support in current browsers. + +The ``RestController`` still supports the ``index``, ``_default``, and +``_lookup`` routing overrides. If you need to override ``_route``, however, +make sure to call ``RestController._route`` at the end of your custom +``_route`` method so that the REST routing described above still occurs. + +Nesting +------- + +``RestController`` instances can be nested so that child resources get the +parameters necessary to look up parent resources. For example:: + + from pecan import expose + from pecan.rest import RestController + + from mymodel import Author, Book + + class BooksController(RestController): + + @expose() + def get(self, author_id, id): + author = Author.get(author_id) + if not author_id: + abort(404) + book = author.get_book(id) + if not book: + abort(404) + return book.title + + class AuthorsController(RestController): + + books = BooksController() + + @expose() + def get(self, id): + author = Author.get(id) + if not author: + abort(404) + return author.name + + class RootController(object): + + authors = AuthorsController() + +Accessing ``/authors/1/books/2`` would call ``BooksController.get`` with an +``author_id`` of 1 and ``id`` of 2. + +To determine which arguments are associated with the parent resource, Pecan +looks at the ``get_one`` or ``get`` method signatures, in that order, in the +parent controller. If the parent resource takes a variable number of arguments, +Pecan will hand it everything up to the child resource controller name (e.g., +``books`` in the above example). + +Custom Actions +-------------- + +In addition to the default methods defined above, you can add additional +behaviors to a ``RestController`` by defining a special ``_custom_actions`` +dictionary. For example:: + + from pecan import expose + from pecan.rest import RestController + + from mymodel import Book + + class BooksController(RestController): + + _custom_actions = { + 'checkout': ['POST'] + } + + @expose() + def checkout(self, id): + book = Book.get(id) + if not book: + abort(404) + book.checkout() + +Additional method names are the keys in the dictionary. The values are lists +of valid HTTP verbs for those custom actions, including PUT and DELETE. diff --git a/docs/source/routing.rst b/docs/source/routing.rst index cfd405b..2023a17 100644 --- a/docs/source/routing.rst +++ b/docs/source/routing.rst @@ -218,72 +218,3 @@ requests ``/hello.json``. The second tells the templating engine to use tells Pecan to use the html_template.mako when the client requests ``/hello.html``. If the client requests ``/hello``, Pecan will use the text/html template. - -.. _advanced_routing: - -Advanced Routing -================ -Pecan offers a few ways to control and extend routing. - - -.. _restcontroller: - -RestController --------------- - A Decorated Controller that dispatches in a RESTful Manner. - - This controller was designed to follow Representational State Transfer protocol, also known as REST. - The goal of this controller method is to provide the developer a way to map - RESTful URLS to controller methods directly, while still allowing Normal Object Dispatch to occur. - - Here is a brief rundown of the methods which are called on dispatch along with an example URL. - - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | Method | Description | Example Method(s) / URL(s) | - +=================+==============================================================+============================================+ - | get_one | Display one record. | GET /movies/1 | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | get_all | Display all records in a resource. | GET /movies/ | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | get | A combo of get_one and get_all. | GET /movies/ | - | | +--------------------------------------------+ - | | | GET /movies/1 | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | new | Display a page to prompt the User for resource creation. | GET /movies/new | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | edit | Display a page to prompt the User for resource modification. | GET /movies/1/edit | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | post | Create a new record. | POST /movies/ | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | put | Update an existing record. | POST /movies/1?_method=PUT | - | | +--------------------------------------------+ - | | | PUT /movies/1 | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | post_delete | Delete an existing record. | POST /movies/1?_method=DELETE | - | | +--------------------------------------------+ - | | | DELETE /movies/1 | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | get_delete | Display a delete Confirmation page. | GET /movies/1/delete | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - | delete | A combination of post_delete and get_delete. | GET /movies/delete | - | | +--------------------------------------------+ - | | | DELETE /movies/1 | - | | +--------------------------------------------+ - | | | DELETE /movies/ | - | | +--------------------------------------------+ - | | | POST /movies/1/delete | - | | +--------------------------------------------+ - | | | POST /movies/delete | - +-----------------+--------------------------------------------------------------+--------------------------------------------+ - - You may note the ?_method on some of the URLs. This is basically a hack because exiting browsers - do not support the PUT and DELETE methods. Just note that if you decide to use a this resource with a web browser, - you will likely have to add a _method as a hidden field in your forms for these items. Also note that RestController differs - from a base Pecan controller in that it offers no index, default, or lookup. It is intended primarily for resource management. - - -.. _note: - The following items are still pending: - - * Hooks - * Security From 21f342047b8c32245698ce72334a4005b1b96c23 Mon Sep 17 00:00:00 2001 From: Yoann Roman Date: Mon, 18 Apr 2011 12:01:57 -0400 Subject: [PATCH 3/7] Updating py.test requirement --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8afd8bb..3292dab 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ version = '0.1' # integration with py.test for `python setup.py test` # tests_require = [ - "pytest" + "pytest>=2.0.3" ] class PyTest(Command): From 421744648406de4ef9f61bc18416b46496149542 Mon Sep 17 00:00:00 2001 From: Yoann Roman Date: Mon, 18 Apr 2011 12:32:57 -0400 Subject: [PATCH 4/7] Updating testing documentation for plugin --- docs/source/testing.rst | 73 ++++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/docs/source/testing.rst b/docs/source/testing.rst index cee6c53..b085c88 100644 --- a/docs/source/testing.rst +++ b/docs/source/testing.rst @@ -17,7 +17,7 @@ Structure --------- This guide assumes that you have all your tests in a ``tests`` directory. If you have created a project from the ``base`` project template that Pecan -provides you should already have this directory with a few tests. +provides, you should already have this directory with a few tests. The template project uses UnitTest-type tests and some of those tests use WebTest. We will describe how they work in the next section. @@ -37,34 +37,54 @@ This is how running those tests with ``py.test`` would look like:: Configuration and Testing ------------------------- -When running tests, you would want to avoid as much as possible setting up test -cases by creating a Pecan app on each instance. To avoid this, you need to -create a proper test configuration file and load it at setup time. +When you create a new project using the ``base`` project template, Pecan adds +a reference to its ``py.test`` plugin to your project's ``setup.cfg`` file. +This handles loading your Pecan configuration and setting up your app as +defined by your project's ``app.py`` file. -To do this, you need to know the absolute path for your configuration file and -then call ``set_config`` with it. A typical ``setUp`` method would look like:: +If you've created your own project without using Pecan's template, you can +load the plugin yourself by adding this to your ``setup.cfg`` file:: - def setUp(self): - config_path = '/path/to/test_config.py' - pecan.set_config(config_path) + [pytest] + addopts = -p pecan.testing --with-config=./config.py - self.app = TestApp( - make_app( - config.app.root - template_path = config.app.template_path - ) - ) - +Alternatively, you can just pass those options to ``py.test`` directly. -As you can see, we are loading the configuration file into Pecan first and then -creating a Pecan application with it. Any interaction after ``setUp`` will be -exactly as if your application was really running via an HTTP server. +By default, Pecan's testing plugin assumes you will be using the ``config.py`` +configuration file to run your tests. To change which configuration file gets +used once, run ``py.test`` with the `--with-config` option. To make the change +permanent, modify that option in the `addopts` setting of your ``setup.cfg`` +file. + +Pecan's ``py.test`` plugin exposes two new variables in the ``py.test`` +namespace: ``temp_dir`` and ``wsgi_app``. + +``py.test.temp_dir`` is a temporary directory that you can use for your tests. +It's created at startup and deleted after all tests have completed. When using +locally distributed testing with py.test, this is guaranteed to be shared by +each test process. This is useful if you need to create some initial resource +(e.g., a database template) that is later copied by each test. If you're using +remotely distributed testing, the directory won't be shared across nodes. + +``py.test.wsgi_app`` is your Pecan app loaded and configured per your project's +``app.py`` file. In your test's ``setUp`` method, you would wrap this with +``TestApp``:: + + from unittest import TestCase + from webtest import TestApp + + import py.test + + class TestRootController(TestCase): + + def setUp(self): + self.app = TestApp(py.test.wsgi_app) Using WebTest with a UnitTest ----------------------------- -Once you have a ``setUp`` method with your Pecan configuration loaded you have -a wealth of actions provided within the test class to interact with your Pecan +Once you have a ``setUp`` method with your ``TestApp`` created, you have a +wealth of actions provided within the test class to interact with your Pecan application:: * POST => self.app.post @@ -72,19 +92,18 @@ application:: * DELETE => self.app.delete * PUT => self.app.put -For example, if I wanted to assert that I can get the root of my application, -I would probably do something similar to this:: +For example, if you want to assert that you can get to the root of your +application, you could do something similar to this:: response = self.app.get('/') assert response.status_int == 200 -If you are expecting error responses from your application, you should make -sure that you pass the `expect_errors` flag and set it to True:: +If you are expecting error responses from your application, make sure to pass +`expect_errors=True`:: response = self.app.get('/url/does/not/exist', expect_errors=True) assert response.status_int == 404 If you would like to dig in to more examples in how to test and verify more -actions, make sure you take a look at the +actions, take a look at the `WebTest documentation `_ - From 87564451b08576c1c549388f85888ad53a3b9247 Mon Sep 17 00:00:00 2001 From: Yoann Roman Date: Mon, 18 Apr 2011 12:46:13 -0400 Subject: [PATCH 5/7] Fixing a test in the base template --- pecan/templates/project/+package+/tests/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pecan/templates/project/+package+/tests/test_config.py b/pecan/templates/project/+package+/tests/test_config.py index eff4425..b5bcbfd 100644 --- a/pecan/templates/project/+package+/tests/test_config.py +++ b/pecan/templates/project/+package+/tests/test_config.py @@ -21,7 +21,7 @@ class TestConfigApp(TestCase): assert len(config.app['modules']) == 1 def test_app_static_root(self): - assert config.app['static_root'] == 'public' + assert 'public' in config.app['static_root'] def test_app_template_path(self): assert 'templates' in config.app['template_path'] From 4793e1aab9f5ae4d4ef69e6e8824d805901c0038 Mon Sep 17 00:00:00 2001 From: Yoann Roman Date: Wed, 20 Apr 2011 10:43:41 -0400 Subject: [PATCH 6/7] Adding py.test as a project requirement --- setup.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 3292dab..0739088 100644 --- a/setup.py +++ b/setup.py @@ -5,10 +5,6 @@ version = '0.1' # # integration with py.test for `python setup.py test` # -tests_require = [ - "pytest>=2.0.3" -] - class PyTest(Command): user_options = [] def initialize_options(self): @@ -31,7 +27,8 @@ requirements = [ "Paste >= 1.7.5.1", "PasteScript >= 1.7.3", "formencode >= 1.2.2", - "WebTest >= 1.2.2" + "WebTest >= 1.2.2", + "pytest >= 2.0.3" ] try: From 1581c005e26d36c788dfb5f0a9afd4e4f199c253 Mon Sep 17 00:00:00 2001 From: Yoann Roman Date: Wed, 20 Apr 2011 10:46:11 -0400 Subject: [PATCH 7/7] Fixing commands so they don't hide import errors --- pecan/commands/base.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/pecan/commands/base.py b/pecan/commands/base.py index 83d31ac..b630274 100644 --- a/pecan/commands/base.py +++ b/pecan/commands/base.py @@ -32,36 +32,31 @@ class Command(paste_command.Command): ex.args[0] = self.parser.error(ex.args[0]) raise - def can_import(self, name): - try: - __import__(name) - return True - except ImportError: - return False - def get_package_names(self, config): if not hasattr(config.app, 'modules'): return [] return [module.__name__ for module in config.app.modules if hasattr(module, '__name__')] + def import_module(self, package, name): + parent = __import__(package, fromlist=[name]) + return getattr(parent, name, None) + def load_configuration(self, name): set_config(name) return _runtime_conf def load_app(self, config): for package_name in self.get_package_names(config): - module_name = '%s.app' % package_name - if self.can_import(module_name): - module = sys.modules[module_name] - if hasattr(module, 'setup_app'): - return module.setup_app(config) - raise paste_command.BadCommand('No app.setup_app found in any of the configured app.modules') + module = self.import_module(package_name, 'app') + if hasattr(module, 'setup_app'): + return module.setup_app(config) + raise paste_command.BadCommand('No app.setup_app found in any app modules') def load_model(self, config): for package_name in self.get_package_names(config): - module_name = '%s.model' % package_name - if self.can_import(module_name): - return sys.modules[module_name] + module = self.import_module(package_name, 'model') + if module: + return module return None def logging_file_config(self, config_file):