diff options
| -rw-r--r-- | .travis.yml | 2 | ||||
| -rw-r--r-- | CHANGES.txt | 49 | ||||
| -rw-r--r-- | HACKING.txt | 32 | ||||
| -rw-r--r-- | docs/narr/i18n.rst | 6 | ||||
| -rw-r--r-- | docs/whatsnew-1.5.rst | 78 | ||||
| -rw-r--r-- | pyramid/authentication.py | 86 | ||||
| -rw-r--r-- | pyramid/config/views.py | 18 | ||||
| -rw-r--r-- | pyramid/encode.py | 23 | ||||
| -rw-r--r-- | pyramid/i18n.py | 33 | ||||
| -rw-r--r-- | pyramid/scripts/pshell.py | 29 | ||||
| -rw-r--r-- | pyramid/session.py | 113 | ||||
| -rw-r--r-- | pyramid/testing.py | 1 | ||||
| -rw-r--r-- | pyramid/tests/test_authentication.py | 129 | ||||
| -rw-r--r-- | pyramid/tests/test_config/test_views.py | 21 | ||||
| -rw-r--r-- | pyramid/tests/test_encode.py | 5 | ||||
| -rw-r--r-- | pyramid/tests/test_scripts/test_pshell.py | 64 | ||||
| -rw-r--r-- | pyramid/tests/test_session.py | 27 | ||||
| -rw-r--r-- | pyramid/tests/test_url.py | 55 | ||||
| -rw-r--r-- | pyramid/url.py | 167 | ||||
| -rw-r--r-- | setup.py | 4 |
20 files changed, 603 insertions, 339 deletions
diff --git a/.travis.yml b/.travis.yml index bc82c8faf..29e499e76 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,8 @@ python: - 3.2 - 3.3 +install: python setup.py dev + script: python setup.py test -q notifications: diff --git a/CHANGES.txt b/CHANGES.txt index c05c4561e..1ae2ebbd9 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,5 @@ -Unreleased -========== +1.5a3 (2013-12-10) +================== Features -------- @@ -35,11 +35,13 @@ Features See https://github.com/Pylons/pyramid/pull/1149 - Added a new ``SignedCookieSessionFactory`` which is very similar to the - ``UnencryptedCookieSessionFactoryConfig`` but with a clearer focus on - signing content. The custom serializer arguments to this function should - only focus on serializing, unlike its predecessor which required the - serializer to also perform signing. - See https://github.com/Pylons/pyramid/pull/1142 + ``UnencryptedCookieSessionFactoryConfig`` but with a clearer focus on signing + content. The custom serializer arguments to this function should only focus + on serializing, unlike its predecessor which required the serializer to also + perform signing. See https://github.com/Pylons/pyramid/pull/1142 . Note + that cookies generated using ``SignedCookieSessionFactory`` are not + compatible with cookies generated using ``UnencryptedCookieSessionFactory``, + so existing user session data will be destroyed if you switch to it. - Added a new ``BaseCookieSessionFactory`` which acts as a generic cookie factory that can be used by framework implementors to create their own @@ -48,6 +50,27 @@ Features timeouts, and conformance with the ``ISession`` API. See https://github.com/Pylons/pyramid/pull/1142 +- The anchor argument to ``pyramid.request.Request.route_url`` and + ``pyramid.request.Request.resource_url`` and their derivatives will now be + escaped via URL quoting to ensure minimal conformance. See + https://github.com/Pylons/pyramid/pull/1183 + +- Allow sending of ``_query`` and ``_anchor`` options to + ``pyramid.request.Request.static_url`` when an external URL is being + generated. + See https://github.com/Pylons/pyramid/pull/1183 + +- You can now send a string as the ``_query`` argument to + ``pyramid.request.Request.route_url`` and + ``pyramid.request.Request.resource_url`` and their derivatives. When a + string is sent instead of a list or dictionary. it is URL-quoted however it + does not need to be in ``k=v`` form. This is useful if you want to be able + to use a different query string format than ``x-www-form-urlencoded``. See + https://github.com/Pylons/pyramid/pull/1183 + +- ``pyramid.testing.DummyRequest`` now has a ``domain`` attribute to match the + new WebOb 1.3 API. Its value is ``example.com``. + Bug Fixes --------- @@ -122,12 +145,6 @@ Deprecations - The ``pyramid.security.has_permission`` API is now deprecated. Instead, use the newly-added ``has_permission`` method of the request object. -- The ``pyramid.security.forget`` API is now deprecated. Instead, use - the newly-added ``forget_userid`` method of the request object. - -- The ``pyramid.security.remember`` API is now deprecated. Instead, use - the newly-added ``remember_userid`` method of the request object. - - The ``pyramid.security.effective_principals`` API is now deprecated. Instead, use the newly-added ``effective_principals`` attribute of the request object. @@ -140,6 +157,12 @@ Deprecations Instead, use the newly-added ``unauthenticated_userid`` attribute of the request object. +Dependencies +------------ + +- Pyramid now depends on WebOb>=1.3 (it uses ``webob.cookies.CookieProfile`` + from 1.3+). + 1.5a2 (2013-09-22) ================== diff --git a/HACKING.txt b/HACKING.txt index b32a8a957..12f2d68e2 100644 --- a/HACKING.txt +++ b/HACKING.txt @@ -6,9 +6,9 @@ Here are some guidelines about hacking on Pyramid. Using a Development Checkout ---------------------------- -You'll have to create a development environment to hack on Pyramid, using a -Pyramid checkout. You can either do this by hand or, if you have ``tox`` -installed (it's on PyPI), you can (ab)use tox to get a working development +You'll have to create a development environment to hack on Pyramid, using a +Pyramid checkout. You can either do this by hand or, if you have ``tox`` +installed (it's on PyPI), you can (ab)use tox to get a working development environment. Each installation method is described below. By Hand @@ -25,15 +25,15 @@ By Hand $ cd ~/hack-on-pyramid $ virtualenv -ppython2.7 env - Note that very old versions of virtualenv (virtualenv versions below, say, + Note that very old versions of virtualenv (virtualenv versions below, say, 1.10 or thereabouts) require you to pass a ``--no-site-packages`` flag to get a completely isolated environment. - You can choose which Python version you want to use by passing a ``-p`` + You can choose which Python version you want to use by passing a ``-p`` flag to ``virtualenv``. For example, ``virtualenv -ppython2.7`` chooses the Python 2.7 interpreter to be installed. - From here on in within these instructions, the ``~/hack-on-pyramid/env`` + From here on in within these instructions, the ``~/hack-on-pyramid/env`` virtual environment you created above will be referred to as ``$VENV``. To use the instructions in the steps that follow literally, use the ``export VENV=~/hack-on-pyramid/env`` command. @@ -132,7 +132,7 @@ Coding Style - PEP8 compliance. Whitespace rules are relaxed: not necessary to put 2 newlines between classes. But 80-column lines, in particular, are - mandatory. See + mandatory. See http://docs.pylonsproject.org/en/latest/community/codestyle.html for more information. @@ -142,14 +142,14 @@ Coding Style Running Tests -------------- -- To run all tests for Pyramid on a single Python version, run ``nosetests`` +- To run all tests for Pyramid on a single Python version, run ``nosetests`` from your development virtualenv (See *Using a Development Checkout* above). - To run individual tests (i.e. during development) you can use a regular expression with the ``-t`` parameter courtesy of the `nose-selecttests - <https://pypi.python.org/pypi/nose-selecttests/>`_ plugin that's been - installed (along with nose itself) via ``python setup.py dev``. The - easiest usage is to simply provide the verbatim name of the test you're + <https://pypi.python.org/pypi/nose-selecttests/>`_ plugin that's been + installed (along with nose itself) via ``python setup.py dev``. The + easiest usage is to simply provide the verbatim name of the test you're working on. - To run the full set of Pyramid tests on all platforms, install ``tox`` @@ -191,8 +191,8 @@ or adds the feature. To build and review docs (where ``$VENV`` refers to the virtualenv you're using to develop Pyramid): -1. After following the steps above in "Using a Development Checkout", cause - Sphinx and all development requirements to be installed in your +1. After following the steps above in "Using a Development Checkout", cause + Sphinx and all development requirements to be installed in your virtualenv:: $ cd ~/hack-on-pyramid @@ -212,9 +212,9 @@ using to develop Pyramid): $ cd ~/hack-on-pyramid/pyramid/docs $ make clean html SPHINXBUILD=$VENV/bin/sphinx-build - The ``SPHINXBUILD=...`` hair is there in order to tell it to use the - virtualenv Python, which will have both Sphinx and Pyramid (for API - documentation generation) installed. + The ``SPHINXBUILD=...`` argument tells Sphinx to use the virtualenv Python, + which will have both Sphinx and Pyramid (for API documentation generation) + installed. 4. Open the ``docs/_build/html/index.html`` file to see the resulting HTML rendering. diff --git a/docs/narr/i18n.rst b/docs/narr/i18n.rst index c9b782c08..5f50ca212 100644 --- a/docs/narr/i18n.rst +++ b/docs/narr/i18n.rst @@ -607,10 +607,8 @@ object, but the domain and mapping information attached is ignored. def aview(request): localizer = request.localizer num = 1 - translated = localizer.pluralize( - _('item_plural', default="${number} items"), - None, num, 'mydomain', mapping={'number':num} - ) + translated = localizer.pluralize('item_plural', '${number} items', + num, 'mydomain', mapping={'number':num}) The corresponding message catalog must have language plural definitions and plural alternatives set. diff --git a/docs/whatsnew-1.5.rst b/docs/whatsnew-1.5.rst index 57f93cbff..9ccf097a8 100644 --- a/docs/whatsnew-1.5.rst +++ b/docs/whatsnew-1.5.rst @@ -316,6 +316,51 @@ The feature additions in Pyramid 1.5 follow. - :func:`pyramid.path.package_name` no longer thows an exception when resolving the package name for namespace packages that have no ``__file__`` attribute. +- An authorization API has been added as a method of the request: + :meth:`pyramid.request.Request.has_permission`. It is a method-based + alternative to the :func:`pyramid.security.has_permission` API and works + exactly the same. The older API is now deprecated. + +- Property API attributes have been added to the request for easier access to + authentication data: :attr:`pyramid.request.Request.authenticated_userid`, + :attr:`pyramid.request.Request.unauthenticated_userid`, and + :attr:`pyramid.request.Request.effective_principals`. These are analogues, + respectively, of :func:`pyramid.security.authenticated_userid`, + :func:`pyramid.security.unauthenticated_userid`, and + :func:`pyramid.security.effective_principals`. They operate exactly the + same, except they are attributes of the request instead of functions + accepting a request. They are properties, so they cannot be assigned to. + The older function-based APIs are now deprecated. + +- Pyramid's console scripts (``pserve``, ``pviews``, etc) can now be run + directly, allowing custom arguments to be sent to the python interpreter + at runtime. For example:: + + python -3 -m pyramid.scripts.pserve development.ini + +- Added a specific subclass of :class:`pyramid.httpexceptions.HTTPBadRequest` + named :class:`pyramid.exceptions.BadCSRFToken` which will now be raised in + response to failures in the ``check_csrf_token`` view predicate. See + https://github.com/Pylons/pyramid/pull/1149 + +- Added a new ``SignedCookieSessionFactory`` which is very similar to the + ``UnencryptedCookieSessionFactoryConfig`` but with a clearer focus on + signing content. The custom serializer arguments to this function should + only focus on serializing, unlike its predecessor which required the + serializer to also perform signing. + See https://github.com/Pylons/pyramid/pull/1142 . Note + that cookies generated using ``SignedCookieSessionFactory`` are not + compatible with cookies generated using ``UnencryptedCookieSessionFactory``, + so existing user session data will be destroyed if you switch to it. + +- Added a new ``BaseCookieSessionFactory`` which acts as a generic cookie + factory that can be used by framework implementors to create their own + session implementations. It provides a reusable API which focuses strictly + on providing a dictionary-like object that properly handles renewals, + timeouts, and conformance with the ``ISession`` API. + See https://github.com/Pylons/pyramid/pull/1142 + + Other Backwards Incompatibilities --------------------------------- @@ -404,6 +449,13 @@ Other Backwards Incompatibilities Pyramid narrative documentation instead of providing renderer globals values to the configurator. +- The key/values in the ``_query`` parameter of + :meth:`pyramid.request.Request.route_url` and the ``query`` parameter of + :meth:`pyramid.request.Request.resource_url` (and their variants), used to + encode a value of ``None`` as the string ``'None'``, leaving the resulting + query string to be ``a=b&key=None``. The value is now dropped in this + situation, leaving a query string of ``a=b&key=``. See + https://github.com/Pylons/pyramid/issues/1119 Deprecations ------------ @@ -417,12 +469,36 @@ Deprecations a deprecation warning when used. It had been docs-deprecated in 1.4 but did not issue a deprecation warning when used. +- :func:`pyramid.security.has_permission` is now deprecated in favor of using + :meth:`pyramid.request.Request.has_permission`. + +- The :func:`pyramid.security.authenticated_userid`, + :func:`pyramid.security.unauthenticated_userid`, and + :func:`pyramid.security.effective_principals` functions have been + deprecated. Use :attr:`pyramid.request.Request.authenticated_userid`, + :attr:`pyramid.request.Request.unauthenticated_userid` and + :attr:`pyramid.request.Request.effective_principals` instead. + +- Deprecate the ``pyramid.interfaces.ITemplateRenderer`` interface. It was + ill-defined and became unused when Mako and Chameleon template bindings were + split into their own packages. + +- The ``pyramid.session.UnencryptedCookieSessionFactoryConfig`` API has been + deprecated and is superseded by the + ``pyramid.session.SignedCookieSessionFactory``. Note that while the cookies + generated by the ``UnencryptedCookieSessionFactoryConfig`` + are compatible with cookies generated by old releases, cookies generated by + the SignedCookieSessionFactory are not. See + https://github.com/Pylons/pyramid/pull/1142 + Documentation Enhancements -------------------------- - A new documentation chapter named :ref:`quick_tour` was added. It describes starting out with Pyramid from a high level. +- Added a :ref:`quick_tutorial` to go with the Quick Tour + - Many other enhancements. @@ -431,3 +507,5 @@ Dependency Changes - Pyramid no longer depends upon ``Mako`` or ``Chameleon``. +- Pyramid now depends on WebOb>=1.3 (it uses ``webob.cookies.CookieProfile`` + from 1.3+). diff --git a/pyramid/authentication.py b/pyramid/authentication.py index 2c301bd29..ba7b864f9 100644 --- a/pyramid/authentication.py +++ b/pyramid/authentication.py @@ -10,6 +10,8 @@ import warnings from zope.interface import implementer +from webob.cookies import CookieProfile + from pyramid.compat import ( long, text_type, @@ -18,6 +20,7 @@ from pyramid.compat import ( url_quote, bytes_, ascii_native_, + native_, ) from pyramid.interfaces import ( @@ -798,8 +801,6 @@ def encode_ip_timestamp(ip, timestamp): ts_chars = ''.join(map(chr, ts)) return bytes_(ip_chars + ts_chars) -EXPIRE = object() - class AuthTktCookieHelper(object): """ A helper class for use in third-party authentication policy @@ -830,55 +831,32 @@ class AuthTktCookieHelper(object): include_ip=False, timeout=None, reissue_time=None, max_age=None, http_only=False, path="/", wild_domain=True, hashalg='md5', parent_domain=False, domain=None): + + serializer = _SimpleSerializer() + + self.cookie_profile = CookieProfile( + cookie_name = cookie_name, + secure = secure, + max_age = max_age, + httponly = http_only, + path = path, + serializer=serializer + ) + self.secret = secret self.cookie_name = cookie_name - self.include_ip = include_ip self.secure = secure + self.include_ip = include_ip self.timeout = timeout self.reissue_time = reissue_time self.max_age = max_age - self.http_only = http_only - self.path = path self.wild_domain = wild_domain self.parent_domain = parent_domain self.domain = domain self.hashalg = hashalg - static_flags = [] - if self.secure: - static_flags.append('; Secure') - if self.http_only: - static_flags.append('; HttpOnly') - self.static_flags = "".join(static_flags) - - def _get_cookies(self, environ, value, max_age=None): - if max_age is EXPIRE: - max_age = "; Max-Age=0; Expires=Wed, 31-Dec-97 23:59:59 GMT" - elif max_age is not None: - later = datetime.datetime.utcnow() + datetime.timedelta( - seconds=int(max_age)) - # Wdy, DD-Mon-YY HH:MM:SS GMT - expires = later.strftime('%a, %d %b %Y %H:%M:%S GMT') - # the Expires header is *required* at least for IE7 (IE7 does - # not respect Max-Age) - max_age = "; Max-Age=%s; Expires=%s" % (max_age, expires) - else: - max_age = '' - - cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME')) - - # While Chrome, IE, and Firefox can cope, Opera (at least) cannot - # cope with a port number in the cookie domain when the URL it - # receives the cookie from does not also have that port number in it - # (e.g via a proxy). In the meantime, HTTP_HOST is sent with port - # number, and neither Firefox nor Chrome do anything with the - # information when it's provided in a cookie domain except strip it - # out. So we strip out any port number from the cookie domain - # aggressively to avoid problems. See also - # https://github.com/Pylons/pyramid/issues/131 - if ':' in cur_domain: - cur_domain = cur_domain.split(':', 1)[0] - + def _get_cookies(self, request, value, max_age=None): + cur_domain = request.domain domains = [] if self.domain: @@ -892,14 +870,15 @@ class AuthTktCookieHelper(object): if self.wild_domain: domains.append('.' + cur_domain) - cookies = [] - base_cookie = '%s="%s"; Path=%s%s%s' % (self.cookie_name, value, - self.path, max_age, self.static_flags) - for domain in domains: - domain = '; Domain=%s' % domain if domain is not None else '' - cookies.append(('Set-Cookie', '%s%s' % (base_cookie, domain))) + profile = self.cookie_profile(request) - return cookies + kw = {} + kw['domains'] = domains + if max_age is not None: + kw['max_age'] = max_age + + headers = profile.get_headers(value, **kw) + return headers def identify(self, request): """ Return a dictionary with authentication information, or ``None`` @@ -968,9 +947,8 @@ class AuthTktCookieHelper(object): def forget(self, request): """ Return a set of expires Set-Cookie headers, which will destroy any existing auth_tkt cookie when attached to a response""" - environ = request.environ request._authtkt_reissue_revoked = True - return self._get_cookies(environ, '', max_age=EXPIRE) + return self._get_cookies(request, None) def remember(self, request, userid, max_age=None, tokens=()): """ Return a set of Set-Cookie headers; when set into a response, @@ -1037,7 +1015,7 @@ class AuthTktCookieHelper(object): ) cookie_value = ticket.cookie_value() - return self._get_cookies(environ, cookie_value, max_age) + return self._get_cookies(request, cookie_value, max_age) @implementer(IAuthenticationPolicy) class SessionAuthenticationPolicy(CallbackAuthenticationPolicy): @@ -1196,3 +1174,11 @@ class BasicAuthAuthenticationPolicy(CallbackAuthenticationPolicy): except ValueError: # not enough values to unpack return None return username, password + +class _SimpleSerializer(object): + def loads(self, bstruct): + return native_(bstruct) + + def dumps(self, appstruct): + return bytes_(appstruct) + diff --git a/pyramid/config/views.py b/pyramid/config/views.py index a3f885504..72dc3f414 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -44,6 +44,11 @@ from pyramid.compat import ( is_nonstr_iter ) +from pyramid.encode import ( + quote_plus, + urlencode, +) + from pyramid.exceptions import ( ConfigurationError, PredicateMismatch, @@ -65,6 +70,8 @@ from pyramid.security import NO_PERMISSION_REQUIRED from pyramid.static import static_view from pyramid.threadlocal import get_current_registry +from pyramid.url import parse_url_overrides + from pyramid.view import ( render_view_to_response, AppendSlashNotFoundViewFactory, @@ -1895,14 +1902,15 @@ class StaticURLInfo(object): kw['subpath'] = subpath return request.route_url(route_name, **kw) else: + app_url, scheme, host, port, qs, anchor = \ + parse_url_overrides(kw) parsed = url_parse(url) if not parsed.scheme: - # parsed.scheme is readonly, so we have to parse again - # to change the scheme, sigh. - url = urlparse.urlunparse(url_parse( - url, scheme=request.environ['wsgi.url_scheme'])) + url = urlparse.urlunparse(parsed._replace( + scheme=request.environ['wsgi.url_scheme'])) subpath = url_quote(subpath) - return urljoin(url, subpath) + result = urljoin(url, subpath) + return result + qs + anchor raise ValueError('No static URL definition matching %s' % path) diff --git a/pyramid/encode.py b/pyramid/encode.py index 9e190bc21..0be0107b3 100644 --- a/pyramid/encode.py +++ b/pyramid/encode.py @@ -3,11 +3,16 @@ from pyramid.compat import ( binary_type, is_nonstr_iter, url_quote as _url_quote, - url_quote_plus as quote_plus, # bw compat api (dnr) + url_quote_plus as _quote_plus, ) -def url_quote(s, safe=''): # bw compat api - return _url_quote(s, safe=safe) +def url_quote(val, safe=''): # bw compat api + cls = val.__class__ + if cls is text_type: + val = val.encode('utf-8') + elif cls is not binary_type: + val = str(val).encode('utf-8') + return _url_quote(val, safe=safe) def urlencode(query, doseq=True): """ @@ -47,28 +52,28 @@ def urlencode(query, doseq=True): prefix = '' for (k, v) in query: - k = _enc(k) + k = quote_plus(k) if is_nonstr_iter(v): for x in v: - x = _enc(x) + x = quote_plus(x) result += '%s%s=%s' % (prefix, k, x) prefix = '&' elif v is None: result += '%s%s=' % (prefix, k) else: - v = _enc(v) + v = quote_plus(v) result += '%s%s=%s' % (prefix, k, v) prefix = '&' return result -def _enc(val): +# bw compat api (dnr) +def quote_plus(val, safe=''): cls = val.__class__ if cls is text_type: val = val.encode('utf-8') elif cls is not binary_type: val = str(val).encode('utf-8') - return quote_plus(val) - + return _quote_plus(val, safe=safe) diff --git a/pyramid/i18n.py b/pyramid/i18n.py index 6ffd93e8f..aaba769c6 100644 --- a/pyramid/i18n.py +++ b/pyramid/i18n.py @@ -75,16 +75,16 @@ class Localizer(object): :term:`message identifier` objects as a singular/plural pair and an ``n`` value representing the number that appears in the message using gettext plural forms support. The ``singular`` - and ``plural`` objects passed may be translation strings or - unicode strings. ``n`` represents the number of elements. - ``domain`` is the translation domain to use to do the - pluralization, and ``mapping`` is the interpolation mapping - that should be used on the result. Note that if the objects - passed are translation strings, their domains and mappings are - ignored. The domain and mapping arguments must be used - instead. If the ``domain`` is not supplied, a default domain - is used (usually ``messages``). - + and ``plural`` objects should be unicode strings. There is no + reason to use translation string objects as arguments as all + metadata is ignored. + + ``n`` represents the number of elements. ``domain`` is the + translation domain to use to do the pluralization, and ``mapping`` + is the interpolation mapping that should be used on the result. If + the ``domain`` is not supplied, a default domain is used (usually + ``messages``). + Example:: num = 1 @@ -93,6 +93,19 @@ class Localizer(object): num, mapping={'num':num}) + If using the gettext plural support, which is required for + languages that have pluralisation rules other than n != 1, the + ``singular`` argument must be the message_id defined in the + translation file. The plural argument is not used in this case. + + Example:: + + num = 1 + translated = localizer.pluralize('item_plural', + '', + num, + mapping={'num':num}) + """ if self.pluralizer is None: diff --git a/pyramid/scripts/pshell.py b/pyramid/scripts/pshell.py index dd09bf457..12b078677 100644 --- a/pyramid/scripts/pshell.py +++ b/pyramid/scripts/pshell.py @@ -153,16 +153,12 @@ class PShellCommand(object): shell = None user_shell = self.options.python_shell.lower() if not user_shell: - shell = self.make_ipython_v0_11_shell() - if shell is None: - shell = self.make_ipython_v0_10_shell() + shell = self.make_ipython_shell() if shell is None: shell = self.make_bpython_shell() elif user_shell == 'ipython': - shell = self.make_ipython_v0_11_shell() - if shell is None: - shell = self.make_ipython_v0_10_shell() + shell = self.make_ipython_shell() elif user_shell == 'bpython': shell = self.make_bpython_shell() @@ -191,6 +187,27 @@ class PShellCommand(object): BPShell(locals_=env, banner=help + '\n') return shell + def make_ipython_shell(self): + shell = self.make_ipython_v1_1_shell() + if shell is None: + shell = self.make_ipython_v0_11_shell() + if shell is None: + shell = self.make_ipython_v0_10_shell() + return shell + + def make_ipython_v1_1_shell(self, IPShellFactory=None): + if IPShellFactory is None: # pragma: no cover + try: + from IPython.terminal.embed import ( + InteractiveShellEmbed) + IPShellFactory = InteractiveShellEmbed + except ImportError: + return None + def shell(env, help): + IPShell = IPShellFactory(banner2=help + '\n', user_ns=env) + IPShell() + return shell + def make_ipython_v0_11_shell(self, IPShellFactory=None): if IPShellFactory is None: # pragma: no cover try: diff --git a/pyramid/session.py b/pyramid/session.py index d3a4113b9..8c9900975 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -8,6 +8,8 @@ import time from zope.deprecation import deprecated from zope.interface import implementer +from webob.cookies import SignedSerializer + from pyramid.compat import ( pickle, PY3, @@ -119,9 +121,17 @@ def check_csrf_token(request, return False return True +class PickleSerializer(object): + """ A Webob cookie serializer that uses the pickle protocol to dump Python + data to bytes.""" + def loads(self, bstruct): + return pickle.loads(bstruct) + + def dumps(self, appstruct): + return pickle.dumps(appstruct, pickle.HIGHEST_PROTOCOL) + def BaseCookieSessionFactory( - serialize, - deserialize, + serializer, cookie_name='session', max_age=None, path='/', @@ -154,13 +164,11 @@ def BaseCookieSessionFactory( Parameters: - ``serialize`` - A callable accepting a Python object and returning a bytestring. A - ``ValueError`` should be raised for malformed inputs. - - ``deserialize`` - A callable accepting a bytestring and returning a Python object. A - ``ValueError`` should be raised for malformed inputs. + ``serializer`` + An object with two methods: `loads`` and ``dumps``. The ``loads`` method + should accept bytes and return a Python object. The ``dumps`` method + should accept a Python object and return bytes. A ``ValueError`` should + be raised for malformed inputs. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. @@ -238,7 +246,7 @@ def BaseCookieSessionFactory( cookieval = request.cookies.get(self._cookie_name) if cookieval is not None: try: - value = deserialize(bytes_(cookieval)) + value = serializer.loads(bytes_(cookieval)) except ValueError: # the cookie failed to deserialize, dropped value = None @@ -336,7 +344,7 @@ def BaseCookieSessionFactory( exception = getattr(self.request, 'exception', None) if exception is not None: # dont set a cookie during exceptions return False - cookieval = native_(serialize( + cookieval = native_(serializer.dumps( (self.accessed, self.created, dict(self)) )) if len(cookieval) > 4064: @@ -374,6 +382,10 @@ def UnencryptedCookieSessionFactoryConfig( """ .. deprecated:: 1.5 Use :func:`pyramid.session.SignedCookieSessionFactory` instead. + Caveat: Cookies generated using ``SignedCookieSessionFactory`` are not + compatible with cookies generated using + ``UnencryptedCookieSessionFactory``, so existing user session data will + be destroyed if you switch to it. Configure a :term:`session factory` which will provide unencrypted (but signed) cookie-based sessions. The return value of this @@ -430,9 +442,20 @@ def UnencryptedCookieSessionFactoryConfig( is valid. Default: ``signed_deserialize`` (using pickle). """ + class SerializerWrapper(object): + def __init__(self, secret): + self.secret = secret + + def loads(self, bstruct): + return signed_deserialize(bstruct, secret) + + def dumps(self, appstruct): + return signed_serialize(appstruct, secret) + + serializer = SerializerWrapper(secret) + return BaseCookieSessionFactory( - lambda v: signed_serialize(v, secret), - lambda v: signed_deserialize(v, secret), + serializer, cookie_name=cookie_name, max_age=cookie_max_age, path=cookie_path, @@ -447,7 +470,10 @@ def UnencryptedCookieSessionFactoryConfig( deprecated( 'UnencryptedCookieSessionFactoryConfig', 'The UnencryptedCookieSessionFactoryConfig callable is deprecated as of ' - 'Pyramid 1.5. Use ``pyramid.session.SignedCookieSessionFactory`` instead.' + 'Pyramid 1.5. Use ``pyramid.session.SignedCookieSessionFactory`` instead. ' + 'Caveat: Cookies generated using SignedCookieSessionFactory are not ' + 'compatible with cookies generated using UnencryptedCookieSessionFactory, ' + 'so existing user session data will be destroyed if you switch to it.' ) def SignedCookieSessionFactory( @@ -463,8 +489,7 @@ def SignedCookieSessionFactory( reissue_time=0, hashalg='sha512', salt='pyramid.session.', - serialize=None, - deserialize=None, + serializer=None, ): """ .. versionadded:: 1.5 @@ -546,53 +571,27 @@ def SignedCookieSessionFactory( If ``True``, set a session cookie even if an exception occurs while rendering a view. Default: ``True``. - ``serialize`` - A callable accepting a Python object and returning a bytestring. A - ``ValueError`` should be raised for malformed inputs. - Default: :func:`pickle.dumps`. - - ``deserialize`` - A callable accepting a bytestring and returning a Python object. A - ``ValueError`` should be raised for malformed inputs. - Default: :func:`pickle.loads`. + ``serializer`` + An object with two methods: `loads`` and ``dumps``. The ``loads`` method + should accept bytes and return a Python object. The ``dumps`` method + should accept a Python object and return bytes. A ``ValueError`` should + be raised for malformed inputs. If a serializer is not passed, the + :class:`pyramid.session.PickleSerializer` serializer will be used. .. versionadded: 1.5a3 """ + if serializer is None: + serializer = PickleSerializer() - if serialize is None: - serialize = lambda v: pickle.dumps(v, pickle.HIGHEST_PROTOCOL) - - if deserialize is None: - deserialize = pickle.loads - - digestmod = lambda string=b'': hashlib.new(hashalg, string) - digest_size = digestmod().digest_size - - salted_secret = bytes_(salt or '') + bytes_(secret) - - def signed_serialize(appstruct): - cstruct = serialize(appstruct) - sig = hmac.new(salted_secret, cstruct, digestmod).digest() - return base64.b64encode(cstruct + sig) - - def signed_deserialize(bstruct): - try: - fstruct = base64.b64decode(bstruct) - except (binascii.Error, TypeError) as e: - raise ValueError('Badly formed base64 data: %s' % e) - - cstruct = fstruct[:-digest_size] - expected_sig = fstruct[-digest_size:] - - sig = hmac.new(salted_secret, cstruct, digestmod).digest() - if strings_differ(sig, expected_sig): - raise ValueError('Invalid signature') - - return deserialize(cstruct) + signed_serializer = SignedSerializer( + secret, + salt, + hashalg, + serializer=serializer, + ) return BaseCookieSessionFactory( - signed_serialize, - signed_deserialize, + signed_serializer, cookie_name=cookie_name, max_age=max_age, path=path, diff --git a/pyramid/testing.py b/pyramid/testing.py index b3460d8aa..91dc41dd5 100644 --- a/pyramid/testing.py +++ b/pyramid/testing.py @@ -320,6 +320,7 @@ class DummyRequest( method = 'GET' application_url = 'http://example.com' host = 'example.com:80' + domain = 'example.com' content_length = 0 query_string = '' charset = 'UTF-8' diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index 3ac8f2d61..79d2a5923 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -572,7 +572,12 @@ class TestAuthTktCookieHelper(unittest.TestCase): return DummyRequest(environ, cookie=cookie) def _cookieValue(self, cookie): - return eval(cookie.value) + items = cookie.value.split('/') + D = {} + for item in items: + k, v = item.split('=', 1) + D[k] = v + return D def _parseHeaders(self, headers): return [ self._parseHeader(header) for header in headers ] @@ -838,7 +843,7 @@ class TestAuthTktCookieHelper(unittest.TestCase): request.callbacks[0](None, response) self.assertEqual(len(response.headerlist), 3) self.assertEqual(response.headerlist[0][0], 'Set-Cookie') - self.assertTrue("'tokens': ()" in response.headerlist[0][1]) + self.assertTrue("/tokens=/" in response.headerlist[0][1]) def test_remember(self): helper = self._makeOne('secret') @@ -851,11 +856,11 @@ class TestAuthTktCookieHelper(unittest.TestCase): self.assertTrue(result[0][1].startswith('auth_tkt=')) self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue(result[1][1].endswith('; Path=/; Domain=localhost')) + self.assertTrue(result[1][1].endswith('; Domain=localhost; Path=/')) self.assertTrue(result[1][1].startswith('auth_tkt=')) self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue(result[2][1].endswith('; Path=/; Domain=.localhost')) + self.assertTrue(result[2][1].endswith('; Domain=.localhost; Path=/')) self.assertTrue(result[2][1].startswith('auth_tkt=')) def test_remember_include_ip(self): @@ -869,11 +874,11 @@ class TestAuthTktCookieHelper(unittest.TestCase): self.assertTrue(result[0][1].startswith('auth_tkt=')) self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue(result[1][1].endswith('; Path=/; Domain=localhost')) + self.assertTrue(result[1][1].endswith('; Domain=localhost; Path=/')) self.assertTrue(result[1][1].startswith('auth_tkt=')) self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue(result[2][1].endswith('; Path=/; Domain=.localhost')) + self.assertTrue(result[2][1].endswith('; Domain=.localhost; Path=/')) self.assertTrue(result[2][1].startswith('auth_tkt=')) def test_remember_path(self): @@ -889,12 +894,12 @@ class TestAuthTktCookieHelper(unittest.TestCase): self.assertEqual(result[1][0], 'Set-Cookie') self.assertTrue(result[1][1].endswith( - '; Path=/cgi-bin/app.cgi/; Domain=localhost')) + '; Domain=localhost; Path=/cgi-bin/app.cgi/')) self.assertTrue(result[1][1].startswith('auth_tkt=')) self.assertEqual(result[2][0], 'Set-Cookie') self.assertTrue(result[2][1].endswith( - '; Path=/cgi-bin/app.cgi/; Domain=.localhost')) + '; Domain=.localhost; Path=/cgi-bin/app.cgi/')) self.assertTrue(result[2][1].startswith('auth_tkt=')) def test_remember_http_only(self): @@ -922,15 +927,15 @@ class TestAuthTktCookieHelper(unittest.TestCase): self.assertEqual(len(result), 3) self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue('; Secure' in result[0][1]) + self.assertTrue('; secure' in result[0][1]) self.assertTrue(result[0][1].startswith('auth_tkt=')) self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue('; Secure' in result[1][1]) + self.assertTrue('; secure' in result[1][1]) self.assertTrue(result[1][1].startswith('auth_tkt=')) self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue('; Secure' in result[2][1]) + self.assertTrue('; secure' in result[2][1]) self.assertTrue(result[2][1].startswith('auth_tkt=')) def test_remember_wild_domain_disabled(self): @@ -944,62 +949,49 @@ class TestAuthTktCookieHelper(unittest.TestCase): self.assertTrue(result[0][1].startswith('auth_tkt=')) self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue(result[1][1].endswith('; Path=/; Domain=localhost')) + self.assertTrue(result[1][1].endswith('; Domain=localhost; Path=/')) self.assertTrue(result[1][1].startswith('auth_tkt=')) def test_remember_parent_domain(self): helper = self._makeOne('secret', parent_domain=True) request = self._makeRequest() - request.environ['HTTP_HOST'] = 'www.example.com' + request.domain = 'www.example.com' result = helper.remember(request, 'other') self.assertEqual(len(result), 1) self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; Domain=.example.com')) + self.assertTrue(result[0][1].endswith('; Domain=.example.com; Path=/')) self.assertTrue(result[0][1].startswith('auth_tkt=')) def test_remember_parent_domain_supercedes_wild_domain(self): helper = self._makeOne('secret', parent_domain=True, wild_domain=True) request = self._makeRequest() - request.environ['HTTP_HOST'] = 'www.example.com' + request.domain = 'www.example.com' result = helper.remember(request, 'other') self.assertEqual(len(result), 1) - self.assertTrue(result[0][1].endswith('; Domain=.example.com')) + self.assertTrue(result[0][1].endswith('; Domain=.example.com; Path=/')) def test_remember_explicit_domain(self): helper = self._makeOne('secret', domain='pyramid.bazinga') request = self._makeRequest() - request.environ['HTTP_HOST'] = 'www.example.com' + request.domain = 'www.example.com' result = helper.remember(request, 'other') self.assertEqual(len(result), 1) self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; Domain=pyramid.bazinga')) + self.assertTrue(result[0][1].endswith( + '; Domain=pyramid.bazinga; Path=/')) self.assertTrue(result[0][1].startswith('auth_tkt=')) def test_remember_domain_supercedes_parent_and_wild_domain(self): helper = self._makeOne('secret', domain='pyramid.bazinga', parent_domain=True, wild_domain=True) request = self._makeRequest() - request.environ['HTTP_HOST'] = 'www.example.com' + request.domain = 'www.example.com' result = helper.remember(request, 'other') self.assertEqual(len(result), 1) - self.assertTrue(result[0][1].endswith('; Path=/; Domain=pyramid.bazinga')) - - def test_remember_domain_has_port(self): - helper = self._makeOne('secret', wild_domain=False) - request = self._makeRequest() - request.environ['HTTP_HOST'] = 'example.com:80' - result = helper.remember(request, 'other') - self.assertEqual(len(result), 2) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue(result[1][1].endswith('; Path=/; Domain=example.com')) - self.assertTrue(result[1][1].startswith('auth_tkt=')) + self.assertTrue(result[0][1].endswith( + '; Domain=pyramid.bazinga; Path=/')) def test_remember_binary_userid(self): import base64 @@ -1010,7 +1002,7 @@ class TestAuthTktCookieHelper(unittest.TestCase): self.assertEqual(len(result), 3) val = self._cookieValue(values[0]) self.assertEqual(val['userid'], - bytes_(base64.b64encode(b'userid').strip())) + text_(base64.b64encode(b'userid').strip())) self.assertEqual(val['user_data'], 'userid_type:b64str') def test_remember_int_userid(self): @@ -1044,7 +1036,7 @@ class TestAuthTktCookieHelper(unittest.TestCase): self.assertEqual(len(result), 3) val = self._cookieValue(values[0]) self.assertEqual(val['userid'], - base64.b64encode(userid.encode('utf-8'))) + text_(base64.b64encode(userid.encode('utf-8')))) self.assertEqual(val['user_data'], 'userid_type:b64unicode') def test_remember_insane_userid(self): @@ -1074,13 +1066,13 @@ class TestAuthTktCookieHelper(unittest.TestCase): self.assertEqual(len(result), 3) self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue("'tokens': ('foo', 'bar')" in result[0][1]) + self.assertTrue("/tokens=foo|bar/" in result[0][1]) self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue("'tokens': ('foo', 'bar')" in result[1][1]) + self.assertTrue("/tokens=foo|bar/" in result[1][1]) self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue("'tokens': ('foo', 'bar')" in result[2][1]) + self.assertTrue("/tokens=foo|bar/" in result[2][1]) def test_remember_unicode_but_ascii_token(self): helper = self._makeOne('secret') @@ -1088,7 +1080,7 @@ class TestAuthTktCookieHelper(unittest.TestCase): la = text_(b'foo', 'utf-8') result = helper.remember(request, 'other', tokens=(la,)) # tokens must be str type on both Python 2 and 3 - self.assertTrue("'tokens': ('foo',)" in result[0][1]) + self.assertTrue("/tokens=foo/" in result[0][1]) def test_remember_nonascii_token(self): helper = self._makeOne('secret') @@ -1112,18 +1104,25 @@ class TestAuthTktCookieHelper(unittest.TestCase): self.assertEqual(len(headers), 3) name, value = headers[0] self.assertEqual(name, 'Set-Cookie') - self.assertEqual(value, - 'auth_tkt=""; Path=/; Max-Age=0; Expires=Wed, 31-Dec-97 23:59:59 GMT') + self.assertEqual( + value, + 'auth_tkt=; Max-Age=0; Path=/; ' + 'expires=Wed, 31-Dec-97 23:59:59 GMT' + ) name, value = headers[1] self.assertEqual(name, 'Set-Cookie') - self.assertEqual(value, - 'auth_tkt=""; Path=/; Max-Age=0; ' - 'Expires=Wed, 31-Dec-97 23:59:59 GMT; Domain=localhost') + self.assertEqual( + value, + 'auth_tkt=; Domain=localhost; Max-Age=0; Path=/; ' + 'expires=Wed, 31-Dec-97 23:59:59 GMT' + ) name, value = headers[2] self.assertEqual(name, 'Set-Cookie') - self.assertEqual(value, - 'auth_tkt=""; Path=/; Max-Age=0; ' - 'Expires=Wed, 31-Dec-97 23:59:59 GMT; Domain=.localhost') + self.assertEqual( + value, + 'auth_tkt=; Domain=.localhost; Max-Age=0; Path=/; ' + 'expires=Wed, 31-Dec-97 23:59:59 GMT' + ) class TestAuthTicket(unittest.TestCase): def _makeOne(self, *arg, **kw): @@ -1417,7 +1416,19 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): self.assertEqual(policy.forget(None), [ ('WWW-Authenticate', 'Basic realm="SomeRealm"')]) +class TestSimpleSerializer(unittest.TestCase): + def _makeOne(self): + from pyramid.authentication import _SimpleSerializer + return _SimpleSerializer() + + def test_loads(self): + inst = self._makeOne() + self.assertEqual(inst.loads(b'abc'), text_('abc')) + def test_dumps(self): + inst = self._makeOne() + self.assertEqual(inst.dumps('abc'), bytes_('abc')) + class DummyContext: pass @@ -1429,6 +1440,7 @@ class DummyCookies(object): return self.cookie class DummyRequest: + domain = 'localhost' def __init__(self, environ=None, session=None, registry=None, cookie=None): self.environ = environ or {} self.session = session or {} @@ -1486,10 +1498,23 @@ class DummyAuthTktModule(object): self.kw = kw def cookie_value(self): - result = {'secret':self.secret, 'userid':self.userid, - 'remote_addr':self.remote_addr} + result = { + 'secret':self.secret, + 'userid':self.userid, + 'remote_addr':self.remote_addr + } result.update(self.kw) - result = repr(result) + tokens = result.pop('tokens', None) + if tokens is not None: + tokens = '|'.join(tokens) + result['tokens'] = tokens + items = sorted(result.items()) + new_items = [] + for k, v in items: + if isinstance(v, bytes): + v = text_(v) + new_items.append((k,v)) + result = '/'.join(['%s=%s' % (k, v) for k,v in new_items ]) return result self.AuthTicket = AuthTicket diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index 051961d25..57bb5e9d0 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -3820,6 +3820,27 @@ class TestStaticURLInfo(unittest.TestCase): result = inst.generate('package:path/abc def', request, a=1) self.assertEqual(result, 'http://example.com/abc%20def') + def test_generate_url_with_custom_query(self): + inst = self._makeOne() + registrations = [('http://example.com/', 'package:path/', None)] + inst._get_registrations = lambda *x: registrations + request = self._makeRequest() + result = inst.generate('package:path/abc def', request, a=1, + _query='(openlayers)') + self.assertEqual(result, + 'http://example.com/abc%20def?(openlayers)') + + def test_generate_url_with_custom_anchor(self): + inst = self._makeOne() + registrations = [('http://example.com/', 'package:path/', None)] + inst._get_registrations = lambda *x: registrations + request = self._makeRequest() + uc = text_(b'La Pe\xc3\xb1a', 'utf-8') + result = inst.generate('package:path/abc def', request, a=1, + _anchor=uc) + self.assertEqual(result, + 'http://example.com/abc%20def#La%20Pe%C3%B1a') + def test_add_already_exists(self): inst = self._makeOne() config = self._makeConfig( diff --git a/pyramid/tests/test_encode.py b/pyramid/tests/test_encode.py index 908249877..8fb766d88 100644 --- a/pyramid/tests/test_encode.py +++ b/pyramid/tests/test_encode.py @@ -72,3 +72,8 @@ class URLQuoteTests(unittest.TestCase): la = b'La/Pe\xc3\xb1a' result = self._callFUT(la, '/') self.assertEqual(result, 'La/Pe%C3%B1a') + + def test_it_with_nonstr_nonbinary(self): + la = None + result = self._callFUT(la, '/') + self.assertEqual(result, 'None') diff --git a/pyramid/tests/test_scripts/test_pshell.py b/pyramid/tests/test_scripts/test_pshell.py index 8f9f3abfb..7cb130c41 100644 --- a/pyramid/tests/test_scripts/test_pshell.py +++ b/pyramid/tests/test_scripts/test_pshell.py @@ -42,6 +42,15 @@ class TestPShellCommand(unittest.TestCase): self.assertEqual(bpython.locals_, {'foo': 'bar'}) self.assertTrue('a help message' in bpython.banner) + def test_make_ipython_v1_1_shell(self): + command = self._makeOne() + ipshell_factory = dummy.DummyIPShellFactory() + shell = command.make_ipython_v1_1_shell(ipshell_factory) + shell({'foo': 'bar'}, 'a help message') + self.assertEqual(ipshell_factory.kw['user_ns'], {'foo': 'bar'}) + self.assertTrue('a help message' in ipshell_factory.kw['banner2']) + self.assertTrue(ipshell_factory.shell.called) + def test_make_ipython_v0_11_shell(self): command = self._makeOne() ipshell_factory = dummy.DummyIPShellFactory() @@ -64,8 +73,7 @@ class TestPShellCommand(unittest.TestCase): def test_command_loads_default_shell(self): command = self._makeOne() shell = dummy.DummyShell() - command.make_ipython_v0_11_shell = lambda: None - command.make_ipython_v0_10_shell = lambda: None + command.make_ipython_shell = lambda: None command.make_bpython_shell = lambda: None command.make_default_shell = lambda: shell command.run() @@ -86,8 +94,7 @@ class TestPShellCommand(unittest.TestCase): command = self._makeOne() shell = dummy.DummyShell() bad_shell = dummy.DummyShell() - command.make_ipython_v0_11_shell = lambda: bad_shell - command.make_ipython_v0_10_shell = lambda: bad_shell + command.make_ipython_shell = lambda: bad_shell command.make_bpython_shell = lambda: bad_shell command.make_default_shell = lambda: shell command.options.python_shell = 'unknow_python_shell' @@ -106,9 +113,33 @@ class TestPShellCommand(unittest.TestCase): self.assertTrue(self.bootstrap.closer.called) self.assertTrue(shell.help) + def test_command_loads_ipython_v1_1(self): + command = self._makeOne() + shell = dummy.DummyShell() + command.make_ipython_v1_1_shell = lambda: shell + command.make_ipython_v0_11_shell = lambda: None + command.make_ipython_v0_10_shell = lambda: None + command.make_bpython_shell = lambda: None + command.make_default_shell = lambda: None + command.options.python_shell = 'ipython' + command.run() + self.assertTrue(self.config_factory.parser) + self.assertEqual(self.config_factory.parser.filename, + '/foo/bar/myapp.ini') + self.assertEqual(self.bootstrap.a[0], '/foo/bar/myapp.ini#myapp') + self.assertEqual(shell.env, { + 'app':self.bootstrap.app, 'root':self.bootstrap.root, + 'registry':self.bootstrap.registry, + 'request':self.bootstrap.request, + 'root_factory':self.bootstrap.root_factory, + }) + self.assertTrue(self.bootstrap.closer.called) + self.assertTrue(shell.help) + def test_command_loads_ipython_v0_11(self): command = self._makeOne() shell = dummy.DummyShell() + command.make_ipython_v1_1_shell = lambda: None command.make_ipython_v0_11_shell = lambda: shell command.make_ipython_v0_10_shell = lambda: None command.make_bpython_shell = lambda: None @@ -131,6 +162,7 @@ class TestPShellCommand(unittest.TestCase): def test_command_loads_ipython_v0_10(self): command = self._makeOne() shell = dummy.DummyShell() + command.make_ipython_v1_1_shell = lambda: None command.make_ipython_v0_11_shell = lambda: None command.make_ipython_v0_10_shell = lambda: shell command.make_bpython_shell = lambda: None @@ -153,8 +185,7 @@ class TestPShellCommand(unittest.TestCase): def test_command_loads_bpython_shell(self): command = self._makeOne() shell = dummy.DummyBPythonShell() - command.make_ipython_v0_11_shell = lambda: None - command.make_ipython_v0_10_shell = lambda: None + command.make_ipython_shell = lambda: None command.make_bpython_shell = lambda: shell command.options.python_shell = 'bpython' command.run() @@ -173,25 +204,34 @@ class TestPShellCommand(unittest.TestCase): def test_shell_ipython_ordering(self): command = self._makeOne() + shell1_1 = dummy.DummyShell() shell0_11 = dummy.DummyShell() shell0_10 = dummy.DummyShell() + command.make_ipython_v1_1_shell = lambda: shell1_1 + shell = command.make_shell() + self.assertEqual(shell, shell1_1) + + command.make_ipython_v1_1_shell = lambda: None command.make_ipython_v0_11_shell = lambda: shell0_11 - command.make_ipython_v0_10_shell = lambda: shell0_10 - command.make_bpython_shell = lambda: None shell = command.make_shell() self.assertEqual(shell, shell0_11) + command.make_ipython_v0_11_shell = lambda: None + command.make_ipython_v0_10_shell = lambda: shell0_10 + shell = command.make_shell() + self.assertEqual(shell, shell0_10) + command.options.python_shell = 'ipython' + command.make_ipython_v1_1_shell = lambda: shell1_1 shell = command.make_shell() - self.assertEqual(shell, shell0_11) + self.assertEqual(shell, shell1_1) def test_shell_ordering(self): command = self._makeOne() ipshell = dummy.DummyShell() bpshell = dummy.DummyShell() dshell = dummy.DummyShell() - command.make_ipython_v0_11_shell = lambda: None - command.make_ipython_v0_10_shell = lambda: None + command.make_ipython_shell = lambda: None command.make_bpython_shell = lambda: None command.make_default_shell = lambda: dshell @@ -206,7 +246,7 @@ class TestPShellCommand(unittest.TestCase): shell = command.make_shell() self.assertEqual(shell, dshell) - command.make_ipython_v0_11_shell = lambda: ipshell + command.make_ipython_shell = lambda: ipshell command.make_bpython_shell = lambda: bpshell command.options.python_shell = 'ipython' shell = command.make_shell() diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index a9f70d6a0..1ad0729b3 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -264,8 +264,8 @@ class SharedCookieSessionTests(object): class TestBaseCookieSession(SharedCookieSessionTests, unittest.TestCase): def _makeOne(self, request, **kw): from pyramid.session import BaseCookieSessionFactory - return BaseCookieSessionFactory( - dummy_serialize, dummy_deserialize, **kw)(request) + serializer = DummySerializer() + return BaseCookieSessionFactory(serializer, **kw)(request) def _serialize(self, value): return json.dumps(value) @@ -294,7 +294,7 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): digestmod = lambda: hashlib.new(hashalg) cstruct = pickle.dumps(value, pickle.HIGHEST_PROTOCOL) sig = hmac.new(salt + b'secret', cstruct, digestmod).digest() - return base64.b64encode(cstruct + sig) + return base64.urlsafe_b64encode(sig + cstruct).rstrip(b'=') def test_reissue_not_triggered(self): import time @@ -353,11 +353,12 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): import hmac import time request = testing.DummyRequest() - cstruct = dummy_serialize((time.time(), 0, {'state': 1})) + serializer = DummySerializer() + cstruct = serializer.dumps((time.time(), 0, {'state': 1})) sig = hmac.new(b'pyramid.session.secret', cstruct, sha512).digest() - cookieval = base64.b64encode(cstruct + sig) + cookieval = base64.urlsafe_b64encode(sig + cstruct).rstrip(b'=') request.cookies['session'] = cookieval - session = self._makeOne(request, deserialize=dummy_deserialize) + session = self._makeOne(request, serializer=serializer) self.assertEqual(session['state'], 1) def test_invalid_data_size(self): @@ -382,7 +383,7 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): try: result = callbacks[0](request, response) - except TypeError as e: # pragma: no cover + except TypeError: # pragma: no cover self.fail('HMAC failed to initialize due to key length.') self.assertEqual(result, None) @@ -413,8 +414,9 @@ class TestUnencryptedCookieSession(SharedCookieSessionTests, unittest.TestCase): kw.setdefault(dest, kw.pop(src)) def _serialize(self, value): + from pyramid.compat import bytes_ from pyramid.session import signed_serialize - return signed_serialize(value, 'secret') + return bytes_(signed_serialize(value, 'secret')) def test_serialize_option(self): from pyramid.response import Response @@ -596,11 +598,12 @@ class Test_check_csrf_token(unittest.TestCase): result = self._callFUT(request, 'csrf_token', raises=False) self.assertEqual(result, False) -def dummy_serialize(value): - return json.dumps(value).encode('utf-8') +class DummySerializer(object): + def dumps(self, value): + return json.dumps(value).encode('utf-8') -def dummy_deserialize(value): - return json.loads(value.decode('utf-8')) + def loads(self, value): + return json.loads(value.decode('utf-8')) class DummySessionFactory(dict): _dirty = False diff --git a/pyramid/tests/test_url.py b/pyramid/tests/test_url.py index f6117777f..0a788ba97 100644 --- a/pyramid/tests/test_url.py +++ b/pyramid/tests/test_url.py @@ -6,7 +6,6 @@ from pyramid import testing from pyramid.compat import ( text_, - native_, WIN, ) @@ -93,6 +92,14 @@ class TestURLMethodsMixin(unittest.TestCase): result = request.resource_url(context, 'a b c') self.assertEqual(result, 'http://example.com:5432/context/a%20b%20c') + def test_resource_url_with_query_str(self): + request = self._makeOne() + self._registerResourceURL(request.registry) + context = DummyContext() + result = request.resource_url(context, 'a', query='(openlayers)') + self.assertEqual(result, + 'http://example.com:5432/context/a?(openlayers)') + def test_resource_url_with_query_dict(self): request = self._makeOne() self._registerResourceURL(request.registry) @@ -149,23 +156,18 @@ class TestURLMethodsMixin(unittest.TestCase): request = self._makeOne() self._registerResourceURL(request.registry) context = DummyContext() - uc = text_(b'La Pe\xc3\xb1a', 'utf-8') + uc = text_(b'La Pe\xc3\xb1a', 'utf-8') result = request.resource_url(context, anchor=uc) - self.assertEqual( - result, - native_( - text_(b'http://example.com:5432/context/#La Pe\xc3\xb1a', - 'utf-8'), - 'utf-8') - ) + self.assertEqual(result, + 'http://example.com:5432/context/#La%20Pe%C3%B1a') - def test_resource_url_anchor_is_not_urlencoded(self): + def test_resource_url_anchor_is_urlencoded_safe(self): request = self._makeOne() self._registerResourceURL(request.registry) context = DummyContext() - result = request.resource_url(context, anchor=' /#') + result = request.resource_url(context, anchor=' /#?&+') self.assertEqual(result, - 'http://example.com:5432/context/# /#') + 'http://example.com:5432/context/#%20/%23?&+') def test_resource_url_no_IResourceURL_registered(self): # falls back to ResourceURL @@ -448,14 +450,8 @@ class TestURLMethodsMixin(unittest.TestCase): request.registry.registerUtility(mapper, IRoutesMapper) result = request.route_url('flub', _anchor=b"La Pe\xc3\xb1a") - self.assertEqual( - result, - native_( - text_( - b'http://example.com:5432/1/2/3#La Pe\xc3\xb1a', - 'utf-8'), - 'utf-8') - ) + self.assertEqual(result, + 'http://example.com:5432/1/2/3#La%20Pe%C3%B1a') def test_route_url_with_anchor_unicode(self): from pyramid.interfaces import IRoutesMapper @@ -465,14 +461,8 @@ class TestURLMethodsMixin(unittest.TestCase): anchor = text_(b'La Pe\xc3\xb1a', 'utf-8') result = request.route_url('flub', _anchor=anchor) - self.assertEqual( - result, - native_( - text_( - b'http://example.com:5432/1/2/3#La Pe\xc3\xb1a', - 'utf-8'), - 'utf-8') - ) + self.assertEqual(result, + 'http://example.com:5432/1/2/3#La%20Pe%C3%B1a') def test_route_url_with_query(self): from pyramid.interfaces import IRoutesMapper @@ -483,6 +473,15 @@ class TestURLMethodsMixin(unittest.TestCase): self.assertEqual(result, 'http://example.com:5432/1/2/3?q=1') + def test_route_url_with_query_str(self): + from pyramid.interfaces import IRoutesMapper + request = self._makeOne() + mapper = DummyRoutesMapper(route=DummyRoute('/1/2/3')) + request.registry.registerUtility(mapper, IRoutesMapper) + result = request.route_url('flub', _query='(openlayers)') + self.assertEqual(result, + 'http://example.com:5432/1/2/3?(openlayers)') + def test_route_url_with_empty_query(self): from pyramid.interfaces import IRoutesMapper request = self._makeOne() diff --git a/pyramid/url.py b/pyramid/url.py index fda2c72c7..78dd297d5 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -12,12 +12,13 @@ from pyramid.interfaces import ( ) from pyramid.compat import ( - native_, bytes_, - text_type, - url_quote, + string_types, ) -from pyramid.encode import urlencode +from pyramid.encode import ( + url_quote, + urlencode, +) from pyramid.path import caller_package from pyramid.threadlocal import get_current_registry @@ -27,6 +28,48 @@ from pyramid.traversal import ( ) PATH_SAFE = '/:@&+$,' # from webob +QUERY_SAFE = '/?:@!$&\'()*+,;=' # RFC 3986 +ANCHOR_SAFE = QUERY_SAFE + +def parse_url_overrides(kw): + """Parse special arguments passed when generating urls. + + The supplied dictionary is mutated, popping arguments as necessary. + Returns a 6-tuple of the format ``(app_url, scheme, host, port, + qs, anchor)``. + """ + anchor = '' + qs = '' + app_url = None + host = None + scheme = None + port = None + + if '_query' in kw: + query = kw.pop('_query') + if isinstance(query, string_types): + qs = '?' + url_quote(query, QUERY_SAFE) + elif query: + qs = '?' + urlencode(query, doseq=True) + + if '_anchor' in kw: + anchor = kw.pop('_anchor') + anchor = url_quote(anchor, ANCHOR_SAFE) + anchor = '#' + anchor + + if '_app_url' in kw: + app_url = kw.pop('_app_url') + + if '_host' in kw: + host = kw.pop('_host') + + if '_scheme' in kw: + scheme = kw.pop('_scheme') + + if '_port' in kw: + port = kw.pop('_port') + + return app_url, scheme, host, port, qs, anchor class URLMethodsMixin(object): """ Request methods mixin for BaseRequest having to do with URL @@ -124,18 +167,22 @@ class URLMethodsMixin(object): ``*remainder`` replacement value, it is tacked on to the URL after being URL-quoted-except-for-embedded-slashes. - If no ``_query`` keyword argument is provided, the request - query string will be returned in the URL. If it is present, it - will be used to compose a query string that will be tacked on - to the end of the URL, replacing any request query string. - The value of ``_query`` must be a sequence of two-tuples *or* - a data structure with an ``.items()`` method that returns a - sequence of two-tuples (presumably a dictionary). This data - structure will be turned into a query string per the - documentation of :func:`pyramid.encode.urlencode` function. - After the query data is turned into a query string, a leading - ``?`` is prepended, and the resulting string is appended to - the generated URL. + If no ``_query`` keyword argument is provided, the request query string + will be returned in the URL. If it is present, it will be used to + compose a query string that will be tacked on to the end of the URL, + replacing any request query string. The value of ``_query`` may be a + sequence of two-tuples *or* a data structure with an ``.items()`` + method that returns a sequence of two-tuples (presumably a dictionary). + This data structure will be turned into a query string per the + documentation of :func:`pyramid.url.urlencode` function. This will + produce a query string in the ``x-www-form-urlencoded`` format. A + non-``x-www-form-urlencoded`` query string may be used by passing a + *string* value as ``_query`` in which case it will be URL-quoted + (e.g. query="foo bar" will become "foo%20bar"). However, the result + will not need to be in ``k=v`` form as required by + ``x-www-form-urlencoded``. After the query data is turned into a query + string, a leading ``?`` is prepended, and the resulting string is + appended to the generated URL. .. note:: @@ -146,8 +193,13 @@ class URLMethodsMixin(object): as values, and a k=v pair will be placed into the query string for each value. + .. versionchanged:: 1.5 + Allow the ``_query`` option to be a string to enable alternative + encodings. + If a keyword argument ``_anchor`` is present, its string - representation will be used as a named anchor in the generated URL + representation will be quoted per :rfc:`3986#section-3.5` and used as + a named anchor in the generated URL (e.g. if ``_anchor`` is passed as ``foo`` and the route URL is ``http://example.com/route/url``, the resulting generated URL will be ``http://example.com/route/url#foo``). @@ -156,8 +208,11 @@ class URLMethodsMixin(object): If ``_anchor`` is passed as a string, it should be UTF-8 encoded. If ``_anchor`` is passed as a Unicode object, it will be converted to - UTF-8 before being appended to the URL. The anchor value is not - quoted in any way before being appended to the generated URL. + UTF-8 before being appended to the URL. + + .. versionchanged:: 1.5 + The ``_anchor`` option will be escaped instead of using + its raw string representation. If both ``_anchor`` and ``_query`` are specified, the anchor element will always follow the query element, @@ -213,34 +268,7 @@ class URLMethodsMixin(object): if route.pregenerator is not None: elements, kw = route.pregenerator(self, elements, kw) - anchor = '' - qs = '' - app_url = None - host = None - scheme = None - port = None - - if '_query' in kw: - query = kw.pop('_query') - if query: - qs = '?' + urlencode(query, doseq=True) - - if '_anchor' in kw: - anchor = kw.pop('_anchor') - anchor = native_(anchor, 'utf-8') - anchor = '#' + anchor - - if '_app_url' in kw: - app_url = kw.pop('_app_url') - - if '_host' in kw: - host = kw.pop('_host') - - if '_scheme' in kw: - scheme = kw.pop('_scheme') - - if '_port' in kw: - port = kw.pop('_port') + app_url, scheme, host, port, qs, anchor = parse_url_overrides(kw) if app_url is None: if (scheme is not None or host is not None or port is not None): @@ -331,17 +359,22 @@ class URLMethodsMixin(object): .. warning:: if no ``elements`` arguments are specified, the resource URL will end with a trailing slash. If any ``elements`` are used, the generated URL will *not* - end in trailing a slash. - - If a keyword argument ``query`` is present, it will be used to - compose a query string that will be tacked on to the end of the URL. - The value of ``query`` must be a sequence of two-tuples *or* a data - structure with an ``.items()`` method that returns a sequence of - two-tuples (presumably a dictionary). This data structure will be - turned into a query string per the documentation of - ``pyramid.url.urlencode`` function. After the query data is turned - into a query string, a leading ``?`` is prepended, and the resulting - string is appended to the generated URL. + end in a trailing slash. + + If a keyword argument ``query`` is present, it will be used to compose + a query string that will be tacked on to the end of the URL. The value + of ``query`` may be a sequence of two-tuples *or* a data structure with + an ``.items()`` method that returns a sequence of two-tuples + (presumably a dictionary). This data structure will be turned into a + query string per the documentation of :func:``pyramid.url.urlencode`` + function. This will produce a query string in the + ``x-www-form-urlencoded`` encoding. A non-``x-www-form-urlencoded`` + query string may be used by passing a *string* value as ``query`` in + which case it will be URL-quoted (e.g. query="foo bar" will become + "foo%20bar"). However, the result will not need to be in ``k=v`` form + as required by ``x-www-form-urlencoded``. After the query data is + turned into a query string, a leading ``?`` is prepended, and the + resulting string is appended to the generated URL. .. note:: @@ -352,6 +385,10 @@ class URLMethodsMixin(object): as values, and a k=v pair will be placed into the query string for each value. + .. versionchanged:: 1.5 + Allow the ``query`` option to be a string to enable alternative + encodings. + If a keyword argument ``anchor`` is present, its string representation will be used as a named anchor in the generated URL (e.g. if ``anchor`` is passed as ``foo`` and the resource URL is @@ -362,8 +399,11 @@ class URLMethodsMixin(object): If ``anchor`` is passed as a string, it should be UTF-8 encoded. If ``anchor`` is passed as a Unicode object, it will be converted to - UTF-8 before being appended to the URL. The anchor value is not - quoted in any way before being appended to the generated URL. + UTF-8 before being appended to the URL. + + .. versionchanged:: 1.5 + The ``anchor`` option will be escaped instead of using + its raw string representation. If both ``anchor`` and ``query`` are specified, the anchor element will always follow the query element, @@ -580,13 +620,14 @@ class URLMethodsMixin(object): if 'query' in kw: query = kw['query'] - if query: + if isinstance(query, string_types): + qs = '?' + url_quote(query, QUERY_SAFE) + elif query: qs = '?' + urlencode(query, doseq=True) if 'anchor' in kw: anchor = kw['anchor'] - if isinstance(anchor, text_type): - anchor = native_(anchor, 'utf-8') + anchor = url_quote(anchor, ANCHOR_SAFE) anchor = '#' + anchor if elements: @@ -39,7 +39,7 @@ except IOError: install_requires=[ 'setuptools', - 'WebOb >= 1.2b3', # request.path_info is unicode + 'WebOb >= 1.3', # request.domain and CookieProfile 'repoze.lru >= 0.4', # py3 compat 'zope.interface >= 3.8.0', # has zope.interface.registry 'zope.deprecation >= 3.5.0', # py3 compat @@ -69,7 +69,7 @@ testing_extras = tests_require + [ ] setup(name='pyramid', - version='1.5a2', + version='1.5a3', description='The Pyramid Web Framework, a Pylons project', long_description=README + '\n\n' + CHANGES, classifiers=[ |
