diff options
| author | Michael Merickel <michael@merickel.org> | 2020-01-06 22:57:34 -0600 |
|---|---|---|
| committer | Michael Merickel <michael@merickel.org> | 2020-01-06 22:57:34 -0600 |
| commit | a4b0781604fd217341cc43eec47a95c725860ced (patch) | |
| tree | 47dbf81eea8b184431bf48b72b1eb06ecb2b7128 /docs/tutorials | |
| parent | ef5b4019633b8ca383df75bdf517932ee23f304e (diff) | |
| download | pyramid-a4b0781604fd217341cc43eec47a95c725860ced.tar.gz pyramid-a4b0781604fd217341cc43eec47a95c725860ced.tar.bz2 pyramid-a4b0781604fd217341cc43eec47a95c725860ced.zip | |
sync basiclayout, installation, models with new structure
Diffstat (limited to 'docs/tutorials')
32 files changed, 827 insertions, 270 deletions
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: <somepath>/tutorial, inifile: pytest.ini, testpaths: tutorial + rootdir: <somepath>/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'} |
