Skip to content

Commit c834ed1

Browse files
authored
Add support for class attributes and bound methods to escape hatch (#2775)
Update the escape hatch to allow escaping the following components: - Class-level attributes - Also fixed a bug where falsy enum values (e.g. 0 and “”) would throw a RuntimeError since they were considered None - Bound methods
1 parent 3788e91 commit c834ed1

7 files changed

Lines changed: 77 additions & 9 deletions

File tree

metaflow/plugins/env_escape/client.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,16 +378,16 @@ def name_to_parent_name(name):
378378
else:
379379
lookup_name = name
380380

381-
if name == "function":
382-
# Special handling of pickled functions. We create a new class that
381+
if name in ("function", "method"):
382+
# Special handling of pickled functions and methods. We create a new class that
383383
# simply has a __call__ method that will forward things back to
384384
# the server side.
385385
if obj_id is None:
386-
raise RuntimeError("Local function unpickling without an object ID")
386+
raise RuntimeError("Local %s unpickling without an object ID" % name)
387387
if obj_id not in self._proxied_standalone_functions:
388388
self._proxied_standalone_functions[obj_id] = create_class(
389389
self,
390-
"__main__.__function_%s" % obj_id,
390+
"__main__.__%s_%s" % (name, obj_id),
391391
{},
392392
{},
393393
{},

metaflow/plugins/env_escape/configurations/emulate_test_lib/server_mappings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"ChildClass": lib.ChildClass,
1818
"ExceptionAndClass": lib.ExceptionAndClass,
1919
"ExceptionAndClassChild": lib.ExceptionAndClassChild,
20+
"TestIntEnum": lib.TestIntEnum,
21+
"TestStrEnum": lib.TestStrEnum,
2022
}
2123
}
2224

metaflow/plugins/env_escape/configurations/test_lib_impl/test_lib.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
import functools
2+
from enum import Enum, IntEnum
23
from html.parser import HTMLParser
34

45

6+
class TestIntEnum(IntEnum):
7+
ZERO = 0
8+
ONE = 1
9+
TWO = 2
10+
11+
12+
class TestStrEnum(str, Enum):
13+
EMPTY = ""
14+
FOO = "foo"
15+
BAR = "bar"
16+
17+
518
class MyBaseException(Exception):
619
pass
720

@@ -106,6 +119,9 @@ def weird_indirection(self, name):
106119
def returnChild(self):
107120
return ChildClass()
108121

122+
def get_bound_method(self):
123+
return self.print_value
124+
109125
def raiseOrReturnValueError(self, doRaise=False):
110126
if doRaise:
111127
raise ValueError("I raised")

metaflow/plugins/env_escape/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
OP_INIT = 17
4141
OP_CALLONCLASS = 18
4242
OP_SUBCLASSCHECK = 19
43+
OP_GETCLASSATTR = 20
4344

4445
# Control messages
4546
CONTROL_SHUTDOWN = 1

metaflow/plugins/env_escape/server.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
OP_SETVAL,
3838
OP_INIT,
3939
OP_SUBCLASSCHECK,
40+
OP_GETCLASSATTR,
4041
VALUE_LOCAL,
4142
VALUE_REMOTE,
4243
CONTROL_GETEXPORTS,
@@ -96,11 +97,19 @@ def __init__(self, config_dir, max_pickle_version):
9697
{v: k for k, v in self._proxied_types.items()}
9798
)
9899

99-
# We will also proxy functions from objects as needed. This is useful
100+
# We will also proxy functions and methods from objects as needed. This is useful
100101
# for defaultdict for example since the `default_factory` function is a
101-
# lambda that needs to be transferred.
102+
# lambda that needs to be transferred. Methods are also proxied to support
103+
# bound methods returned from functions (e.g., evaluator patterns).
102104
self._class_types_to_names[type(lambda x: x)] = "function"
103105

