PROJET AUTOBLOG


Sam et Max

source: Sam et Max

⇐ retour index

Guido van Rossum s’offre “des vacances permanentes” 2

jeudi 12 juillet 2018 à 22:56

Le BDFL (Dictateur Bénévole À Vie) et créateur du langage Python vient d’annoncer qu’il laissait son bébé à la communauté.

Sans quitter son rôle de core dev, et en suggérant qu’il passe plus de temps en tant que mentor de ses collègues sur le projet, il abandonne néanmoins son pouvoir de décision absolu sur le langage et son rôle de validateur ultime de chaque introduction de nouvelles fonctionnalités.

Dans son email, il ne passe pas le pouvoir à qui que ce soit ni n’invite à un mode de direction particulier. Il suggère que l’équipe actuelle des core devs, les développeurs ayant les droits de commit sur le repository du langage, choisisse le format qui leur convienne le mieux pour diriger à présent le projet.

Un moment important dans la vie de Python, mais aussi un événement qui demande beaucoup de remise en contexte.

Python-idea, Python-dev et les PEP

Durant les dernières années, les décisions de modification du langage passaient généralement par plusieurs phases.

D’abord, la proposition faite sur la mailing list python-idea. Cette liste est publique. Tout le monde peut y participer et y donner son avis.

Après débat, si le climat n’est pas hostile (ce qui est une notion TRES subjective), un des core devs suggère généralement à l’auteur de l’idée d’écrire un PEP.

Le PEP (Proposition d’Amélioration de Python) est l’équivalent d’une RFC pour Python. C’est un document formalisant une proposition d’amélioration du langage et de son écosystème. Le plus connu est bien entendu le PEP 8.

S’ensuit encore plus de débats, et des modifications itératives du PEP, jusqu’à, soit l’abandon du projet par l’auteur, soit l’oubli, soit le verdict de Guido.

Ce dernier est définitif: le PEP est approuvé ou rejeté. Dans le premier cas, il sera appliqué. Dans le second, il part à la poubelle.

Quelque part à la fin de ce processus, les implémenteurs du PEP discutent en plus petit commité, sur la liste python-dev, de la réalisation.

Guido a toujours, jusqu’ici, été donc la dernière étape de filtre de cet enchaînement. A la foi gardien de sa philosophie du langage, et goulot d’étranglement.

Il y a eu quelques améliorations, comme la nomination ponctuelle de BDFL délégués pour certains sujets, mais le format reste quand même globalement le même.

Pride and prejudice

Ce système a l’avantage d’être très ouvert. Tout le monde peut participer. Peu de personnes le font pourtant, par rapport à la masse d’utilisateurs de Python, mais c’est déjà beaucoup si on considère l’inertie que ça implique.

Le mail est un format très accessible, décentralisé, ouvert et standard. Son austérité évite l’effet réseau social qu’on voit trop souvent sur les outils plus modernes.

En contrepartie, le processus est très flou. Il n’y a pas de vote a proprement parler. Les threads s’enchaînent un peu n’importe comment. Les nouveaux venus ne sachant pas comment utiliser l’outil le font imparfaitement. Et le media ne permet pas facilement de suivre la continuité à court terme et à long terme de grand-chose, ni en termes de flux, ni en termes de référence, ni en termes de recherche.

Mais c’est aussi l’aspect social qui est problématique. Il n’y a aucune organisation claire dans la liste, et tout le monde semble “au même niveau”. Or ce n’est pas absolument pas le cas. La réputation et les participations passées, ainsi que les caractères des participants et leurs relations jouent énormément sur le processus. Le timing également.

Ça parait équitable sur le papier, mais c’est une illusion, et en prime cela implique des frictions qui pourraient être évitées. Par exemple quand un étudiant demande à Larry Hastings de l’aider à faire ses devoirs ou qu’un auteur de PEP cite le Zen of Python à Tim Peters. Inversement, quand vous proposez une idée, et que tout le monde vous envoie péter, mais que 6 mois plus tard, Brett Cannon dit que c’est intéressant et que tout le monde cri au génie, ça fout les boules.

Ajouté à cela, la qualité du débat qui peut varier du tout au tout : du caprice a la revue technique, en passant par le mail à côté de la plaque, la crise d’égo, l’appel au calme, le récap qui aide tout le monde, les idées brillantes, les suggestions étonnantes, les redites, et le spam.

Et évidement, la réponse par défaut est toujours non.

Se faire entendre là dedans est très difficile. Garder son calme l’est souvent aussi. Mais surtout, persévérer à travers le processus durant le temps nécessaire pour l’aboutissement de celui-ci est une épreuve épuisante, irritante, et ingrate. Surtout quand 9 fois sur 10, elle se finit par un rejet.

Un rejet cependant, n’est pas la mise à mort d’une idée. L’année suivante on peut relancer le débat, et ainsi de suite, comme un politicien à l’assemblée qui veut faire passer son projet de loi. Et comme au Parlement, la même idée présentée par une nouvelle personne, ou avec un autre timing peut avoir un résultat radicalement différent.

Un anus pour les rassembler tôt

Guido, bien qu’au sommet de cette pyramide, n’est pas isolé de tout cela, bien au contraire. Il est une des rares personnes qui doit trancher sur presque tout, et donc se coltiner une bonne partie des débats. Et des réactions quand il rejette l’idée d’autrui.

À ceci se rajoute sa présence sur les bugs trackers, et bien entendu son activité de développeur.

Un rôle difficile, fatigant, et un vrai sacerdoce pour quelqu’un qui pourrait dire “allez tous vous faire foutre, j’ai pondu ce langage utilisé par des millions de personnes tout seul, je sais mieux que vous” au lieu de prendre en considération l’avis du public.

Un rôle, je vous le rappelle, qu’il tient depuis plus de 20 ans.

