PROJET AUTOBLOG


Sam & Max: Python, Django, Git et du cul

Site original : Sam & Max: Python, Django, Git et du cul

⇐ retour index

Mise à jour

Mise à jour de la base de données, veuillez patienter...

Signes qu’il faut extraire une fonction. 18

lundi 15 février 2016 à 12:41

Organiser son code est tout un art. Ça se peaufine au fur et à mesure d’une carrière. J’ai découvert au fil du temps des règles qui formalisent quelque chose que je faisais sans vraiment m’en rendre compte. Vous savez comme ces règles de français à la noix que vous utilisez quotidiennement sans savoir qu’il y a règle.

DRY au bout de 3

Je commence par la plus facile.

Le célèbre Don’t Repeat Yourself n’a pas besoin d’être poussée à l’extrême. Copier/coller du code une fois est tout à fait acceptable, et souvent bien plus productif que de refactoriser son code. On commence donc à faire une fonction si on a 3 copies de son code.

Dans ce code par exemple, j’utilise 3 fois mon trigger, il est mur pour la cueillette :

async def setup(self, cwd=None):
 
 
    if cwd is None:
        cwd = get_project_dir()
    self.project_dir = Path(cwd)
 
    # Tell all component to hook them self to the events they want to react
    # to
    futures = [ensure_awaitable(c.setup) for c in self.components.values()]
    await asyncio.gather(*futures)
 
    self.state = "init"
    await self.trigger('init')
 
    self.state = "ready"
    await self.trigger('ready')
 
    self.state = "running"
    return self.trigger('running')

Pouf:

def change_state(self, value):
    """ Change the inner state of the app, and call all state observers. """
    self.state = value
    return self.trigger(value)
 
async def setup(self, cwd=None):
 
    if cwd is None:
        cwd = get_project_dir()
    self.project_dir = Path(cwd)
 
    # Tell all component to hook them self to the events they want to react
    # to
    futures = [ensure_awaitable(c.setup) for c in self.components.values()]
    await asyncio.gather(*futures)
 
    await self.change_state('init')
    await self.change_state('ready')
    return self.change_state('running')

Transformez vos commentaires en fonctions

C’est un conseil qui a apparemment été rendu populaire pas le courant Extreme Programming. Leur idée c’est que les noms de fonctions sont une forme de documentation. Chaque fonction devant être testée et documentée, si vous avez un commentaire qui se balade tout seul au milieu du code, c’est le signe qu’il faut en faire une fonction.

Dans le code qu’on vient de voir, j’ai un gros commentaire en plein milieu expliquant un bout de code un peu compliqué. Hop, on coupe :

async def setup(self, cwd=None):
 
    if cwd is None:
        cwd = get_project_dir()
    self.project_dir = Path(cwd)
 
    # Tell all component to hook them self to the events they want to react
    # to
    futures = [ensure_awaitable(c.setup) for c in self.components.values()]
    await asyncio.gather(*futures)
 
    await self.change_state('init')
    await self.change_state('ready')
    return self.change_state('running')

Pouf:

async def setup_components(self):
    """ Give all components a chance to hook to life cycle events """
    futures = [ensure_awaitable(c.setup) for c in self.components.values()]
    return await asyncio.gather(*futures)
 
 
async def setup(self, cwd=None):
 
    if cwd is None:
        cwd = get_project_dir()
    self.project_dir = Path(cwd)
 
    await self.setup_components()
 
    await self.change_state('init')
    await self.change_state('ready')
    return self.change_state('running')

On voit ici que la présence du commentaire signalait que le code était assez complexe, et donc méritait d’être isolé. Cela permet de :

Une partie qui n’a rien à voir avec la choucroute mérite sa propre choucroute

Dans de grosses fonctions, on a souvent des bouts qui sont des étapes, mais qui ne sont pas liés sémantiquement aux autres étapes. Elles doivent juste arriver chronologiquement avant ou après celle-ci.

