【问题标题】:hook into the builtin python f-string format machinery挂钩到内置的 python f-string 格式机制
【发布时间】:2019-09-16 12:04:51
【问题描述】:

总结

我真的很喜欢f-strings。它们是非常棒的语法。

现在我已经有一段时间了an idea for a little library- 如下所述*- 来进一步利用它们。我想要它做的一个简单的例子:

>>> import simpleformatter as sf
>>> def format_camel_case(string):
...     """camel cases a sentence"""
...     return ''.join(s.capitalize() for s in string.split())
...
>>> @sf.formattable(camcase=format_camel_case)
... class MyStr(str): ...
...
>>> f'{MyStr("lime cordial delicious"):camcase}'
'LimeCordialDelicious'

这将非常有用——出于简化 API 的目的,并将使用扩展到内置类实例——找到一种连接到内置 python 格式化机制的方法,这将允许自定义格式规范内置:

>>> f'{"lime cordial delicious":camcase}'
'LimeCordialDelicious'

换句话说,我想覆盖内置的 format 函数(由 f-string 语法使用)——或者,扩展现有标准库类的内置 __format__ 方法-- 这样我就可以写出这样的东西了:

for x, y, z in complicated_generator:
    eat_string(f"x: {x:custom_spec1}, y: {x:custom_spec2}, z: {x:custom_spec3}")

我已经通过使用自己的 __format__ 方法创建子类来实现这一点,但当然这不适用于内置类。

我可以使用string.Formatter api 接近它:

my_formatter=MyFormatter()  # custom string.Formatter instance

format_str = "x: {x:custom_spec1}, y: {x:custom_spec2}, z: {x:custom_spec3}"

for x, y, z in complicated_generator:
    eat_string(my_formatter.format(format_str, **locals()))

我觉得这有点笨拙,与 f-string api 相比绝对不可读。

可以做的另一件事是覆盖builtins.format

>>> import builtins
>>> builtins.format = lambda *args, **kwargs: 'womp womp'
>>> format(1,"foo")
'womp womp'

...但这不适用于 f 字符串:

>>> f"{1:foo}"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: Invalid format specifier

详情

目前my API 看起来像这样(有些简化):

import simpleformatter as sf
@sf.formatter("this_specification")
def this_formatting_function(some_obj):
    return "this formatted someobj!"

@sf.formatter("that_specification")
def that_formatting_function(some_obj):
    return "that formatted someobj!"

@sf.formattable
class SomeClass: ...

之后你可以编写如下代码:

some_obj = SomeClass()
f"{some_obj:this_specification}"
f"{some_obj:that_specification}"

我希望 api 更像下面这样:

@sf.formatter("this_specification")
def this_formatting_function(some_obj):
    return "this formatted someobj!"

@sf.formatter("that_specification")
def that_formatting_function(some_obj):
    return "that formatted someobj!"

class SomeClass: ...  # no class decorator needed

...并允许在内置类上使用自定义格式规范:

x=1  # built-in type instance
f"{x:this_specification}"
f"{x:that_specification}"

但是为了做这些事情,我们必须深入了解内置的format() 函数。我怎样才能迷上那多汁的 f 弦善良?

* 注意:我可能永远不会真正实现这个库!但我确实认为这是一个好主意,并邀请任何想要的人从我这里偷走它:)。

【问题讨论】:

  • 你能写f"{spec(x)}"吗?
  • @RickTeachey:如果要在格式化系统中注册,则必须将它们导入某个地方,所以这不会打扰我。它还具有在另一个全局命名空间(除了顶级模块/包之外)不会冒冲突风险的优点。
  • f("{x:spec}") 怎么样,使用 string.Formatter 以及 inspect.currentframe().f_back .f_locals.f_globals(参见 docs.python.org/3/library/inspect.htmlstackoverflow.com/questions/6618795/…)?您还可以使用 @ 等运算符来改进语法 (f@"{x:spec}")。
  • @SolomonUcko f@ 的想法很有创意!缺点:IDE 不知道字符串包含没有前导 f 的表达式。但这几乎不会破坏交易。

标签: python format f-string


【解决方案1】:

概述

您可以,但前提是您编写了可能永远不会出现在生产软件中的恶意代码。那么让我们开始吧!