Depuis la 3.4, on sent le poids commencer à peser sur ses épaules. A 62 ans, et toujours avec un travail à plein temps chez Dropbox (qui heureusement lui laisse le loisir de travailler sur Python), on peut imaginer l’énergie nécessaire pour s’impliquer encore autant.

Cela se ressent particulièrement dans ses mails et ses tickets. Une sorte de lassitude. Et une retenue qu’on devine de plus en plus difficile.

Honnêtement, je ne sais pas comment il a fait pour ne pas exploser sur quelqu’un. Dans certains échanges que j’ai eus avec lui sur la liste ou sur github, si nos rôles avaient été inversés, j’aurais sans aucun doute été bien moins diplomate.

Quitter maintenant, avant que la charge ne devienne trop pesante, est à mon sens un acte de grande classe.

Comme un comédien, un musicien, un metteur en scène ou un écrivain qui fait ses adieux au sommet de son art. J’aurais aimé que Lucas, Spielberg et Besson aient su en faire autant.

Mais pour bien comprendre à quel point cette décision de laisser sa place demande du courage, il faut réaliser une chose: il laisse son enfant dans les mains des autres. L’œuvre de sa vie. Une réalisation colossale qui plus est, qui a généré des centaines de milliers d’emplois, fait tourné des milliers de projets.

Moi, j’ai du mal à m’imaginer arrêter d’écrire ce misérable petit blog. Et pourtant j’y ai pensé souvent.

L’étincelle qui a fait déborder la moutarde, c’est bien entendu le débat sans fin sur l’expression d’assignation, et la critique vigoureuse de son acceptation. (C’est vrai que c’était chaud)

Guido en a eu ras le cul, quoi.

So what now ?

Personnellement, j’accueille la nouvelle avec un mélange d’espoir et de mélancolie.

Python n’aurait jamais été ce qu’il est aujourd’hui sans la constante vigilance de son BDFL.

Dire “non” est la partie la plus importante pour sculpter une expérience. C’est ce qui fait la différence entre Gates et Jobs, Van dam et Lee, Hitler et… heu… non.

C’est ce qui fait qu’on n’a des lambda bridées et pas d’opérateur pour ajouter un élément dans une liste. Qu’on le veuille ou pas.

Et c’est ce qui fait qu’un langage né en fucking 1985 (avant Java !) tient toujours la route aujourd’hui. A gardé sa lisibilité. Sa facilité de prise en main. Et continue d’être utile.

C’est un travail de titan !

Néanmoins je ne me peux m’empêcher de noter que les dernières fonctionnalités majeures de Python ont été introduites de manière brouillonne. Je pense notamment au type hints et à asyncio, que Guido a bdfl -f sans donner le temps de mûrir leur design. Elles ont mis plusieurs versions à ne serait-ce qu’être utilisables, et méritent encore du travail.

Je pense aussi que Python est prêt pour des ajustements, et espère que passer le flambeau à l’équipe de core dev va apporter du renouveau au langage.

En effet, il est peu probable qu’un nouveau BDFL s’installe, et bien qu’il soit trop tôt pour faire une prédiction, la mise en place d’un quorum semble plus probable. Dans ce cas, il faudra sans doute un vecteur de vote. Et de là, qui sait, naîtra peut-être un complément ou replacement à python-idea.

Je l’espère en tout cas.

Sortir de l’usage de la mailing list pour un outil plus encadré, mettant les propositions en avant, avec un système de sondage, des procédures plus claires, des références plus faciles au passé, et une distinction explicite du statut des personnes qui s’expriment.

Bref, faciliter l’accès au processus, tout en permettant une gestion plus saine. On ne voudrait pas que Victor ou Yuri nous posent leur dem dans les 6 mois pour cause de pétage-de-clablite aigue.

Également, qui sait, peut-être reviendra-t-on – pour le meilleur ou pour le pire ? – sur des BanDFL tel que:

Difficile de croire que le débat ne sera pas relancé maintenant que Cerbère ne garde plus les Champs Élysées.

Évidemment, il est logique qu’il y ait un temps de flottement. D’abord par respect pour le père de Python. Ça ferait un peu GoT d’enchaîner quand même. Ensuite parce qu’il va falloir qu’un nouveau mode de fonctionnement émerge, et prouve son efficacité.

Quoi qu’il arrive

Merci à Guido Van Rossum pour ce cadeau immense.

On va essayer de ne pas trop l’abîmer.

L’expression d’assignation vient d’être acceptée 12   Recently updated !

mardi 3 juillet 2018 à 11:15

Après des mois de débats sur python-idea (mailing list sur laquelle, je vous le rappelle, tout le monde a le droit de participer), Guido a validé la PEP 572. (foo := bar) sera donc un code valide en Python 3.8.

Je n’avais pas vu de feature plus controversée depuis les f-strings. Et je gage que, comme les f-strings, après un temps d’adaptation, la communauté va se demander comment on a vécu sans auparavant.

Un peu d’histoire

Il existe deux grandes catégories d’instructions dans les langages de programmation. Les déclarations et les expressions.

La déclaration, en anglais “statement”, est une action indépendante formulée sur une ligne. C’est une unité syntaxique, et une déclaration ne peut être contenue dans une autre déclaration sur la même ligne.

Ex:

import os

est une déclaration. Un import est tout seul sur sa ligne et ne se combine pas avec d’autres instructions.

L’expression, elle, est une combinaison de littéraux, variables, d’opérateurs et d’appels de fonctions qui retournent un résultat. Les expressions peuvent contenir d’autres expressions, et elles peuvent être contenues dans une déclaration.

Ex:

1 + 1

est une expression. On peut, en effet, assigner le résultat de ce code à une variable, le passer à une fonction en paramètre ou le tester dans une condition.

Les langages, comme le COBOL, qui privilégient le style impératif utilisent majoritairement des déclarations. Les langages, comme LISP, qui privilégient le style fonctionnel, utilisent majoritairement des expressions.