Toujours dans le même code, on a un bout qui initialise le project_dir de l’app. Ce snippet, c’est comme l’inspecteur Karamazov, fils unique, aucun lien. En tout cas aucun avec le setup de components ou le changement du state. Il est là car il doit arriver en premier. Aucune, aucune, aucune hésitation ! Amputation, SO-LU-TION !

async def setup(self, cwd=None):
 
    if cwd is None:
        cwd = get_project_dir()
    self.project_dir = Path(cwd)
 
    await self.setup_components()
 
    await self.change_state('init')
    await self.change_state('ready')
    return self.change_state('running')

Pouf :

def setup_environment(self, cwd=None):
    """ Set project dir for the current app """
    if cwd is None:
        cwd = get_project_dir()
    self.project_dir = Path(cwd)
 
async def setup(self, cwd=None):
 
    self.setup_environment(cwd)
 
    await self.setup_components()
 
    await self.change_state('init')
    await self.change_state('ready')
    return self.change_state('running')

On gagne en testabilité.

Et maintenant notre méthode setup() est très, très facile à comprendre. Son rôle est d’organiser 5 étapes. Pas de faire les étapes elle-même, ça c’est délégué. Le cycle de vie de l’application saute maintenant aux yeux. S’il y a un bug, on cherchera uniquement dans la fonction qui est responsable.

Sans le savoir, et sans vraiment le chercher, nous avons mis en oeuvre le principe de séparation des responsabilités. Seulement au lieu d’avoir cette notion floue et abstraite de responsabilité, on a maintenant quelques règles qui peuvent être facilement appliquées.

Diantre, je faisais donc de la prose, depuis toujours dans le savoir…

Pas de paramètre on/off

Le code de l’article d’hier était un cas d’école

Ma première version ressemblait à :

def resource_stream(resource, locations, encoding="utf8", ..., binary=False):
    """ Return a resource from this path or package """
    # du code, du code... puis:
    if binary:
        return stream
    return io.TextIOWrapper(stream, encoding, errors, newline, line_buffering)

On voit clairement ici que binary est un paramètre on/off, un truc qui déclenche un mode auquel on va passer un booléen.

Ça va s’utiliser comme ça:

txtstream =  resource_stream(...)
bstream =  resource_stream(..., True)

Quand on a des constantes magiques, il faut sortir la hache :

def binary_resource_stream(resource, locations):
    """ Return a resource from this path or package """
    ...
    return stream
 
 
def text_resource_stream(path, locations, encoding="utf8", ...):
    stream = binary_resource_stream(path, locations)
    return io.TextIOWrapper(stream, encoding, errors, newline, line_buffering)

La seconde fonction utilise la première. A l’usage le code est beaucoup plus clair :

txtstream =  text_resource_stream(...)
bstream =  binary_resource_stream(...)

Encore une fois, c’est plus facile à tester, plus facile à comprendre, et on diminue le nombre de branches dans le code.

Pour les gens particulièrement intéressés par la lisibilité du code, il existe l’indicateur de McCabe qui se base notamment sur le nombre de branches logiques. Le max recommandé est en général de 10, personnellement mes codes dépassent rarement 5. flake8 a l’option –max-complexity pour vérifier que votre code s’y tient.

Embarquer un fichier non Python proprement 8

vendredi 12 février 2016 à 13:38

Ah, le packaging Python, c’est toujours pas fun.

Parmi les nombreux problèmes, la question suivante se pose souvent : comment je livre proprement un fichier de données fourni avec mon package Python ?

Le problème est double:

On pourrait croire qu’un truc aussi basique serait simple, mais non.

Ca ne l’est pas car un package Python peut être livré sous forme de code source, ou d’un binaire, ou d’une archive eggs/whl/pyz… Et en prime il peut être transformé par les packageurs des repos debian, centos, homebrew, etc.

Mais skippy a la solution à tous vos soucis !

Include les fichiers avec votre code

A la racine de votre projet, il faut un fichier MANIFEST.IN bien rempli et include_package_data sur True dans setup.py. On a un article là dessus, ça tombe bien.

