1 points par GN⁺ 2024-08-23 | 1 commentaires | Partager sur WhatsApp
  • Python peut utiliser les deux premières lignes de commentaire magique d’encodage source et un codec personnalisé pour transformer le contenu d’un fichier avant son exécution, voire le remplacer entièrement par un autre code
  • Un codec personnalisé peut être enregistré pendant l’initialisation de l’interpréteur via l’exécution d’un import dans un fichier .pth de configuration de chemin, puis en ajoutant une fonction de recherche avec codecs.register
  • L’implémentation d’un codec nécessite decode(data: bytes) -> tuple[str, int] et un décodeur incrémental ; sans gestion des exceptions, on risque de ne voir que SyntaxError: encoding problem: your_codec au lieu de la cause réelle
  • Le même point d’entrée permet d’implémenter des opérateurs d’incrément/décrément ++/--, un Python à accolades, l’exécution de C/C++ via cppyy, ou même une validation de TOML par JSON Schema
  • Au-delà des exemples ludiques, cela peut aussi servir à des extensions ou backports de Python comme pythonql, future-typing, future-fstrings et future-annotations ; magic_codec aide à réduire le travail répétitif

Utiliser l’encodage source comme point d’entrée de prétraitement

  • Selon la PEP-0263, on peut indiquer l’encodage du code source sur l’une des deux premières lignes d’un fichier Python
    • Ex. : # coding=utf8, # -*- coding: utf8 -*-, # vim: set fileencoding=utf8 :
  • La ligne magique doit correspondre à l’expression régulière ^[ \t\f]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)
    • Le nom du codec doit donc correspondre à [-_.a-zA-Z0-9]+
  • Un codec personnalisé ne se limite pas à décoder la source : il peut modifier la chaîne source avant de la transmettre à l’interpréteur Python

Enregistrer un codec via un fichier .pth

  • Quand l’interpréteur Python démarre sans l’option -S, le package site est chargé pendant l’initialisation
  • Les fichiers .pth de configuration de chemin dans site-packages ajoutent leur contenu au chemin de recherche des modules, sauf les lignes vides et celles qui commencent par #
  • D’après la documentation Python, les lignes qui commencent par import suivi d’un espace ou d’une tabulation sont exécutées
    • Par exemple, si packagename.pth contient import packagename.register_codec, ce module sera importé pendant l’initialisation
  • Le module importé peut appeler codecs.register pour enregistrer une fonction de recherche de codec
    • Comme l’import n’est exécuté qu’une seule fois, la fonction de recherche n’est elle aussi enregistrée qu’une seule fois

Implémenter un codec personnalisé

  • Un codec personnalisé a besoin de deux éléments
    • decode(data: bytes) -> tuple[str, int]
    • une classe de décodeur incrémental
  • La fonction decode peut utiliser codecs.utf_8_decode pour effectuer le vrai décodage UTF-8, puis transmettre la chaîne obtenue à une fonction de prétraitement
  • Si une exception interne au codec n’est pas interceptée, Python peut n’afficher que SyntaxError: encoding problem: your_codec au lieu d’un traceback normal
    • Mieux vaut donc afficher explicitement l’exception avec traceback.print_exc() dans la fonction de prétraitement avant de la relancer
  • Le décodeur incrémental peut accumuler le fichier entier en mémoire puis n’effectuer le prétraitement qu’au dernier appel à decode
    • L’exemple montré hérite de codecs.BufferedIncrementalDecoder et ne traite les données dans decode(self, data, final=False) que lorsque final vaut vrai
  • Le résultat du prétraitement n’a pas besoin de reprendre le contenu original du fichier et peut même renvoyer un code Python entièrement arbitraire
    • En revanche, la première ligne est supprimée car elle est supposée être la ligne magique, et le résultat doit être du Python valide

Exemples d’extension de la syntaxe Python

  • Opérateurs d’incrément et de décrément ++ et --

    • Python ne possède pas d’opérateur unaire d’incrément/décrément
    • x++ et x-- ne sont pas syntaxiquement valides
    • ++x et --x sont valides du point de vue syntaxique, mais appellent respectivement x.__pos__().__pos__() et x.__neg__().__neg__()
    • Un préprocesseur peut modifier le flux de tokens pour leur donner le comportement d’opérateurs d’incrément/décrément
      • x++(x, x := x + 1)[0]
      • x--(x, x := x - 1)[0]
      • ++x(x, x := x + 1)[1]
      • --x(x, x := x - 1)[1]
    • Cette transformation utilise l’opérateur morse (walrus operator), c’est-à-dire l’expression d’affectation de Python
    • Une simple substitution de tokens peut échouer sur une expression comme x++ - -y ; on peut réduire l’ambiguïté avec des parenthèses, par exemple x++ - (-y)
    • incdec.py effectue ce remplacement par expressions régulières, mais cela peut rester fragile même en essayant d’éviter les substitutions dans les littéraux de chaîne
    • Une implémentation qui modifie directement le flux de tokens est disponible dans magic.incdec
  • Python à accolades

    • from __future__ import braces provoque SyntaxError: not a chance
    • Un préprocesseur peut modifier le flux de tokens pour transformer une portée à accolades en Python basé sur l’indentation
    • Le déroulement de l’implémentation est le suivant
      • Génération des tokens avec tokenize.generate_tokens
      • Fourniture de la chaîne d’entrée ligne par ligne via readline de io.StringIO
      • Suppression des tokens INDENT et DEDENT existants
      • À la rencontre de {, augmentation du niveau d’indentation et émission de :
      • À la rencontre de }, diminution du niveau d’indentation
      • Après NL, ajout d’un token INDENT correspondant au niveau d’indentation courant
    • Pour limiter les conflits avec les littéraux dictionnaire de Python, on peut ne modifier le niveau d’indentation que si { est suivi d’un saut de ligne, et ne traiter } comme fermeture de portée que s’il est précédé d’un saut de ligne
    • Les dictionnaires sur plusieurs lignes restent utilisables avec un antislash, car cela évite la génération de tokens de saut de ligne à l’intérieur des accolades

