Architecture Logicielle

Michel VEDRINE

Site web

https://kathode.neocities.org

Objectifs

Un séance c’est …

Généralités

Architecture n-tier

Cas d’une bibliothèque

L’application

Gestion de budget

Planning

  1. Interface en ligne de commande
  2. Séance libre
    • template de remise à niveau
  3. Gestion des données
  4. Interface web
    • rendu intermédiaire
  1. Industrialisation
  2. Validation & Traçabilité
  3. Authentification & Autorisation
  4. API HTTP
    • rendu final

Technologies

Outils nécessaires

Installation

https://docs.astral.sh/uv/#installation

Utilisation

Attention à toujours se positionner au même niveau que le pyproject.toml avant de lancer une commande uv :

$ uv sync           # pour installer les dépendances
$ uv run archilog   # lancement de l'application

Plus d’info sur https://docs.astral.sh/uv/.

État d’esprit

Interface en ligne de commande

Click

Click is a Python package for creating beautiful command line interfaces in a composable way with as little code as necessary.

Déclaration

import click

@click.command()
@click.option("--count", default=1)
@click.option("--name", prompt="Your name")
def hello(count, name):
    print(name, count)

utilisation

$ uv run archilog hello --count=3
Your name: John
John 3

SQLite

SQLite is a C library that provides a lightweight disk-based database that doesn’t require a separate server process.

DB-API

Utilisation

import sqlite3

db = sqlite3.connect("tutorial.db")
db.execute("CREATE TABLE movie(title, year, score)")

result = db.execute("SELECT title FROM movie").fetchone()

Documentation

Objectifs

Gestion des données

Modèle actuel

Modèle cible

SQLAlchemy

SQLAlchemy est un toolkit open source SQL et un mapping objet-relationnel (ORM) écrit en Python et publié sous licence MIT.

Architecture de SQLAlchemy

Configuration

engine = create_engine("sqlite:///data.db", echo=True)
metadata = MetaData()

users_table = Table(
    "users",
    metadata,
    Column("id", Uuid, primary_key=True, default=uuid.uuid4),
    Column("login", String, nullable=False)
)

Utilisation

stmt = users_table.insert().values(login="john.doe")

with engine.begin() as conn:
    result = conn.execute(stmt)

Installation

$ uv add sqlalchemy

Documentation

Objectifs

Interface web

Architecture cible

Flask

Flask is a lightweight WSGI web application framework. It is designed to make getting started quick and easy, with the ability to scale up to complex applications.

Application simple

# inside archilog/views.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"

Lancement

$ uv run flask --app archilog.views --debug run
 * Serving Flask app 'archilog.views'
 * Debug mode: on
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit

Template HTML

from flask import render_template

@app.route("/hello/")
@app.route("/hello/<name>")
def hello(name=None):
    return render_template("hello.html", name=name)

Template HTML avec Flask & Jinja2

<!doctype html>
<title>Hello from Flask</title>
{% if name %}
  <h1>Hello {{ name }}!</h1>
{% else %}
  <h1>Hello, World!</h1>
{% endif %}

Arborescence des fichiers

.
├── pyproject.toml
├── README.md
└── src
    └── archilog
        ├── __init__.py
        ├── models.py
        ├── services.py
        ├── templates
        │   └── home.html
        └── views.py

Installation

$ uv add flask

Documentation

Objectifs

Industrialisation

Routeurs

Blueprints Flask

from flask import Blueprint, render_template

web_ui = Blueprint("web_ui", __name__)

@web_ui.route("/<page>")
def show(page):
    return render_template(f"pages/{page}.html")

Application factory

def create_app():
    app = Flask(__name__)

    from archilog.views import api, web_ui
    app.register_blueprint(web_ui, url_prefix="/")
    app.register_blueprint(api, url_prefix="/api")

    return app

Lancement

Rien ne change, Flask cherche une fonction create_app :

$ uv run flask --app archilog.views --debug run

