【问题标题】:Is it possible to "hack" Python's print function?是否可以“破解” Python 的打印功能?
【发布时间】:2018-08-22 15:05:33
【问题描述】:

注意:此问题仅供参考。我有兴趣了解 Python 的内部结构有多深。

不久前,某个question 内部开始讨论是否可以在调用print 之后/期间修改传递给打印语句的字符串。例如,考虑函数:

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

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

This dog was scared.

请注意,“猫”一词已被“狗”一词取代。某处某处能够修改这些内部缓冲区以更改打印的内容。假设这是在没有原始代码作者明确许可的情况下完成的(因此,黑客/劫持)。

这个来自聪明的@abarnert 的comment 尤其让我思考:

有几种方法可以做到这一点,但它们都非常丑陋,而且 永远不应该做。最不难看的方法是可能更换 code 函数内的对象与一个不同的co_consts 列表。接下来可能是进入 C API 以访问 str 的 内部缓冲区。 [...]

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

这是我处理这个问题的幼稚方法:

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

当然,exec 不好,但这并不能真正回答问题,因为它实际上并没有修改任何内容在调用 print 时/之后

如@abarnert 解释的那样,它会如何完成?

【问题讨论】:

  • 顺便说一句,int 的内部存储比字符串简单得多,浮动更是如此。而且,作为奖励,为什么将42 的值更改为23 的值比将"My name is Y" 的值更改为"My name is X" 的值是一个坏主意要明显得多。

标签: python python-3.x printing python-internals


【解决方案1】:

首先,实际上有一种不那么老套的方法。我们要做的就是改变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)

或者,类似地,您可以使用猴子补丁 sys.stdout 而不是 print


另外,exec … getsource … 的想法也没有错。嗯,当然有很多错误,但比下面的要少……


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

如果你真的想真正地玩弄代码对象,你应该使用像bytecode(当它完成时)或byteplay(直到那时,或者对于旧的 Python 版本)这样的库,而不是手动进行。即使对于这种微不足道的事情,CodeType 初始化程序也很痛苦。如果你真的需要做一些事情,比如修复lnotab,只有疯子才会手动完成。

此外,不用说,并非所有 Python 实现都使用 CPython 样式的代码对象。此代码将在 CPython 3.7 中运行,并且可能所有版本都至少回到 2.2 并进行一些小的更改(不是代码黑客的东西,而是生成器表达式之类的东西),但它不适用于任何版本的 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 堆栈上没有任何内容(字节码 b'S\0' 用于 3.6+,b'S' 之前),或者当有一个 LOAD_CONST 0 时为 co_consts 创建一个空元组字节码,或者 varnames 减 1,所以最高的 LOAD_FAST 实际上加载了一个 freevar/cellvar 单元。为了一些真正的乐趣,如果你把lnotab 弄错了,你的代码只会在调试器中运行时出现段错误。

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


现在进入 #2。

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

但是如果你可以直接改变一个字符串呢?

好吧,在幕后,一切都只是指向一些 C 数据的指针,对吧?如果您使用的是 CPython,则有 a C API to access the objectsyou 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 无法让我们安全地获取已冻结字符串的内部存储。所以放心吧,让我们read the header files 自己找到那个存储空间。

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

为了让事情稍微简单一些,我使用了我的 GitHub 上的 superhackyinternals 项目。 (它是故意不可安装的,因为您真的不应该使用它,除非您尝试本地构建的解释器等。)

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

…假设代码框有一个无限长的滚动条。

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

【讨论】:

  • @cᴏʟᴅsᴘᴇᴇᴅ 代码处理可以说是合理的 Python,尽管您通常只想出于更好的原因(例如,通过自定义优化器运行字节码)来触摸代码对象.另一方面,访问PyUnicodeObject 的内部存储可能真的只有 Python,因为 Python 解释器会运行它……
  • 您的第一个代码 sn-p 引发 NameError: name 'arg' is not defined。你的意思是:args = [arg.replace('cat', 'dog') if isinstance(arg, str) else arg for arg in args]?可以说更好的写法是:args = [str(arg).replace('cat', 'dog') for arg in args]。另一个更短的选项:args = map(lambda a: str(a).replace('cat', 'dog'), args)。这还有一个额外的好处,那就是args 是惰性的(这也可以通过用生成器替换上面的列表理解来实现——*args 可以使用任何一种方式)。
  • @cᴏʟᴅsᴘᴇᴇᴅ 是的,IIRC 我只使用PyUnicodeObject 结构定义,但我认为将其复制到答案中会妨碍我,我认为自述文件和/或源 cmets到superhackyinternals 实际上解释了如何访问缓冲区(至少足以在下次我关心时提醒我;不确定它是否足以满足其他任何人......),我不想进入这里。相关部分是如何通过ctypes 从一个活动的 Python 对象到它的PyObject *。 (并且可能模拟指针运算,避免自动char_p 转换等)
  • @jpmc26 我认为您不需要在导入模块之前执行此操作,只要您在它们打印之前执行此操作即可。模块每次都会进行名称查找,除非它们明确地将 print 绑定到名称。您也可以为它们绑定名称printimport yourmodule; yourmodule.print = badprint
  • @abarnert:我注意到您经常警告您这样做(例如"you never want to actually do this""why it's a bad idea to change the value" 等)。目前尚不清楚可能会出现什么问题(讽刺),您愿意详细说明一下吗?对于那些想盲目尝试的人来说,它可能会有所帮助。
【解决方案2】:

让我们将它与框架内省结合起来!

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

你会发现这个技巧会在每个问候语的前面加上调用函数或方法。这对于日志记录或调试可能非常有用;特别是因为它可以让您“劫持”第三方代码中的打印语句。

【讨论】:

    【解决方案3】:

    猴子补丁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.
    

    所以它确实按预期工作。

    但是,如果您只是暂时想要猴子补丁打印,您可以将其包装在上下文管理器中:

    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.
    

    这就是你可以通过猴子补丁“破解”print 的方式。

    修改目标而不是print

    如果您查看print 的签名,您会注意到file 参数默认为sys.stdout。请注意,这是一个动态默认参数(它真的在您每次调用 print 时都会查找 sys.stdout),而不像 Python 中的普通默认参数。因此,如果您更改 sys.stdout print 实际上会打印到不同的目标,Python 还提供了 redirect_stdout 函数(从 Python 3.4 开始,但很容易为早期的 Python 版本创建等效函数)。

    缺点是它不适用于不打印到sys.stdoutprint 语句,并且创建自己的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__)以及如何使该更改仅是临时的(使用上下文管理器)。

    【讨论】:

    • 是的,任何人都应该真正想做的最接近这个问题的是redirect_stdout,所以很高兴有一个明确的答案来解决这个问题。
    【解决方案4】:

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

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

    会打印

    嗨,约翰 再见约翰

    【讨论】:

      猜你喜欢
      • 2010-09-16
      • 1970-01-01
      • 2018-04-17
      • 1970-01-01
      • 2019-11-16
      • 1970-01-01
      • 1970-01-01
      • 2013-07-05
      • 2011-11-19
      相关资源
      最近更新 更多