将代码嵌入到Python内置的f-string格式化机制中

31

概要

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

有段时间以来,我一直有一个小型库的想法,如下所述,用于进一步利用它们。这里有一个快速的示例来说明我想让它做什么:

>>> 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-strings无效:

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

细节

目前 我的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-string功能呢?

* 注意:我可能永远不会真正实现这个库!但是我认为这是一个很棒的想法,欢迎任何想要偷走它的人 :)。


5
你能写出 f"{spec(x)}" 吗? - Davis Herring
3
请查看我的进展:https://stackoverflow.com/questions/61187996/how-can-i-parse-pythons-triple-quote-f-strings?noredirect=1#comment108245877_61187996 - HappyFace
3
关于 f("{x:spec}"),可以使用 string.Formatter 以及 inspect.currentframe().f_back .f_locals.f_globals(参见 https://docs.python.org/3/library/inspect.html 和 https://dev59.com/HGw15IYBdhLWcg3wbbNx)?您还可以使用运算符,例如 @ 来改进语法 (f@"{x:spec}")。 - Solomon Ucko
1
@SolomonUcko 这个 f@ 的想法相当有创意!缺点是:没有前导的 f,IDE 将不知道字符串包含表达式。但这几乎不会成为阻碍。 - Rick
1
@RicksupportsMonica 感谢您的见解。就个人而言,我对提供更好的语言互操作性这样的事情很感兴趣。我有一个模块,可以提供Python和另一种语言之间的转换,并且有一个函数可以接受来自该语言的代码字符串进行评估。例如,如果另一种语言被称为“v”,那么如果我能编写类似于v_eval(f'<v lang code>{x!v}<more v code>')的东西就太好了,其中x是Python变量,它被插入到v-lang代码中,即通过转换它并提供某些v理解的参考。 - Will Da Silva
显示剩余8条评论
1个回答

30

概述

你可以这样做,但只有在编写邪恶代码时才应该这样做,而这些代码可能永远不会出现在生产软件中。所以让我们开始吧!

我不会将其集成到您的库中,但我会向您展示如何钩入f-strings的行为。大致工作原理如下:

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

您可以在https://github.com/mivdnber/formathack上获取完整的源代码,但下面的内容已经对其进行了解释。

免责声明

此解决方案并不完美,因为:

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

然而,这确实是一个解决方案,并且在流行的包(如PonyORM)中已成功使用了字节码操作。只需记住它有点破解,复杂而且可能需要大量维护。

第一部分:字节码操作

Python代码不直接执行,而是首先编译为更简单的中间非人类可读的基于堆栈的语言,称为Python字节码(它在*.pyc文件中)。您可以使用标准库dis模块来检查一个简单函数的字节码,以了解该字节码的外观:

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包,该包提供了非常好的抽象,可以用来执行糟糕的操作。

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'

成功!手动对带有污染字节码的代码对象进行诅咒并不会让我们的灵魂永世受苦;为此,我们应该自动操作所有代码。

第二部分:钩入导入过程

为了使新的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"

所以就是这样了!你可以扩展它,让钩子函数变得更加智能和有用(例如,通过注册处理特定格式说明符的函数)。

2
“它们肯定不会在PyPy等替代Python实现中工作。”你能试一下吗?PyPy似乎具有相同的字节码格式,至少在运行时;他们的JIT仅使用字节码,而不是替换它。因此,在PyPy中很有可能可以工作。 - MisterMiyagi
2
@MisterMiyagi 很酷,我不知道这个!我刚刚用 PyPy 7.3.5 (3.7.10) 进行了测试,似乎因为 dis.stack_effect 在那里不可用而失败了。尽管如此,“绝对不会工作”是一个夸张的说法,所以我会编辑答案。 - Michilus
1
稍后再回来看这个,它非常美丽,真的需要成为PyCon演讲。 - Rick

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接