Très souvent néanmoins, les langages populaires font largement usage des deux. L’expression est plus flexible et plus puissante. La déclaration force une opinion sur la structure du programme et évite les divergences de style. Selon la philosophie que l’on souhaite donner à son bébé, un créateur de langage va donc s’orienter plus vers l’une ou l’autre.

Python est multiparadigme et soutient le style impératif, fonctionnel et orienté objet. Il possède donc non seulement des expressions et des déclarations, mais également souvent les deux versions pour une même instruction.

Ex, les déclarations:

squares = []
for x in numbers:
    squares.append(x * x)
 
def mean(x):
    return x * x / 2
 
if is_ok:
    print(welcome)
else:
    print(error)

ont des expressions équivalentes:

[x * x for x in number]
 
lambda x: x * x / 2
 
print(welcome if is_ok else error)

Parfois Python choisit d’orienter le style du programmeur, non pas évitant de fournir des expressions, mais par le biais de la grammaire imposée. Ainsi il oblige à indenter, et n’autorise pas les lambdas à contenir de déclaration. C’est une autre stratégie pour imposer une philosophie au langage. Pour Python, la philosophie est que la capacité à s’exprimer doit rester riche, mais pas au détriment de la capacité à comprendre le code.

Une particularité de Python, c’est que l’assignation, c’est-à-dire le fait d’associer une valeur à une variable, est une déclaration. Ex:

a = 1

Comme les déclarations ne peuvent contenir d’autres déclarations, cette syntaxe interdit:

if a = 1:

Ce n’est pas le cas dans de nombreux langages populaires, comme le C, PHP, le JS… Exemple en Ruby:

if (value = Settings.get('test_setting'))
  perform_action(value)
end

Ici, non seulement on assigne le résultat du get() à la variable value, mais en plus, on teste le résultat du get() avec le if.

Actuellement ceci est impossible en Python, et le même code serait:

value = Settings.get('test_setting')
if value:
  perform_action(value)

Ce n’est pas une erreur, c’est un choix de design. En effet une source de bug très courante en programmation est de vouloir faire une comparaison, mais de taper une assignation.

Ainsi, quelqu’un voudrait faire:

while reponse == "oui":
    ...
    reponse = input('Voulez-vous continuer ?')

Mais ferait:

while reponse = "oui": # erreur subtile et difficile à voir
    ...
    reponse = input('Voulez-vous continuer ?')

Ce qui ne compare pas DU TOUT la variable. Et en plus change son contenu. Dans ce cas précis, le résultat est une boucle infinie, mais parfaitement valide et qui ne provoquera pas d’erreur.

Pour éviter ce genre de bug que même les programmeurs aguerris font un jour de fatigue, la syntaxe a tout simplement été interdite en stipulant que l’assignation était toujours une déclaration.

La PEP 572

L’absence de cette fonctionnalité a eu d’excellents bénéfices. Je le vois régulièrement dans mes salles de classe, ce bug. La SyntaxError qui résulte de cette faute en Python permet de l’attraper avant qu’il ne fasse le moindre dégât.

Car c’est tout le problème de cette erreur: si la syntaxe est valide, le bug est silencieux, le code tourne, il ne fait juste pas du tout ce qu’on lui demande. C’est la plus pernicieuse des situations, avec des conséquences qui peuvent ne se déclarer que bien plus loin dans le code et une séance de débuggage des plus irritantes.

A mon sens, c’était une excellente décision de design.

Pourtant la PEP 572 revient dessus, en proposant, comme pour lambda, la boucle for ou la condition, un équivalent sous forme d’expression.

Pourquoi ?

Et bien Python doit aussi satisfaire les programmeurs chevronnés, qui en ont marre de se retrouver avec des situations comme :

match1 = pattern1.match(data)
if match1:
    print(match1.group(1))
else:
    match2 = pattern2.match(data)
    if match2:
        print(match2.group(2))

Certes, ce code et clair et facile à comprendre, mais il est très verbeux. On doit faire avec une indentation artificiellement induite par la limite de la syntaxe, et non par la logique du raisonnement qui est ici parfaitement linéaire.

Comment donc réconcilier le désir d’éviter le fameux bug tout en satisfaisant les besoins d’expressivité, légitimes une fois qu’on quitte le nid des débutants ?

La solution proposée est triple :

Le nouvel opérateur choisi est := et il ne peut exister qu’entre parenthèses. Il peut être utilisé là où un simple = ne serait pas autorisé. Mais, il ne peut PAS être utilisé là où on peut utiliser =. Le but est de ne jamais mettre ces deux opérateurs en concurrence: une situation permet l’un ou l’autre, jamais les deux.

= ne change pas. La seule différence, c’est qu’à partir de Python 3.8, vous aurez le droit de faire:

if (match1 := pattern1.match(data)):
    print(match1.group(1))
elif (match2 := pattern2.match(data)):
    print(match2.group(2))

L’opérateur := permet donc bien, dans des situations très précises, d’obtenir un code plus cours et élégant, sans introduire pourtant d’ambiguïté et donc de bug potentiel. Il autorise une nouvelle forme d’expressivité, mais sa syntaxe est très marquée: impossible de le confondre avec son frère, et les parenthèses l’isolent du reste du code.

On ne se pose pas non plus la question de quand choisir = et := puisque:

a = 1

est valide, mais pas:

a := 1

Bien que:

(a := 1)

soit valide, personne n’aura envie d’utiliser vainement cette forme plus lourde.

L’usage de := est donc marginal, et cantonné à des cas particuliers.

Une opinion, c’est comme un trou du cul…

Personnellement j’étais mitigé sur l’idée. De plus, j’aurais préféré l’usage du mot clé as, puisqu’on l’utilisait déjà dans les imports, les context managers et la gestion des exceptions. Néanmoins le nouvel opérateur permet de supporter les types hints sans aucun aménagement :

