Flashback

fin 2012, je créais un petit projet et en mai 2013, je sortais une version de ce dernier, nommé "Trigger Happy", entierement conçu avec l'excellentissime framework web Django.

7 ans plus tard, en l'écrivant je m'étouffe en réalisant, le projet n'est pas mort mais mais mais depuis l'arrivée d'asyncio, je me suis demandé comment lui apporter un seconde souflle funky, en terme de techno. Django-Channel vit le jour et promettait de l'async partout mais l'archi me paraissait beaucoup beaucoup trop lourde, en témoigne la palanquée de pré-requis.

Avant hier

Lors de l'hacktoberfest 2018 dernier, j'aperçu le projet Responder, tout asynchrone, et sortie vitesse lumière à la fin du mois d'octobre, avec moult talentueux contributeurs, dont Tom Christie, "Mr Dango Rest Framework". Le projet m'attira très fortement, mais encore un petit quelque chose m'empecha de me l'approprier et je laissais alors tomber, avec la flemme et envie d'autre chose.

Aujourd'hui

Mais depuis quelques mois, en décortiquant d'avantage les prerequis un à un de responder, je me suis arrêté sur starlette (The little ASGI framework that shines) L'auteur de Starlette n'est autre que Tom Christie ;) Comme d'hab pour s'approprier le projet, rien de mieux qu'un tuto.

Je tombe alors donc sur https://github.com/encode/starlette-example/ et hop c'était parti.

Le principe de starlette, pour qui ne connait pas encore le projet, est d'éviter la moindre friction entre les couches que sont la vue, la base de données, les formulaires, etc.

Du coup Starlette propose un socle de base très efficace, et l'auteur de starlette s'est, dans la foulée attelé, à produire des projets péripheriques tels que

  • orm ; pour comme avec Django ; permettre d'utiliser vos tables avec juste Models.object.all()
  • typeschema ; pour la validation de vos formulaires
  • database ; pour l'abstraction aux SGBD

Évidemment tous sont async.

Si vous souhaitez voir une video qui detaille Starlette, Tom Christie donne un talk, commencez donc la vidéo à partir de 7h30 vous aurez une heure à vous régaler ;)

Comme un poisson dans l'eau

Passer de Django "synchrone" à starlette "asynchrone" devrait être un jeu d'enfant pour vous autres, utilisateurs de Django, comme la plupart des éléments dans starlette, devraient furieusement, vous rappeler des strates de Django.

Comparatif des couches entre les 2 projets

PérimètreDjangoStarlette
TemplatingTemplate DjangoJinja2
Vue*Viewfonctions dans le(s) module(s) de votre application
Formulaire *Form typesystem (*)
ORMDjango ORMORM (*)
"Migration"manage.py migrateutilisation d'SQL Alchemy
Routageurls.pyRoute()

(*) = Fonctionnalités non incluses dans starlette, application tierce.

Les projets jumeaux

Pour vous illustrer le propos, revenons à Trigger Happy.

Alors non je ne l'ai pas refait intégralement avec starlette, mais j'en ai pris 2 morceaux pour produire un petit projet:

  • le premier : l'extraction des flux RSS
  • le second : l'éditeur markdown nommé Joplin

avec ces 2 là, je créé des notes automatiquement dans Joplin à partir du flux RSS de mon choix

Mon but étant, au départ, de faire la veille techno en créant des notes dans joplin pour les lire ultérieurement à partir de flux RSS de mes sites favoris.

Le premier projet en Django est nommé JONG : JOplin Note Generator

Le second projet avec Starlette est nommé Yeoboseyo

C'est au pif que je me suis mis à recorder Jong à la sauce Starlette, c'est au 3/4 de la fin que je me suis dit

ho marrant j'ai refait Jong avec Starlette finallement

et finallement ce n'etait pas la mer à boire, loin s'en faut, et puis c'est modulaire souple et c'est tout ce qu'on attend ;)

Comment fait on ...

