From faa5987b3308021f37d083084cf79455740f49f7 Mon Sep 17 00:00:00 2001 From: Jack Ye Date: Tue, 10 Nov 2026 08:22:52 -0800 Subject: [PATCH] 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. --- python/python/lancedb/functions.py | 17 +++--- .../python/tests/test_function_packaging.py | 54 +++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/python/python/lancedb/functions.py b/python/python/lancedb/functions.py index fe5a1a45a..eed4050a8 100644 --- a/python/python/lancedb/functions.py +++ b/python/python/lancedb/functions.py @@ -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 diff --git a/python/python/tests/test_function_packaging.py b/python/python/tests/test_function_packaging.py index 050aeb3ea..83dfdaeb3 100644 --- a/python/python/tests/test_function_packaging.py +++ b/python/python/tests/test_function_packaging.py @@ -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):