(carottes : Union[Legume, SexToy] = buy())

Chaque ajout d’expression rajoute également la possibilité d’abus. Si vous avez déjà vu ces horreurs à base de lambdas imbriquées ou d’intensions sans fin, vous savez de quoi je parle.

Avec :=, on peut vraiment se lancer dans du grand n’importe quoi:

Pour reprendre l’exemple de la PEP:

while True:
    old = total
    total += term
    if old == total:
        return total
    term *= mx2 / (i*(i+1))
    i += 2

est très clair.

En revanche:

while total != (total := total + term):
    term *= mx2 / (i*(i+1))
    i += 2
return total

Est une abomination qu’il faut purger par le feu.

Mais par expérience, j’ai rarement vu en 15 ans de Python beaucoup d’abus de ses fonctionnalités avancées. Les bénéfices ont toujours dépassé le coût d’une large marge. Pourtant entre les décorateurs, les dunder methods et les meta classes, il y a matière à messe noire.

Par ailleurs j’avoue que je suis ravi de pouvoir enfin faire:

while (bytes := io.get(x)):

Et:

[bar(x) for z in stuff if (x := foo(z))]

La PEP mentionne aussi un exemple que je n’avais pas prévu, et qui souffle le chaud et le froid:

diff = x - x_base
if diff:
    g = gcd(diff, n)
    if g > 1:
        return g

Peut devenir:

if (diff := x - x_base) and (g := gcd(diff, n)) > 1:
    return g

Comme l’auteur, j’approuve le fait que la première version est inutilement verbeuse, mais contrairement à lui, je trouve que la seconde est bien trop complexe pour être scannée d’un coup d’œil.

En revanche:

diff = x - x_base
if diff and (g := gcd(diff, n)) > 1:
    return g

est tout à fait à mon goût.

Ceci démontre bien qu’il va falloir un temps d’adaptation avant que la communauté trouve l’équilibre entre Perl et BASIC. Quoi qu’il en soit, on n’aura pas à s’en soucier avant l’année prochaine, et même d’ici là, peu de code pourra en faire usage avant que la 3.8 soit largement installée.

De mon côté je m’attends à ce qu’on ignore majoritairement cette fonctionnalité, jusqu’au moment où elle apparaîtra dans un coin de l’esprit le temps d’un besoin ponctuel et local, pour être oubliée à nouveau jusqu’à l’occasion suivante. Comme Dieu Guido l’aura voulu. D’ailleurs, côté enseignement, je ne compte pas introduire l’opérateur dans mes cours, ou alors dans une section bonus, à moins qu’un participant ne pose la question.

Aller, vous pouvez râler en commentaire maintenant :)

Le don du mois: mobx 2   Recently updated !

lundi 2 juillet 2018 à 12:41

Si vous avez suivi un peu les différents dons du mois au fur et à mesure de la vie du blog, vous avez du vous rendre compte de 2 choses:

En fait le seul et unique projet JS supporté a été VueJS. C’est dire que la barre est haute, étant donné la qualité exceptionnelle de ce projet.

Donc quand je vous dis que j’ai donné 50 balles à Mobx, c’est que le projet déchire. Et il déchire malgré le fait qu’il soit codé en JS.

Mobx permet, en gros, de surveiller les modifications à une structure de données. Vous posez un marqueur sur la structure, et un sur les fonctions utilisant la structure, et c’est tout:

class TodoList {
    @observable todos = []; // dire à mobx de surveiller
}
 
...
 