suis ci dessous, chaque élément du tableau, une fois pour django une fois pour starlette

... une Vue

enfin ici il m'aura fallu 3 CBV * Django

class RssListView(ListView):
    """
        list of Rss
    """
    context_object_name = "rss_list"
    queryset = Rss.objects.all()

    def get_queryset(self):
        return self.queryset.filter().order_by('-date_triggered')

    def get_context_data(self, **kw):
        data = self.queryset.filter().order_by('-date_triggered')
        # paginator vars
        record_per_page = 10
        page = self.request.GET.get('page')
        # paginator call
        paginator = Paginator(data, record_per_page)
        try:
            data = paginator.page(page)
        except PageNotAnInteger:
            # If page is not an integer, deliver first page.
            data = paginator.page(1)
        except EmptyPage:
            # If page is out of range (e.g. 9999),
            # deliver last page of results.
            data = paginator.page(paginator.num_pages)

        context = super(RssListView, self).get_context_data(**kw)
        context['data'] = data
        return context


class RssCreateView(RssMixin, CreateView):
    """
        list of Rss
    """
    template_name = 'jong/rss.html'

    def get_context_data(self, **kw):
        context = super(RssCreateView, self).get_context_data(**kw)
        context['mode'] = 'add'
        return context


class RssUpdateView(RssMixin, UpdateView):
    """
        Form to update description
    """
    template_name = 'jong/rss.html'

    def get_context_data(self, **kw):
        context = super(RssUpdateView, self).get_context_data(**kw)
        context['mode'] = 'Edit'
        return context
  • Starlette

avec Starlette, pas de ClassBasedView :P on a une class HTTPEndPoint si on veut mais là pour la petitesse du projet je m'en suis affranchi

async def homepage(request):
    """
    get the list of triggers
    :param request:
    :return:
    """
    triggers = await Trigger.objects.all()
    template = "index.html"
    if request.method == 'GET':
        # trigger_id provided, form to edit this one
        if 'trigger_id' in request.path_params:
            trigger_id = request.path_params['trigger_id']
            trigger = await Trigger.objects.get(id=trigger_id)
            form = forms.Form(TriggerSchema, values=trigger)
        # empty form
        else:
            trigger_id = 0
            form = forms.Form(TriggerSchema)
        for trigger in triggers:
            print(trigger)
        context = {"request": request, "form": form, "triggers_list": triggers, "trigger_id": trigger_id}
        return templates.TemplateResponse(template, context)
    # POST
    else:
        data = await request.form()
        trigger, errors = TriggerSchema.validate_or_error(data)

        if errors:
            form = forms.Form(TriggerSchema, values=data, errors=errors)
            context = {"request": request, "form": form, "triggers_list": triggers}
            return templates.TemplateResponse(template, context)

        if 'trigger_id' in request.path_params:
            trigger_id = request.path_params['trigger_id']
            trigger_to_update = await Trigger.objects.get(id=trigger_id)
            print(trigger.rss_url, trigger.joplin_folder, trigger.description)
            await trigger_to_update.update(rss_url=trigger.rss_url,
                                           joplin_folder=trigger.joplin_folder,
                                           status=bool(trigger.status),
                                           description=trigger.description)
        else:
            await Trigger.objects.create(rss_url=trigger.rss_url,
                                         joplin_folder=trigger.joplin_folder,
                                         description=trigger.description)
    return RedirectResponse(request.url_for("homepage"))

voilà qui remplace les 3 CBV :P

... un Template

  • Django
<table class="table table-striped table-hover">
<tr>
    <th>{% trans "Name" %}</th>
    <th>{% trans "URL" %}</th>
    <th>{% trans "Triggered" %}</th>
    <th>{% trans "Notebook" %}</th>
    <th>{% trans "Bypass Errors" %}</th>
    <th>{% trans "Actions" %}</th>