我不打算将它集成到您​​的库中,但我将向您展示如何挂钩 f 字符串的行为。大致是这样的:

  1. 编写一个操作代码对象的字节码指令的函数,将FORMAT_VALUE 指令替换为对钩子函数的调用;
  2. 自定义导入机制以确保每个模块和包(标准库模块和站点包除外)的字节码都使用该函数进行修改。

您可以在https://github.com/mivdnber/formathack 获得完整的源代码,但所有内容都在下面进行了解释。

免责声明

这个解决方案不是很好,因为

  1. 根本无法保证这不会破坏完全不相关的代码;
  2. 无法保证此处描述的字节码操作将在较新的 Python 版本中继续工作。它绝对不适用于不编译为 CPython 兼容字节码的替代 Python 实现。 PyPy 理论上可以工作,但这里描述的解决方案不能工作,因为 bytecode package 不是 100% 兼容的。

然而,它一种解决方案,并且字节码操作已成功用于像PonyORM 这样的流行包中。请记住,它很hacky、复杂并且可能需要大量维护。

第 1 部分:字节码操作

Python 代码不是直接执行的,而是首先编译成一种更简单的中介、非人类可读的基于堆栈的语言,称为 Python 字节码(它是 *.pyc 文件中的内容)。要了解该字节码的样子,您可以使用standard library dis module 来检查一个简单函数的字节码:

def invalid_format(x):
    return f"{x:foo}"

调用此函数会导致异常,但我们会尽快“修复”该问题。

>>> invalid_format("bar")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in invalid_format
ValueError: Invalid format specifier

要检查字节码,请启动 Python 控制台并调用 dis.dis

>>> import dis
>>> dis.dis(invalid_format)
  2           0 LOAD_FAST                0 (x)
              2 LOAD_CONST               1 ('foo')
              4 FORMAT_VALUE             4 (with format)
              6 RETURN_VALUE

我在下面的输出中添加了注释来解释发生了什么:

# line 2      # Put the value of function parameter x on the stack
  2           0 LOAD_FAST                0 (x)
              # Put the format spec on the stack as a string
              2 LOAD_CONST               1 ('foo')
              # Pop both values from the stack and perform the actual formatting
              # This puts the formatted string on the stack
              4 FORMAT_VALUE             4 (with format)
              # pop the result from the stack and return it
              6 RETURN_VALUE

这里的想法是将FORMAT_VALUE 指令替换为对钩子函数的调用,该函数允许我们实现我们想要的任何行为。现在让我们像这样实现它:

def formathack_hook__(value, format_spec=None):
    """
    Gets called whenever a value is formatted. Right now it's a silly implementation,
    but it can be expanded with all sorts of nasty hacks.
    """
    return f"{value} formatted with {format_spec}"

为了替换指令,我使用了bytecode package,它为做可怕的事情提供了非常好的抽象。

from bytecode import Bytecode
def formathack_rewrite_bytecode__(code):
    """
    Modifies a code object to override the behavior of the FORMAT_VALUE
    instructions used by f-strings.
    """
    decompiled = Bytecode.from_code(code)
    modified_instructions = []
    for instruction in decompiled:
        name = getattr(instruction, 'name', None)
        if name == 'FORMAT_VALUE':
            # 0x04 means that a format spec is present
            if instruction.arg & 0x04 == 0x04:
                callback_arg_count = 2
            else:
                callback_arg_count = 1
            modified_instructions.extend([
                # Load in the callback
                Instr("LOAD_GLOBAL", "formathack_hook__"),
                # Shuffle around the top of the stack to put the arguments on top
                # of the function global
                Instr("ROT_THREE" if callback_arg_count == 2 else "ROT_TWO"),
                # Call the callback function instead of executing FORMAT_VALUE
                Instr("CALL_FUNCTION", callback_arg_count)
            ])
        # Kind of nasty: we want to recursively alter the code of functions.
        elif name == 'LOAD_CONST' and isinstance(instruction.arg, types.CodeType):
            modified_instructions.extend([
                Instr("LOAD_CONST", formathack_rewrite_bytecode__(instruction.arg), lineno=instruction.lineno)
            ])
        else:
            modified_instructions.append(instruction)
    modified_bytecode = Bytecode(modified_instructions)
    # For functions, copy over argument definitions
    modified_bytecode.argnames = decompiled.argnames
    modified_bytecode.argcount = decompiled.argcount
    modified_bytecode.name = decompiled.name
    return modified_bytecode.to_code()