@observer // dire à mobx de tenir à jour
class TodoListView extends Component {
    render() {
        return <ul>
            {this.props.todoList.todos.map(todo =>)}
        </ul>
    }

C’est là la brillance du système: malgré sa simplicité, mobx va récursivement réagir à toute modifications, même sur des données complexes imbriquées; Et calculer toutes les dépendances de chaque fonction pour ne les appeler qu’au meilleur moment.

C’est facile à utiliser, et étonnamment rapide à exécuter.

Le résultat ? Quand un client me force à utiliser ReactJS, je saute redux, et je met Mobx à la place. Ca donne presque l’impression d’utiliser Vue: le code de manipulation d’état est simple à comprendre, gentil sur le CPU et les mutations d’état restent courtes et élégantes. Le reste est toujours moche, mais ça on y peut rien.

Bref, mobx est ce qui rend react acceptable. Et tout ce qui peut apaiser la douleur du dev en front-end n’a pas de prix.

J’ai regardé le code source, et la popote interne est bien complexe. Mais à chaque fois que je veux l’utiliser je me dis que ça ne pourra pas être si simple… et si.

La page de don, c’est par là.

Python 3.7 sort de sa coquille 4   Recently updated !

jeudi 28 juin 2018 à 18:54

La 3.4 était la première version 3 à valoir le coup, et a donc été le déclencheur de la migration 2->3 qui trainant depuis si longtemps.

La 3.5(.3) a rendu asyncio utilisable, incluant async / await et corrigeant le bug abusé de get_event_loop().

La 3.6 est mon chouchou. Sa meilleure intégration de Pathlib et les f-strings en font un plaisir total à utiliser. En plus black ne tourne que dessus. Je suis autant que possible en 3.6, je l’ai même installé sur une vieille centos 7 chez un client.

Alors que vaut cette 3.7, et est-ce qu’il faut migrer ?

Et bien avec des améliorations de perfs partout et une syntaxe simplifiée pour les classes, c’est une belle release. La 3.6 est va être bien plus facile à avoir sous linux pendant un bon bout de temps et suffit amplement, donc je ne vais pas forcer le pas. Mais bon je l’ai quand même compilé par acquit de conscience.

Regardons ce qu’il y a au menu.

Les data classes

Clairement la feature phare de la release, les data classes sont une manière plus concise d’écrire des classes, s’inspirant de la bibliothèque attrs dont elles n’implémentent qu’un sous-ensemble.

Une très bonne nouvelle, car je n’installais jamais attrs: dépendre d’une lib juste pour ça m’embêtait et pour la sérialisation/validation j’utilise marshmallow.

Par exemple:

from dataclasses import dataclass
 
@dataclass
class Achat:
    produit: str
    prix: float
    quantite: int = 0

Ce qui va générer une classe toute ce qui a de plus normale, mais dont le __init__, __repr__ et __eq__ automatiquement. Vous pouvez bien entendu ajouter les méthodes que vous voulez, comme d’habitude.

Il ne reste plus qu’à faire:

>>> print(Achat("foo", 2))
Achat(produit='foo', prix=2, quantite=0)

Toute la magie est sélectivement désactivable, et une méthode __post_init__ est ajoutée à la classe qui fait exactement ce que vous pensez que ça fait.

En prime, on a aussi dataclasses.field qui permet de fournir une factory pour un paramètre (typiquement list, tuple, dict…).

Puis, comme un bonheur ne vient jamais seul:

>>> from dataclasses import asdict, astuple
>>> print(asdict(Achat("foo", 2)))
{'produit': 'foo', 'prix': 2, 'quantite': 0}
>>> print(astuple(Achat("foo", 2)))
('foo', 2, 0)

C’est récursif sur les dicts, lists, tuples et dataclasses \o/

Enfin, pour finir:

>>> from dataclasses import asdict, astuple
>>> a = Achat("foo", 2)
>>> b = replace(a, quantite=3, prix=1)
>>> print(a, id(a))
Achat(produit='foo', prix=2, quantite=0) 140275795603296
>>> print(b, id(b))
Achat(produit='foo', prix=1, quantite=3) 140275775561456

Ouai ça déchire.

asyncio++

Much love to asyncio dans cette version.

Déjà, un truc qui aurait dû être là dès le début, la nouvelle fonction asyncio.run(), qui masque le setup de l’event loop pour vous.

On passe de :

loop = asyncio.get_event_loop() loop.run_until_complete(coro)

à:

asyncio.run(coro)

Juste ça, ça fait vachement moins peur aux gens. Et en prime ça évite qu’ils commencent à chercher la merde avec un setup custom de loop.

asyncio.current_task() retourne la tache dans laquelle on est. D’ailleurs, un détail, mais on a maintenant l’équivalent de thread local, mais pour la coroutine en cours.

asyncio.get_running_loop() retourne la boucle courante, mais seulement si elle existe. Elle lève une exception plutôt que de créer une loop comme get_event_loop() si aucune loop n’est présente.

StreamWriter.wait_closed() permet d’attendre qu’un stream se ferme. Les gars de aiohttp doivent être contents.

Task.get_loop() retourne la boucle de la tache. Pour le multi-threading avec plusieurs loops, c’est cool.

loop.create_server() a maintenant un argument start_serving qui controle si on veut le lancer immédiatement. J’ai toujours du mal à croire que des dev qui sont capables de participer à la stdlib ont pu commiter un code qui instancie et enchaine sur un effet de bord. Heureusement c’est corrigé.

Les handlers retournés par loop.call_later() retournent leur ETA avec .when() et ont une méthode .cancelled().

TCP_NODELAY est utilisé par défaut sous Linux. Des perfs gratuites, merci. Sans surprise contribué par Victor Stinner ^^

Les intensions acceptent maintenant async/await.

Et enfin, les exceptions des taches annulées ne sont plus loggées. Parce que forcément quand on crashait tout, le log devenait un peu chargé…

Bon, asyncio était déjà très utilisable en 3.6, ne n’exagérons pas. L’important étant d’utiliser le mode debug, gather() et run_until_complete(), ce qui devrait être écrit en gros, en rouge dans la doc.

Mais toutes ces modifications sont bienvenues.

Ah, oui, les perfs ont été aussi améliorées… Mais c’est le cas partout.

Des perfs

Le focus sur les perfs de Python augmente doucement. La 3.6 avait amorcé la tendance, et ça se confirme. J’attends d’avoir des retours sur des mises en prod un peu serieuses pour savoir si ça a payé.

Le temps de démarrage de Python est d’ailleurs pas mal pointé du doigt. Certes, on est pas au niveau de l’outre sclérosée que constitue nodejs au réveil, mais c’est pas une référence. Donc, des choses sont mises en place. Notamment python -X importtime qui va afficher le temps que prend chaque import.

Des aménagements ont aussi été fait pour accélérer le module typing, maintenant que l’usage des type hints pour les annotations est entériné. Un side effect sympas est que les classes que vous allez écrire seront plus rapide à instancier, et les méthodes plus rapide à résoudre.

D’ailleurs, les type hints sont maintenant résolus paresseusement, à la fois pour améliorer la vitesse de chargement et pour faciliter l’auto-référencement.

breakpoint()

breakpoint() est techniquement un alias configurable à import pdb; pdb.set_trace(). Ça à l’air de rien, mais c’est super:

Ça vient bien entendu avec une variable d’environnement et d’un hook dans sys pour custo le comportement.

Quelques détails

Dicts ordonnés

La spec garanti que les clés vont garder leur ordre d’insertion. C’était déjà le cas en 3.6, la 3.7 rend juste la mesure officielle.

Ne jetez pas OrderedDict à la poubelle pour autant, car il préserve l’ordre des clés après suppression également.

async/await sont des mots clés

Et ne peuvent donc plus être écrasés par erreur.

Des soeurs pour __getitem__ et __getattr__

On va pouvoir définir un __getattr__ sur les modules (surtout utile pour le lazy loading) et un __class_getitem__ pour pouvoir faire MaClass[] sans utiliser de metaclass.

Traduction de la doc

Le processus pour avoir des docs dans d’autres langues est maintenant officiel. Pour l’instant le jap, le koréen et le kokorico

DeprecationWarning plus visible

La connerie de les cacher a été corrigée. Qui a pensé que c’était une bonne idée ? Mais seulement pour le script principal, ce qui va permettre aux des des libs de les voir sans faire chier les utilisateurs.

Debug mode

python -X dev va devenir votre nouvel ami, activant tout un tas de fonctions de debug coûteuses en production. Notamment plus de warning, asyncio debug mode, le faulhandler qui dump la stacktrace en cas de catastrophe, etc.

Des pyc reproductibles

Un même fichier donnera maintenant toujours un même .pyc. C’est pour les packagers et les amateurs de sécu.

Meilleur ImportError

L’exception va maintenant afficher le nom du module et son __file__ path si from ... import broute. Ça va rendre les imports circulaires, la plaie des gros projets Python, plus facile à debugger.

https://docs.python.org/3.7/whatsnew/3.7.html#importlib-resources

packaging

Introduction de importlib.resources, un remplacement pour le détestable pkg_resource qui va rendre sans regret de ma part mon article obsolète.

Autre ajout notable: README.rst est maintenant reconnu et ajouté automatiquement quand on fait son paquet cadeau. Puisque maintenant pypi accept le markdown, ça aurait été cool de le faire avec les .md également.

time est plus précis

Sensible à la nanoseconde. Perso je m’en bats les steaks mais je me suis dit que je ferai passer l’info.

unittest -k

Copieurs.

namedtuple supporte les valeurs par defaut

Rien à ajouter, si ce n’est qu’entre SimpleNamespace et les dataclasses, je crois qu’on a de quoi voir venir. Même si j’aimerais avoir un literal pour les namestuples sous la forme de (foo=1, bar=2) mais ça a été refusé.

Ajouts à Contexlib

Quelques outils en plus, dont un context manager qui ne fait rien (rigolez pas, c’est super utile !), et des contexts managers async.

lifting pour subprocess

Ok, plutôt bottox. C’est cosmétique, mais c’est bienvenu: des aménagements pour rendre les appels un poil plus courts, notamment dans le cas de la capture des stdx.



Et encore plein d’autres mini trucs.

C’est dispo en DL pour windows et mac. Pour linux, comme d’hab, soit on attend la mise à jour des depôts/ppa/etc, soit on compile à la main (étonnamment facile, si on se rappelle de faire make altinstall et pas make install), soit on utilise l’excellent on install pyenv et pyenv install 3.7.

Super article invité sur Trio que l’auteur a oublié de titrer   Recently updated !

jeudi 14 juin 2018 à 09:39

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

C’est bon vous avez cédé à la hype ?

Après un n-ème talk sur asyncio vous avez été convaincu que tout vos sites webs doivent être recordé dans cette techno ? Oui, surtout celui de la mairie de Gaudriole-sur-Gironde avec ses 50 visiteurs/jour, Django ça scalera pas et vous aurez sûrement besoin de websockets à l’avenir.

Et puis là pan ! En commençant à utiliser asyncio on se rend compte que ça va pas être aussi marrant que ce que vous a vendu l’enfoiré de hipster dans son talk avec son exemple de crawler web en 20 lignes :

Je ne parle même pas des soucis ceinture-noir-2ème-dan du genre high-water mark qui vous tomberons dessus une fois l’appli en prod.

Lourd est le parpaing de la réalité sur la tartelette aux fraises de nos illusions…

1 – Pourquoi c’est (de) la merde ?

Pour faire simple asyncio a été pensé à la base comme une tentative de standardisation de l’écosystème asynchrone Python où chaque framework (Twisted et Tornado principalement) était incompatible avec les autres et devait re-créer son écosystème de zéro.

C’était la bonne chose à faire à l’époque, ça a eu beaucoup de succès (Twisted et Tornado sont maintenant compatible asyncio), ça a donné une killer-feature pour faire taire les rageux au sujet de Python 3 et ça a créé une émulsion formidable concernant la programmation asynchrone en Python.
Mais dans le même temps ça a obligé cette nouvelle lib à hériter des choix historiques des anciennes libs : les callbacks.

Pour faire simple un framework asynchrone c’est deux choses :

Concernant le 2ème point, cela veut dire que si on a une fonction synchrone comme ceci :

def listen_and_answer(sock):
    print('start')
    data = sock.read()
    print('working with %s' % data)
    sock.write('ok')
    print('done')

Il faut trouver un moyen pour la découper en une série de morceaux de codes et d’IO.

Il y la façon « javascript », où on découpe à la main comme un compilo déroulerai une boucle :

def listen_and_answer(sock):
    print('start')
 