</tr>
{% for data in rss_list %}
<tr>
    <td><a href="{% url 'edit' data.id %}" title="Edit this feed">{{ data.name }}</a></td>
    <td><a href="{{ data.url }}" title="Go to this feed">{{ data.url }}</a></td>
    <td>{{ data.date_triggered }}</td>
    <td>{{ data.notebook }}</td>
    <td>{% if data.bypass_bozo %}<span class="label label-danger">{% trans "Yes" %}</span>{% else %}<span class="label label-success">{% trans "No" %}</span>{% endif %}</td>
    <td><a class="btn btn-sm btn-md btn-lg btn-success" role="button" href="{% url 'edit' data.id %}" title="Edit this feed"><span class="glyphicon glyphicon-pencil icon-white"></span></a>
        {% if data.status %}
        <a class="btn btn-sm btn-md btn-lg btn-primary" role="button" href="{% url 'switch' data.id %}" title="{% trans 'Set this Feed off' %}"><span class="glyphicon glyphicon-off icon-white"></span></a>
        {% else %}
        <a class="btn btn-sm btn-md btn-lg btn-warning" role="button" href="{% url 'switch' data.id %}" title="{% trans 'Set this Feed on' %}"><span class="glyphicon glyphicon-off icon-white"></span></a>
        {% endif %}
        <a class="btn btn-sm btn-md btn-lg btn-danger" role="button"  href="{% url 'delete' data.id %}" title="Delete this feed"><span class="glyphicon glyphicon-trash icon-white"></span></a>
    </td>
</tr>
{% endfor %}
</table>
  • Starlette
{% extends "base.html" %}

{% block content %}
<div class="col-xs-5 col-md-5 col-lg-5">
    {% if trigger_id > 0 %}
    <form method="POST" action='{{ url_for('homepage', trigger_id=trigger_id) }}'>
    {% else %}
    <form method="POST" action='{{ url_for('homepage') }}'>
    {% endif %}
        {{ form }}
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
</div>
<div class="col-xs-7 col-md-7 col-lg-7">
    <table class="table table-striped table-hover">
        <thead>
            <tr><th scope="col">Description</th>
                <th scope="col">RSS URL</th>
                <th scope="col">Joplin Folder</th>
                <th scope="col">Triggered</th>
                <th scope="col">Created</th>
                <th scope="col">Status</th>
                <th scope="col">Action</th>
            </tr>
        </thead>
        {% if triggers_list %}
        <tbody>
        {% for trigger in triggers_list %}
            <tr><td><a href="{{ url_for('homepage', trigger_id=trigger.id)}}" title="Edit this trigger">{{ trigger.description }}</a></td>
                <td><a href="{{ trigger.rss_url }}" title="go to this URL">{{ trigger.rss_url }}</a> </td>
                <td>{{ trigger.joplin_folder }}</td>
                <td>{{ trigger.date_triggered  }}</td>
                <td>{{ trigger.date_created}}</td>
                <td>{{ trigger.status }}</td>
                <td>
                    <button type="button" class="btn btn-danger" data-toggle="modal" data-target="#trigger{{ trigger.id }}">Delete</button>
                    <div class="modal fade" id="trigger{{ trigger.id }}" tabindex="-1" role="dialog" aria-labelledby="trigger{{ trigger.id}}Label" aria-hidden="true">
                      <div class="modal-dialog" role="document">
                        <div class="modal-content">
                          <div class="modal-header">
                            <h5 class="modal-title">Deletion : {{ trigger.description }}</h5>
                            <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                              <span aria-hidden="true">&times;</span>
                            </button>
                          </div>
                          <div class="modal-body">
                            <p>are your sure you want to delete this trigger ? {{ trigger.description }}</p>
                          </div>
                          <div class="modal-footer">
                            <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
                            <a href="{{ url_for('delete', trigger_id=trigger.id)}}" class="btn btn-danger" role="button">Delete it</a>
                          </div>
                        </div>
                      </div>
                    </div>
                </td>
            </tr>
        {% endfor %}
        </tbody>
        {% endif %}
    </table>
