├── .gitignore ├── .hgignore ├── AUTHORS ├── CHANGES ├── LICENSE ├── MANIFEST.in ├── README ├── alfajor ├── __init__.py ├── _compat.py ├── _config.py ├── _management.py ├── apiclient.py ├── browsers │ ├── __init__.py │ ├── _lxml.py │ ├── _waitexpr.py │ ├── managers.py │ ├── network.py │ ├── selenium.py │ ├── wsgi.py │ └── zero.py ├── runners │ ├── __init__.py │ └── nose.py └── utilities.py ├── docs ├── __init__.py ├── examples │ ├── __init__.py │ ├── alfajor.ini │ ├── templates │ │ ├── index.html │ │ └── results.html │ ├── test_simple.py │ └── webapp.py ├── licenses │ ├── flatland.txt │ ├── jquery.txt │ ├── lxml.txt │ └── werkzeug.txt └── source │ ├── Makefile │ ├── browsers.rst │ ├── conf.py │ ├── index.rst │ ├── ini.rst │ ├── intro │ ├── index.rst │ ├── install.rst │ └── tutorial.rst │ └── lxml.rst ├── scripts ├── alfajor-invoke └── ntest ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── browser ├── __init__.py ├── alfajor.ini ├── images │ └── bread.jpg ├── static │ └── jquery-1.4.2.min.js ├── templates │ ├── ajax.html │ ├── assign_cookie.html │ ├── assign_cookies.html │ ├── dom.html │ ├── form_checkboxes.html │ ├── form_fill.html │ ├── form_methods.html │ ├── form_multipart.html │ ├── form_radios.html │ ├── form_select.html │ ├── form_submit.html │ ├── form_textareas.html │ ├── index.html │ ├── seq_a.html │ ├── seq_b.html │ ├── seq_c.html │ ├── seq_d.html │ └── waitfor.html ├── test_browser.py ├── test_dom.py ├── test_forms.py └── webapp.py ├── client ├── __init__.py ├── alfajor.ini ├── test_basic.py └── webapp.py └── test_management.py /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | *.py? 3 | *.egg-info 4 | *,cover 5 | *.swp 6 | .DS_Store 7 | .coverage 8 | .coverage.* 9 | cover 10 | htmlcov 11 | bin/* 12 | include 13 | lib 14 | dist 15 | build 16 | 17 | docs/text 18 | docs/html 19 | docs/doctrees 20 | docs/doctest 21 | docs/pickles 22 | docs/source/_static 23 | docs/source/_template 24 | 25 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | 3 | MANIFEST 4 | *.py? 5 | *.egg-info 6 | *,cover 7 | .DS_Store 8 | .coverage 9 | .coverage.* 10 | cover 11 | htmlcov 12 | bin/* 13 | include 14 | lib 15 | dist 16 | build 17 | 18 | docs/text 19 | docs/html 20 | docs/doctrees 21 | docs/doctest 22 | docs/pickles 23 | docs/source/_static 24 | docs/source/_template 25 | 26 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Alfajor is an open source project of Action Without Borders, Inc. in 2 | collaboration with the Alfajor contributors. 3 | 4 | Contributors are: 5 | 6 | - Jason Kirtland 7 | - Dan Colish 8 | - Craig Dennis 9 | - Michel Pelletier 10 | - Scott Wilson 11 | - Kevin Turner 12 | 13 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Alfajor Release History 2 | ======================= 3 | 4 | - Added a browser called 'network' which talks to a web server 5 | over a network socket using urllib2. 6 | 7 | 8 | 0.1 (June 24th, 2010) 9 | --------------------- 10 | 11 | - Initial public alpha release. 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 by Action Without Borders, Inc., the Alfajor authors and 2 | contributors. All rights reserved. See AUTHORS for details of authorship. 3 | 4 | Alfajor is distributed under under the BSD license (see below), with the 5 | following exceptions: 6 | 7 | - Some code, such as alfajor/browsers/_lxml.py, is derived from lxml. See 8 | docs/licenses/lxml.txt for the license text. 9 | 10 | - The File implementation of the WSGI APIClient is derived from Werkzeug. 11 | See docs/licenses/werkzeug.txt for the license text. 12 | 13 | - A variety of code is derived from Flatland. See docs/licenses/flatland.txt 14 | for the license text. 15 | 16 | - The tests directory distributes a copy of jQuery. See 17 | docs/licenses/jquery.txt for the license text. 18 | 19 | - The Alfajor documentation is distributed under the Berkeley Documentation 20 | License (BDL). See below. 21 | 22 | The BSD license 23 | --------------- 24 | 25 | Redistribution and use in source and binary forms, with or without 26 | modification, are permitted provided that the following conditions are met: 27 | 28 | * Redistributions of source code must retain the above copyright notice, 29 | this list of conditions and the following disclaimer. 30 | 31 | * Redistributions in binary form must reproduce the above copyright notice, 32 | this list of conditions and the following disclaimer in the documentation 33 | and/or other materials provided with the distribution. 34 | 35 | * Neither the names of the authors nor the names of the contributors may be 36 | used to endorse or promote products derived from this software without 37 | specific prior written permission. 38 | 39 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 40 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 41 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 42 | ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 43 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 44 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 45 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 46 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 47 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 48 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 49 | 50 | The Berkeley Documentation License 51 | ---------------------------------- 52 | 53 | Redistribution and use in source (reStructuredText format and ALLCAPS text 54 | files) and 'compiled' forms (PDF, PostScript, HTML, RTF, etc), with or 55 | without modification, are permitted provided that the following conditions 56 | are met: 57 | 58 | * Redistributions of source code (reStructuredText format and ALLCAPS text 59 | files) must retain the above copyright notice, this list of conditions and 60 | the following disclaimer. 61 | 62 | * Redistributions in compiled form (converted to PDF, PostScript, HTML, RTF, 63 | and other formats) must reproduce the above copyright notice, this list of 64 | conditions and the following disclaimer in the documentation and/or other 65 | materials provided with the distribution. 66 | 67 | * The names of the authors and contributors may not be used to endorse or 68 | promote products derived from this documentation without specific prior 69 | written permission. 70 | 71 | THIS DOCUMENTATION IS PROVIDED BY THE AUTHOR AS IS AND ANY EXPRESS OR 72 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 73 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 74 | EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 75 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 76 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 77 | OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 78 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 79 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS DOCUMENTATION, EVEN IF 80 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 81 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README 3 | include AUTHORS 4 | include CHANGES 5 | recursive-include tests *py *ini *css *js *jpg *html 6 | #recursive-include docs/source *rst *py 7 | #recursive-include docs/text *txt 8 | #recursive-include docs/html *html *txt *png *css *js *inv 9 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Alfajor 2 | ------- 3 | 4 | Tasty functional testing. 5 | 6 | Alfajor provides a modern, object-oriented and browser-neutral interface to 7 | HTTP resources. With Alfajor, your Python scripts and test code have a live, 8 | synchronized mirror of the browser's X/HTML DOM, even with DOM changes made on 9 | the client by JavaScript. 10 | 11 | Alfajor provides: 12 | 13 | - A straightforward 'browser' object, with an implementation that 14 | communicates in real-time with live web browsers via Selenium and a fast, 15 | no-javascript implementation via an integrated WSGI gateway 16 | 17 | - Use a specific browser, or, via integration with the 'nose' test runner, 18 | switch out the browser backend via a command line option to your tests. 19 | Firefox, Safari, WSGI- choose which you want on a run-by-run basis. 20 | 21 | - Synchronized access to the page DOM via a rich dialect of lxml, with great 22 | time-saving shortcuts that make tests compact, readable and fun to write. 23 | 24 | - Optional management of server processes under test, allowing them to 25 | transparently start and stop on demand as your tests run. 26 | 27 | - An 'apiclient' with native JSON response support, useful for testing REST 28 | and web api implementations at a fine-grained level. 29 | 30 | - A friendly BSD license. 31 | 32 | Behind the scenes, Alfajor has a well-defined structure that supports plugins 33 | for new browser backends and testing requirements. The following plugins are 34 | already underway or in planning: 35 | 36 | - Windmill 37 | - Selenium 2.0 / WebDriver 38 | - cloud-based Selenium testing services 39 | - py.test integration 40 | 41 | Getting Started 42 | =============== 43 | 44 | This is the alpha-release README. Please, bear with us as we assemble 45 | traditional documentation and tutorial material. Until then... 46 | 47 | To get started quickly, use the Alfajor self-tests to see it in action: 48 | 49 | Setup: 50 | 51 | 1) create and activate a virtualenv (optional) 52 | 2) cd to the top of this distribution 53 | 3) python setup.py develop 54 | 4) easy_install nose 55 | 56 | If you don't have Selenium installed, download Selenium RC. All you need is 57 | the selenium-server.jar, and no configuration is required. Run it with 'java 58 | -jar selenium-server.jar'. 59 | 60 | Action: 61 | 62 | 1) nosetests --help 63 | 64 | After following the steps above, the Alfajor plugin should be available 65 | and listing command-line options for nose. 66 | 67 | 2) nosetests 68 | 69 | You just ran a whole mess of tests against an in-process web app through 70 | a WSGI interface. 71 | 72 | 3) nosetests --browser=firefox 73 | 74 | You just ran the same mess of tests in a real web browser! 75 | 76 | You can try other browser names: safari, etc. 77 | 78 | ------------------------------------------------------------------------------ 79 | 80 | The main action of Alfajor is directed through an alfajor.ini file. At the 81 | simplest, this can be anywhere on the filesystem (see the --alfajor-config 82 | option in nose) or placed in the same directory as the .py file that 83 | configures the WebBrowser. See tests/webapp/{__init__.py,alfajor.ini}. 84 | 85 | -------------------------------------------------------------------------------- /alfajor/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Alfajor functional browser and HTTP client.""" 8 | 9 | from alfajor._management import APIClient, WebBrowser 10 | 11 | 12 | __all__ = ['APIClient', 'WebBrowser'] 13 | __version__ = '0.2' 14 | -------------------------------------------------------------------------------- /alfajor/_compat.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Glue code for Python version compatibility.""" 8 | 9 | _json = None 10 | 11 | try: 12 | property.getter 13 | except AttributeError: 14 | class property(property): 15 | """A work-alike for Python 2.6's property.""" 16 | __slots__ = () 17 | 18 | def getter(self, fn): 19 | return property(fn, self.fset, self.fdel) 20 | 21 | def setter(self, fn): 22 | return property(self.fget, fn, self.fdel) 23 | 24 | def deleter(self, fn): 25 | return property(self.fget, self.fset, fn) 26 | else: 27 | property = property 28 | 29 | 30 | def _load_json(): 31 | global _json 32 | if _json is None: 33 | try: 34 | import json as _json 35 | except ImportError: 36 | try: 37 | import simplejson as _json 38 | except ImportError: 39 | pass 40 | if not _json: 41 | raise ImportError( 42 | "This feature requires Python 2.6+ or simplejson.") 43 | 44 | 45 | def json_loads(*args, **kw): 46 | if _json is None: 47 | _load_json() 48 | return _json.loads(*args, **kw) 49 | 50 | 51 | def json_dumps(*args, **kw): 52 | if _json is None: 53 | _load_json() 54 | return _json.dumps(*args, **kw) 55 | -------------------------------------------------------------------------------- /alfajor/_config.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """INI helpers.""" 8 | import ConfigParser 9 | from StringIO import StringIO 10 | 11 | 12 | class Configuration(ConfigParser.SafeConfigParser): 13 | """Alfajor run-time configuration.""" 14 | 15 | _default_config = """\ 16 | [default] 17 | wsgi = wsgi 18 | * = selenium 19 | 20 | [default+browser.zero] 21 | """ 22 | 23 | def __init__(self, file): 24 | ConfigParser.SafeConfigParser.__init__(self) 25 | self.readfp(StringIO(self._default_config)) 26 | if not self.read(file): 27 | raise IOError("Could not open config file %r" % file) 28 | self.source = file 29 | 30 | def get_section(self, name, default=None, 31 | template='%(name)s', logger=None, fallback=None, **kw): 32 | section_name = template % dict(kw, name=name) 33 | try: 34 | return dict(self.items(section_name)) 35 | except ConfigParser.NoSectionError: 36 | pass 37 | 38 | msg = "Configuration %r does not contain section %r" % ( 39 | self.source, section_name) 40 | 41 | if fallback and fallback != name: 42 | try: 43 | section = self.get_section(fallback, default, template, 44 | logger, **kw) 45 | except LookupError: 46 | pass 47 | else: 48 | if logger: 49 | fallback_name = fallback % dict(kw, name=fallback) 50 | logger.debug("%s, falling back to %r" % ( 51 | msg, section_name, fallback_name)) 52 | return section 53 | if default is not None: 54 | if logger: 55 | logger.debug(msg + ", using default.") 56 | return default 57 | raise LookupError(msg) 58 | -------------------------------------------------------------------------------- /alfajor/_management.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Routines for discovering and preparing backend managers.""" 8 | import inspect 9 | from logging import getLogger 10 | from os import path 11 | 12 | from alfajor.utilities import eval_dotted_path 13 | from alfajor._config import Configuration 14 | 15 | 16 | __all__ = [ 17 | 'APIClient', 18 | 'ManagerLookupError', 19 | 'WebBrowser', 20 | 'new_manager', 21 | ] 22 | 23 | _default_logger = getLogger('alfajor') 24 | 25 | managers = { 26 | 'browser': { 27 | 'selenium': 'alfajor.browsers.managers:SeleniumManager', 28 | 'wsgi': 'alfajor.browsers.managers:WSGIManager', 29 | 'network': 'alfajor.browsers.managers:NetworkManager', 30 | 'zero': 'alfajor.browsers.managers:ZeroManager', 31 | }, 32 | 'apiclient': { 33 | 'wsgi': 'alfajor.apiclient:WSGIClientManager', 34 | }, 35 | } 36 | 37 | 38 | try: 39 | import pkg_resources 40 | except ImportError: 41 | pass 42 | else: 43 | for tool in 'browser', 'apiclient': 44 | group = 'alfajor.' + tool 45 | for entrypoint in pkg_resources.iter_entry_points(group=group): 46 | try: 47 | entry = entrypoint.load() 48 | except Exception, exc: 49 | _default_logger.error("Error loading %s: %s", entrypoint, exc) 50 | else: 51 | managers[tool][entrypoint.name] = entry 52 | 53 | 54 | class ManagerLookupError(Exception): 55 | """Raised if a declaration could not be resolved.""" 56 | 57 | 58 | def new_manager(declaration, runner_options, logger=None): 59 | try: 60 | factory = _ManagerFactory(declaration, runner_options, logger) 61 | return factory.get_instance() 62 | except (KeyboardInterrupt, SystemExit): 63 | raise 64 | except Exception, exc: 65 | raise ManagerLookupError(exc) 66 | 67 | 68 | class _DeferredProxy(object): 69 | """Fronts for another, created-on-demand instance.""" 70 | 71 | def __init__(self): 72 | self._factory = None 73 | self._instance = None 74 | 75 | def _get_instance(self): 76 | if self._instance is not None: # pragma: nocover 77 | return self._instance 78 | if self._factory is None: 79 | raise RuntimeError("%s is not configured." % type(self).__name__) 80 | self._instance = instance = self._factory() 81 | return instance 82 | 83 | def __getattr__(self, key): 84 | if self._instance is None: 85 | instance = self._get_instance() 86 | else: 87 | instance = self._instance 88 | return getattr(instance, key) 89 | 90 | def configure_in_scope(self, configuration='default', default_target=None, 91 | ini_file=None): 92 | namespace = inspect.stack()[1][0].f_globals 93 | setups = namespace.setdefault('__alfajor_setup__', []) 94 | configuration = Declaration(proxy=self, 95 | configuration=configuration, 96 | default_target=default_target, 97 | ini_file=ini_file, 98 | tool=self.tool, 99 | declared_in=namespace.get('__file__')) 100 | setups.append(configuration) 101 | 102 | 103 | class WebBrowser(_DeferredProxy): 104 | """A web browser for functional tests. 105 | 106 | Acts as a shell around a specific backend browser implementation, 107 | allowing a browser instance to be imported into a test module's 108 | namespace before configuration has been processed. 109 | 110 | """ 111 | tool = 'browser' 112 | 113 | def __contains__(self, needle): 114 | browser = self._get_instance() 115 | return needle in browser 116 | 117 | 118 | class APIClient(_DeferredProxy): 119 | """A wire-level HTTP client for functional tests. 120 | 121 | Acts as a shell around a demand-loaded backend implementation, allowing a 122 | client instance to be imported into a test module's namespace before 123 | configuration has been processed. 124 | """ 125 | tool = 'apiclient' 126 | 127 | 128 | class Declaration(object): 129 | 130 | def __init__(self, proxy, configuration, default_target, ini_file, 131 | tool, declared_in): 132 | self.proxy = proxy 133 | self.configuration = configuration 134 | self.default_target = default_target 135 | self.ini_file = ini_file 136 | self.tool = tool 137 | self.declared_in = declared_in 138 | 139 | 140 | class _ManagerFactory(object): 141 | """Encapsulates the process of divining and loading a backend manager.""" 142 | _configs = {} 143 | 144 | def __init__(self, declaration, runner_options, logger=None): 145 | self.declaration = declaration 146 | self.runner_options = runner_options 147 | self.logger = logger or _default_logger 148 | self.config = self._get_configuration(declaration) 149 | self.name = declaration.configuration 150 | 151 | def get_instance(self): 152 | """Return a ready to instantiate backend manager callable. 153 | 154 | Will raise errors if problems are encountered during discovery. 155 | 156 | """ 157 | frontend_name = self._get_frontend_name() 158 | backend_name = self._get_backend_name(frontend_name) 159 | tool = self.declaration.tool 160 | 161 | try: 162 | manager_factory = self._load_backend(tool, backend_name) 163 | except KeyError: 164 | raise KeyError("No known backend %r in configuration %r" % ( 165 | backend_name, self.config.source)) 166 | 167 | backend_config = self.config.get_section( 168 | self.name, template='%(name)s+%(tool)s.%(backend)s', 169 | tool=tool, backend=backend_name, 170 | logger=self.logger, fallback='default') 171 | 172 | return manager_factory(frontend_name, 173 | backend_config, 174 | self.runner_options) 175 | 176 | def _get_configuration(self, declaration): 177 | """Return a Configuration applicable to *declaration*. 178 | 179 | Configuration may come from a declaration option, a runner option 180 | or the default. 181 | 182 | """ 183 | # --alfajor-config overrides any config data in code 184 | if self.runner_options['ini_file']: 185 | finder = self.runner_options['ini_file'] 186 | # if not configured in code, look for 'alfajor.ini' or a declared path 187 | # relative to the file the declaration was made in. 188 | else: 189 | finder = path.abspath( 190 | path.join(path.dirname(declaration.declared_in), 191 | (declaration.ini_file or 'alfajor.ini'))) 192 | # TODO: empty config 193 | try: 194 | return self._configs[finder] 195 | except KeyError: 196 | config = Configuration(finder) 197 | self._configs[finder] = config 198 | return config 199 | 200 | def _get_frontend_name(self): 201 | """Return the frontend requested by the runner or declaration.""" 202 | runner_override = self.declaration.tool + '_frontend' 203 | frontend = self.runner_options.get(runner_override) 204 | if not frontend: 205 | frontend = self.declaration.default_target 206 | if not frontend: 207 | frontend = 'default' 208 | return frontend 209 | 210 | def _get_backend_name(self, frontend): 211 | """Return the backend name for *frontend*.""" 212 | if frontend == 'default': 213 | defaults = self.config.get_section('default-targets', default={}) 214 | key = '%s+%s' % (self.declaration.configuration, 215 | self.declaration.tool) 216 | if key not in defaults: 217 | key = 'default+%s' % (self.declaration.tool,) 218 | try: 219 | frontend = defaults[key] 220 | except KeyError: 221 | raise LookupError("No default target declared.") 222 | mapping = self.config.get_section(self.name, fallback='default') 223 | try: 224 | return mapping[frontend] 225 | except KeyError: 226 | return mapping['*'] 227 | 228 | def _load_backend(self, tool, backend): 229 | """Load a *backend* callable for *tool*. 230 | 231 | Consults the [tool.backends] section of the active configuration 232 | first for a "tool = evalable.dotted:path" entry. If not found, 233 | looks in the process-wide registry of built-in and pkg_resources 234 | managed backends. 235 | 236 | A config entry will override an equivalently named process entry. 237 | 238 | """ 239 | point_of_service_managers = self.config.get_section( 240 | '%(tool)s.backends', default={}, logger=self.logger, 241 | tool=tool) 242 | try: 243 | entry = point_of_service_managers[backend] 244 | except KeyError: 245 | pass 246 | else: 247 | if callable(entry): 248 | return entry 249 | else: 250 | return eval_dotted_path(entry) 251 | 252 | entry = managers[tool][backend] 253 | if callable(entry): 254 | return entry 255 | fn = eval_dotted_path(entry) 256 | managers[tool].setdefault(backend, fn) 257 | return fn 258 | -------------------------------------------------------------------------------- /alfajor/apiclient.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """A low-level HTTP client suitable for testing APIs.""" 8 | import copy 9 | from cStringIO import StringIO 10 | import dummy_threading 11 | from cookielib import DefaultCookiePolicy 12 | from logging import DEBUG, getLogger 13 | import mimetypes 14 | from urllib import urlencode 15 | from urlparse import urlparse, urlunparse 16 | from wsgiref.util import request_uri 17 | 18 | from werkzeug import BaseResponse, Headers, create_environ, run_wsgi_app 19 | from werkzeug.test import _TestCookieJar, encode_multipart 20 | 21 | from alfajor.utilities import eval_dotted_path 22 | from alfajor._compat import json_loads as loads 23 | 24 | 25 | logger = getLogger(__name__) 26 | 27 | _json_content_types = set([ 28 | 'application/json', 29 | 'application/x-javascript', 30 | 'text/javascript', 31 | 'text/x-javascript', 32 | 'text/x-json', 33 | ]) 34 | 35 | 36 | class WSGIClientManager(object): 37 | """Lifecycle manager for global api clients.""" 38 | 39 | def __init__(self, frontend_name, backend_config, runner_options): 40 | self.config = backend_config 41 | 42 | def create(self): 43 | from alfajor.apiclient import APIClient 44 | 45 | entry_point = self.config['server-entry-point'] 46 | app = eval_dotted_path(entry_point) 47 | 48 | base_url = self.config.get('base_url') 49 | logger.debug("Created in-process WSGI api client rooted at %s.", 50 | base_url) 51 | return APIClient(app, base_url=base_url) 52 | 53 | def destroy(self): 54 | logger.debug("Destroying in-process WSGI api client.") 55 | 56 | 57 | class APIClient(object): 58 | 59 | def __init__(self, application, state=None, base_url=None): 60 | self.application = application 61 | self.state = state or _APIClientState(application) 62 | self.base_url = base_url 63 | 64 | def open(self, path='/', base_url=None, query_string=None, method='GET', 65 | data=None, input_stream=None, content_type=None, 66 | content_length=0, errors_stream=None, multithread=False, 67 | multiprocess=False, run_once=False, environ_overrides=None, 68 | buffered=True): 69 | 70 | parsed = urlparse(path) 71 | if parsed.scheme: 72 | if base_url is None: 73 | base_url = parsed.scheme + '://' + parsed.netloc 74 | if query_string is None: 75 | query_string = parsed.query 76 | path = parsed.path 77 | 78 | if (input_stream is None and 79 | data is not None and 80 | method in ('PUT', 'POST')): 81 | input_stream, content_length, content_type = \ 82 | self._prep_input(input_stream, data, content_type) 83 | 84 | if base_url is None: 85 | base_url = self.base_url or self.state.base_url 86 | 87 | environ = create_environ(path, base_url, query_string, method, 88 | input_stream, content_type, content_length, 89 | errors_stream, multithread, 90 | multiprocess, run_once) 91 | 92 | current_state = self.state 93 | current_state.prepare_environ(environ) 94 | if environ_overrides: 95 | environ.update(environ_overrides) 96 | 97 | logger.info("%s %s" % (method, request_uri(environ))) 98 | rv = run_wsgi_app(self.application, environ, buffered=buffered) 99 | 100 | response = _APIClientResponse(*rv) 101 | response.state = new_state = current_state.copy() 102 | new_state.process_response(response, environ) 103 | return response 104 | 105 | def get(self, *args, **kw): 106 | """:meth:`open` as a GET request.""" 107 | kw['method'] = 'GET' 108 | return self.open(*args, **kw) 109 | 110 | def post(self, *args, **kw): 111 | """:meth:`open` as a POST request.""" 112 | kw['method'] = 'POST' 113 | return self.open(*args, **kw) 114 | 115 | def head(self, *args, **kw): 116 | """:meth:`open` as a HEAD request.""" 117 | kw['method'] = 'HEAD' 118 | return self.open(*args, **kw) 119 | 120 | def put(self, *args, **kw): 121 | """:meth:`open` as a PUT request.""" 122 | kw['method'] = 'PUT' 123 | return self.open(*args, **kw) 124 | 125 | def delete(self, *args, **kw): 126 | """:meth:`open` as a DELETE request.""" 127 | kw['method'] = 'DELETE' 128 | return self.open(*args, **kw) 129 | 130 | def wrap_file(self, fd, filename=None, mimetype=None): 131 | """Wrap a file for use in POSTing or PUTing. 132 | 133 | :param fd: a file name or file-like object 134 | :param filename: file name to send in the HTTP request 135 | :param mimetype: mime type to send, guessed if not supplied. 136 | """ 137 | return File(fd, filename, mimetype) 138 | 139 | def _prep_input(self, input_stream, data, content_type): 140 | if isinstance(data, basestring): 141 | assert content_type is not None, 'content type required' 142 | else: 143 | need_multipart = False 144 | pairs = [] 145 | debugging = logger.isEnabledFor(DEBUG) 146 | for key, value in _to_pairs(data): 147 | if isinstance(value, basestring): 148 | if isinstance(value, unicode): 149 | value = str(value) 150 | if debugging: 151 | logger.debug("%r=%r" % (key, value)) 152 | pairs.append((key, value)) 153 | continue 154 | need_multipart = True 155 | if isinstance(value, tuple): 156 | pairs.append((key, File(*value))) 157 | elif isinstance(value, dict): 158 | pairs.append((key, File(**value))) 159 | elif not isinstance(value, File): 160 | pairs.append((key, File(value))) 161 | else: 162 | pairs.append((key, value)) 163 | if need_multipart: 164 | boundary, data = encode_multipart(pairs) 165 | if content_type is None: 166 | content_type = 'multipart/form-data; boundary=' + \ 167 | boundary 168 | else: 169 | data = urlencode(pairs) 170 | logger.debug('data: ' + data) 171 | if content_type is None: 172 | content_type = 'application/x-www-form-urlencoded' 173 | content_length = len(data) 174 | input_stream = StringIO(data) 175 | return input_stream, content_length, content_type 176 | 177 | 178 | class _APIClientResponse(object): 179 | state = None 180 | 181 | @property 182 | def client(self): 183 | """A new client born from this response. 184 | 185 | The client will have access to any cookies that were sent as part 186 | of this response & send this response's URL as a referrer. 187 | 188 | Each access to this property returns an independent client with its 189 | own copy of the cookie jar. 190 | 191 | """ 192 | state = self.state 193 | return APIClient(application=state.application, state=state) 194 | 195 | status_code = BaseResponse.status_code 196 | 197 | @property 198 | def request_uri(self): 199 | """The source URI for this response.""" 200 | return request_uri(self.state.source_environ) 201 | 202 | @property 203 | def is_json(self): 204 | """True if the response is JSON and the HTTP status was 200.""" 205 | return (self.status_code == 200 and 206 | self.headers.get('Content-Type', '') in _json_content_types) 207 | 208 | @property 209 | def json(self): 210 | """The response parsed as JSON. 211 | 212 | No attempt is made to ensure the response is valid or even looks 213 | like JSON before parsing. 214 | """ 215 | return loads(self.response) 216 | 217 | def __init__(self, app_iter, status, headers): 218 | self.headers = Headers(headers) 219 | if isinstance(status, (int, long)): 220 | self.status_code = status # sets .status as well 221 | else: 222 | self.status = status 223 | 224 | if isinstance(app_iter, basestring): 225 | self.response = app_iter 226 | else: 227 | self.response = ''.join(app_iter) 228 | if 'Content-Length' not in self.headers: 229 | self.headers['Content-Length'] = len(self.response) 230 | 231 | 232 | class _APIClientState(object): 233 | default_base_url = 'http://localhost' 234 | 235 | def __init__(self, application): 236 | self.application = application 237 | self.cookie_jar = _CookieJar() 238 | self.auth = None 239 | self.referrer = None 240 | 241 | @property 242 | def base_url(self): 243 | if not self.referrer: 244 | return self.default_base_url 245 | url = urlparse(self.referrer) 246 | return urlunparse(url[:2] + ('', '', '', '')) 247 | 248 | def copy(self): 249 | fork = copy.copy(self) 250 | fork.cookie_jar = self.cookie_jar.copy() 251 | return fork 252 | 253 | def prepare_environ(self, environ): 254 | if self.referrer: 255 | environ['HTTP_REFERER'] = self.referrer 256 | if len(self.cookie_jar): 257 | self.cookie_jar.inject_wsgi(environ) 258 | environ.setdefault('REMOTE_ADDR', '127.0.0.1') 259 | 260 | def process_response(self, response, request_environ): 261 | headers = response.headers 262 | if 'Set-Cookie' in headers or 'Set-Cookie2' in headers: 263 | self.cookie_jar.extract_wsgi(request_environ, headers) 264 | self.referrer = request_uri(request_environ) 265 | self.source_environ = request_environ 266 | 267 | 268 | # lifted from werkzeug 0.4 269 | class File(object): 270 | """Wraps a file descriptor or any other stream so that `encode_multipart` 271 | can get the mimetype and filename from it. 272 | """ 273 | 274 | def __init__(self, fd, filename=None, mimetype=None): 275 | if isinstance(fd, basestring): 276 | if filename is None: 277 | filename = fd 278 | fd = file(fd, 'rb') 279 | try: 280 | self.stream = StringIO(fd.read()) 281 | finally: 282 | fd.close() 283 | else: 284 | self.stream = fd 285 | if filename is None: 286 | if not hasattr(fd, 'name'): 287 | raise ValueError('no filename for provided') 288 | filename = fd.name 289 | if mimetype is None: 290 | mimetype = mimetypes.guess_type(filename)[0] 291 | self.filename = filename 292 | self.mimetype = mimetype or 'application/octet-stream' 293 | 294 | def __getattr__(self, name): 295 | return getattr(self.stream, name) 296 | 297 | def __repr__(self): 298 | return '<%s %r>' % (self.__class__.__name__, self.filename) 299 | 300 | 301 | class _CookieJar(_TestCookieJar): 302 | """A lock-less, wsgi-friendly CookieJar that can clone itself.""" 303 | 304 | def __init__(self, policy=None): 305 | if policy is None: 306 | policy = DefaultCookiePolicy() 307 | self._policy = policy 308 | self._cookies = {} 309 | self._cookies_lock = dummy_threading.RLock() 310 | 311 | def copy(self): 312 | fork = copy.copy(self) 313 | fork._cookies = copy.deepcopy(self._cookies) 314 | return fork 315 | 316 | 317 | # taken from flatland 318 | def _to_pairs(dictlike): 319 | """Yield (key, value) pairs from any dict-like object. 320 | 321 | Implements an optimized version of the dict.update() definition of 322 | "dictlike". 323 | 324 | """ 325 | if hasattr(dictlike, 'items'): 326 | return dictlike.items() 327 | elif hasattr(dictlike, 'keys'): 328 | return [(key, dictlike[key]) for key in dictlike.keys()] 329 | else: 330 | return [(key, value) for key, value in dictlike] 331 | -------------------------------------------------------------------------------- /alfajor/browsers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | """Functional browsers.""" 8 | -------------------------------------------------------------------------------- /alfajor/browsers/_lxml.py: -------------------------------------------------------------------------------- 1 | # Copyright Action Without Borders, Inc., the Alfajor authors and contributors. 2 | # All rights reserved. See AUTHORS. 3 | # 4 | # This file is part of 'Alfajor' and is distributed under the BSD license. 5 | # See LICENSE for more details. 6 | 7 | 8 | """Low level LXML element implementation & parser wrangling.""" 9 | from collections import defaultdict 10 | import mimetypes 11 | import re 12 | from UserDict import DictMixin 13 | from textwrap import fill 14 | 15 | from lxml import html as lxml_html 16 | from lxml.etree import ElementTree, XPath 17 | from lxml.html import ( 18 | fromstring as html_from_string, 19 | tostring, 20 | ) 21 | from lxml.html._setmixin import SetMixin 22 | 23 | from alfajor._compat import property 24 | from alfajor.utilities import lazy_property, to_pairs 25 | 26 | 27 | __all__ = ['html_parser_for', 'html_from_string'] 28 | _single_id_selector = re.compile(r'#[A-Za-z][A-Za-z0-9:_.\-]*$') 29 | XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml" 30 | 31 | # lifted from lxml 32 | _options_xpath = XPath( 33 | "descendant-or-self::option|descendant-or-self::x:option", 34 | namespaces={'x': XHTML_NAMESPACE}) 35 | _collect_string_content = XPath("string()") 36 | _forms_xpath = XPath("descendant-or-self::form|descendant-or-self::x:form", 37 | namespaces={'x': XHTML_NAMESPACE}) 38 | 39 | 40 | def _nons(tag): 41 | if isinstance(tag, basestring): 42 | if (tag[0] == '{' and 43 | tag[1:len(XHTML_NAMESPACE) + 1] == XHTML_NAMESPACE): 44 | return tag.split('}')[-1] 45 | return tag 46 | 47 | # not lifted from lxml 48 | _enclosing_form_xpath = XPath('ancestor::form[1]') 49 | 50 | 51 | class callable_unicode(unicode): 52 | """Compatibility class for 'element.text_content'""" 53 | 54 | def __call__(self): 55 | return unicode(self) 56 | 57 | 58 | def html_parser_for(browser, element_mixins): 59 | "Return an HTMLParser linked to *browser* and powered by *element_mixins*." 60 | parser = lxml_html.HTMLParser() 61 | parser.set_element_class_lookup(ElementLookup(browser, element_mixins)) 62 | return parser 63 | 64 | 65 | class DOMMixin(object): 66 | """Supplies DOM parsing and query methods to browsers. 67 | 68 | Browsers must implement a ``self._lxml_parser`` property that contains a 69 | parser specific to this browser instance. For example: 70 | 71 | element_mixins = {} # pairs of 'element name': 72 | 73 | @lazy_property 74 | def _lxml_parser(self): 75 | return html_parser_for(self, self.element_mixins) 76 | 77 | """ 78 | 79 | @lazy_property 80 | def document(self): 81 | """An LXML tree of the :attr:`response` content.""" 82 | # TODO: document decision to use 'fromstring' (means dom may 83 | # be what the remote sent, may not.) 84 | if self.response is None: 85 | return None 86 | return html_from_string(self.response, parser=self._lxml_parser) 87 | 88 | def sync_document(self): 89 | """Synchronize the :attr:`document` DOM with the visible page.""" 90 | self.__dict__.pop('document', None) 91 | 92 | def __contains__(self, needle): 93 | """True if *needle* exists anywhere in the response content.""" 94 | # TODO: make this normalize whitespace? something like split 95 | # *needle* on whitespace, build a regex of r'\s+'-separated 96 | # bits. this could be a fallback to a simple containment 97 | # test. 98 | document = self.document 99 | if document is None: 100 | return False 101 | return needle in document.text_content 102 | 103 | @property 104 | def xpath(self): 105 | """An xpath querying function querying at the top of the document.""" 106 | return self.document.xpath 107 | 108 | @property 109 | def cssselect(self): 110 | """A CSS selector function selecting at the top of the document.""" 111 | return self.document.cssselect 112 | 113 | 114 | class DOMElement(object): 115 | """Functionality added to all elements on all browsers.""" 116 | 117 | @lazy_property 118 | def fq_xpath(self): 119 | """The fully qualified xpath to this element.""" 120 | return ElementTree(self).getpath(self) 121 | 122 | @property 123 | def forms(self): 124 | """Return a list of all the forms.""" 125 | return _FormsList(_forms_xpath(self)) 126 | 127 | # DOM methods (Mostly applicable only with javascript enabled.) Capable 128 | # browsers should re-implement these methods. 129 | 130 | def click(self, wait_for=None, timeout=0): 131 | """Click this element.""" 132 | 133 | def double_click(self, wait_for=None, timeout=0): 134 | """Double-click this element.""" 135 | 136 | def mouse_over(self, wait_for=None, timeout=0): 137 | """Move the mouse into this element's bounding box.""" 138 | 139 | def mouse_out(self, wait_for=None, timeout=0): 140 | """Move the mouse out of this element's bounding box.""" 141 | 142 | def focus(self, wait_for=None, timeout=0): 143 | """Shift focus to this element.""" 144 | 145 | def fire_event(self, name, wait_for=None, timeout=0): 146 | """Fire DOM event *name* on this element.""" 147 | 148 | # TODO:jek: investigate css-tools for implementing this for the WSGI 149 | # browser 150 | is_visible = True 151 | """True if the element is visible. 152 | 153 | Note: currently always True in the WSGI browser. 154 | 155 | """ 156 | 157 | @property 158 | def text_content(self): 159 | """The text content of the tag and its children. 160 | 161 | This property overrides the text_content() method of regular 162 | lxml.html elements. Similar, but acts usable as an 163 | attribute or as a method call and normalizes all whitespace 164 | as single spaces. 165 | 166 | """ 167 | text = u' '.join(_collect_string_content(self).split()) 168 | return callable_unicode(text) 169 | 170 | @property 171 | def innerHTML(self): 172 | inner = ''.join(tostring(el) for el in self.iterchildren()) 173 | if self.text: 174 | return self.text + inner 175 | else: 176 | return inner 177 | 178 | def __contains__(self, needle): 179 | """True if the element or its children contains *needle*. 180 | 181 | :param needle: may be an document element, integer index or a 182 | CSS select query. 183 | 184 | If *needle* is a document element, only immediate decedent 185 | elements are considered. 186 | 187 | """ 188 | if not isinstance(needle, (int, basestring)): 189 | return super(DOMElement, self).__contains__(needle) 190 | try: 191 | self[needle] 192 | except (AssertionError, IndexError): 193 | return False 194 | else: 195 | return True 196 | 197 | def __getitem__(self, key): 198 | """Retrieve elements by integer index, id or CSS select query.""" 199 | if not isinstance(key, basestring): 200 | return super(DOMElement, self).__getitem__(key) 201 | # '#foo'? (and not '#foo li') 202 | if _single_id_selector.match(key): 203 | try: 204 | return self.get_element_by_id(key[1:]) 205 | except KeyError: 206 | label = 'Document' if self.tag == 'html' else 'Fragment' 207 | raise AssertionError("%s contains no element with " 208 | "id %r" % (label, key)) 209 | # 'li #foo'? (and not 'li #foo li') 210 | elif _single_id_selector.search(key): 211 | elements = self.cssselect(key) 212 | if len(elements) != 1: 213 | label = 'Document' if self.tag == 'html' else 'Fragment' 214 | raise AssertionError("%s contains %s elements matching " 215 | "id %s!" % (label, len(elements), key)) 216 | return elements[0] 217 | else: 218 | elements = self.cssselect(key) 219 | if not elements: 220 | label = 'Document' if self.tag == 'html' else 'Fragment' 221 | raise AssertionError("%s contains no elements matching " 222 | "css selector %r" % (label, key)) 223 | return elements 224 | 225 | def __str__(self): 226 | """An excerpt of the HTML of this element (without its children).""" 227 | clone = self.makeelement(self.tag, self.attrib, self.nsmap) 228 | if self.text_content: 229 | clone.text = u'...' 230 | value = self.get('value', '') 231 | if len(value) > 32: 232 | clone.attrib['value'] = value + u'...' 233 | html = tostring(clone) 234 | return fill(html, 79, subsequent_indent=' ') 235 | 236 | 237 | class FormElement(object): 238 | 239 | @property 240 | def inputs(self): 241 | """An accessor for all the input elements in the form. 242 | 243 | See :class:`InputGetter` for more information about the object. 244 | """ 245 | return InputGetter(self) 246 | 247 | def fields(self): 248 | """A dict-like read/write mapping of form field values.""" 249 | return FieldsDict(self.inputs) 250 | 251 | fields = property(fields, lxml_html.FormElement._fields__set) 252 | 253 | def submit(self, wait_for=None, timeout=0): 254 | """Submit the form's values. 255 | 256 | Equivalent to hitting 'return' in a browser form: the data is 257 | submitted without the submit button's key/value pair. 258 | 259 | """ 260 | 261 | def fill(self, values, wait_for=None, timeout=0, with_prefix=u''): 262 | """Fill fields of the form from *values*. 263 | 264 | :param values: a mapping or sequence of name/value pairs of form data. 265 | If a sequence is provided, the sequence order will be respected when 266 | filling fields with the exception of disjoint pairs in a checkbox 267 | group, which will be set all at once. 268 | 269 | :param with_prefix: optional, a string that all form fields should 270 | start with. If a supplied field name does not start with this 271 | prefix, it will be prepended. 272 | 273 | """ 274 | grouped = _group_key_value_pairs(values, with_prefix) 275 | fields = self.fields 276 | for name, field_values in grouped: 277 | if len(field_values) == 1: 278 | value = field_values[0] 279 | else: 280 | value = field_values 281 | fields[name] = value 282 | 283 | def form_values(self): 284 | """Return name, value pairs of form data as a browser would submit.""" 285 | results = [] 286 | for name, elements in self.inputs.iteritems(): 287 | if not name: 288 | continue 289 | if elements[0].tag == 'input': 290 | type = elements[0].type 291 | else: 292 | type = elements[0].tag 293 | if type in ('submit', 'image', 'reset'): 294 | continue 295 | for el in elements: 296 | value = el.value 297 | if getattr(el, 'checkable', False): 298 | if not el.checked: 299 | continue 300 | # emulate browser behavior for valueless checkboxes 301 | results.append((name, value or 'on')) 302 | continue 303 | elif type == 'select': 304 | if value is None: 305 | # this won't be reached unless the first option is 306 | #