    def on_listen(data):
        print('working with %s' % data)
 
        def on_write(ret):
            print('done')
 
        sock.write('ok', on_write)
 
    sock.read(on_listen)

Et là j’ai fait la version simple sans chercher à gérer les exceptions et autres joyeusetés. Autant dire que quand un vieux dev Twisted vous dit le regard vide et la voix chevrotante qu’il a connu l’enfer, ne prenez pas ses déclarations à la légère.

Sinon la façon async/await si chère à asyncio :

async def listen_and_answer(sock):
    print('start')
    data = await sock.read()
    print('working with %s' % data)
    await sock.write('ok')
    print('done')

C’est clair, c’est propre, la gestion des exceptions est totalement naturelle, bref c’est du Python dans toute sa splendeur.
Sauf que non, tout ça n’est qu’un putain d’écran de fumée : pour être compatible avec Twisted&co sous le capot asyncio fonctionne avec des callbacks.

Vous vous souvenez de cette sensation de détresse mêlée d’hilarité devant une stacktrace d’un projet Javascript lambda d’où vous ne reconnaissez que la première ligne ? C’est ça les callbacks, et c’est ça que vous avez dans asyncio.

Concrètement le soucis vient du fait qu’une callback n’est rien d’autre qu’une fonction passée telle qu’elle sans aucune information quant à d’où elle vient. De fait impossible pour l’event loop asynchrone de reconstruire une callstack à partir de cela.
Heureusement async/await permettent à python de conserver ces informations de fonction appelante ce qui limite un peu le problème avec asyncio.
Toutefois en remontant suffisamment haut on finira toujours avec une callback quelque part, et vous savez qui a l’habitude de remonter aussi haut que nécessaire ? Les exceptions.

import asyncio
import random
 
async def succeed(client_writer):
    print('Lucky guy...')
    # Googlez "ayncio high water mark" pour comprender pourquoi c'est
    # une idée à la con de ne pas avoir cette methode asynchrone
    client_writer.write(b'Lucky guy...')
 
async def fail(client_writer):
    raise RuntimeError('Tough shit...')
 
async def handle_request_russian_roulette_style(client_reader, client_writer):
    handlers = (
        succeed,
        succeed,
        succeed,
        fail,
    )
    await handlers[random.randint(0, 3)](client_writer)
    client_writer.close()
 
async def start_server():
    server = await asyncio.start_server(
        handle_request_russian_roulette_style,
        host='localhost', port=8080)
    await server.wait_closed()
 
asyncio.get_event_loop().run_until_complete(start_server())

Maintenant si on lance tout ça et qu’on envoie des curl localhost:8080 on va finir avec:

$ python3 russian_roulette_server.py
Lucky guy...
Lucky guy...
Task exception was never retrieved
future: <Task finished coro=<handle_request_russian_roulette_style() done, defined at ex.py:11> exception=RuntimeError('Tough shit...',)>
Traceback (most recent call last):
  File "ex.py", line 18, in handle_request_russian_roulette_style
    await handlers[random.randint(0, 3)](client_writer)
  File "ex.py", line 9, in fail
    raise RuntimeError('Tough shit...')
RuntimeError: Tough shit...
Lucky guy...
Task exception was never retrieved
future: <Task finished coro=<handle_request_russian_roulette_style() done, defined at ex.py:11> exception=RuntimeError('Tough shit...',)>
Traceback (most recent call last):
  File "ex.py", line 18, in handle_request_russian_roulette_style
    await handlers[random.randint(0, 3)](client_writer)
  File "ex.py", line 9, in fail
    raise RuntimeError('Tough shit...')
RuntimeError: Tough shit...

Le problème saute aux yeux: asyncio.start_server gère sa tambouille avec des callbacks et se retrouve bien embêté quand notre code remonte une exception. Du coup il fait au mieux en affichant la stacktrace et en faisant comme si de rien n’était. C’est peut-être le comportement qu’on attend d’un serveur web (encore que… si aviez configuré logging pour envoyer dans un fichier vous êtes bien baïzay) mais il existe des tonnes de usecases pour les quel ça pose problème (et de toute façon on n’a vu que la partie émergée de l’iceberg d’emmerdes qu’est la programmation asynchrone).

Bref, si vous voulez en savoir plus, allez lire ce post, d’ailleurs allez lire tous les posts du blog, ce mec est un génie.

2 – Trio, une façon de faire de l’asynchrone

Ce mec en question, c’est Nathaniel J. Smith et il a eu la très cool idée de créer sa propre lib asynchrone pour Python: Trio

L’objectif est simple: rendre la programmation asynchrone (presque) aussi simple que celle synchrone en s’appuyant sur les nouvelles fonctionnalités offerte par les dernières versions de Python ainsi qu’un paradigme de concurrence innovant. Cette phrase est digne d’un marketeux, vous avez le droit de me cracher à la gueule.

Concrètement ce que ça donne:

# pip install trio asks beautifulsoup4
import trio
import asks
import bs4
import re
 
 
# Asks est un grosso modo requests en asynchrone, vu qu'il supporte trio et curio
# (une autre lib asynchrone dans le même style), il faut donc lui dire lequel utiliser
asks.init('trio')
 
 
async def recursive_find(url, on_found, depth=0):
    # On fait notre requête HTTP en asynchrone
    rep = await asks.get(url)
    print(f'depth {depth}, try {url}...')
 
