From bd8f73be18f8f54daff34debd976a4b81be886aa Mon Sep 17 00:00:00 2001
From: Michael Merickel
Date: Sun, 29 Dec 2019 23:29:48 -0600
Subject: update authentication and authorization chapters of the
quick_tutorial to use the new ISecurityPolicy
---
docs/quick_tutorial/authentication.rst | 30 +++++++---------
.../authentication/tutorial/__init__.py | 18 ++++------
.../authentication/tutorial/security.py | 27 +++++++++++---
docs/quick_tutorial/authorization.rst | 15 ++++++--
.../authorization/tutorial/__init__.py | 18 ++++------
.../authorization/tutorial/security.py | 41 ++++++++++++++++++++--
6 files changed, 101 insertions(+), 48 deletions(-)
diff --git a/docs/quick_tutorial/authentication.rst b/docs/quick_tutorial/authentication.rst
index cd038ea36..12eb738e2 100644
--- a/docs/quick_tutorial/authentication.rst
+++ b/docs/quick_tutorial/authentication.rst
@@ -55,16 +55,15 @@ Steps
:language: ini
:linenos:
-#. Get authentication (and for now, authorization policies) and login route
- into the :term:`configurator` in ``authentication/tutorial/__init__.py``:
+#. Create an ``authentication/tutorial/security.py`` module that can find our
+ user information by providing a :term:`security policy`:
- .. literalinclude:: authentication/tutorial/__init__.py
+ .. literalinclude:: authentication/tutorial/security.py
:linenos:
-#. Create an ``authentication/tutorial/security.py`` module that can find our
- user information by providing an *authentication policy callback*:
+#. Register the ``SecurityPolicy`` with the :term:`configurator` in ``authentication/tutorial/__init__.py``:
- .. literalinclude:: authentication/tutorial/security.py
+ .. literalinclude:: authentication/tutorial/__init__.py
:linenos:
#. Update the views in ``authentication/tutorial/views.py``:
@@ -111,14 +110,12 @@ are you) and authorization (what are you allowed to do) are not just pluggable,
but decoupled. To learn one step at a time, we provide a system that identifies
users and lets them log out.
-In this example we chose to use the bundled :ref:`AuthTktAuthenticationPolicy
-` policy. We enabled it in our configuration and
-provided a ticket-signing secret in our INI file.
+In this example we chose to use the bundled :class:`pyramid.authentication.AuthTktCookieHelper` helper to store the user's logged-in state in a cookie.
+We enabled it in our configuration and provided a ticket-signing secret in our INI file.
Our view class grew a login view. When you reached it via a ``GET`` request, it
returned a login form. When reached via ``POST``, it processed the submitted
-username and password against the "groupfinder" callable that we registered in
-the configuration.
+username and password against the ``USERS`` data store.
The function ``hash_password`` uses a one-way hashing algorithm with a salt on
the user's password via ``bcrypt``, instead of storing the password in plain
@@ -134,6 +131,9 @@ submitted password and the user's password stored in the database. If the
hashed values are equivalent, then the user is authenticated, else
authentication fails.
+Assuming the password was validated, we invoke :func:`pyramid.security.remember` to generate a cookie that is set in the response.
+Subsequent requests return that cookie and identify the user.
+
In our template, we fetched the ``logged_in`` value from the view class. We use
this to calculate the logged-in user, if any. In the template we can then
choose to show a login link to anonymous visitors or a logout link to logged-in
@@ -143,13 +143,9 @@ users.
Extra credit
============
-#. What is the difference between a user and a principal?
-
-#. Can I use a database behind my ``groupfinder`` to look up principals?
+#. Can I use a database instead of ``USERS`` to authenticate users?
#. Once I am logged in, does any user-centric information get jammed onto each
request? Use ``import pdb; pdb.set_trace()`` to answer this.
-.. seealso:: See also :ref:`security_chapter`,
- :ref:`AuthTktAuthenticationPolicy `, `bcrypt
- `_
+.. seealso:: See also :ref:`security_chapter`, :class:`pyramid.authentication.AuthTktCookieHelper`, `bcrypt `_
diff --git a/docs/quick_tutorial/authentication/tutorial/__init__.py b/docs/quick_tutorial/authentication/tutorial/__init__.py
index efc09e760..ec8a66a23 100644
--- a/docs/quick_tutorial/authentication/tutorial/__init__.py
+++ b/docs/quick_tutorial/authentication/tutorial/__init__.py
@@ -1,25 +1,21 @@
-from pyramid.authentication import AuthTktAuthenticationPolicy
-from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.config import Configurator
-from .security import groupfinder
+from .security import SecurityPolicy
def main(global_config, **settings):
config = Configurator(settings=settings)
config.include('pyramid_chameleon')
- # Security policies
- authn_policy = AuthTktAuthenticationPolicy(
- settings['tutorial.secret'], callback=groupfinder,
- hashalg='sha512')
- authz_policy = ACLAuthorizationPolicy()
- config.set_authentication_policy(authn_policy)
- config.set_authorization_policy(authz_policy)
+ config.set_security_policy(
+ SecurityPolicy(
+ secret=settings['tutorial.secret'],
+ ),
+ )
config.add_route('home', '/')
config.add_route('hello', '/howdy')
config.add_route('login', '/login')
config.add_route('logout', '/logout')
config.scan('.views')
- return config.make_wsgi_app()
\ No newline at end of file
+ return config.make_wsgi_app()
diff --git a/docs/quick_tutorial/authentication/tutorial/security.py b/docs/quick_tutorial/authentication/tutorial/security.py
index e585e2642..acec06e7a 100644
--- a/docs/quick_tutorial/authentication/tutorial/security.py
+++ b/docs/quick_tutorial/authentication/tutorial/security.py
@@ -1,4 +1,5 @@
import bcrypt
+from pyramid.authentication import AuthTktCookieHelper
def hash_password(pw):
@@ -12,9 +13,27 @@ def check_password(pw, hashed_pw):
USERS = {'editor': hash_password('editor'),
'viewer': hash_password('viewer')}
-GROUPS = {'editor': ['group:editors']}
-def groupfinder(userid, request):
- if userid in USERS:
- return GROUPS.get(userid, [])
\ No newline at end of file
+class SecurityPolicy:
+ def __init__(self, secret):
+ self.authtkt = AuthTktCookieHelper(
+ secret=secret,
+ hashalg='sha512',
+ )
+
+ def identify(self, request):
+ identity = self.authtkt.identify(request)
+ if identity is not None and identity['userid'] in USERS:
+ return identity
+
+ def authenticated_userid(self, request):
+ identity = self.identify(request)
+ if identity is not None:
+ return identity['userid']
+
+ def remember(self, request, userid, **kw):
+ return self.authtkt.remember(request, userid, **kw)
+
+ def forget(self, request, **kw):
+ return self.authtkt.forget(request, **kw)
diff --git a/docs/quick_tutorial/authorization.rst b/docs/quick_tutorial/authorization.rst
index e80f88c51..d32a1061c 100644
--- a/docs/quick_tutorial/authorization.rst
+++ b/docs/quick_tutorial/authorization.rst
@@ -55,6 +55,11 @@ Steps
.. literalinclude:: authorization/tutorial/resources.py
:linenos:
+#. Define a ``GROUPS`` data store and the ``permits`` method of our ``SecurityPolicy``:
+
+ .. literalinclude:: authorization/tutorial/security.py
+ :linenos:
+
#. Change ``authorization/tutorial/views.py`` to require the ``edit``
permission on the ``hello`` view and implement the forbidden view:
@@ -87,8 +92,10 @@ This simple tutorial step can be boiled down to the following:
- This ACL says that the ``edit`` permission is available on ``Root`` to the
``group:editors`` *principal*.
-- The registered ``groupfinder`` answers whether a particular user (``editor``)
- has a particular group (``group:editors``).
+- The ``SecurityPolicy.effective_principals`` method answers whether a particular user (``editor``) has a particular group (``group:editors``).
+
+- The ``SecurityPolicy.permits`` method is invoked when Pyramid wants to know whether the user is allowed to do something.
+ To do this, it uses the :class:`pyramid.authorization.ACLHelper` to inspect the ACL on the ``context`` and determine if the request is allowed or denied the specific permission.
In summary, ``hello`` wants ``edit`` permission, ``Root`` says
``group:editors`` has ``edit`` permission.
@@ -105,6 +112,10 @@ Pyramid that the ``login`` view should be used by decorating the view with
Extra credit
============
+#. What is the difference between a user and a principal?
+
+#. Can I use a database instead of the ``GROUPS`` data store to look up principals?
+
#. Do I have to put a ``renderer`` in my ``@forbidden_view_config`` decorator?
#. Perhaps you would like the experience of not having enough permissions
diff --git a/docs/quick_tutorial/authorization/tutorial/__init__.py b/docs/quick_tutorial/authorization/tutorial/__init__.py
index 8f7ab8277..255bb35ac 100644
--- a/docs/quick_tutorial/authorization/tutorial/__init__.py
+++ b/docs/quick_tutorial/authorization/tutorial/__init__.py
@@ -1,8 +1,6 @@
-from pyramid.authentication import AuthTktAuthenticationPolicy
-from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.config import Configurator
-from .security import groupfinder
+from .security import SecurityPolicy
def main(global_config, **settings):
@@ -10,17 +8,15 @@ def main(global_config, **settings):
root_factory='.resources.Root')
config.include('pyramid_chameleon')
- # Security policies
- authn_policy = AuthTktAuthenticationPolicy(
- settings['tutorial.secret'], callback=groupfinder,
- hashalg='sha512')
- authz_policy = ACLAuthorizationPolicy()
- config.set_authentication_policy(authn_policy)
- config.set_authorization_policy(authz_policy)
+ config.set_security_policy(
+ SecurityPolicy(
+ secret=settings['tutorial.secret'],
+ ),
+ )
config.add_route('home', '/')
config.add_route('hello', '/howdy')
config.add_route('login', '/login')
config.add_route('logout', '/logout')
config.scan('.views')
- return config.make_wsgi_app()
\ No newline at end of file
+ return config.make_wsgi_app()
diff --git a/docs/quick_tutorial/authorization/tutorial/security.py b/docs/quick_tutorial/authorization/tutorial/security.py
index e585e2642..a968f680d 100644
--- a/docs/quick_tutorial/authorization/tutorial/security.py
+++ b/docs/quick_tutorial/authorization/tutorial/security.py
@@ -1,4 +1,7 @@
import bcrypt
+from pyramid.authentication import AuthTktCookieHelper
+from pyramid.authorization import ACLHelper
+from pyramid.security import Authenticated, Everyone
def hash_password(pw):
@@ -15,6 +18,38 @@ USERS = {'editor': hash_password('editor'),
GROUPS = {'editor': ['group:editors']}
-def groupfinder(userid, request):
- if userid in USERS:
- return GROUPS.get(userid, [])
\ No newline at end of file
+class SecurityPolicy:
+ def __init__(self, secret):
+ self.authtkt = AuthTktCookieHelper(
+ secret=secret,
+ hashalg='sha512',
+ )
+ self.acl = ACLHelper()
+
+ def identify(self, request):
+ identity = self.authtkt.identify(request)
+ if identity is not None and identity['userid'] in USERS:
+ return identity
+
+ def authenticated_userid(self, request):
+ identity = self.identify(request)
+ if identity is not None:
+ return identity['userid']
+
+ def remember(self, request, userid, **kw):
+ return self.authtkt.remember(request, userid, **kw)
+
+ def forget(self, request, **kw):
+ return self.authtkt.forget(request, **kw)
+
+ def permits(self, request, context, permission):
+ principals = self.effective_principals(request)
+ return self.acl.permits(context, principals, permission)
+
+ def effective_principals(self, request):
+ principals = [Everyone]
+ userid = self.authenticated_userid(request)
+ if userid is not None:
+ principals += [Authenticated, 'u:' + userid]
+ principals += GROUPS.get(userid, [])
+ return principals
--
cgit v1.2.3
From 25439c2dbd4ff971e2a32ac96fc893de0bdcefd3 Mon Sep 17 00:00:00 2001
From: Michael Merickel
Date: Mon, 30 Dec 2019 13:29:25 -0600
Subject: rename identify(request) to authenticated_identity(request)
---
docs/narr/security.rst | 14 +++++++-------
docs/quick_tutorial/authentication/tutorial/security.py | 4 ++--
docs/quick_tutorial/authorization/tutorial/security.py | 4 ++--
src/pyramid/config/testing.py | 4 ++--
src/pyramid/interfaces.py | 10 +++++-----
src/pyramid/security.py | 4 ++--
src/pyramid/testing.py | 2 +-
tests/pkgs/securityapp/__init__.py | 2 +-
tests/test_security.py | 4 ++--
tests/test_testing.py | 4 ++--
10 files changed, 26 insertions(+), 26 deletions(-)
diff --git a/docs/narr/security.rst b/docs/narr/security.rst
index ac64cba0a..e3820ce19 100644
--- a/docs/narr/security.rst
+++ b/docs/narr/security.rst
@@ -69,7 +69,7 @@ A simple security policy might look like the following:
from pyramid.security import Allowed, Denied
class SessionSecurityPolicy:
- def identify(self, request):
+ def authenticated_identity(self, request):
""" Return app-specific user object. """
userid = request.session.get('userid')
if userid is None:
@@ -78,14 +78,14 @@ A simple security policy might look like the following:
def authenticated_userid(self, request):
""" Return a string ID for the user. """
- identity = self.identify(request)
+ identity = self.authenticated_identity(request)
if identity is None:
return None
return string(identity.id)
def permits(self, request, context, permission):
""" Allow access to everything if signed in. """
- identity = self.identify(request)
+ identity = self.authenticated_identity(request)
if identity is not None:
return Allowed('User is signed in.')
else:
@@ -144,7 +144,7 @@ For example, our above security policy can leverage these helpers like so:
def __init__(self):
self.helper = SessionAuthenticationHelper()
- def identify(self, request):
+ def authenticated_identity(self, request):
""" Return app-specific user object. """
userid = self.helper.authenticated_userid(request)
if userid is None:
@@ -153,14 +153,14 @@ For example, our above security policy can leverage these helpers like so:
def authenticated_userid(self, request):
""" Return a string ID for the user. """
- identity = self.identify(request)
+ identity = self.authenticated_identity(request)
if identity is None:
return None
return str(identity.id)
def permits(self, request, context, permission):
""" Allow access to everything if signed in. """
- identity = self.identify(request)
+ identity = self.authenticated_identity(request)
if identity is not None:
return Allowed('User is signed in.')
else:
@@ -249,7 +249,7 @@ might look like so:
class SecurityPolicy:
def permits(self, request, context, permission):
- identity = self.identify(request)
+ identity = self.authenticated_identity(request)
if identity is None:
return Denied('User is not signed in.')
diff --git a/docs/quick_tutorial/authentication/tutorial/security.py b/docs/quick_tutorial/authentication/tutorial/security.py
index acec06e7a..e8d323ea7 100644
--- a/docs/quick_tutorial/authentication/tutorial/security.py
+++ b/docs/quick_tutorial/authentication/tutorial/security.py
@@ -22,13 +22,13 @@ class SecurityPolicy:
hashalg='sha512',
)
- def identify(self, request):
+ def authenticated_identity(self, request):
identity = self.authtkt.identify(request)
if identity is not None and identity['userid'] in USERS:
return identity
def authenticated_userid(self, request):
- identity = self.identify(request)
+ identity = self.authenticated_identity(request)
if identity is not None:
return identity['userid']
diff --git a/docs/quick_tutorial/authorization/tutorial/security.py b/docs/quick_tutorial/authorization/tutorial/security.py
index a968f680d..a004a20f2 100644
--- a/docs/quick_tutorial/authorization/tutorial/security.py
+++ b/docs/quick_tutorial/authorization/tutorial/security.py
@@ -26,13 +26,13 @@ class SecurityPolicy:
)
self.acl = ACLHelper()
- def identify(self, request):
+ def authenticated_identity(self, request):
identity = self.authtkt.identify(request)
if identity is not None and identity['userid'] in USERS:
return identity
def authenticated_userid(self, request):
- identity = self.identify(request)
+ identity = self.authenticated_identity(request)
if identity is not None:
return identity['userid']
diff --git a/src/pyramid/config/testing.py b/src/pyramid/config/testing.py
index 58b239232..f8d81f3d3 100644
--- a/src/pyramid/config/testing.py
+++ b/src/pyramid/config/testing.py
@@ -35,8 +35,8 @@ class TestingConfiguratorMixin(object):
:attr:`pyramid.request.Request.authenticated_userid` will have this
value as well.
:type userid: str
- :param identity: If provided, the policy's ``identify`` method will
- return this value. As a result,
+ :param identity: If provided, the policy's ``authenticated_identity``
+ method will return this value. As a result,
:attr:`pyramid.request.Request.authenticated_identity`` will have
this value.
:type identity: object
diff --git a/src/pyramid/interfaces.py b/src/pyramid/interfaces.py
index c4160cc2b..1f089216f 100644
--- a/src/pyramid/interfaces.py
+++ b/src/pyramid/interfaces.py
@@ -483,16 +483,16 @@ class IViewMapperFactory(Interface):
class ISecurityPolicy(Interface):
+ def authenticated_identity(request):
+ """ Return the :term:`identity` of the current user. The object can be
+ of any shape, such as a simple ID string or an ORM object.
+ """
+
def authenticated_userid(request):
""" Return a :term:`userid` string identifying the trusted and
verified user, or ``None`` if unauthenticated.
"""
- def identify(request):
- """ Return the :term:`identity` of the current user. The object can be
- of any shape, such as a simple ID string or an ORM object.
- """
-
def permits(request, context, permission):
""" Return an instance of :class:`pyramid.security.Allowed` if a user
of the given identity is allowed the ``permission`` in the current
diff --git a/src/pyramid/security.py b/src/pyramid/security.py
index 8a7985a52..dc4713368 100644
--- a/src/pyramid/security.py
+++ b/src/pyramid/security.py
@@ -301,7 +301,7 @@ class SecurityAPIMixin:
policy = _get_security_policy(self)
if policy is None:
return None
- return policy.identify(self)
+ return policy.authenticated_identity(self)
@property
def authenticated_userid(self):
@@ -432,7 +432,7 @@ class LegacySecurityPolicy:
def _get_authz_policy(self, request):
return request.registry.getUtility(IAuthorizationPolicy)
- def identify(self, request):
+ def authenticated_identity(self, request):
return self.authenticated_userid(request)
def authenticated_userid(self, request):
diff --git a/src/pyramid/testing.py b/src/pyramid/testing.py
index a92bb5d03..251e1fcc2 100644
--- a/src/pyramid/testing.py
+++ b/src/pyramid/testing.py
@@ -58,7 +58,7 @@ class DummySecurityPolicy(object):
self.remember_result = remember_result
self.forget_result = forget_result
- def identify(self, request):
+ def authenticated_identity(self, request):
return self.identity
def authenticated_userid(self, request):
diff --git a/tests/pkgs/securityapp/__init__.py b/tests/pkgs/securityapp/__init__.py
index 6c9025e7d..facc37878 100644
--- a/tests/pkgs/securityapp/__init__.py
+++ b/tests/pkgs/securityapp/__init__.py
@@ -3,7 +3,7 @@ from pyramid.security import Allowed, Denied
class SecurityPolicy:
- def identify(self, request):
+ def authenticated_identity(self, request):
raise NotImplementedError() # pragma: no cover
def authenticated_userid(self, request):
diff --git a/tests/test_security.py b/tests/test_security.py
index fa3d165ea..db5861562 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -479,7 +479,7 @@ class TestLegacySecurityPolicy(unittest.TestCase):
policy = LegacySecurityPolicy()
_registerAuthenticationPolicy(request.registry, 'userid')
- self.assertEqual(policy.identify(request), 'userid')
+ self.assertEqual(policy.authenticated_identity(request), 'userid')
def test_remember(self):
from pyramid.security import LegacySecurityPolicy
@@ -532,7 +532,7 @@ class DummySecurityPolicy:
def __init__(self, result):
self.result = result
- def identify(self, request):
+ def authenticated_identity(self, request):
return self.result
def authenticated_userid(self, request):
diff --git a/tests/test_testing.py b/tests/test_testing.py
index be519cd15..31c33cafe 100644
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -27,9 +27,9 @@ class TestDummySecurityPolicy(unittest.TestCase):
klass = self._getTargetClass()
return klass(userid, identity, permissive)
- def test_identify(self):
+ def test_authenticated_identity(self):
policy = self._makeOne('user', 'identity')
- self.assertEqual(policy.identify(None), 'identity')
+ self.assertEqual(policy.authenticated_identity(None), 'identity')
def test_authenticated_userid(self):
policy = self._makeOne('user')
--
cgit v1.2.3
From 4255eecf1544731a7200ab0a24671195416601e2 Mon Sep 17 00:00:00 2001
From: Michael Merickel
Date: Tue, 31 Dec 2019 16:38:44 -0600
Subject: change hashalg on AuthTktCookieHelper to sha512.
---
CHANGES.rst | 4 +
docs/narr/security.rst | 2 +-
.../authentication/tutorial/security.py | 5 +-
.../authorization/tutorial/security.py | 5 +-
src/pyramid/authentication.py | 163 +++++++++++++++++++--
5 files changed, 156 insertions(+), 23 deletions(-)
diff --git a/CHANGES.rst b/CHANGES.rst
index 383906e00..650e7a34f 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -156,6 +156,10 @@ Backward Incompatibilities
``require_csrf`` view option to enable automatic CSRF checking.
See https://github.com/Pylons/pyramid/pull/3521
+- Changed the ``hashalg`` on ``pyramid.authentication.AuthTktCookieHelper`` to
+ ``sha512``.
+ See https://github.com/Pylons/pyramid/pull/3557
+
Documentation Changes
---------------------
diff --git a/docs/narr/security.rst b/docs/narr/security.rst
index e3820ce19..72c2721f6 100644
--- a/docs/narr/security.rst
+++ b/docs/narr/security.rst
@@ -698,7 +698,7 @@ A "secret" is required by various components of Pyramid. For example, the
helper below might be used for a security policy and uses a secret value
``seekrit``::
- helper = AuthTktCookieHelper('seekrit', hashalg='sha512')
+ helper = AuthTktCookieHelper('seekrit')
A :term:`session factory` also requires a secret::
diff --git a/docs/quick_tutorial/authentication/tutorial/security.py b/docs/quick_tutorial/authentication/tutorial/security.py
index e8d323ea7..8324000ed 100644
--- a/docs/quick_tutorial/authentication/tutorial/security.py
+++ b/docs/quick_tutorial/authentication/tutorial/security.py
@@ -17,10 +17,7 @@ USERS = {'editor': hash_password('editor'),
class SecurityPolicy:
def __init__(self, secret):
- self.authtkt = AuthTktCookieHelper(
- secret=secret,
- hashalg='sha512',
- )
+ self.authtkt = AuthTktCookieHelper(secret=secret)
def authenticated_identity(self, request):
identity = self.authtkt.identify(request)
diff --git a/docs/quick_tutorial/authorization/tutorial/security.py b/docs/quick_tutorial/authorization/tutorial/security.py
index a004a20f2..5b3e04a5f 100644
--- a/docs/quick_tutorial/authorization/tutorial/security.py
+++ b/docs/quick_tutorial/authorization/tutorial/security.py
@@ -20,10 +20,7 @@ GROUPS = {'editor': ['group:editors']}
class SecurityPolicy:
def __init__(self, secret):
- self.authtkt = AuthTktCookieHelper(
- secret=secret,
- hashalg='sha512',
- )
+ self.authtkt = AuthTktCookieHelper(secret=secret)
self.acl = ACLHelper()
def authenticated_identity(self, request):
diff --git a/src/pyramid/authentication.py b/src/pyramid/authentication.py
index 500a84646..14a4ad210 100644
--- a/src/pyramid/authentication.py
+++ b/src/pyramid/authentication.py
@@ -428,9 +428,148 @@ class RemoteUserAuthenticationPolicy(CallbackAuthenticationPolicy):
@implementer(IAuthenticationPolicy)
class AuthTktAuthenticationPolicy(CallbackAuthenticationPolicy):
"""A :app:`Pyramid` :term:`authentication policy` which
- obtains data from a Pyramid "auth ticket" cookie. See
- :class:`.AuthTktCookieHelper` for documentation of the constructor
- arguments.
+ obtains data from a Pyramid "auth ticket" cookie.
+
+ Constructor Arguments
+
+ ``secret``
+
+ The secret (a string) used for auth_tkt cookie signing. This value
+ should be unique across all values provided to Pyramid for various
+ subsystem secrets (see :ref:`admonishment_against_secret_sharing`).
+ Required.
+
+ ``callback``
+
+ Default: ``None``. A callback passed the userid and the
+ request, expected to return ``None`` if the userid doesn't
+ exist or a sequence of principal identifiers (possibly empty) if
+ the user does exist. If ``callback`` is ``None``, the userid
+ will be assumed to exist with no principals. Optional.
+
+ ``cookie_name``
+
+ Default: ``auth_tkt``. The cookie name used
+ (string). Optional.
+
+ ``secure``
+
+ Default: ``False``. Only send the cookie back over a secure
+ conn. Optional.
+
+ ``include_ip``
+
+ Default: ``False``. Make the requesting IP address part of
+ the authentication data in the cookie. Optional.
+
+ For IPv6 this option is not recommended. The ``mod_auth_tkt``
+ specification does not specify how to handle IPv6 addresses, so using
+ this option in combination with IPv6 addresses may cause an
+ incompatible cookie. It ties the authentication ticket to that
+ individual's IPv6 address.
+
+ ``timeout``
+
+ Default: ``None``. Maximum number of seconds which a newly
+ issued ticket will be considered valid. After this amount of
+ time, the ticket will expire (effectively logging the user
+ out). If this value is ``None``, the ticket never expires.
+ Optional.
+
+ ``reissue_time``
+
+ Default: ``None``. If this parameter is set, it represents the number
+ of seconds that must pass before an authentication token cookie is
+ automatically reissued as the result of a request which requires
+ authentication. The duration is measured as the number of seconds
+ since the last auth_tkt cookie was issued and 'now'. If this value is
+ ``0``, a new ticket cookie will be reissued on every request which
+ requires authentication.
+
+ A good rule of thumb: if you want auto-expired cookies based on
+ inactivity: set the ``timeout`` value to 1200 (20 mins) and set the
+ ``reissue_time`` value to perhaps a tenth of the ``timeout`` value
+ (120 or 2 mins). It's nonsensical to set the ``timeout`` value lower
+ than the ``reissue_time`` value, as the ticket will never be reissued
+ if so. However, such a configuration is not explicitly prevented.
+
+ Optional.
+
+ ``max_age``
+
+ Default: ``None``. The max age of the auth_tkt cookie, in
+ seconds. This differs from ``timeout`` inasmuch as ``timeout``
+ represents the lifetime of the ticket contained in the cookie,
+ while this value represents the lifetime of the cookie itself.
+ When this value is set, the cookie's ``Max-Age`` and
+ ``Expires`` settings will be set, allowing the auth_tkt cookie
+ to last between browser sessions. It is typically nonsensical
+ to set this to a value that is lower than ``timeout`` or
+ ``reissue_time``, although it is not explicitly prevented.
+ Optional.
+
+ ``path``
+
+ Default: ``/``. The path for which the auth_tkt cookie is valid.
+ May be desirable if the application only serves part of a domain.
+ Optional.
+
+ ``http_only``
+
+ Default: ``False``. Hide cookie from JavaScript by setting the
+ HttpOnly flag. Not honored by all browsers.
+ Optional.
+
+ ``wild_domain``
+
+ Default: ``True``. An auth_tkt cookie will be generated for the
+ wildcard domain. If your site is hosted as ``example.com`` this
+ will make the cookie available for sites underneath ``example.com``
+ such as ``www.example.com``.
+ Optional.
+
+ ``parent_domain``
+
+ Default: ``False``. An auth_tkt cookie will be generated for the
+ parent domain of the current site. For example if your site is
+ hosted under ``www.example.com`` a cookie will be generated for
+ ``.example.com``. This can be useful if you have multiple sites
+ sharing the same domain. This option supercedes the ``wild_domain``
+ option.
+ Optional.
+
+ ``domain``
+
+ Default: ``None``. If provided the auth_tkt cookie will only be
+ set for this domain. This option is not compatible with ``wild_domain``
+ and ``parent_domain``.
+ Optional.
+
+ ``hashalg``
+
+ Default: ``sha512`` (the literal string).
+
+ Any hash algorithm supported by Python's ``hashlib.new()`` function
+ can be used as the ``hashalg``.
+
+ Cookies generated by different instances of AuthTktAuthenticationPolicy
+ using different ``hashalg`` options are not compatible. Switching the
+ ``hashalg`` will imply that all existing users with a valid cookie will
+ be required to re-login.
+
+ Optional.
+
+ ``debug``
+
+ Default: ``False``. If ``debug`` is ``True``, log messages to the
+ Pyramid debug logger about the results of various authentication
+ steps. The output from debugging is useful for reporting to maillist
+ or IRC channels when asking for support.
+
+ ``samesite``
+
+ Default: ``'Lax'``. The 'samesite' option of the session cookie. Set
+ the value to ``None`` to turn off the samesite option.
.. versionchanged:: 1.4
@@ -694,14 +833,6 @@ class AuthTktCookieHelper(object):
subsystem secrets (see :ref:`admonishment_against_secret_sharing`).
Required.
- ``callback``
-
- Default: ``None``. A callback passed the userid and the
- request, expected to return ``None`` if the userid doesn't
- exist or a sequence of principal identifiers (possibly empty) if
- the user does exist. If ``callback`` is ``None``, the userid
- will be assumed to exist with no principals. Optional.
-
``cookie_name``
Default: ``auth_tkt``. The cookie name used
@@ -819,12 +950,16 @@ class AuthTktCookieHelper(object):
Default: ``False``. If ``debug`` is ``True``, log messages to the
Pyramid debug logger about the results of various authentication
steps. The output from debugging is useful for reporting to maillist
- or IRC channels when asking for support.
+ or IRC channels when asking for support. Optional.
``samesite``
Default: ``'Lax'``. The 'samesite' option of the session cookie. Set
- the value to ``None`` to turn off the samesite option.
+ the value to ``None`` to turn off the samesite option. Optional.
+
+ .. versionchanged:: 2.0
+
+ The default ``hashalg`` was changed from ``md5`` to ``sha512``.
"""
@@ -858,7 +993,7 @@ class AuthTktCookieHelper(object):
http_only=False,
path="/",
wild_domain=True,
- hashalg='md5',
+ hashalg='sha512',
parent_domain=False,
domain=None,
samesite='Lax',
--
cgit v1.2.3
From 4d7c139e21a7e4529c68560b2ea3d35ff7bdceb0 Mon Sep 17 00:00:00 2001
From: Michael Merickel
Date: Tue, 31 Dec 2019 16:41:20 -0600
Subject: apply grammar suggestion
---
docs/quick_tutorial/authorization.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/quick_tutorial/authorization.rst b/docs/quick_tutorial/authorization.rst
index d32a1061c..b1ef86a17 100644
--- a/docs/quick_tutorial/authorization.rst
+++ b/docs/quick_tutorial/authorization.rst
@@ -92,7 +92,7 @@ This simple tutorial step can be boiled down to the following:
- This ACL says that the ``edit`` permission is available on ``Root`` to the
``group:editors`` *principal*.
-- The ``SecurityPolicy.effective_principals`` method answers whether a particular user (``editor``) has a particular group (``group:editors``).
+- The ``SecurityPolicy.effective_principals`` method answers whether a particular user (``editor``) is a member of a particular group (``group:editors``).
- The ``SecurityPolicy.permits`` method is invoked when Pyramid wants to know whether the user is allowed to do something.
To do this, it uses the :class:`pyramid.authorization.ACLHelper` to inspect the ACL on the ``context`` and determine if the request is allowed or denied the specific permission.
--
cgit v1.2.3
From 7820b922cc1b87147cc60288dff0bbdfd7b5bc8a Mon Sep 17 00:00:00 2001
From: Michael Merickel
Date: Tue, 31 Dec 2019 17:08:10 -0600
Subject: be more specific
---
CHANGES.rst | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CHANGES.rst b/CHANGES.rst
index 650e7a34f..9f16b06ea 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -156,8 +156,8 @@ Backward Incompatibilities
``require_csrf`` view option to enable automatic CSRF checking.
See https://github.com/Pylons/pyramid/pull/3521
-- Changed the ``hashalg`` on ``pyramid.authentication.AuthTktCookieHelper`` to
- ``sha512``.
+- Changed the default ``hashalg`` on
+ ``pyramid.authentication.AuthTktCookieHelper`` to ``sha512``.
See https://github.com/Pylons/pyramid/pull/3557
Documentation Changes
--
cgit v1.2.3
From ef5b4019633b8ca383df75bdf517932ee23f304e Mon Sep 17 00:00:00 2001
From: Michael Merickel
Date: Sat, 4 Jan 2020 13:17:39 -0600
Subject: clarify that new system is coupled
---
docs/quick_tutorial/authentication.rst | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/quick_tutorial/authentication.rst b/docs/quick_tutorial/authentication.rst
index 12eb738e2..3f6df17de 100644
--- a/docs/quick_tutorial/authentication.rst
+++ b/docs/quick_tutorial/authentication.rst
@@ -106,8 +106,8 @@ Analysis
Unlike many web frameworks, Pyramid includes a built-in but optional security
model for authentication and authorization. This security system is intended to
be flexible and support many needs. In this security model, authentication (who
-are you) and authorization (what are you allowed to do) are not just pluggable,
-but decoupled. To learn one step at a time, we provide a system that identifies
+are you) and authorization (what are you allowed to do) are pluggable.
+To learn one step at a time, we provide a system that identifies
users and lets them log out.
In this example we chose to use the bundled :class:`pyramid.authentication.AuthTktCookieHelper` helper to store the user's logged-in state in a cookie.
--
cgit v1.2.3
From a4b0781604fd217341cc43eec47a95c725860ced Mon Sep 17 00:00:00 2001
From: Michael Merickel
Date: Mon, 6 Jan 2020 22:57:34 -0600
Subject: sync basiclayout, installation, models with new structure
---
docs/tutorials/wiki2/basiclayout.rst | 14 +--
docs/tutorials/wiki2/definingmodels.rst | 10 +-
docs/tutorials/wiki2/installation.rst | 53 ++++-----
docs/tutorials/wiki2/src/basiclayout/.gitignore | 1 +
docs/tutorials/wiki2/src/basiclayout/testing.ini | 79 +++++++++++++
.../wiki2/src/basiclayout/tests/conftest.py | 125 +++++++++++++++++++++
.../wiki2/src/basiclayout/tests/test_functional.py | 13 +++
.../wiki2/src/basiclayout/tests/test_it.py | 66 -----------
.../wiki2/src/basiclayout/tests/test_views.py | 23 ++++
.../wiki2/src/basiclayout/tutorial/__init__.py | 2 +-
.../src/basiclayout/tutorial/models/__init__.py | 22 ++--
.../src/basiclayout/tutorial/views/default.py | 7 +-
docs/tutorials/wiki2/src/installation/.gitignore | 1 +
docs/tutorials/wiki2/src/installation/testing.ini | 79 +++++++++++++
.../wiki2/src/installation/tests/conftest.py | 125 +++++++++++++++++++++
.../src/installation/tests/test_functional.py | 13 +++
.../wiki2/src/installation/tests/test_it.py | 66 -----------
.../wiki2/src/installation/tests/test_views.py | 23 ++++
.../wiki2/src/installation/tutorial/__init__.py | 2 +-
.../src/installation/tutorial/models/__init__.py | 22 ++--
.../src/installation/tutorial/views/default.py | 7 +-
docs/tutorials/wiki2/src/models/.gitignore | 1 +
docs/tutorials/wiki2/src/models/setup.py | 2 +-
docs/tutorials/wiki2/src/models/testing.ini | 79 +++++++++++++
docs/tutorials/wiki2/src/models/tests/conftest.py | 125 +++++++++++++++++++++
.../wiki2/src/models/tests/test_functional.py | 13 +++
docs/tutorials/wiki2/src/models/tests/test_it.py | 66 -----------
.../tutorials/wiki2/src/models/tests/test_views.py | 23 ++++
.../wiki2/src/models/tutorial/__init__.py | 2 +-
.../wiki2/src/models/tutorial/models/__init__.py | 22 ++--
.../src/models/tutorial/scripts/initialize_db.py | 4 +
.../wiki2/src/models/tutorial/views/default.py | 7 +-
32 files changed, 827 insertions(+), 270 deletions(-)
create mode 100644 docs/tutorials/wiki2/src/basiclayout/testing.ini
create mode 100644 docs/tutorials/wiki2/src/basiclayout/tests/conftest.py
create mode 100644 docs/tutorials/wiki2/src/basiclayout/tests/test_functional.py
delete mode 100644 docs/tutorials/wiki2/src/basiclayout/tests/test_it.py
create mode 100644 docs/tutorials/wiki2/src/basiclayout/tests/test_views.py
create mode 100644 docs/tutorials/wiki2/src/installation/testing.ini
create mode 100644 docs/tutorials/wiki2/src/installation/tests/conftest.py
create mode 100644 docs/tutorials/wiki2/src/installation/tests/test_functional.py
delete mode 100644 docs/tutorials/wiki2/src/installation/tests/test_it.py
create mode 100644 docs/tutorials/wiki2/src/installation/tests/test_views.py
create mode 100644 docs/tutorials/wiki2/src/models/testing.ini
create mode 100644 docs/tutorials/wiki2/src/models/tests/conftest.py
create mode 100644 docs/tutorials/wiki2/src/models/tests/test_functional.py
delete mode 100644 docs/tutorials/wiki2/src/models/tests/test_it.py
create mode 100644 docs/tutorials/wiki2/src/models/tests/test_views.py
diff --git a/docs/tutorials/wiki2/basiclayout.rst b/docs/tutorials/wiki2/basiclayout.rst
index ae58d80a5..e8bc4c5a9 100644
--- a/docs/tutorials/wiki2/basiclayout.rst
+++ b/docs/tutorials/wiki2/basiclayout.rst
@@ -58,24 +58,24 @@ dictionary of settings parsed from the ``.ini`` file, which contains
deployment-related values, such as ``pyramid.reload_templates``,
``sqlalchemy.url``, and so on.
-Next include the package ``models`` using a dotted Python path. The exact
-setup of the models will be covered later.
+Next include :term:`Jinja2` templating bindings so that we can use renderers
+with the ``.jinja2`` extension within our project.
.. literalinclude:: src/basiclayout/tutorial/__init__.py
:lines: 8
:lineno-match:
:language: py
-Next include :term:`Jinja2` templating bindings so that we can use renderers
-with the ``.jinja2`` extension within our project.
+Next include the ``routes`` module using a dotted Python path. This module will
+be explained in the next section.
.. literalinclude:: src/basiclayout/tutorial/__init__.py
:lines: 9
:lineno-match:
:language: py
-Next include the ``routes`` module using a dotted Python path. This module will
-be explained in the next section.
+Next include the package ``models`` using a dotted Python path. The exact
+setup of the models will be covered later.
.. literalinclude:: src/basiclayout/tutorial/__init__.py
:lines: 10
@@ -207,7 +207,7 @@ Without repeating ourselves, we will point out the differences between this view
Content models with the ``models`` package
------------------------------------------
-In an SQLAlchemy-based application, a *model* object is an object composed by
+In a SQLAlchemy-based application, a *model* object is an object composed by
querying the SQL database. The ``models`` package is where the ``alchemy``
cookiecutter put the classes that implement our models.
diff --git a/docs/tutorials/wiki2/definingmodels.rst b/docs/tutorials/wiki2/definingmodels.rst
index 4b80e09ac..f84ca6588 100644
--- a/docs/tutorials/wiki2/definingmodels.rst
+++ b/docs/tutorials/wiki2/definingmodels.rst
@@ -32,8 +32,10 @@ parameter in the ``setup()`` function.
Open ``tutorial/setup.py`` and edit it to look like the following:
.. literalinclude:: src/models/setup.py
+ :lines: 11-30
:linenos:
- :emphasize-lines: 11-24
+ :lineno-match:
+ :emphasize-lines: 3
:language: python
It is a good practice to sort packages alphabetically to make them easier to find.
@@ -42,7 +44,9 @@ After adding ``bcrypt`` and sorting packages, we should have the above ``require
.. note::
- We are using the ``bcrypt`` package from PyPI to hash our passwords securely. There are other one-way hash algorithms for passwords if ``bcrypt`` is an issue on your system. Just make sure that it's an algorithm approved for storing passwords versus a generic one-way hash.
+ We are using the ``bcrypt`` package from PyPI to hash our passwords securely.
+ There are other one-way hash algorithms for passwords if ``bcrypt`` is an issue on your system.
+ Just make sure that it's an algorithm approved for storing passwords versus a generic one-way hash.
Running ``pip install -e .``
@@ -245,7 +249,7 @@ following:
.. literalinclude:: src/models/tutorial/scripts/initialize_db.py
:linenos:
:language: python
- :emphasize-lines: 11-24
+ :emphasize-lines: 15-28
Only the highlighted lines need to be changed.
diff --git a/docs/tutorials/wiki2/installation.rst b/docs/tutorials/wiki2/installation.rst
index 55fca15a1..b144fc4e0 100644
--- a/docs/tutorials/wiki2/installation.rst
+++ b/docs/tutorials/wiki2/installation.rst
@@ -182,8 +182,8 @@ The console will show ``pip`` checking for packages and installing missing packa
alembic-1.3.2 attrs-19.3.0 beautifulsoup4-4.8.2 coverage-5.0.1 \
hupper-1.9.1 importlib-metadata-1.3.0 more-itertools-8.0.2 packaging-19.2 \
plaster-1.0 plaster-pastedeploy-0.7 pluggy-0.13.1 py-1.8.1 \
- pyparsing-2.4.6 pyramid-1.10.4 pyramid-debugtoolbar-4.5.1 \
- pyramid-jinja2-2.8 pyramid-mako-1.1.0 pyramid-retry-2.1 pyramid-tm-2.3 \
+ pyparsing-2.4.6 pyramid-1.10.4 pyramid-debugtoolbar-4.5.2 \
+ pyramid-jinja2-2.8 pyramid-mako-1.1.0 pyramid-retry-2.1 pyramid-tm-2.4 \
pytest-5.3.2 pytest-cov-2.8.1 python-dateutil-2.8.1 python-editor-1.0.4 \
repoze.lru-0.7 six-1.13.0 soupsieve-1.9.5 transaction-3.0.0 \
translationstring-1.3 tutorial venusian-3.0.0 waitress-1.4.1 \
@@ -350,30 +350,33 @@ If successful, you will see output something like this:
======================== test session starts ========================
platform -- Python 3.7.3, pytest-5.3.2, py-1.8.1, pluggy-0.13.1
- rootdir: /tutorial, inifile: pytest.ini, testpaths: tutorial
+ rootdir: /tutorial, inifile: pytest.ini, testpaths: tutorial, tests
plugins: cov-2.8.1
- collected 2 items
-
- tutorial/tests.py ..
-
- ------------------ coverage: platform Python 3.7.3 ------------------
- Name Stmts Miss Cover Missing
- -----------------------------------------------------------------
- tutorial/__init__.py 8 6 25% 7-12
- tutorial/models/__init__.py 24 0 100%
- tutorial/models/meta.py 5 0 100%
- tutorial/models/mymodel.py 8 0 100%
- tutorial/pshell.py 7 7 0% 1-13
- tutorial/routes.py 3 3 0% 1-3
- tutorial/scripts/__init__.py 0 0 100%
- tutorial/scripts/initialize_db.py 22 22 0% 1-38
- tutorial/views/__init__.py 0 0 100%
- tutorial/views/default.py 12 0 100%
- tutorial/views/notfound.py 4 4 0% 1-7
- -----------------------------------------------------------------
- TOTAL 93 42 55%
-
- ===================== 2 passed in 0.64 seconds ======================
+ collected 5 items
+
+ tests/test_functional.py ..
+ tests/test_views.py ...
+
+ ---------- coverage: platform darwin, python 3.7.4-final-0 -----------
+ Name Stmts Miss Cover Missing
+ ----------------------------------------------------------------------------------
+ tutorial/__init__.py 8 0 100%
+ tutorial/alembic/env.py 23 4 83% 28-30, 56
+ tutorial/alembic/versions/20200106_8c274fe5f3c4.py 12 2 83% 31-32
+ tutorial/models/__init__.py 32 2 94% 71, 82
+ tutorial/models/meta.py 5 0 100%
+ tutorial/models/mymodel.py 8 0 100%
+ tutorial/pshell.py 7 5 29% 5-13
+ tutorial/routes.py 3 0 100%
+ tutorial/scripts/__init__.py 0 0 100%
+ tutorial/scripts/initialize_db.py 22 14 36% 15-16, 20-25, 29-38
+ tutorial/views/__init__.py 0 0 100%
+ tutorial/views/default.py 12 0 100%
+ tutorial/views/notfound.py 4 0 100%
+ ----------------------------------------------------------------------------------
+ TOTAL 136 27 80%
+
+ ===================== 5 passed in 0.77 seconds ======================
Our package doesn't quite have 100% test coverage.
diff --git a/docs/tutorials/wiki2/src/basiclayout/.gitignore b/docs/tutorials/wiki2/src/basiclayout/.gitignore
index 1853d983c..c612e59f2 100644
--- a/docs/tutorials/wiki2/src/basiclayout/.gitignore
+++ b/docs/tutorials/wiki2/src/basiclayout/.gitignore
@@ -19,3 +19,4 @@ Data.fs*
.DS_Store
coverage
test
+*.sqlite
diff --git a/docs/tutorials/wiki2/src/basiclayout/testing.ini b/docs/tutorials/wiki2/src/basiclayout/testing.ini
new file mode 100644
index 000000000..85e5e1ae9
--- /dev/null
+++ b/docs/tutorials/wiki2/src/basiclayout/testing.ini
@@ -0,0 +1,79 @@
+###
+# app configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
+###
+
+[app:main]
+use = egg:tutorial
+
+pyramid.reload_templates = false
+pyramid.debug_authorization = false
+pyramid.debug_notfound = false
+pyramid.debug_routematch = false
+pyramid.default_locale_name = en
+
+sqlalchemy.url = sqlite:///%(here)s/testing.sqlite
+
+retry.attempts = 3
+
+[pshell]
+setup = tutorial.pshell.setup
+
+###
+# wsgi server configuration
+###
+
+[alembic]
+# path to migration scripts
+script_location = tutorial/alembic
+file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s
+# file_template = %%(rev)s_%%(slug)s
+
+[server:main]
+use = egg:waitress#main
+listen = *:6543
+
+###
+# logging configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
+###
+
+[loggers]
+keys = root, tutorial, sqlalchemy, alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_tutorial]
+level = WARN
+handlers =
+qualname = tutorial
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+# "level = INFO" logs SQL queries.
+# "level = DEBUG" logs SQL queries and results.
+# "level = WARN" logs neither. (Recommended for production systems.)
+
+[logger_alembic]
+level = WARN
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s
diff --git a/docs/tutorials/wiki2/src/basiclayout/tests/conftest.py b/docs/tutorials/wiki2/src/basiclayout/tests/conftest.py
new file mode 100644
index 000000000..2db65f887
--- /dev/null
+++ b/docs/tutorials/wiki2/src/basiclayout/tests/conftest.py
@@ -0,0 +1,125 @@
+import alembic
+import alembic.config
+import alembic.command
+import os
+from pyramid.paster import get_appsettings
+from pyramid.scripting import prepare
+from pyramid.testing import DummyRequest
+import pytest
+import transaction
+from webob.cookies import Cookie
+import webtest
+
+from tutorial import main
+from tutorial import models
+from tutorial.models.meta import Base
+
+
+def pytest_addoption(parser):
+ parser.addoption('--ini', action='store', metavar='INI_FILE')
+
+@pytest.fixture(scope='session')
+def ini_file(request):
+ # potentially grab this path from a pytest option
+ return os.path.abspath(request.config.option.ini or 'testing.ini')
+
+@pytest.fixture(scope='session')
+def app_settings(ini_file):
+ return get_appsettings(ini_file)
+
+@pytest.fixture(scope='session')
+def dbengine(app_settings, ini_file):
+ engine = models.get_engine(app_settings)
+
+ alembic_cfg = alembic.config.Config(ini_file)
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+ # run migrations to initialize the database
+ # depending on how we want to initialize the database from scratch
+ # we could alternatively call:
+ # Base.metadata.create_all(bind=engine)
+ # alembic.command.stamp(alembic_cfg, "head")
+ alembic.command.upgrade(alembic_cfg, "head")
+
+ yield engine
+
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+@pytest.fixture(scope='session')
+def app(app_settings, dbengine):
+ return main({}, dbengine=dbengine, **app_settings)
+
+@pytest.fixture
+def tm():
+ tm = transaction.TransactionManager(explicit=True)
+ tm.begin()
+ tm.doom()
+
+ yield tm
+
+ tm.abort()
+
+@pytest.fixture
+def dbsession(app, tm):
+ session_factory = app.registry['dbsession_factory']
+ return models.get_tm_session(session_factory, tm)
+
+@pytest.fixture
+def testapp(app, tm, dbsession):
+ # override request.dbsession and request.tm with our own
+ # externally-controlled values that are shared across requests but aborted
+ # at the end
+ testapp = webtest.TestApp(app, extra_environ={
+ 'HTTP_HOST': 'example.com',
+ 'tm.active': True,
+ 'tm.manager': tm,
+ 'app.dbsession': dbsession,
+ })
+
+ return testapp
+
+@pytest.fixture
+def app_request(app, tm, dbsession):
+ """
+ A real request.
+
+ This request is almost identical to a real request but it has some
+ drawbacks in tests as it's harder to mock data and is heavier.
+
+ """
+ env = prepare(registry=app.registry)
+ request = env['request']
+ request.host = 'example.com'
+
+ # without this, request.dbsession will be joined to the same transaction
+ # manager but it will be using a different sqlalchemy.orm.Session using
+ # a separate database transaction
+ request.dbsession = dbsession
+ request.tm = tm
+
+ yield request
+ env['closer']()
+
+@pytest.fixture
+def dummy_request(app, tm, dbsession):
+ """
+ A lightweight dummy request.
+
+ This request is ultra-lightweight and should be used only when the
+ request itself is not a large focus in the call-stack.
+
+ It is way easier to mock and control side-effects using this object.
+
+ - It does not have request extensions applied.
+ - Threadlocals are not properly pushed.
+
+ """
+ request = DummyRequest()
+ request.registry = app.registry
+ request.host = 'example.com'
+ request.dbsession = dbsession
+ request.tm = tm
+
+ return request
diff --git a/docs/tutorials/wiki2/src/basiclayout/tests/test_functional.py b/docs/tutorials/wiki2/src/basiclayout/tests/test_functional.py
new file mode 100644
index 000000000..dbcd8aec7
--- /dev/null
+++ b/docs/tutorials/wiki2/src/basiclayout/tests/test_functional.py
@@ -0,0 +1,13 @@
+from tutorial import models
+
+def test_my_view_success(testapp, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ res = testapp.get('/', status=200)
+ assert res.body
+
+def test_notfound(testapp):
+ res = testapp.get('/badurl', status=404)
+ assert res.status_code == 404
diff --git a/docs/tutorials/wiki2/src/basiclayout/tests/test_it.py b/docs/tutorials/wiki2/src/basiclayout/tests/test_it.py
deleted file mode 100644
index ea16534fc..000000000
--- a/docs/tutorials/wiki2/src/basiclayout/tests/test_it.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import unittest
-
-from pyramid import testing
-
-import transaction
-
-
-def dummy_request(dbsession):
- return testing.DummyRequest(dbsession=dbsession)
-
-
-class BaseTest(unittest.TestCase):
- def setUp(self):
- self.config = testing.setUp(settings={
- 'sqlalchemy.url': 'sqlite:///:memory:'
- })
- self.config.include('tutorial.models')
- settings = self.config.get_settings()
-
- from tutorial.models import (
- get_engine,
- get_session_factory,
- get_tm_session,
- )
-
- self.engine = get_engine(settings)
- session_factory = get_session_factory(self.engine)
-
- self.session = get_tm_session(session_factory, transaction.manager)
-
- def init_database(self):
- from tutorial.models.meta import Base
- Base.metadata.create_all(self.engine)
-
- def tearDown(self):
- from tutorial.models.meta import Base
-
- testing.tearDown()
- transaction.abort()
- Base.metadata.drop_all(self.engine)
-
-
-class TestMyViewSuccessCondition(BaseTest):
-
- def setUp(self):
- super(TestMyViewSuccessCondition, self).setUp()
- self.init_database()
-
- from tutorial.models import MyModel
-
- model = MyModel(name='one', value=55)
- self.session.add(model)
-
- def test_passing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info['one'].name, 'one')
- self.assertEqual(info['project'], 'myproj')
-
-
-class TestMyViewFailureCondition(BaseTest):
-
- def test_failing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info.status_int, 500)
diff --git a/docs/tutorials/wiki2/src/basiclayout/tests/test_views.py b/docs/tutorials/wiki2/src/basiclayout/tests/test_views.py
new file mode 100644
index 000000000..8ae464d03
--- /dev/null
+++ b/docs/tutorials/wiki2/src/basiclayout/tests/test_views.py
@@ -0,0 +1,23 @@
+from tutorial import models
+from tutorial.views.default import my_view
+from tutorial.views.notfound import notfound_view
+
+
+def test_my_view_failure(app_request):
+ info = my_view(app_request)
+ assert info.status_int == 500
+
+def test_my_view_success(app_request, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ info = my_view(app_request)
+ assert app_request.response.status_int == 200
+ assert info['one'].name == 'one'
+ assert info['project'] == 'myproj'
+
+def test_notfound_view(app_request):
+ info = notfound_view(app_request)
+ assert app_request.response.status_int == 404
+ assert info == {}
diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py
index 5c2ba5cc0..7edc0957d 100644
--- a/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py
+++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py
@@ -5,8 +5,8 @@ def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
with Configurator(settings=settings) as config:
- config.include('.models')
config.include('pyramid_jinja2')
config.include('.routes')
+ config.include('.models')
config.scan()
return config.make_wsgi_app()
diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py
index d8a273e9e..1c3ec5ee8 100644
--- a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py
+++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py
@@ -65,13 +65,21 @@ def includeme(config):
# use pyramid_retry to retry a request when transient exceptions occur
config.include('pyramid_retry')
- session_factory = get_session_factory(get_engine(settings))
+ # hook to share the dbengine fixture in testing
+ dbengine = settings.get('dbengine')
+ if not dbengine:
+ dbengine = get_engine(settings)
+
+ session_factory = get_session_factory(dbengine)
config.registry['dbsession_factory'] = session_factory
# make request.dbsession available for use in Pyramid
- config.add_request_method(
- # r.tm is the transaction manager used by pyramid_tm
- lambda r: get_tm_session(session_factory, r.tm),
- 'dbsession',
- reify=True
- )
+ def dbsession(request):
+ # hook to share the dbsession fixture in testing
+ dbsession = request.environ.get('app.dbsession')
+ if dbsession is None:
+ # request.tm is the transaction manager used by pyramid_tm
+ dbsession = get_tm_session(session_factory, request.tm)
+ return dbsession
+
+ config.add_request_method(dbsession, reify=True)
diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py
index 094b2f303..a0f654d38 100644
--- a/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py
+++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py
@@ -1,7 +1,6 @@
from pyramid.view import view_config
from pyramid.response import Response
-
-from sqlalchemy.exc import DBAPIError
+from sqlalchemy.exc import SQLAlchemyError
from .. import models
@@ -10,8 +9,8 @@ from .. import models
def my_view(request):
try:
query = request.dbsession.query(models.MyModel)
- one = query.filter(models.MyModel.name == 'one').first()
- except DBAPIError:
+ one = query.filter(models.MyModel.name == 'one').one()
+ except SQLAlchemyError:
return Response(db_err_msg, content_type='text/plain', status=500)
return {'one': one, 'project': 'myproj'}
diff --git a/docs/tutorials/wiki2/src/installation/.gitignore b/docs/tutorials/wiki2/src/installation/.gitignore
index 1853d983c..c612e59f2 100644
--- a/docs/tutorials/wiki2/src/installation/.gitignore
+++ b/docs/tutorials/wiki2/src/installation/.gitignore
@@ -19,3 +19,4 @@ Data.fs*
.DS_Store
coverage
test
+*.sqlite
diff --git a/docs/tutorials/wiki2/src/installation/testing.ini b/docs/tutorials/wiki2/src/installation/testing.ini
new file mode 100644
index 000000000..85e5e1ae9
--- /dev/null
+++ b/docs/tutorials/wiki2/src/installation/testing.ini
@@ -0,0 +1,79 @@
+###
+# app configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
+###
+
+[app:main]
+use = egg:tutorial
+
+pyramid.reload_templates = false
+pyramid.debug_authorization = false
+pyramid.debug_notfound = false
+pyramid.debug_routematch = false
+pyramid.default_locale_name = en
+
+sqlalchemy.url = sqlite:///%(here)s/testing.sqlite
+
+retry.attempts = 3
+
+[pshell]
+setup = tutorial.pshell.setup
+
+###
+# wsgi server configuration
+###
+
+[alembic]
+# path to migration scripts
+script_location = tutorial/alembic
+file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s
+# file_template = %%(rev)s_%%(slug)s
+
+[server:main]
+use = egg:waitress#main
+listen = *:6543
+
+###
+# logging configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
+###
+
+[loggers]
+keys = root, tutorial, sqlalchemy, alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_tutorial]
+level = WARN
+handlers =
+qualname = tutorial
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+# "level = INFO" logs SQL queries.
+# "level = DEBUG" logs SQL queries and results.
+# "level = WARN" logs neither. (Recommended for production systems.)
+
+[logger_alembic]
+level = WARN
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s
diff --git a/docs/tutorials/wiki2/src/installation/tests/conftest.py b/docs/tutorials/wiki2/src/installation/tests/conftest.py
new file mode 100644
index 000000000..2db65f887
--- /dev/null
+++ b/docs/tutorials/wiki2/src/installation/tests/conftest.py
@@ -0,0 +1,125 @@
+import alembic
+import alembic.config
+import alembic.command
+import os
+from pyramid.paster import get_appsettings
+from pyramid.scripting import prepare
+from pyramid.testing import DummyRequest
+import pytest
+import transaction
+from webob.cookies import Cookie
+import webtest
+
+from tutorial import main
+from tutorial import models
+from tutorial.models.meta import Base
+
+
+def pytest_addoption(parser):
+ parser.addoption('--ini', action='store', metavar='INI_FILE')
+
+@pytest.fixture(scope='session')
+def ini_file(request):
+ # potentially grab this path from a pytest option
+ return os.path.abspath(request.config.option.ini or 'testing.ini')
+
+@pytest.fixture(scope='session')
+def app_settings(ini_file):
+ return get_appsettings(ini_file)
+
+@pytest.fixture(scope='session')
+def dbengine(app_settings, ini_file):
+ engine = models.get_engine(app_settings)
+
+ alembic_cfg = alembic.config.Config(ini_file)
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+ # run migrations to initialize the database
+ # depending on how we want to initialize the database from scratch
+ # we could alternatively call:
+ # Base.metadata.create_all(bind=engine)
+ # alembic.command.stamp(alembic_cfg, "head")
+ alembic.command.upgrade(alembic_cfg, "head")
+
+ yield engine
+
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+@pytest.fixture(scope='session')
+def app(app_settings, dbengine):
+ return main({}, dbengine=dbengine, **app_settings)
+
+@pytest.fixture
+def tm():
+ tm = transaction.TransactionManager(explicit=True)
+ tm.begin()
+ tm.doom()
+
+ yield tm
+
+ tm.abort()
+
+@pytest.fixture
+def dbsession(app, tm):
+ session_factory = app.registry['dbsession_factory']
+ return models.get_tm_session(session_factory, tm)
+
+@pytest.fixture
+def testapp(app, tm, dbsession):
+ # override request.dbsession and request.tm with our own
+ # externally-controlled values that are shared across requests but aborted
+ # at the end
+ testapp = webtest.TestApp(app, extra_environ={
+ 'HTTP_HOST': 'example.com',
+ 'tm.active': True,
+ 'tm.manager': tm,
+ 'app.dbsession': dbsession,
+ })
+
+ return testapp
+
+@pytest.fixture
+def app_request(app, tm, dbsession):
+ """
+ A real request.
+
+ This request is almost identical to a real request but it has some
+ drawbacks in tests as it's harder to mock data and is heavier.
+
+ """
+ env = prepare(registry=app.registry)
+ request = env['request']
+ request.host = 'example.com'
+
+ # without this, request.dbsession will be joined to the same transaction
+ # manager but it will be using a different sqlalchemy.orm.Session using
+ # a separate database transaction
+ request.dbsession = dbsession
+ request.tm = tm
+
+ yield request
+ env['closer']()
+
+@pytest.fixture
+def dummy_request(app, tm, dbsession):
+ """
+ A lightweight dummy request.
+
+ This request is ultra-lightweight and should be used only when the
+ request itself is not a large focus in the call-stack.
+
+ It is way easier to mock and control side-effects using this object.
+
+ - It does not have request extensions applied.
+ - Threadlocals are not properly pushed.
+
+ """
+ request = DummyRequest()
+ request.registry = app.registry
+ request.host = 'example.com'
+ request.dbsession = dbsession
+ request.tm = tm
+
+ return request
diff --git a/docs/tutorials/wiki2/src/installation/tests/test_functional.py b/docs/tutorials/wiki2/src/installation/tests/test_functional.py
new file mode 100644
index 000000000..dbcd8aec7
--- /dev/null
+++ b/docs/tutorials/wiki2/src/installation/tests/test_functional.py
@@ -0,0 +1,13 @@
+from tutorial import models
+
+def test_my_view_success(testapp, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ res = testapp.get('/', status=200)
+ assert res.body
+
+def test_notfound(testapp):
+ res = testapp.get('/badurl', status=404)
+ assert res.status_code == 404
diff --git a/docs/tutorials/wiki2/src/installation/tests/test_it.py b/docs/tutorials/wiki2/src/installation/tests/test_it.py
deleted file mode 100644
index ea16534fc..000000000
--- a/docs/tutorials/wiki2/src/installation/tests/test_it.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import unittest
-
-from pyramid import testing
-
-import transaction
-
-
-def dummy_request(dbsession):
- return testing.DummyRequest(dbsession=dbsession)
-
-
-class BaseTest(unittest.TestCase):
- def setUp(self):
- self.config = testing.setUp(settings={
- 'sqlalchemy.url': 'sqlite:///:memory:'
- })
- self.config.include('tutorial.models')
- settings = self.config.get_settings()
-
- from tutorial.models import (
- get_engine,
- get_session_factory,
- get_tm_session,
- )
-
- self.engine = get_engine(settings)
- session_factory = get_session_factory(self.engine)
-
- self.session = get_tm_session(session_factory, transaction.manager)
-
- def init_database(self):
- from tutorial.models.meta import Base
- Base.metadata.create_all(self.engine)
-
- def tearDown(self):
- from tutorial.models.meta import Base
-
- testing.tearDown()
- transaction.abort()
- Base.metadata.drop_all(self.engine)
-
-
-class TestMyViewSuccessCondition(BaseTest):
-
- def setUp(self):
- super(TestMyViewSuccessCondition, self).setUp()
- self.init_database()
-
- from tutorial.models import MyModel
-
- model = MyModel(name='one', value=55)
- self.session.add(model)
-
- def test_passing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info['one'].name, 'one')
- self.assertEqual(info['project'], 'myproj')
-
-
-class TestMyViewFailureCondition(BaseTest):
-
- def test_failing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info.status_int, 500)
diff --git a/docs/tutorials/wiki2/src/installation/tests/test_views.py b/docs/tutorials/wiki2/src/installation/tests/test_views.py
new file mode 100644
index 000000000..8ae464d03
--- /dev/null
+++ b/docs/tutorials/wiki2/src/installation/tests/test_views.py
@@ -0,0 +1,23 @@
+from tutorial import models
+from tutorial.views.default import my_view
+from tutorial.views.notfound import notfound_view
+
+
+def test_my_view_failure(app_request):
+ info = my_view(app_request)
+ assert info.status_int == 500
+
+def test_my_view_success(app_request, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ info = my_view(app_request)
+ assert app_request.response.status_int == 200
+ assert info['one'].name == 'one'
+ assert info['project'] == 'myproj'
+
+def test_notfound_view(app_request):
+ info = notfound_view(app_request)
+ assert app_request.response.status_int == 404
+ assert info == {}
diff --git a/docs/tutorials/wiki2/src/installation/tutorial/__init__.py b/docs/tutorials/wiki2/src/installation/tutorial/__init__.py
index 5c2ba5cc0..7edc0957d 100644
--- a/docs/tutorials/wiki2/src/installation/tutorial/__init__.py
+++ b/docs/tutorials/wiki2/src/installation/tutorial/__init__.py
@@ -5,8 +5,8 @@ def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
with Configurator(settings=settings) as config:
- config.include('.models')
config.include('pyramid_jinja2')
config.include('.routes')
+ config.include('.models')
config.scan()
return config.make_wsgi_app()
diff --git a/docs/tutorials/wiki2/src/installation/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/installation/tutorial/models/__init__.py
index d8a273e9e..1c3ec5ee8 100644
--- a/docs/tutorials/wiki2/src/installation/tutorial/models/__init__.py
+++ b/docs/tutorials/wiki2/src/installation/tutorial/models/__init__.py
@@ -65,13 +65,21 @@ def includeme(config):
# use pyramid_retry to retry a request when transient exceptions occur
config.include('pyramid_retry')
- session_factory = get_session_factory(get_engine(settings))
+ # hook to share the dbengine fixture in testing
+ dbengine = settings.get('dbengine')
+ if not dbengine:
+ dbengine = get_engine(settings)
+
+ session_factory = get_session_factory(dbengine)
config.registry['dbsession_factory'] = session_factory
# make request.dbsession available for use in Pyramid
- config.add_request_method(
- # r.tm is the transaction manager used by pyramid_tm
- lambda r: get_tm_session(session_factory, r.tm),
- 'dbsession',
- reify=True
- )
+ def dbsession(request):
+ # hook to share the dbsession fixture in testing
+ dbsession = request.environ.get('app.dbsession')
+ if dbsession is None:
+ # request.tm is the transaction manager used by pyramid_tm
+ dbsession = get_tm_session(session_factory, request.tm)
+ return dbsession
+
+ config.add_request_method(dbsession, reify=True)
diff --git a/docs/tutorials/wiki2/src/installation/tutorial/views/default.py b/docs/tutorials/wiki2/src/installation/tutorial/views/default.py
index 094b2f303..a0f654d38 100644
--- a/docs/tutorials/wiki2/src/installation/tutorial/views/default.py
+++ b/docs/tutorials/wiki2/src/installation/tutorial/views/default.py
@@ -1,7 +1,6 @@
from pyramid.view import view_config
from pyramid.response import Response
-
-from sqlalchemy.exc import DBAPIError
+from sqlalchemy.exc import SQLAlchemyError
from .. import models
@@ -10,8 +9,8 @@ from .. import models
def my_view(request):
try:
query = request.dbsession.query(models.MyModel)
- one = query.filter(models.MyModel.name == 'one').first()
- except DBAPIError:
+ one = query.filter(models.MyModel.name == 'one').one()
+ except SQLAlchemyError:
return Response(db_err_msg, content_type='text/plain', status=500)
return {'one': one, 'project': 'myproj'}
diff --git a/docs/tutorials/wiki2/src/models/.gitignore b/docs/tutorials/wiki2/src/models/.gitignore
index 1853d983c..c612e59f2 100644
--- a/docs/tutorials/wiki2/src/models/.gitignore
+++ b/docs/tutorials/wiki2/src/models/.gitignore
@@ -19,3 +19,4 @@ Data.fs*
.DS_Store
coverage
test
+*.sqlite
diff --git a/docs/tutorials/wiki2/src/models/setup.py b/docs/tutorials/wiki2/src/models/setup.py
index 60234751a..fbd848136 100644
--- a/docs/tutorials/wiki2/src/models/setup.py
+++ b/docs/tutorials/wiki2/src/models/setup.py
@@ -19,8 +19,8 @@ requires = [
'pyramid_tm',
'SQLAlchemy',
'transaction',
- 'zope.sqlalchemy',
'waitress',
+ 'zope.sqlalchemy',
]
tests_require = [
diff --git a/docs/tutorials/wiki2/src/models/testing.ini b/docs/tutorials/wiki2/src/models/testing.ini
new file mode 100644
index 000000000..85e5e1ae9
--- /dev/null
+++ b/docs/tutorials/wiki2/src/models/testing.ini
@@ -0,0 +1,79 @@
+###
+# app configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
+###
+
+[app:main]
+use = egg:tutorial
+
+pyramid.reload_templates = false
+pyramid.debug_authorization = false
+pyramid.debug_notfound = false
+pyramid.debug_routematch = false
+pyramid.default_locale_name = en
+
+sqlalchemy.url = sqlite:///%(here)s/testing.sqlite
+
+retry.attempts = 3
+
+[pshell]
+setup = tutorial.pshell.setup
+
+###
+# wsgi server configuration
+###
+
+[alembic]
+# path to migration scripts
+script_location = tutorial/alembic
+file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s
+# file_template = %%(rev)s_%%(slug)s
+
+[server:main]
+use = egg:waitress#main
+listen = *:6543
+
+###
+# logging configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
+###
+
+[loggers]
+keys = root, tutorial, sqlalchemy, alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_tutorial]
+level = WARN
+handlers =
+qualname = tutorial
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+# "level = INFO" logs SQL queries.
+# "level = DEBUG" logs SQL queries and results.
+# "level = WARN" logs neither. (Recommended for production systems.)
+
+[logger_alembic]
+level = WARN
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s
diff --git a/docs/tutorials/wiki2/src/models/tests/conftest.py b/docs/tutorials/wiki2/src/models/tests/conftest.py
new file mode 100644
index 000000000..2db65f887
--- /dev/null
+++ b/docs/tutorials/wiki2/src/models/tests/conftest.py
@@ -0,0 +1,125 @@
+import alembic
+import alembic.config
+import alembic.command
+import os
+from pyramid.paster import get_appsettings
+from pyramid.scripting import prepare
+from pyramid.testing import DummyRequest
+import pytest
+import transaction
+from webob.cookies import Cookie
+import webtest
+
+from tutorial import main
+from tutorial import models
+from tutorial.models.meta import Base
+
+
+def pytest_addoption(parser):
+ parser.addoption('--ini', action='store', metavar='INI_FILE')
+
+@pytest.fixture(scope='session')
+def ini_file(request):
+ # potentially grab this path from a pytest option
+ return os.path.abspath(request.config.option.ini or 'testing.ini')
+
+@pytest.fixture(scope='session')
+def app_settings(ini_file):
+ return get_appsettings(ini_file)
+
+@pytest.fixture(scope='session')
+def dbengine(app_settings, ini_file):
+ engine = models.get_engine(app_settings)
+
+ alembic_cfg = alembic.config.Config(ini_file)
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+ # run migrations to initialize the database
+ # depending on how we want to initialize the database from scratch
+ # we could alternatively call:
+ # Base.metadata.create_all(bind=engine)
+ # alembic.command.stamp(alembic_cfg, "head")
+ alembic.command.upgrade(alembic_cfg, "head")
+
+ yield engine
+
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+@pytest.fixture(scope='session')
+def app(app_settings, dbengine):
+ return main({}, dbengine=dbengine, **app_settings)
+
+@pytest.fixture
+def tm():
+ tm = transaction.TransactionManager(explicit=True)
+ tm.begin()
+ tm.doom()
+
+ yield tm
+
+ tm.abort()
+
+@pytest.fixture
+def dbsession(app, tm):
+ session_factory = app.registry['dbsession_factory']
+ return models.get_tm_session(session_factory, tm)
+
+@pytest.fixture
+def testapp(app, tm, dbsession):
+ # override request.dbsession and request.tm with our own
+ # externally-controlled values that are shared across requests but aborted
+ # at the end
+ testapp = webtest.TestApp(app, extra_environ={
+ 'HTTP_HOST': 'example.com',
+ 'tm.active': True,
+ 'tm.manager': tm,
+ 'app.dbsession': dbsession,
+ })
+
+ return testapp
+
+@pytest.fixture
+def app_request(app, tm, dbsession):
+ """
+ A real request.
+
+ This request is almost identical to a real request but it has some
+ drawbacks in tests as it's harder to mock data and is heavier.
+
+ """
+ env = prepare(registry=app.registry)
+ request = env['request']
+ request.host = 'example.com'
+
+ # without this, request.dbsession will be joined to the same transaction
+ # manager but it will be using a different sqlalchemy.orm.Session using
+ # a separate database transaction
+ request.dbsession = dbsession
+ request.tm = tm
+
+ yield request
+ env['closer']()
+
+@pytest.fixture
+def dummy_request(app, tm, dbsession):
+ """
+ A lightweight dummy request.
+
+ This request is ultra-lightweight and should be used only when the
+ request itself is not a large focus in the call-stack.
+
+ It is way easier to mock and control side-effects using this object.
+
+ - It does not have request extensions applied.
+ - Threadlocals are not properly pushed.
+
+ """
+ request = DummyRequest()
+ request.registry = app.registry
+ request.host = 'example.com'
+ request.dbsession = dbsession
+ request.tm = tm
+
+ return request
diff --git a/docs/tutorials/wiki2/src/models/tests/test_functional.py b/docs/tutorials/wiki2/src/models/tests/test_functional.py
new file mode 100644
index 000000000..dbcd8aec7
--- /dev/null
+++ b/docs/tutorials/wiki2/src/models/tests/test_functional.py
@@ -0,0 +1,13 @@
+from tutorial import models
+
+def test_my_view_success(testapp, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ res = testapp.get('/', status=200)
+ assert res.body
+
+def test_notfound(testapp):
+ res = testapp.get('/badurl', status=404)
+ assert res.status_code == 404
diff --git a/docs/tutorials/wiki2/src/models/tests/test_it.py b/docs/tutorials/wiki2/src/models/tests/test_it.py
deleted file mode 100644
index ea16534fc..000000000
--- a/docs/tutorials/wiki2/src/models/tests/test_it.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import unittest
-
-from pyramid import testing
-
-import transaction
-
-
-def dummy_request(dbsession):
- return testing.DummyRequest(dbsession=dbsession)
-
-
-class BaseTest(unittest.TestCase):
- def setUp(self):
- self.config = testing.setUp(settings={
- 'sqlalchemy.url': 'sqlite:///:memory:'
- })
- self.config.include('tutorial.models')
- settings = self.config.get_settings()
-
- from tutorial.models import (
- get_engine,
- get_session_factory,
- get_tm_session,
- )
-
- self.engine = get_engine(settings)
- session_factory = get_session_factory(self.engine)
-
- self.session = get_tm_session(session_factory, transaction.manager)
-
- def init_database(self):
- from tutorial.models.meta import Base
- Base.metadata.create_all(self.engine)
-
- def tearDown(self):
- from tutorial.models.meta import Base
-
- testing.tearDown()
- transaction.abort()
- Base.metadata.drop_all(self.engine)
-
-
-class TestMyViewSuccessCondition(BaseTest):
-
- def setUp(self):
- super(TestMyViewSuccessCondition, self).setUp()
- self.init_database()
-
- from tutorial.models import MyModel
-
- model = MyModel(name='one', value=55)
- self.session.add(model)
-
- def test_passing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info['one'].name, 'one')
- self.assertEqual(info['project'], 'myproj')
-
-
-class TestMyViewFailureCondition(BaseTest):
-
- def test_failing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info.status_int, 500)
diff --git a/docs/tutorials/wiki2/src/models/tests/test_views.py b/docs/tutorials/wiki2/src/models/tests/test_views.py
new file mode 100644
index 000000000..8ae464d03
--- /dev/null
+++ b/docs/tutorials/wiki2/src/models/tests/test_views.py
@@ -0,0 +1,23 @@
+from tutorial import models
+from tutorial.views.default import my_view
+from tutorial.views.notfound import notfound_view
+
+
+def test_my_view_failure(app_request):
+ info = my_view(app_request)
+ assert info.status_int == 500
+
+def test_my_view_success(app_request, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ info = my_view(app_request)
+ assert app_request.response.status_int == 200
+ assert info['one'].name == 'one'
+ assert info['project'] == 'myproj'
+
+def test_notfound_view(app_request):
+ info = notfound_view(app_request)
+ assert app_request.response.status_int == 404
+ assert info == {}
diff --git a/docs/tutorials/wiki2/src/models/tutorial/__init__.py b/docs/tutorials/wiki2/src/models/tutorial/__init__.py
index 5c2ba5cc0..7edc0957d 100644
--- a/docs/tutorials/wiki2/src/models/tutorial/__init__.py
+++ b/docs/tutorials/wiki2/src/models/tutorial/__init__.py
@@ -5,8 +5,8 @@ def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
with Configurator(settings=settings) as config:
- config.include('.models')
config.include('pyramid_jinja2')
config.include('.routes')
+ config.include('.models')
config.scan()
return config.make_wsgi_app()
diff --git a/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py
index a4209a6e9..47d77ef01 100644
--- a/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py
+++ b/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py
@@ -66,13 +66,21 @@ def includeme(config):
# use pyramid_retry to retry a request when transient exceptions occur
config.include('pyramid_retry')
- session_factory = get_session_factory(get_engine(settings))
+ # hook to share the dbengine fixture in testing
+ dbengine = settings.get('dbengine')
+ if not dbengine:
+ dbengine = get_engine(settings)
+
+ session_factory = get_session_factory(dbengine)
config.registry['dbsession_factory'] = session_factory
# make request.dbsession available for use in Pyramid
- config.add_request_method(
- # r.tm is the transaction manager used by pyramid_tm
- lambda r: get_tm_session(session_factory, r.tm),
- 'dbsession',
- reify=True
- )
+ def dbsession(request):
+ # hook to share the dbsession fixture in testing
+ dbsession = request.environ.get('app.dbsession')
+ if dbsession is None:
+ # request.tm is the transaction manager used by pyramid_tm
+ dbsession = get_tm_session(session_factory, request.tm)
+ return dbsession
+
+ config.add_request_method(dbsession, reify=True)
diff --git a/docs/tutorials/wiki2/src/models/tutorial/scripts/initialize_db.py b/docs/tutorials/wiki2/src/models/tutorial/scripts/initialize_db.py
index e6350fb36..c8034e5a5 100644
--- a/docs/tutorials/wiki2/src/models/tutorial/scripts/initialize_db.py
+++ b/docs/tutorials/wiki2/src/models/tutorial/scripts/initialize_db.py
@@ -8,6 +8,10 @@ from .. import models
def setup_models(dbsession):
+ """
+ Add or update models / fixtures in the database.
+
+ """
editor = models.User(name='editor', role='editor')
editor.set_password('editor')
dbsession.add(editor)
diff --git a/docs/tutorials/wiki2/src/models/tutorial/views/default.py b/docs/tutorials/wiki2/src/models/tutorial/views/default.py
index 094b2f303..a0f654d38 100644
--- a/docs/tutorials/wiki2/src/models/tutorial/views/default.py
+++ b/docs/tutorials/wiki2/src/models/tutorial/views/default.py
@@ -1,7 +1,6 @@
from pyramid.view import view_config
from pyramid.response import Response
-
-from sqlalchemy.exc import DBAPIError
+from sqlalchemy.exc import SQLAlchemyError
from .. import models
@@ -10,8 +9,8 @@ from .. import models
def my_view(request):
try:
query = request.dbsession.query(models.MyModel)
- one = query.filter(models.MyModel.name == 'one').first()
- except DBAPIError:
+ one = query.filter(models.MyModel.name == 'one').one()
+ except SQLAlchemyError:
return Response(db_err_msg, content_type='text/plain', status=500)
return {'one': one, 'project': 'myproj'}
--
cgit v1.2.3
From cd666082fbbd8b11d5cefd4a2d72209ae4f847be Mon Sep 17 00:00:00 2001
From: Michael Merickel
Date: Mon, 6 Jan 2020 22:58:07 -0600
Subject: sync views with new structure and add csrf protection
---
docs/narr/security.rst | 2 +
docs/tutorials/wiki2/definingviews.rst | 140 ++++++++++++---------
docs/tutorials/wiki2/src/views/.gitignore | 1 +
docs/tutorials/wiki2/src/views/setup.py | 2 +-
docs/tutorials/wiki2/src/views/testing.ini | 79 ++++++++++++
docs/tutorials/wiki2/src/views/tests/conftest.py | 125 ++++++++++++++++++
.../wiki2/src/views/tests/test_functional.py | 13 ++
docs/tutorials/wiki2/src/views/tests/test_it.py | 66 ----------
docs/tutorials/wiki2/src/views/tests/test_views.py | 23 ++++
.../tutorials/wiki2/src/views/tutorial/__init__.py | 3 +-
.../wiki2/src/views/tutorial/models/__init__.py | 22 ++--
.../src/views/tutorial/scripts/initialize_db.py | 4 +
.../tutorials/wiki2/src/views/tutorial/security.py | 6 +
.../wiki2/src/views/tutorial/templates/404.jinja2 | 6 +-
.../wiki2/src/views/tutorial/templates/edit.jinja2 | 3 +-
.../src/views/tutorial/templates/layout.jinja2 | 9 ++
.../wiki2/src/views/tutorial/views/default.py | 23 ++--
17 files changed, 377 insertions(+), 150 deletions(-)
create mode 100644 docs/tutorials/wiki2/src/views/testing.ini
create mode 100644 docs/tutorials/wiki2/src/views/tests/conftest.py
create mode 100644 docs/tutorials/wiki2/src/views/tests/test_functional.py
delete mode 100644 docs/tutorials/wiki2/src/views/tests/test_it.py
create mode 100644 docs/tutorials/wiki2/src/views/tests/test_views.py
create mode 100644 docs/tutorials/wiki2/src/views/tutorial/security.py
diff --git a/docs/narr/security.rst b/docs/narr/security.rst
index 72c2721f6..b4203161e 100644
--- a/docs/narr/security.rst
+++ b/docs/narr/security.rst
@@ -720,6 +720,8 @@ has the possibility of providing a chosen plaintext.
single: preventing cross-site request forgery attacks
single: cross-site request forgery attacks, prevention
+.. _csrf_protection:
+
Preventing Cross-Site Request Forgery Attacks
---------------------------------------------
diff --git a/docs/tutorials/wiki2/definingviews.rst b/docs/tutorials/wiki2/definingviews.rst
index a434039ca..122164083 100644
--- a/docs/tutorials/wiki2/definingviews.rst
+++ b/docs/tutorials/wiki2/definingviews.rst
@@ -26,14 +26,15 @@ is not a dependency of the original "tutorial" application.
We need to add a dependency on the ``docutils`` package to our ``tutorial``
package's ``setup.py`` file by assigning this dependency to the ``requires``
-parameter in the ``setup()`` function.
+list.
Open ``tutorial/setup.py`` and edit it to look like the following:
.. literalinclude:: src/views/setup.py
- :linenos:
- :emphasize-lines: 14
- :language: python
+ :lines: 11-31
+ :lineno-match:
+ :emphasize-lines: 4
+ :language: python
Only the highlighted line needs to be added.
@@ -50,7 +51,7 @@ were provided at the time we created the project.
As an example, the CSS file will be accessed via
``http://localhost:6543/static/theme.css`` by virtue of the call to the
-``add_static_view`` directive we've made in the ``routes.py`` file. Any number
+``add_static_view`` directive we've made in the ``tutorial/routes.py`` file. Any number
and type of static assets can be placed in this directory (or subdirectories)
and are just referred to by URL or by using the convenience method
``static_url``, e.g., ``request.static_url(':static/foo.css')`` within
@@ -63,7 +64,7 @@ Adding routes to ``routes.py``
This is the `URL Dispatch` tutorial, so let's start by adding some URL patterns
to our app. Later we'll attach views to handle the URLs.
-The ``routes.py`` file contains :meth:`pyramid.config.Configurator.add_route`
+The ``tutorial/routes.py`` file contains :meth:`pyramid.config.Configurator.add_route`
calls which serve to add routes to our application. First we'll get rid of the
existing route created by the template using the name ``'home'``. It's only an
example and isn't relevant to our application.
@@ -96,13 +97,13 @@ order they're registered.
decorator attached to the ``edit_page`` view function, which in turn will be
indicated by ``route_name='edit_page'``.
-As a result of our edits, the ``routes.py`` file should look like the
+As a result of our edits, the ``tutorial/routes.py`` file should look like the
following:
.. literalinclude:: src/views/tutorial/routes.py
- :linenos:
- :emphasize-lines: 3-6
- :language: python
+ :linenos:
+ :emphasize-lines: 3-6
+ :language: python
The highlighted lines are the ones that need to be added or edited.
@@ -117,18 +118,41 @@ The highlighted lines are the ones that need to be added or edited.
behavior in your own apps.
+CSRF protection
+===============
+
+When handling HTML forms that mutate data in our database we need to verify that the form submission is legitimate and not from a URL embedded in a third-party website.
+This is done by adding a unique token to each form that a third-party could not easily guess.
+Read more about CSRF at :ref:`csrf_protection`.
+For this tutorial, we'll store the active CSRF token in a cookie.
+
+Let's add a new ``tutorial/security.py`` file:
+
+.. literalinclude:: src/views/tutorial/security.py
+ :linenos:
+ :emphasize-lines: 5-6
+ :language: python
+
+Since we've added a new ``tutorial/security.py`` module, we need to include it.
+Open the file ``tutorial/__init__.py`` and edit the following lines:
+
+.. literalinclude:: src/views/tutorial/__init__.py
+ :linenos:
+ :emphasize-lines: 9
+ :language: python
+
+On forms that mutate data, we'll be sure to add the CSRF token to the form, using :func:`pyramid.csrf.get_csrf_token`.
+
+
Adding view functions in ``views/default.py``
=============================================
It's time for a major change. Open ``tutorial/views/default.py`` and
-edit it to look like the following:
+replace it with the following:
.. literalinclude:: src/views/tutorial/views/default.py
- :linenos:
- :language: python
- :emphasize-lines: 1-9,14-
-
-The highlighted lines need to be added or edited.
+ :linenos:
+ :language: python
We added some imports, and created a regular expression to find "WikiWords".
@@ -137,7 +161,7 @@ when originally rendered after we selected the ``sqlalchemy`` backend option in
the cookiecutter. It was only an example and isn't relevant to our
application. We also deleted the ``db_err_msg`` string.
-Then we added four :term:`view callable` functions to our ``views/default.py``
+Then we added four :term:`view callable` functions to our ``tutorial/views/default.py``
module, as mentioned in the previous step:
* ``view_wiki()`` - Displays the wiki itself. It will answer on the root URL.
@@ -163,10 +187,10 @@ The ``view_wiki`` view function
Following is the code for the ``view_wiki`` view function and its decorator:
.. literalinclude:: src/views/tutorial/views/default.py
- :lines: 17-20
- :lineno-match:
- :linenos:
- :language: python
+ :lines: 16-19
+ :lineno-match:
+ :linenos:
+ :language: python
``view_wiki()`` is the :term:`default view` that gets called when a request is
made to the root URL of our wiki. It always redirects to a URL which
@@ -174,12 +198,12 @@ represents the path to our "FrontPage".
The ``view_wiki`` view callable always redirects to the URL of a Page resource
named "FrontPage". To do so, it returns an instance of the
-:class:`pyramid.httpexceptions.HTTPFound` class (instances of which implement
+:class:`pyramid.httpexceptions.HTTPSeeOther` class (instances of which implement
the :class:`pyramid.interfaces.IResponse` interface, like
:class:`pyramid.response.Response`). It uses the
:meth:`pyramid.request.Request.route_url` API to construct a URL to the
``FrontPage`` page (i.e., ``http://localhost:6543/FrontPage``), and uses it as
-the "location" of the ``HTTPFound`` response, forming an HTTP redirect.
+the "location" of the ``HTTPSeeOther`` response, forming an HTTP redirect.
The ``view_page`` view function
@@ -188,10 +212,10 @@ The ``view_page`` view function
Here is the code for the ``view_page`` view function and its decorator:
.. literalinclude:: src/views/tutorial/views/default.py
- :lines: 22-42
- :lineno-match:
- :linenos:
- :language: python
+ :lines: 21-41
+ :lineno-match:
+ :linenos:
+ :language: python
``view_page()`` is used to display a single page of our wiki. It renders the
:term:`reStructuredText` body of a page (stored as the ``data`` attribute of a
@@ -241,10 +265,10 @@ The ``edit_page`` view function
Here is the code for the ``edit_page`` view function and its decorator:
.. literalinclude:: src/views/tutorial/views/default.py
- :lines: 44-56
- :lineno-match:
- :linenos:
- :language: python
+ :lines: 43-55
+ :lineno-match:
+ :linenos:
+ :language: python
``edit_page()`` is invoked when a user clicks the "Edit this Page" button on
the view form. It renders an edit form, but it also acts as the handler for the
@@ -252,14 +276,13 @@ form which it renders. The ``matchdict`` attribute of the request passed to the
``edit_page`` view will have a ``'pagename'`` key matching the name of the page
that the user wants to edit.
-If the view execution *is* a result of a form submission (i.e., the expression
-``'form.submitted' in request.params`` is ``True``), the view grabs the
+If the view execution *is* a result of a form submission (i.e., ``request.method == 'POST'``), the view grabs the
``body`` element of the request parameters and sets it as the ``data``
attribute of the page object. It then redirects to the ``view_page`` view
of the wiki page.
If the view execution is *not* a result of a form submission (i.e., the
-expression ``'form.submitted' in request.params`` is ``False``), the view
+expression ``request.method != 'POST'``), the view
simply renders the edit form, passing the page object and a ``save_url``
which will be used as the action of the generated form.
@@ -279,10 +302,10 @@ The ``add_page`` view function
Here is the code for the ``add_page`` view function and its decorator:
.. literalinclude:: src/views/tutorial/views/default.py
- :lines: 58-
- :lineno-match:
- :linenos:
- :language: python
+ :lines: 57-
+ :lineno-match:
+ :linenos:
+ :language: python
``add_page()`` is invoked when a user clicks on a *WikiWord* which isn't yet
represented as a page in the system. The ``add_link`` function within the
@@ -301,7 +324,7 @@ the database. If it already exists, then the client is redirected to the
``edit_page`` view, else we continue to the next check.
If the view execution *is* a result of a form submission (i.e., the expression
-``'form.submitted' in request.params`` is ``True``), we grab the page body from
+``request.method == 'POST'``), we grab the page body from
the form data, create a Page object with this page body and the name taken from
``matchdict['pagename']``, and save it into the database using
``request.dbession.add``. Since we have not yet covered authentication, we
@@ -312,7 +335,7 @@ Finally, we redirect the client back to the ``view_page`` view for the newly
created page.
If the view execution is *not* a result of a form submission (i.e., the
-expression ``'form.submitted' in request.params`` is ``False``), the view
+expression ``request.method != 'POST'`` is ``False``), the view
callable renders a template. To do so, it generates a ``save_url`` which the
template uses as the form post URL during rendering. We're lazy here, so
we're going to use the same template (``templates/edit.jinja2``) for the add
@@ -339,9 +362,9 @@ Update ``tutorial/templates/layout.jinja2`` with the following content, as
indicated by the emphasized lines:
.. literalinclude:: src/views/tutorial/templates/layout.jinja2
- :linenos:
- :emphasize-lines: 11,35-37
- :language: html
+ :linenos:
+ :emphasize-lines: 11,35-37
+ :language: html
Since we're using a templating engine, we can factor common boilerplate out of
our page templates into reusable components. One method for doing this is
@@ -350,8 +373,7 @@ template inheritance via blocks.
- We have defined two placeholders in the layout template where a child
template can override the content. These blocks are named ``subtitle`` (line
11) and ``content`` (line 36).
-- Please refer to the `Jinja2 documentation `_ for more information about template
- inheritance.
+- Please refer to the `Jinja2 documentation `_ for more information about template inheritance.
The ``view.jinja2`` template
@@ -360,8 +382,8 @@ The ``view.jinja2`` template
Create ``tutorial/templates/view.jinja2`` and add the following content:
.. literalinclude:: src/views/tutorial/templates/view.jinja2
- :linenos:
- :language: html
+ :linenos:
+ :language: html
This template is used by ``view_page()`` for displaying a single wiki page.
@@ -384,9 +406,9 @@ The ``edit.jinja2`` template
Create ``tutorial/templates/edit.jinja2`` and add the following content:
.. literalinclude:: src/views/tutorial/templates/edit.jinja2
- :linenos:
- :emphasize-lines: 1,3,12,14,17
- :language: html
+ :linenos:
+ :emphasize-lines: 1,3,12,13,15,18
+ :language: html
This template serves two use cases. It is used by ``add_page()`` and
``edit_page()`` for adding and editing a wiki page. It displays a page
@@ -396,11 +418,13 @@ containing a form and which provides the following:
of the page (line 1).
- Override the ``subtitle`` block to affect the ```` tag in the
``head`` of the page (line 3).
+- Add the CSRF token to the form (line 13).
+ Without this line, attempts to edit the page would result in a ``400 Bad Request`` error.
- A 10-row by 60-column ``textarea`` field named ``body`` that is filled with
- any existing page data when it is rendered (line 14).
-- A submit button that has the name ``form.submitted`` (line 17).
+ any existing page data when it is rendered (line 15).
+- A submit button (line 18).
- The form POSTs back to the ``save_url`` argument supplied by the view (line
- 12). The view will use the ``body`` and ``form.submitted`` values.
+ 12). The view will use the ``body`` value.
The ``404.jinja2`` template
@@ -409,16 +433,16 @@ The ``404.jinja2`` template
Replace ``tutorial/templates/404.jinja2`` with the following content:
.. literalinclude:: src/views/tutorial/templates/404.jinja2
- :linenos:
- :language: html
+ :linenos:
+ :language: html
This template is linked from the ``notfound_view`` defined in
``tutorial/views/notfound.py`` as shown here:
.. literalinclude:: src/views/tutorial/views/notfound.py
- :linenos:
- :emphasize-lines: 6
- :language: python
+ :linenos:
+ :emphasize-lines: 6
+ :language: python
There are several important things to note about this configuration:
diff --git a/docs/tutorials/wiki2/src/views/.gitignore b/docs/tutorials/wiki2/src/views/.gitignore
index 1853d983c..c612e59f2 100644
--- a/docs/tutorials/wiki2/src/views/.gitignore
+++ b/docs/tutorials/wiki2/src/views/.gitignore
@@ -19,3 +19,4 @@ Data.fs*
.DS_Store
coverage
test
+*.sqlite
diff --git a/docs/tutorials/wiki2/src/views/setup.py b/docs/tutorials/wiki2/src/views/setup.py
index 500c5e599..12eabaff2 100644
--- a/docs/tutorials/wiki2/src/views/setup.py
+++ b/docs/tutorials/wiki2/src/views/setup.py
@@ -20,8 +20,8 @@ requires = [
'pyramid_tm',
'SQLAlchemy',
'transaction',
- 'zope.sqlalchemy',
'waitress',
+ 'zope.sqlalchemy',
]
tests_require = [
diff --git a/docs/tutorials/wiki2/src/views/testing.ini b/docs/tutorials/wiki2/src/views/testing.ini
new file mode 100644
index 000000000..85e5e1ae9
--- /dev/null
+++ b/docs/tutorials/wiki2/src/views/testing.ini
@@ -0,0 +1,79 @@
+###
+# app configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
+###
+
+[app:main]
+use = egg:tutorial
+
+pyramid.reload_templates = false
+pyramid.debug_authorization = false
+pyramid.debug_notfound = false
+pyramid.debug_routematch = false
+pyramid.default_locale_name = en
+
+sqlalchemy.url = sqlite:///%(here)s/testing.sqlite
+
+retry.attempts = 3
+
+[pshell]
+setup = tutorial.pshell.setup
+
+###
+# wsgi server configuration
+###
+
+[alembic]
+# path to migration scripts
+script_location = tutorial/alembic
+file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s
+# file_template = %%(rev)s_%%(slug)s
+
+[server:main]
+use = egg:waitress#main
+listen = *:6543
+
+###
+# logging configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
+###
+
+[loggers]
+keys = root, tutorial, sqlalchemy, alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_tutorial]
+level = WARN
+handlers =
+qualname = tutorial
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+# "level = INFO" logs SQL queries.
+# "level = DEBUG" logs SQL queries and results.
+# "level = WARN" logs neither. (Recommended for production systems.)
+
+[logger_alembic]
+level = WARN
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s
diff --git a/docs/tutorials/wiki2/src/views/tests/conftest.py b/docs/tutorials/wiki2/src/views/tests/conftest.py
new file mode 100644
index 000000000..2db65f887
--- /dev/null
+++ b/docs/tutorials/wiki2/src/views/tests/conftest.py
@@ -0,0 +1,125 @@
+import alembic
+import alembic.config
+import alembic.command
+import os
+from pyramid.paster import get_appsettings
+from pyramid.scripting import prepare
+from pyramid.testing import DummyRequest
+import pytest
+import transaction
+from webob.cookies import Cookie
+import webtest
+
+from tutorial import main
+from tutorial import models
+from tutorial.models.meta import Base
+
+
+def pytest_addoption(parser):
+ parser.addoption('--ini', action='store', metavar='INI_FILE')
+
+@pytest.fixture(scope='session')
+def ini_file(request):
+ # potentially grab this path from a pytest option
+ return os.path.abspath(request.config.option.ini or 'testing.ini')
+
+@pytest.fixture(scope='session')
+def app_settings(ini_file):
+ return get_appsettings(ini_file)
+
+@pytest.fixture(scope='session')
+def dbengine(app_settings, ini_file):
+ engine = models.get_engine(app_settings)
+
+ alembic_cfg = alembic.config.Config(ini_file)
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+ # run migrations to initialize the database
+ # depending on how we want to initialize the database from scratch
+ # we could alternatively call:
+ # Base.metadata.create_all(bind=engine)
+ # alembic.command.stamp(alembic_cfg, "head")
+ alembic.command.upgrade(alembic_cfg, "head")
+
+ yield engine
+
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+@pytest.fixture(scope='session')
+def app(app_settings, dbengine):
+ return main({}, dbengine=dbengine, **app_settings)
+
+@pytest.fixture
+def tm():
+ tm = transaction.TransactionManager(explicit=True)
+ tm.begin()
+ tm.doom()
+
+ yield tm
+
+ tm.abort()
+
+@pytest.fixture
+def dbsession(app, tm):
+ session_factory = app.registry['dbsession_factory']
+ return models.get_tm_session(session_factory, tm)
+
+@pytest.fixture
+def testapp(app, tm, dbsession):
+ # override request.dbsession and request.tm with our own
+ # externally-controlled values that are shared across requests but aborted
+ # at the end
+ testapp = webtest.TestApp(app, extra_environ={
+ 'HTTP_HOST': 'example.com',
+ 'tm.active': True,
+ 'tm.manager': tm,
+ 'app.dbsession': dbsession,
+ })
+
+ return testapp
+
+@pytest.fixture
+def app_request(app, tm, dbsession):
+ """
+ A real request.
+
+ This request is almost identical to a real request but it has some
+ drawbacks in tests as it's harder to mock data and is heavier.
+
+ """
+ env = prepare(registry=app.registry)
+ request = env['request']
+ request.host = 'example.com'
+
+ # without this, request.dbsession will be joined to the same transaction
+ # manager but it will be using a different sqlalchemy.orm.Session using
+ # a separate database transaction
+ request.dbsession = dbsession
+ request.tm = tm
+
+ yield request
+ env['closer']()
+
+@pytest.fixture
+def dummy_request(app, tm, dbsession):
+ """
+ A lightweight dummy request.
+
+ This request is ultra-lightweight and should be used only when the
+ request itself is not a large focus in the call-stack.
+
+ It is way easier to mock and control side-effects using this object.
+
+ - It does not have request extensions applied.
+ - Threadlocals are not properly pushed.
+
+ """
+ request = DummyRequest()
+ request.registry = app.registry
+ request.host = 'example.com'
+ request.dbsession = dbsession
+ request.tm = tm
+
+ return request
diff --git a/docs/tutorials/wiki2/src/views/tests/test_functional.py b/docs/tutorials/wiki2/src/views/tests/test_functional.py
new file mode 100644
index 000000000..dbcd8aec7
--- /dev/null
+++ b/docs/tutorials/wiki2/src/views/tests/test_functional.py
@@ -0,0 +1,13 @@
+from tutorial import models
+
+def test_my_view_success(testapp, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ res = testapp.get('/', status=200)
+ assert res.body
+
+def test_notfound(testapp):
+ res = testapp.get('/badurl', status=404)
+ assert res.status_code == 404
diff --git a/docs/tutorials/wiki2/src/views/tests/test_it.py b/docs/tutorials/wiki2/src/views/tests/test_it.py
deleted file mode 100644
index ea16534fc..000000000
--- a/docs/tutorials/wiki2/src/views/tests/test_it.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import unittest
-
-from pyramid import testing
-
-import transaction
-
-
-def dummy_request(dbsession):
- return testing.DummyRequest(dbsession=dbsession)
-
-
-class BaseTest(unittest.TestCase):
- def setUp(self):
- self.config = testing.setUp(settings={
- 'sqlalchemy.url': 'sqlite:///:memory:'
- })
- self.config.include('tutorial.models')
- settings = self.config.get_settings()
-
- from tutorial.models import (
- get_engine,
- get_session_factory,
- get_tm_session,
- )
-
- self.engine = get_engine(settings)
- session_factory = get_session_factory(self.engine)
-
- self.session = get_tm_session(session_factory, transaction.manager)
-
- def init_database(self):
- from tutorial.models.meta import Base
- Base.metadata.create_all(self.engine)
-
- def tearDown(self):
- from tutorial.models.meta import Base
-
- testing.tearDown()
- transaction.abort()
- Base.metadata.drop_all(self.engine)
-
-
-class TestMyViewSuccessCondition(BaseTest):
-
- def setUp(self):
- super(TestMyViewSuccessCondition, self).setUp()
- self.init_database()
-
- from tutorial.models import MyModel
-
- model = MyModel(name='one', value=55)
- self.session.add(model)
-
- def test_passing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info['one'].name, 'one')
- self.assertEqual(info['project'], 'myproj')
-
-
-class TestMyViewFailureCondition(BaseTest):
-
- def test_failing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info.status_int, 500)
diff --git a/docs/tutorials/wiki2/src/views/tests/test_views.py b/docs/tutorials/wiki2/src/views/tests/test_views.py
new file mode 100644
index 000000000..8ae464d03
--- /dev/null
+++ b/docs/tutorials/wiki2/src/views/tests/test_views.py
@@ -0,0 +1,23 @@
+from tutorial import models
+from tutorial.views.default import my_view
+from tutorial.views.notfound import notfound_view
+
+
+def test_my_view_failure(app_request):
+ info = my_view(app_request)
+ assert info.status_int == 500
+
+def test_my_view_success(app_request, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ info = my_view(app_request)
+ assert app_request.response.status_int == 200
+ assert info['one'].name == 'one'
+ assert info['project'] == 'myproj'
+
+def test_notfound_view(app_request):
+ info = notfound_view(app_request)
+ assert app_request.response.status_int == 404
+ assert info == {}
diff --git a/docs/tutorials/wiki2/src/views/tutorial/__init__.py b/docs/tutorials/wiki2/src/views/tutorial/__init__.py
index 5c2ba5cc0..81a22c68c 100644
--- a/docs/tutorials/wiki2/src/views/tutorial/__init__.py
+++ b/docs/tutorials/wiki2/src/views/tutorial/__init__.py
@@ -5,8 +5,9 @@ def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
with Configurator(settings=settings) as config:
- config.include('.models')
config.include('pyramid_jinja2')
+ config.include('.security')
config.include('.routes')
+ config.include('.models')
config.scan()
return config.make_wsgi_app()
diff --git a/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py
index a4209a6e9..47d77ef01 100644
--- a/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py
+++ b/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py
@@ -66,13 +66,21 @@ def includeme(config):
# use pyramid_retry to retry a request when transient exceptions occur
config.include('pyramid_retry')
- session_factory = get_session_factory(get_engine(settings))
+ # hook to share the dbengine fixture in testing
+ dbengine = settings.get('dbengine')
+ if not dbengine:
+ dbengine = get_engine(settings)
+
+ session_factory = get_session_factory(dbengine)
config.registry['dbsession_factory'] = session_factory
# make request.dbsession available for use in Pyramid
- config.add_request_method(
- # r.tm is the transaction manager used by pyramid_tm
- lambda r: get_tm_session(session_factory, r.tm),
- 'dbsession',
- reify=True
- )
+ def dbsession(request):
+ # hook to share the dbsession fixture in testing
+ dbsession = request.environ.get('app.dbsession')
+ if dbsession is None:
+ # request.tm is the transaction manager used by pyramid_tm
+ dbsession = get_tm_session(session_factory, request.tm)
+ return dbsession
+
+ config.add_request_method(dbsession, reify=True)
diff --git a/docs/tutorials/wiki2/src/views/tutorial/scripts/initialize_db.py b/docs/tutorials/wiki2/src/views/tutorial/scripts/initialize_db.py
index e6350fb36..c8034e5a5 100644
--- a/docs/tutorials/wiki2/src/views/tutorial/scripts/initialize_db.py
+++ b/docs/tutorials/wiki2/src/views/tutorial/scripts/initialize_db.py
@@ -8,6 +8,10 @@ from .. import models
def setup_models(dbsession):
+ """
+ Add or update models / fixtures in the database.
+
+ """
editor = models.User(name='editor', role='editor')
editor.set_password('editor')
dbsession.add(editor)
diff --git a/docs/tutorials/wiki2/src/views/tutorial/security.py b/docs/tutorials/wiki2/src/views/tutorial/security.py
new file mode 100644
index 000000000..216894e07
--- /dev/null
+++ b/docs/tutorials/wiki2/src/views/tutorial/security.py
@@ -0,0 +1,6 @@
+from pyramid.csrf import CookieCSRFStoragePolicy
+
+
+def includeme(config):
+ config.set_csrf_storage_policy(CookieCSRFStoragePolicy())
+ config.set_default_csrf_options(require_csrf=True)
diff --git a/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2
index aaf12413f..5edb15285 100644
--- a/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2
+++ b/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2
@@ -1,8 +1,6 @@
{% extends "layout.jinja2" %}
{% block content %}
-
diff --git a/docs/tutorials/wiki/src/tests/tutorial/views/auth.py b/docs/tutorials/wiki/src/tests/tutorial/views/auth.py
new file mode 100644
index 000000000..5062779a6
--- /dev/null
+++ b/docs/tutorials/wiki/src/tests/tutorial/views/auth.py
@@ -0,0 +1,51 @@
+from pyramid.httpexceptions import HTTPSeeOther
+from pyramid.security import (
+ forget,
+ remember,
+)
+from pyramid.view import (
+ forbidden_view_config,
+ view_config,
+)
+
+from ..security import check_password, USERS
+
+
+@view_config(context='..models.Wiki', name='login',
+ renderer='tutorial:templates/login.pt')
+@forbidden_view_config(renderer='tutorial:templates/login.pt')
+def login(request):
+ login_url = request.resource_url(request.root, 'login')
+ referrer = request.url
+ if referrer == login_url:
+ referrer = '/' # never use the login form itself as came_from
+ came_from = request.params.get('came_from', referrer)
+ message = ''
+ login = ''
+ password = ''
+ if 'form.submitted' in request.params:
+ login = request.params['login']
+ password = request.params['password']
+ if check_password(USERS.get(login), password):
+ headers = remember(request, login)
+ return HTTPSeeOther(location=came_from, headers=headers)
+ message = 'Failed login'
+ request.response.status = 400
+
+ return dict(
+ message=message,
+ url=login_url,
+ came_from=came_from,
+ login=login,
+ password=password,
+ title='Login',
+ )
+
+
+@view_config(context='..models.Wiki', name='logout')
+def logout(request):
+ headers = forget(request)
+ return HTTPSeeOther(
+ location=request.resource_url(request.context),
+ headers=headers,
+ )
diff --git a/docs/tutorials/wiki/src/tests/tutorial/views/default.py b/docs/tutorials/wiki/src/tests/tutorial/views/default.py
index 7ba99c65b..5bb21fbcd 100644
--- a/docs/tutorials/wiki/src/tests/tutorial/views/default.py
+++ b/docs/tutorials/wiki/src/tests/tutorial/views/default.py
@@ -1,30 +1,21 @@
from docutils.core import publish_parts
+from pyramid.httpexceptions import HTTPSeeOther
+from pyramid.view import view_config
import re
-from pyramid.httpexceptions import HTTPFound
-from pyramid.security import (
- forget,
- remember,
-)
-from pyramid.view import (
- forbidden_view_config,
- view_config,
- )
-
from ..models import Page
-from ..security import check_password, USERS
+
# regular expression used to find WikiWords
wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)")
-
-@view_config(context='..models.Wiki',
- permission='view')
+@view_config(context='..models.Wiki', permission='view')
def view_wiki(context, request):
- return HTTPFound(location=request.resource_url(context, 'FrontPage'))
+ return HTTPSeeOther(location=request.resource_url(context, 'FrontPage'))
-@view_config(context='..models.Page', renderer='tutorial:templates/view.pt',
+@view_config(context='..models.Page',
+ renderer='tutorial:templates/view.pt',
permission='view')
def view_page(context, request):
wiki = context.__parent__
@@ -42,8 +33,7 @@ def view_page(context, request):
page_text = publish_parts(context.data, writer_name='html')['html_body']
page_text = wikiwords.sub(check, page_text)
edit_url = request.resource_url(context, 'edit_page')
- return dict(page=context, page_text=page_text, edit_url=edit_url,
- logged_in=request.authenticated_userid)
+ return dict(page=context, page_text=page_text, edit_url=edit_url)
@view_config(name='add_page', context='..models.Wiki',
@@ -57,13 +47,12 @@ def add_page(context, request):
page.__name__ = pagename
page.__parent__ = context
context[pagename] = page
- return HTTPFound(location=request.resource_url(page))
+ return HTTPSeeOther(location=request.resource_url(page))
save_url = request.resource_url(context, 'add_page', pagename)
page = Page('')
page.__name__ = pagename
page.__parent__ = context
- return dict(page=page, save_url=save_url,
- logged_in=request.authenticated_userid)
+ return dict(page=page, save_url=save_url)
@view_config(name='edit_page', context='..models.Page',
@@ -72,46 +61,9 @@ def add_page(context, request):
def edit_page(context, request):
if 'form.submitted' in request.params:
context.data = request.params['body']
- return HTTPFound(location=request.resource_url(context))
-
- return dict(page=context,
- save_url=request.resource_url(context, 'edit_page'),
- logged_in=request.authenticated_userid)
-
-
-@view_config(context='..models.Wiki', name='login',
- renderer='tutorial:templates/login.pt')
-@forbidden_view_config(renderer='tutorial:templates/login.pt')
-def login(request):
- login_url = request.resource_url(request.context, 'login')
- referrer = request.url
- if referrer == login_url:
- referrer = '/' # never use the login form itself as came_from
- came_from = request.params.get('came_from', referrer)
- message = ''
- login = ''
- password = ''
- if 'form.submitted' in request.params:
- login = request.params['login']
- password = request.params['password']
- if check_password(USERS.get(login), password):
- headers = remember(request, login)
- return HTTPFound(location=came_from,
- headers=headers)
- message = 'Failed login'
+ return HTTPSeeOther(location=request.resource_url(context))
return dict(
- message=message,
- url=request.application_url + '/login',
- came_from=came_from,
- login=login,
- password=password,
- title='Login',
+ page=context,
+ save_url=request.resource_url(context, 'edit_page'),
)
-
-
-@view_config(context='..models.Wiki', name='logout')
-def logout(request):
- headers = forget(request)
- return HTTPFound(location=request.resource_url(request.context),
- headers=headers)
diff --git a/docs/tutorials/wiki/tests.rst b/docs/tutorials/wiki/tests.rst
index f710b3b10..e563b174e 100644
--- a/docs/tutorials/wiki/tests.rst
+++ b/docs/tutorials/wiki/tests.rst
@@ -4,45 +4,98 @@
Adding Tests
============
-We will now add tests for the models and the views and a few functional tests in ``tests/test_it.py``.
+We will now add tests for the models and the views and a few functional tests in the ``tests`` package.
Tests ensure that an application works, and that it continues to work when changes are made in the future.
-Test the models
-===============
+Test harness
+============
-We write tests for the ``model`` classes and the ``appmaker``.
-We will modify our ``test_it.py`` file, writing a separate test class for each ``model`` class.
-We will also write a test class for the ``appmaker``.
+The project came bootstrapped with some tests and a basic harness.
+These are located in the ``tests`` package at the top-level of the project.
+It is a common practice to put tests into a ``tests`` package alongside the application package, especially as projects grow in size and complexity.
+A useful convention is for each module in the application to contain a corresponding module in the ``tests`` package.
+The test module would have the same name with the prefix ``test_``.
-We will add three test classes, one for each of the following:
+The harness consists of the following setup:
-- the ``Page`` model named ``PageModelTests``
-- the ``Wiki`` model named ``WikiModelTests``
-- the appmaker named ``AppmakerTests``
+- ``pytest.ini`` - controls basic ``pytest`` config including where to find the tests.
+ We have configured ``pytest`` to search for tests in the application package and in the ``tests`` package.
+- ``.coveragerc`` - controls coverage config.
+ In our setup, it works with the ``pytest-cov`` plugin that we use via the ``--cov`` options to the ``pytest`` command.
-Test the views
-==============
+- ``testing.ini`` - a mirror of ``development.ini`` and ``production.ini`` that contains settings used for executing the test suite.
+ Most importantly, it contains the database connection information used by tests that require the database.
-We will modify our ``test_it.py`` file, adding tests for each view function that we added previously.
-As a result, we will delete the ``ViewTests`` class that the ``zodb`` backend option provided, and add four other test classes: ``ViewWikiTests``, ``ViewPageTests``, ``AddPageTests``, and ``EditPageTests``.
-These test the ``view_wiki``, ``view_page``, ``add_page``, and ``edit_page`` views.
+- ``tests_require`` in ``setup.py`` - controls the dependencies installed when testing.
+ When the list is changed, it's necessary to re-run ``$VENV/bin/pip install -e ".[testing]"`` to ensure the new dependencies are installed.
+- ``tests/conftest.py`` - the core fixtures available throughout our tests.
+ The fixtures are explained in more detail below.
-Functional tests
-================
-We will test the whole application, covering security aspects that are not tested in the unit tests, such as logging in, logging out, checking that the ``viewer`` user cannot add or edit pages, but the ``editor`` user can, and so on.
-As a result we will add two test classes, ``SecurityTests`` and ``FunctionalTests``.
+Session-scoped test fixtures
+----------------------------
+
+- ``app_settings`` - the settings ``dict`` parsed from the ``testing.ini`` file that would normally be passed by ``pserve`` into your app's ``main`` function.
+
+- ``app`` - the :app:`Pyramid` WSGI application, implementing the :class:`pyramid.interfaces.IRouter` interface.
+ Most commonly this would be used for functional tests.
+
+
+Per-test fixtures
+-----------------
+
+- ``tm`` - a :class:`transaction.TransactionManager` object controlling a transaction lifecycle.
+ Generally other fixtures would join to the ``tm`` fixture to control their lifecycle and ensure they are aborted at the end of the test.
+
+- ``testapp`` - a :class:`webtest.TestApp` instance wrapping the ``app`` and is used to sending requests into the application and return full response objects that can be inspected.
+ The ``testapp`` is able to mutate the request environ such that the ``tm`` fixture is injected and used by any code that's touching ``request.tm``.
+ This should join the ``request.root`` ZODB model to the transaction manager as well, to enable rolling back changes to the database.
+ The ``testapp`` maintains a cookiejar, so it can be used to share state across requests, as well as the transaction database connection.
+
+- ``app_request`` - a :class:`pyramid.request.Request` object that can be used for more lightweight tests versus the full ``testapp``.
+ The ``app_request`` can be passed to view functions and other code that need a fully functional request object.
+
+- ``dummy_request`` - a :class:`pyramid.testing.DummyRequest` object that is very lightweight.
+ This is a great object to pass to view functions that have minimal side-effects as it'll be fast and simple.
+
+
+Unit tests
+==========
+
+We can test individual APIs within our codebase to ensure they fulfill the expected contract that the rest of the application expects.
+For example, we'll test the password hashing features we added to ``tutorial.security`` and the rest of our models.
+
+Create ``tests/test_models.py`` such that it appears as follows:
+.. literalinclude:: src/tests/tests/test_models.py
+ :linenos:
+ :language: python
+
+
+Integration tests
+=================
+
+We can directly execute the view code, bypassing :app:`Pyramid` and testing just the code that we've written.
+These tests use dummy requests that we'll prepare appropriately to set the conditions each view expects.
+
+Update ``tests/test_views.py`` such that it appears as follows:
+
+.. literalinclude:: src/tests/tests/test_views.py
+ :linenos:
+ :language: python
+
+
+Functional tests
+================
-View the results of all our edits to ``tests/test_it.py``
-=========================================================
+We'll test the whole application, covering security aspects that are not tested in the unit and integration tests, like logging in, logging out, checking that the ``basic`` user cannot edit pages that it didn't create but the ``editor`` user can, and so on.
-Open the ``tests/test_it.py`` module, and edit it such that it appears as follows:
+Update ``tests/test_functional.py`` such that it appears as follows:
-.. literalinclude:: src/tests/tests/test_it.py
+.. literalinclude:: src/tests/tests/test_functional.py
:linenos:
:language: python
@@ -72,4 +125,4 @@ The expected result should look like the following:
.. code-block:: text
.........................
- 25 passed in 6.87 seconds
+ 25 passed in 3.87 seconds
--
cgit v1.2.3
From b4eda298728883eaa767b1fd21146c6b4202eb17 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 00:26:32 -0800
Subject: grammar fix
---
docs/tutorials/wiki2/index.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki2/index.rst b/docs/tutorials/wiki2/index.rst
index 40a194155..69e69684b 100644
--- a/docs/tutorials/wiki2/index.rst
+++ b/docs/tutorials/wiki2/index.rst
@@ -8,7 +8,7 @@ This tutorial introduces an :term:`SQLAlchemy` and :term:`URL dispatch`-based
application with authentication and authorization.
For cut and paste purposes, the source code for all stages of this
-tutorial can be browsed on GitHub at `GitHub `_ for a specific branch or version under ``docs/tutorials/wiki2/src``,
+tutorial can be browsed on `GitHub `_ for a specific branch or version under ``docs/tutorials/wiki2/src``,
which corresponds to the same location if you have Pyramid sources.
.. toctree::
--
cgit v1.2.3
From 3a23085dc7ad597900c0ad4cbca8a342329386d2 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 01:01:42 -0800
Subject: Update version numbers since last run to the latest
---
docs/tutorials/wiki2/installation.rst | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/tutorials/wiki2/installation.rst b/docs/tutorials/wiki2/installation.rst
index 9defef31a..3da8a8f17 100644
--- a/docs/tutorials/wiki2/installation.rst
+++ b/docs/tutorials/wiki2/installation.rst
@@ -179,15 +179,15 @@ The console will show ``pip`` checking for packages and installing missing packa
Successfully installed Jinja2-2.10.3 Mako-1.1.0 MarkupSafe-1.1.1 \
PasteDeploy-2.0.1 Pygments-2.5.2 SQLAlchemy-1.3.12 WebTest-2.0.33 \
- alembic-1.3.2 attrs-19.3.0 beautifulsoup4-4.8.2 coverage-5.0.1 \
- hupper-1.9.1 importlib-metadata-1.3.0 more-itertools-8.0.2 packaging-19.2 \
+ alembic-1.3.2 attrs-19.3.0 beautifulsoup4-4.8.2 coverage-5.0.3 \
+ hupper-1.9.1 importlib-metadata-1.4.0 more-itertools-8.1.0 packaging-20.0 \
plaster-1.0 plaster-pastedeploy-0.7 pluggy-0.13.1 py-1.8.1 \
pyparsing-2.4.6 pyramid-1.10.4 pyramid-debugtoolbar-4.5.2 \
pyramid-jinja2-2.8 pyramid-mako-1.1.0 pyramid-retry-2.1 pyramid-tm-2.4 \
pytest-5.3.2 pytest-cov-2.8.1 python-dateutil-2.8.1 python-editor-1.0.4 \
repoze.lru-0.7 six-1.13.0 soupsieve-1.9.5 transaction-3.0.0 \
- translationstring-1.3 tutorial venusian-3.0.0 waitress-1.4.1 \
- wcwidth-0.1.7 webob-1.8.5 zipp-0.6.0 zope.deprecation-4.4.0 \
+ translationstring-1.3 tutorial venusian-3.0.0 waitress-1.4.2 \
+ wcwidth-0.1.8 webob-1.8.5 zipp-0.6.0 zope.deprecation-4.4.0 \
zope.interface-4.7.1 zope.sqlalchemy-1.2
Testing requirements are defined in our project's ``setup.py`` file, in the ``tests_require`` and ``extras_require`` stanzas.
--
cgit v1.2.3
From 715988f6946402a940a4b670b2a1b9ae7423b0aa Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 01:19:22 -0800
Subject: Improve requires section - Improve narrative flow - Remove
test_requires from literalinclude
---
docs/tutorials/wiki2/definingmodels.rst | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/docs/tutorials/wiki2/definingmodels.rst b/docs/tutorials/wiki2/definingmodels.rst
index f84ca6588..129d77806 100644
--- a/docs/tutorials/wiki2/definingmodels.rst
+++ b/docs/tutorials/wiki2/definingmodels.rst
@@ -29,10 +29,10 @@ We need to add a dependency, the `bcrypt `_ pa
package's ``setup.py`` file by assigning this dependency to the ``requires``
parameter in the ``setup()`` function.
-Open ``tutorial/setup.py`` and edit it to look like the following:
+Open ``tutorial/setup.py`` and edit it to look like the following by adding ``bcrypt`` and sorting the packages:
.. literalinclude:: src/models/setup.py
- :lines: 11-30
+ :lines: 11-24
:linenos:
:lineno-match:
:emphasize-lines: 3
@@ -40,7 +40,6 @@ Open ``tutorial/setup.py`` and edit it to look like the following:
It is a good practice to sort packages alphabetically to make them easier to find.
Our cookiecutter does not have its packages sorted because it merely tacks on additional packages depending on our selections.
-After adding ``bcrypt`` and sorting packages, we should have the above ``requires`` list.
.. note::
--
cgit v1.2.3
From fd553ef2592587422bfce1139cfba3d8945882c1 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 01:29:43 -0800
Subject: Remove tests_require from literalinclude
---
docs/tutorials/wiki2/definingviews.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki2/definingviews.rst b/docs/tutorials/wiki2/definingviews.rst
index 122164083..b3c9487ec 100644
--- a/docs/tutorials/wiki2/definingviews.rst
+++ b/docs/tutorials/wiki2/definingviews.rst
@@ -31,7 +31,7 @@ list.
Open ``tutorial/setup.py`` and edit it to look like the following:
.. literalinclude:: src/views/setup.py
- :lines: 11-31
+ :lines: 11-25
:lineno-match:
:emphasize-lines: 4
:language: python
--
cgit v1.2.3
From a3b0a6d480ad0995d3acd4ae6d7c731bbe8cbea5 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 01:35:53 -0800
Subject: Remove emphasize-lines because the entire module is new, and there is
no narrative reference to the emphasized lines
---
docs/tutorials/wiki2/definingviews.rst | 1 -
1 file changed, 1 deletion(-)
diff --git a/docs/tutorials/wiki2/definingviews.rst b/docs/tutorials/wiki2/definingviews.rst
index b3c9487ec..ca2760de5 100644
--- a/docs/tutorials/wiki2/definingviews.rst
+++ b/docs/tutorials/wiki2/definingviews.rst
@@ -130,7 +130,6 @@ Let's add a new ``tutorial/security.py`` file:
.. literalinclude:: src/views/tutorial/security.py
:linenos:
- :emphasize-lines: 5-6
:language: python
Since we've added a new ``tutorial/security.py`` module, we need to include it.
--
cgit v1.2.3
From ef8bf6f1c6b616812a96e7b582bcdf2f7038e7f3 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 01:41:02 -0800
Subject: Remove emphasize-lines because the entire module is new and
highlighting doesn't help here, just adds maintenance
---
docs/tutorials/wiki2/definingviews.rst | 1 -
1 file changed, 1 deletion(-)
diff --git a/docs/tutorials/wiki2/definingviews.rst b/docs/tutorials/wiki2/definingviews.rst
index ca2760de5..367b45163 100644
--- a/docs/tutorials/wiki2/definingviews.rst
+++ b/docs/tutorials/wiki2/definingviews.rst
@@ -406,7 +406,6 @@ Create ``tutorial/templates/edit.jinja2`` and add the following content:
.. literalinclude:: src/views/tutorial/templates/edit.jinja2
:linenos:
- :emphasize-lines: 1,3,12,13,15,18
:language: html
This template serves two use cases. It is used by ``add_page()`` and
--
cgit v1.2.3
From 24c9a330c0941e0e63be4e0809c72d972a2fb7fa Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 01:43:46 -0800
Subject: Align emphasis to method name
---
docs/tutorials/wiki2/definingviews.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki2/definingviews.rst b/docs/tutorials/wiki2/definingviews.rst
index 367b45163..c4712faf0 100644
--- a/docs/tutorials/wiki2/definingviews.rst
+++ b/docs/tutorials/wiki2/definingviews.rst
@@ -439,7 +439,7 @@ This template is linked from the ``notfound_view`` defined in
.. literalinclude:: src/views/tutorial/views/notfound.py
:linenos:
- :emphasize-lines: 6
+ :emphasize-lines: 5
:language: python
There are several important things to note about this configuration:
--
cgit v1.2.3
From 05f9ff6e2d1d89af68a70ab52894f6575377f78a Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 01:49:57 -0800
Subject: Align line numbers to code - @mmerickel can you check the wording to
ensure it is still correct for the code?
---
docs/tutorials/wiki2/authentication.rst | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/tutorials/wiki2/authentication.rst b/docs/tutorials/wiki2/authentication.rst
index c799d79bf..5e0077b20 100644
--- a/docs/tutorials/wiki2/authentication.rst
+++ b/docs/tutorials/wiki2/authentication.rst
@@ -43,8 +43,8 @@ Update ``tutorial/security.py`` with the following content:
Here we've defined:
-* A new security policy named ``MySecurityPolicy``, which is implementing most of the :class:`pyramid.interfaces.ISecurityPolicy` interface by tracking a :term:`identity` using a signed cookie implemented by :class:`pyramid.authentication.AuthTktCookieHelper` (lines 7-29).
-* The ``request.user`` computed property is registered for use throughout our application as the authenticated ``tutorial.models.User`` object for the logged-in user (line 38-39).
+* A new security policy named ``MySecurityPolicy``, which is implementing most of the :class:`pyramid.interfaces.ISecurityPolicy` interface by tracking a :term:`identity` using a signed cookie implemented by :class:`pyramid.authentication.AuthTktCookieHelper` (lines 8-34).
+* The ``request.user`` computed property is registered for use throughout our application as the authenticated ``tutorial.models.User`` object for the logged-in user (line 42-44).
Our new :term:`security policy` defines how our application will remember, forget, and identify users.
It also handles authorization, which we'll cover in the next chapter (if you're wondering why we didn't implement the ``permits`` method yet).
--
cgit v1.2.3
From 459b0c7051f5a0c4e4ef7adf1e51e3548dba6b39 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 01:51:37 -0800
Subject: Use reST numbered list syntax, not markdown
---
docs/tutorials/wiki2/authentication.rst | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/docs/tutorials/wiki2/authentication.rst b/docs/tutorials/wiki2/authentication.rst
index 5e0077b20..25240b191 100644
--- a/docs/tutorials/wiki2/authentication.rst
+++ b/docs/tutorials/wiki2/authentication.rst
@@ -49,20 +49,20 @@ Here we've defined:
Our new :term:`security policy` defines how our application will remember, forget, and identify users.
It also handles authorization, which we'll cover in the next chapter (if you're wondering why we didn't implement the ``permits`` method yet).
-Identifying the current user is done in a couple steps:
+Identifying the current user is done in a few steps:
-1. :app:`Pyramid` invokes a method on the policy requesting identity, userid, or permission to perform an operation.
+#. :app:`Pyramid` invokes a method on the policy requesting identity, userid, or permission to perform an operation.
-1. The policy starts by calling :meth:`pyramid.request.RequestLocalCache.get_or_create` to load the identity.
+#. The policy starts by calling :meth:`pyramid.request.RequestLocalCache.get_or_create` to load the identity.
-1. The ``MySecurityPolicy.load_identity`` method asks the cookie helper to pull the identity from the request.
+#. The ``MySecurityPolicy.load_identity`` method asks the cookie helper to pull the identity from the request.
This value is ``None`` if the cookie is missing or the content cannot be verified.
-1. The policy then translates the identity into a ``tutorial.models.User`` object by looking for a record in the database.
+#. The policy then translates the identity into a ``tutorial.models.User`` object by looking for a record in the database.
This is a good spot to confirm that the user is actually allowed to access our application.
For example, maybe they were marked deleted or banned and we should return ``None`` instead of the ``user`` object.
-1. The result is stored in the ``identity_cache`` which ensures that subsequent invocations return the same identity object for the request.
+#. The result is stored in the ``identity_cache`` which ensures that subsequent invocations return the same identity object for the request.
Finally, :attr:`pyramid.request.Request.authenticated_identity` contains either ``None`` or a ``tutorial.models.User`` instance and that value is aliased to ``request.user`` for convenience in our application.
--
cgit v1.2.3
From 6ca6370c751dd7a629f7057a79941764c71d4aeb Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 02:07:07 -0800
Subject: Use correct verb
---
docs/tutorials/wiki2/authentication.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki2/authentication.rst b/docs/tutorials/wiki2/authentication.rst
index 25240b191..5519a967e 100644
--- a/docs/tutorials/wiki2/authentication.rst
+++ b/docs/tutorials/wiki2/authentication.rst
@@ -129,7 +129,7 @@ Open the file ``tutorial/views/default.py`` and fix the following import:
:emphasize-lines: 2
:language: python
-Change the highlighted line.
+Insert the highlighted line.
In the same file, now edit the ``edit_page`` view function:
--
cgit v1.2.3
From 57e7a4a3571bb6ea1bfd767c2c50674ede944ca6 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 02:13:25 -0800
Subject: from webob.cookies import Cookie already existed; restore deleted
comment
---
docs/tutorials/wiki2/src/tests/tests/conftest.py | 1 +
docs/tutorials/wiki2/tests.rst | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki2/src/tests/tests/conftest.py b/docs/tutorials/wiki2/src/tests/tests/conftest.py
index 094bc06f1..1c8fb16d0 100644
--- a/docs/tutorials/wiki2/src/tests/tests/conftest.py
+++ b/docs/tutorials/wiki2/src/tests/tests/conftest.py
@@ -39,6 +39,7 @@ def dbengine(app_settings, ini_file):
# depending on how we want to initialize the database from scratch
# we could alternatively call:
# Base.metadata.create_all(bind=engine)
+ # alembic.command.stamp(alembic_cfg, "head")
alembic.command.upgrade(alembic_cfg, "head")
yield engine
diff --git a/docs/tutorials/wiki2/tests.rst b/docs/tutorials/wiki2/tests.rst
index 8a3e79363..cea07eeca 100644
--- a/docs/tutorials/wiki2/tests.rst
+++ b/docs/tutorials/wiki2/tests.rst
@@ -88,7 +88,7 @@ Update ``tests/conftest.py`` to look like the following, adding the highlighted
.. literalinclude:: src/tests/tests/conftest.py
:linenos:
- :emphasize-lines: 10,68-103,110,117-119
+ :emphasize-lines: 68-103,110,117-119
:language: python
--
cgit v1.2.3
From 84225a4db96dc08fa5923c29777fadbbaad0a098 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 02:14:38 -0800
Subject: realign emphasize-lines
---
docs/tutorials/wiki2/tests.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki2/tests.rst b/docs/tutorials/wiki2/tests.rst
index cea07eeca..03e90458c 100644
--- a/docs/tutorials/wiki2/tests.rst
+++ b/docs/tutorials/wiki2/tests.rst
@@ -88,7 +88,7 @@ Update ``tests/conftest.py`` to look like the following, adding the highlighted
.. literalinclude:: src/tests/tests/conftest.py
:linenos:
- :emphasize-lines: 68-103,110,117-119
+ :emphasize-lines: 69-104,111,118-120
:language: python
--
cgit v1.2.3
From 56fd7cbeaf107405cca2399d2b0bba60c8e60010 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 05:19:17 -0800
Subject: grammar fix
---
docs/tutorials/wiki/index.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki/index.rst b/docs/tutorials/wiki/index.rst
index 7bd58656b..a45e7f3e2 100644
--- a/docs/tutorials/wiki/index.rst
+++ b/docs/tutorials/wiki/index.rst
@@ -10,7 +10,7 @@ finished, the developer will have created a basic Wiki application with
authentication.
For cut and paste purposes, the source code for all stages of this
-tutorial can be browsed on GitHub at `GitHub `_ for a specific branch or version under ``docs/tutorials/wiki/src``,
+tutorial can be browsed on `GitHub `_ for a specific branch or version under ``docs/tutorials/wiki/src``,
which corresponds to the same location if you have Pyramid sources.
.. toctree::
--
cgit v1.2.3
From 04cef9070f8554a9a025ae968368d03b4ed939c2 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Mon, 13 Jan 2020 05:32:13 -0800
Subject: update output of package versions, coverage. fix command.
---
docs/tutorials/wiki/installation.rst | 32 ++++++++++++++++----------------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/docs/tutorials/wiki/installation.rst b/docs/tutorials/wiki/installation.rst
index 392441eae..ae8b2b8f6 100644
--- a/docs/tutorials/wiki/installation.rst
+++ b/docs/tutorials/wiki/installation.rst
@@ -188,15 +188,15 @@ The console will show ``pip`` checking for packages and installing missing packa
Successfully installed BTrees-4.6.1 Chameleon-3.6.2 Mako-1.1.0 \
MarkupSafe-1.1.1 PasteDeploy-2.0.1 Pygments-2.5.2 WebTest-2.0.33 \
ZConfig-3.5.0 ZEO-5.2.1 ZODB-5.5.1 ZODB3-3.11.0 attrs-19.3.0 \
- beautifulsoup4-4.8.2 cffi-1.13.2 coverage-5.0.1 hupper-1.9.1 \
- importlib-metadata-1.3.0 more-itertools-8.0.2 packaging-19.2 \
+ beautifulsoup4-4.8.2 cffi-1.13.2 coverage-5.0.3 hupper-1.9.1 \
+ importlib-metadata-1.4.0 more-itertools-8.1.0 packaging-20.0 \
persistent-4.5.1 plaster-1.0 plaster-pastedeploy-0.7 pluggy-0.13.1 \
py-1.8.1 pycparser-2.19 pyparsing-2.4.6 pyramid-1.10.4 \
- pyramid-chameleon-0.3 pyramid-debugtoolbar-4.5.1 pyramid-mako-1.1.0 \
- pyramid-retry-2.1 pyramid-tm-2.3 pyramid-zodbconn-0.8.1 pytest-5.3.2 \
+ pyramid-chameleon-0.3 pyramid-debugtoolbar-4.5.2 pyramid-mako-1.1.0 \
+ pyramid-retry-2.1 pyramid-tm-2.4 pyramid-zodbconn-0.8.1 pytest-5.3.2 \
pytest-cov-2.8.1 repoze.lru-0.7 six-1.13.0 soupsieve-1.9.5 \
transaction-3.0.0 translationstring-1.3 tutorial venusian-3.0.0 \
- waitress-1.4.1 wcwidth-0.1.7 webob-1.8.5 zc.lockfile-2.0 zdaemon-4.3 \
+ waitress-1.4.2 wcwidth-0.1.8 webob-1.8.5 zc.lockfile-2.0 zdaemon-4.3 \
zipp-0.6.0 zodbpickle-2.0.0 zodburi-2.4.0 zope.deprecation-4.4.0 \
zope.interface-4.7.1
@@ -279,25 +279,25 @@ If successful, you will see output something like this:
platform darwin -- Python 3.7.3, pytest-5.3.2, py-1.8.1, pluggy-0.13.1
rootdir: /filepath/tutorial, inifile: pytest.ini, testpaths: tutorial
plugins: cov-2.8.1
- collected 2 items
+ collected 4 items
- tutorial/tests.py .. [100%]
+ tests/test_functional.py .. [ 50%]
+ tests/test_views.py .. [100%]
-
- ---------- coverage: platform darwin, python 3.7.0-final-0 -----------
+ ---------- coverage: platform darwin, python 3.7.3-final-0 -----------
Name Stmts Miss Cover Missing
-----------------------------------------------------------
- tutorial/__init__.py 16 11 31% 7-8, 14-22
- tutorial/models/__init__.py 8 4 50% 9-12
- tutorial/pshell.py 6 6 0% 1-12
- tutorial/routes.py 2 2 0% 1-2
+ tutorial/__init__.py 16 0 100%
+ tutorial/models/__init__.py 8 0 100%
+ tutorial/pshell.py 6 4 33% 5-12
+ tutorial/routes.py 2 0 100%
tutorial/views/__init__.py 0 0 100%
tutorial/views/default.py 4 0 100%
tutorial/views/notfound.py 4 0 100%
-----------------------------------------------------------
- TOTAL 40 23 42%
+ TOTAL 40 4 90%
- ===================== 2 passed in 0.55 seconds =======================
+ ===================== 4 passed in 0.85 seconds =======================
Our package doesn't quite have 100% test coverage.
@@ -317,7 +317,7 @@ On Unix
.. code-block:: bash
- $VENV/bin/pytest --cov=tutorial tests.py -q
+ $VENV/bin/pytest --cov=tutorial tests -q
On Windows
^^^^^^^^^^
--
cgit v1.2.3
From 0be9d736de379efcdc858936cb91f7edb2fa5563 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Tue, 14 Jan 2020 03:34:04 -0800
Subject: Update test output
---
docs/tutorials/wiki2/tests.rst | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/tutorials/wiki2/tests.rst b/docs/tutorials/wiki2/tests.rst
index 03e90458c..9c2312ec5 100644
--- a/docs/tutorials/wiki2/tests.rst
+++ b/docs/tutorials/wiki2/tests.rst
@@ -150,7 +150,7 @@ The expected result should look like the following:
.. code-block:: text
- ...............................
- 31 passed in 8.85 seconds
+ ........................... [100%]
+ 27 passed in 6.91s
.. _webtest: https://docs.pylonsproject.org/projects/webtest/en/latest/
--
cgit v1.2.3
From 6d3e713a603b2b3663bb77af9884505419f8879c Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Tue, 14 Jan 2020 03:39:49 -0800
Subject: Revert emphasis removal
---
docs/tutorials/wiki2/tests.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki2/tests.rst b/docs/tutorials/wiki2/tests.rst
index 9c2312ec5..1bf38d988 100644
--- a/docs/tutorials/wiki2/tests.rst
+++ b/docs/tutorials/wiki2/tests.rst
@@ -88,7 +88,7 @@ Update ``tests/conftest.py`` to look like the following, adding the highlighted
.. literalinclude:: src/tests/tests/conftest.py
:linenos:
- :emphasize-lines: 69-104,111,118-120
+ :emphasize-lines: 10,69-104,111,118-120
:language: python
--
cgit v1.2.3
From 907c7c0ff9732ecdafab2d6b0c599d86fe0e6a80 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Tue, 14 Jan 2020 03:47:31 -0800
Subject: Remove Windows command prompt
---
docs/tutorials/wiki2/installation.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki2/installation.rst b/docs/tutorials/wiki2/installation.rst
index 3da8a8f17..8c897fc86 100644
--- a/docs/tutorials/wiki2/installation.rst
+++ b/docs/tutorials/wiki2/installation.rst
@@ -342,7 +342,7 @@ On Windows
.. code-block:: doscon
- c:\tutorial> %VENV%\Scripts\pytest --cov --cov-report=term-missing
+ %VENV%\Scripts\pytest --cov --cov-report=term-missing
If successful, you will see output something like this:
--
cgit v1.2.3
From 87f6d25d27bb3b08e620f7aed206687a93fedde5 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Tue, 14 Jan 2020 03:59:22 -0800
Subject: Align Windows command with Unix
---
docs/tutorials/wiki2/installation.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki2/installation.rst b/docs/tutorials/wiki2/installation.rst
index 8c897fc86..1e22fb624 100644
--- a/docs/tutorials/wiki2/installation.rst
+++ b/docs/tutorials/wiki2/installation.rst
@@ -403,7 +403,7 @@ On Windows
.. code-block:: doscon
- %VENV%\Scripts\pytest --cov=tutorial tutorial\tests.py -q
+ %VENV%\Scripts\pytest --cov=tutorial tests -q
pytest follows :ref:`conventions for Python test discovery
`, and the configuration defaults from the cookiecutter
--
cgit v1.2.3
From 3152aa5e4b1cf053f94725d3dc8069bfe20a14be Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Wed, 15 Jan 2020 03:29:58 -0800
Subject: Remove bits that demo what one would need to do without defaults -
Sync up wiki with language from wiki2
---
docs/tutorials/wiki/installation.rst | 17 -----------------
docs/tutorials/wiki2/installation.rst | 29 +++++------------------------
2 files changed, 5 insertions(+), 41 deletions(-)
diff --git a/docs/tutorials/wiki/installation.rst b/docs/tutorials/wiki/installation.rst
index ae8b2b8f6..4de9b4b9c 100644
--- a/docs/tutorials/wiki/installation.rst
+++ b/docs/tutorials/wiki/installation.rst
@@ -309,23 +309,6 @@ Test and coverage cookiecutter defaults
The Pyramid cookiecutter includes configuration defaults for ``pytest`` and test coverage.
These configuration files are ``pytest.ini`` and ``.coveragerc``, located at the root of your package.
-Without these defaults, we would need to specify the path to the module on which we want to run tests and coverage.
-
-
-On Unix
-^^^^^^^
-
-.. code-block:: bash
-
- $VENV/bin/pytest --cov=tutorial tests -q
-
-On Windows
-^^^^^^^^^^
-
-.. code-block:: doscon
-
- %VENV%\Scripts\pytest --cov=tutorial tests -q
-
``pytest`` follows :ref:`conventions for Python test discovery `.
The configuration defaults from the cookiecutter tell ``pytest`` where to find the module on which we want to run tests and coverage.
diff --git a/docs/tutorials/wiki2/installation.rst b/docs/tutorials/wiki2/installation.rst
index 1e22fb624..f016f19df 100644
--- a/docs/tutorials/wiki2/installation.rst
+++ b/docs/tutorials/wiki2/installation.rst
@@ -386,32 +386,13 @@ Our package doesn't quite have 100% test coverage.
Test and coverage cookiecutter defaults
---------------------------------------
-Cookiecutters include configuration defaults for ``pytest`` and test coverage.
-These configuration files are ``pytest.ini`` and ``.coveragerc``, located at
-the root of your package. Without these defaults, we would need to specify the
-path to the module on which we want to run tests and coverage.
+The Pyramid cookiecutter includes configuration defaults for ``pytest`` and test coverage.
+These configuration files are ``pytest.ini`` and ``.coveragerc``, located at the root of your package.
-On Unix
-^^^^^^^
-
-.. code-block:: bash
-
- $VENV/bin/pytest --cov=tutorial tests -q
-
-On Windows
-^^^^^^^^^^
-
-.. code-block:: doscon
-
- %VENV%\Scripts\pytest --cov=tutorial tests -q
-
-pytest follows :ref:`conventions for Python test discovery
-`, and the configuration defaults from the cookiecutter
-tell ``pytest`` where to find the module on which we want to run tests and
-coverage.
+``pytest`` follows :ref:`conventions for Python test discovery `.
+The configuration defaults from the cookiecutter tell ``pytest`` where to find the module on which we want to run tests and coverage.
-.. seealso:: See ``pytest``'s documentation for :ref:`pytest:usage` or invoke
- ``pytest -h`` to see its full set of options.
+.. seealso:: See ``pytest``'s documentation for :ref:`pytest:usage` or invoke ``pytest -h`` to see its full set of options.
.. _wiki2-start-the-application:
--
cgit v1.2.3
From f2c900097e709c71c893cce6492c6d8d1cdb25f2 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Wed, 15 Jan 2020 05:25:13 -0800
Subject: Update emphasize-lines for definingmodels.rst to clarify changes and
fix alignment.
---
docs/tutorials/wiki/definingmodels.rst | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/docs/tutorials/wiki/definingmodels.rst b/docs/tutorials/wiki/definingmodels.rst
index d4402915a..3b1e38c7d 100644
--- a/docs/tutorials/wiki/definingmodels.rst
+++ b/docs/tutorials/wiki/definingmodels.rst
@@ -43,8 +43,11 @@ Open ``tutorial/models/__init__.py`` file and edit it to look like the following
.. literalinclude:: src/models/tutorial/models/__init__.py
:linenos:
+ :emphasize-lines: 1,5-11,15-19
:language: python
+The emphasized lines indicate changes, described as follows.
+
Remove the ``MyModel`` class from the generated ``models/__init__.py`` file.
The ``MyModel`` class is only a sample and we're not going to use it.
@@ -62,6 +65,7 @@ Then we add a ``Wiki`` class.
.. literalinclude:: src/models/tutorial/models/__init__.py
:pyobject: Wiki
:lineno-match:
+ :emphasize-lines: 1-3
:language: py
We want it to inherit from the :class:`persistent.mapping.PersistentMapping` class because it provides mapping behavior.
@@ -76,6 +80,7 @@ Now we add a ``Page`` class.
.. literalinclude:: src/models/tutorial/models/__init__.py
:pyobject: Page
:lineno-match:
+ :emphasize-lines: 1-3
:language: py
This class should inherit from the :class:`persistent.Persistent` class.
@@ -93,7 +98,7 @@ As a last step, edit the ``appmaker`` function.
.. literalinclude:: src/models/tutorial/models/__init__.py
:pyobject: appmaker
:lineno-match:
- :emphasize-lines: 4-8
+ :emphasize-lines: 3-7
:language: py
The :term:`root` :term:`resource` of our application is a Wiki instance.
--
cgit v1.2.3
From 8322a2e409c94000761b9fc69fd5914c6cab9bb7 Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Wed, 15 Jan 2020 05:51:25 -0800
Subject: Update output of install of docutils - Fix line number reference
---
docs/tutorials/wiki/definingviews.rst | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/tutorials/wiki/definingviews.rst b/docs/tutorials/wiki/definingviews.rst
index 5aafd68d6..02d7bde9a 100644
--- a/docs/tutorials/wiki/definingviews.rst
+++ b/docs/tutorials/wiki/definingviews.rst
@@ -74,7 +74,7 @@ Success executing this command will end with a line to the console similar to th
.. code-block:: text
- Successfully installed docutils-0.15.2 tutorial
+ Successfully installed docutils-0.16 tutorial
Adding view functions in the ``views`` package
@@ -291,7 +291,7 @@ We can do this via :term:`METAL` macros and slots.
- The cookiecutter defined a macro named ``layout`` (line 1).
This macro consists of the entire template.
-- We changed the ``title`` tag to use the ``name`` attribute of a ``page`` object, or if it does not exist then the page title (lines 11-12).
+- We changed the ``title`` tag to use the ``name`` attribute of a ``page`` object, or if it does not exist then the page title (line 11).
- The cookiecutter defined a macro customization point or `slot` (line 35).
This slot is inside the macro ``layout``.
Therefore it can be replaced by content, customizing the macro.
--
cgit v1.2.3
From c963dd0b6aefa148a486d58f0621e83f53ea95cb Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Wed, 15 Jan 2020 23:21:18 -0800
Subject: Minor grammar fixes - Swap order of editing tutorial/views/default.py
so that line numbers in the user's editor align with the rendered docs
---
docs/tutorials/wiki/authorization.rst | 42 +++++++++++++++++------------------
1 file changed, 20 insertions(+), 22 deletions(-)
diff --git a/docs/tutorials/wiki/authorization.rst b/docs/tutorials/wiki/authorization.rst
index 1469fae44..995dfa729 100644
--- a/docs/tutorials/wiki/authorization.rst
+++ b/docs/tutorials/wiki/authorization.rst
@@ -78,7 +78,7 @@ Open the file ``tutorial/__init__.py`` and edit the following lines:
The security policy controls several aspects of authentication and authorization:
-- Identifying the current user / :term:`identity` for a ``request``.
+- Identifying the current user's :term:`identity` for a ``request``.
- Authorizating access to resources.
@@ -90,7 +90,7 @@ Identifying logged-in users
The ``MySecurityPolicy.authenticated_identity`` method inspects the ``request`` and determines if it came from an authenticated user.
It does this by utilizing the :class:`pyramid.authentication.AuthTktCookieHelper` class which stores the :term:`identity` in a cryptographically-signed cookie.
-If a ``request`` does contain an identity then we perform a final check to determine if the user is valid in our current ``USERS`` store.
+If a ``request`` does contain an identity, then we perform a final check to determine if the user is valid in our current ``USERS`` store.
Authorizing access to resources
@@ -111,9 +111,9 @@ For our application we've defined a list of a few principals:
- :attr:`pyramid.security.Authenticated`
- :attr:`pyramid.security.Everyone`
-Later, various wiki pages will grant some of these principals access to edit, or add new pages.
+Various wiki pages will grant some of these principals access to edit existing or add new pages.
-Finally, there are two helper methods that will help us later to authenticate users.
+Finally there are two helper methods that will help us to authenticate users.
The first is ``hash_password`` which takes a raw password and transforms it using
bcrypt into an irreversible representation, a process known as "hashing".
The second method, ``check_password``, will allow us to compare the hashed value of the submitted password against the hashed value of the password stored in the user's
@@ -140,8 +140,8 @@ the file ``development.ini`` and add the highlighted line below:
:lineno-match:
:language: ini
-Finally, best practices tell us to use a different secret in each environment, so
-open ``production.ini`` and add a different secret:
+Best practices tell us to use a different secret in each environment.
+Open ``production.ini`` and add a different secret:
.. literalinclude:: src/authorization/production.ini
:lines: 17-19
@@ -149,7 +149,7 @@ open ``production.ini`` and add a different secret:
:lineno-match:
:language: ini
-And ``testing.ini``:
+Edit ``testing.ini`` to add its unique secret:
.. literalinclude:: src/authorization/testing.ini
:lines: 17-19
@@ -202,44 +202,42 @@ We actually need only *one* ACL for the entire system, however, because our secu
Add permission declarations
~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Open ``tutorial/views/default.py`` and add a ``permission='edit'`` parameter to the ``@view_config`` decorators for ``add_page()`` and ``edit_page()``:
+Open ``tutorial/views/default.py``.
+Add a ``permission='view'`` parameter to the ``@view_config`` decorators for ``view_wiki()`` and ``view_page()`` as follows:
.. literalinclude:: src/authorization/tutorial/views/default.py
- :lines: 39-41
+ :lines: 12
:lineno-match:
- :emphasize-lines: 2-3
+ :emphasize-lines: 1
:language: python
.. literalinclude:: src/authorization/tutorial/views/default.py
- :lines: 58-60
+ :lines: 17-19
:lineno-match:
:emphasize-lines: 2-3
:language: python
Only the highlighted lines, along with their preceding commas, need to be edited and added.
-The result is that only users who possess the ``edit`` permission at the time of the request may invoke those two views.
+This allows anyone to invoke these two views.
-Add a ``permission='view'`` parameter to the ``@view_config`` decorator for
-``view_wiki()`` as follows:
+Next add a ``permission='edit'`` parameter to the ``@view_config`` decorators for ``add_page()`` and ``edit_page()``:
.. literalinclude:: src/authorization/tutorial/views/default.py
- :lines: 12
+ :lines: 39-41
:lineno-match:
- :emphasize-lines: 1
+ :emphasize-lines: 2-3
:language: python
-And ``view_page()`` as follows:
-
.. literalinclude:: src/authorization/tutorial/views/default.py
- :lines: 17-19
+ :lines: 58-60
:lineno-match:
:emphasize-lines: 2-3
:language: python
Only the highlighted lines, along with their preceding commas, need to be edited and added.
-This allows anyone to invoke these two views.
+The result is that only users who possess the ``edit`` permission at the time of the request may invoke those two views.
We are done with the changes needed to control access.
The changes that follow will add the login and logout feature.
@@ -290,8 +288,8 @@ Create ``tutorial/templates/login.pt`` with the following content:
The above template is referenced in the login view that we just added in ``views.py``.
-Add a "Login" and "Logout" links
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Add "Login" and "Logout" links
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Open ``tutorial/templates/layout.pt`` and add the following code as indicated by the highlighted lines.
--
cgit v1.2.3
From cc26acfd29c94036d1c4d9164dba6a2b7792c00a Mon Sep 17 00:00:00 2001
From: Steve Piercy
Date: Wed, 15 Jan 2020 23:36:43 -0800
Subject: Minor grammar fixes - Expand contractions and spell out words
---
docs/tutorials/wiki/tests.rst | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/docs/tutorials/wiki/tests.rst b/docs/tutorials/wiki/tests.rst
index e563b174e..231945c9a 100644
--- a/docs/tutorials/wiki/tests.rst
+++ b/docs/tutorials/wiki/tests.rst
@@ -19,7 +19,7 @@ The test module would have the same name with the prefix ``test_``.
The harness consists of the following setup:
-- ``pytest.ini`` - controls basic ``pytest`` config including where to find the tests.
+- ``pytest.ini`` - controls basic ``pytest`` configuration, including where to find the tests.
We have configured ``pytest`` to search for tests in the application package and in the ``tests`` package.
- ``.coveragerc`` - controls coverage config.
@@ -29,10 +29,11 @@ The harness consists of the following setup:
Most importantly, it contains the database connection information used by tests that require the database.
- ``tests_require`` in ``setup.py`` - controls the dependencies installed when testing.
- When the list is changed, it's necessary to re-run ``$VENV/bin/pip install -e ".[testing]"`` to ensure the new dependencies are installed.
+ When the list is changed, it is necessary to re-run ``$VENV/bin/pip install -e ".[testing]"`` to ensure the new dependencies are installed.
- ``tests/conftest.py`` - the core fixtures available throughout our tests.
- The fixtures are explained in more detail below.
+ The fixtures are explained in more detail in the following sections.
+ Open ``tests/conftest.py`` and follow along.
Session-scoped test fixtures
@@ -51,7 +52,7 @@ Per-test fixtures
Generally other fixtures would join to the ``tm`` fixture to control their lifecycle and ensure they are aborted at the end of the test.
- ``testapp`` - a :class:`webtest.TestApp` instance wrapping the ``app`` and is used to sending requests into the application and return full response objects that can be inspected.
- The ``testapp`` is able to mutate the request environ such that the ``tm`` fixture is injected and used by any code that's touching ``request.tm``.
+ The ``testapp`` is able to mutate the request environ such that the ``tm`` fixture is injected and used by any code that touches ``request.tm``.
This should join the ``request.root`` ZODB model to the transaction manager as well, to enable rolling back changes to the database.
The ``testapp`` maintains a cookiejar, so it can be used to share state across requests, as well as the transaction database connection.
@@ -59,14 +60,14 @@ Per-test fixtures
The ``app_request`` can be passed to view functions and other code that need a fully functional request object.
- ``dummy_request`` - a :class:`pyramid.testing.DummyRequest` object that is very lightweight.
- This is a great object to pass to view functions that have minimal side-effects as it'll be fast and simple.
+ This is a great object to pass to view functions that have minimal side-effects as it will be fast and simple.
Unit tests
==========
We can test individual APIs within our codebase to ensure they fulfill the expected contract that the rest of the application expects.
-For example, we'll test the password hashing features we added to ``tutorial.security`` and the rest of our models.
+For example, we will test the password hashing features we added to ``tutorial.security`` and the rest of our models.
Create ``tests/test_models.py`` such that it appears as follows:
@@ -78,8 +79,8 @@ Create ``tests/test_models.py`` such that it appears as follows:
Integration tests
=================
-We can directly execute the view code, bypassing :app:`Pyramid` and testing just the code that we've written.
-These tests use dummy requests that we'll prepare appropriately to set the conditions each view expects.
+We can directly execute the view code, bypassing :app:`Pyramid` and testing just the code that we have written.
+These tests use dummy requests that we will prepare appropriately to set the conditions each view expects.
Update ``tests/test_views.py`` such that it appears as follows:
@@ -91,7 +92,7 @@ Update ``tests/test_views.py`` such that it appears as follows:
Functional tests
================
-We'll test the whole application, covering security aspects that are not tested in the unit and integration tests, like logging in, logging out, checking that the ``basic`` user cannot edit pages that it didn't create but the ``editor`` user can, and so on.
+We will test the whole application, covering security aspects that are not tested in the unit and integration tests, like logging in, logging out, checking that the ``basic`` user cannot edit pages that it did not create, but that the ``editor`` user can, and so on.
Update ``tests/test_functional.py`` such that it appears as follows:
--
cgit v1.2.3