Skip to content

Commit

Permalink
Chapitre 13
Browse files Browse the repository at this point in the history
  • Loading branch information
PonteIneptique committed Feb 13, 2018
1 parent e0e4f7e commit 0898657
Show file tree
Hide file tree
Showing 41 changed files with 18,485 additions and 5 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ pyhum
pydf
styles
*.bak
pages.csv
pages.csv
*.sqlite
223 changes: 219 additions & 4 deletions Chapitre 13 - Ecrire des tests.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -238,15 +238,230 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Tests et Flask"
"## Tests et Flask\n",
"\n",
"### Avant-propos \n",
"\n",
"Pour tester une application, et *a priori* une application qui va posséder une base de données, le premier pas va être de générer un ensemble de données qui sera utilisé pour pouvoir testers les différentes pages. Dans cet avant-propos, nous allons voir quelques conseils permettant de bien travailler avec SQLAlchemy et les tests.\n",
"\n",
"#### Génération de la base de données\n",
"\n",
"SQLAlchemy et Flask-SQLAlchemy sont tellement bien qu'ils permettent aussi de générer des bases de données à partir de l'ensemble des modèles enregistrés. C'est extrêmement pratique et surtout dans le cadre de tests.\n",
"\n",
"Pour générer une base de données, nous aurons besoin de faire :\n",
"\n",
"```python\n",
"from app import db # Cette ligne d'import peut varier\n",
"db.create_all()\n",
"```\n",
"\n",
"et nous pourrons de la même manière l'effacer :\n",
"\n",
"```python\n",
"from app import db # Cette ligne d'import peut varier\n",
"db.drop_all()\n",
"```\n",
"\n",
"##### Scripts de migration\n",
"\n",
"Des outils bien plus complets ainsi qu'un tutorial pour permettre les migrations sont disponibles sur le site de M. Grinberg ( [SQLAlchemy - Flask Mega Tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-iv-database) ).\n",
"\n",
"#### Configuration tests et configuration production\n",
"\n",
"Le problème des scripts précédents, c'est qu'ils risquent fortement de détruire les données que nous avons déjà rentré. Ou pire, ils pourraient facilement se retrouver sur le site en production et détruire les données dessus. Comment limiter ce risque ? En multipliant les configurations.\n",
"\n",
"Il y a beaucoup de manière de faire cela : variables environnementales (variable spécifique à votre machine), configurations multiples dans un module config, etc. Nous allons voir une méthode qui me semble particulièrement facile d'utilisation.\n",
"\n",
"**Attention:** cette méthode est très susceptible à l'ordre de création des autres variables !\n",
"\n",
"##### gazetteer/config.py\n",
"\n",
"```python\n",
"# ...\n",
"class _TEST:\n",
" SECRET_KEY = SECRET_KEY\n",
" # On configure la base de données\n",
" SQLALCHEMY_DATABASE_URI = 'sqlite:///test_db.sqlite'\n",
" SQLALCHEMY_TRACK_MODIFICATIONS = False\n",
"\n",
"\n",
"class _PRODUCTION:\n",
" SECRET_KEY = SECRET_KEY\n",
" # On configure la base de données\n",
" SQLALCHEMY_DATABASE_URI = 'mysql://gazetteer_user:password@localhost/gazetteer'\n",
" SQLALCHEMY_TRACK_MODIFICATIONS = False\n",
"\n",
"CONFIG = {\n",
" \"test\": _TEST,\n",
" \"production\": _PRODUCTION\n",
"}\n",
"```\n",
"\n",
"- On crée deux classes qui possède des propriétés similaires aux configurations nécessaires de Flask\n",
" - Commencer un nom de variable, de classe ou de fonction par un `_` signifie qu'elle ne devrait pas être appellée directement.\n",
"- On les regroupe dans un dictionnaire\n",
"\n",
"##### gazetteer/app.py\n",
"\n",
"```python\n",
"from flask import Flask\n",
"from flask_sqlalchemy import SQLAlchemy\n",
"from flask_login import LoginManager\n",
"import os\n",
"from .constantes import CONFIG\n",
"\n",
"chemin_actuel = os.path.dirname(os.path.abspath(__file__))\n",
"templates = os.path.join(chemin_actuel, \"templates\")\n",
"statics = os.path.join(chemin_actuel, \"static\")\n",
"\n",
"# On initie l'extension\n",
"db = SQLAlchemy()\n",
"# On met en place la gestion d'utilisateur-rice-s\n",
"login = LoginManager()\n",
"\n",
"app = Flask(\n",
" __name__,\n",
" template_folder=templates,\n",
" static_folder=statics\n",
")\n",
"\n",
"\n",
"from .routes import generic\n",
"from .routes import api\n",
"\n",
"\n",
"def config_app(config_name=\"test\"):\n",
" \"\"\" Create the application \"\"\"\n",
" app.config.from_object(CONFIG[config_name])\n",
"\n",
" # On initie les extensions\n",
" db.init_app(app)\n",
" login.init_app(app)\n",
"\n",
" return app\n",
"```\n",
"\n",
"- Toute la partie initiation des routines est mise à part dans une fonction `config_app()` qui retourne l'application.\n",
"- On importera désormais config_app qu'on exécutera afin de lancer l'application (voire le fichier suivant)\n",
"\n",
"##### run.py\n",
"\n",
"```python\n",
"from gazetteer.app import config_app\n",
"\n",
"if __name__ == \"__main__\":\n",
" app = config_app(\"production\")\n",
" app.run(debug=True)\n",
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Mise en place des tests\n",
"\n",
"Voici un exemple de test avec l'ensemble de ce que l'on vient de voir :\n",
"\n",
"```python\n",
"from gazetteer.app import db, config_app, login\n",
"from gazetteer.modeles.utilisateurs import User\n",
"from gazetteer.modeles.donnees import Place, Authorship\n",
"from unittest import TestCase\n",
"\n",
"\n",
"class TestApi(Base):\n",
" places = [\n",
" Place(\n",
" place_nom='Hippana',\n",
" place_description='Ancient settlement in the western part of Sicily, probably founded in the seventh century B.C.',\n",
" place_longitude=37.7018481,\n",
" place_latitude=13.4357804,\n",
" place_type='settlement'\n",
" )\n",
" ]\n",
"\n",
" def setUp(self):\n",
" self.app = config_app(\"test\")\n",
" self.db = db\n",
" self.client = self.app.test_client()\n",
" self.db.create_all(app=self.app)\n",
"\n",
" def tearDown(self):\n",
" self.db.drop_all(app=self.app)\n",
"\n",
" def insert_all(self, places=True):\n",
" # On donne à notre DB le contexte d'exécution\n",
" with self.app.app_context():\n",
" if places:\n",
" for fixture in self.places:\n",
" self.db.session.add(fixture)\n",
" self.db.session.commit()\n",
" \n",
" def test_single_place(self):\n",
" \"\"\" Vérifie qu'un lieu est bien traité \"\"\"\n",
" self.insert_all()\n",
" response = self.client.get(\"/api/places/1\")\n",
" # Le corps de la réponse est dans .data\n",
" # .data est en \"bytes\". Pour convertir des bytes en str, on fait .decode()\n",
" content = response.data.decode()\n",
" self.assertEqual(\n",
" response.headers[\"Content-Type\"], \"application/json\"\n",
" )\n",
" json_parse = loads(content)\n",
" self.assertEqual(json_parse[\"type\"], \"place\")\n",
" self.assertEqual(\n",
" json_parse[\"attributes\"],\n",
" {'name': 'Hippana', 'latitude': 13.4357804, 'longitude': 37.7018481, 'category': 'settlement',\n",
" 'description': 'Ancient settlement in the western part of Sicily, probably '\n",
" 'founded in the seventh century B.C.'}\n",
" )\n",
" self.assertEqual(json_parse[\"links\"][\"self\"], 'http://localhost/place/1')\n",
"\n",
" # On vérifie que le lien est correct\n",
" seconde_requete = self.client.get(json_parse[\"links\"][\"self\"])\n",
" self.assertEqual(seconde_requete.status_code, 200)\n",
"```\n",
"\n",
"Remarquez que :\n",
"1. On génère l'application dans `setUp()`\n",
"2. On garder la capacité d'insérer ou non des données (préférences personnelles).\n",
"3. Une fois l'application générée, on génère un client de test qui nous permettra de faire des requêtes (`test_client()`)\n",
"4. On utilise ce client pour faire des requêtes. Les réponses sont composées de :\n",
" - `.headers` qui est un dictonnaire\n",
" - `.data` qui est un `bytes`. On le transforme facilement en `str` via `chaine = response.data.decode()`\n",
" - `.status_code` qui est le code réponse HTTP\n",
" \n",
"**Question:** Quel type de test avons-nous ici ?\n",
"\n",
"### Tests mixtes\n",
"\n",
"Des tests ont été écrits dans `cours-flask/exemple18`. Regardez les. Nous pouvons les exécutez d'ici via la commande qui suit."
]
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 4,
"metadata": {},
"outputs": [],
"source": []
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"/home/thibault/dev/MM-Python-Course/cours-flask/exemple18/gazetteer/constantes.py:8: Warning: Le secret par défaut n'a pas été changé, vous devriez le faire\n",
" warn(\"Le secret par défaut n'a pas été changé, vous devriez le faire\", Warning)\n",
"...\n",
"----------------------------------------------------------------------\n",
"Ran 3 tests in 0.306s\n",
"\n",
"OK\n"
]
}
],
"source": [
"!PYTHONPATH=cours-flask/exemple18/ python -m unittest discover tests\n",
"# PYTHONPATH=cours-flask/exemple18 permet de dire à python que le dossier d'exécution est ce dernier."
]
}
],
"metadata": {
Expand Down
Empty file.
39 changes: 39 additions & 0 deletions cours-flask/exemple18/gazetteer/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
import os
from .constantes import CONFIG

chemin_actuel = os.path.dirname(os.path.abspath(__file__))
templates = os.path.join(chemin_actuel, "templates")
statics = os.path.join(chemin_actuel, "static")

# On initie l'extension
db = SQLAlchemy()
# On met en place la gestion d'utilisateur-rice-s
login = LoginManager()

app = Flask(
__name__,
template_folder=templates,
static_folder=statics
)


from .routes import generic
from .routes import api


def config_app(config_name="test"):
""" Create the application """
app.config.from_object(CONFIG[config_name])

# Set up extensions
db.init_app(app)
# assets_env = Environment(app)
login.init_app(app)

# Register Jinja template functions

return app

27 changes: 27 additions & 0 deletions cours-flask/exemple18/gazetteer/constantes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from warnings import warn

LIEUX_PAR_PAGE = 2
SECRET_KEY = "JE SUIS UN SECRET !"
API_ROUTE = "/api"

if SECRET_KEY == "JE SUIS UN SECRET !":
warn("Le secret par défaut n'a pas été changé, vous devriez le faire", Warning)


class _TEST:
SECRET_KEY = SECRET_KEY
# On configure la base de données
SQLALCHEMY_DATABASE_URI = 'sqlite:///test_db.sqlite'
SQLALCHEMY_TRACK_MODIFICATIONS = False


class _PRODUCTION:
SECRET_KEY = SECRET_KEY
# On configure la base de données
SQLALCHEMY_DATABASE_URI = 'mysql://gazetteer_user:password@localhost/gazetteer'
SQLALCHEMY_TRACK_MODIFICATIONS = False

CONFIG = {
"test": _TEST,
"production": _PRODUCTION
}
Empty file.
58 changes: 58 additions & 0 deletions cours-flask/exemple18/gazetteer/modeles/donnees.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from flask import url_for
import datetime

from .. app import db


class Authorship(db.Model):
__tablename__ = "authorship"
authorship_id = db.Column(db.Integer, nullable=True, autoincrement=True, primary_key=True)
authorship_place_id = db.Column(db.Integer, db.ForeignKey('place.place_id'))
authorship_user_id = db.Column(db.Integer, db.ForeignKey('user.user_id'))
authorship_date = db.Column(db.DateTime, default=datetime.datetime.utcnow)
user = db.relationship("User", back_populates="authorships")
place = db.relationship("Place", back_populates="authorships")

def author_to_json(self):
return {
"author": self.user.to_jsonapi_dict(),
"on": self.authorship_date
}


# On crée notre modèle
class Place(db.Model):
place_id = db.Column(db.Integer, unique=True, nullable=False, primary_key=True, autoincrement=True)
place_nom = db.Column(db.Text)
place_description = db.Column(db.Text)
place_longitude = db.Column(db.Float)
place_latitude = db.Column(db.Float)
place_type = db.Column(db.String(45))
authorships = db.relationship("Authorship", back_populates="place")

def to_jsonapi_dict(self):
""" It ressembles a little JSON API format but it is not completely compatible
:return:
"""
return {
"type": "place",
"id": self.place_id,
"attributes": {
"name": self.place_nom,
"description": self.place_description,
"longitude": self.place_longitude,
"latitude": self.place_latitude,
"category": self.place_type
},
"links": {
"self": url_for("lieu", place_id=self.place_id, _external=True),
"json": url_for("api_places_single", place_id=self.place_id, _external=True)
},
"relationships": {
"editions": [
author.author_to_json()
for author in self.authorships
]
}
}
Loading

0 comments on commit 0898657

Please sign in to comment.