await
ont été introduits en Python 3.5, tout le monde a trouvé l'idée formidable. D'ailleurs, ça a été intégré à JavaScript.
Malheureusement, introduire des mots clés dans un langage est une opération très délicate.]]>async
et await
ont été introduits en Python 3.5, tout le monde a trouvé l’idée formidable. D’ailleurs, ça a été intégré à JavaScript.
Malheureusement, introduire des mots clés dans un langage est une opération très délicate.
En Python les mots clés ont une caractéristique importante : on ne peut pas les utiliser pour quoi que ce soit d’autre.
Par exemple, class
est un mot clé, donc je ne peux pas créer une variable, un attribut, ou une fonction appelé class
. Ceci lève une erreur:
>>> class = 1
File "", line 1
class = 1
^
SyntaxError: invalid syntax
>>> class Foo: pass
...
>>> Foo.class = 1
File "", line 1
Foo.class = 1
^
SyntaxError: invalid syntax
>>>
Pour cette raison, quand on veut qu’une variable contienne une classe en Python, on la nomme cls
:
>>> class Bar:
... @classmethod
... def wololo(cls):
... print(cls, 'wololo')
...
>>>
>>> Bar.wololo()
wololo
>>>
C’est aussi pour cela que vous voyez parfois des variables nommées truc_
. Souvent from_
par exemple, parce que from
est un mot clé.
(pro tip: plutôt que from
et to
, utilisez src
et dest
)
Quand en Python 2 on a introduit True
et False
, un gros problème s’ensuivit: soit on en faisait des mots clés, et on pétait tout le code précédent qui utilisait ces mots, soit on en faisait des variables globales.
Le choix a été de garder la stabilité jusqu’à la prochaine version majeure, et c’est pour cela que:
True = False
en Python 2. Ouch.Pour la 3.5, on avait donc ce même problème, avec une cerise sur le gâteau: la lib standard utilisait elle-même la fonction asyncio.async
.
Le choix a donc de faire de async
/ await
des variables globales, et de les transformer en mot clé en 3.7.
En 3.6, un warning a été ajouté pour rappeler aux gens de migrer leur code.
C’est un sacré taf, et ça comporte des risques comme nous allons le voir plus loin. C’est pour cette raison que l’ajout d’un mot clé dans Python est une des choses les plus difficiles à faire passer sur la mailling list python-idea.
La 3.7 est sortie avec tout un tas de goodies. Youpi. Mais aussi avec le passage de async/await
de variables globales à mots clés, cassant la compatibilité ascendante. Quelque chose de rare en Python, et que personnellement j’aurais réservé pour Python 4, ne serait-ce que pour respecter semver.
Le résultat, tout un tas de systèmes ont pété: des linux en rolling release, des gens qui ont fait l’update de Python à la main, des gens qui maintiennent des libs compatibles 3.5 a 3.7…
D’autant que la 3.5 a asyncio.async
, mais 3.7 considère ça une erreur.
Petit exemple avec l’impact sur debian.
D’abord, il aurait fallu ne pas introduire asyncio à l’arrache. Dans mon “au revoir” à Guido, je disais que je trouvais que les dernières fonctionnalités majeures de Python avaient été mises en oeuvre de manière précipitée.
Cela se vérifie encore et encore avec asyncio, dont il faudra que je fasse un article pour dire tout ce qui a mal tourné.
Casser la compatibilité ascendante dans une version mineure n’est pas acceptable, même si les dégâts sont limités et qu’on y survivra très bien.
Le fait qu’asyncio soit une API marquée comme “provisional” n’a jamais empêché quelqu’un d’appeler ses variables async
. Après tout on utilise les threads depuis bien longtemps.
L’autre problème vient de l’amateurisme qui se glisse de plus en plus dans le dev.
C’est une bonne chose, parce que ça veut dire que la programmation est de plus en plus accessible et accueille de plus en plus de monde.
Mais cela veut dire aussi qu’une grosse part la population de programmeurs est aujourd’hui constituée de personnes qui n’ont ni les connaissances, compétences ou ressources pour faire les choses correctement.
On le voit particulièrement dans le monde JavaScript, ou c’est l’explosion (là encore, ça mérite un nouvel article). Mais l’exemple de la 3.7 nous montre que la communauté Python n’est pas immunisée, et je pense que le problème va s’amplifier.
Que veux-je dire par là ?
Et bien il y a 30 ans, cela ne serait pas venu à l’esprit de la plupart des devs de compiler quelques choses sans mettre les flags en mode parano pour voir ce qui allait péter. Après tout, quand on code en C, on sait que tout peut imploser à tout moment, alors la prudence est une question de culture.
Aujourd’hui par contre, la majorité des devs des langages haut niveau écrivent du code, font quelques tests à la main, et publient ça. D’autres les utilisent. Font des mises à jour en masse. Aucun ne prennent le temps ne serait-ce que d’activer les warnings les plus basiques.
Comme tout est facile à première vue, et c’est quelque chose dont on fait la promotion pédagogiquement parlant, car ça incite les gens à se lancer, on oublie la complexité inhérente à la programmation.
Mais il y a une différence colossale entre avoir un code qui marche une fois sur sa machine, et un code prêt pour la production.
Par exemple en Python, vous pouvez demander l’activation des warning pour chaque appel avec:
python -Wd
En 3.6, ça implique ceci:
>>> def async():
... pass
...
:1: DeprecationWarning: 'async' and 'await' will become reserved keywords in Python 3.7
L’info a toujours été là. Prête à être utilisée.
Mais alors pourquoi ne pas afficher tous les warnings, tout le temps ?
Et bien si je le fais:
python -Wa
Voilà ce que ça donne quand je lance juste le shell de python 3.6:
Vous comprenez donc bien que ce n’est PAS activé par défaut. En fait, originalement le message était dans le corps de l’article, mais j’ai du le mettre sur 0bin parce que ça faisait planter WordPress. Si.
A chaque upgrade, il est important de vérifier les warnings pour préparer ses migrations futures.
Oui, c’est du boulot.
En fait…
Même si on arrive maintenant à extraire une frame vidéo en gif en une ligne de commande.
Surtout maintenant qu’on y arrive en fait, car on multiplie les agencements hétérogènes de boites noires pour créer nos merveilleux programmes qui font le café.
Alors on prend des raccourcis.
Et puis aussi, parce qu’on ne sait pas. Qui parmi les lecteurs du blog, pourtant du coup appartenant à la toute petite bulle des gens très intéressés par la technique, connaissaient le rôle des warnings et comment les activer ?
Mais ce n’est pas le seul problème. Il y a clairement une question d’attentes et de moyen.
L’utilisateur (ou le client) final veut toujours plus, pour moins cher, et plus vite !
Et le programmeur veut se faire chier le moins possible.
Comme la complexité des empilements d’abstractions augmente, cela conduit à ignorer ce sur quoi on se base pour créer ce qui doit combler notre satisfaction immédiate.
J’ai parlé d’amateurs plus haut.
Mais je ne parle pas simplement de mes élèves. De mes lecteurs.
Je parle aussi de moi.
Prenez 0bin par exemple.
Il n’est plus à jour. Il n’a pas de tests unitaires. Il a des bugs ouverts depuis
Ce n’est pas pro du tout.
Sauf que je ne suis pas payé pour m’en occuper, et c’est bien une partie du problème: nous sommes de nombreux bénévoles à faire tourner la machine a produire du logiciel aujourd’hui. Donc si je n’ai pas envie, fuck it !
Vous imaginez si l’industrie du bâtiment ou celle de l’automobile tournaient sur les mêmes principes ?
La moitié des dessins industriels faits par des bloggers, des étudiants, des retraités, des profs de lycées, des géographes, de biologistes et des postes administratifs ?
Des immeubles et des voitures dont des pièces sont fabriquées par des potes qui chattent sur IRC et s’en occupent quand ils ont le temps ? Gratuitement. Y compris le service après-vente.
Alors que les usagers veulent toujours plus: des normes sismiques et de la conduite autonome. Tout le monde le fait, alors la maison de campagne et la fiat punto, c’est mort, personne ne l’utilisera.
Difficile de maintenir la qualité à cette échelle.
Il y a tellement de demandes de dev, jamais assez d’offres, de ressources toujours limitées.
Et ça grossit. Ça grossit !
Ceci dit, à l’échelle de la PSF, ça aurait dû être évité.
Avant d’aborder les aides techniques, il serait bon d’arrêter les conneries. Je me répète, mais c’était une vaste dauberie de faire passer async/await
en mot clé avant Python 4.
J’ai parfaitement conscience du besoin de faire progresser un langage pour ne pas rester coincé dans le passé. Je suis pour async/await
, très bonne idée, superbe ajout. Mettre un warning ? Parfait ! Mais on respecte semver s’il vous plait. Si vous avez envie de faciliter la transition, mettre un import __future__
, et inciter les linters à faire leur taff.
En attendant, pour la suite, Python va faciliter le debuggage.
Par exemple, depuis la 3.7, les DeprecationWarning
sont activés par défaut au moins dans le module __main__
. Donc un développeur verra ses conneries bien plus rapidement.
E.G:
Imp est déprécié en 3.6, mais sans -Wd, on ne le voit pas:
$ python3.6
Python 3.6.5 (default, May 3 2018, 10:08:28)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import imp
En 3.7, plein de modules importent imp
, mais les DeprecationWarning
ne sont pas montrés, car ça arrive dans des codes importés. En revanche, si dans le module principal, vous importez imp
:
$ python3.7
Python 3.7.0+ (default, Jun 28 2018, 14:08:14)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import imp
__main__:1: DeprecationWarning: the imp module is deprecated in favour of importlib; see the module's documentation for alternative uses
Ça donne une info importante, sans foutre un mur de warnings à chaque lancement.
Une autre aide est l’apparition, toujours en 3.7, du mode développement de Python avec -X dev
qui active tout un tas de comportements aidant au développement:
-Wd
PyMem_SetupDebugHooks
faulthandler
sys.flags.dev_mode
sur True
Évidemment, tout ça ne sert pas à grand-chose si on ne sait pas ce qu’il faut en faire. Et ça demande du temps et du travail, ce que l’amateurisme ne permet pas forcément.
Enfin je dis ça. La plupart des employeurs s’attendent à tout, tout de suite également. Donc au final, n’est-ce pas la culture générale de notre industrie qui est en train de virer dangereusement vers le vite fait mal fait ?
Même si il y a clairement une question de compétence (un prof de maths est généralement compétent en maths, alors que j’attends toujours de rencontrer un prof d’info qui est capable de mettre quelque chose en prod), la pression du marché a créé des attentes impossibles…
L’informatique n’existe comme secteur économique que depuis quelques décennies, contre des siècles pour la plupart des autres disciplines scientifiques. Pourtant on exige d’elle le même niveau de productivité. Il a bien fallut rogner quelque part, et c’est la fiabilité qu’on a choisit.
Quand il y 20 ans, on rigolait en comparant le debuggage de Windows a la réparation d’une voiture, et la punchline sur le redémarrage, ce n’était pas grave: un peu de virtuel dans un monde plein d’encyclopédies papier, de cabines ou bottins téléphoniques et autres cartes routières.
Aujourd’hui que notre monde entier dépend du fonctionnement de nos conneries codées à l’arrache, c’est plus emmerdant. Et ça explique aussi pourquoi le téléphone de ma grand mère fonctionne toujours mieux pour faire des appels que mon putain de smartphone a 600 euros. Mais je peux draguer une meuf par texto en faisant caca à l’aéroport. Tout a un prix.
]]>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 recodé 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 :
await
vous envoi à perpet’ dans l’event loopget_event_loop()
à la place. Et ça va merder sévère à un moment (typiquement quand vous ajouterez des tests non triviaux), et vous allez devoir corriger tout votre code.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…
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 complète à 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: 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: 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 lesquels ç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.
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 offertes 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, on_found, depth+1)
async def godwin_find(url):
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, 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, on_found, depth+1)
En trio, une coroutine doit forcément être connectée à une nurserie. Cela permet deux choses:
async with
, la nursery bloque tant que toutes les coroutines qu’elle gère n’ont pas terminé. Si une coroutine raise une exception, la nursery va pouvoir cancel les autres coroutines avant de re-raise l’exception en question (cf. le point précédent)Quel intérêt à borner la durée de vie des coroutines ? Si on avait voulu écrire un truc équivalent en asyncio on aurait sans doute utilisé asyncio.gather
:
coroutines = [recursive_find(link) for link in links]
await asyncio.gather(coroutines)
Maintenant on fait tourner ce code avec une connection internet un peu faiblarde (au hasard sur la box Orange de Sam ces temps ci…) les ennuis auraient commencé 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 relancé sans pour autant fermer les autres coroutines qui auraient 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 survit à 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(work, 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, 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()
.
Dans notre exemple, on définit un scope dont on sortira obligatoirement au bout de 10s. Pour éviter d’attendre pour rien, on annule le scope explicitement dans la closure appelée quand un résultat est trouvé.
Vu que les nurseries définies à chaque appel de recursive_find
se trouvent englobées par notre cancel scope, elles seront automatiquement détruites (et toutes les coroutines qu’elles gèrent avec).
Pour faire la même chose avec asyncio bonne chance:
time.monotonic()
en passant le temps restant autorisé aux coroutines filles. Bonjour la gueule du code.En plus comme en parlait un mec (décidemment !), la gestion du timeout dans une socket tcp est 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.
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énie ?).
C’est là où on se rend compte que asyncio est malgré ses lacunes 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 trio.run
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 !
]]>asyncio
, lui a fait écho 50 ans plus tard, ça a beaucoup discuté sur les mailing lists et les bugs trackers.]]>asyncio
, lui a fait écho 50 ans plus tard, ça a beaucoup discuté sur les mailing lists et les bugs trackers.
Nathaniel J. Smith, le dev susnommé, en a profité pour introduire une nouvelle primitive, actuellement surnommée la nursery, pour répondre au problème. Une idée visiblement tellement bonne que notre Yury préféré a décidé de la porter à asyncio
. La boucle d’événements est bouclée, si je puis dire.
Mais une autre chose intéressante en découle : on a mis en lumière la présence d’un goto dans asyncio, et qu’il y a de bonnes pratiques, validées par Guido himself, pour coder avec cette lib pour éviter les douleurs.
Le problème du goto
, c’est que l’instruction permet d’aller de n’importe où à n’importe où. Cela rend le flux du programme très dur à suivre. Pour éviter cela, on a catégorisé les usages clean du goto
: répéter une action, changer de comportement en fonction d’un test, sortir d’un algo en cas de problème, etc. Et on en a fait des primitives : les if
, les while
, les exceptions… Dans les langages les plus modernes, on a carrément viré le goto
pour éviter les abus et erreurs. Joie.
Dans asyncio
, le “goto” en question se trouve quand on veut lancer des tâches en arrière plan, comme ceci :
import asyncio as aio
loop = aio.get_event_loop()
aio.ensure_future(foo()) # GOTO !
aio.ensure_future(bar()) # GOTO !
loop.run_forever()
Le problème d’ensure_future()
est multiple:
Task
. Ca n’a rien à voir, mais je tenais à dire à quel point c’était con de l’avoir nommé ainsi (même si techniquement, Task
hérite de Future
).foo()
ou bar()
seront terminées à une zone précise du code. Tout au plus que tuer la boucle tue les taches. Leur flux d’exécution est complètement freestyle et décorrélé de tout le reste du programme, ainsi que de l’une de l’autres. Si ces coroutines font des await
, on peut basculer de n’importe où du programme vers elles et inversement à tout moment. goto
foo()
et bar()
dès que la boucle peut les lancer. Ici la boucle ne tourne pas encore. Plus le programme est complexe, plus il va devenir difficile de savoir à quelle étape logique les coroutines vont démarrer.En prime run_forever()
est un piège à con, car les exceptions qui arrivent dans la boucle sont logguées, mais ne font pas crasher le programme, ce qui rend le debuggage super rude, même avec debug mode activé (dont de toute façon personne ne soupçonne l’existence).
import asyncio as aio
loop = aio.get_event_loop()
loop.run_until_complete(aio.gather(foo(), bar())
En plus d’être plus court, les exceptions vont faire planter le programme, la loop s’arrêtera quand les coroutines auront fini leur taff, leur flux a un début et une fin encapsulés par le gather()
. Ceci est encore plus visible si on met le même code à l’intérieur d’une coroutine à l’intérieur d’une coroutine à l’intérieur d’une coroutine plutôt qu’à la racine du programme. En effet dans un exemple si simple, on se borne au démarrage et à l’arrêt de la boucle. Mais je suis paresseux.
Donc, c’est la bonne pratique, mais tout le monde ne le sait pas.
Pardon, correction.
Tous les devs Python ne connaissent pas asyncio
. Parmi ceux qui connaissent asyncio
, une petite partie comprend comme ça marche.
Dans ce lot rikiki, un pouillième sait que c’est la bonne pratique.
En fait, gather()
est probablement la fonction la plus importante d’asyncio
, et pourtant elle apparaît à peine dans la doc. C’est la malédiction d’asyncio
, une lib que tout le monde attendait pour propulser Python dans la league des langages avec frameworks modernes, mais qui commence à peine à devenir utilisable par le commun des mortel en 2018. Et encore.
Il ne faut jamais utiliser ensure_future()
à moins de vouloir attacher un callback à la main dessus, ce qui n’est probablement jamais ce que vous voulez à cette époque merveilleuse ou existe async/await
. ensure_future()
est un goto
, gather()
est un concept de plus haut niveau.
Mais deux problèmes demeurent…
Contrairement au goto
banni de Python, ensure_future()
est là, et va rester. Donc n’importe quel connard peut dans un code ailleurs vous niquer profond, et en tâche de fond.
ensure_future()
(ou son petit frère EventLoop.create_task()
) reste le seul moyen valable pour lancer une tâche, faire quelque chose, lancer une autre tâche, puis enfin faire un gather()
sur les deux tâches:
async def grrr():
task1 = aio.ensure_future(foo())
# faire un truc pendant que task1 tourne
task2 = aio.ensure_future(bar())
# faire un truc pendant que task1 et task2 tournent
# On s'assure que tout se rejoint à la fin:
await aio.gather(task1, task2)
Et puis, faire une pyramide de gather()
dans tout son code pour s’assurer que tout va bien de haut en bas, c’est facile à rater.
Une nursery agit comme un scope qui pose les limites du cycle de vie des tâches qui lui sont attachées. C’est un gather()
, sous stéroide, et avec une portée visuellement claire:
async def grrr():
async with trio.open_nursery() as nursery:
task1 = nursery.start_soon(foo)
# faire un truc pendant que task1 tourne
task2 = nursery.start_soon(bar)
# faire un truc pendant que task1 et task2 tournent
Les taches sont garanties, à la sortie du with
, de se terminer. Le ensure_future()
n’a pas d’équivalent en trio, et donc aucun moyen de lancer un truc dans le vent sans explicitement lui passer au moins une nursery à laquelle on souhaite l’attacher.
Résultat, on ne peut plus faire de goto
, et le flux du program est clair et explicite.
Notez que, tout comme if
et while
ne permettaient rien qu’un utilisateur soigneux de goto
ne pouvait faire, la nursery ne permet rien qu’un utilisateur soigneux de ensure_future()
ne peut faire. Mais ça force un ensemble de bonnes pratiques.
Évidemment, on peut ouvrir une nursery dans un bloc d’une autre nursery, ce qui permet d’imbriquer différentes portées, comme on le ferait avec un begin()
de transaction de base de données. Or, une exception à l’intérieur d’une nursery bubble naturellement comme toute exception Python, et stoppe toutes les tâches de la nursery encore en train de tourner. Alors qu’avec
En définitive, c’était la pièce manquante. La moitié du boulot avait était faite quand on a introduit un moyen de gérer des tâches asynchrones qui dépendent les unes des autres, en remplaçant les callbacks par un truc de haut niveau : async/await
. Il restait la gestion des tâches en parallèle qui se faisait encore selon les goûts et compétences de chacun, mais la nursery va remplir ce vide.
Cela devrait être intégré à asyncio
en Python 3.8, soit une bonne année et demie pour ceux qui ont la chance de pouvoir faire du bleeding edge.
Comme certains ne voudront pas attendre, je vous ai fait un POC qui vous montre comment ça pourrait marcher. Mais cette version ne sera jamais utilisée. En effet, elle intercepte ensure_future()
(en fait le create_task()
sous-jacent) pour attacher son résultat à la nursery en cours, évitant tout effet goto
, et ça péterait trop de code existant. Mon pognon est plutôt sur un gros warning émis par Python quand on fait une gotise.
Dernier mot: s’il vous plaît, allez voter pour change le nom de nursery. C’est beaucoup trop long à taper pour un truc qu’on va utiliser tout le temps.
]]>Toujours choper une event loop toute fraiche, et ouverte:
import asyncio
def aloop(*args, **kargs):
""" Ensure there is an opened event loop available and return it"""
loop = asyncio.get_event_loop()
if loop.is_closed():
policy = asyncio.get_event_loop_policy()
loop = policy.new_event_loop(*args, **kargs)
policy.set_event_loop(loop)
return loop
Lancer une coroutine dans une event loop jusqu’à ce qu’elle se termine:
import inspect
def arun(coro):
""" Run this in a event loop """
loop = aloop()
if not inspect.isawaitable(coro):
if not inspect.iscoroutinefunction(coro):
coro = asyncio.coroutine(coro)
coro = coro()
future = asyncio.ensure_future(coro)
return loop.run_until_complete(future)
Et ça s’utilise comme ça:
async def foo():
await asyncio.sleep(1)
print('bar')
arun(foo)
Ça me fait gagner pas mal de temps.
J’utilise aussi cette commande magique dans iPython qui permet de balancer des await
en plein milieu du shell. Je suis en train de voir pour ajouter un truc similaire à ptpython.
Je me suis fait aussi un petit script pour lancer vite fait n’importe quel script dans une boucle asyncio de manière transparente, mais j’ai pas le temps de poster ça aujourd’hui donc ça sera pour la prochaine fois.
]]>Par exemple, lire de manière asynchrone les données pipées sur stdin:
async def main(loop):
reader = asyncio.StreamReader()
def get_reader():
return asyncio.StreamReaderProtocol(reader)
await loop.connect_read_pipe(get_reader, sys.stdin)
while True:
line = await reader.readline()
if not line:
break
print(line)
loop = asyncio.get_event_loop()
loop.run_until_complete(main(loop))
C’est assez verbeux, pas très Pythonique (une factory qui instancie un classe a qui on passe une autre instance, c’est très java), ça n’exploite pas toute la force de async
/await
(le while
qui pourrait être un async for
), et il faut se soucier de lancer la boucle.
En prime, c’est pas encore évident de trouver qu’il faut faire ça dans la doc.
Pareil, pour lire de manière asynchrone les données écrites par un utilisateur sur stdin:
def on_stdin(*args):
print("Somebody wrote:", sys.stdin.readline())
loop = asyncio.get_event_loop()
loop.add_reader(sys.stdin.fileno(), on_stdin)
loop.run_forever()
Bon, là on a du callback, clairement on peut faire mieux.
La bonne nouvelle, c’est que ça veut dire qu’on a un champ entier où on peut être le premier à écrire une lib, et donc devenir une implémentation de référence, un outil connu, etc. Si vous avez envie de faire votre trou, c’est une opportunité, et en plus, c’est rigolo :)
En effet, avec un peu d’enrobage, on peut rapidement faire des trucs choupinets:
class AioStdinPipe:
def __init__(self, loop=None):
self.loop = loop or asyncio.get_event_loop()
self.reader = asyncio.StreamReader()
def get_reader(self):
return asyncio.StreamReaderProtocol(self.reader)
async def __aiter__(self):
await self.loop.connect_read_pipe(self.get_reader, sys.stdin)
return self
async def __anext__(self):
while True:
val = await self.reader.readline()
if val == b'':
raise StopAsyncIteration
return val
def run_in_loop(coro):
loop = asyncio.get_event_loop()
loop.run_until_complete(coro())
Et ainsi réduire le premier code à un truc très simple, très clair, très Python:
@run_in_loop
async def main():
async for line in AioStdinPipe():
print(line)
J’aime 2016, c’est une année pleine de possibilités.
]]>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.
]]>Si vous avez eu le plaisir de jouer avec asyncio
, vous avez du noter que unittest.mock
n’a aucun outil pour gérer gérer les coroutines.
En attendant que ce soit intégré à la stdlib, voici une petite recette :
import asyncio
from unittest.mock import Mock
# on utilise toute la machinerie du Mock original
class AMock(Mock):
def __call__(self, *args, **kwargs):
# la référence du parent doit se récupérer hors
# hors de la closure
parent = super(AMock, self)
# sauf qu'à l'appel on créé une fonction coroutine
@asyncio.coroutine
def coro():
# Qui fait le vrai Mock.__call__ (et donc popule l'historique
# des appels), mais seulement après l'évent loop l'ait éxécuté
return parent.__call__(*args, **kwargs)
# On appelle la fonction coroutine pour générer une coroutine
# (les coroutines marchent comme les générateurs)
return coro()
Je propose qu’en l’honneur de ce bidouillage, on l’appelle… mockoroutine !
Ca va s’utiliser comme ça:
mockorourine = AMock()
yield from mockorourine()
Après le yield from
, mockorourine.call_count == 1
, et mockorourine.assert_called_once_with()
passe.
Si vous êtes en 3.5+, on peut même faire:
class AMock(Mock):
def __call__(self, *args, **kwargs):
parent = super(AMock, self)
async def coro():
return parent.__call__(*args, **kwargs)
return coro()
def __await__(self):
# on delegue le await à la couroutine créée par __call__
return self().__await__()
Puis:
await AMock()
]]>Je vois beaucoup dans les tutos ici et là des gens qui opposent asyncio avec l’ancienne manière de faire de l’IO non bloquante : les threads.
C’est une erreur : les deux méthodes ne sont pas opposées, elles sont complémentaires.
En effet, asyncio ne peut être non bloquant que sur le réseau : ça ne gère pas l’IO sur les fichiers. Par ailleurs, toute opération CPU bloque également la boucle d’évènement.
C’est très bien de ne pas bloquer sur le réseau, mais encore faut-il pouvoir faire la requête réseau.
Si votre programme est bloqué à attendre une compression zip qui dure 3 secondes, pendant ce temps, les opérations réseaux déjà lancées tournent bien.
MAIS IL N’EN LANCERA PAS DE NOUVELLES.
Pendant 3 secondes, aucune nouvelle requête ne sera faite puisque le programme ne fait qu’une chose : ziper. Ca marche pour d’autres choses hein : copie de fichier, gros calcul matheux, traverser une grande liste, etc.
Donc si vous avez fini toutes vos requêtes à la seconde 1, pendant 2 secondes, votre programme n’est pas utilisé à son plein potentiel.
Les threads ne permettent pas d’avancer plus vite, mais ils permettent de faire 2 travaux en parallèle. Par exemple, de lancer pendant ces 2 secondes des requêtes supplémentaires sur le réseau.
On a vendu asyncio comme plus léger que les threads. Ce n’est pas tout à fait exact. asyncio a les avantages suivants :
Mais si on a quelques threads, le changement de contexte entre les threads est moins important que ce que coûte asyncio à faire tourner.
Pour certains travaux où le coût d’une opération réseau est faible, mais que cumulativement toutes les opérations ralentissent votre programme, un thread sera plus performant.
Par exemple, beaucoup d’opérations sur les bases de données tombent dans cette catégorie, à moins d’avoir des très longues requêtes.
Dans ce cas, avoir un thread dédié aux opérations de la base de données peut être une bonne décision.
Si vous avez des opérations sur des fichiers ou de grosses opérations CPU, les faire travailler dans un thread peut booster votre programme. Ca tombe bien il y a une lib pour ça.
Si vous avez beaucoup de petites opérations réseau, les grouper, dans un thread à part, peut booster votre programme.
Et asyncio pour le reste, par exemple des requêtes HTTPs, DNS, SMTP, FTP, SSH, etc.
On peut donc copieusement utiliser les deux en parallèle. La bonne nouvelle, c’est que Guido a designé asyncio pour ça:
import asyncio
import aiohttp
async def main(loop):
# ça c’est fait avec asyncio
await aiohttp.get('http://bidule.com')
# ça c’est fait dans un thread
await loop.run_in_executor(None, gros_calcul, params)
loop = asyncio.get_event_loop()
loop.run_until_complete(main(loop))
Notez bien :
ProcessPoolExecutor
ou un ThreadPoolExecutor
pour choisir la stratégie de parallélisme à adapter : process, thread, nombres de workers, etc.Bien entendu, chaque fois que vous ajoutez un mécanisme de concurrence, vous ajoutez de la complexité, donc ne le faites que si c’est nécessaire.
Commencez avec un programme synchrone simple. Si c’est trop lent, ajouter les threads ou l’asyncio, si ça ne suffit pas, utilisez les deux.
]]>La release est récente, mais fort heureusement on peut facilement l’installer. Sous Windows et Mac, il y a des builds tout chauds.
Pour linux, en attendant un repo tierce partie ou l’upgrade du système, on peut l’installer quelques commandes depuis les sources. Par exemple pour les distros basées sur Debian comme Ubuntu, ça ressemble à :
$ # dependances pour compiler python
$ sudo apt-get install build-essential libreadline-dev tk8.4-dev libsqlite3-dev libgdbm-dev libreadline6-dev liblzma-dev libbz2-dev libncurses5-dev libssl-dev python3-dev tk-dev
$ sudo apt-get build-dep python3 # juste pour etre sur :)
$ # téléchargement des sources
$ cd /tmp
$ wget https://www.python.org/ftp/python/3.5.0/Python-3.5.0.tar.xz
$ tar -xvf Python-3.5.0.tar.xz
$ cd Python-3.5.0
$ # et on build
$ ./configure
$ make
$ sudo make altinstall
# pas 'make install' qui écrase le python du système !
$ python3.5 # ahhhhhh
Python 3.5.0 (default, Sep 16 2015, 10:44:14)
[GCC 4.9.2] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
Sur les centos-likes, c’est grosso merdo la même chose, sans le build-dep (mais plutôt un truc genre sudo yum groupinstall 'Development Tools'
), et en remplaçant les -dev
par -devel
.
@
est maintenant le nouvel opérateur de produit matriciel, mais il ne fait officiellement rien.
Comprenez par là que Python implémente l’opérateur, mais pas le produit en lui-même, la feature ayant été spécialement incluse pour faire plaisir aux utilisateurs de libs scientifiques type numpy.
On va donc tester ça sur le terrain. On se fait un petit env temporaire avec pew et on s’installe numpy :
pew mktmpenv -p python3.5
pip install pip setuptools --upgrade
pip install numpy
# encore un peu de compilation
Testons mon bon. L’ancienne manière de faire :
>>> a = np.array([[1, 0], [0, 1]])
>>> b = np.array([[4, 1], [2, 2]])
>>> np.dot(a, b)
array([[4, 1],
[2, 2]])
Et la nouvelle :
>>> a @ b
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
in ()
----> 1 a @ b
TypeError: unsupported operand type(s) for @: 'numpy.ndarray' and 'numpy.ndarray'
Woops, apparemment numpy n’a pas encore implémenté le truc.
Bon. Bon, bon, bon. Comment on va tester alors… Ah, oui, y a une magic method :
class Array(np.ndarray):
def __matmul__(self, other):
return np.dot(self, other)
>>> a = a.view(Array)
>>> b = b.view(Array)
>>> a @ b
Array([[4, 1],
[2, 2]])
Bon, voilà ce que ça donnera quand les devs de numpy auront implémenté le bouzin (la dernière ligne hein, pas tout le bordel avant).
Apparemment ça fait bander les matheux, donc je suppose que c’est une super nouvelle.
En python 2, on pouvait faire "truc %s" % "bidule"
et u"truc %s" % u"bidule"
et b"truc %s" % u"bidule"
et ça a été viré en python 3 qui ne garde %
que pour str
et pas pour bytes
.
Ca n’aurait pas été un problème si ce n’est que Python est très utilisé pour le réseau, et que construire un paquet qui mélange de la sémantique binaire et textuelle devient soudainement une grosse soupe de decode()
et encode()
.
Jour 1, test 3, suspense…
>>> bytearray([66, 108, 117, 101, 32, 112, 114, 105, 101, 115, 116, 32, 115, 97, 121, 115, 58, 32, 37, 115]) % b"wololo"
bytearray(b'Blue priest says: wololo')
Voilà ça c’est fait !
os.path.walk()
est dans mon top 10 des APIs que je déteste le plus en Python, juste à côté de la gestion timezone. Avoir os.walk()
en Python 3 qui retourne un générateur me ravit. Avoir une version 10 X plus rapide avec scandir
, n’est-ce pas choupinet ?
>>> import os
>>> list(os.scandir('/tmp/'))
[,
,
,
,
,
]
C’est très dommage que ça ne retourne pas des objets Path
de pathlib
, mais bon, les perfs, tout ça…
Le saviez-vous ? Python peut exécuter un zip, ce qui permet de créer un script en plusieurs fichiers et de le partager comme un seul fichier. Non vous ne le saviez-vous-te-pas car personne n’en parle jamais.
La 3.5 vient avec un outil en ligne de commande pour faciliter la création de tels zip et une nouvelle extension (que l’installeur fera reconnaitre à Windows) pour cesdits fichiers : .pyz.
Je fais mon script :
foo
├── bar.py
├── __init__.py
└── __main__.py
__main__.py est obligatoire, c’est ce qui sera lancé quand on exécutera notre script. Dedans je mets import bar
et dans bar print('wololo again')
.
Ensuite je fusionne tout ça :
python -m zipapp foo
Et pouf, j’ai mon fichier foo.pyz :
$ python3.5 foo.pyz
wololo again
Attention aux imports dedans, ils sont assez chiants à gérer.
J’adore cette feature. J’adore toutes les features de la 3.5. Cette release est fantastique. Depuis la 3.3 chaque release est fantastique.
Mais bon, zeste de savoir l’a traité en long et en large donc rien à dire de plus, si ce n’est que j’avais raté un GROS truc :
Donc ces syntaxes sont valides :
>>> *range(2), *[1, 3], *'ea'
(0, 1, 1, 3, 'e', 'a')
>>> *[x * x for x in range(3)], *{"a": 1}.values()
(0, 1, 4, 1)
Ce qui peut être très chouette et aussi la porte ouverte à l’implémentation d’un sous-ensemble de Perl en Python. C’est selon l’abruti qui code.
Ce qu’il faut bien comprendre avec les types hints, c’est que Python ne s’en sert pas. Il n’en fait rien. Que dalle. Nada. Peau de balle. Zob. Niet. Zero. La bulle. Néant. Null. None. Réforme gouvernementale.
Les types hints sont disponibles, mais Python ne va pas les traiter différemment d’autres annotations. Le but est de permettre à des outils externes (linter, IDE, etc) de se baser sur ces informations pour ajouter des fonctionnalités.
Pour l’instant, un seul le fait : mypy.
Et là on sent bien que tout ça est tout neuf car si on fait pip install mypy-lang
, on tombe sur une version buggée. Il faut donc l’installer directement depuis le repo, soit :
pip install https://github.com/JukkaL/mypy/archive/master.zip
Puis écriture d’une fonction annotée avec des types hints :
from typing import Iterable, Tuple
PixelArray = Iterable[Tuple[int, int, int]]
def rgb2hex(pixels: PixelArray) -> list:
pattern = "#{0:02x}{1:02x}{2:02x}"
return [pattern.format(r, g, b) for r, g, b in pixels]
# ça marche :
rgb2hex([(1, 2, 3), (1, 2, 3)])
# ['#010203', '#010203']
La preuve que Python n’en fait rien :
>>> hex("fjdkls")
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
in ()
----> 1 hex("fjdkls")
TypeError: 'str' object cannot be interpreted as an integer
Même la doc profite peu du typage :
Help on function rgb2hex in module __main__: rgb2hex(pixels:typing.Iterable) -> list
Mais si on met ça dans un fichier foo.py :
from essai import rgb2hex
print(rgb2hex("fdjksl"))
res = rgb2hex([(1, 2, 3), (3, 4, 5)])
print(res + 1)
Et qu’on le passe à la moulinette :
$ mypy foo.py
foo.py:3: error: Argument 1 to "rgb2hex" has incompatible type "str"; expected Iterable[...]
foo.py:5: error: Unsupported operand types for + (List[Any] and "int")
Ensuite j’ai essayé de créer un stub file, c’est-à-dire de mettre les hints dans un fichier à part plutôt que directement dans le code. Ma fonction redevient :
def rgb2hex(pixels):
pattern = "#{0:02x}{1:02x}{2:02x}"
return [pattern.format(r, g, b) for r, g, b in pixels]
Et mon fichier stub (même nom, mais avec extension .pyi) contient :
from typing import Iterable, Tuple
PixelArray = Iterable[Tuple[int, int, int]]
def rgb2hex(pixels: PixelArray) -> list:...
Les stubs sont donc bien des fichiers Python valides, mais avec une extension différente, et juste les signatures des fonctions (le corps du bloc est une Ellipsis
).
Et poof, ça marche pareil :
$ mypy foo.py
foo.py:3: error: Argument 1 to "rgb2hex" has incompatible type "str"; expected Iterable[...]
foo.py:5: error: Unsupported operand types for + (List[Any] and "int")
Il y a un repo qui contient des fichiers stubs pour la stdlib. Vous pouvez y participer, c’est un moyen simple de contribuer à Python.
Bref, pour le moment ça demande encore un peu de maturité, mais je pense que d’ici quelques mois on aura des outils bien rodés pour faire tout ça automatiquement.
La feature pub. Techniquement le truc qui a fait dire à tous ceux qui voulaient de l’asyncrone que Python en fait, c’était trop cool. Sauf que Python pouvait faire ça avec yield from
avant, mais c’est sur que c’était super confusionant.
Maintenant on a un truc propre : pas de décorateur @coroutine
, pas de syntaxe semblable aux générateurs, mais des méthodes magiques comme __await__
et de jolis mots-clés async
et await
.
Vu que Crossbar est maintenant compatible Python 3, et qu’il supporte asyncio pour les clients… Si on s’implémentait un petit wrapper WAMP pour s’amuser à voir ce que ressemblerait une API moderne pour du Websocket en Python ?
pip install crossbar
crossbar init
crossbar start
(Ouhhhh, plein de zolies couleurs apparaissent dans ma console ! Ils ont fait des efforts cosmétiques chez Tavendo)
Bien, voici maintenant l’exemple d’un client WAMP de base codé avec asyncio selon l’ancienne API :
import asyncio
from autobahn.asyncio.wamp import ApplicationSession, ApplicationRunner
class MyComponent(ApplicationSession):
@asyncio.coroutine
def onJoin(self, details):
# on marque cette fonction comme appelable
# a distance en RPC
def add(a, b):
return a + b
self.register(add, "add")
# et on triche en l'appelant cash. J'ai
# la flemme de coder un deuxième client
# et ça passe quand même par le routeur
# donc merde
res = yield from self.call("add", 2, 3)
print("Got result: {}".format(res))
if __name__ == '__main__':
runner = ApplicationRunner("ws://127.0.0.1:8080/ws",
u"crossbardemo",
debug_wamp=False, # optional; log many WAMP details
debug=False, # optional; log even more details
)
runner.run(MyComponent)
Et ça marche nickel en 3.5. Mais qu’est-ce que c’est moche !
On est en train de bosser sur l’amélioration de l’API, mais je pense que ça va reste plus bas niveau que je le voudrais.
Donc, amusons-nous un peu à coder un truc plus sexy. Je vous préviens, le code du wrapper est velu, j’avais envie de me marrer un peu après les exemples ballots plus haut :
import asyncio
from autobahn.asyncio.wamp import ApplicationSession, ApplicationRunner
class App:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.procedures = []
self.subscriptions = []
self.event_handlers = {}
def run(self, url="ws://127.0.0.1:8080/ws",
realm="realm1", debug_wamp=False, debug=False):
runner = ApplicationRunner(url, realm,
debug_wamp=debug_wamp,
debug=debug)
runner.run(self)
def run_cmd(self, *args, **kwargs):
# et on pourrait même ici mettre du parsing d'argument
# et de os.environ, mais j'ai la flemme
if __name__ == '__main__':
self.run(*args, **kwargs)
# quelques décorateurs pour faire du déclaratif
# et remettre les paramètres dans le bon ordre
def register(self, name, *args, **kwargs):
def wrapper(proc):
self.procedures.append([name, proc, args, kwargs])
return proc
return wrapper
def subscribe(self, topic, *args, **kwargs):
def wrapper(callback):
self.procedures.append([topic, callback, args, kwargs])
return callback
return wrapper
# un système d'event interne
def on(self, event):
def wrapper(callback):
self.event_handlers.setdefault(event, []).append(callback)
return callback
return wrapper
async def trigger(self, event):
for callback in self.event_handlers.get(event, ()):
await callback(self.session)
# un peu de code de compatibilité avec l'API initiale
def __call__(self, *args):
class CustomeSession(ApplicationSession):
async def onJoin(session_self, details):
# on joint on fait tous les registers et tous les
# subscribes
for name, proc, args, kwargs in self.procedures:
session_self.register(proc, name, *args, **kwargs)
for topic, callback, args, kwargs in self.subscriptions:
session_self.subscribe(proc, topic, *args, **kwargs)
# on appelle les handlers de notre event
await self.trigger('joined')
self.session = CustomeSession(*args)
return self.session
Évidement la coloration syntaxique ne suit pas sur nos async
/await
.
Bon, vous allez me dire, mais ça quoi ça sert tout ça ? Et bien, c’est une version tronquée et codée à l’arrache de l’API Application pour Twisted… mais version asyncio.
C’est-à-dire que c’est une lib qui permet de faire le même exemple que le tout premier qu’on a vu dans cette partie – qui souvenez-vous était fort moche -, mais comme ça :
app = App()
@app.register('add')
async def add(a, b):
return a + b
@app.on('joined')
async def _(session):
res = await session.call("add", 2, 3)
print("Got result: {}".format(res))
app.run_cmd()
Des jolis décorateurs ! Des jolis async ! Des jolis await !
Et tout ça tourne parfaitement sur 3.5 messieurs-dames.
Bref, on peut faire du WAMP avec une syntaxe claire et belle, il faut juste se bouger le cul pour coder une abstraction un peu propre.
Je pense que autobahn restera toujours un peu bas niveau. Donc il va falloir que quelqu’un se colle à faire une lib pour wrapper tout ça.
Des volontaires ?
Arf, je savais bien que ça allait me retomber sur la gueule.
]]>Signalement de rigueur que l’article est long :
Un callback, ça va.
Deux callbacks, pour un seul appel, ça commence à être chiant, mais c’est compréhensible.
Quand les callbacks appellent eux aussi des callbacks, ça donne des codes imbitables :
$(function(){
$.post('/auth/token', function(token){
saveToken(token);
$.get('/sessions/last', function(session){
if (session.device != currentDevice){
$.get('/session/ ' + session.id + '/context', function(context){
loadContext(function(){
startApp(function(){
initUi()
})
})}
)}
else {
startApp(function(){
initUi()
})
}}
)
})
});
Il y a pire que de lire ce code : le modifier ! Retirez un bloc, pour voir. Oh, et histoire de vous faire partager l’expérience complète, j’ai volontairement déplacé l’indentation d’une parenthèse et de deux brackets.
Or les codes asynchrones ont besoin de callback afin d’enchainer certaines opérations dans le bon ordre, sinon on ne peut pas récupérer le résultat d’une fonction et l’utiliser dans une autre, puisqu’on ne sait pas quand l’opération se termine.
Dans notre exemple, $.post
et $.get
font des requêtes POST et GET, et comme on ne sait pas quand le serveur va répondre, il faut mettre un callback pour gérer la réponse quand elle arrive. C’est plus performant que de bloquer jusqu’à ce que la première requête soit terminée car pendant ce temps, notre programme peut faire autre chose. Mais c’est aussi super relou à écrire et comprendre.
Entrent en jeu les promesses (promises). Ou les deferred. Ou les futures.
Typiquement, on retrouve des deferreds dans Twisted, des promises pour l’AJAX avec jQuery, des futures pour asyncio… Mais il y en a un peu partout de nos jours, et une lib peut utiliser plusieurs de ces concepts.
En fait c’est la même chose, un nom différent donné au même concept, par des gens qui l’ont réinventé dans leur coin. Les puristes vous diront qu’il y a des différences dans l’implémentation, ou alors que la promesse est l’interface tandis que le deferred est l’objet retourné, bla, bla, bla.
Fuck it, on va considérer que c’est tout pareil.
Les promesses sont une des manières de rendre un code asynchrone plus facile à gérer. On dit : ce groupe de fonctions doit s’exécuter dans un ordre car elles sont dépendantes les unes des autres.
Il y a d’autres moyens de gérer le problème de l’asynchrone: des événements, des queues, etc. L’avantage des promesses c’est que c’est assez simple, et ça marche là où on utilisait des callbacks avant, donc on a pu les rajouter aux libs qui étaient blindées de callbacks.
La promesse est un moyen de dire que certaines fonctions, bien que non bloquantes et asynchrones, sont liées entre elles, et doivent s’exécuter les unes à la suite des autres. Cela permet de donner un ordre d’exécution à un groupe de fonctions, et surtout, que chaque fonction puisse accéder au résultat de la fonction précédente. Tout ceci sans bloquer le reste du système asynchrone.
En résumé, cela donne un gout de programmation synchrone, à quelque chose qui ne l’est pas.
Cela se passe ainsi :
Voilà un exemple :
// $.get est asynchrone. On a pas le résultat tout de suite, mais en attendant
// on a une promesse tout de suite.
var $promesse = $.get('/truc/machin');
// premier callback. Il sera appelé quand $.get aura récupéré son
// résultat
$promesse.then(function(resultat){
// faire un truc avec le résultat
// puis on retourne le nouveau résultat
return nouveau_resultat;
});
// deuxième callback. Il sera appelé quand le premier callback
// aura retourné son résultat.
$promesse.then(function(nouveau_resultat){
// faire un truc
});
Notez bien que c’est TRES différent de ça (en Python):
resultat = request.get('/truc/marchin')
def function(resultat):
# faire un truc
return nouveau_resultat
nouveau_resultat = function(resultat)
def autre_function(nouveau_resultat):
# faire un truc
autre_function(nouveau_resultat)
En Python, le code est bloquant par défaut. Ça va marcher, mais pendant que le code attend la réponse du serveur, votre ordinateur est en pause et ne travaille pas.
On se retrouve avec un code asynchrone, mais qui s’exécute dans l’ordre de lecture. Et comme on peut chainer les then()
et donc ne pas réécrire $promesse
à chaque fois, on obtient quelque chose de beaucoup plus lisible :
$.get('/truc/machin')
.then(function(resultat){
// faire un truc
return nouveau_resultat;
})
.then(function(nouveau_resultat){
// faire un truc
});
Si on reprend notre premier exemple, ça donne ça :
$(function(){
// create new token
$.post('/auth/token')
// then save token and get last session
.then(function(token){
saveToken(token);
return $.get('/sessions/last');
})
// then init session
.then(function(session){
if (session.device != currentDevice){
$.get('/session/ ' + session.id + '/context')
.then(function(context){
loadContext(function(){
startApp(function(){
initUi()
})
})
})
}
else {
startApp(function(){
initUi()
})
}}
})
});
Tout ça s’exécute de manière non bloquante (d’autres fonctions ailleurs dans le programme peuvent s’exécuter pendant qu’on attend la réponse du serveur), mais dans l’ordre de lecture, donc on comprend bien ce qui se passe. Si on veut retirer un bloc, c’est beaucoup plus facile.
Histoire d’avoir une idée de comment une promise marche, on va faire une implémentation, simpliste et naïve, mais compréhensible, d’une promesse en Python. Pour rendre l’API un peu sympa,je vais utiliser les décorateurs.
class Promise:
# La promesse contient une liste de callbacks, donc une liste de fonctions.
# Pas le résultat des fonctions, mais bien les fonctions elles mêmes,
# puisque les fonctions sont manipulables en Python.
def __init__(self):
self.callbacks = []
# Point d'entrée pour ajouter un callback à la promesse
def then(self, callback):
self.callbacks.append(callback)
# Cette méthode est celle qui sera appelée par le code asynchrone
# quand il reçoit son résultat.
def resolve(self, resultat):
# Ici, on obtient le résultat du code asycnhrone, donc on boucle
# sur les callbacks pour les appeler
while self.callbacks:
# On retire le premier callback de la liste, et on l'appelle
# avec le résultat
resultat = self.callbacks.pop(0)(resultat)
# Si le resultat est une promesse, on dit à cette nouvelle promesse
# de nous rappeler quand elle a reçu ses résultats à elle avant
# d'aller le reste de nos callbacks à nous : on fusionne les deux
# promesses :
# Promesse 1
# - callback1
# - callback2
# - Promesse 2
# * callback 1
# * callback 2
# - callback 3
if isinstance(resultat, Promise):
resultat.then(self.resolve)
break
Maintenant, créons un code asynchrone:
from threading import Timer
def func1(v1):
# On dit complètement artificiellement d'afficher le résultat
# de la fonction dans 3 secondes, sans bloquer histoire d'avoir
# un peu de nonbloquitude dans notre code et justifier l'asynchrone.
def callback1():
print(v1)
t = Timer(3, callback1)
t.start()
def func2(v2):
# Le même, mais pour 2 secondes
def callback2():
print(v2)
t = Timer(2, callback2)
t.start()
# Deux fonctions normales
def func3(v3):
print(v3)
def func4(v4):
print(v4)
# Et si on les enchaines...
print('Je commence')
func1(1)
print('Juste après')
func2(2)
func3(3)
func4(4)
# ... le résultat est bien désordonné :
## Je commence
## Juste après
## 3
## 4
## 2
## 1
Parfois c’est ce que l’on veut, que les choses s’exécutent dans le désordre, sans bloquer.
Mais quand on a des fonctions qui dépendent les unes des autres, au milieu d’un code asynchrone, on veut qu’elles se transmettent le résultat les unes aux autres au bon moment. Pour cela, utilisons notre promesse :
from threading import Timer
# La mise en place de promesses suppose que le code
# écrit en fasse explicitement usage. Notre code est
# définitivement lié à cette manière de faire.
def func1(v1):
# Notre fonction doit créer la promesse et la retourner
p = Promise()
def callback1():
print(v1)
# Dans le callback, elle doit dire quand la promesse est tenue
p.resolve(v1)
t = Timer(3, callback1)
t.start()
return p
# On lance la première fonction.
print('Je commence')
promise = func1(1)
print('Juste après')
# On ajoute des callbacks à notre promesse.
@promise.then
def func2(v2):
p = Promise()
def callback2():
# Pour justifier l’enchainement des fonctions, on fait en sorte que
# chaque fonction attend le résultat de la précédente, et
# l'incrémente de 1.
print(v2 + 1)
p.resolve(v2 + 1)
t = Timer(2, callback2)
t.start()
# Ce callback retourne lui-même une promesse, qui sera fusionnée
return p
# Ces callbacks ne retournent pas de promesses, et seront chainés
# normalement
@promise.then
def func3(v3):
print(v3 + 1)
return v3 + 1
@promise.then
def func4(v4):
print(v4 + 1)
# Nos fonctions s'exécutent dans le bon ordre, mais bien de manière
# asynchrone par rapport au reste du programme.
## Je commence
## Juste après
## 1
## 2
## 3
## 4
Notez bien :
func3
soit lancé. Évidement, n’utilisez pas cette implémentation de promise à la maison, c’est pédagogique. Ça ne gère pas les erreurs, ni le cas où le callback est enregistré après l’arrivée du résultat, et tout un tas d’autres cas tordus.
En Python, beaucoup de frameworks ont une approche plus agréable pour gérer les promesses à grand coup de yield. Twisted fait ça avec son @inlineCallback
, asyncio
avec @coroutine
. C’est juste du sucre syntaxique pour vous rendre la vie plus facile.
Il s’agit de transformer une fonction en générateur, et à chaque fois qu’on appelle yield
sur une promesse, elle est fusionnée avec la précédente. Ça donne presque l’impression d’écrire un code bloquant normal :
# un appel de fonction asyncrone typique de twisted
@inlineCallback
def une_fonction(data):
data = yield func1(data)
data = yield func2(data)
data = yield func3(data)
une_fonction(truc)
Les fonctions 1, 2 et 3 vont ainsi être appelées de manière asynchrone par rapport au reste du programme, mais bien s’enchainer les unes à la suite des autres.
Ouai, tout ce bordel parce que l’asynchrone, c’est dur, donc on essaye de le faire ressembler à du code synchrone, qui lui est facile.
]]>