106+
# Register method type for bound methods
107+
class _TempClass:
108+
def _temp_method(self):
109+
pass
110+
111+
self._class_types_to_names[type(_TempClass()._temp_method)] = "method"
112+
104113
# Update all alias information
105114
for base_name, aliases in itertools.chain(
106115
a1.items(), a2.items(), a3.items(), a4.items()
@@ -257,6 +266,7 @@ def __init__(self, config_dir, max_pickle_version):
257266
OP_SETVAL: self._handle_setval,
258267
OP_INIT: self._handle_init,
259268
OP_SUBCLASSCHECK: self._handle_subclasscheck,
269+
OP_GETCLASSATTR: self._handle_getclassattr,
260270
}
261271

262272
self._local_objects = {}
@@ -391,9 +401,9 @@ def pickle_object(self, obj):
391401
def unpickle_object(self, obj):
392402
if (not isinstance(obj, ObjReference)) or obj.value_type != VALUE_LOCAL:
393403
raise ValueError("Invalid transferred object: %s" % str(obj))
394-
obj = self._local_objects.get(obj.identifier)
395-
if obj:
396-
return obj
404+
result = self._local_objects.get(obj.identifier)
405+
if result is not None:
406+
return result
397407
raise ValueError("Invalid object -- id %s not known" % obj.identifier)
398408

399409
@staticmethod
@@ -527,6 +537,15 @@ def _handle_subclasscheck(self, target, class_name, otherclass_name, reverse=Fal
527537
return issubclass(class_type, getattr(sys.modules[sub_module], sub_name))
528538
return issubclass(getattr(sys.modules[sub_module], sub_name), class_type)
529539

540+
def _handle_getclassattr(self, target, class_name, attr_name):
541+
# Handle class-level attribute access like EnumClass.MEMBER
542+
class_type = self._known_classes.get(class_name)
543+
if class_type is None:
544+
class_type = self._proxied_types.get(class_name)
545+
if class_type is None:
546+
raise ValueError("Unknown class %s" % class_name)
547+
return getattr(class_type, attr_name)
548+
530549

531550
if __name__ == "__main__":
532551
max_pickle_version = int(sys.argv[1])

metaflow/plugins/env_escape/stub.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
OP_DIR,
1818
OP_INIT,
1919
OP_SUBCLASSCHECK,
20+
OP_GETCLASSATTR,
2021
)
2122

2223
from .exception_transferer import ExceptionMetaClass
@@ -311,6 +312,14 @@ def __instancecheck__(cls, instance):
311312
# Goes to __subclasscheck__ above
312313
return cls.__subclasscheck__(type(instance))
313314

315+
def __getattr__(cls, name):
316+
# This handles class-level attribute access like EnumClass.MEMBER
317+
# When accessing an attribute on the stub class itself (not an instance),
318+
# forward the request to the server to get the class attribute.
319+
return cls.___class_connection___.stub_request(
320+
None, OP_GETCLASSATTR, cls.___class_remote_class_name___, name
321+
)
322+
314323

315324
class MetaExceptionWithConnection(StubMetaClass, ExceptionMetaClass):
316325
def __new__(cls, class_name, base_classes, class_dict, connection):

test/env_escape/example.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,27 @@ def run_test(through_escape=False):
137137
# assert issubclass(test.ChildClass, HTMLParser)
138138
assert issubclass(test.ChildClass, object)
139139

140+
print("-- Test enum class attribute access --")
141+
assert test.TestIntEnum.ZERO == 0 # Also tests falsy value (0)
142+
assert test.TestIntEnum.ONE == 1
143+
assert test.TestIntEnum.TWO == 2
144+
assert test.TestStrEnum.EMPTY == "" # Also tests falsy value (empty string)
145+
assert test.TestStrEnum.FOO == "foo"
146+
assert test.TestStrEnum.BAR == "bar"
147+
148+
print("-- Test bound method returns --")
149+
o1_for_method = test.TestClass1(5)
150+
151+
# Get a bound method (not functools.partial)
152+
bound_method = o1_for_method.get_bound_method()
153+
154+
# Verify it's callable
155+
assert callable(bound_method)
156+
157+
# Call the bound method - returns the raw value (no override applied)
158+
result = bound_method()
159+
assert result == 5, f"Expected 5, got {result}"
160+
140161
print("-- Test exceptions --")
141162
# Non proxied exceptions can't be returned as objects
142163
try:

0 commit comments

Comments
 (0)