Exécuter d’autres langages via Python

  • C et C++

    • Les langages qui utilisent # pour les commentaires, comme les scripts shell, les scripts CMake, PHP ou Ruby, permettent facilement d’ajouter une ligne magique d’encodage avec un shebang
    • En C et C++, les commentaires s’écrivent /* ... */ ou // ..., mais les directives du préprocesseur commencent par #, ce qui permet de correspondre à l’expression régulière d’encodage
    • La ligne magique d’exemple est valide à la fois en C/C++ et pour le motif d’encodage de Python
      • #define CODEC "coding:magic.cpp"
    • Avec cppyy, Python peut interpréter du code C/C++ et générer des bindings
      • cppyy s’appuie en interne sur cling
    • Le résultat du prétraitement devient grosso modo le code Python suivant
      • import cppyy
      • cppyy.cppdef("<input source file content>")
      • from cppyy.gbl import main
      • appel à main() du code C/C++ lorsque __name__ == "__main__"
    • Une implémentation d’exemple est disponible dans magic.cpp

L’utiliser comme outil de validation TOML

  • TOML utilise # pour les commentaires, donc on peut y ajouter une ligne magique d’encodage comme # coding: magic.toml
  • Si le résultat du prétraitement est converti en script Python de validation, l’interpréteur Python peut alors servir d’outil de validation TOML
  • L’exemple de validation utilise les modules suivants
    • tomllib pour lire le fichier TOML
    • json pour lire le fichier JSON Schema
    • jsonschema pour effectuer la validation
  • Exécution :
    • python tests/toml/data_valid.toml -s tests/toml/schema.json
    • Si tout est valide, le programme affiche Successfully validated.
  • Dans un exemple de TOML invalide, une erreur de validation signale que la chaîne '20' dans le tableau scores n’est pas un nombre
  • Une implémentation d’exemple est disponible dans magic.toml

Cas d’usage réels et magic_codec

  • En combinant un codec personnalisé et un fichier .pth, on peut modifier en profondeur le comportement de l’interpréteur Python
  • La plupart des exemples sont là pour le plaisir, mais il existe aussi des usages concrets
  • Si l’on veut éviter de modifier directement site-packages ou d’écrire soi-même les fichiers .pth et le code répétitif, on peut utiliser magic_codec
  • Les extensions magic_codec peuvent être créées sous forme de packages Python préfixés par magic_
    • Si on définit le codec du fichier sur magic_foo, le package magic_foo est chargé
    • Le système vérifie ensuite si ce package expose une fonction preprocess
  • La signature attendue pour preprocess est la suivante
    • def preprocess(data: str) -> str:
  • Des exemples d’extension sont disponibles dans example/

