Skip to content

Commit a831843

Browse files
committed
[#28420] Fix type inference for closures over local variables
1 parent a2a14f5 commit a831843

3 files changed

Lines changed: 126 additions & 29 deletions

File tree

sdks/python/apache_beam/typehints/opcodes.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from apache_beam.typehints import typehints
4141
from apache_beam.typehints.trivial_inference import BoundMethod
4242
from apache_beam.typehints.trivial_inference import Const
43+
from apache_beam.typehints.trivial_inference import _TypeInCell
4344
from apache_beam.typehints.trivial_inference import element_type
4445
from apache_beam.typehints.trivial_inference import key_value_types
4546
from apache_beam.typehints.trivial_inference import resolve_dataclass_field_type
@@ -570,18 +571,17 @@ def gen_start(state, arg):
570571

571572

572573
def load_closure(state, arg):
573-
# The arg is no longer offset by len(covar_names) as of 3.11
574+
# closure_type performs version-aware resolution of the index: as of 3.11
575+
# it refers to the frame's localsplus storage (in which the cell of a
576+
# captured parameter shares the parameter's slot) rather than
577+
# co_cellvars + co_freevars.
574578
# See https://docs.python.org/3/library/dis.html#opcode-LOAD_CLOSURE
575-
if (sys.version_info.major, sys.version_info.minor) >= (3, 11):
576-
arg -= len(state.co.co_varnames)
577579
state.stack.append(state.closure_type(arg))
578580

579581

580582
def load_deref(state, arg):
581-
# The arg is no longer offset by len(covar_names) as of 3.11
582-
# See https://docs.python.org/3/library/dis.html#opcode-LOAD_DEREF
583-
if (sys.version_info.major, sys.version_info.minor) >= (3, 11):
584-
arg -= len(state.co.co_varnames)
583+
# See load_closure above for how the index is resolved.
584+
# https://docs.python.org/3/library/dis.html#opcode-LOAD_DEREF
585585
state.stack.append(state.closure_type(arg))
586586

587587

@@ -615,7 +615,7 @@ def make_function(state, arg):
615615
closureTuplePos = -2
616616
else:
617617
closureTuplePos = -3
618-
closure = tuple((lambda _: lambda: _)(t).__closure__[0]
618+
closure = tuple((lambda _: lambda: _)(_TypeInCell(t)).__closure__[0]
619619
for t in state.stack[closureTuplePos].tuple_types)
620620

621621
func = types.FunctionType(func_code, globals, name=func_name, closure=closure)
@@ -629,7 +629,7 @@ def set_function_attribute(state, arg):
629629
attr = state.stack.pop().value
630630
closure = None
631631
if arg & 0x08:
632-
closure = tuple((lambda _: lambda: _)(t).__closure__[0]
632+
closure = tuple((lambda _: lambda: _)(_TypeInCell(t)).__closure__[0]
633633
for t in state.stack[attr].tuple_types)
634634
new_func = types.FunctionType(
635635
func.code, func.globals, name=func.name, closure=closure)

sdks/python/apache_beam/typehints/trivial_inference.py

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ def unwrap_all(xs):
123123
return [Const.unwrap(x) for x in xs]
124124

125125

126+
class _TypeInCell(object):
127+
"""Marker for an inferred type stored in a synthetic closure cell.
128+
129+
When MAKE_FUNCTION is emulated, the closure cells of the function it
130+
creates are populated with the inferred types of the captured variables
131+
rather than with actual runtime values. This wrapper marks such cells so
132+
that FrameState.closure_type can distinguish them from the cells of a real
133+
closure, which hold runtime values.
134+
"""
135+
def __init__(self, value):
136+
self.value = value
137+
138+
126139
class FrameState(object):
127140
"""Stores the state of the frame at a particular point of execution.
128141
"""
@@ -145,20 +158,42 @@ def copy(self):
145158
def const_type(self, i):
146159
return Const(self.co.co_consts[i])
147160

148-
def get_closure(self, i):
149-
num_cellvars = len(self.co.co_cellvars)
150-
if i < num_cellvars:
151-
return self.vars[i]
152-
else:
153-
return self.f.__closure__[i - num_cellvars].cell_contents
154-
155161
def closure_type(self, i):
156-
"""Returns a TypeConstraint or Const."""
157-
val = self.get_closure(i)
158-
if isinstance(val, typehints.TypeConstraint):
159-
return val
162+
"""Returns the type of the cell or free variable with the given index.
163+
164+
The index is the raw oparg of a LOAD_CLOSURE or LOAD_DEREF instruction.
165+
For Python < 3.11 it indexes co_cellvars + co_freevars. From Python 3.11
166+
on it indexes the frame's "fast locals" (localsplus) storage, in which
167+
the cell of a captured parameter shares the slot of the parameter
168+
itself, so the slot layout is co_varnames, followed by the cell
169+
variables that are not parameters, followed by the free variables.
170+
"""
171+
if sys.version_info >= (3, 11):
172+
names = self.co.co_varnames + tuple(
173+
c for c in self.co.co_cellvars
174+
if c not in self.co.co_varnames) + self.co.co_freevars
160175
else:
176+
names = self.co.co_cellvars + self.co.co_freevars
177+
name = names[i]
178+
if name in self.co.co_freevars:
179+
# A free variable: its cell belongs to the function's closure.
180+
val = self.f.__closure__[self.co.co_freevars.index(name)].cell_contents
181+
if isinstance(val, _TypeInCell):
182+
# A synthetic cell produced while emulating MAKE_FUNCTION: it holds
183+
# the inferred type of the captured variable, not an actual runtime
184+
# value.
185+
return val.value
161186
return Const(val)
187+
try:
188+
# A cell variable of the current frame. The frame state tracks the
189+
# inferred *type* of each local variable rather than its value, so the
190+
# tracked type can be returned directly.
191+
return self.vars[self.co.co_varnames.index(name)]
192+
except ValueError:
193+
# The cell variable does not correspond to a tracked local variable
194+
# (e.g. it is only ever assigned via STORE_DEREF, which is not
195+
# modeled), so its type is unknown.
196+
return typehints.Any
162197

163198
def get_global(self, i):
164199
name = self.get_name(i)
@@ -468,13 +503,15 @@ def infer_return_type_func(f, input_types, debug=False, depth=0):
468503
print('(' + dis.cmp_op[arg] + ')', end=' ')
469504
elif op in dis.hasfree:
470505
if free is None:
471-
free = co.co_cellvars + co.co_freevars
472-
# From 3.11 on the arg is no longer offset by len(co_varnames)
473-
# so we adjust it back
474-
print_arg = arg
475-
if (sys.version_info.major, sys.version_info.minor) >= (3, 11):
476-
print_arg = arg - len(co.co_varnames)
477-
print('(' + free[print_arg] + ')', end=' ')
506+
# From 3.11 on the arg indexes the localsplus storage, in which
507+
# the cell of a captured parameter shares the parameter's slot.
508+
if (sys.version_info.major, sys.version_info.minor) >= (3, 11):
509+
free = co.co_varnames + tuple(
510+
c for c in co.co_cellvars
511+
if c not in co.co_varnames) + co.co_freevars
512+
else:
513+
free = co.co_cellvars + co.co_freevars
514+
print('(' + free[arg] + ')', end=' ')
478515

479516
# Actually emulate the op.
480517
if state is None and states[start] is None:

sdks/python/apache_beam/typehints/trivial_inference_test.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
# pytype: skip-file
2121

2222
import dataclasses
23+
import sys
2324
import types
2425
import unittest
2526

@@ -276,7 +277,11 @@ def testClosure(self):
276277
y = 1.0
277278
self.assertReturnType(typehints.Tuple[int, float], lambda: (x, y))
278279

279-
@unittest.skip("https://github.com/apache/beam/issues/28420")
280+
@unittest.skipIf(
281+
sys.version_info >= (3, 13),
282+
'MAKE_FUNCTION closure emulation is not yet supported from Python '
283+
'3.13, in which closures are attached via SET_FUNCTION_ATTRIBUTE '
284+
'after function creation: https://github.com/apache/beam/issues/28420')
280285
def testLocalClosure(self):
281286
self.assertReturnType(
282287
typehints.Tuple[int, int], lambda x: (x, (lambda: x)()), [int])
@@ -515,5 +520,60 @@ class MyDataClass:
515520
"lambda x: (x.id, x.name, x.tags, x.custom)"), [MyDataClass])
516521

517522

523+
class ClosureTypeInferenceTest(unittest.TestCase):
524+
def assertReturnType(self, expected, f, inputs=(), depth=5):
525+
self.assertEqual(
526+
expected,
527+
trivial_inference.infer_return_type(
528+
f, inputs, debug=False, depth=depth))
529+
530+
@unittest.skipIf(
531+
sys.version_info >= (3, 13),
532+
'MAKE_FUNCTION closure emulation is not yet supported from Python '
533+
'3.13, in which closures are attached via SET_FUNCTION_ATTRIBUTE '
534+
'after function creation: https://github.com/apache/beam/issues/28420')
535+
def testClosureCallingCapturedArgument(self):
536+
# https://github.com/apache/beam/issues/28420
537+
self.assertReturnType(
538+
typehints.Tuple[int, int], lambda x: (x, (lambda: x)()), [int])
539+
540+
@unittest.skipIf(
541+
sys.version_info >= (3, 13),
542+
'MAKE_FUNCTION closure emulation is not yet supported from Python '
543+
'3.13, in which closures are attached via SET_FUNCTION_ATTRIBUTE '
544+
'after function creation: https://github.com/apache/beam/issues/28420')
545+
def testClosureCallingCapturedArgumentOnly(self):
546+
self.assertReturnType(int, lambda x: (lambda: x)(), [int])
547+
548+
@unittest.skipIf(
549+
sys.version_info >= (3, 13),
550+
'MAKE_FUNCTION closure emulation is not yet supported from Python '
551+
'3.13, in which closures are attached via SET_FUNCTION_ATTRIBUTE '
552+
'after function creation: https://github.com/apache/beam/issues/28420')
553+
def testNestedClosureCallingCapturedArgument(self):
554+
self.assertReturnType(int, lambda x: (lambda: (lambda: x)())(), [int])
555+
556+
@unittest.skipIf(
557+
sys.version_info >= (3, 13),
558+
'MAKE_FUNCTION closure emulation is not yet supported from Python '
559+
'3.13, in which closures are attached via SET_FUNCTION_ATTRIBUTE '
560+
'after function creation: https://github.com/apache/beam/issues/28420')
561+
def testClosureCapturedArgumentMixedWithParameter(self):
562+
self.assertReturnType(
563+
typehints.Tuple[int, str], lambda x, y: (lambda z: (x, z))(y),
564+
[int, str])
565+
566+
def testRealClosureCellsHoldValues(self):
567+
# Cells of an actual (non-emulated) closure contain runtime values and
568+
# must still be inferred as constants of the value's type.
569+
v = 123
570+
self.assertReturnType(int, lambda: v)
571+
572+
def testRealClosureCellHoldingClass(self):
573+
# A captured class is a constant and calling it constructs an instance.
574+
cls = int
575+
self.assertReturnType(int, lambda: cls('7'))
576+
577+
518578
if __name__ == '__main__':
519-
unittest.main()
579+
unittest.main()

0 commit comments

Comments
 (0)