fix(python): explain Function namespace import restrictions (#4982)

A plain `import ctypes.util` in a Function currently reports “dynamic
namespace access” without explaining the module restriction or a way
forward. Give blocked module imports a distinct error that names the
modules, explains the reconstructed namespace constraint, and recommends
an importable helper supplied through `code=`, `pip=`, or `conda=`.
Dynamic namespace operations also get an actionable explanation.

Document the full restrictions and the helper alternative in the
decorator and Python Functions reference. Add regression coverage for
direct imports, `from` imports, global module aliases, class Functions,
the deprecated `@udf` alias, and helpers imported normally or shipped
with `code=`. Existing import restrictions remain in place.

For example, move `ctypes.util.find_library` into
`native_diagnostics/__init__.py`, then use:

```python
import native_diagnostics
from lancedb import function


@function(code=[native_diagnostics])
def probe_library(name: str) -> str:
    return native_diagnostics.find_library(name)
```

Fixes ENT-3408.
This commit is contained in:
Jack Ye
2026-11-12 09:01:39 -08:00
committed by GitHub
parent b4d1c55f62
commit e61e21035c
4 changed files with 214 additions and 21 deletions
+47 -16
View File
@@ -1570,10 +1570,23 @@ _NAMESPACE_MODULES = frozenset(
)
def _namespace_acquisition(
definition: ast.FunctionDef, references: set[str]
) -> list[str]:
found = set(references & _DYNAMIC_NAMESPACE_ACCESS)
_NAMESPACE_HELPER_HINT = (
"Move this code into an importable helper module supplied with code=, pip=, "
"or conda=, then import and call the helper from the Function."
)
def _namespace_import_error(modules: Sequence[str]) -> ValueError:
return ValueError(
f"@function cannot package imports from namespace-access modules: {modules!r}. "
f"Imports of {', '.join(sorted(_NAMESPACE_MODULES))} (including submodules) "
"are blocked because they can access names outside the packaged "
"Function's reconstructed module namespace. " + _NAMESPACE_HELPER_HINT
)
def _namespace_imports(definition: Union[ast.FunctionDef, ast.ClassDef]) -> list[str]:
found: set[str] = set()
for node in ast.walk(definition):
if isinstance(node, ast.Import):
found.update(
@@ -1883,11 +1896,17 @@ class _SourcePackager:
)
source = ast.unparse(definition)
references = _module_references(f"{_SOURCE_HEADER}\n\n{source}\n")
dynamic = _namespace_acquisition(definition, references)
dynamic = sorted(references & _DYNAMIC_NAMESPACE_ACCESS)
if dynamic:
raise ValueError(
f"@function cannot package dynamic namespace access: {dynamic!r}"
f"@function cannot package dynamic namespace access: {dynamic!r}. "
"These operations can access names outside the packaged "
"Function's reconstructed module namespace. Reference dependencies "
"explicitly. " + _NAMESPACE_HELPER_HINT
)
namespace_imports = _namespace_imports(definition)
if namespace_imports:
raise _namespace_import_error(namespace_imports)
self._warn_unshipped_direct_imports(definition)
self._packaged.add(id(target))
self._claim(target.__name__, ("definition", id(target)))
@@ -1982,10 +2001,7 @@ class _SourcePackager:
return f"from lance_blob import BlobReader as {name}"
if isinstance(value, types.ModuleType):
if value.__name__.split(".")[0] in _NAMESPACE_MODULES:
raise ValueError(
"@function cannot package dynamic namespace access: "
f"{value.__name__!r}"
)
raise _namespace_import_error([value.__name__])
try:
imported = importlib.import_module(value.__name__)
except ImportError:
@@ -2663,13 +2679,28 @@ def function(
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
Closures, lambdas, nested definitions, mutable module-level objects, and a
non-standard ``__builtins__`` are rejected where they can be seen and
otherwise unsupported. Changing any packaged source, including a module in
``code``, produces a new Function version; the environment built from
``pip`` or ``conda`` is reused.
otherwise unsupported.
**Namespace restrictions.** The packaged source cannot import ``sys``,
``builtins``, ``importlib``, ``inspect``, ``gc``, ``ctypes``, or ``types``,
including submodules such as ``ctypes.util`` and ``importlib.metadata``.
Referencing a module-level alias for one of these modules is also rejected.
References to ``globals``, ``locals``, ``vars``, ``eval``, ``exec``,
``compile``, or ``__import__`` are rejected as dynamic namespace access.
These restrictions are conservative: even ordinary uses such as
``ctypes.util.find_library`` or ``sys.version`` are blocked because these
modules can reach names outside the reconstructed Function namespace.
Move such code into an importable helper module supplied with ``code=``,
``pip=``, or ``conda=``, then import and call the helper from the Function.
Helpers imported normally keep their own module namespace; these checks do
not scan their bodies. A helper defined in ``__main__`` is packaged by source
and is subject to the same checks.
Changing any packaged source, including a module in ``code``, produces a
new Function version; the environment built from ``pip`` or ``conda`` is
reused.
Parameters
----------
@@ -888,7 +888,7 @@ def test_function_rejects_a_module_level_namespace_alias(tmp_path):
spec = importlib.util.spec_from_file_location("aliasing_udfs", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
with pytest.raises(ValueError, match="dynamic namespace access"):
with pytest.raises(ValueError, match="imports from namespace-access modules"):
function(module.score)
@@ -899,7 +899,6 @@ def test_function_rejects_a_module_level_namespace_alias(tmp_path):
"eval('THRESHOLD')",
"(lambda g: g()['THRESHOLD'])(globals)",
"__import__('sys').modules[__name__].THRESHOLD",
"sys.modules[__name__].THRESHOLD",
],
)
def test_function_rejects_dynamic_namespace_access(access):
@@ -911,9 +910,7 @@ def test_function_rejects_dynamic_namespace_access(access):
)
with pytest.raises(ValueError, match="dynamic namespace access"):
_package_from_text(
"def score(value: int) -> int:\n"
" import sys\n"
f" return value + {access}\n"
f"def score(value: int) -> int:\n return value + {access}\n"
)
@@ -466,6 +466,126 @@ def test_nested_class_and_closure_are_rejected_with_the_reason():
return value * factor
@pytest.mark.parametrize(
"module_name",
[
"sys",
"builtins",
"importlib",
"inspect",
"gc",
"ctypes",
"types",
"ctypes.util",
"importlib.metadata",
],
)
@pytest.mark.parametrize("import_style", ["import", "from", "global"])
@pytest.mark.parametrize("class_function", [False, True])
def test_namespace_module_errors_explain_the_rule_and_remedy(
helper_package, module_name, import_style, class_function
):
statement = f"import {module_name} as namespace_module"
body = f"{statement}\nreturn value\n"
preamble = ""
if import_style == "from":
body = f"from {module_name} import __name__\nreturn value\n"
elif import_style == "global":
preamble = statement + "\n"
body = "return str(namespace_module)\n"
if class_function:
definition = "class blocked:\n def __call__(self, value: str) -> str:\n"
source = preamble + definition + textwrap.indent(body, " ")
else:
definition = "def blocked(value: str) -> str:\n"
source = preamble + definition + textwrap.indent(body, " ")
module = helper_package("fx_namespace_import", {"__init__.py": source})
with pytest.raises(ValueError) as error:
function(module.blocked)
message = str(error.value)
assert "imports from namespace-access modules" in message
assert repr(module_name) in message
assert "builtins, ctypes, gc, importlib, inspect, sys, types" in message
assert "including submodules" in message
assert "reconstructed module namespace" in message
assert "importable helper module" in message
assert "code=, pip=, or conda=" in message
assert "dynamic namespace access" not in message
@pytest.mark.filterwarnings("ignore:lancedb.udf is deprecated:DeprecationWarning")
def test_udf_namespace_import_error_explains_the_helper_alternative():
with pytest.raises(ValueError, match="ctypes.util.*importable helper module"):
@udf
def probe(value: str) -> str:
import ctypes.util
return str(ctypes.util.find_library(value))
@pytest.mark.parametrize(
"operation", ["globals", "locals", "vars", "eval", "exec", "compile", "__import__"]
)
def test_dynamic_namespace_errors_name_the_operation_and_remedy(
helper_package, operation
):
module = helper_package(
"fx_namespace_operation",
{
"__init__.py": (
f"def blocked(value: str) -> str:\n return str({operation})\n"
)
},
)
with pytest.raises(ValueError) as error:
function(module.blocked)
message = str(error.value)
assert f"dynamic namespace access: ['{operation}']" in message
assert "Reference dependencies explicitly" in message
assert "importable helper module" in message
@pytest.mark.parametrize("ship_source", [False, True])
def test_namespace_access_in_importable_helpers_keeps_its_module_namespace(
helper_package, ship_source
):
helpers = helper_package(
"fx_namespace_helper",
{
"__init__.py": """
import ctypes.util
def module_name():
return ctypes.util.__name__
""",
},
)
entry = helper_package(
"fx_namespace_entry",
{
"__init__.py": """
from fx_namespace_helper import module_name
def probe(value: str) -> str:
return module_name() + value
""",
},
)
options = {"code": [helpers]} if ship_source else {"pip": ["fx-namespace-helper"]}
definition = function(**options)(entry.probe)
if ship_source:
files = bundle_files(definition)
assert "import ctypes.util" in files["fx_namespace_helper/__init__.py"]
source = files["user_function.py"]
else:
source = artifact(definition).decode()
namespace = {}
exec(compile(source, "user_function.py", "exec"), namespace)
assert namespace["probe"]("!") == "ctypes.util!"
def test_binding_passes_initialization_as_constants():
version = FunctionVersion.from_json(
json.dumps(