Est-il possible de «pirater» la fonction d'impression de Python?

151

Remarque: cette question est à titre informatif uniquement. Je suis intéressé de voir à quel point il est possible d'approfondir les composants internes de Python.

Il n'y a pas très longtemps, une discussion a commencé à l'intérieur d'une certaine question concernant la question de savoir si les chaînes passées aux instructions print pouvaient être modifiées après / pendant l'appel à print. Par exemple, considérons la fonction:

def print_something():
    print('This cat was scared.')

Maintenant, quand printest exécuté, alors la sortie vers le terminal devrait afficher:

This dog was scared.

Notez que le mot «chat» a été remplacé par le mot «chien». Quelque chose quelque part a pu modifier ces tampons internes pour changer ce qui était imprimé. Supposons que cela soit fait sans l'autorisation explicite de l'auteur du code d'origine (par conséquent, piratage / détournement).

Ce commentaire du sage @abarnert, en particulier, m'a fait réfléchir:

Il y a plusieurs façons de le faire, mais elles sont toutes très laides et ne devraient jamais être faites. Le moyen le moins laid est probablement de remplacer l' codeobjet à l'intérieur de la fonction par un objet avec une co_consts liste différente . La prochaine étape consiste probablement à accéder à l'API C pour accéder au tampon interne de str. [...]

Donc, il semble que cela soit réellement possible.

Voici ma façon naïve d'aborder ce problème:

>>> import inspect
>>> exec(inspect.getsource(print_something).replace('cat', 'dog'))
>>> print_something()
This dog was scared.

Bien sûr, execc'est mauvais, mais cela ne répond pas vraiment à la question, car cela ne modifie en fait rien pendant le moment ou après l' print appel.

Comment cela se passerait-il comme @abarnert l'a expliqué?

cs95
la source
3
Soit dit en passant, le stockage interne des ints est beaucoup plus simple que les chaînes et flotte encore plus. Et, en prime, il est beaucoup plus évident pourquoi il est une mauvaise idée de changer la valeur 42pour 23que pourquoi il est une mauvaise idée de changer la valeur "My name is Y"à "My name is X".
abarnert

Réponses:

243

Premièrement, il existe en fait une manière beaucoup moins piratée. Tout ce que nous voulons faire, c'est changer les printimpressions, non?

_print = print
def print(*args, **kw):
    args = (arg.replace('cat', 'dog') if isinstance(arg, str) else arg
            for arg in args)
    _print(*args, **kw)

Ou, de même, vous pouvez monkeypatch sys.stdoutau lieu de print.


De plus, rien de mal avec l' exec … getsource …idée. Eh bien, bien sûr, il y a beaucoup de mal à cela, mais moins que ce qui suit ici ...


Mais si vous souhaitez modifier les constantes de code de l'objet fonction, nous pouvons le faire.