    # On retrouve le corps de l'article grace à beautiful soup
    soup = bs4.BeautifulSoup(rep.text, 'html.parser')
    body = soup.find('div', attrs={"id": 'mw-content-text'})
 
    # On cherche notre point Godwin
    if re.search(r'(?i)hitler|nazi|adolf', body.text):
        on_found(url, depth)
 
    else:
        async with trio.open_nursery() as nursery:
            # On retrouve tous les liens de l'article et relance le recherche
            # de manière récursive
            for tag in body.find_all('a'):
                if tag.has_attr('href') and tag.attrs['href'].startswith('/wiki/'):
                    child_link = 'https://en.wikipedia.org' + tag.attrs['href']
                    # On créé une nouvelle coroutine par lien à crawler
                    nursery.start_soon(recursive_find, child_link, limit, on_found, depth+1)
 
 
async def godwin_find(url):
    limit = trio.CapacityLimiter(10)
    results = []
 
    with trio.move_on_after(10) as cancel_scope:
        def on_found(found_url, depth):
            results.append((found_url, depth))
            cancel_scope.cancel()
 
        await recursive_find(url, limit, on_found)
 
    if results:
        found_url, depth = results[0]
        print(f'Found Godwin point in {found_url} (depth: {depth})')
    else:
        print('No point for this article')
 
 
trio.run(godwin_find, 'https://en.wikipedia.org/wiki/My_Little_Pony')

L’idée de ce code est, partant d’un article wikipedia, de crawler ses liens récursivement jusqu’à ce qu’on trouve un article contenant des mots clés.

Au niveau des trucs intéressants:

async with trio.open_nursery() as nursery:
    for tag in body.find_all('a'):
        if tag.has_attr('href') and tag.attrs['href'].startswith('/wiki/'):
            child_link = 'https://en.wikipedia.org' + tag.attrs['href']
            nursery.start_soon(recursive_find, child_link, limit, on_found, depth+1)

En trio, une coroutine doit forcément être connectée à une nurserie. Cela permet deux choses:

Quel intérêt à borner la durée de vie des coroutines ? Si on avait voulu écrire un truc équivalent en asyncio on aurrait sans doute utilisé asyncio.gather:

coroutines = [recursive_find(link) for link in links]
await asyncio.gather(coroutines)

Maintenant on fait tourner ce code sur avec une connection internet un peu faiblarde (au hasard sur la box Orange de Max ces temps ci…) les ennuis aurait commencés dès qu’une requête http aurait timeout.
L’exception de timeout aurait été récupérée par asyncio.gather qui l’aurait relancer sans pour autant fermer les autres coroutines qui aurait continué à crawler wikipedia en créant des centaines de coroutines (oui recursive_find est un peu bourrin).
De fait si on se place dans le cas d’un code tournant longtemps (typiquement on a un serveur web qui a lancé notre code dans le cadre du traitement d’une requête entrante) on va avoir bien du mal à retrouver l’état ayant mené à ce bordel.

Du coup en trio la seule solution pour avoir une coroutine qui survie à son parent c’est de lui passer une nursery en paramètre:

async def work(sleep_time, nursery):
    await trio.sleep(sleep_time)
    print('work done !')
    # Je vous ai dit qu'une nurserie contient automatiquement un cancel scope ?
    nursery.cancel_scope.cancel()
 
async def work_generator(nursery):
    print('bootstrapping...')
    await trio.sleep(1)
    for sleep_time in range(10):
        nursery.start_soon(nursery, sleep_time, nursery)
 
async def stop_a_first_work_done():
    async with trio.open_nursery() as nursery:
        await work_generator(nursery)
        print('Waiting for a work to finish...')

Un autre truc cool:

with trio.move_on_after(10) as cancel_scope:
    def on_found(found_url, depth):
        results.append((found_url, depth))
        cancel_scope.cancel()
 