Configuration centralisée : avant

engine = create_engine("sqlite:///data.db", echo=True)
metadata = MetaData()

Configuration centralisée : après

from archilog import config

engine = create_engine(config.DATABASE_URL, echo=config.DEBUG)
metadata = MetaData()

Configuration centralisée : déclaration

from dataclasses import dataclass

@dataclass
class Config:
    DATABASE_URL: str
    DEBUG: bool

config = Config(
    DATABASE_URL="sqlite:///data.db",
    DEBUG=True
)

dotenv

[…] commonly used to store configuration settings and sensitive information in a simple text file, typically named .env.

Exemple

Dans un fichier dev.env :

ARCHILOG_DATABASE_URL=sqlite:///data.db
ARCHILOG_DEBUG=True
ARCHILOG_FLASK_SECRET_KEY=secret!

Injection manuelle avec bash

$ env $(cat dev.env | xargs) uv run flask --app ...

Injection automatique avec uv & pyproject-runner

Dans le fichier pyproject.toml :

[tool.pyproject-runner.tasks]
start = {
    cmd = "flask --app archilog.views --debug run",
    env-file = "!/dev.env"
}

Lancement d’une tâche

$ uv run rr start

Dans l’application

import os

[...]

config = Config(
    DATABASE_URL=os.getenv(
        "ARCHILOG_DATABASE_URL", "sqlite:///data.db"
    ),
    DEBUG=os.getenv("ARCHILOG_DEBUG", "False") == "True"
)

Configurer Flask

app.config.from_prefixed_env(prefix="ARCHILOG_FLASK")

Installation

$ uv add --dev pyproject-runner

Documentation

Objectifs

Validation & Traçabilité

Types de validation

Syntaxique : le type de la donnée est respecté (date, entier, texte, etc).

Sémantique : les données ont du sens (date de début inférieur à la date de fin, prix supérieur à 0, etc).

WTForms

[…] a flexible forms validation and rendering library for Python web development.

Formulaire

from flask_wtf import FlaskForm
from wtforms import StringField
from wtforms.validators import DataRequired

class MyForm(FlaskForm):
    name = StringField('name', validators=[DataRequired()])

Template

<form method="POST" action="/">
    {{ form.csrf_token }}
    {{ form.name.label }} {{ form.name(size=20) }}
    <input type="submit" value="Go">
</form>

Validation

@app.route('/submit', methods=['GET', 'POST'])
def submit():
    form = MyForm()
    if form.validate_on_submit():
        return redirect('/success')
    return render_template('submit.html', form=form)

Logging

import logging

logging.warning("Watch out!")  # will print to the console
logging.info("I told you so")  # will not print anything

Configuration

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("archilog.log"),
        logging.StreamHandler()
    ]
)

Gestion des erreurs

@app.errorhandler(500)
def handle_internal_error(error):
    flash("Erreur interne du serveur", "error")
    logging.exception(error)
    return redirect(url_for(".home"))

Tester son error handler

from flask import abort, render_template

@app.get("/users/create")
def users_create_form():
    abort(500)  # raise a 500 Internal Server Error exception
    return render_template("users_create_form.html")

Installation

$ uv add flask-wtf

Documentation

Objectifs

Authentification & Autorisation

Définitions

HTTP Authentication

Role-Based Access Control

Workflow

  1. récupération du login + mot de passe ;
  2. comparaison avec les informations du backend ;
  3. récupération des rôles associés au login ;
  4. vérification des permissions des rôles.

flask-httpauth

[…] a Flask extension that simplifies the use of HTTP authentication with Flask routes.

Pré-requis

from flask import Flask
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import generate_password_hash

app = Flask(__name__)
auth = HTTPBasicAuth()

users = {
    "john": generate_password_hash("hello"),
    "susan": generate_password_hash("bye")
}

Configuration

from werkzeug.security import check_password_hash

@auth.verify_password
def verify_password(username, password):
    if username in users and \
            check_password_hash(users.get(username), password):
        return username

