├── .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 | 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 | --------------------------------------------------------------------------------