    await recursive_find(url, limit, on_found)

Vu qu’en trio on se retrouve avec un arbre de coroutines, il est très facile d’appliquer des conditions sur un sous-ensemble de l’arbre. C’est le rôle des cancel scope.
Comme pour les nursery, les cancel scope sont des contexts managers (mais synchrone ceux-ci). On peut les configurer avec un timeout, une deadline, ou bien tout simplement les annuler manuellement via cancel_scope.cancel().
D’ailleurs en parlant de nursery, celles-ci contiennent elle-même un cancel scope. C’est bien pratique pour

Dans notre exemple, on défini un scope dont on sortira obligatoirement au bout de 10s. Pour éviter d’attendre pour rien, on annule le scope explicitement dans la closure appeler quand un résultat est trouvée.
Vu que les nurseries définies à chaque appel de recursive_find se trouvent englobées par notre cancel scope, elle seront automatiquement détruites (et toutes les coroutines qu’elles gère avec).

Pour faire la même chose avec asyncio bonne chance:

En plus comme en parlait un mec (décidemment !), la gestion du timeout dans une socket tcp foireuse, il suffit de recevoir un paquet (et une requête entière peut contenir beaucoup de paquets !) pour que le timeout soit remis à zéro. Donc encore une fois pas de garanties fortes quant à quand le code s’arrêtera.

Eeeeet c’est tout !

Au final la doc de l’api de trio pourrait tenir sur l’étiquette de mon slip: pas de promise, de futurs, de tasks, de pattern Protocol/Transport legacy. On se retrouve juste avec la sainte trinité (j’imagine que c’est de là que vient le nom) async/await, nursery, cancel scope.

Et évidemment maintenant, l’enfoiré de hipster qui vous vend une techno à coup de whao effect avec un crawler asynchrone de 20 lignes c’est moi…

Remarquez si vous préférez la version longue je vous conseil cet excellent article de Nathaniel (je vous ai dit que ce mec était un géni ?).

3 – L’écosystème

C’est là où on se rend compte que asyncio est malgré ses lacune une super idée: il a suffit d’écrire une implémentation de l’event loop asyncio en trio pour pouvoir utiliser tout l’écosystème asyncio (ce qui inclus donc Twisted et Tornado, snif c’est beau !).

Allez pour le plasir un exemple d’utilisation de asyncpg depuis trio:

import trio_asyncio
import asyncpg
 
 
class TrioConnProxy:
    # Le décorateur permet de marquer la frontière entre trio et asyncio
    @trio_asyncio.trio2aio
    async def init(self, url):
        # Ici on est donc dans asyncio
        self.conn = await asyncpg.connect(url)
 
    @trio_asyncio.trio2aio
    async def execute(self, *args):
        return await self.conn.execute(*args)
 
    @trio_asyncio.trio2aio
    async def fetch(self, *args):
        return await self.conn.fetch(*args)
 
 
async def main():
    # Ici on est dans trio, c'est la fête
 
    conn = TrioConnProxy()
    await conn.init('postgresql:///')
 
    await conn.execute('CREATE TABLE IF NOT EXISTS users(name text primary key)')
 
    for name in ('Riri', 'Fifi', 'Loulou'):
        await conn.execute('INSERT INTO users(name) VALUES ($1)', name)
 
    users = await conn.fetch('SELECT * FROM users')
    print('users:', [user[0] for user in users])
 
 
# trio_asyncio s'occupe de configurer l'event loop par défaut de asyncio
# puis lance le <code>trio.run</code> classique
trio_asyncio.run(main)

En plus de ça trio vient avec son module pytest (avec gestion des fixtures asynchrones s’il vous plait) et Keneith Reitz a promis que la prochain version de requests supporterait async/await et trio nativement, elle est pas belle la vie !