Table des matières

, , , , , ,

Flask : Gérer les migrations avec l'extension Flask-Migrate

Présentation

Flask-Migrate s'appuie sur Alembic pour gérer les migrations de la base de données gérée par l'ORM (SQLAlchemy). Ainsi le modèle de données définit dans l'application Flask peut être amendé/corrigé, c'est l'extension Flask-Migrate qui se chargera de créer les script de migrations capables de modifier le schéma de la base de données préexistante pour que le nouveau modèle de données soit exploitable sans perte de données.

Pour illustrer le fonctionnement de l'extension Flask-Migrate nous allons utiliser une application Flask minimale en configuration monolithique avec une classe de modèle simple : Product.

Installation

Création d'un environnement virtuel Python pour le projet Flask et installation des packages via pip :

#création d'un répertoire dédié à l'application
mkdir tuto-migrate && cd tuto-migrate
 
# création /activation de l'environnement virtuel Python
python3 -m venv .venv
source .venv/bin/activate 
 
# Installation de Flask et de l'extension Flask-Migrate
pip install Flask Flask-SQLAlchemy Flask-Migrate

Création de l'application Flask

Création de l'application Flask dans le module Python app.py

app.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
 
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///database.db"
db = SQLAlchemy(app)
migrate = Migrate(app, db)
 
 
class Product(db.Model):
    __tablename__ = "products"
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50))
 
    def __repr__(self):
        return f"<Product id={self.id} name='{self.name}'>"
 
 
@app.route("/")
def index():
    return "<h1>Hello, World!</h1>"

Le module Python app.py définit la classe Product avec une clé primaire id et un champ name.

Pour tester rapidement le code :

# Afficher les routes existantes
flask routes
 
# Pour tester la création d'une instance de Product
flask shell
>>> mandarine = Product(name='mandarine')
>>> mandarine
<Product id=None name='mandarine'>
>>> quit()

Initialisation de Flask-Migrate

Les commandes proposées par l'extension Flask-Migrate sont regroupées sous flask db. On commence par initialiser l’environnement nécessaire à l'extension puis on génère le premier script de migration :

# Création de l'environnement nécessaire à Flask-Migrate
flask db init
 
# Création du premier script
flask db migrate -m "Initial DB Model Product"

La commande retourne quelques messages de la forme :

INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'products'
  Generating /home/username/tuto-migrate/migrations/versions/a09681708867_initial_db_model_product.py ...  done

L'outil analyse les modifications apportées au modèle de données et crée un script de migration. Ce script est créé dans le sous dossier ./migrations/versions du projet.

Le dossier ./migrations doit être ajouté à votre outil de gestion de révision comme les autres sources de votre projet.

A ce stade, le script existe mais n'a pas été appliqué. Il est intéressant de le relire :

less migrations/versions/a09681708867_initial_db_model_product.py

On peut y voir deux fonctions : upgrade() et downgrade().

Les scripts de migration sont générés automatiquement et peuvent comporter des erreurs ou des imperfections en fonction de la complexité et de la nature des changements apportés au modèle de données. Il est donc recommandé de vérifier le code généré.

Si on affiche le contenu de la base on peut vérifier qu'elle est encore vide pour le moment :

sqlite3 instance/database.db .tables
alembic_version
 
# Une seule table existe nommée alembic_version, elle est vide pour le moment
sqlite3 instance/database.db 'SELECT * FROM alembic_version;'

Pour appliquer les modifications décrites dans le script de migration à la base de données du projet :

flask db upgrade

La commande retourne quelques messages du type :

INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade  -> a09681708867, Initial DB Model Product

Si on inspecte à nouveau la base, on peut voir que la table products existe à présent et qu'une entrée existe dans la table alembic_version :

# Lister les tables existantes
sqlite3 instance/database.db .tables
alembic_version  products
 
# Schéma des tables
sqlite3 instance/database.db '.schema'
CREATE TABLE alembic_version (
	version_num VARCHAR(32) NOT NULL, 
	CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
);
CREATE TABLE products (
	id INTEGER NOT NULL, 
	name VARCHAR(50), 
	PRIMARY KEY (id)
);
 
# Afficher le contenu de la table alembic_version
sqlite3 instance/database.db 'SELECT * FROM alembic_version;'
a09681708867
Le premier script de migration équivaut à l'exécution de la fonction db.create_all() depuis flask shell.
Lorsqu'on ajoute l'extension Flask-Migrate sur un projet préexistant, la commande flask db upgrade retourne une erreur car la base existe déjà. Dans ce cas il faut utiliser la commande flask db stamp pour marquer la base comme déjà migrée.