N’utilisez pas package_data, ça ne marche pas à tous les coups.

Charger les fichiers dans votre code

Si vous êtes pressés, copiez ça dans un fichier utils.py:

import os
import io
 
from types import ModuleType
 
 
class RessourceError(EnvironmentError):
 
    def __init__(self, msg, errors):
        super().__init__(msg)
        self.errors = errors
 
 
def binary_resource_stream(resource, locations):
    """ Return a resource from this path or package """
 
    # convert
    errors = []
 
    # If location is a string or a module, we wrap it in a tuple
    if isinstance(locations, (str, ModuleType)):
        locations = (locations,)
 
    for location in locations:
 
        # Assume location is a module and try to load it using pkg_resource
        try:
            import pkg_resources
            module_name = getattr(location, '__name__', location)
            return pkg_resources.resource_stream(module_name, resource)
        except (ImportError, EnvironmentError) as e:
            errors.append(e)
 
            # Falling back to load it from path.
            try:
                # location is a module
                module_path = __import__(location).__file__
                parent_dir = os.path_dirname(module_path)
            except (AttributeError, ImportError):
                # location is a dir path
                parent_dir = os.path.realpath(location)
 
            # Now we got a resource full path. Just open it as a regular file.
            canonical_path = os.path.join(parent_dir, resource)
            try:
                return open(os.path.join(canonical_path), mode="rb")
            except EnvironmentError as e:
                errors.append(e)
 
    msg = ('Unable to find resource "%s" in "%s". '
           'Inspect RessourceError.errors for list of encountered erros.')
    raise RessourceError(msg % (resource, locations), errors)
 
 
def text_resource_stream(path, locations, encoding="utf8", errors=None,
                    newline=None, line_buffering=False):
    """ Return a resource from this path or package. Transparently decode the stream. """
    stream = binary_resource_stream(path, locations)
    return io.TextIOWrapper(stream, encoding, errors, newline, line_buffering)

Et faites:

from utils import binary_resource_stream, text_resource_stream 
data_stream = binary_resource_stream('./chemin/relatif', package) # pour du binaire 
data = data_stream.read() 
txt_stream = text_resource_stream('./chemin/relatif', package) # pour du texte text = txt_stream.read()

Par exemple:

image_data = binary_resource_stream('./static/img/logo.png', "super_package").read() 
text = text_resource_stream('./config/default.ini', "super_package", encoding="cp850").read()

Si vous n’êtes pas pressés, voilà toute l’histoire…

A la base, on fait généralement un truc du genre:

PROJECT_DIR = os.path.dirname(os.path.realpath(__file__)) # ou pathlib/path.py 
data = open(os.path.join(PROJECT_DIR, chemin_relatif)).read())

Mais ça ne marche pas dans le cas d’une installation binaire, zippée, trafiquée, en plein questionnement existentiel après avoir regardé un film des frères Cohen, etc.

La manière la plus sûre de le faire est:

import pkg_resources 
data = pkg_resources.resource_stream('nom_de_votre_module', chemin_relatif).read()

Ça va marcher tant que votre package est importable. On se fout d’où il est, de sa forme, Python se démerde.

Mais ça pose plusieurs autres problèmes:

Le snippet fourni corrige ces problèmes en gérant les cas tordus pour vous. Moi je l’utilise souvent en faisant:

with text_resource_stream('./chemin/relatif', ['package_name', 'chemin_vers_au_cas_ou_c_est_pas_un_package']) as f:
     print('boom !', f.read())

Je devrais peut-être rajouter ça dans batbelt et minibelt…

Travailler avec des fichiers non Python

Ça, c’est pour lire les ressources fournies. Mais si vous devez écrire des trucs, il vous faut un dossier de travail.

Si c’est pour un boulot jetable, faites-le dans un dossier temporaire. Le module tempfile est votre ami.

