Skip to content

Commit 1277a4a

Browse files
TrevorBergeronchalmerlowe
authored andcommitted
chore(bigframes): Add simple Python bytecode translation (#17320)
1 parent 8dd9661 commit 1277a4a

4 files changed

Lines changed: 720 additions & 2 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import dis
16+
import operator
17+
import sys
18+
from types import ModuleType
19+
from typing import Callable
20+
21+
import bigframes.core.py_expressions as py_exprs
22+
from bigframes.core import expression
23+
24+
_BINARY_OP_MAP = {
25+
"+": operator.add,
26+
"-": operator.sub,
27+
"*": operator.mul,
28+
"/": operator.truediv,
29+
"//": operator.floordiv,
30+
"%": operator.mod,
31+
"**": operator.pow,
32+
}
33+
34+
_COMPARE_OP_MAP = {
35+
"==": operator.eq,
36+
"!=": operator.ne,
37+
"<": operator.lt,
38+
"<=": operator.le,
39+
">": operator.gt,
40+
">=": operator.ge,
41+
}
42+
43+
_OLD_BINARY_OP_MAP = {
44+
"BINARY_ADD": operator.add,
45+
"INPLACE_ADD": operator.add,
46+
"BINARY_SUBTRACT": operator.sub,
47+
"INPLACE_SUBTRACT": operator.sub,
48+
"BINARY_MULTIPLY": operator.mul,
49+
"INPLACE_MULTIPLY": operator.mul,
50+
"BINARY_TRUE_DIVIDE": operator.truediv,
51+
"INPLACE_TRUE_DIVIDE": operator.truediv,
52+
"BINARY_FLOOR_DIVIDE": operator.floordiv,
53+
"INPLACE_FLOOR_DIVIDE": operator.floordiv,
54+
"BINARY_MODULO": operator.mod,
55+
"INPLACE_MODULO": operator.mod,
56+
"BINARY_POWER": operator.pow,
57+
"INPLACE_POWER": operator.pow,
58+
}
59+
60+
61+
_NULL = py_exprs.PyObject(None)
62+
63+
64+
def _compile_bytecode_to_py_expr(func: Callable) -> expression.Expression:
65+
instructions = list(dis.get_instructions(func))
66+
67+
stack: list[expression.Expression] = []
68+
globals_dict = func.__globals__
69+
import builtins
70+
71+
builtins_dict = builtins.__dict__
72+
73+
closure_dict = {}
74+
if func.__closure__:
75+
free_vars = func.__code__.co_freevars
76+
for var, cell in zip(free_vars, func.__closure__):
77+
try:
78+
closure_dict[var] = cell.cell_contents
79+
except ValueError:
80+
pass
81+
82+
for inst in instructions:
83+
opname = inst.opname
84+
85+
if opname in ("RESUME", "PRECALL"):
86+
continue
87+
88+
elif opname in ("LOAD_FAST_LOAD_FAST", "LOAD_FAST_BORROW_LOAD_FAST_BORROW"):
89+
var1, var2 = inst.argval
90+
stack.append(expression.UnboundVariableExpression(var1))
91+
stack.append(expression.UnboundVariableExpression(var2))
92+
93+
elif opname.startswith("LOAD_FAST"):
94+
stack.append(expression.UnboundVariableExpression(inst.argval))
95+
96+
elif opname in ("LOAD_CONST", "LOAD_SMALL_INT"):
97+
stack.append(py_exprs.PyObject(inst.argval))
98+
99+
elif opname == "LOAD_GLOBAL":
100+
# In Python 3.11+, the lowest bit of inst.arg indicates that a NULL
101+
# should be pushed before the global variable.
102+
if sys.version_info >= (3, 11) and inst.arg is not None and (inst.arg & 1):
103+
stack.append(_NULL)
104+
name = inst.argval
105+
found = False
106+
val = None
107+
if name in closure_dict:
108+
val = closure_dict[name]
109+
found = True
110+
elif name in globals_dict:
111+
val = globals_dict[name]
112+
found = True
113+
elif name in builtins_dict:
114+
val = builtins_dict[name]
115+
found = True
116+
117+
if found:
118+
if isinstance(val, ModuleType):
119+
stack.append(py_exprs.Module(val))
120+
else:
121+
stack.append(py_exprs.PyObject(val))
122+
else:
123+
stack.append(expression.UnboundVariableExpression(name))
124+
125+
elif opname in ("LOAD_ATTR", "LOAD_METHOD"):
126+
if not stack:
127+
raise ValueError("Stack is empty")
128+
target = stack.pop()
129+
stack.append(py_exprs.GetAttr(target, inst.argval))
130+
if opname == "LOAD_METHOD":
131+
if isinstance(target, py_exprs.Module):
132+
stack.append(_NULL)
133+
else:
134+
stack.append(target)
135+
136+
elif opname == "PUSH_NULL":
137+
stack.append(_NULL)
138+
139+
elif opname == "BINARY_OP":
140+
if len(stack) < 2:
141+
raise ValueError("Stack is empty")
142+
right = stack.pop()
143+
left = stack.pop()
144+
op_symbol = inst.argrepr
145+
if not op_symbol and isinstance(inst.argval, str):
146+
op_symbol = inst.argval
147+
if op_symbol and op_symbol.endswith("="):
148+
op_symbol = op_symbol[:-1]
149+
150+
if op_symbol not in _BINARY_OP_MAP:
151+
raise ValueError(f"Unsupported binary operator: {op_symbol}")
152+
stack.append(
153+
py_exprs.Call(
154+
py_exprs.PyObject(_BINARY_OP_MAP[op_symbol]), (left, right)
155+
)
156+
)
157+
158+
# Support older Python versions compatibility
159+
elif opname in _OLD_BINARY_OP_MAP:
160+
if len(stack) < 2:
161+
raise ValueError("Stack has < 2 elements")
162+
right = stack.pop()
163+
left = stack.pop()
164+
stack.append(
165+
py_exprs.Call(
166+
py_exprs.PyObject(_OLD_BINARY_OP_MAP[opname]), (left, right)
167+
)
168+
)
169+
170+
elif opname == "COMPARE_OP":
171+
if len(stack) < 2:
172+
raise ValueError("Stack has < 2 elements")
173+
right = stack.pop()
174+
left = stack.pop()
175+
op_symbol = inst.argval
176+
if op_symbol not in _COMPARE_OP_MAP:
177+
raise ValueError(f"Unsupported compare operator: {op_symbol}")
178+
stack.append(
179+
py_exprs.Call(
180+
py_exprs.PyObject(_COMPARE_OP_MAP[op_symbol]), (left, right)
181+
)
182+
)
183+
184+
elif opname in ("UNARY_NEGATIVE", "UNARY_INVERT"):
185+
if not stack:
186+
raise ValueError("Stack is empty")
187+
target = stack.pop()
188+
stack.append(
189+
py_exprs.Call(
190+
py_exprs.PyObject(
191+
operator.neg if opname == "UNARY_NEGATIVE" else operator.invert
192+
),
193+
(target,),
194+
)
195+
)
196+
197+
elif opname == "UNARY_POSITIVE":
198+
if not stack:
199+
raise ValueError("Stack is empty")
200+
target = stack.pop()
201+
stack.append(py_exprs.Call(py_exprs.PyObject(operator.pos), (target,)))
202+
203+
elif opname == "CALL_INTRINSIC_1":
204+
if inst.argrepr == "INTRINSIC_UNARY_POSITIVE":
205+
if not stack:
206+
raise ValueError("Stack is empty")
207+
target = stack.pop()
208+
stack.append(py_exprs.Call(py_exprs.PyObject(operator.pos), (target,)))
209+
else:
210+
raise ValueError(f"Unsupported intrinsic: {inst.argrepr}")
211+
212+
elif opname in ("CALL", "CALL_FUNCTION", "CALL_METHOD"):
213+
num_args = inst.arg
214+
assert num_args is not None
215+
if len(stack) < num_args:
216+
raise ValueError("Stack has < 2 elements")
217+
args = [stack.pop() for _ in range(num_args)][::-1]
218+
# In Python 3.11, LOAD_GLOBAL with NULL push puts NULL below the global.
219+
# If NULL is below the callable on the stack, swap them to match
220+
# the expected layout [callable, NULL].
221+
if len(stack) >= 2 and stack[-2] == _NULL:
222+
stack[-1], stack[-2] = stack[-2], stack[-1]
223+
if stack and stack[-1] == _NULL:
224+
stack.pop()
225+
elif (
226+
stack
227+
and stack[-1] != _NULL
228+
and isinstance(stack[-1], expression.Expression)
229+
):
230+
self_arg = stack.pop()
231+
args = [self_arg] + args
232+
if not stack:
233+
raise ValueError("Stack is empty")
234+
callable_expr = stack.pop()
235+
stack.append(py_exprs.Call(callable_expr, tuple(args)))
236+
237+
elif opname == "RETURN_VALUE":
238+
if not stack:
239+
raise ValueError("Stack is empty")
240+
return stack[-1]
241+
242+
elif opname in ("STORE_FAST", "POP_TOP"):
243+
if stack:
244+
stack.pop()
245+
246+
else:
247+
raise ValueError(f"Unsupported opcode: {opname}")
248+
249+
raise ValueError("No return value found")
250+
251+
252+
def dis_to_expr(func: Callable, unpack_mode: bool = False) -> expression.Expression:
253+
"""
254+
Try to convert a python function to a BigQuery expression.
255+
256+
Unpack mode is whether SQL columns are addressed as attributes of a single
257+
python argument (e.g. row.col1), or as separate arguments (e.g. col1).
258+
259+
This is "best effort" - if the function contains operations that cannot
260+
be converted to BigQuery expressions, it will raise an Exception.
261+
"""
262+
py_expr = _compile_bytecode_to_py_expr(func)
263+
return py_exprs.resolve_py_exprs(py_expr, unpack_mode=unpack_mode)

0 commit comments

Comments
 (0)