A chaque modification du modèle de données, il faudra répéter les opérations migrate(alias de revision) et upgrade.

Peuplement de la base de données

Pour illustrer le bon fonctionnement de Flask-Migrate nous allons :

  1. Introduire des données dans la base ;
  2. Modifier le modèle de données ;
  3. Créer et appliquer une migration afin de vérifier que la base existante peut utiliser notre nouveau modèle de données.

Pour ajouter des données dans la base, on utilise flask shell :

>>> apple = Product(name="Apple")
>>> orange = Product(name="Orange")
>>> banana = Product(name="Banana")
>>> db.session.add_all([apple, orange, banana])
>>> db.session.commit()
>>>
>>> # Supprime les instances et affiche les valeurs présentes dans la base
>>> del apple,orange,banana
>>> Product.query.all()
[<Product id=1 name='Apple'>, <Product id=2 name='Orange'>, <Product id=3 name='Banana'>]
>>> quit()

Modification du modèle de données

On modifie la classe Product en y ajoutant un attribut price

app.py
 

On regénère un script de migration :

flask db migrate -m "Ajout attribut price"

La commande retourne des messages indiquant que des modifications ont été identifiées et qu'un nouveau script est produit :

INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'products.price'
  Generating /home/username/tuto-migrate/migrations/versions/7d38ed81182c_ajout_attribut_price.py ...  done

Mise à niveau de la base de données

On peut consulter ce nouveau script puis l'exécuter :

less migrations/versions/7d38ed81182c_ajout_attribut_price.py 
flask db upgrade

Pour vérifier que le nouveau modèle s'applique sans retourner d'erreurs, on utilise flask shell :

>>> Product.query.all()
[<Product id=1 name='Apple' price=None>, <Product id=2 name='Orange' price=None>, <Product id=3 name='Banana' price=None>]
>>> quit()

Même si aucune valeur n'est renseignée, l'attribut price existe bien.

Rétrogradation de la base de données

Version actuelle de la base :

sqlite3 instance/database.db 'SELECT * FROM alembic_version;'
7d38ed81182c
 
sqlite3 instance/database.db '.schema products'
CREATE TABLE products (
	id INTEGER NOT NULL, 
	name VARCHAR(50), price INTEGER, 
	PRIMARY KEY (id)
);

Pour rétrograder la base à la version précédente :

flask db dowgrade

Etat de la base :

sqlite3 instance/database.db 'SELECT * FROM alembic_version;'
a09681708867
 
sqlite3 instance/database.db '.schema products'
CREATE TABLE IF NOT EXISTS "products" (
	id INTEGER NOT NULL, 
	name VARCHAR(50), 
	PRIMARY KEY (id)
);

On peut voir que la version a bien été modifiée et que la table ne comporte plus de colonne price.

Mais notre modèle de données n'est plus en adéquation avec le schéma de la base de données : son utilisation provoquera une erreur :

>>> # Depuis flask shell
>>> ananas = Product(name="Ananas")
>>> db.session.add(ananas)
>>>
>>> # La sauvegarde en base provoquera une erreur
>>> db.session.commit()

Le commit retournera un ensemble de messages de la forme :

Traceback (most recent call last):
  File "/home/username/tuto-migrate/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
    self.dialect.do_execute(
  File "/home/username/tuto-migrate/.venv/lib/python3.12/site-packages/sqlalchemy/engine/default.py", line 951, in do_execute
    cursor.execute(statement, parameters)
sqlite3.OperationalError: table products has no column named price

...

Il faudra retirer l'attribut price de la classe Product pour que les écritures en base puisse se faire normalement.

Renommer les commandes de l'extension

Le groupe de commandes par défaut de Flask-Migrate est nommé “db”. Il peut être renommé lors de l'instanciation de l'extension dans l'application Flask s'il ne convient pas au projet :

app.py
migrate = Migrate(app, db, command='db-tools')

Au sein du projet, on peut à présent appeler les commandes de Flask-Migrate :

flask db-tools show
flask db-tools --help

Résumé

On a créé une application Flask minimale et utilisé l'extension Flask-Migrate pour géré les migrations de la base de données après avoir modifier les classes de modèle de données.

En général on peut suivre les étapes suivantes lors du developpement d'une application Flask :

  1. Modifier les modèles ;
  2. Générer un script de migration via la commande flask db migrate ;
  3. Vérifier le script généré et le compléter si nécessaire ;
  4. Appliquer les changements sur la base de données via la commande flask db upgrade ;

La documentation complète de l'extension Flask-Migrate est fournie dans les références.

Références