@auth.get_user_roles
def get_user_roles(username):
    return get_roles(username)

Utilisation

@app.route("/")
@auth.login_required
def index():
    return f"Hello, {auth.current_user()}!"

@app.route("/admin")
@auth.login_required(role="admin")
def admins_only():
    return f"Hello {auth.current_user()}, you are an admin!"

Installation

$ uv add flask-httpauth

Documentation

Objectifs

Astuce

Pour se déconnecter, nettoyer les données du navigateur avec ctrl+shift+del.

HTTPS

Stockage des mots de passe

API HTTP

Définition

Une API s’appuyant sur HTTP est définie par :

Souvent appelée à tort une API REST ou RESTful.

Formalismes courants

CRUD

Manipulation d’une ressource via les verbes HTTP.

CRUD : exemples

Verbe Ressource Signification
GET /users récupère toutes les entités de la collection
POST /users crée une entité dans la collection
GET /users/13 récupère une entité
PUT /users/13 met à jour entièrement l’entité
PATCH /users/13 met à jour partiellement l’entité
DELETE /users/13 supprime l’entité
GET /users?inactive=true récupère toutes les entités inactive
GET /users?role=admin récupère toutes les entités admin

RPC

Appel d’une fonction via un POST + paramètres.

RPC : exemples

Verbe Ressource
POST /notify?channel=archilog&priority=critical
POST /drafts/42/publish

Format des données

Dans la majorité des cas, ce sera au format JSON, ensuite vient le xml.

{
    "name": "john",
    "age": "42"
}
<user>
    <name>john</name>
    <age>42</age>
</user>

spectree au lieu de WTForms

API spec validator and OpenAPI document generator for Python web frameworks.

Configuration

from flask import Blueprint
from spectree import SpecTree

api = Blueprint("api", __name__)
api_spec = SpecTree("flask")

Utilisation

from pydantic import BaseModel, Field

class UserData(BaseModel):
    name: str = Field(min_length=2, max_length=40)
    age: int = Field(gt=0, lt=150)

@api.post("/users")
@api_spec.validate(tags=["api"])
def create_user(json: UserData):
    return {"username": json.name}

HTTPTokenAuth au lieu de HTTPBasicAuth

from flask_httpauth import HTTPTokenAuth

auth = HTTPTokenAuth(scheme='Bearer')

@auth.verify_token
def verify_token(token):
    pass

Tester manuellement

$ curl http://localhost:5000/api/users \
    -H "Accept: application/json" \
    -H "Authorization: Bearer {token}"

Réponse :

[
    {"name": "john", "age": "42"},
    {"name": "jane", "age": "34"}
]

Tester avec Swagger

api = Blueprint("api", __name__)
api_spec = SpecTree("flask", app=api)

Puis se rendre sur /api/apidoc/swagger.

S’authentifier avec token sur Swagger

from spectree import SpecTree, SecurityScheme

api_spec = SpecTree(
    "flask",
    app=api,
    security_schemes=[
        SecurityScheme(
            name="bearer_token",
            data={"type": "http", "scheme": "bearer"}
        )
    ],
    security=[{"bearer_token": []}]
)

Documentation

Objectifs

Architecture finale

Packaging & Déploiement

Serveur d’application

gunicorn

$ uv add --dev gunicorn
$ uv run gunicorn archilog.views:create_app()

Packaging

$ uv build
$ ls dist/
archilog-0.2-py3-none-any.whl  archilog-0.2.tar.gz

Containerfile

FROM python:3

WORKDIR /app

COPY dist/archilog-*-py3-none-any.whl /app/
RUN pip install *.whl gunicorn

CMD ["gunicorn", "archilog.views:create_app()"]

Utilisation

Avec buildah/podman ou docker :

$ docker build -t my-username/my-image .
$ docker run --name mycontainer my-username/my-image
$ docker exec -d mycontainer sh -c "archilog init-db"