1 commentaires

 
GN⁺ 2024-08-23
Avis sur Hacker News
  • Le message d’erreur amusant SyntaxError: not a chance, qui apparaît quand on exécute from __future__ import braces, était codé en dur dans CPython depuis 2001
    https://github.com/python/cpython/commit/ad3d3f2f3f19833f59f...
    Son auteur, Jeremy Hylton, travaille aujourd’hui chez Google comme Principal Engineer en charge de la qualité de la recherche IA ; c’est assez impressionnant de voir, en 24 ans, une carrière passer d’une célébration facétieuse d’une syntaxe interdite à des systèmes de requête universels qui n’ont pas besoin de syntaxe dédiée

    • Ça me rappelle le cas de break rust;, qui provoquait une erreur interne du compilateur dans le compilateur Rust. Je me demande combien d’easter eggs similaires existent dans d’autres langages
    • Je ne vois pas en quoi c’est surprenant. En 2001, n’importe qui ne pouvait pas mettre quelque chose dans Python ; à l’époque, c’était un projet de niche, et les contributeurs étaient des gens intelligents et dévoués, donc ils avaient de bonnes chances d’avoir une carrière influente
      Je pense que c’est une idée reçue de croire que le hacking ludique et informel en amateur serait un monde séparé du vrai développement professionnel
    • C’était une époque innocente. Hylton pourrait bien rejoindre le mouvement de vote de défiance pour défendre Tim Peters
      https://news.ycombinator.com/item?id=41314393
    • Voir ce genre d’easter eggs est toujours sympa. Dommage qu’ils soient moins courants qu’avant
  • Je pensais que jouer avec les import hooks était la façon la plus créative de se faire virer, mais c’était naïf. C’est dommage : à cause de la regex des codecs, on ne pourra sans doute pas troller correctement avec des trucs comme μtf8. Il ne reste donc plus qu’à utiliser un import hook, un préprocesseur et sys.settrace pour monkey-patcher chaque fonction avec celle appelée juste avant, et échanger stdout et stderr toutes les 17 minutes

    • Comme tout bon langage, il faut aussi imposer l’usage des accolades
  • Il y a de bonnes raisons pour lesquelles Python n’expose pas volontairement de hook de préprocesseur, et c’est quelque chose dont des adultes raisonnables devraient, à mon avis, se tenir éloignés
    Mais d’un autre côté, je n’ai pas envie de fréquenter des adultes raisonnables. On pourrait faire beaucoup de choses vraiment amusantes

    • Python a une philosophie de langage pour « adultes consentants » (consenting adults), d’où l’absence de modificateurs de visibilité public/private, et sans doute aussi l’ouverture à toutes sortes de magie de métaprogrammation. S’ils s’étaient inquiétés des « adultes raisonnables », ces choix de conception auraient été un peu étranges :)
    • Si on empêche les gens de faire ça de manière simple et évidente, ils essaieront de le faire de façon encore pire et plus hacky
  • Ça a l’air pratique et vraiment utile. Quand je faisais des hacks d’import absurdes, j’importais généralement le module, je réécrivais le code avec le module ast, je faisais un exec, puis j’insérais un exit() ; avec un préprocesseur, ce serait beaucoup plus agréable à utiliser
    Avant que tous les dict ne garantissent l’ordre, je m’en servais surtout pour transformer, via réécriture ast, des littéraux de liste en appels à des ordered dict, et c’était réellement utile
    J’aime la flexibilité de Python. La chose la plus maudite que j’aie faite a été de modifier des chaînes en place, et j’ai fini par abuser de mmap pour écrire un script qui se modifie lui-même. J’ai l’impression que je devrais maintenant écrire un interpréteur Lisp comme producer

    • « Modifier des chaînes en place » ? Les chaînes sont immuables. Tu as écrit directement à l’emplacement mémoire avec un truc comme ctypes ?
  • Le meilleur cas d’usage que j’aie trouvé jusqu’ici est pyxl, inspiré de JSX : https://github.com/dropbox/pyxl
    On peut écrire du code comme ceci
    # coding: pyxl
    print Hello World!

  • Je me demande si cela aurait pu servir à mieux gérer la transition de Python 2 vers Python 3. Par exemple, # coding: six.python2 pourrait adapter du code Python 2 pour en faire du code Python 3 valide, ou # coding: six.python3 transformer du code Python 3 pour qu’il tourne sous Python 2
    Il aurait peut-être aussi été possible d’ajouter ou de supprimer des préfixes b"..." ou u"..."

    • Ça aurait pu aider, mais les parties où ça aide sont les parties faciles. La difficulté du passage de Python 2 à 3 venait des changements de comportement à l’exécution. En Python 2, une chaîne Unicode contenant de l’ASCII et une chaîne ordinaire se comportaient comme la même chaîne : utilisées comme clés de dict, elles pointaient vers la même entrée ; en Python 3, des bytes et un str ayant le même contenu ASCII pointent vers des entrées différentes dans le même dict
      Il y a aussi des changements plus subtils. Plusieurs fonctionnalités intégrées, comme .keys() et .values(), renvoient des listes en Python 2 mais des itérateurs en Python 3. Si l’on essaie de traduire le code de façon sûre avec les utilitaires six ou d’autres contournements, le code devient très verbeux, parce que la plupart des valeurs ne sont utilisées qu’une fois, mais parfois deux
      Si l’on dispose d’un outil capable de réécrire le code au moment de l’import, je pense qu’il vaut mieux committer directement le code transformé et le nettoyer progressivement. La partie difficile, ce sont les changements de comportement qui se propagent à du code éloigné, comme str contre bytes
  • Les dépendances créées par cette stratégie de coding hook, est-ce que pip freeze ou uv les détectent ?
    Sinon, ça promet de bons moments :). Si quelqu’un a glissé un truc comme ça, on peut presque être sûr qu’il y a d’autres pièges ; plutôt que de combattre ce genre de dragons, il serait probablement plus simple de réécrire la bibliothèque

  • Ce serait assez drôle de créer un pseudo-code Python et de le faire décoder par un LLM. Ce serait évidemment horrible, mais sans doute amusant