├── .env
├── .gitignore
├── .python-version
├── .travis.yml
├── Dockerfile
├── Dockerfile.test
├── LICENSE
├── README.rst
├── Vagrantfile
├── bootstrap.sh
├── docker-compose.yml
├── europython
├── __init__.py
├── api.py
├── blog.py
├── config.py
├── database.py
├── factories.py
├── models.py
├── schemas.py
├── templates
│ ├── article.html
│ └── home.html
└── test.py
├── manage.py
├── requirements.txt
├── setup.py
├── tasks.py
├── tests
├── blog.feature
├── conftest.py
├── test_api.py
└── test_blog.py
└── tox.ini
/.env:
--------------------------------------------------------------------------------
1 | EUROPYTHON_SERVER_PORT=5000
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Editors #
2 |
3 | # VIM Swap Files
4 | *.swp
5 |
6 | # IntelliJ Project Directories
7 | .idea
8 |
9 | # Apple Desktop Services Store
10 | .DS_Store
11 |
12 | # Visual Studio Code
13 | .vscode
14 |
15 |
16 | # Python #
17 |
18 | # Python Bytecode
19 | *.pyc
20 | __pycache__/
21 |
22 | # Python Packaging
23 | *.egg-info/
24 | *.eggs
25 | build/
26 |
27 | # Python Testing
28 | .coverage
29 | .pytest_cache/
30 | .tox/
31 | htmlcov/
32 | tests/screenshots/
33 | venv/
34 |
35 | # Vagrant
36 | .vagrant/
37 | ubuntu-bionic-18.04-cloudimg-console.log
38 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.6.6
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - "3.6"
4 | install:
5 | - pip install invoke docker-compose
6 | sudo: required
7 | services:
8 | - docker
9 | script:
10 | - invoke build
11 | - invoke test
12 | env:
13 | - EUROPYTHON_SERVER_PORT=5000
14 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.6-alpine
2 |
3 | ENV DATABASE_URL postgresql://user:password@localhost/database
4 |
5 | RUN apk add --update \
6 | # PostgreSQL
7 | gcc libc-dev postgresql-dev
8 |
9 | ADD . /src/
10 | RUN pip install -r /src/requirements.txt
11 |
12 | WORKDIR /src
13 | ENTRYPOINT ["python", "manage.py"]
14 |
--------------------------------------------------------------------------------
/Dockerfile.test:
--------------------------------------------------------------------------------
1 | # -*- mode: dockerfile -*-
2 | # vi: set ft=dockerfile :
3 |
4 | FROM europython/workshop:latest
5 |
6 | RUN apk add --update \
7 | # Acceptance Tests
8 | chromium chromium-chromedriver
9 |
10 | RUN pip install tox
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Alexandre Figura
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ===================================
2 | Writing and Running Tests in Docker
3 | ===================================
4 |
5 | Material for my workshop at EuroPython 2018.
6 |
7 | Every commit of the repository corresponds to a different step of the workshop.
8 |
9 | .. image:: https://travis-ci.org/arugifa/europython-2018-workshop.svg?branch=master
10 | :target: https://travis-ci.org/arugifa/europython-2018-workshop
11 |
12 |
13 | Getting Started
14 | ===============
15 |
16 | First, go back in time::
17 |
18 | git checkout -b workshop setup
19 |
20 | You will start to work from this commit, in a new ``workshop`` branch.
21 | From there, you can have a look into ``master`` if you are blocked and don't know what to do next.
22 |
23 | Then, **install requirements**:
24 |
25 | - `VirtualBox`_: to run the workshop inside a virtual machine (Ubuntu),
26 | - `Vagrant`_: to set-up the virtual machine,
27 |
28 | .. _Vagrant: https://www.vagrantup.com/downloads.html
29 | .. _VirtualBox: https://www.virtualbox.org/wiki/Downloads
30 |
31 | By using **VirtualBox**, we are sure to all have the same environment. Which will save us time!
32 |
33 | Now that you are ready to start, let's **create a virtual machine**::
34 |
35 | vagrant up # Create the VM
36 | vagrant ssh # Connect to the VM
37 |
38 | Inside the virtual machine, you can now **set-up the demo project**::
39 |
40 | cd /vagrant # Local repository is shared with the VM in /vagrant
41 | virtualenv -p python3.6 venv
42 | . venv/bin/activate
43 | pip install -r requirements-test.txt
44 |
45 | Finally, you can check that everything is working, by running the demo server::
46 |
47 | ./manage.py demo --port 5000 --db_url "sqlite:////tmp/europython.sqlite"
48 |
49 | And access to the demo blog on `http://192.168.50.4:5000/ `_,
50 | using your favorite browser on your local machine.
51 |
52 |
53 | Writing Tests (with Pytest)
54 | ===========================
55 |
56 | First step of the workshop is to write tests.
57 |
58 | If you try to run them now, you will only get failures::
59 |
60 | python -m pytest tests/
61 |
62 | By looking at error messages, you will see ``NotImplementedError`` exceptions.
63 | It's because you have to implement yourself:
64 |
65 | - **acceptance tests** for the demo blog (using `Pytest-BDD`_) in ``tests/test_blog.py``,
66 | - **end-to-end tests** for the demo web API (using `WebTest`_) in ``tests/test_api.py``,
67 | - `Pytest fixtures`_, in ``tests/conftest.py``.
68 |
69 | .. _WebTest: http://webtest.pythonpaste.org/
70 | .. _Pytest-BDD: https://github.com/pytest-dev/pytest-bdd
71 | .. _Pytest fixtures: https://docs.pytest.org/en/latest/fixture.html
72 |
73 | To implement the tests:
74 |
75 | #. **Execute** them one by one:
76 |
77 | - for the blog: ``python -m pytest tests/test_blog.py::test_read_article``
78 | - for the web API: ``python -m pytest tests/test_api.py::test_get_articles``
79 |
80 | #. **Read** the error message,
81 | #. **Let you guide** by the error message to find a solution,
82 | #. **Implement** the solution.
83 | #. **Do it again**, until you fix all errors.
84 |
85 | Hints:
86 |
87 | - there are **factories** in ``europython/factories.py``
88 | to create objects in database from the tests,
89 | - **test helpers** are available in ``europython/database.py`` and ``europython/test.py``,
90 | - look at **Pytest extensions** we use in ``requirements-test.txt``:
91 | they already provide fixtures, that our own fixtures or tests are depending on.
92 |
93 | Useful links:
94 |
95 | - Pytest: https://docs.pytest.org/
96 | - Pytest-BDD: https://github.com/pytest-dev/pytest-bdd
97 | - Pytest-Splinter: https://github.com/pytest-dev/pytest-splinter
98 | - Splinter: https://splinter.readthedocs.io/
99 | - Pytest-SQLAlchemy: https://github.com/toirl/pytest-sqlalchemy
100 | - SQLAlchemy: https://docs.sqlalchemy.org/en/latest/orm/tutorial.html
101 | - Pytest-LocalServer: https://bitbucket.org/pytest-dev/pytest-localserver
102 | - Webtest: http://webtest.pythonpaste.org/
103 |
104 |
105 | Automating Tests (with Tox)
106 | ===========================
107 |
108 | Now that our tests are **GREEN**, an important step is still missing:
109 | how to be sure that our project is packaged correctly?
110 | Because if you update a broken package to `PyPI`_, people will not be able to use it...
111 |
112 | For this purpose, we will use `Tox`_, to run our tests inside a virtual environment,
113 | automatically updated every time you change a dependency or the project's packaging.
114 | In fact, we will need several environments:
115 |
116 | - one for **running the tests**,
117 | - another one for **running lint checking** with `Flake8`_,
118 | - and two **development environments**:
119 |
120 | - the first one to be used **locally** *(e.g., to get auto-completion in your IDE)*,
121 | - the second one to be used in **Docker** later on.
122 |
123 | - as a bonus, you can also create one for **checking security issues**
124 | in dependencies (with `Safety`_).
125 |
126 | .. _Coverage.py: https://coverage.readthedocs.io/
127 | .. _Flake8: http://flake8.pycqa.org/
128 | .. _PyPI: https://pypi.org/
129 | .. _Safety: https://github.com/pyupio/safety
130 | .. _Tox: https://tox.readthedocs.io/
131 |
132 | Hints:
133 |
134 | - now that we are using **Tox**, you can move test dependencies
135 | from ``requirements-test.txt`` into ``tox.ini``,
136 | - you can get **test coverage** for free with `Coverage.py`_.
137 |
138 |
139 | Running Tests (in Docker)
140 | =========================
141 |
142 | Running tests locally is nice. But only when you work alone!
143 |
144 | As soon as you work with other persons, using **different operating systems**,
145 | things start to be complicated. People will probably have different versions
146 | of **system dependencies**, and most of the time, installing the needed version
147 | will not be straightforward.
148 |
149 | That's where `Docker`_ comes to the rescue!
150 |
151 | With **Docker**, the only system dependency people will have to install is **Docker** itself. Period.
152 |
153 | For our workshop, we will need two **Docker** images:
154 |
155 | 1. one for running on **production**,
156 | 2. another one to use for **tests**, based on the PROD image (to not dupplicate *Dockerfiles*),
157 | and including all test dependencies like **Tox**, **Pytest** and others.
158 |
159 | Finally, to simulate a real use case, with a project depending on external systems,
160 | we will also replace with `PostgreSQL`_ the **SQLite** in-memory database
161 | we were using until now. For this purpose, we will set-up our testing stack with `Docker Compose`_.
162 |
163 | .. _Docker: https://docs.docker.com/engine/reference/builder/
164 | .. _Docker Compose: https://docs.docker.com/compose/
165 | .. _PostgreSQL: https://hub.docker.com/_/postgres/
166 |
167 | Hints:
168 |
169 | - Use **Alpine images**, for a smaller image footprint.
170 | - **Configure** the application container using **environment variables** (see ``europython/config.py``).
171 | - You will have to:
172 |
173 | - add ``psycopg2`` to the application's dependencies,
174 | and install ``gcc``, ``libc-dev`` and ``postgresql-dev`` in the PROD application container,
175 | in order to interact with **PostgreSQL**,
176 | - install ``chromium`` and ``chromium-chromedriver`` in the TEST application container,
177 | in order to run the acceptance tests.
178 |
179 | - You already run tests locally with **Tox**. So do the same inside Docker 😃
180 | Just don't forget to forward environment variables (used to configure containers)
181 | to Pytest, with the `passenv directive`_.
182 | - Run the tests directly inside the application container in order to:
183 |
184 | - share your local code with the container,
185 | - take advantage of using `PDB`_ when debugging tests.
186 |
187 | .. _passenv directive: https://tox.readthedocs.io/en/latest/config.html?#confval-passenv=SPACE-SEPARATED-GLOBNAMES
188 | .. _PDB: https://docs.python.org/3/library/pdb.html
189 |
190 |
191 | Automating Workflow (with Invoke)
192 | =================================
193 |
194 | TODO: explain what to do next.
195 |
196 | .. _Invoke: http://www.pyinvoke.org/
197 |
198 |
199 | Adding Continuous Integration (on Travis CI)
200 | ============================================
201 |
202 | TODO: explain what to do next.
203 |
--------------------------------------------------------------------------------
/Vagrantfile:
--------------------------------------------------------------------------------
1 | # -*- mode: ruby -*-
2 | # vi: set ft=ruby :
3 |
4 | Vagrant.configure("2") do |config|
5 | config.vm.box = "ubuntu/bionic64"
6 | config.vm.provision :shell, path: "bootstrap.sh"
7 |
8 | # Create a private network, to access to the VM from the machine host.
9 | # IP address chosen randomly (taken from the Vagrant tutorial in fact).
10 | config.vm.network "private_network", ip: "192.168.50.4"
11 | end
12 |
--------------------------------------------------------------------------------
/bootstrap.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | export DEBIAN_FRONTEND=noninteractive
4 |
5 | apt-get update && apt-get -y dist-upgrade
6 |
7 | apt-get install -y \
8 | chromium-browser chromium-chromedriver \
9 | docker docker-compose \
10 | python3 tox
11 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | web:
4 | build:
5 | context: .
6 | dockerfile: Dockerfile.test
7 | image: europython/workshop:test
8 | command: demo
9 | environment:
10 | DATABASE_URL: postgresql://john_doe:foobar@database/europython
11 | SERVER_PORT: ${EUROPYTHON_SERVER_PORT}
12 | ports:
13 | - ${EUROPYTHON_SERVER_PORT}:${EUROPYTHON_SERVER_PORT}
14 | volumes:
15 | - .:/src
16 | depends_on:
17 | - database
18 |
19 | database:
20 | image: postgres:11-alpine
21 | environment:
22 | POSTGRES_USER: john_doe
23 | POSTGRES_PASSWORD: foobar
24 | POSTGRES_DB: europython
25 |
--------------------------------------------------------------------------------
/europython/__init__.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | from pathlib import Path
3 |
4 | from apistar import App, Route
5 | from apistar_sqlalchemy.components import SQLAlchemySessionComponent
6 | from apistar_sqlalchemy.event_hooks import SQLAlchemyTransactionHook
7 |
8 | from europython import api, blog
9 | from europython.config import DefaultConfig
10 |
11 | TEMPLATES_DIR = Path(__file__).parent / 'templates'
12 |
13 | routes = [
14 | # Blog
15 | Route('/', method='GET', handler=blog.home),
16 | Route('/articles/{article_id}/', method='GET', handler=blog.article),
17 |
18 | # API
19 | Route('/api/articles/', method='GET', handler=api.articles),
20 | ]
21 |
22 |
23 | def create_app(config=DefaultConfig):
24 | """Set-up the web API.
25 |
26 | :rtype: apistar.App
27 | """
28 | if inspect.isclass(config):
29 | config = config() # To load configuration from environment variables
30 |
31 | database_url = f'{config.DATABASE_URL}'
32 | components = [SQLAlchemySessionComponent(url=database_url)]
33 | event_hooks = [SQLAlchemyTransactionHook()]
34 |
35 | return App(
36 | routes=routes, template_dir=str(TEMPLATES_DIR),
37 | components=components, event_hooks=event_hooks,
38 | )
39 |
--------------------------------------------------------------------------------
/europython/api.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from apistar.http import QueryParam, QueryParams
4 | from sqlalchemy.orm import Session
5 |
6 | from europython import models, schemas
7 |
8 |
9 | def articles(
10 | title: QueryParam, query: QueryParams,
11 | session: Session) -> List[schemas.Article]:
12 | """Return list of articles."""
13 | articles = models.Article.filter(**query)
14 | return [schemas.Article(article) for article in articles]
15 |
--------------------------------------------------------------------------------
/europython/blog.py:
--------------------------------------------------------------------------------
1 | from apistar import App
2 | from apistar.exceptions import NotFound
3 | from sqlalchemy.orm import Session
4 |
5 | from europython import models
6 |
7 |
8 | def home(app: App, session: Session):
9 | """Blog homepage."""
10 | articles = models.Article.all()
11 | return app.render_template('home.html', articles=articles)
12 |
13 |
14 | def article(app: App, article_id: int, session: Session):
15 | """Article page."""
16 | article = models.Article.find(id=article_id)
17 |
18 | if not article:
19 | raise NotFound
20 |
21 | return app.render_template('article.html', article=article)
22 |
--------------------------------------------------------------------------------
/europython/config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | class DefaultConfig:
5 | """Application configuration.
6 |
7 | Default values are defined as class attributes. If you want to override
8 | them, you MUST instantiate the config class, and:
9 |
10 | - define new values during instantiation (as keyword arguments),
11 | - define new values as environment variables.
12 |
13 | Setting values are processed in this order of importance:
14 |
15 | 1. instance attributes,
16 | 2. environment variables,
17 | 3. class attributes.
18 | """
19 |
20 | #: How to connect to the database.
21 | DATABASE_URL = 'postgresql://user:password@localhost/database'
22 |
23 | def __init__(self, **kwargs):
24 | for setting, default_value in self.__class__.__dict__.items():
25 | if setting.isupper(): # Filter configuration settings only
26 | value = os.environ.get(setting, default_value)
27 | setattr(self, setting, value)
28 |
29 | self.__dict__.update(**kwargs)
30 |
--------------------------------------------------------------------------------
/europython/database.py:
--------------------------------------------------------------------------------
1 | from apistar_sqlalchemy.database import Base
2 | from sqlalchemy import create_engine
3 |
4 |
5 | def connect_db(url):
6 | """Connect to the database.
7 |
8 | :param str url:
9 | database's URL
10 | (e.g., ``postgresql://user:password@localhost/database``).
11 | :return:
12 | connection to the database.
13 | :rtype:
14 | sqlalchemy.engine.Engine
15 | """
16 | return create_engine(url)
17 |
18 |
19 | def init_db(engine):
20 | """Create all model tables into database.
21 |
22 | :param connection: connection to the database.
23 | :type connection: sqlalchemy.engine.Engine
24 | """
25 | Base.metadata.create_all(engine)
26 |
27 |
28 | def clean_db(engine):
29 | """Drop all model tables from the database.
30 |
31 | :param connection: connection to the database.
32 | :type connection: sqlalchemy.engine.Engine
33 | """
34 | Base.metadata.drop_all(engine)
35 |
--------------------------------------------------------------------------------
/europython/factories.py:
--------------------------------------------------------------------------------
1 | from apistar_sqlalchemy.database import Session
2 | from factory import Sequence
3 | from factory.alchemy import SQLAlchemyModelFactory
4 |
5 | from europython import models
6 |
7 |
8 | class ArticleFactory(SQLAlchemyModelFactory):
9 | class Meta:
10 | model = models.Article
11 | sqlalchemy_session = Session
12 | # We commit objects, in order to make them visible
13 | # between different sessions (e.g., test and codebase sessions).
14 | sqlalchemy_session_persistence = 'commit'
15 |
16 | title = Sequence(lambda n: f'Article {n}')
17 | content = "This is a great article about a super cool technology."
18 |
--------------------------------------------------------------------------------
/europython/models.py:
--------------------------------------------------------------------------------
1 | from apistar_sqlalchemy.database import Base, Session
2 | from sqlalchemy import Column, Integer, String, Text
3 |
4 |
5 | class Article(Base):
6 | """Article model."""
7 |
8 | __tablename__ = 'articles'
9 |
10 | id = Column(Integer, primary_key=True)
11 | title = Column(String, nullable=False)
12 | content = Column(Text, nullable=False)
13 |
14 | @classmethod
15 | def all(cls):
16 | """Return all articles.
17 |
18 | :rtype: list
19 | """
20 | return Session.query(cls).all()
21 |
22 | @classmethod
23 | def filter(cls, **kwargs):
24 | """Filter articles.
25 |
26 | Fields to filter on can be given as keyword arguments.
27 |
28 | :rtype: list
29 | """
30 | return Session.query(cls).filter_by(**kwargs).all()
31 |
32 | @classmethod
33 | def find(cls, **kwargs):
34 | """Find a specific article.
35 |
36 | Fields to filter on can be given as keyword arguments.
37 |
38 | :return: the article if found; ``None`` otherwise.
39 | :rtype: Article
40 | """
41 | return Session.query(cls).filter_by(**kwargs).one_or_none()
42 |
--------------------------------------------------------------------------------
/europython/schemas.py:
--------------------------------------------------------------------------------
1 | from apistar import types, validators
2 |
3 |
4 | class Article(types.Type):
5 | """Schema for :class:`europython.models.Article`."""
6 |
7 | id = validators.Integer(description="Article's identifier.")
8 | title = validators.String(description="Article's title.")
9 | content = validators.String(description="Article's content.")
10 |
--------------------------------------------------------------------------------
/europython/templates/article.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ article.title }}
4 |
5 |
6 |
7 | {{ article.title }}
8 |
9 |
10 | {{ article.content }}
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/europython/templates/home.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Blog Homepage
4 |
5 |
6 | {% if articles %}
7 | Articles
8 |
9 |
10 | {% for article in articles %}
11 | -
12 |
13 | {{ article.title }}
14 |
15 |
16 | {% endfor %}
17 |
18 | {% else %}
19 | No Article Yet!
20 | {% endif %}
21 |
22 |
23 |
--------------------------------------------------------------------------------
/europython/test.py:
--------------------------------------------------------------------------------
1 | from factory.alchemy import SQLAlchemyModelFactory
2 |
3 |
4 | def reset_factories_session(session):
5 | """Reset database session used by all factories.
6 |
7 | By default, factories use :data:``apistar_sqlalchemy.database.Session``.
8 | But this session is closed after every request made to the web application.
9 | The result is that objects created with factories before making a request,
10 | are detached from the session after the request, and it becomes impossible
11 | to access to their attributes afterwards.
12 |
13 | For example, this code snippet would fail without using this helper::
14 |
15 | item = factories.MyFactory()
16 | response = client.get('/items/').json
17 | assert item.id in response.keys()
18 |
19 | :param session: the new session to used.
20 | :type session: sqlalchemy.orm.session.Session
21 | """
22 | for cls in SQLAlchemyModelFactory.__subclasses__():
23 | cls._meta.sqlalchemy_session = session
24 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | """Command-line utility for administrative tasks."""
4 |
5 | import os
6 |
7 | import click
8 |
9 | from europython import create_app
10 | from europython.config import DefaultConfig
11 | from europython.database import connect_db, init_db
12 | from europython.factories import ArticleFactory
13 | from europython.models import Article
14 |
15 |
16 | # Settings retrieved from environment variables.
17 | DATABASE_URL = 'DATABASE_URL'
18 | SERVER_PORT = 'SERVER_PORT'
19 |
20 |
21 | @click.group()
22 | def cli(): # noqa: D401
23 | """Base command-line group.
24 |
25 | To be used for decorating any other command.
26 | """
27 |
28 |
29 | @cli.command()
30 | @click.option(
31 | '--port', envvar=SERVER_PORT,
32 | required=True, type=int, help="Demo server's port.")
33 | @click.option(
34 | '--db_url', envvar=DATABASE_URL,
35 | required=True, help="How to connect to the database.")
36 | def demo(port, db_url):
37 | """Launch a demo server."""
38 | config = DefaultConfig(DATABASE_URL=db_url)
39 | app = create_app(config)
40 |
41 | db = connect_db(config.DATABASE_URL)
42 | init_db(db)
43 |
44 | if not Article.all(): # Not reusing previous demo database
45 | ArticleFactory.create_batch(10)
46 |
47 | app.serve('0.0.0.0', port, debug=True)
48 |
49 |
50 | if __name__ == '__main__':
51 | cli()
52 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | apistar==0.5.40
2 | apistar-sqlalchemy==0.3.1
3 | click==6.7
4 | factory-boy==2.11.1
5 | psycopg2==2.7.5
6 | sqlalchemy==1.2.9
7 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from setuptools import find_packages, setup
4 |
5 | here = Path(__file__).parent
6 |
7 | with (here / 'README.rst').open() as f:
8 | long_description = f.read()
9 |
10 | setup(
11 | name='europython',
12 | version='2018.07.19',
13 | description="Material for my EuroPython 2018 workshop.",
14 | long_description=long_description,
15 | author="Alexandre Figura",
16 | classifiers=[
17 | 'Development Status :: 3 - Alpha',
18 | 'Programming Language :: Python :: 3',
19 | 'Programming Language :: Python :: 3.6',
20 | ],
21 | packages=find_packages(exclude=['tests']),
22 | install_requires=[
23 | 'apistar>=0.5',
24 | 'apistar-sqlalchemy>=0.3',
25 | 'click>=6.7',
26 | 'factory-boy>=2.11',
27 | 'psycopg2>=2.7',
28 | 'sqlalchemy>=1.2',
29 | ],
30 | )
31 |
--------------------------------------------------------------------------------
/tasks.py:
--------------------------------------------------------------------------------
1 | import re
2 | import sys
3 | from datetime import date
4 |
5 | from invoke import task
6 | from invoke.context import Context
7 |
8 | # Docker
9 | IMAGE_NAME = f'europython/workshop'
10 | DOCKER_SERVICE = 'web'
11 |
12 | # Tox
13 | DEV_ENV = 'dev'
14 | ACTIVATE_DEV = f'.tox/{DEV_ENV}/bin/activate'
15 | PYTHON_TESTS = 'py36'
16 | LOCAL_ENV = 'local'
17 |
18 | # Python
19 | SETUP_FILE = 'setup.py'
20 |
21 |
22 | # Commands
23 |
24 | build_help = {
25 | 'erase': "Overwritte previously created container with the same tag.",
26 | }
27 |
28 |
29 | @task
30 | def build(ctx, erase=False):
31 | """Build a new Docker container."""
32 | today = date.today().strftime('%Y.%m.%d')
33 |
34 | # Build the Docker container.
35 | if erase:
36 | print("Erasing existing Docker image")
37 | command = f'docker rmi {IMAGE_NAME}:{today}'
38 | run(command)
39 |
40 | print("Building new Docker image")
41 | command = f'docker build -t {IMAGE_NAME}:{today} -t {IMAGE_NAME}:latest .'
42 | run(command)
43 |
44 | # Update application version.
45 | print(f"Updating version in {SETUP_FILE}")
46 | to_replace = [(r"version='\d+.\d+.\d+'", f"version='{today}'")]
47 | replace(to_replace, SETUP_FILE)
48 |
49 |
50 | demo_help = {
51 | 'debug': "Execute a shell inside the Docker container instead.",
52 | }
53 |
54 |
55 | @task(help=demo_help)
56 | def demo(ctx, debug=False):
57 | """Launch the demo web API in foreground."""
58 | if debug:
59 | docker_compose_run('sh')
60 | else:
61 | run('docker-compose up')
62 |
63 |
64 | test_help = {
65 | 'debug': "Execute a shell inside the Docker container instead.",
66 | }
67 |
68 |
69 | @task(help=test_help)
70 | def test(ctx, debug=False):
71 | """Execute the test suite."""
72 | if debug:
73 | command = f'tox -e {DEV_ENV} && . {ACTIVATE_DEV} && sh'
74 | docker_compose_run(command)
75 | else:
76 | command = f'tox -e {PYTHON_TESTS}'
77 | docker_compose_run(command)
78 |
79 |
80 | @task
81 | def virtualenv(ctx):
82 | """Install the web application and its dependencies locally."""
83 | command = f'tox -e {LOCAL_ENV}'
84 | run(command)
85 |
86 |
87 | # Helpers
88 |
89 | def run(command):
90 | """Execute a command with Invoke."""
91 | ctx = Context()
92 | ctx.run(
93 | command,
94 | echo=True, # To improve User eXperience
95 | pty=True, # To get colors in output
96 | )
97 |
98 |
99 | def docker_run(command):
100 | """Execute a command inside a standalone running Docker container."""
101 | cmdline = f'docker run {IMAGE_NAME}:latest {command}'
102 | run(cmdline)
103 |
104 |
105 | def docker_compose_run(command):
106 | """Execute a command inside a fully running Docker Compose stack."""
107 | cmdline = (
108 | f'docker-compose run '
109 | f'--entrypoint "sh -c" ' # To run any command
110 | f'--service-ports ' # To expose container's ports on host machine
111 | f'{DOCKER_SERVICE} "{command}"'
112 | )
113 | run(cmdline)
114 |
115 |
116 | def replace(lines, in_file):
117 | """Replace lines in a file.
118 |
119 | Terminate script execution if lines are not found.
120 |
121 | :param lines:
122 | list of string tuples,
123 | with regex expressions to look for and their substitutes.
124 | """
125 | with open(in_file, 'r+') as f:
126 | content = f.read()
127 |
128 | for regex, string in lines:
129 | content, has_been_changed = re.subn(regex, string, content)
130 | if not has_been_changed:
131 | sys.exit(f"Regex not found in {in_file}: {regex}")
132 |
133 | f.seek(0)
134 | f.write(content)
135 | f.truncate()
136 |
--------------------------------------------------------------------------------
/tests/blog.feature:
--------------------------------------------------------------------------------
1 | Feature: Blog
2 | A website where you can read articles.
3 |
4 | Scenario: Reading an article
5 | Given I wrote 3 articles
6 | When I access to my blog
7 | And I click on the first article's title
8 | Then I should be redirected to the article's page
9 | And I should see the article's content
10 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from pathlib import PurePath
2 |
3 | import pytest
4 | import webtest
5 | from pytest_localserver.http import WSGIServer
6 | from selenium.webdriver.chrome.options import Options as ChromeOptions
7 |
8 | from europython import create_app
9 | from europython.config import DefaultConfig
10 | from europython.database import clean_db, init_db
11 | from europython.test import reset_factories_session
12 |
13 |
14 | @pytest.fixture(scope='session')
15 | def app(config):
16 | """Return the web application."""
17 | app = create_app(config)
18 | app.debug = True
19 | return app
20 |
21 |
22 | @pytest.fixture(scope='session')
23 | def client(app):
24 | """Return a client query the web application."""
25 | return webtest.TestApp(app)
26 |
27 |
28 | @pytest.fixture(scope='session')
29 | def config(tmpdir_factory):
30 | """Return applicaton's configuration.
31 |
32 | :rtype: europython.config.DefaultConfig
33 | """
34 | config = DefaultConfig() # Read config from environment variables
35 |
36 | if 'sqlite' in config.DATABASE_URL:
37 | tmp_db = tmpdir_factory.mktemp('database').join('test.sqlite')
38 | config.DATABASE_URL = f'sqlite:///{tmp_db}'
39 |
40 | return config
41 |
42 |
43 | @pytest.fixture
44 | def db(dbsession, engine):
45 | """Return database session.
46 |
47 | All tables are recreated between tests execution.
48 |
49 | :rtype: sqlalchemy.orm.session.Session
50 | """
51 | clean_db(engine) # Cleaning at the end make Pytest hanging in Docker
52 | init_db(engine)
53 |
54 | reset_factories_session(dbsession)
55 |
56 | return dbsession
57 |
58 |
59 |
60 | @pytest.fixture(scope='session')
61 | def server(app):
62 | """Return an HTTP server hosting the web application.
63 |
64 | :rtype: pytest_localserver.http.WSGIServer
65 | """
66 | server = WSGIServer(application=app)
67 | server.start()
68 | yield server
69 | server.stop()
70 |
71 |
72 | @pytest.fixture(scope='session')
73 | def sqlalchemy_connect_url(config):
74 | """Return database's connection URL.
75 |
76 | Used by :mod:`pytest_sqlalchemy`.
77 | """
78 | return f'{config.DATABASE_URL}'
79 |
80 |
81 | @pytest.fixture(scope='session')
82 | def splinter_driver_kwargs():
83 | """Launch Chrome with the sandbox disabled.
84 |
85 | Otherwise, Chrome refuses to start inside Docker::
86 |
87 | Failed to move to new namespace:
88 | PID namespaces supported, Network namespace supported,
89 | but failed: errno = Operation not permitted
90 |
91 | For an overwiew of supported options by the Chrome WebDriver,
92 | see https://sites.google.com/a/chromium.org/chromedriver/capabilities
93 | """
94 | options = ChromeOptions()
95 | options.add_argument('--no-sandbox')
96 | return {'options': options}
97 |
98 |
99 | @pytest.fixture(scope='session')
100 | def splinter_headless():
101 | """Run Chrome in headless mode, for faster tests."""
102 | return True
103 |
104 |
105 | @pytest.fixture(scope='session')
106 | def splinter_screenshot_dir():
107 | """Store screenshots near the tests.
108 |
109 | Used by :mod:`pytest_splinter`.
110 | """
111 | return str(PurePath(__file__).parent / 'screenshots')
112 |
113 |
114 | @pytest.fixture(scope='session')
115 | def splinter_webdriver():
116 | """Use Chrome, which is simpler to launch in headless mode.
117 |
118 | Firefox needs the Gecko driver to be manually installed first.
119 | Moreover, as of 22/07/2018, Firefox in headless mode is not yet
120 | available in python:3.6-alpine3.7 (version 52).
121 | """
122 | return 'chrome'
123 |
--------------------------------------------------------------------------------
/tests/test_api.py:
--------------------------------------------------------------------------------
1 | from europython.factories import ArticleFactory
2 |
3 |
4 | def test_get_articles(client, db):
5 | articles = ArticleFactory.create_batch(2)
6 | response = client.get('/api/articles/').json
7 |
8 | for actual, expected in zip(response, articles):
9 | assert actual['id'] == expected.id
10 | assert actual['title'] == expected.title
11 |
--------------------------------------------------------------------------------
/tests/test_blog.py:
--------------------------------------------------------------------------------
1 | from pytest_bdd import given, scenario, then, when
2 | from pytest_bdd.parsers import parse
3 |
4 | from europython.factories import ArticleFactory
5 |
6 |
7 | # Scenarios
8 |
9 | @scenario('blog.feature', "Reading an article")
10 | def test_read_article():
11 | pass
12 |
13 |
14 | # Requirements
15 |
16 | # FIXME: Use Pytest-BDD parser, again.
17 | # For an unknown reason, parsers stopped to work properly (as of 06/08/2018),
18 | # leading to a "Step definition not found" error. See:
19 | # https://travis-ci.org/arugifa/ep2018-workshop/builds/411806000
20 | #
21 | # from pytest_bdd.parsers import parse
22 | # @given(parse("I wrote {count:d} articles"))
23 |
24 | @given("I wrote 3 articles")
25 | def articles(db):
26 | return ArticleFactory.create_batch(3)
27 |
28 |
29 | # Actions
30 |
31 | @when("I access to my blog")
32 | def go_to_homepage(browser, server):
33 | browser.visit(server.url)
34 |
35 |
36 | @when("I click on the first article's title")
37 | def go_to_first_article(browser):
38 | article = browser.find_by_css('.article-list__title').first
39 | article.click()
40 |
41 |
42 | # Assertions
43 |
44 | @then("I should be redirected to the article's page")
45 | def should_be_redirected(articles, browser):
46 | article = articles[0] # We are reading the first article
47 | assert article.title in browser.title
48 |
49 |
50 | @then("I should see the article's content")
51 | def should_see_content(articles, browser):
52 | article = articles[0] # We are reading the first article
53 | assert browser.is_text_present(article.content)
54 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = lint, py36, security
3 |
4 | [testenv]
5 | deps =
6 | coverage
7 | pytest
8 | pytest-bdd
9 | pytest-localserver
10 | pytest-splinter
11 | pytest-sqlalchemy
12 | webtest
13 | -rrequirements.txt
14 | commands =
15 | coverage run -m pytest --color=yes {posargs} tests/
16 | coverage report
17 | passenv =
18 | DATABASE_URL
19 |
20 | [testenv:dev]
21 | basepython = python3.6
22 | commands =
23 | sitepackages = True
24 | usedevelop = True
25 |
26 | [testenv:lint]
27 | deps =
28 | flake8
29 | flake8-bugbear
30 | flake8-commas
31 | flake8-docstrings
32 | flake8-import-order
33 | flake8-per-file-ignores
34 | mccabe
35 | pep8-naming
36 | commands = flake8 {posargs} europython/
37 | usedevelop = True
38 |
39 | [testenv:local]
40 | basepython = python3.6
41 | commands =
42 | deps =
43 | {[testenv]deps}
44 | {[testenv:lint]deps}
45 | envdir = venv
46 | usedevelop = True
47 |
48 | [testenv:security]
49 | deps =
50 | safety
51 | -rrequirements.txt
52 | commands = safety check {posargs} --full-report
53 | usedevelop = True
54 |
55 |
56 | # Coverage
57 |
58 | [coverage:run]
59 | branch = true
60 | source = europython/
61 |
62 | [coverage:report]
63 | show_missing = true
64 | skip_covered = true
65 |
66 |
67 | # Static Code Analysis
68 |
69 | [flake8]
70 | application-import-names = europython
71 | count = true
72 | import-order-style = edited
73 | ignore =
74 | C814
75 | D100
76 | D104
77 | D105
78 | D106
79 | D107
80 | max-complexity = 10
81 | per-file-ignores =
82 | europython/factories.py: D101
83 | tests/**/test_*.py: D101, D102, D103, X100
84 | statistics = true
85 |
--------------------------------------------------------------------------------