python - 挂钩到内置的 python f-string 格式机制
问题描述
概括
我真的很喜欢f 弦。它们是非常棒的语法。
一段时间以来,我有一个小图书馆的想法- 如下所述* - 以进一步利用它们。我想要它做的一个简单的例子:
>>> 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
细节
目前我的 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 字符串的行为。这大致是它的工作方式:
- 编写一个操作代码对象的字节码指令的函数,将指令替换
FORMAT_VALUE
为对钩子函数的调用; - 自定义导入机制以确保每个模块和包(标准库模块和站点包除外)的字节码都使用该函数进行修改。
您可以在https://github.com/mivdnber/formathack获得完整的源代码,但下面将解释所有内容。
免责声明
这个解决方案不是很好,因为
- 根本无法保证这不会破坏完全不相关的代码;
- 无法保证此处描述的字节码操作将在较新的 Python 版本中继续工作。它绝对不适用于不编译为 CPython 兼容字节码的替代 Python 实现。PyPy 理论上可以工作,但这里描述的解决方案不能,因为字节码包不是 100% 兼容的。
然而,它是一种解决方案,字节码操作已成功用于PonyORM等流行包中。请记住,它很hacky,复杂并且可能需要大量维护。
第 1 部分:字节码操作
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}"
为了替换指令,我使用了字节码包,它为做可怕的事情提供了非常好的抽象。
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"
就是这样!您可以对此进行扩展以使钩子函数更加智能和有用(例如,通过注册处理某些格式说明符的函数)。
推荐阅读
- python - 我需要将原始 MySQL 查询转换为 Django ORM 查询
- plotly.js - 如何在 plotlyjs 轴刻度标签中使用/显示图像
- python - Python-pandas - Datetimeindex:分析滚动滚动的最常用的 Python 策略是什么?(例如每天的特定时间)
- angular - 将数据从父母传递给孩子不能有条件地工作 - Angular 7/8
- r - 如何将矩阵写入文本文件?
- java - 如何使用 JPA CriteriaBuilder selectCase() 以便它可以有 Predicate 作为结果?
- scheduled-tasks - 是否可以安排刷新数据集的不同部分?
- javafx - 如何将多个属性绑定到 JavaFX 中的一个可观察对象
- mysql - Codeigniter 查询,用于选择多列的总和,每行具有 where 条件
- vue.js - 使用现有数据 VueJ 填充动态输入框