</div>


{% endblock %}

... un Model

  • Django
from django.db import models


class Rss(models.Model):

    """
        Rss
    """
    name = models.CharField(max_length=200, unique=True)
    status = models.BooleanField(default=True)
    notebook = models.CharField(max_length=200)
    url = models.URLField()
    tag = models.CharField(max_length=40, null=True, blank=True)
    date_triggered = models.DateTimeField(auto_now=True, auto_created=True)
    # to ignore the not well formed RSS feeds
    # bozo detection https://pythonhosted.org/feedparser/bozo.html?highlight=bozo
    # default is False : we do not ignore not well formed Feeds.
    bypass_bozo = models.BooleanField(default=False)

    def show(self):
        """

        :return: string representing object
        """
        return "RSS %s %s" % (self.name, self.status)

    def __str__(self):
        return self.name
  • Starlette
import orm

class Trigger(orm.Model):
    __tablename__ = "trigger"
    __database__ = database
    __metadata__ = metadata

    id = orm.Integer(primary_key=True)
    rss_url = orm.String(max_length=255)
    joplin_folder = orm.String(max_length=80)
    description = orm.String(max_length=200)
    date_created = orm.DateTime(default=datetime.datetime.now)
    date_triggered = orm.DateTime(allow_null=True)
    status = orm.Boolean(default=False)
    result = orm.Text(allow_null=True)
    date_result = orm.DateTime(allow_null=True)
    provider_failed = orm.Integer(allow_null=True)
    consumer_failed = orm.Integer(allow_null=True)

... Un Forms

  • Django

je vous fais grâce du template du formulaire, le but étant de se focaliser sur les éléments identiques ;)

class RssForm(forms.ModelForm):

    """
        RSS Form
    """
    def __init__(self, *args, **kwargs):
        # Get initial data passed from the view
        super(RssForm, self).__init__(*args, **kwargs)
        self.fields['notebook'].choices = folders()

    class Meta:

        model = Rss
        exclude = ('date_triggered',)
        widgets = {
            'name': TextInput(attrs={'class': 'form-control'}),
            'url': TextInput(attrs={'class': 'form-control'}),
            'notebook': TextInput(attrs={'class': 'form-control'}),
            'tag': TextInput(attrs={'class': 'form-control'}),
            'status': TextInput(attrs={'class': 'form-control'}),
            'bypass_bozo': CheckboxInput(attrs={'class': 'checkbox'}),
        }

    notebook = forms.ChoiceField()
  • Starlette
import typesystem


class TriggerSchema(typesystem.Schema):
    """
       Schema to define the structure of a Trigger
    """
    rss_url = typesystem.String(title="RSS URL", max_length=255)
    joplin_folder = typesystem.String(title="Joplin Folder", max_length=80)
    description = typesystem.String(title="Description", max_length=200)
    status = typesystem.Boolean(title="Status", default=False)

... Un Routage

  • Django
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', RssListView.as_view(), name='base'),
    path('', RssListView.as_view(), name='rss'),
    path('add/', RssCreateView.as_view(), name='add'),
    url(r'^edit/(?P<pk>\d+)$', RssUpdateView.as_view(), name='edit'),
    [...]
]
  • Starlette
app = Starlette(
    debug=True,
    routes=[
        Route('/', homepage, methods=['GET', 'POST'], name='homepage'),
        Route('/id/{trigger_id:int}', homepage, methods=['POST', 'GET'], name='homepage'),
        Route('/delete/{trigger_id:int}', delete, methods=['GET'], name='delete'),
        Mount('/static', StaticFiles(directory='static'), name='static')
    ],
)

Voilou pour l'essentiel, si vous souhaitez comparer intégralement les 2 projets, au dossier/fichier pret, vous avez les liens sur github au dessus.

Pour ma part je trouve starlette et ses amis, modulaires comme il faut. On dirait qu'on plug des pièces de tétris au fur et à mesure pour obtenir à la fin un projet costaud.