"hack" Python 的打印功能可以吗?

Is it possible to "hack" Python's print function?

注意:此问题仅供参考。我很想看看 Python 的内部结构有多深,可以使用它。

不久前,某个 内部开始讨论是否可以修改传递给 print 语句的字符串 after/during 已调用 print。例如,考虑函数:

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

现在,当 print 为 运行 时,终端的输出应显示:

This dog was scared.

请注意单词 "cat" 已被单词 "dog" 替换。某个地方的某些东西能够以某种方式修改这些内部缓冲区以更改打印的内容。假设这是在没有原始代码作者明确许可的情况下完成的(因此,hacking/hijacking)。

这个 来自聪明的@abarnert,特别让我思考:

There are a couple of ways to do that, but they're all very ugly, and should never be done. The least ugly way is to probably replace the code object inside the function with one with a different co_consts list. Next is probably reaching into the C API to access the str's internal buffer. [...]

所以,看起来这实际上是可能的。

这是我解决这个问题的天真的方法:

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

当然,exec 不好,但这并不能真正回答问题,因为它实际上并没有在 when/after print 被调用。

如何按照@abarnert 的解释完成?

首先,实际上有一种更简单的方法。我们想要做的就是改变 print 打印的内容,对吗?

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

或者,类似地,您可以使用 monkeypatch sys.stdout 而不是 print


此外,exec … getsource … 的想法没有错。好吧,当然有 很多 错误,但比下面的要少……


但是如果您确实想修改函数对象的代码常量,我们可以这样做。

如果你真的想真正地使用代码对象,你应该使用像 bytecode (when it's finished) or byteplay 这样的库(在那之前,或者对于旧的 Python 版本)而不是手动进行。即使对于这种微不足道的事情, CodeType 初始化程序也是一种痛苦;如果你真的需要做一些事情,比如修复 lnotab,只有疯子才会手动去做。

此外,不用说并非所有 Python 实现都使用 CPython-style 代码对象。这段代码将在 CPython 3.7 中工作,并且可能所有版本都至少回到 2.2 并进行一些小的更改(而不是 code-hacking 的东西,但是生成器表达式之类的东西),但它不会使用任何版本的 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()

破解代码对象会出什么问题?大多数只是段错误,RuntimeErrors 会吃掉整个堆栈,更正常的 RuntimeErrors 可以处理,或者可能只会引发 TypeErrorAttributeError 的垃圾值当您尝试使用它们时。例如,尝试创建一个只有 RETURN_VALUE 的代码对象,堆栈上什么都没有(3.6+ 的字节码 b'S[=27=]',之前的 b'S'),或者 [=29= 的空元组] 当字节码中有一个 LOAD_CONST 0 时,或者 varnames 减 1 所以最高的 LOAD_FAST 实际上加载了一个 freevar/cellvar 单元格。为了一些真正的乐趣,如果你把 lnotab 弄错了,你的代码只会在调试器中 运行 时出现段错误。

使用 bytecodebyteplay 并不能保护你免受所有这些问题的困扰,但它们确实有一些基本的健全性检查,以及可以让你做一些事情的好帮手,比如插入一大块代码并让它担心更新所有偏移量和标签,这样你就不会弄错,等等。 (另外,它们使您不必输入那个荒谬的 6 行构造函数,也不必调试由此产生的愚蠢拼写错误。)


现在进入#2。

我提到代码对象是不可变的。当然,常量是一个元组,所以我们不能直接改变它。而const元组里面的东西是一个字符串,我们也不能直接改变。这就是为什么我必须构建一个新的字符串来构建一个新的元组来构建一个新的代码对象。

但是如果您可以直接更改字符串呢?

好吧,在幕后足够深,一切都只是指向某些 C 数据的指针,对吗?如果您使用的是 CPython,则有 a C API to access the objects, and you can use ctypes to access that API from within Python itself, which is such a terrible idea that they put a pythonapi right there in the stdlib's ctypes module。 :) 您需要知道的最重要的技巧是 id(x) 是内存中指向 x 的实际指针(作为 int)。

不幸的是,用于字符串的 C API 不允许我们安全地访问 already-frozen 字符串的内部存储。所以安全地拧紧,让我们 read the header files 自己找到那个存储空间。

如果您使用的是 CPython 3.4 - 3.7(它与旧版本不同,未来谁知道),将存储来自由纯 ASCII 构成的模块的字符串文字使用紧凑的 ASCII 格式,这意味着结构提前结束并且 ASCII 字节的缓冲区紧跟在内存中。如果您在字符串中放入 non-ASCII 字符或某些类型的 non-literal 字符串,这将中断(可能是段错误),但您可以阅读其他 4 种访问缓冲区的方法各种字符串。

为了让事情稍微简单一些,我在 GitHub 中使用了 superhackyinternals 项目。 (它不是故意的 pip-installable 因为你真的不应该使用它,除了试验你本地构建的解释器等等。)

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()

如果你想玩这个东西,int 在幕后比 str 简单得多。并且通过将 2 的值更改为 1 来猜测您可以破解的内容要容易得多,对吗?其实,别想了,直接动手吧(再用superhackyinternals中的类型):

>>> 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

…假装代码框有一个infinite-length滚动条。

我在 IPython 中尝试了同样的事情,我第一次尝试在提示符下计算 2 时,它进入了某种不可中断的无限循环。大概它在其 REPL 循环中使用数字 2 作为某些东西,而股票解释器不是?

print 函数捕获所有输出然后对其进行处理的一种简单方法是将输出流更改为其他内容,例如一个文件。

我将使用 PHP 命名约定 (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()

用法:

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

会打印

Hi John Bye John

Monkey-patch print

print 是内置函数,因此它将使用 builtins 模块中定义的 print 函数(或 Python 2 中的 __builtin__ )。因此,无论何时您想要修改或更改内置函数的行为,您都可以简单地重新分配该模块中的名称。

这个过程叫做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

之后每个 print 调用都将通过 custom_print,即使 print 在外部模块中也是如此。

但是您并不是真的想打印额外的文本,而是想更改打印的文本。解决此问题的一种方法是将其替换为将要打印的字符串:

_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

事实上,如果你 运行:

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

或者如果您将其写入文件:

test_file.py

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

print_something()

并导入它:

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

所以它真的按预期工作了。

但是,如果您只是暂时想 monkey-patch 打印,您可以将其包装在 context-manager:

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

所以当你 运行 时,它取决于打印的内容:

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

这就是您可以 "hack" print 通过 monkey-patching 的方式。

修改目标而不是print

如果您查看 print you'll notice a file argument which is sys.stdout by default. Note that this is a dynamic default argument (it really looks up sys.stdout every time you call print) and not like normal default arguments in Python. So if you change sys.stdout print will actually print to the different target even more convenient that Python also provides a redirect_stdout 函数的签名(从 Python 3.4 开始,但很容易为更早的 Python 版本创建等效函数)。

缺点是它不适用于 print 不打印到 sys.stdout 的语句,并且创建您自己的 stdout 并不是很简单。

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'))

然而这也有效:

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

总结

@abarnet 已经提到了其中一些要点,但我想更详细地探讨这些选项。特别是如何跨模块修改它(使用 builtins/__builtin__)以及如何使该更改只是临时的(使用上下文管理器)。

让我们将其与帧内省结合起来!

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}!")

你会发现这个技巧在调用函数或方法的每一个问候语之前。这对于记录或调试可能非常有用;特别是因为它允许您 "hijack" 在第三方代码中打印语句。