Si c’est pour un boulot persistant, trouvez le dossier approprié à votre (fichier de config, fichier de log, etc) et travaillez dedans. path.py a des méthodes dédiées à cela (un des nombreuses raisons qui rendent ce module meilleur que pathlib).

Histoire de ne pas perdre le fil : TrackingFields 16

dimanche 7 février 2016 à 12:03

Ceci est un post invité de Foxmask posté sous licence creative common 3.0 unported.

Préambule

Je sais bien qu’une partie de ce billet ne plaira pas à Sam&Max (thanks to the Django CBV & Mixin:)

Introduction
Le but du billet sera de montrer comment, sans rien changer dans un formulaire, on peut arriver à pister les modifications des données effectuées dans l’application.

La première partie va planter le décor en commençant par vous montrer comment s’articule une application avec formulaire composé d’un sous formulaire en sus (j’expliquerai pourquoi après :)

Pour ce faire, je vous emmène dans l’univers du 7° art, viendez on va refaire StarWars!

Un modèle, un formulaire, une vue, un template et ca sera fini.

le models.py

    from django.db import models
 
 
    class Movie(models.Model):
        """
            Movie
        """
        name = models.CharField(max_length=200, unique=True)
        description = models.CharField(max_length=200)
 
        def __str__(self):
            return "%s" % self.name
 
 
    class Episode(models.Model):
        """
           Episode - for Trilogy and So on ;)
        """
        name = models.CharField(max_length=200)
        scenario = models.TextField()
        movie = models.ForeignKey(Movie)
 
        def __str__(self):
            return "%s" % self.name

le forms.py, tout rikiki :

    from django import forms
    from django.forms.models import inlineformset_factory
 
    from starwars.models import Movie, Episode
 
 
    class MovieForm(forms.ModelForm):
 
        class Meta:
            """
                As I have to use : "exclude" or "fields"
                As I'm very lazy, I dont want to fill the list in the "fields"
                so I say that I just want to exclude ... nothing :P
            """
            model = Movie
            exclude = []
 
    # a formset based on the model of the Mother "Movie" and Child "Episode" + 1 new empty lines
    # for more details, have a look at https://docs.djangoproject.com/fr/1.9/topics/forms/modelforms/#inline-formsets
    EpisodeFormSet = inlineformset_factory(Movie, Episode, fields=('name', 'scenario'), extra=1)

la vue views.py, très sèche, très DRY ;)

    from django.http import HttpResponseRedirect
    from django.core.urlresolvers import reverse
    from django.views.generic import CreateView, UpdateView, ListView
 
    from starwars.models import Movie
    from starwars.forms import MovieForm, EpisodeFormSet
 
 
    class MovieMixin(object):
        model = Movie
        form_class = MovieForm
 
        def get_context_data(self, **kw):
            """ init form with data if any """
            context = super(MovieMixin, self).get_context_data(**kw)
            if self.request.POST:
                context['episode_form'] = EpisodeFormSet(self.request.POST)
            else:
                context['episode_form'] = EpisodeFormSet(instance=self.object)
            return context
 
        def get_success_url(self):
            """ where to go back, once data are validated """
            return reverse("home")
 
        def form_valid(self, form):
            """ form validation """
            formset = EpisodeFormSet((self.request.POST or None), instance=self.object)
            if formset.is_valid():
                self.object = form.save()
                formset.instance = self.object
                formset.save()
 
            return HttpResponseRedirect(reverse('home'))
 
 
    class Movies(ListView):
        model = Movie
        context_object_name = "movies"
        template_name = "base.html"
 
 
    class MovieCreate(MovieMixin, CreateView):
        """
            MovieMixin manages everything for me ...
        """
        pass
 
 
    class MovieUpdate(MovieMixin, UpdateView):
        """
            ... and as I'm DRY I wont repeat myself myself myself ;)
        """
        pass

Pour finir de planter le décors et les costumes (merci Roger Hart et Donald Cardwell)