Si vous voulez vraiment jouer avec des objets de code pour de vrai, vous devriez utiliser une bibliothèque comme bytecode(quand c'est fini) ou byteplay(jusque-là, ou pour les anciennes versions de Python) au lieu de le faire manuellement. Même pour quelque chose d'aussi trivial, l' CodeTypeinitialiseur est une douleur; si vous avez réellement besoin de faire des choses comme réparer lnotab, seul un fou le ferait manuellement.

De plus, il va sans dire que toutes les implémentations Python n'utilisent pas des objets de code de style CPython. Ce code fonctionnera dans CPython 3.7, et probablement toutes les versions remontant à au moins 2.2 avec quelques modifications mineures (et non les trucs de piratage de code, mais des choses comme les expressions de générateur), mais il ne fonctionnera avec aucune version d'IronPython.

import types

def print_function():
    print ("This cat was scared.")

def main():
    # A function object is a wrapper around a code object, with
    # a bit of extra stuff like default values and closure cells.
    # See inspect module docs for more details.
    co = print_function.__code__
    # A code object is a wrapper around a string of bytecode, with a
    # whole bunch of extra stuff, including a list of constants used
    # by that bytecode. Again see inspect module docs. Anyway, inside
    # the bytecode for string (which you can read by typing
    # dis.dis(string) in your REPL), there's going to be an
    # instruction like LOAD_CONST 1 to load the string literal onto
    # the stack to pass to the print function, and that works by just
    # reading co.co_consts[1]. So, that's what we want to change.
    consts = tuple(c.replace("cat", "dog") if isinstance(c, str) else c
                   for c in co.co_consts)
    # Unfortunately, code objects are immutable, so we have to create
    # a new one, copying over everything except for co_consts, which
    # we'll replace. And the initializer has a zillion parameters.
    # Try help(types.CodeType) at the REPL to see the whole list.
    co = types.CodeType(
        co.co_argcount, co.co_kwonlyargcount, co.co_nlocals,
        co.co_stacksize, co.co_flags, co.co_code,
        consts, co.co_names, co.co_varnames, co.co_filename,
        co.co_name, co.co_firstlineno, co.co_lnotab,
        co.co_freevars, co.co_cellvars)
    print_function.__code__ = co
    print_function()

main()

Qu'est-ce qui pourrait mal tourner avec le piratage des objets de code? Surtout juste des segfaults, des RuntimeErrors qui consomment toute la pile, des RuntimeErrors plus normales qui peuvent être gérées, ou des valeurs de garbage qui ne feront probablement que lever un TypeErrorou AttributeErrorlorsque vous essayez de les utiliser. Pour des exemples, essayez de créer un objet de code avec juste un RETURN_VALUEsans rien sur la pile (bytecode b'S\0'pour 3.6+, b'S'avant), ou avec un tuple vide pour co_constsquand il y a un LOAD_CONST 0dans le bytecode, ou avec varnamesdécrémenté de 1 pour que le plus élevé LOAD_FASTcharge réellement une freevar / cellvar cellule. Pour vous amuser vraiment, si vous vous lnotabtrompez suffisamment, votre code ne fera que segfault lorsqu'il sera exécuté dans le débogueur.

Utiliser bytecodeou byteplayne vous protégera pas de tous ces problèmes, mais ils ont quelques vérifications de base de la cohérence et de bons assistants qui vous permettent de faire des choses comme insérer un morceau de code et le laisser s'inquiéter de la mise à jour de tous les décalages et étiquettes afin que vous puissiez ' t se tromper, et ainsi de suite. (De plus, ils vous évitent d'avoir à taper dans ce ridicule constructeur de 6 lignes et de déboguer les fautes de frappe qui en découlent.)


Passons maintenant au n ° 2.

J'ai mentionné que les objets de code sont immuables. Et bien sûr, les consts sont un tuple, donc nous ne pouvons pas changer cela directement. Et la chose dans le tuple const est une chaîne, que nous ne pouvons pas non plus changer directement. C'est pourquoi j'ai dû créer une nouvelle chaîne pour créer un nouveau tuple afin de créer un nouvel objet de code.

Mais que se passerait-il si vous pouviez changer une chaîne directement?

Eh bien, assez profondément sous les couvertures, tout n'est qu'un pointeur vers des données C, non? Si vous utilisez CPython, il existe une API C pour accéder aux objets , et vous pouvez l'utiliser ctypespour accéder à cette API à partir de Python lui-même, ce qui est une idée tellement terrible qu'ils ont mis un pythonapidroit là dans le ctypesmodule de stdlib . :) L'astuce la plus importante que vous devez savoir est que id(x)c'est le pointeur vers xen mémoire (en tant que int).

Malheureusement, l'API C pour les chaînes ne nous permettra pas d'accéder en toute sécurité au stockage interne d'une chaîne déjà figée. Alors vissez en toute sécurité, lisons simplement les fichiers d'en-tête et trouvons ce stockage nous-mêmes.

Si vous utilisez CPython 3.4 - 3.7 (c'est différent pour les anciennes versions, et qui sait pour le futur), une chaîne littérale d'un module en ASCII pur sera stockée en utilisant le format ASCII compact, ce qui signifie que la structure se termine tôt et le tampon d'octets ASCII suit immédiatement en mémoire. Cela cassera (comme probablement dans segfault) si vous mettez un caractère non-ASCII dans la chaîne, ou certains types de chaînes non littérales, mais vous pouvez lire les 4 autres façons d'accéder au tampon pour différents types de chaînes.

Pour rendre les choses un peu plus faciles, j'utilise le superhackyinternalsprojet depuis mon GitHub. (Ce n'est intentionnellement pas installable par pip car vous ne devriez vraiment pas l'utiliser sauf pour expérimenter votre version locale de l'interpréteur et autres.)

import ctypes
import internals # https://github.com/abarnert/superhackyinternals/blob/master/internals.py

def print_function():
    print ("This cat was scared.")

def main():
    for c in print_function.__code__.co_consts:
        if isinstance(c, str):
            idx = c.find('cat')
            if idx != -1:
                # Too much to explain here; just guess and learn to
                # love the segfaults...
                p = internals.PyUnicodeObject.from_address(id(c))
                assert p.compact and p.ascii
                addr = id(c) + internals.PyUnicodeObject.utf8_length.offset
                buf = (ctypes.c_int8 * 3).from_address(addr + idx)
                buf[:3] = b'dog'

    print_function()

main()

Si vous voulez jouer avec ce truc, intc'est beaucoup plus simple sous les couvertures que str. Et il est beaucoup plus facile de deviner ce que vous pouvez briser en changeant la valeur de 2à 1, non? En fait, oubliez d'imaginer, faisons-le simplement (en utilisant à superhackyinternalsnouveau les types de ):

>>> n = 2
>>> pn = PyLongObject.from_address(id(n))
>>> pn.ob_digit[0]
2
>>> pn.ob_digit[0] = 1
>>> 2
1
>>> n * 3
3
>>> i = 10
>>> while i < 40:
...     i *= 2
...     print(i)
10
10
10

… Prétendez que la zone de code a une barre de défilement infinie.

J'ai essayé la même chose dans IPython, et la première fois que j'ai essayé d'évaluer 2à l'invite, cela s'est passé dans une sorte de boucle infinie ininterrompue. Il utilise probablement le nombre 2pour quelque chose dans sa boucle REPL, contrairement à l'interpréteur de stock?

Abarnert
la source
11
@ cᴏʟᴅsᴘᴇᴇᴅ Le code-munging est sans doute raisonnable Python, bien que vous ne souhaitiez généralement toucher les objets de code que pour de bien meilleures raisons (par exemple, exécuter le bytecode via un optimiseur personnalisé). Accéder au stockage interne d'un PyUnicodeObject, d'autre part, c'est probablement vraiment seulement Python dans le sens où un interpréteur Python l'exécutera…
abarnert
4
Votre premier extrait de code augmente NameError: name 'arg' is not defined. Vouliez-vous dire args = [arg.replace('cat', 'dog') if isinstance(arg, str) else arg for arg in args]:? Un sans doute meilleure façon d'écrire ce serait: args = [str(arg).replace('cat', 'dog') for arg in args]. Une autre, encore plus courte, l' option: args = map(lambda a: str(a).replace('cat', 'dog'), args). Ceci a l'avantage supplémentaire qui argsest paresseux (qui pourrait également être accompli en remplaçant la compréhension de liste ci-dessus par une compréhension génératrice - *argsfonctionne dans les deux sens).
Konstantin
1
@ cᴏʟᴅsᴘᴇᴇᴅ Ouais, IIRC J'utilise uniquement la PyUnicodeObjectdéfinition de structure, mais en copiant cela dans la réponse, je pense que cela me gênerait , et je pense que le readme et / ou les commentaires de la source superhackyinternalsexpliquent réellement comment accéder au tampon (au moins assez bien pour me le rappeler la prochaine fois que je m'en soucie; je ne sais pas si ce sera suffisant pour quelqu'un d'autre…), ce dont je ne voulais pas parler ici. La partie pertinente est de savoir comment passer d'un objet Python en direct à son PyObject *via ctypes. (Et peut-être simuler l'arithmétique des pointeurs, éviter les char_pconversions automatiques , etc.)
abarnert
1
@ jpmc26 Je ne pense pas que vous deviez le faire avant d' importer des modules, du moment que vous le faites avant qu'ils ne s'impriment. Les modules effectueront la recherche de nom à chaque fois, sauf s'ils se lient explicitement printà un nom. Vous pouvez également lier le nom printpour eux: import yourmodule; yourmodule.print = badprint.
leewz
1
@abarnert: J'ai remarqué que vous aviez souvent averti de faire cela (par exemple "vous ne voulez jamais faire ça" , "pourquoi c'est une mauvaise idée de changer la valeur" , etc.). On ne sait pas exactement ce qui pourrait mal tourner (sarcasme), seriez-vous prêt à élaborer un peu là-dessus? Cela pourrait éventuellement aider ceux qui sont tentés de l'essayer aveuglément.
l'L'l
37

