Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2f2f1d6
Add stable identifier pickling option to context
kristynsmith Oct 7, 2026
9c03511
Refactor config assignment in cloudpickle_pickler
kristynsmith Oct 7, 2026
5477ec1
Fix import statement for dataclasses module
kristynsmith Oct 7, 2026
88ae832
Fix indentation in cloudpickle_pickler.py
kristynsmith Oct 7, 2026
6795c3a
Merge branch 'apache:master' into stable-code-identifier-pickling
kristynsmith Oct 21, 2026
aeeba6c
Revert config changes
kristynsmith Oct 21, 2026
7fd251c
Revert changes
kristynsmith Oct 21, 2026
7289fb6
Refactor code object identifier and hashing logic
kristynsmith Oct 21, 2026
195bc35
Enhance tests for lambda and decorator functionality
kristynsmith Oct 21, 2026
48d8d4e
Refactor code object search and lambda handling
kristynsmith Oct 21, 2026
5dfd47b
Add tests for same-named nested functions
kristynsmith Oct 21, 2026
0652719
Add parentheses
kristynsmith Oct 21, 2026
0469219
Fix formatting
kristynsmith Oct 21, 2026
347f28f
Fix indentation for test function name
kristynsmith Oct 21, 2026
8fb15fd
Fix logic
kristynsmith Oct 27, 2026
f1e2206
Fix indentation in test function definitions
kristynsmith Oct 27, 2026
b2c54db
Fix formatting
kristynsmith Oct 27, 2026
1afac1e
remove whitespace
kristynsmith Oct 27, 2026
bdb5a9a
Enhance code_object_pickler tests for nested functions
kristynsmith Oct 28, 2026
0343502
Fix syntax error in code_object_pickler_test.py
kristynsmith Oct 28, 2026
fd2aab3
Rename nested functions in code object pickler tests
kristynsmith Oct 28, 2026
1993f23
add newline
kristynsmith Oct 28, 2026
b197e41
Update sdks/python/apache_beam/internal/code_object_pickler.py
kristynsmith Oct 29, 2026
f78c349
fix formatting
kristynsmith Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 102 additions & 73 deletions sdks/python/apache_beam/internal/code_object_pickler.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@