le template base.html

    <!DOCTYPE html>
    <html lang="fr">
    <head>
        <title>Manage stories for StarWars</title>
    </head>
    <body>
    <h1>Stories Manager for Starwars</h1>
    {% block content %}
    <a href="{% url 'movie_create' %}">Add a movie</a><br/>
    <h2>Movie list</h2>
    <ul>
    {% for movie in movies %}
    <li><a href="{% url 'movie_edit' movie.id %}">{{ movie.name }}</a></li>
    {% endfor %}
    </ul>
    {% endblock %}
    </body>
    </html>

enfin movie_form.html (le template utilisé par les UpdateView & CreateView)

    {% extends "base.html" %}
    {% block content %}
    <form method="post" action="">
        {% csrf_token %}
        {{ formset.management_form }}
        <table>
        {{ form.as_table }}
        </table>
        <table>
        {{ episode_form.as_table }}
        </table>
        <button>Save</button>
    </form>
    {% endblock %}

Mise à jour de la base de données

cela s’impose :

(starwars) foxmask@foxmask:~/DjangoVirtualEnv/starwars/starwars $  ./manage.py migrate
 
Operations to perform:
  Synchronize unmigrated apps: messages, starwars, staticfiles
  Apply all migrations: contenttypes, admin, sessions, auth
Synchronizing apps without migrations:
  Creating tables...
    Creating table starwars_movie
    Creating table starwars_episode
    Running deferred SQL...
  Installing custom SQL...

Voilà le tout est prêt (après le lancement du serveur bien sûr), je peux allégrement créer ma double trilogie pépère tel George Lucas.

Trackons l’impie

Seulement un jour arrive où, moi, George Lucas, je vends StarWars à Walt Disney, mais comme je ne veux pas rater de ce qu’ils vont faire de mon “bébé”, je rajoute un “tracker de modifications” à mon application, pour ne pas perdre le “field” de l’Histoire.

Installation de Tracking Fields

en prérequis django-tracking-fields requiert django-cuser, donc ze pip qui va bien donne :

    (starwars) foxmask@foxmask:~/DjangoVirtualEnv/starwars/starwars $ pip install django-tracking-fields django-cuser
    Collecting django-tracking-fields
      Downloading django-tracking-fields-1.0.6.tar.gz (58kB)
        100% |████████████████████████████████| 61kB 104kB/s 
    Collecting django-cuser
      Downloading django-cuser-2014.9.28.tar.gz
    Requirement already satisfied (use --upgrade to upgrade): Django>=1.5 in /home/foxmask/DjangoVirtualEnv/starwars/lib/python3.5/site-packages (from django-cuser)
    Installing collected packages: django-tracking-fields, django-cuser
      Running setup.py install for django-tracking-fields ... done
      Running setup.py install for django-cuser ... done
    Successfully installed django-cuser-2014.9.28 django-tracking-fields-1.0.6

comme de coutume, après un pip install, une modification dans le settings.py suit,

INSTALLED_APPS = (
        ...
        'cuser',
        'tracking_fields',
        ...
    )
    MIDDLEWARE_CLASSES = (
        'django.contrib.sessions.middleware.SessionMiddleware',
        'django.middleware.common.CommonMiddleware',
        'django.middleware.csrf.CsrfViewMiddleware',
        'django.contrib.auth.middleware.AuthenticationMiddleware',
        'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
        'django.contrib.messages.middleware.MessageMiddleware',
        'django.middleware.clickjacking.XFrameOptionsMiddleware',
        'django.middleware.security.SecurityMiddleware',
        'cuser.middleware.CuserMiddleware',  ## <=== ne pas oublier, pour chopper le user qui fait le con avec mes films;)
    )

le petit migrate qui va bien aussi pour ajouter les tables pour les modèles de django-tracking-fields

    (starwars) foxmask@foxmask:~/DjangoVirtualEnv/starwars/starwars $  ./manage.py migrate
    Operations to perform:
      Synchronize unmigrated apps: staticfiles, messages, cuser, starwars
      Apply all migrations: auth, sessions, contenttypes, tracking_fields, admin
    Synchronizing apps without migrations:
      Creating tables...
        Running deferred SQL...
      Installing custom SQL...
    Running migrations:
      Rendering model states... DONE
      Applying tracking_fields.0001_initial... OK
      Applying tracking_fields.0002_auto_20160203_1048... OK

