Lista de Conteúdos
Introdução
Não se assuste com as palavras no título. Embora possam ser estranhas para você provavelmente em algum momento você utilizou ferramentas que fazem uso de técnicas de metaprogramação ou inspeção de AST. Pytest e Numba são exemplos.
No post anterior eu falei sobre python frames e inspection..
Mostrei como podemos usar inspect.signautre
para criar um decorador que valide argumentos:
@math_validator()
def simple_method(x: "\in R", y: "\in R_+", z: float = 2) -> float:
...
simple_method(1, 0)
simple_method((1, 2)) -> 1.5
---> 19 simple_method(1, 0)
...
<locals>.decorate.<locals>.decorated(*_args)
11 continue
13 if not MATH_SPACES[annotation]["validator"](_args[i]):
---> 14 raise ValueError(f"{k} doesn't belong to the {MATH_SPACES[annotation]['name']}")
15 result = func(*_args)
16 print(f"{func.__name__}({_args}) -> {result}")
ValueError: y doesn't belong to the space of real numbers greater than zero
No outro exemplo mostrei como podemos combinar o signature
com sys.trace
para criar um decorador que expõe o locals
da função decorada. O que nos permite fazer coisas legais tais como criar um decorador @report
@report('{arg.n_bananas} Monkey {gluttonous_monkey} ate too much bananas. Num monkeys {num_monkeys}')
def feed_monkeys(n_bananas):
num_monkeys = 3
monkeys = {
f"monkey_{i}": {"bananas": 0}
for i in range(num_monkeys)
}
while n_bananas > 0:
if np.random.uniform() < 0.4:
continue
monkey = monkeys[np.random.choice(list(monkeys.keys()))]
if n_bananas > 0:
monkey["bananas"] += 1
n_bananas -= 1
gluttonous_monkey = max(monkeys, key=lambda k: monkeys[k]["bananas"])
Contudo, no final do post passado eu disse que essa solução tem alguns problemas
Click here to see the solution
import sys
import inspect
from types import SimpleNamespace
def call_and_extract_frame(func, *args, **kwargs):
frame_var = None
trace = sys.gettrace()
def update_frame_var(stack_frame, event_name, arg_frame):
"""
Args:
stack_frame: (frame)
The current stack frame.
event_name: (str)
The name of the event that triggered the call.
Can be 'call', 'line', 'return' and 'exception'.
arg_frame:
Depends on the event. Can be a None type
"""
nonlocal frame_var # nonlocal is a keyword which allows us to modify the outisde scope variable
if event_name != 'call':
return trace
frame_var = stack_frame
sys.settrace(trace)
return trace
sys.settrace(update_frame_var)
try:
func_result = func(*args, **kwargs)
finally:
sys.settrace(trace)
return frame_var, func_result
def report(formater):
def decorate(func):
def decorated(*_args):
sig = inspect.signature(func)
named_args = {}
num_args = len(_args)
for i, (k, v) in enumerate(sig.parameters.items()):
if i < num_args:
named_args[k] = repr(_args[i])
else:
named_args[k] = repr(v.default)
frame_func, _result = call_and_extract_frame(func, *_args)
name = func.__name__
result = repr(_result)
args_dict = {
"args": SimpleNamespace(**named_args),
"args_repr": repr(SimpleNamespace(**named_args)),
**locals(),
**frame_func.f_locals,
}
print(formater.format(**args_dict))
# do other stuff here
return _result
return decorated
return decorate
-
É esperado que o tracing reduza a performance do sistema. Se você usar a solução acima só para casos pontuais ou debug é ok
-
Pode criar conflitos com outras ferramentas e bibliotecas que também estão usando a ferramenta de tracing, tais como debuggers.
-
Parece uma solução feia!
Você pode se perguntar: “Overengineering! Era só esse fazer isso aqui:”
@report('stuff goes here')
def func(x, y):
random_var = np.random.uniform()
... #more local vars
result = (x+y)**random_var
return result, locals
"..e dentro do decorador ele mudar para isso"
_result, local_vars = func(x, y)
A razão é:
O ponto de usar um decorador é para evitar mudanças em qualquer outra parte da nossa codebase. Por exemplo, se em qualquer outra parte da nossa codebase func está sendo chamada, eu teria que fazer mudanças do tipo
result = func(x, y) # to
result = func(x, y)[0]
E se futuramente eu quisesse remover o decorador de uma função eu teria que desfazer todas as mudanças acima
-
Você irá aumentar o cognitive load de todos os membros do seu time que não precisam saber sobre ou usar o decorador.
-
Se você está ok com fazer mudanças em outros lugares do seu código por que não criar novas funções ao invés de decoradores que funcionam mais ou menos?
Ok, você pode estar pensando: “Tá , faz sentido não fazer isso que sugeri, mas do que adianta evitar sujar sua codebase se você tá criando problemas de desempenho e debug? Não parece uma boa solução na maioria dos casos.” Eu tenho que concordar com você!
Bom, então o que podemos fazer?? O problema que encontramos é que em python não temos context managers que podem lidar com namespaces https://mail.python.org/archives/list/python-ideas@python.org/. Mas não se desanime com essa limitação, a questão agora é:
Em pyhton estamos bem com isso pois é fácil manipular o que é conhecido como Abstract Syntax Tree (árvore sintática abstrata) e compilar ela em uma nova função em tempo de execução (runtime). ** Quando programamos desse jeito estamos no reino da metaprogramação! Tentarei esclarecer esses pontos agora**
ASTs: O que são?
Uma linguagem de programação é obviamente, pelo menos uma linguagem… OK, mas o que é uma linguagem? Todas as linguagens humanas compartilham uma estrutura em comum? Como podemos comparar sentenças diferentes na mesma linguagem? Essas questões talvez pareçam ser mais adequadas para serem respondidas por filósofos. Contudo, também é tema de trabalho de matemáticos e computeiros
A grande diferença é que matemáticos e computeiros comumente preferem falar sobre coisas usando algum formalismo matemático. Em essência, AST faz parte de um formalismo matemático que permite isso. Uma AST permite representar uma sentença através de um grafo direcionado do tipo árvore. Para isso usamos um conjunto de regras bem definidas em como construir essa árvore.
Como saber se uma sentença está gramaticalmente correta?
Você provavelmente se lembra quase institivamente de um conjunto de regras que aprendeu durante sua vida ou acabou se acostumando sobre como organizar e compor verbos, substantivos, adjetivos, etc. Este conjunto de regras e guias é a sintaxe da linguagem que você fala/escreve. ASTs permitem checar e compreender uma sentença utilizando essas regras
Pegue por exemplo a sentença
“I drive a car to my college”, a AST é a seguinte
Qual a vantagem de usar ASTs? Note que não precisamos falar de espaços, caligrafia ou estilo pessoal de organizar escrita para compreender uma sentença e saber se ela está válida. Além disso, temos uma estrutura hierárquica que permite entender a sentença por níveis!
Não é uma surpresa que ASTs são também uma ferramenta comum em processos de analisar a validade de um código ou na construção de um compilador/interpretador. Nesse post iremos manipular a AST! Mas antes disso quero fazer uma pergunta:
Python é interpretado ou compilado?
Geralmente, quando encontro um hater de python ou mesmo um entusiasta ouço ou leio coisas do tipo:
- “Python é lento pois é uma linguagem interpretada”
- “*Python é legal pois não tem chatice de compilação”
- “Python é ruim comparado a C pois não tem um compilador”
Bem, essas asserções não são verdadeiras, pois estão usando conceitos errados! Outra confusão é que geralmente quando se fala em python estamos nos referindo a linguagem (sintaxe, etc) python mais a máquina virtual do CPython. Vamos conversar um pouco mais sobre isso
Dizer que uma linguagem hoje é puramente compilada ou interpretada é confuso, pois essa divisão é borrada. Veja o seguinte
hello_world = "print('Hello, world!')"
hello_world_obj = compile(hello_world, '<string>', 'single')
Pois é… se você tentaria defender nos comentários que python é puramente interpretado as coisas estão mais difíceis para você. Por que tem um compile
disponível? O que ele faz?
exec(hello_world_obj)
Hello, world!
O que será que tem dentro desse hello_world_obj
?
print(f"Bad news for you:\n\tContent: {hello_world_obj.co_code}\n\tType: {type(hello_world_obj.co_code)}")
Bad news for you:
Content: b'e\x00d\x00\x83\x01F\x00d\x01S\x00'
Type: <class 'bytes'>
Para entender os prints acima você precisa compreender o que acontece por trás dos panos quando um código python é “interpretado”.
Após você escrever um código e chamar o comando python, o python inicia um processo de compilação criando as ASTs, depois gerando bytecodes a partir das ASTs e esses últimos serão encapsulados em code_object
s. Na última etapa os code objects serão interpretados pela máquina virtual do CPython. O diagrama à baixo é uma representação simples (com passos omitidos) do processo
A fase de compilação são os primeiros passos do diagrama acima
Ok, onde isso entra na minha solução? O que eu proponho fazer é manipular a AST e compilar um novo code object que será interpretado pelo cpython!
Extraindo e interpretando ASTs
Veja o seguinte exemplo:
import inspect
import ast
import astor # install this for pretty printing
def example(a: float, b:float = 2) -> float:
s = a+b
return s
tree = ast.parse(inspect.getsource(example))
print(astor.dump(tree))
astor.to_source(tree)
Module(
body=[
FunctionDef(name='example',
args=arguments(posonlyargs=[],
args=[arg(arg='a', annotation=Name(id='float'), type_comment=None),
arg(arg='b', annotation=Name(id='float'), type_comment=None)],
vararg=None,
kwonlyargs=[],
kw_defaults=[],
kwarg=None,
defaults=[Constant(value=2, kind=None)]),
body=[
Assign(targets=[Name(id='s')],
value=BinOp(left=Name(id='a'), op=Add, right=Name(id='b')),
type_comment=None),
Return(value=Name(id='s'))],
decorator_list=[],
returns=Name(id='float'),
type_comment=None)],
type_ignores=[])
O output acima é a AST da função. Gaste algum tempo olhando essa saída e tente entender/inferir o que cada coisa significa e como ela é organizada. A imagem abaixo é a representação visual da saída acima
Cada elemento do output que inicia com uma letra maiúscula é um nó, node(Name, BinOp, FunctionDef, etc) derivado da classe ast.Node
. Um dos nós mais importante é o ast.Name
.
Por exemplo em
value=BinOp(left=Name(id='a'), op=Add, right=Name(id='b')),
o ast.Name(...
é usado para referenciar as variáveis a
e b
da nossa função.
Ok, voltemos ao nosso problema. Lembre-se que uma solução ruim era reescrever cada função que precisa ser decorada, por exemplo
def func(x, y):
random_var = np.random.uniform()
... #more local vars
result = (x+y)**random_var
return result
como
def func_transformed(x, y):
random_var = np.random.uniform()
... #more local vars
result = (x+y)**random_var
return result, locals
A coisa legal que faremos aqui é escrever uma função que escrevera essas mudanças para nós! E depois colocaremos a compilação dentro de um decorador para evitar que nossa codebase seja alterada.
Como metaprogramar de forma eficiente?
Fazer um código que faça as alterações desejadas na nossa AST pode ser trabalhoso. Como começar a ter uma ideia do que precisa ser feito? Eu penso em uma sucessão de 6 passos e ir iterando para melhorar
6 passos simples
- Criar uma função exemplo (A)
- Codar uma função transformada do jeito que queremos que ela seja (B)
- Escrever um teste para que possa ser usado posteriormente para
checar se nossa função transformada (B) bate com a função gerada pela meta-programação (C) - Extrair a AST de A e B
- Comparar as ASTs. O que elas diferem? Anote as diferenças
- Você pode usar a
difflib
do python para fazer isso
- Você pode usar a
- Criar uma nova e mais complexa função exemplo (A) e repetir o processo até termos uma boa ideia das modificações necessárias na AST
Criando nossa meta-função
Primeira iteração
Começaremos escrevendo uma função incrivelmente simples
def example_1(x, y):
internal_var = 222
result = (x+y)**internal_var
return result
def example_1_expected(x, y):
internal_var = 222
result = (x+y)**internal_var
return result, locals()
def test_meta_example_1(meta_func, x, y):
expected_result, expected_locals = example_1_expected(x, y)
result, locals_dict = meta_func(x, y)
assert result == expected_result
assert expected_locals == locals_dict
Agora usaseri a difflib
para entender as diferenças entre as duas ASTs.
import difflib
from pprint import pprint
example_1_ast_str = astor.dump_tree(ast.parse(inspect.getsource(example_1)))
example_1_expected_str = astor.dump_tree(ast.parse(inspect.getsource(example_1_expected)))
pprint(
list(
difflib.unified_diff(example_1_ast_str.splitlines(), example_1_expected_str.splitlines(), n=0)
)
)
['--- \n',
'+++ \n',
'@@ -3 +3 @@\n',
"- FunctionDef(name='example_1',",
"+ FunctionDef(name='example_1_expected',",
'@@ -19 +19 @@\n',
"- Return(value=Name(id='result'))],",
"+ Return(value=Tuple(elts=[Name(id='result'), "
"Call(func=Name(id='locals'), args=[], keywords=[])]))],"]
Com o output acima sabemos aogra que precisaremos mudar o seguinte nó na AST
Return(value=Name(id='result'))],
para isto
Return(value=Tuple(elts=[Name(id='result'), Call(func=Name(id='locals'), args=[], keywords=[])]))],
Como alterar nós na AST? Com a ajuda do NodeTransformer
O NodeTransformer
O ast.NodeTransformer
nos permite criar objetos com uma interface de caminhante. O caminhante visitará cada Node da AST e durante cada visita ele pode remover, substituir, modificar ou adicionar Nodes. Após fazer essas alterações o caminhante pode continuar sua caminhada nos filhos do Node ou apenas parar.
Vamos iniciar criando uma classe derivada de ast.NodeTransformer
class ASTTransformer(ast.NodeTransformer):
def visit_Return(self, node):
Se queremos interagir com um nó do tipo AlgumaCoisa
precisamos sobrescrever o método visit_AlgumaCoisa
.
Portanto, como sabemos que precisamos mudar o Return
iremos sobrescrever o visit_Return
. Precisaremos criar também um nó para pegar o locals
. Esse nó é o Call
class ASTTransformer(ast.NodeTransformer):
def visit_Return(self, node):
node_locals = ast.Call(
func=ast.Name(id='locals', ctx=ast.Load()),
args=[], keywords=[]
)
self.generic_visit(node)
return node
Veja que usamos o nó Name
para identificar a função locals
. Agora, de acordo com o resultado do nosso diff o resultado do Return
precisa ser uma nó do tipo Tuple
class ASTTransformer(ast.NodeTransformer):
def visit_Return(self, node):
node_locals = ast.Call(
func=ast.Name(id='locals', ctx=ast.Load()),
args=[], keywords=[]
)
new_node.value = ast.Tuple(
elts=[
node.value,
node_locals
],
ctx=ast.Load()
)
self.generic_visit(new_node)
return new_node
Uma nova coisa apareceu. O argumento elts
. Não se preoucupe em entender tudo. Mas o elts
é um arg que diz qual é a lista de nós que a Tupla deve conter. Toda vez que você quiser entender um pouco mais sobre ASTs e a gramática do python você pode consultar a documentação oficial
aqui.
Quase tudo pronto. A última coisa que precisamos fazer é corrigir nossa AST. Pois ao alterar o Node precisamos preencher/corrigir as informações de line_number e column_offest. O python torna isso fácil com o método fix_missing_locations
class ASTTransformer(ast.NodeTransformer):
def visit_Return(self, node):
new_node = node
node_locals = ast.Call(
func=ast.Name(id='locals', ctx=ast.Load()),
args=[], keywords=[]
)
new_node.value = ast.Tuple(
elts=[
node.value,
node_locals
],
ctx=ast.Load()
)
ast.copy_location(new_node, node)
ast.fix_missing_locations(new_node)
self.generic_visit(new_node)
return new_node
Ok, vamos ver se funcionou. Para isso, precisamos instanciar nosso transformer e chamar o método visit
que diz para o caminhante iniciar a caminhada e fazer as modificações pedidas
tree_meta = ast.parse(inspect.getsource(example_1))
transformer = ASTTransformer()
transformer.visit(tree_meta)
example_1_meta_ast_str = astor.dump_tree(tree_meta)
example_1_expected_str = astor.dump_tree(ast.parse(inspect.getsource(example_1_expected)))
pprint(
list(
difflib.unified_diff(example_1_meta_ast_str.splitlines(), example_1_expected_str.splitlines(), n=0)
)
)
['--- \n',
'+++ \n',
'@@ -3 +3 @@\n',
"- FunctionDef(name='example_1',",
"+ FunctionDef(name='example_1_expected',"]
Funcionou! Vamos adicionar um pouco mais de complicação para ver se o NodeTransformer continuará funcionando.
A segunda iteração
Seja criativo na hora de complicar, eu fiz isso aqui é feio mais adiciona muita confusão para estressar o NodeTransformer.
def example_2(x, y):
internal_var = 222
def sub(x, y):
ommit_this_var = 1
return x - y
result = sub(x,y)**internal_var
return (result, False)
def example_2_expected(x, y):
internal_var = 222
def sub(x, y):
ommit_this_var = 1
return x - y
result = sub(x,y)**internal_var
return ((result, False), locals())
def test_meta_example_2(meta_func, x, y):
expected_result, expected_locals = example_2_expected(x, y)
result, locals_dict = meta_func(x, y)
del locals_dict["sub"]
del expected_locals["sub"]
assert result == expected_result
assert expected_locals == locals_dict
example_2_ast_str = astor.dump_tree(ast.parse(inspect.getsource(example_2)))
example_2_expected_str = astor.dump_tree(ast.parse(inspect.getsource(example_2_expected)))
pprint(
list(
difflib.unified_diff(example_2_ast_str.splitlines(), example_2_expected_str.splitlines(), n=0)
)
)
['--- \n',
'+++ \n',
'@@ -3 +3 @@\n',
"- FunctionDef(name='example_2',",
"+ FunctionDef(name='example_2_expected',",
'@@ -37 +37,4 @@\n',
"- Return(value=Tuple(elts=[Name(id='result'), "
'Constant(value=False, kind=None)]))],',
'+ Return(',
'+ value=Tuple(',
"+ elts=[Tuple(elts=[Name(id='result'), "
'Constant(value=False, kind=None)]),',
"+ Call(func=Name(id='locals'), args=[], "
'keywords=[])]))],']
Agora é hora de cruzar os dedos e esperar que continue funcionando
tree_meta = ast.parse(inspect.getsource(example_2))
transformer = ASTTransformer()
transformer.visit(tree_meta)
example_2_meta_ast_str = astor.dump_tree(tree_meta)
example_2_expected_str = astor.dump_tree(ast.parse(inspect.getsource(example_2_expected)))
pprint(
list(
difflib.unified_diff(example_2_meta_ast_str.splitlines(), example_2_expected_str.splitlines(), n=0)
)
)
['--- \n',
'+++ \n',
'@@ -3 +3 @@\n',
"- FunctionDef(name='example_2',",
"+ FunctionDef(name='example_2_expected',",
'@@ -27,4 +27 @@\n',
'- Return(',
'- value=Tuple(',
"- elts=[BinOp(left=Name(id='x'), op=Sub, "
"right=Name(id='y')),",
"- Call(func=Name(id='locals'), args=[], "
'keywords=[])]))],',
"+ Return(value=BinOp(left=Name(id='x'), op=Sub, "
"right=Name(id='y')))],"]
Falhou miseravelmente. Qual é o problema? Se você olhar o diff
com cuidado verá que o NodeTransformer
alterou a função interna. Não queremos isso. Portanto, diremos para o caminhante evitar modificar se estiver em uma função interna. Para isso, precisamos sobrescrever o método visit_FunctionDef
e criar uma flag para marcar em que nível o caminhante está
class ASTTransformer(ast.NodeTransformer):
def visit_FunctionDef(self, node):
if self._sub:
return node
self._sub = True
self.generic_visit(node)
return node
def visit_Module(self, node):
self._sub = 0
self.generic_visit(node)
def visit_Return(self, node):
new_node = node
node_locals = ast.Call(
func=ast.Name(id='locals', ctx=ast.Load()),
args=[], keywords=[]
)
new_node.value = ast.Tuple(
elts=[
node.value,
node_locals
],
ctx=ast.Load()
)
ast.copy_location(new_node, node)
ast.fix_missing_locations(new_node)
self.generic_visit(new_node)
return new_node
tree_meta = ast.parse(inspect.getsource(example_2))
transformer = ASTTransformer()
transformer.visit(tree_meta)
example_2_meta_ast_str = astor.dump_tree(tree_meta)
example_2_expected_str = astor.dump_tree(ast.parse(inspect.getsource(example_2_expected)))
pprint(
list(
difflib.unified_diff(example_2_meta_ast_str.splitlines(), example_2_expected_str.splitlines(), n=0)
)
)
['--- \n',
'+++ \n',
'@@ -3 +3 @@\n',
"- FunctionDef(name='example_2',",
"+ FunctionDef(name='example_2_expected',"]
Tudo ok! Próximo passo: compilar nossa ast.
Criando uma nova função em runtime
O que faremos agora é compilar a AST transformada e associa-la com uma nova função. Em python podemos fazer isso em tempo de execução com type.FunctionType
from types import FunctionType, CodeType
def transform_and_compile(func: FunctionType)->FunctionType:
source = inspect.getsource(func)
# we put this to remove the line from source code with the decorator
source = "\n".join([l for l in source.splitlines() if not l.startswith("@")])
tree = ast.parse(source)
transformer = ASTTransformer()
transformer.visit(tree)
code_obj = compile(tree, func.__code__.co_filename, 'exec')
function_code = [c for c in code_obj.co_consts if isinstance(c, CodeType)][0]
# we must to pass the globals context to the function
transformed_func = FunctionType(function_code, func.__globals__)
return transformed_func
test_meta_example_1(transform_and_compile(example_1), 4, 2)
test_meta_example_2(transform_and_compile(example_2), 1, 2)
Veja que transform_and_compile
foi capaz de criar novas funções que passaram nos testes que escrevemos nas iterações anteriores! Agora é o passo final e mais fácil desse post. Integrar com o decorador.
Integrando a manipulação de AST com um decorador
O que faremos é chamar transform_and_compile
logo após o def decorate
para evitar compilações desnecessárias toda vez que chamarmos a função decorada
def report(fmt):
def decorate(func):
meta_func = transform_and_compile(func)
....
Agora, dentro de def decorated
podemos chamar a meta_func
e retornar só o resultado pois não queremos mudar nossa codebase
def report(fmt):
def decorate(func):
meta_func = transform_and_compile(func)
...
def decorated(*_args):
_result, internal_locals = meta_func(*_args)
....
return _result
Com todas as coisas que fizemos no post nosso decorador report
está pronto para ser usado
def report(fmt):
def decorate(func):
meta_func = transform_and_compile(func)
sig = inspect.signature(func)
def decorated(*_args):
_result, internal_locals = meta_func(*_args)
named_args = {}
num_args = len(_args)
for i, (k, v) in enumerate(sig.parameters.items()):
if i < num_args:
named_args[k] = repr(_args[i])
else:
named_args[k] = repr(v.default)
name = func.__name__
result = repr(_result)
args_dict = {
**internal_locals,
**locals(),
**named_args
}
print(fmt.format(**args_dict))
# store the information in some place
return result
return decorated
return decorate
Veja o resultado em uma função bem simples
@report(fmt='{name}(a={a}, b={b}, c={c}); sum_ab {sum_ab}, diff_ab {dif_ab}; r={result}')
def dummy_example(a, b, c=2):
sum_ab = a + b
dif_ab = a - b
r = sum_ab**c + dif_ab**c
return r
r = dummy_example(2, 3, 1)
print("r:", r)
dummy_example(a=2, b=3, c=1); sum_ab 5, diff_ab -1; r=4
r: 4
Eu sei que esse post pode ter sido difícil, se tiver dúvida você pode entrar em contato comigo pelos comentários abaixo, twitter ou linkedin. Compartilhe se você gostou.