fix(python): name unsupported Function globals in capture errors (#4938)

Function source packaging rejected unsupported global values without
identifying the variable or explaining the supported captures. Include
the global name even for a nested tuple failure, describe the supported
literal values and remedy, and document the capture boundary.

Adds function/class regression cases for Arrow types, nested tuples,
dictionaries and non-finite floats, plus a supported nested literal
control.

New serialization support remains separate.

Formatting applied; no local builds or tests were run. CI will execute
the regression coverage.
This commit is contained in:
Jack Ye
2026-11-10 08:22:52 -08:00
committed by GitHub
parent f7648d4a22
commit faa5987b33
2 changed files with 65 additions and 6 deletions
+11 -6
View File
@@ -1428,19 +1428,21 @@ def _is_function_decorator(node: ast.expr) -> bool:
)
def _literal_source(value: Any) -> str:
def _literal_source(value: Any, name: str) -> str:
if value is None or type(value) in (bool, int, str, bytes):
return repr(value)
if type(value) is float and math.isfinite(value):
return repr(value)
if type(value) is tuple:
children = ", ".join(_literal_source(child) for child in value)
children = ", ".join(_literal_source(child, name) for child in value)
if len(value) == 1:
children += ","
return f"({children})"
raise TypeError(
"Function source references an unsupported global value of type "
f"{type(value).__name__}"
f"{type(value).__name__} in {name!r}. Only None, bool, int, str, bytes, "
"finite float, and tuples of these literals can be captured. "
"Define this value inside the function or class body instead."
)
@@ -1875,7 +1877,7 @@ class _SourcePackager:
if imported is value:
self._require_importable(module_name)
return f"from {module_name} import {qualname} as {name}"
return f"{name} = {_literal_source(value)}"
return f"{name} = {_literal_source(value, name)}"
def _require_importable(self, module_name: str) -> None:
"""Warn about an import the Function's environment may not be able to
@@ -2486,8 +2488,8 @@ def function(
- modules, and classes and functions importable from an installed
package, become imports; the package must be declared in ``pip`` or
``conda``;
- literals (``None``, booleans, numbers, strings, bytes, tuples of them)
are inlined;
- literals (``None``, ``bool``, ``int``, ``str``, ``bytes``, finite
``float``, and tuples containing only these literals) are inlined;
- functions and classes defined in ``__main__``, such as notebook cells,
are packaged by source, together with what they reference;
- modules and packages listed in ``code`` are shipped as Python source and
@@ -2505,6 +2507,9 @@ def function(
image. Warnings follow normal Python warning filters and may be promoted
to errors. Declare every third-party import in ``pip`` or ``conda`` even
when it happens to be installed on the registration host.
Other global values, including Arrow types and schemas, lists, dictionaries,
sets, and NumPy arrays, cannot be captured as literals. Define them inside
the function or class body instead.
Closures, lambdas, nested definitions, mutable module-level
objects, code that reaches the module namespace another way
(``globals()``/``eval``, ``sys.modules``, ``builtins``), and a
@@ -127,6 +127,60 @@ def test_function_without_initialization_keeps_its_wire_form():
assert request["artifact"]["kind"] == "python_callable"
@pytest.mark.parametrize("kind", ["function", "class"])
@pytest.mark.parametrize(
("global_value", "value_type"),
[
("pa.struct([('label', pa.string())])", "StructType"),
("(1, (pa.int64(),))", "DataType"),
("{'label': 1}", "dict"),
("float('inf')", "float"),
],
)
def test_unsupported_global_names_value_and_remedy(
helper_package, kind, global_value, value_type
):
definition = (
"class uses_global:\n"
" def __call__(self, value: int) -> int:\n"
" return value if CAPTURED_VALUE else 0\n"
if kind == "class"
else "def uses_global(value: int) -> int:\n"
" return value if CAPTURED_VALUE else 0\n"
)
module = helper_package(
"fx_unsupported_global",
{
"__init__.py": f"import pyarrow as pa\nCAPTURED_VALUE = {global_value}\n"
+ definition
},
)
with pytest.raises(TypeError) as error:
function(module.uses_global)
message = str(error.value)
assert "CAPTURED_VALUE" in message
assert value_type in message
assert "finite float" in message
assert "inside the function or class body" in message
def test_supported_nested_literal_global_still_packages(helper_package):
module = helper_package(
"fx_literal_global",
{
"__init__.py": "CAPTURED_VALUE = (None, True, 1, 'label', "
"b'bytes', (1.5,))\n"
"def uses_global(value: int) -> int:\n"
" return value + CAPTURED_VALUE[2]\n"
},
)
definition = function(module.uses_global)
namespace = {}
exec(compile(artifact(definition), "user_function.py", "exec"), namespace)
assert namespace["uses_global"](3) == 4
assert namespace["CAPTURED_VALUE"] == module.CAPTURED_VALUE
@pytest.mark.parametrize("annotation", ["Array", "RecordBatch"])
@pytest.mark.parametrize("kind", ["function", "class"])
def test_batch_annotations_require_explicit_schemas(annotation, kind, helper_package):