et nous voilà prêts à joueur les trackers.

Utilisation

On ne peut pas rêver plus simple, cela se résume à un décorateur sur le modèle qui identifie quelles données sont modifiées, et un field histo qui va lier le modele TrackingEvent de l’application TrackingFields, à ma table à surveiller. Et là, bien que le modele ait été modifié, inutile de faire un nouveau python manage.py migrate, rien ne bougera, histo sera une GenericRelation(). En effet, TrackingEvent repose sur ContenType aka “L’Infrastructure des Types de Contenu”. Si vous avez déjà tripoté la gestion des permissions, vous avez déjà dû vous y frotter;)

Pour la faire courte, en clair ça donne :

le models.py arrangé pour l’occasion du décorateur

    from django.db import models
    from django.contrib.contenttypes.fields import GenericRelation
 
    from tracking_fields.decorators import track
    from tracking_fields.models import TrackingEvent
 
 
    @track('name', 'description')   # decorator added
    class Movie(models.Model):
        """
            Movie
        """
        name = models.CharField(max_length=200, unique=True)
        description = models.CharField(max_length=200)
        # to get the changes made on movie
        histo = GenericRelation(TrackingEvent, content_type_field='object_content_type')
 
        def episodes(self):
            return Episode.objects.filter(movie=self)
 
        def __str__(self):
            return "%s" % self.name
 
    @track('name', 'scenario')   # decorator added
    class Episode(models.Model):
        """
           Episode - for Trilogy and So on ;)
        """
        name = models.CharField(max_length=200)
        scenario = models.TextField()
        movie = models.ForeignKey(Movie)
        # to get the changes made on episode
        histo = GenericRelation(TrackingEvent, content_type_field='object_content_type')
 
        def __str__(self):
            return "%s" % self.name

bon là c’est simplissime comme une recette de pate à crêpes: 3 imports de rigueur, le décorateur et la GenericRelation() on mélange le tout et ca donne ce qui suit J’ai, au passage, rajouté une fonction episodes à ma classe Movie, dont je vous reparlerai plus bas.

le template de la DetailView (pour afficher uniquement les details d’un film) qui va bien

    <table>
       <caption>History of the modification of {{ object }} </caption>
       <thead>
       <tr><th>Old Value</th><th>New Value</th><th>By</th><th>at</th></tr>
       </thead>
       <tbody>
    {% for h in object.histo.all %}
       {% for f in h.fields.all %}
           <tr><td>{{ f.old_value }}</td><td>{{ f.new_value }}</td><td>{{ h.user }}</td><td>{{ h.date }}</td></tr>
       {% endfor %}
    {% endfor %}
       </tbody>
    </table>

A présent si je me rends dans ma page pour modifier le scénario d’un Episode, mon template ci dessus, ne m’affichera pas ces modications ! Pourquoi bou diou ? Parce qu’ici j’affiche “l’histo” de Movie pas de Episode… On comprend à présent ici mon intéret pour le sous formulaire. Le “problème” aurait été masqué si je m’étais arrêté à un seul simple formulaire.

Corrigeons

c’est là qu’entre en jeu la fonction episodes à ma classe Movie, pour me permettre d’itérer dessus et afficher tout le toutim