Patch de singe print

printest une fonction intégrée donc elle utilisera la printfonction définie dans le builtinsmodule (ou __builtin__en Python 2). Ainsi, chaque fois que vous souhaitez modifier ou changer le comportement d'une fonction intégrée, vous pouvez simplement réaffecter le nom dans ce module.

Ce processus est appelé monkey-patching.

# Store the real print function in another variable otherwise
# it will be inaccessible after being modified.
_print = print  

# Actual implementation of the new print
def custom_print(*args, **options):
    _print('custom print called')
    _print(*args, **options)

# Change the print function globally
import builtins
builtins.print = custom_print

Après cela, chaque printappel passera custom_print, même si le printest dans un module externe.

Cependant, vous ne souhaitez pas vraiment imprimer de texte supplémentaire, vous souhaitez modifier le texte imprimé. Une façon de procéder consiste à le remplacer dans la chaîne qui serait imprimée:

_print = print  

def custom_print(*args, **options):
    # Get the desired seperator or the default whitspace
    sep = options.pop('sep', ' ')
    # Create the final string
    printed_string = sep.join(args)
    # Modify the final string
    printed_string = printed_string.replace('cat', 'dog')
    # Call the default print function
    _print(printed_string, **options)

import builtins
builtins.print = custom_print

Et en effet si vous exécutez:

>>> def print_something():
...     print('This cat was scared.')
>>> print_something()
This dog was scared.

Ou si vous écrivez cela dans un fichier:

test_file.py

def print_something():
    print('This cat was scared.')

print_something()

et importez-le:

>>> import test_file
This dog was scared.
>>> test_file.print_something()
This dog was scared.

Cela fonctionne donc vraiment comme prévu.

Cependant, au cas où vous ne voudriez que temporairement imprimer un patch, vous pouvez envelopper ceci dans un gestionnaire de contexte:

import builtins

class ChangePrint(object):
    def __init__(self):
        self.old_print = print

    def __enter__(self):
        def custom_print(*args, **options):
            # Get the desired seperator or the default whitspace
            sep = options.pop('sep', ' ')
            # Create the final string
            printed_string = sep.join(args)
            # Modify the final string
            printed_string = printed_string.replace('cat', 'dog')
            # Call the default print function
            self.old_print(printed_string, **options)

        builtins.print = custom_print

    def __exit__(self, *args, **kwargs):
        builtins.print = self.old_print

Donc, lorsque vous exécutez cela, cela dépend du contexte ce qui est imprimé:

>>> with ChangePrint() as x:
...     test_file.print_something()
... 
This dog was scared.
>>> test_file.print_something()
This cat was scared.

C'est ainsi que vous pouvez "pirater" printpar le patching de singe.

Modifiez la cible au lieu du print

Si vous regardez la signature de, printvous remarquerez un fileargument qui est sys.stdoutpar défaut. Notez qu'il s'agit d'un argument par défaut dynamique (il recherche vraimentsys.stdout chaque fois que vous appelez print) et non comme des arguments par défaut normaux en Python. Donc, si vous changez sys.stdout print, l'impression sur la cible différente sera d'autant plus pratique que Python fournit également une redirect_stdoutfonction (à partir de Python 3.4, mais il est facile de créer une fonction équivalente pour les versions antérieures de Python).

L'inconvénient est que cela ne fonctionnera pas pour les printinstructions qui ne s'impriment pas sys.stdoutet que créer les vôtres stdoutn'est pas vraiment simple.

import io
import sys

class CustomStdout(object):
    def __init__(self, *args, **kwargs):
        self.current_stdout = sys.stdout

    def write(self, string):
        self.current_stdout.write(string.replace('cat', 'dog'))

Cependant, cela fonctionne également:

>>> import contextlib
>>> with contextlib.redirect_stdout(CustomStdout()):
...     test_file.print_something()
... 
This dog was scared.
>>> test_file.print_something()
This cat was scared.

Résumé

Certains de ces points ont déjà été mentionnés par @abarnet mais je voulais explorer ces options plus en détail. Surtout comment le modifier à travers les modules (en utilisant builtins/ __builtin__) et comment rendre ce changement uniquement temporaire (en utilisant des contextmanagers).

MSeifert
la source
4
Oui, la chose la plus proche de cette question que quiconque devrait jamais vouloir faire est redirect_stdout, donc c'est bien d'avoir une réponse claire qui mène à cela.
abarnert
6

Un moyen simple de capturer toute la sortie d'une printfonction puis de la traiter est de changer le flux de sortie en quelque chose d'autre, par exemple un fichier.

Je vais utiliser une PHPconvention de nommage ( ob_start , ob_get_contents , ...)

from functools import partial
output_buffer = None
print_orig = print
def ob_start(fname="print.txt"):
    global print
    global output_buffer
    print = partial(print_orig, file=output_buffer)
    output_buffer = open(fname, 'w')
def ob_end():
    global output_buffer
    close(output_buffer)
    print = print_orig
def ob_get_contents(fname="print.txt"):
    return open(fname, 'r').read()

Usage:

print ("Hi John")
ob_start()
print ("Hi John")
ob_end()
print (ob_get_contents().replace("Hi", "Bye"))

Serait imprimer

Salut John Bye John

Uri Goren
la source
5

Combinons cela avec l'introspection du cadre!

import sys

_print = print

def print(*args, **kw):
    frame = sys._getframe(1)
    _print(frame.f_code.co_name)
    _print(*args, **kw)

def greetly(name, greeting = "Hi")
    print(f"{greeting}, {name}!")

class Greeter:
    def __init__(self, greeting = "Hi"):
        self.greeting = greeting
    def greet(self, name):
        print(f"{self.greeting}, {name}!")

Vous trouverez cette astuce précédant chaque message d'accueil avec la fonction ou la méthode d'appel. Cela peut être très utile pour la journalisation ou le débogage; d'autant plus qu'il vous permet de "détourner" des instructions d'impression dans du code tiers.

Rafaël Dera
la source