import collections
import hashlib
import importlib
import inspect
import re
import sys
Expand Down Expand Up @@ -213,7 +214,7 @@ def _search_function(
first_part = qual_name_parts[0]
if (node.__code__ == callable.__code__):
if len(qual_name_parts) > 1:
raise ValueError('Qual name parts too long')
return None
return '__code__'
# If first part is '<locals>' then the code object is in a local variable
# so we should add __code__ to the path to indicate that we are entering
Expand All @@ -236,40 +237,28 @@ def _search_code(

Returns:
The code object identifier, or None if not found.

Raises:
ValueError: If the qual name parts are too long.
"""
first_part = qual_name_parts[0]
rest = qual_name_parts[1:]
if hasattr(callable, '__code__') and node == callable.__code__:
if len(qual_name_parts) > 1:
raise ValueError('Qual name parts too long')
return None
return ''
elif first_part == '<locals>':
code_objects_by_name = collections.defaultdict(list)
for co_const in node.co_consts:
if inspect.iscode(co_const):
code_objects_by_name[co_const.co_name].append(co_const)
num_lambdas = len(code_objects_by_name.get('<lambda>', []))
# If there is only one lambda, we can use the default path
# 'co_consts[<lambda>]'. This is the most common case and it is
# faster than calculating the signature and the hash.
if num_lambdas == 1:
path = _search(callable, code_objects_by_name['<lambda>'][0], rest)
if path is not None:
return _extend_path('co_consts[<lambda>]', path)
else:
return _search_lambda(callable, code_objects_by_name, rest)
return _search_locals(callable, code_objects_by_name, rest)
elif node.co_name == first_part:
return _search(callable, node, rest)


def _search_lambda(
def _search_locals(
callable: types.FunctionType,
code_objects_by_name: dict[str, list[types.CodeType]],
qual_name_parts: list[str]):
"""Searches a lambda to create a code object identifier.
"""Searches local code objects to create a code object identifier.

Args:
callable: The callable to search for.
Expand All @@ -279,51 +268,57 @@ def _search_lambda(
Returns:
The code object identifier, or None if not found.
"""
# There are multiple lambdas in the code object, so we need to calculate
# the signature and the hash to identify the correct lambda.
lambda_code_objects_by_name = collections.defaultdict(list)
if not qual_name_parts:
return None
name = qual_name_parts[0]
code_objects = code_objects_by_name[name]
if not code_objects:
return None
if name == '<lambda>':
for code_object in code_objects:
lambda_name = f'<lambda>, {_signature(code_object)}'
lambda_code_objects_by_name[lambda_name].append(code_object)
# Check if there are any lambdas with the same signature.
# If there are, we need to calculate the hash to identify the correct
# lambda.
for lambda_name, lambda_objects in lambda_code_objects_by_name.items():
if len(lambda_objects) > 1:
for lambda_object in lambda_objects:
path = _search(callable, lambda_object, qual_name_parts)
if path is not None:
return _extend_path(
f'co_consts[{lambda_name},'
f' {_create_bytecode_hash(lambda_object)}]',
path,
)
else:
# If there is only one lambda with this signature, we can
# use the signature to identify the correct lambda.
path = _search(callable, code_objects[0], qual_name_parts)
if path is not None:
return _extend_path(f'co_consts[{lambda_name}]', path)
else:
# For non lambda objects, we can use the name to identify the object.
# If there is only one code object with this name, we can use the default path
# 'co_consts[<name>]'. This is the most common case and it is faster than
# calculating the signature and the hash.
if len(code_objects) == 1:
path = _search(callable, code_objects[0], qual_name_parts)
if path is not None:
return _extend_path(f'co_consts[{name}]', path)
return None

# There are multiple code objects with the same name, so we need to calculate
# the signature and the hash to identify the correct code object.
code_objects_by_sig = collections.defaultdict(list)
for code_object in code_objects:
sig_name = f'{name}, {_signature(code_object)}'
code_objects_by_sig[sig_name].append(code_object)
# Check if there are any code objects with the same signature.
# If there are, we need to calculate the hash to identify the correct
# code object.
for sig_name, sig_objects in code_objects_by_sig.items():
if len(sig_objects) > 1:
for code_object in sig_objects:
path = _search(callable, code_object, qual_name_parts)
if path is not None:
return _extend_path(
f'co_consts[{sig_name},'
f' {_create_bytecode_hash(code_object)}]',
path,
)
else:
# If there is only one code object with this signature, we can
# use the signature to identify the correct code object.
path = _search(callable, sig_objects[0], qual_name_parts)
if path is not None:
return _extend_path(f'co_consts[{sig_name}]', path)


# Matches a path like: co_consts[my_function]
_SINGLE_NAME_PATTERN = re.compile(r'co_consts\[([a-zA-Z0-9\<\>_-]+)]')
# Matches a path like: co_consts[<lambda>, ('x',)]
_LAMBDA_WITH_ARGS_PATTERN = re.compile(
r"co_consts\[(<.*?>),\s(\('[^']+'(?:,\s*'[^']+')*,?\))\]")
# Matches a path like: co_consts[<lambda>, ('x',), 1234567890]
_LAMBDA_WITH_HASH_PATTERN = re.compile(
r"co_consts\[(<[^>]+>),\s*(\([^\)]*\)),?\s*(.*)\]")
# Matches a path like: co_consts[<lambda>, ()] or co_consts[my_function, ('x',)]
_OBJECT_WITH_ARGS_PATTERN = re.compile(
r'co_consts\[([a-zA-Z0-9\<\>_-]+),\s*(\([^\)]*\))\]')
# Matches a path like: co_consts[<lambda>, ('x',), 1234567890] or
# co_consts[my_function, ('x',), 1234567890]
_OBJECT_WITH_HASH_PATTERN = re.compile(
r'co_consts\[([a-zA-Z0-9\<\>_-]+),\s*(\([^\)]*\)),\s*(.+)\]')
# Matches a path like: __defaults__[0]
_DEFAULT_PATTERN = re.compile(r'(__defaults__)\[(\d+)\]')
# Matches an argument like: 'x'
Expand Down Expand Up @@ -357,13 +352,13 @@ def _get_code_object_from_single_name_pattern(
raise AttributeError(f'Could not find code object with path: {path}')


def _get_code_object_from_lambda_with_args_pattern(
obj: types.ModuleType, lambda_with_args_result: re.Match[str], path: str):
"""Returns the code object from a lambda with args pattern.
def _get_code_object_from_object_with_args_pattern(
obj: types.ModuleType, object_with_args_result: re.Match[str], path: str):
"""Returns the code object from an object with args pattern.

Args:
obj: The object to search within.
lambda_with_args_result: The result of the lambda with args pattern search.
object_with_args_result: The result of the lambda with args pattern search.
path: The path to the code object.

Returns:
Expand All @@ -372,7 +367,7 @@ def _get_code_object_from_lambda_with_args_pattern(
Raises:
AttributeError: If the code object is not found.
"""
name = lambda_with_args_result.group(1)
name = object_with_args_result.group(1)
code_objects = collections.defaultdict(list)
if hasattr(obj, 'co_consts'):
for co_const in obj.co_consts:
Expand All @@ -381,19 +376,19 @@ def _get_code_object_from_lambda_with_args_pattern(
for name, objects in code_objects.items():
for obj_ in objects:
args = tuple(
re.findall(_ARGUMENT_PATTERN, lambda_with_args_result.group(2)))
re.findall(_ARGUMENT_PATTERN, object_with_args_result.group(2)))
if obj_.co_varnames[:_get_arg_count(obj_)] == args:
return obj_
raise AttributeError(f'Could not find code object with path: {path}')


def _get_code_object_from_lambda_with_hash_pattern(
obj: types.ModuleType, lambda_with_hash_result: re.Match[str], path: str):
def _get_code_object_from_object_with_hash_pattern(
obj: types.ModuleType, object_with_hash_result: re.Match[str], path: str):
"""Returns the code object from a lambda with hash pattern.

Args:
obj: The object to search within.
lambda_with_hash_result: The result of the lambda with hash pattern search.
object_with_hash_result: The result of the lambda with hash pattern search.
path: The path to the code object.

Returns:
Expand All @@ -402,7 +397,7 @@ def _get_code_object_from_lambda_with_hash_pattern(
Raises:
AttributeError: If the code object is not found.
"""
name = lambda_with_hash_result.group(1)
name = object_with_hash_result.group(1)
code_objects = collections.defaultdict(list)
if hasattr(obj, 'co_consts'):
for co_const in obj.co_consts:
Expand All @@ -411,9 +406,9 @@ def _get_code_object_from_lambda_with_hash_pattern(
for name, objects in code_objects.items():
for obj_ in objects:
args = tuple(
re.findall(_ARGUMENT_PATTERN, lambda_with_hash_result.group(2)))
re.findall(_ARGUMENT_PATTERN, object_with_hash_result.group(2)))
if obj_.co_varnames[:_get_arg_count(obj_)] == args:
hash_value = lambda_with_hash_result.group(3)
hash_value = object_with_hash_result.group(3)
if hash_value == str(_create_bytecode_hash(obj_)):
return obj_
raise AttributeError(f'Could not find code object with path: {path}')
Expand All @@ -435,19 +430,32 @@ def get_code_from_identifier(code_object_identifier: str):
if not code_object_identifier:
raise ValueError('Path must not be empty.')
parts = code_object_identifier.split('.')
if parts[0] not in sys.modules:
obj = None
start_index = 0
for i in range(len(parts), 0, -1):
module_name = '.'.join(parts[:i])
if module_name in sys.modules and sys.modules[module_name] is not None:
obj = sys.modules[module_name]
start_index = i
break
try:
obj = importlib.import_module(module_name)
start_index = i
break
except (ImportError, ModuleNotFoundError, ValueError):
continue
if obj is None:
raise AttributeError(f'Module {parts[0]} not found in sys.modules')
obj = sys.modules[parts[0]]
for part in parts[1:]:
for part in parts[start_index:]:
if name_result := _SINGLE_NAME_PATTERN.fullmatch(part):
obj = _get_code_object_from_single_name_pattern(
obj, name_result, code_object_identifier)
elif lambda_with_args_result := _LAMBDA_WITH_ARGS_PATTERN.fullmatch(part):
obj = _get_code_object_from_lambda_with_args_pattern(
obj, lambda_with_args_result, code_object_identifier)
elif lambda_with_hash_result := _LAMBDA_WITH_HASH_PATTERN.fullmatch(part):
obj = _get_code_object_from_lambda_with_hash_pattern(
obj, lambda_with_hash_result, code_object_identifier)
elif object_with_args_result := _OBJECT_WITH_ARGS_PATTERN.fullmatch(part):
obj = _get_code_object_from_object_with_args_pattern(
obj, object_with_args_result, code_object_identifier)
elif object_with_hash_result := _OBJECT_WITH_HASH_PATTERN.fullmatch(part):
obj = _get_code_object_from_object_with_hash_pattern(
obj, object_with_hash_result, code_object_identifier)
elif default_result := _DEFAULT_PATTERN.fullmatch(part):
index = int(default_result.group(2))
if index >= len(obj.__defaults__):
Expand Down Expand Up @@ -496,6 +504,19 @@ def _get_arg_count(obj: types.CodeType):
)


def _normalize_const(const):
"""Normalizes a constant from co_consts for deterministic hashing."""
if inspect.iscode(const):
return ('__code__', _create_bytecode_hash(const))
if isinstance(const, tuple):
return tuple(_normalize_const(x) for x in const)
if isinstance(const, frozenset):
return (
'__frozenset__',
tuple(sorted((_normalize_const(x) for x in const), key=repr)))
return (type(const).__qualname__, repr(const))


def _create_bytecode_hash(code_object: types.CodeType):
"""Returns the hash of a code object.

Expand All @@ -505,4 +526,12 @@ def _create_bytecode_hash(code_object: types.CodeType):
Returns:
The hash of the code object.
"""
return hashlib.md5(code_object.co_code).hexdigest()
hash_input = repr((
code_object.co_code,
tuple(_normalize_const(c) for c in code_object.co_consts),
code_object.co_names,
code_object.co_varnames,
code_object.co_freevars,
code_object.co_cellvars,
)).encode('utf-8')
return hashlib.md5(hash_input).hexdigest()
Loading
Loading