我们现在可以让我们之前定义的invalid_format 函数工作:

>>> invalid_format.__code__ = formathack_rewrite_bytecode__(invalid_format.__code__)
>>> invalid_format("bar")
'bar formatted with foo'

成功!手动诅咒带有受污染字节码的代码对象本身不会使我们的灵魂遭受永恒的痛苦;为此,我们应该自动操作所有代码。

第 2 部分:挂钩到导入过程

为了让新的 f-string 行为无处不在,而不仅仅是在手动修补的函数中,我们可以使用标准库 importlib 模块提供的功能,使用自定义模块查找器和加载器自定义 Python 模块导入过程:

class _FormatHackLoader(importlib.machinery.SourceFileLoader):
    """
    A module loader that modifies the code of the modules it imports to override
    the behavior of f-strings. Nasty stuff.
    """
    @classmethod
    def find_spec(cls, name, path, target=None):
        # Start out with a spec from a default finder
        spec = importlib.machinery.PathFinder.find_spec(
            fullname=name,
             # Only apply to modules and packages in the current directory
             # This prevents standard library modules or site-packages
             # from being patched.
            path=[""],
            target=target
        )
        if spec is None:
            return None
        
        # Modify the loader in the spec to this loader
        spec.loader = cls(name, spec.origin)
        return spec

    def get_code(self, fullname):
        # This is called by exec_module to get the code of the module
        # to execute it.
        code = super().get_code(fullname)
        # Rewrite the code to modify the f-string formatting opcodes
        rewritten_code = formathack_rewrite_bytecode__(code)
        return rewritten_code

    def exec_module(self, module):
        # We introduce the callback that hooks into the f-string formatting
        # process in every imported module
        module.__dict__["formathack_hook__"] = formathack_hook__
        return super().exec_module(module)

为了确保 Python 解释器使用这个加载器来导入所有文件,我们必须将它添加到 sys.meta_path

def install():
    # If the _FormatHackLoader is not registered as a finder,
    # do it now!
    if sys.meta_path[0] is not _FormatHackLoader:
        sys.meta_path.insert(0, _FormatHackLoader)
        # Tricky part: we want to be able to use our custom f-string behavior
        # in the main module where install was called. That module was loaded
        # with a standard loader though, so that's impossible without additional
        # dirty hacks.
        # Here, we execute the module _again_, this time with _FormatHackLoader
        module_globals = inspect.currentframe().f_back.f_globals
        module_name = module_globals["__name__"]
        module_file = module_globals["__file__"]
        loader = _FormatHackLoader(module_name, module_file)
        loader.load_module(module_name)
        # This is actually pretty important. If we don't exit here, the main module
        # will continue from the formathack.install method, causing it to run twice!
        sys.exit(0)

如果我们将它们放在一个 formathack 模块中(请参阅 https://github.com/mivdnber/formathack 以获取集成的工作示例),我们现在可以像这样使用它:

# In your main Python module, install formathack ASAP
import formathack
formathack.install()

# From now on, f-string behavior will be overridden!

print(f"{foo:bar}")
# -> "foo formatted with bar"

原来如此!您可以对此进行扩展以使钩子函数更加智能和有用(例如,通过注册处理某些格式说明符的函数)。

【讨论】:

  • “它们肯定不会在 PyPy 等替代 Python 实现中工作。”你能试一试吗? PyPy 似乎具有相同的字节码格式,至少在运行时是这样;他们的 JIT 仅适用于 字节码,它不会替换它。所以这很有可能在 PyPy 中起作用。
  • @MisterMiyagi 酷,我不知道!我刚刚使用 PyPy 7.3.5 (3.7.10) 对其进行了测试,但它似乎确实失败了,因为那里没有 dis.stack_effect。尽管如此,“绝对行不通”还是言过其实,所以我会编辑答案。
猜你喜欢
  • 2023-03-11
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-07-26
  • 2017-12-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多