le template de la DetailView qui va bien (bis)

    <table>
        <caption>History of the modifications of {{ object }} </caption>
        <thead>
            <tr><th>Old Value</th><th>New Value</th><th>By</th><th>at</th></tr>
        </thead>
        <tbody>
    {% for h in object.histo.all %}
       {% for f in h.fields.all %}
           <tr><td>{{ f.old_value }}</td><td>{{ f.new_value }}</td><td>{{ h.user }}</td><td>{{ h.date }}</td></tr>
       {% endfor %}
    {% endfor %}
        </tbody>
    </table>
    {% for ep in object.episodes %}
        {% if ep.histo.all %}
    <table>
        <caption>history of the modifications of Episode</caption>
        <thead>
            <tr><th>Old Value</th><th>New Value</th><th>By</th><th>at</th></tr>
        </thead>
        <tbody>
            {% for h in ep.histo.all %}
                {% for f in h.fields.all %}
                {% if f.old_value == f.new_value %} {# they are the same when the new value is created to avoid to display "null" #}
                {% else %}
                <tr><td>{{ f.old_value }}</td><td>{{ f.new_value }}</td><td>{{ h.user }}</td><td>{{ h.date }}</td></tr>
                {% endif %}
                {%  endfor %}
            {% endfor %}
        </tbody>
     </table>
        {% endif %}
    {% endfor %}

Voili voilou ! Et en prime, si vous êtes curieux, coté admin, vous avez aussi la liste de toutes les modifications si besoin ;)

Aux utilisateurs avertis qui diraient :

pourquoi l’avoir recodé coté front puisque c’est déjà géré coté admin sans lever le petit doigt ?

Parce que George Lucas veut montrer les modifications apportées à son bébé StarWars par Walt Disney, au monde entier pardi !

Ah un détail en passant : dans l’admin la vue qui affiche la liste des modifications donne : “Episode Object” ou “Movie Object”. Pour éviter ça, zavez dû remarquer que j’ai mis la fonction __str__ dans mes modèles ce qui vous rendra une valeur plus “lisible” sur ce qui a été modifié.

Conclusion :

Dans la vraie vie de votre serviteur, ne se voyait pas créer un modele “history” lié “physiquement” par une FK à chaque modèle, entreprenait de chercher au travers de la toile quelques ressources.

C’est finallement sur #django-fr@freenode qu’il a posé la question et a obtenu de Gagaro le grââl : une application nommée tracking-fields, dont il est l’auteur.

Pour une fois qu’il fait sa faignasse en ne codant pas tout by himself, ça fait plaisir de tomber sur une appli pareille !

Si vous voulez jouer avec le code de ce gestionnaire de films c’est par ici la bonne soupe

Le piège d’écrire du code couplé à une implémentation 10

mercredi 3 février 2016 à 10:17

On a reproché à la communauté de Twisted que c’était un silo fermé. Une lib écrite pour Twisted ne marchait que pour Twisted.

Puis on a reproché à la communauté de gevent la même chose.

Et maintenant la communauté d’asyncio recommence à faire la même erreur.

Regardez, pleins de libs compatibles asyncio, c’est génial non ?

Je ne vais pas dire non. Ça boost l’utilisabilité, l’adoption, etc.

Mais c’est aussi un énorme travail qui passe à côté de toute l’expérience (bugs, cas extrêmes, best practices, perfs…) des communautés précédentes. Et qui a une date de péremption, qui sera foutu à la poubelle à la prochaine vague.

Pourquoi ?

Parce que toutes ces libs se concentrent sur l’implémentation.

Vous ne pouvez pas réutiliser le code d’une lib SMTP Twisted, car elle est liée au reactor. Pourtant cette lib contient des milliers d’infos utiles, la gestion de ce serveur bizarre, la correction de cette erreur de calcul de date, qui n’ont rien à voir avec Twisted.

C’est la même chose avec ces libs pour asyncio.

Que faire alors ?

Et bien d’abord écrire une lib neutre. Qui contient des choses comme :

Il faut écrire cette lib de manière à ce qu’elle puisse être réutilisée dans tout contexte. À base de callbacks simples, de hooks, de points d’entrées.

Puis, vous rajoutez dans un sous-module, le support pour votre plateforme favorite. Un adaptateur qui utilise ces hooks pour Twisted, ou asyncio, ou gevent.

Cela a de multiples bénéfices:

Toutes les plateformes ont une manière ou un autre pour attaquer ce problème. Twisted par exemple a une classe protocole qui est indépendante du reactor. Oui, mais elle est dépendante de la manière de penser en Twisted. Personne ne documente ces protocoles de manière neutre. Personne n’utilise ces protocoles de manière neutre.

gevent utilise carrément le monkey patching pour essayer de se rendre transparent. Évidemment ça veut dire que c’est très dépendant de l’implémentation. Si CPython change, ça casse. Si on utilise une implémentation différente de Python, ça ne marche pas. Si on fait des expérimentations comme actuellement sur le JIT, les résultats sont imprévisibles.

async/await a l’énorme bénéfice de proposer une interface commune à tout travail asynchrone. Fut-ce de l’IO, du thread, du multi-processing, du sous-processing, du multi-interpretteur ou des callbacks ordinaires… Cela va donc énormément gommer ces problèmes de compatibilité, même si la séparation des responsabilités que je recommande n’est pas suivie. Mais pour le moment tout le monde n’implémente pas __await__. Et si __await__ lance le code sur l’autre plateforme, ça fait un truc en plus à gérer. Ce n’est pas tout à faire neutre.

Attention, je comprends très bien que cette séparation que je recommande ne soit pas suivie.

C’est très difficile de faire une API agnostique par rapport à la plateforme. Ça demande beaucoup plus de taf, de connaissance, etc. Je suis le premier à ne pas le faire pas fainéantise ou ignorance.

Mais il faut bien comprendre qu’à chaque fois, on réinvente la roue, une roue jetable par ailleurs.

Bien entendu, je dis ça pour l’async, mais c’est vrai pour tout.

Par exemple, des centaines de code ont leur propre moyen de définir un schéma et valider les données en entrée. Les ORM sont particulièrement coupable de cela, les libs de form aussi, mais on a tous codé ce genre de truc. C’est idiot, c’est un code qui n’a pas à être lié à une plateforme.

Des centaines de libs ont leur code de persistance lié à une plateforme. Même celles qui utilisent un ORM, au final, se lient à certaines bases de données (raison pour laquelle je suis GraphQL de très près).

La généricité a ses limites, et c’est toujours un compromis entre le coût immédiat, et le bénéfice futur. Si on fait tout générique, on se retrouve avec un truc qui évolue à 2 à l’heure et qui a 15 surcouches pour faire un print. On se retrouve avec Zope. Dont personne, et c’est ironique, ne réutilise les composants parce que c’est devenu trop compliqué de les comprendre.

Car évidemment, qui dit découplage, dit doc bien faite, qui explique clairement comment bénéficier de ce découplage. Mais dit aussi que le code utilisant les adapteurs doit être aussi simple que si on avait un fort couplage, ce qui est dur à faire.

Et on tombe ici sur un autre problème : la compétence pour faire ce genre de code. Si il faut 10 ans d’expérience pour faire une lib propre, alors on va réduire considérablement le nombre de personnes qui vont oser coder des libs.

Aussi cet article n’est en aucun cas un savon que je souhaite passer aux auteurs. Merci de coder ces libs. Merci de donner de votre temps.

Non, cet article est juste là pour dire : dans la mesure du possible, il est très bénéfique sur le long terme de se découpler de la plateforme.

Le don du mois : nuitka 16

lundi 1 février 2016 à 17:37

Comme je vous l’ai dit dernièrement, le packaging et les performances sont deux points qui méritent d’être améliorés en Python.

Nuitka est un projet qui vise à tacler ces deux problèmes d’un coup en compilant le code Python pour obtenir un exécutable autonome sous forme de code machine.

Plus besoin de s’inquiéter d’avoir la bonne version de Python installée. Plus besoin de se soucier d’avoir Python, ou une lib quelconque installée. Et en prime, le compilateur implémente le plus d’optimisations qu’il peut.

Nuitka est un projet en cours et son équipe est petite. Les progrès sont sérieux, mais lents. Histoire d’encourager tout ça, je fais un don de 50€ à l’auteur.

Pour donner à votre tour, c’est par ici.