feat(python)!: rename the @udf decorator to @function (#4837)

The client registers Functions, but the decorator that packages one was
called `udf` and produced a `UdfDefinition`. Rename them to `function`
and `FunctionDefinition`, and say "function" instead of "UDF" in the
docstrings, error messages and docs around them.

`udf` and `UdfDefinition` stay as deprecated aliases that emit a
`DeprecationWarning`. The source packager strips either spelling of the
decorator, so `@udf` and `@function` produce the same artifact and
digest, and the wire contract is unchanged. DataFusion and Lance UDF
types, and Geneva's `@udf` in the integration docs, are unrelated and
untouched.
This commit is contained in:
Wyatt Alt
2026-11-05 00:36:20 -08:00
committed by GitHub
parent 7abc57a4d0
commit b5c5ede39f
16 changed files with 367 additions and 289 deletions
+11 -1
View File
@@ -3,6 +3,7 @@
import os
import warnings
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta
from typing import Dict, Optional, Union, Any, List, Iterable, Literal, TYPE_CHECKING
@@ -39,12 +40,13 @@ from .functions import (
FunctionArtifactRequest as FunctionArtifactRequest,
FunctionApplication as FunctionApplication,
FunctionBinding as FunctionBinding,
FunctionDefinition as FunctionDefinition,
FunctionRegistrationRequest as FunctionRegistrationRequest,
FunctionVersion as FunctionVersion,
HuggingFaceModel as HuggingFaceModel,
PythonRuntimeSpec as PythonRuntimeSpec,
RefreshColumnResult as RefreshColumnResult,
UdfDefinition as UdfDefinition,
function as function,
udf as udf,
)
from .secrets import EnvVarSecret as EnvVarSecret
@@ -77,6 +79,7 @@ from .catalog import (
if TYPE_CHECKING:
from lance.blob import BlobType as BlobType
from .functions import UdfDefinition as UdfDefinition
def __getattr__(name: str):
@@ -85,6 +88,13 @@ def __getattr__(name: str):
globals()["BlobType"] = BlobType
return BlobType
if name == "UdfDefinition":
from . import functions
warnings.warn(
functions.UDF_DEFINITION_DEPRECATION, DeprecationWarning, stacklevel=2
)
return functions.FunctionDefinition
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+12 -12
View File
@@ -58,7 +58,7 @@ from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
from . import __version__
from ._lancedb import _append_doc_url, connect as lancedb_connect # type: ignore
from .functions import FunctionVersion, UdfDefinition
from .functions import FunctionVersion, FunctionDefinition
from .graph import (
AsyncPropertyGraph,
EdgeTable,
@@ -958,12 +958,12 @@ class DBConnection(EnforceOverrides):
def create_function(
self,
definition: UdfDefinition,
definition: FunctionDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
namespace_path: Optional[List[str]] = None,
) -> FunctionVersion:
"""Build and register a scalar Python UDF, then return its version.
"""Build and register a scalar Python function, then return its version.
The server builds the OCI image and registers the completed artifact.
This is the blocking counterpart of :meth:`create_function_async`.
@@ -971,8 +971,8 @@ class DBConnection(EnforceOverrides):
Parameters
----------
definition : UdfDefinition
A callable decorated with [udf][lancedb.udf].
definition : FunctionDefinition
A callable decorated with [function][lancedb.function].
secrets : sequence of EnvVarSecret, optional
One [EnvVarSecret][lancedb.secrets.EnvVarSecret] per credential the
Function needs, each naming a Secret and the environment variable
@@ -1002,12 +1002,12 @@ class DBConnection(EnforceOverrides):
def create_function_async(
self,
definition: UdfDefinition,
definition: FunctionDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
namespace_path: Optional[List[str]] = None,
) -> Job[FunctionVersion]:
"""Submit a scalar Python UDF for building and registration.
"""Submit a scalar Python function for building and registration.
The server-side job builds the OCI image, then registers the completed
artifact. The returned job has type ``query`` and the SQL query UUID as
@@ -2284,7 +2284,7 @@ class LanceDBConnection(DBConnection):
@override
def create_function_async(
self,
definition: UdfDefinition,
definition: FunctionDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
namespace_path: Optional[List[str]] = None,
@@ -3573,12 +3573,12 @@ class AsyncConnection(object):
async def create_function_async(
self,
definition: UdfDefinition,
definition: FunctionDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
namespace_path: Optional[List[str]] = None,
) -> AsyncJob[FunctionVersion]:
"""Submit a scalar Python UDF for building and registration.
"""Submit a scalar Python function for building and registration.
The server-side job builds the OCI image, then registers the completed
artifact. The returned job has type ``query`` and the SQL query UUID as
@@ -3589,8 +3589,8 @@ class AsyncConnection(object):
the namespace the Function is registered in; None or an empty list is
the root. LanceDB Embedded connections raise ``NotImplementedError``.
"""
if not isinstance(definition, UdfDefinition):
raise TypeError("create_function_async requires a @udf definition")
if not isinstance(definition, FunctionDefinition):
raise TypeError("create_function_async requires a @function definition")
request = definition.bind_secrets(secrets)
inner = await self._inner.create_function_async(
request.to_canonical_json(), namespace_path
+85 -51
View File
@@ -35,6 +35,7 @@ from typing import (
Callable,
Literal,
Optional,
TYPE_CHECKING,
Union,
get_args,
get_origin,
@@ -333,7 +334,7 @@ class HuggingFaceModel(_RemoteValue):
snapshot before initialization, so the model can load without a revision
argument or a Hugging Face Hub request::
@udf(
@function(
pip=["sentence-transformers==3.3.1"],
models=[HuggingFaceModel(
repo="sentence-transformers/all-MiniLM-L6-v2",
@@ -482,8 +483,8 @@ class FunctionVersion(_RemoteValue):
``source_digest`` identifies the registered source artifact, independently
of the built ``image``. Compare it with
``udf_def.registration_request.artifact.digest`` to check whether a local
[UdfDefinition][lancedb.functions.UdfDefinition] has the same source.
``definition.registration_request.artifact.digest`` to check whether a local
[FunctionDefinition][lancedb.functions.FunctionDefinition] has the same source.
It is ``None`` when the server does not provide a source digest.
``gpu_class`` is the operator-defined GPU pool fixed into this Function
@@ -639,7 +640,7 @@ def _initialization_value(name: str, value: Any) -> Any:
class FunctionRegistrationRequest(_RemoteValue):
"""Stable remote registration envelope produced by :func:`udf`.
"""Stable remote registration envelope produced by :func:`function`.
Credential values deliberately have no field here. The only secret-shaped
thing a client sends is ``secret_bindings``: the name of a Secret the
@@ -1375,11 +1376,15 @@ def _infer_signature(
return FunctionSignature(inputs=tuple(inputs), output=output)
def _is_udf_decorator(node: ast.expr) -> bool:
# Both spellings strip from packaged source; `udf` is the deprecated alias.
_DECORATOR_NAMES = frozenset({"function", "udf"})
def _is_function_decorator(node: ast.expr) -> bool:
if isinstance(node, ast.Call):
node = node.func
return (isinstance(node, ast.Name) and node.id == "udf") or (
isinstance(node, ast.Attribute) and node.attr == "udf"
return (isinstance(node, ast.Name) and node.id in _DECORATOR_NAMES) or (
isinstance(node, ast.Attribute) and node.attr in _DECORATOR_NAMES
)
@@ -1444,7 +1449,7 @@ def _module_references(module_source: str) -> set[str]:
visit(child, found)
found: set[str] = set()
module = symtable.symtable(module_source, "<udf>", "exec")
module = symtable.symtable(module_source, "<function>", "exec")
for symbol in module.get_symbols():
if (
symbol.is_referenced()
@@ -1517,7 +1522,7 @@ def _definition_node(
)
except (OSError, TypeError) as error:
raise ValueError(
f"@udf requires inspectable Python source for {target.__qualname__!r}"
f"@function requires inspectable Python source for {target.__qualname__!r}"
) from error
module = ast.parse(source)
is_class = inspect.isclass(target)
@@ -1532,15 +1537,16 @@ def _definition_node(
]
if len(definitions) != 1 or isinstance(definitions[0], ast.AsyncFunctionDef):
raise ValueError(
"@udf source must contain exactly one "
"@function source must contain exactly one "
+ ("class" if is_class else "synchronous function")
)
definition = definitions[0]
if entry:
if any(
not _is_udf_decorator(decorator) for decorator in definition.decorator_list
not _is_function_decorator(decorator)
for decorator in definition.decorator_list
):
raise ValueError("@udf cannot package additional Python decorators")
raise ValueError("@function cannot package additional Python decorators")
definition.decorator_list = []
return definition
@@ -1551,7 +1557,7 @@ def _definition_namespace(target: Any) -> Mapping[str, Any]:
module = sys.modules.get(target.__module__)
if module is None:
raise ValueError(
f"@udf cannot resolve the module of {target.__qualname__!r}"
f"@function cannot resolve the module of {target.__qualname__!r}"
)
namespace = vars(module)
environment = namespace.get("__builtins__", builtins)
@@ -1566,7 +1572,7 @@ def _definition_namespace(target: Any) -> Mapping[str, Any]:
# The artifact runs under the standard builtins; only the exact mapping is
# provably equivalent (a subclass or copy can change lookups and hooks).
if environment is not vars(builtins):
raise ValueError("@udf cannot package a non-standard builtins environment")
raise ValueError("@function cannot package a non-standard builtins environment")
return namespace
@@ -1580,7 +1586,7 @@ def _is_self_reference(target: Any, namespace: Mapping[str, Any], name: str) ->
return True
# The decorator's own result is the one wrapper known to behave like
# `target`; any other binding may differ from the packaged definition.
return type(bound) is UdfDefinition and bound._function is target
return type(bound) is FunctionDefinition and bound._function is target
def _packaged_by_source(value: Any) -> bool:
@@ -1593,7 +1599,7 @@ def _packaged_by_source(value: Any) -> bool:
qualname = value.__qualname__
if "<" in qualname or "." in qualname:
raise ValueError(
f"@udf cannot package {qualname!r}: only module-level functions and "
f"@function cannot package {qualname!r}: only module-level functions and "
"classes travel with a Function, not lambdas, closures, or nested "
"definitions"
)
@@ -1689,7 +1695,7 @@ class _SourcePackager:
definition = _definition_node(target, entry=entry)
if not inspect.isclass(target) and inspect.getclosurevars(target).nonlocals:
raise ValueError(
"@udf cannot package functions that capture closure values"
"@function cannot package functions that capture closure values"
)
namespace = _definition_namespace(target)
reader_name = (
@@ -1702,7 +1708,7 @@ class _SourcePackager:
dynamic = _namespace_acquisition(definition, references)
if dynamic:
raise ValueError(
f"@udf cannot package dynamic namespace access: {dynamic!r}"
f"@function cannot package dynamic namespace access: {dynamic!r}"
)
self._warn_unshipped_direct_imports(definition)
self._packaged.add(id(target))
@@ -1714,9 +1720,9 @@ class _SourcePackager:
if name == target.__name__:
if not _is_self_reference(target, namespace, name):
raise ValueError(
f"@udf cannot package {name!r}: the module binds that name "
"to another value, which the artifact's own definition "
"would shadow"
f"@function cannot package {name!r}: the module binds that "
"name to another value, which the artifact's own "
"definition would shadow"
)
continue
if name in namespace:
@@ -1725,7 +1731,7 @@ class _SourcePackager:
unresolved.append(name)
if unresolved:
raise ValueError(
f"@udf source contains unresolved global names: {unresolved!r}"
f"@function source contains unresolved global names: {unresolved!r}"
)
return source
@@ -1771,7 +1777,7 @@ class _SourcePackager:
claimed = self._names.setdefault(name, meaning)
if claimed != meaning:
raise ValueError(
f"@udf cannot package {name!r}: packaged definitions bind that "
f"@function cannot package {name!r}: packaged definitions bind that "
"name to different values"
)
@@ -1779,8 +1785,8 @@ class _SourcePackager:
if _packaged_by_source(value):
if value.__name__ != name:
raise ValueError(
f"@udf packages {value.__qualname__!r} by source, so it must be "
f"referenced by its own name, not {name!r}"
f"@function packages {value.__qualname__!r} by source, so it "
f"must be referenced by its own name, not {name!r}"
)
if id(value) not in self._packaged:
self._definitions.append(self._definition(value, entry=False))
@@ -1799,7 +1805,8 @@ class _SourcePackager:
if isinstance(value, types.ModuleType):
if value.__name__.split(".")[0] in _NAMESPACE_MODULES:
raise ValueError(
f"@udf cannot package dynamic namespace access: {value.__name__!r}"
"@function cannot package dynamic namespace access: "
f"{value.__name__!r}"
)
try:
imported = importlib.import_module(value.__name__)
@@ -1854,7 +1861,7 @@ class _SourcePackager:
return
self._warned_imports.add(top)
warnings.warn(
f"@udf source imports {module_name!r} from {locations[0]}, a local "
f"@function source imports {module_name!r} from {locations[0]}, a local "
"module the Function's environment may not be able to import; ship "
f"it with code=[{top}] or declare the package that provides it in "
"pip/conda",
@@ -2070,10 +2077,10 @@ def _class_call(cls: type) -> Callable[..., Any]:
return call
class UdfDefinition:
class FunctionDefinition:
"""A Python callable prepared for remote Function registration.
Instances are created with :func:`udf`. Calling an instance is ordinary
Instances are created with :func:`function`. Calling an instance is ordinary
local Python: a decorated function is called directly, while a decorated
class is constructed using its initialization fields and the returned
instance is called with each input. Reuse the instance and call its
@@ -2114,7 +2121,9 @@ class UdfDefinition:
if not is_class and (
not inspect.isfunction(function) or inspect.iscoroutinefunction(function)
):
raise TypeError("@udf requires a synchronous Python function or a class")
raise TypeError(
"@function requires a synchronous Python function or a class"
)
function_name = name or function.__name__
if not _FUNCTION_NAME.fullmatch(function_name):
raise ValueError(f"invalid Function name: {function_name!r}")
@@ -2181,7 +2190,7 @@ class UdfDefinition:
if is_class:
if "<" in function.__qualname__ or "." in function.__qualname__:
raise ValueError(
"@udf classes must be defined at module level; nested classes "
"@function classes must be defined at module level; nested classes "
"would capture their enclosing scope"
)
signature = _infer_signature(
@@ -2327,12 +2336,12 @@ class UdfDefinition:
@overload
def udf(function: Callable[..., Any]) -> UdfDefinition: ...
def function(target: Callable[..., Any]) -> FunctionDefinition: ...
@overload
def udf(
function: None = None,
def function(
target: None = None,
*,
name: Optional[str] = None,
input_schema: Optional[pa.Schema] = None,
@@ -2350,11 +2359,11 @@ def udf(
image: Optional[str] = None,
code: Sequence[types.ModuleType] = (),
models: Sequence[HuggingFaceModel] = (),
) -> Callable[[Callable[..., Any]], UdfDefinition]: ...
) -> Callable[[Callable[..., Any]], FunctionDefinition]: ...
def udf(
function: Optional[Callable[..., Any]] = None,
def function(
target: Optional[Callable[..., Any]] = None,
*,
name: Optional[str] = None,
input_schema: Optional[pa.Schema] = None,
@@ -2511,7 +2520,7 @@ def udf(
files are not copied. To use a package that includes required data
resources, install it with ``pip=`` or ``conda=`` and remove that
package from ``code=`` so the copied Python package cannot shadow the
installed distribution. ``@udf`` warns when it skips files from a
installed distribution. ``@function`` warns when it skips files from a
package in ``code=``.
models : sequence of HuggingFaceModel, optional
Immutable Hugging Face repository snapshots prepared in the worker
@@ -2523,7 +2532,7 @@ def udf(
Returns
-------
UdfDefinition
FunctionDefinition
A callable definition accepted by
:meth:`lancedb.db.DBConnection.create_function`,
:meth:`lancedb.db.AsyncConnection.create_function_async` and
@@ -2531,14 +2540,14 @@ def udf(
Examples
--------
>>> from lancedb import udf
>>> @udf(pip=["numpy==2.2.0"])
>>> from lancedb import function
>>> @function(pip=["numpy==2.2.0"])
... def score(value: float) -> float:
... return value * 2
>>> score(1.5)
3.0
>>> import pyarrow as pa
>>> @udf(
>>> @function(
... input_schema=pa.schema([pa.field("value", pa.int64(), nullable=False)]),
... output_schema=pa.int64(),
... )
@@ -2546,14 +2555,14 @@ def udf(
... return value
>>> batch_identity(pa.array([1, 2, 3])).to_pylist()
[1, 2, 3]
>>> @udf(pip=["cupy-cuda12x"], gpu=True, gpu_class="a10g")
>>> @function(pip=["cupy-cuda12x"], gpu=True, gpu_class="a10g")
... def gpu_score(value: int) -> int:
... return value * 2
>>> gpu_score.registration_request.runtime.gpu
True
>>> gpu_score.registration_request.runtime.gpu_class
'a10g'
>>> @udf
>>> @function
... class scale:
... def __init__(self, factor: float, offset: float = 0.0):
... self.factor, self.offset = factor, offset
@@ -2570,7 +2579,7 @@ def udf(
>>> [field.name for field in scale.registration_request.signature.initialization]
['factor', 'offset']
>>> import lancedb
>>> @udf(
>>> @function(
... input_schema=pa.schema([lancedb.blob("image", nullable=False)]),
... output_schema=lancedb.blob("copy", nullable=False),
... )
@@ -2611,8 +2620,8 @@ def udf(
``blob_mode="bytes"`` cannot fetch payloads inside lists.
"""
def decorate(target: Any) -> UdfDefinition:
return UdfDefinition(
def decorate(target: Any) -> FunctionDefinition:
return FunctionDefinition(
target,
name=name,
input_schema=input_schema,
@@ -2632,9 +2641,9 @@ def udf(
models=tuple(models),
)
if function is None:
if target is None:
return decorate
return decorate(function)
return decorate(target)
__all__ = [
@@ -2660,6 +2669,31 @@ __all__ = [
"PythonAdapterSpec",
"PythonRuntimeSpec",
"RefreshColumnResult",
"UdfDefinition",
"udf",
"FunctionDefinition",
]
def udf(*args: Any, **kwargs: Any) -> Any:
"""Deprecated alias of :func:`function`."""
warnings.warn(
"lancedb.udf is deprecated; use lancedb.function",
DeprecationWarning,
stacklevel=2,
)
return function(*args, **kwargs)
UDF_DEFINITION_DEPRECATION = (
"lancedb.UdfDefinition is deprecated; use lancedb.FunctionDefinition"
)
def __getattr__(name: str) -> Any:
if name == "UdfDefinition":
warnings.warn(UDF_DEFINITION_DEPRECATION, DeprecationWarning, stacklevel=2)
return FunctionDefinition
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
if TYPE_CHECKING:
UdfDefinition = FunctionDefinition
+2 -2
View File
@@ -38,7 +38,7 @@ import pyarrow as pa
from ..common import DATA
from lancedb.listing import Listing, NamespaceListing, TableListing
from ..db import DBConnection, LOOP
from ..functions import FunctionVersion, UdfDefinition
from ..functions import FunctionVersion, FunctionDefinition
from ..graph import EdgeTable, NodeTable, PropertyGraph
from ..job import AsyncJob, Job
from ..sql import Query as SqlQuery
@@ -968,7 +968,7 @@ class RemoteDBConnection(DBConnection):
@override
def create_function_async(
self,
definition: UdfDefinition,
definition: FunctionDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
namespace_path: Optional[List[str]] = None,
+2 -2
View File
@@ -10,7 +10,7 @@ import lancedb
import numpy as np
import pyarrow as pa
import pytest
from lancedb import LanceDBConnection, udf
from lancedb import LanceDBConnection, function
from lancedb.index import FTS
# TODO: setup integ test mark and script
@@ -98,7 +98,7 @@ def test_function_execution_controls_through_sql_and_materialized_view():
view_name = f"batch_probe_view_{suffix}"
registered_version = None
@udf(
@function(
name=function_name,
input_schema=pa.schema([pa.field("value", pa.int64(), nullable=False)]),
output_schema=pa.int64(),
@@ -32,11 +32,11 @@ from lancedb.functions import (
PythonRuntimeSpec,
SecretBinding,
SecretReference,
UdfDefinition,
FunctionDefinition,
_canonical_arrow_type,
_exact_arrow_type,
_GRAMMAR_PRIMITIVES,
udf,
function,
)
from lancedb.secrets import EnvVarSecret
from lancedb.remote.db import RemoteDBConnection
@@ -62,7 +62,7 @@ FUNCTION_VERSION = json.loads(
)["version"]
@udf(
@function(
pip=["numpy>=2"],
env={"MODE": "test"},
python_version="3.12",
@@ -71,7 +71,7 @@ def normalize_score(value: float) -> float:
return value / 100.0
@udf(
@function(
pip=["openai==3.7.0"],
env={"MODE": "test"},
python_version="3.12",
@@ -80,8 +80,8 @@ def analyze_caption(caption: str) -> str:
return caption.strip()
def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
assert isinstance(normalize_score, UdfDefinition)
def test_scalar_function_matches_shared_registration_golden_and_remains_callable():
assert isinstance(normalize_score, FunctionDefinition)
assert normalize_score(25.0) == 0.25
assert (
normalize_score.registration_request.to_canonical_json()
@@ -96,13 +96,13 @@ def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
}
def test_udf_declares_pinned_hugging_face_models():
def test_function_declares_pinned_hugging_face_models():
model = HuggingFaceModel(
repo="sentence-transformers/all-MiniLM-L6-v2",
revision="1110a243fdf4706b3f48f1d95db1a4f5529b4d41",
)
@udf(pip=["sentence-transformers==3.3.1"], models=[model])
@function(pip=["sentence-transformers==3.3.1"], models=[model])
def embed_text(value: str) -> list[float]:
return [1.0]
@@ -117,19 +117,19 @@ def test_udf_declares_pinned_hugging_face_models():
assert "models" not in undeclared
with pytest.raises(ValueError, match="once"):
udf(name="duplicate_model", models=[model, model])(lambda value: value)
function(name="duplicate_model", models=[model, model])(lambda value: value)
with pytest.raises(ValueError, match="40-character"):
HuggingFaceModel(repo="owner/model", revision="main")
with pytest.raises(ValueError, match="reserve"):
udf(
function(
name="reserved_model_env",
models=[model],
env={"HF_HOME": "/tmp/model"},
)(lambda value: value)
def test_udf_execution_controls_match_shared_registration_golden():
controlled = udf(
def test_function_execution_controls_match_shared_registration_golden():
controlled = function(
pip=["numpy>=2"],
env={"MODE": "test"},
python_version="3.12",
@@ -144,8 +144,8 @@ def test_udf_execution_controls_match_shared_registration_golden():
)
def test_udf_execution_controls_apply_to_vectorized_functions():
@udf(
def test_function_execution_controls_apply_to_vectorized_functions():
@function(
input_schema=pa.schema([pa.field("value", pa.int64(), nullable=False)]),
output_schema=pa.int64(),
batch_size=3,
@@ -171,28 +171,28 @@ def test_udf_execution_controls_apply_to_vectorized_functions():
({"row_timeout_seconds": True}, "row_timeout_seconds"),
],
)
def test_udf_execution_controls_require_their_positive_ranges(options, field):
def test_function_execution_controls_require_their_positive_ranges(options, field):
def identity(value: int) -> int:
return value
with pytest.raises(ValueError, match=field):
udf(**options)(identity)
function(**options)(identity)
@pytest.mark.parametrize(
"name", ["examples.image_info", "image-info", "image_info.", "image_info-"]
)
def test_udf_rejects_function_names_with_periods_or_hyphens(name):
def test_function_rejects_function_names_with_periods_or_hyphens(name):
with pytest.raises(ValueError, match="invalid Function name"):
@udf(name=name)
@function(name=name)
def image_info(value: int) -> int:
return value
@pytest.mark.parametrize("name", ["image_info", "ImageInfo123", "_image_info", "f"])
def test_udf_accepts_function_names_with_letters_digits_and_underscores(name):
@udf(name=name)
def test_function_accepts_function_names_with_letters_digits_and_underscores(name):
@function(name=name)
def image_info(value: int) -> int:
return value
@@ -200,7 +200,7 @@ def test_udf_accepts_function_names_with_letters_digits_and_underscores(name):
assert image_info(1) == 1
def test_secret_bound_udf_matches_its_shared_registration_golden():
def test_secret_bound_function_matches_its_shared_registration_golden():
assert analyze_caption(" hello ") == "hello"
bound = analyze_caption.bind_secrets(
[EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")]
@@ -446,15 +446,15 @@ def test_a_secret_name_admits_what_a_namespace_name_does():
)
def _main_udf_source(
def _main_function_source(
*, threshold: int = 20, input_annotation: str = "int", comparison: str = ">="
) -> str:
return (
"from __future__ import annotations\n"
"from lancedb.functions import udf\n"
"from lancedb.functions import function\n"
f"THRESHOLD = {threshold}\n"
"\n"
"@udf\n"
"@function\n"
f"def label(value: {input_annotation}) -> str:\n"
f" return 'big' if value {comparison} THRESHOLD else 'small'\n"
"\n"
@@ -463,7 +463,7 @@ def _main_udf_source(
)
def _run_main_udf(path: Path, source: str) -> dict:
def _run_main_function(path: Path, source: str) -> dict:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(source)
result = subprocess.run(
@@ -475,15 +475,15 @@ def _run_main_udf(path: Path, source: str) -> dict:
return json.loads(result.stdout)
def test_main_udf_registration_identity_is_stable_across_processes_and_paths(
def test_main_function_registration_identity_is_stable_across_processes_and_paths(
tmp_path,
):
source = _main_udf_source()
source = _main_function_source()
original_path = tmp_path / "original" / "job.py"
moved_path = tmp_path / "moved" / "renamed_job.py"
original_runs = [_run_main_udf(original_path, source) for _ in range(2)]
moved_run = _run_main_udf(moved_path, source)
original_runs = [_run_main_function(original_path, source) for _ in range(2)]
moved_run = _run_main_function(moved_path, source)
assert len({run["artifact"]["digest"] for run in [*original_runs, moved_run]}) == 1
assert all(
@@ -492,15 +492,15 @@ def test_main_udf_registration_identity_is_stable_across_processes_and_paths(
)
assert original_runs[0] == original_runs[1] == moved_run
body_change = _run_main_udf(
tmp_path / "changes" / "body.py", _main_udf_source(comparison=">")
body_change = _run_main_function(
tmp_path / "changes" / "body.py", _main_function_source(comparison=">")
)
global_change = _run_main_udf(
tmp_path / "changes" / "global.py", _main_udf_source(threshold=21)
global_change = _run_main_function(
tmp_path / "changes" / "global.py", _main_function_source(threshold=21)
)
annotation_change = _run_main_udf(
annotation_change = _run_main_function(
tmp_path / "changes" / "annotation.py",
_main_udf_source(input_annotation="float"),
_main_function_source(input_annotation="float"),
)
baseline = original_runs[0]
@@ -524,11 +524,13 @@ def _run_packaged(definition, *args):
"""Execute the shipped artifact in a fresh namespace, as a worker would."""
source = base64.b64decode(definition.registration_request.artifact.content.data)
namespace: dict = {}
exec(compile(source, "<udf>", "exec"), namespace)
exec(compile(source, "<function>", "exec"), namespace)
return namespace[definition.registration_request.artifact.entrypoint](*args)
def _execute_registered_array_udf_v1(request: dict, batch: pa.RecordBatch) -> pa.Array:
def _execute_registered_array_function_v1(
request: dict, batch: pa.RecordBatch
) -> pa.Array:
"""Exercise adapter-v1 annotation dispatch over a registered artifact."""
assert request["artifact"]["adapter"] == {
"kind": "scalar_to_arrow_batch",
@@ -542,7 +544,7 @@ def _execute_registered_array_udf_v1(request: dict, batch: pa.RecordBatch) -> pa
"RecordBatch": pa.RecordBatch,
"typing": typing,
}
exec(compile(source, "<registered-udf>", "exec"), namespace)
exec(compile(source, "<registered-function>", "exec"), namespace)
function = namespace[request["artifact"]["entrypoint"]]
parameters = tuple(inspect.signature(function).parameters.values())
annotations = typing.get_type_hints(function)
@@ -555,8 +557,8 @@ def _execute_registered_array_udf_v1(request: dict, batch: pa.RecordBatch) -> pa
return result
def test_udf_conda_environment():
@udf(conda=["scipy", "numpy"], conda_channels=["conda-forge", "defaults"])
def test_function_conda_environment():
@function(conda=["scipy", "numpy"], conda_channels=["conda-forge", "defaults"])
def halve(value: float) -> float:
return value / 2
@@ -570,13 +572,13 @@ def test_udf_conda_environment():
assert "channels" not in pip_request["runtime"]["environment"]
with pytest.raises(ValueError, match="not both"):
udf(name="both", pip=["numpy"], conda=["numpy"])(lambda value: value)
function(name="both", pip=["numpy"], conda=["numpy"])(lambda value: value)
with pytest.raises(ValueError, match="requires conda"):
udf(name="channels", conda_channels=["conda-forge"])(lambda value: value)
function(name="channels", conda_channels=["conda-forge"])(lambda value: value)
def test_udf_apt_system_packages():
@udf(pip=["torchcodec"], apt=["libavutil58", "libavcodec60", "libavutil58"])
def test_function_apt_system_packages():
@function(pip=["torchcodec"], apt=["libavutil58", "libavcodec60", "libavutil58"])
def decode(value: float) -> float:
return value
@@ -587,7 +589,7 @@ def test_udf_apt_system_packages():
"system_packages": ["libavcodec60", "libavutil58"],
}
@udf(conda=["numpy"], apt=["libsndfile1"])
@function(conda=["numpy"], apt=["libsndfile1"])
def conda_decode(value: float) -> float:
return value
@@ -596,14 +598,14 @@ def test_udf_apt_system_packages():
pip_request = json.loads(normalize_score.registration_request.to_canonical_json())
assert "system_packages" not in pip_request["runtime"]["environment"]
with pytest.raises(ValueError, match="trimmed"):
udf(name="padded", apt=[" libsndfile1"])(lambda value: value)
function(name="padded", apt=[" libsndfile1"])(lambda value: value)
@pytest.mark.parametrize("packages", [{"pip": ["numpy"]}, {"conda": ["numpy"]}])
def test_udf_image(packages):
def test_function_image(packages):
image = "ghcr.io/acme/embedder@sha256:" + "a" * 64
@udf(image=image, **packages)
@function(image=image, **packages)
def embed(value: float) -> float:
return value
@@ -616,13 +618,13 @@ def test_udf_image(packages):
@pytest.mark.parametrize(
"image", ["ghcr.io/acme/embedder:latest", "embedder@sha256:" + "a" * 64, ""]
)
def test_udf_image_requires_digest_reference(image):
def test_function_image_requires_digest_reference(image):
with pytest.raises(ValueError, match="sha256"):
udf(name="pinned", image=image)(lambda value: value)
function(name="pinned", image=image)(lambda value: value)
def test_udf_gpu_marker_uses_gpu_runtime():
@udf(pip=["cupy-cuda12x"], gpu=True, gpu_class="a10g")
def test_function_gpu_marker_uses_gpu_runtime():
@function(pip=["cupy-cuda12x"], gpu=True, gpu_class="a10g")
def double_on_gpu(value: int) -> int:
return value * 2
@@ -631,7 +633,7 @@ def test_udf_gpu_marker_uses_gpu_runtime():
assert request["runtime"]["gpu"] is True
assert request["runtime"]["gpu_class"] == "a10g"
@udf(pip=["pyarrow"])
@function(pip=["pyarrow"])
def cpu_function(value: int) -> int:
return value
@@ -647,12 +649,12 @@ def test_udf_gpu_marker_uses_gpu_runtime():
for invalid in [None, 0, 1, -1, 1.5, "", "true", "1", "H100"]:
with pytest.raises(ValueError, match="gpu must be a boolean"):
udf(name="invalid_gpu", gpu=invalid)(identity)
function(name="invalid_gpu", gpu=invalid)(identity)
for invalid in ["H100", "a_b", "-a10g", "a10g-", "a" * 64]:
with pytest.raises(ValueError, match="gpu_class"):
udf(name="invalid_gpu_class", gpu=True, gpu_class=invalid)(identity)
function(name="invalid_gpu_class", gpu=True, gpu_class=invalid)(identity)
with pytest.raises(ValueError, match="requires gpu=True"):
udf(name="gpu_class_without_gpu", gpu_class="a10g")(identity)
function(name="gpu_class_without_gpu", gpu_class="a10g")(identity)
base_runtime = {
"kind": "python_v2",
@@ -692,8 +694,8 @@ def test_unknown_runtime_discards_payload_before_known_field_validation():
assert runtime.to_canonical_json() == '{"kind":"python_v3"}'
def test_udf_packages_attribute_access_and_body_imports():
@udf
def test_function_packages_attribute_access_and_body_imports():
@function
def word_norm(body: str) -> float:
import numpy as np
@@ -706,14 +708,14 @@ def test_udf_packages_attribute_access_and_body_imports():
assert _run_packaged(word_norm, "aa bb") == pytest.approx(8**0.5)
def test_udf_packages_module_globals_and_global_caches():
@udf
def test_function_packages_module_globals_and_global_caches():
@function
def label(value: int) -> str:
return "big" if value >= THRESHOLD else "small"
assert _run_packaged(label, 21) == "big"
@udf
@function
def cached(value: int) -> int:
global _CACHE
if _CACHE is None:
@@ -723,16 +725,16 @@ def test_udf_packages_module_globals_and_global_caches():
assert _run_packaged(cached, 2) == 42
def test_udf_annotations_are_not_runtime_names():
@udf
def test_function_annotations_are_not_runtime_names():
@function
def identity(value: date) -> date:
return value
assert _run_packaged(identity, date(2026, 8, 25)) == date(2026, 8, 25)
def test_udf_nested_scopes_resolve_lexically():
@udf
def test_function_nested_scopes_resolve_lexically():
@function
def score(value: int) -> int:
offset = 2
@@ -744,7 +746,7 @@ def test_udf_nested_scopes_resolve_lexically():
assert _run_packaged(score, 3) == 5
def test_udf_resolves_module_globals_before_builtins(tmp_path):
def test_function_resolves_module_globals_before_builtins(tmp_path):
module_path = tmp_path / "shadowing_udfs.py"
module_path.write_text(
"max = 7\n"
@@ -765,10 +767,10 @@ def test_udf_resolves_module_globals_before_builtins(tmp_path):
spec.loader.exec_module(module)
# The module's `max = 7` is what the interpreter would use, so it ships.
assert _run_packaged(udf(module.uses_literal_shadow), 1) == 8
assert _run_packaged(function(module.uses_literal_shadow), 1) == 8
# A callable global cannot ship; it must not be silently swapped for the builtin.
with pytest.raises(TypeError, match="unsupported global value of type function"):
udf(module.uses_callable_shadow)
function(module.uses_callable_shadow)
def test_canonical_arrow_type_prefers_the_compact_grammar():
@@ -801,8 +803,8 @@ def test_canonical_arrow_type_prefers_the_compact_grammar():
_canonical_arrow_type(outside)
def test_udf_nested_annotations_are_postponed_in_the_artifact():
@udf
def test_function_nested_annotations_are_postponed_in_the_artifact():
@function
def score(value: int) -> int:
def identity(item: date) -> date:
return item
@@ -813,8 +815,8 @@ def test_udf_nested_annotations_are_postponed_in_the_artifact():
assert _run_packaged(score, 3) == 3
def test_udf_ships_globals_the_body_deletes():
@udf
def test_function_ships_globals_the_body_deletes():
@function
def clear(value: int) -> int:
global _CACHE
del _CACHE
@@ -823,7 +825,7 @@ def test_udf_ships_globals_the_body_deletes():
assert _run_packaged(clear, 3) == 3
def test_udf_rejects_a_module_global_that_does_not_import_as_itself(tmp_path):
def test_function_rejects_a_module_global_that_does_not_import_as_itself(tmp_path):
module_path = tmp_path / "fake_module_udfs.py"
module_path.write_text(
"import types\n"
@@ -837,10 +839,10 @@ def test_udf_rejects_a_module_global_that_does_not_import_as_itself(tmp_path):
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
with pytest.raises(TypeError, match="does not import as 'numpy'"):
udf(module.score)
function(module.score)
def test_udf_rejects_a_module_level_namespace_alias(tmp_path):
def test_function_rejects_a_module_level_namespace_alias(tmp_path):
module_path = tmp_path / "aliasing_udfs.py"
module_path.write_text(
"import builtins as b\n"
@@ -853,7 +855,7 @@ def test_udf_rejects_a_module_level_namespace_alias(tmp_path):
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
with pytest.raises(ValueError, match="dynamic namespace access"):
udf(module.score)
function(module.score)
@pytest.mark.parametrize(
@@ -866,7 +868,7 @@ def test_udf_rejects_a_module_level_namespace_alias(tmp_path):
"sys.modules[__name__].THRESHOLD",
],
)
def test_udf_rejects_dynamic_namespace_access(access):
def test_function_rejects_dynamic_namespace_access(access):
namespace: dict = {}
exec(
f"def score(value: int) -> int:\n return value + {access}\n",
@@ -886,9 +888,11 @@ def _package_from_text(source: str, module_globals: dict | None = None):
import tempfile
directory = tempfile.mkdtemp()
path = Path(directory) / "generated_udf_module.py"
path = Path(directory) / "generated_function_module.py"
path.write_text(source)
spec = importlib.util.spec_from_file_location(f"generated_udf_{id(source)}", path)
spec = importlib.util.spec_from_file_location(
f"generated_function_{id(source)}", path
)
module = importlib.util.module_from_spec(spec)
if module_globals:
module.__dict__.update(module_globals)
@@ -898,10 +902,10 @@ def _package_from_text(source: str, module_globals: dict | None = None):
for value in vars(module).values()
if callable(value) and getattr(value, "__module__", None) == module.__name__
]
return udf(functions[0])
return function(functions[0])
def test_udf_rejects_a_non_standard_builtins_environment():
def test_function_rejects_a_non_standard_builtins_environment():
def score(value: int) -> int:
return len([1]) + value
@@ -916,7 +920,7 @@ def test_udf_rejects_a_non_standard_builtins_environment():
patched.__annotations__ = score.__annotations__
assert patched(3) == 102
with pytest.raises(ValueError, match="non-standard builtins environment"):
udf(patched)
function(patched)
class ReportingDict(dict): # reports standard entries, resolves differently
def __missing__(self, key):
@@ -928,7 +932,7 @@ def test_udf_rejects_a_non_standard_builtins_environment():
disguised.__annotations__ = score.__annotations__
assert disguised(3) == 102
with pytest.raises(ValueError, match="non-standard builtins environment"):
udf(disguised)
function(disguised)
hooked = types.FunctionType(
score.__code__,
@@ -937,10 +941,10 @@ def test_udf_rejects_a_non_standard_builtins_environment():
)
hooked.__annotations__ = score.__annotations__
with pytest.raises(ValueError, match="non-standard builtins environment"):
udf(hooked)
function(hooked)
def test_udf_recursion_versus_a_rebound_module_name(tmp_path):
def test_function_recursion_versus_a_rebound_module_name(tmp_path):
module_path = tmp_path / "rebound_udfs.py"
module_path.write_text(
"def fact(value: int) -> int:\n"
@@ -952,20 +956,20 @@ def test_udf_recursion_versus_a_rebound_module_name(tmp_path):
spec = importlib.util.spec_from_file_location("rebound_udfs", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert _run_packaged(udf(module.fact), 5) == 120
assert _run_packaged(function(module.fact), 5) == 120
raw = module.score
module.score = 10
with pytest.raises(ValueError, match="binds that name to another value"):
udf(raw)
function(raw)
# A wrapper that merely exposes __wrapped__ is not the function.
module.score = functools.wraps(raw)(lambda value: 41)
with pytest.raises(ValueError, match="binds that name to another value"):
udf(raw)
function(raw)
# The decorator's own result is; a subclass of it is not.
module.fact = udf(module.fact)
module.fact = function(module.fact)
assert _run_packaged(module.fact, 4) == 24
class Twisted(UdfDefinition):
class Twisted(FunctionDefinition):
def __call__(self, *args, **kwargs):
return 41
@@ -980,7 +984,7 @@ def test_udf_recursion_versus_a_rebound_module_name(tmp_path):
python_version=None,
)
with pytest.raises(ValueError, match="binds that name to another value"):
udf(raw_fact)
function(raw_fact)
def test_canonical_arrow_type_uses_exact_json_for_list_child_properties():
@@ -1051,12 +1055,12 @@ def _shadows_missing_in_a_lambda(value: int) -> int:
@pytest.mark.parametrize(
"function",
"target",
[_calls_missing, _shadows_missing_in_a_comprehension, _shadows_missing_in_a_lambda],
)
def test_udf_rejects_a_truly_unresolved_global(function):
def test_function_rejects_a_truly_unresolved_global(target):
with pytest.raises(ValueError, match=r"unresolved global names: \['missing'\]"):
udf(function)
function(target)
def _arrow_type_from_golden(spec: dict) -> pa.DataType:
@@ -1111,7 +1115,7 @@ def test_explicit_arrow_schema_is_deterministic():
nullable=False,
)
@udf(input_schema=input_schema, output_schema=output_schema)
@function(input_schema=input_schema, output_schema=output_schema)
def explicit(value):
return [value, value, value]
@@ -1123,7 +1127,7 @@ def test_explicit_arrow_schema_is_deterministic():
def test_blob_fields_use_the_scalar_function_semantic_type():
@udf(
@function(
input_schema=pa.schema([lancedb.blob("image", nullable=False)]),
output_schema=lancedb.blob("result", nullable=False),
)
@@ -1137,12 +1141,12 @@ def test_blob_fields_use_the_scalar_function_semantic_type():
def test_blob_input_access_is_inferred_from_annotations():
@udf
@function
def read_blob(video: BlobReader, timestamp_s: float) -> bytes:
video.seek(int(timestamp_s))
return video.read(4)
@udf
@function
def read_bytes(video: bytes) -> int:
return len(video)
@@ -1159,11 +1163,11 @@ def test_blob_input_access_is_inferred_from_annotations():
def test_nullable_blob_reader_and_explicit_blob_bytes():
@udf
@function
def nullable(video: Optional[BlobReader]) -> int:
return 0 if video is None else video.size()
@udf(
@function(
input_schema=pa.schema([lancedb.blob("video", nullable=False)]),
output_schema=pa.field("size", pa.int64(), nullable=False),
)
@@ -1183,7 +1187,7 @@ def test_blob_reader_rejects_an_incompatible_explicit_schema():
return video.size()
with pytest.raises(TypeError, match="requires a Blob V2 field"):
udf(
function(
read,
input_schema=pa.schema([pa.field("video", pa.int64(), nullable=False)]),
output_schema=pa.field("size", pa.int64(), nullable=False),
@@ -1191,7 +1195,7 @@ def test_blob_reader_rejects_an_incompatible_explicit_schema():
def test_whole_named_struct_function_can_include_a_blob_result_field():
@udf(
@function(
input_schema=pa.schema([lancedb.blob("image", nullable=False)]),
output_schema=pa.field(
"payload",
@@ -1245,7 +1249,7 @@ def test_struct_blob_signature_fields_preserve_exact_metadata_and_nullability():
nullable=False,
)
@udf(input_schema=pa.schema([nested_input]), output_schema=nested_output)
@function(input_schema=pa.schema([nested_input]), output_schema=nested_output)
def copy_payload(payload):
return payload
@@ -1289,7 +1293,7 @@ def test_struct_blob_signature_supports_multiple_struct_levels():
nullable=False,
)
@udf(
@function(
input_schema=pa.schema([recursive]),
output_schema=pa.field("size", pa.int64(), nullable=False),
)
@@ -1310,7 +1314,7 @@ def test_struct_blob_signature_supports_multiple_struct_levels():
],
)
def test_blob_output_accepts_list_ancestors(data_type):
@udf(
@function(
input_schema=pa.schema([lancedb.blob("video", nullable=False)]),
output_schema=pa.field("frames", data_type, nullable=False),
)
@@ -1335,7 +1339,7 @@ def test_blob_output_accepts_list_ancestors(data_type):
def test_blob_output_rejects_map_and_fixed_size_list_ancestors(data_type):
with pytest.raises(TypeError, match="nested under collection types"):
@udf(
@function(
input_schema=pa.schema([pa.field("id", pa.int64(), nullable=False)]),
output_schema=pa.field("frames", data_type, nullable=False),
)
@@ -1346,7 +1350,7 @@ def test_blob_output_rejects_map_and_fixed_size_list_ancestors(data_type):
def test_blob_output_named_struct_accepts_list_ancestor():
frames_type = pa.list_(lancedb.blob("item", nullable=False))
@udf(
@function(
input_schema=pa.schema([pa.field("id", pa.int64(), nullable=False)]),
output_schema=pa.schema(
[
@@ -1384,7 +1388,7 @@ def test_blob_signature_rejects_collection_ancestors(data_type):
match="Blob v2 fields nested under collection types are not supported",
):
@udf(
@function(
input_schema=pa.schema([pa.field("value", data_type, nullable=False)]),
output_schema=pa.field("size", pa.int64(), nullable=False),
)
@@ -1411,7 +1415,7 @@ def test_blob_signature_rejects_collection_below_a_struct():
match="Blob v2 fields nested under collection types are not supported",
):
@udf(
@function(
input_schema=pa.schema([nested]),
output_schema=pa.field("size", pa.int64(), nullable=False),
)
@@ -1420,7 +1424,7 @@ def test_blob_signature_rejects_collection_below_a_struct():
def test_blob_fields_support_vectorized_pyarrow_arrays():
@udf(
@function(
input_schema=pa.schema([lancedb.blob("image", nullable=True)]),
output_schema=lancedb.blob("result", nullable=False),
)
@@ -1433,7 +1437,7 @@ def test_blob_fields_support_vectorized_pyarrow_arrays():
values = pa.array([b"large blob", b"", None], type=pa.large_binary())
request = json.loads(copy_blobs.registration_request.to_canonical_json())
batch = pa.RecordBatch.from_arrays([values], names=["image"])
result = _execute_registered_array_udf_v1(request, batch)
result = _execute_registered_array_function_v1(request, batch)
assert isinstance(result, pa.LargeBinaryArray)
assert result.to_pylist() == [b"large blob", b"", b""]
@@ -1454,7 +1458,7 @@ def test_blob_fields_support_vectorized_pyarrow_arrays():
def test_documented_blob_array_identity_executes_packaged_artifact():
@udf(
@function(
input_schema=pa.schema([lancedb.blob("image", nullable=False)]),
output_schema=lancedb.blob("copy", nullable=False),
)
@@ -1463,7 +1467,7 @@ def test_documented_blob_array_identity_executes_packaged_artifact():
values = pa.array([b"large blob", b""], type=pa.large_binary())
request = json.loads(copy_blobs.registration_request.to_canonical_json())
result = _execute_registered_array_udf_v1(
result = _execute_registered_array_function_v1(
request,
pa.RecordBatch.from_arrays([values], names=["image"]),
)
@@ -1471,7 +1475,7 @@ def test_documented_blob_array_identity_executes_packaged_artifact():
def test_named_struct_function_can_include_a_blob_result_field():
@udf(
@function(
input_schema=pa.schema([lancedb.blob("image", nullable=False)]),
output_schema=pa.schema(
[
@@ -1492,7 +1496,7 @@ def test_named_struct_function_can_include_a_blob_result_field():
def test_named_struct_function_preserves_nullable_result_fields():
@udf(
@function(
input_schema=pa.schema([pa.field("value", pa.int64(), nullable=False)]),
output_schema=pa.schema(
[
@@ -1510,7 +1514,7 @@ def test_named_struct_function_preserves_nullable_result_fields():
("failure_code", False),
]
@udf(
@function(
input_schema=pa.schema([pa.field("value", pa.int64(), nullable=False)]),
output_schema=pa.schema(
[
@@ -1540,7 +1544,7 @@ def test_metadata_marked_blob_field_uses_the_semantic_type():
metadata={"ARROW:extension:name": "lance.blob.v2"},
)
@udf(
@function(
input_schema=pa.schema([metadata_blob]),
output_schema=pa.field("size", pa.int64(), nullable=False),
)
@@ -1560,7 +1564,7 @@ def test_blob_marker_rejects_invalid_storage_layout():
with pytest.raises(TypeError, match="requires a supported Blob storage layout"):
@udf(
@function(
input_schema=pa.schema([malformed]),
output_schema=pa.field("size", pa.int64(), nullable=False),
)
@@ -1587,7 +1591,7 @@ def test_nested_non_blob_extension_is_not_silently_unwrapped():
)
with pytest.raises(TypeError, match="unsupported Arrow type"):
@udf(
@function(
input_schema=pa.schema([nested]),
output_schema=pa.field("result", pa.int64(), nullable=False),
)
@@ -1599,7 +1603,7 @@ def test_explicit_large_utf8_schemas_use_the_canonical_function_name():
input_schema = pa.schema([pa.field("text", pa.large_string(), nullable=True)])
output_schema = pa.field("result", pa.large_string(), nullable=False)
@udf(input_schema=input_schema, output_schema=output_schema)
@function(input_schema=input_schema, output_schema=output_schema)
def preserve(text):
return text
@@ -1625,7 +1629,7 @@ def test_explicit_large_utf8_schemas_use_the_canonical_function_name():
def test_large_binary_schemas_use_the_canonical_function_name():
payload = pa.field("data", pa.large_binary(), nullable=True)
@udf(
@function(
input_schema=pa.schema([payload]),
output_schema=pa.field("result", pa.large_binary(), nullable=False),
)
@@ -1696,7 +1700,7 @@ def test_nested_struct_output_uses_canonical_exact_json():
]
)
@udf(
@function(
input_schema=pa.schema([pa.field("text", pa.string(), nullable=False)]),
output_schema=pa.field("analysis", analysis, nullable=False),
)
@@ -1746,31 +1750,31 @@ def test_nested_struct_output_uses_canonical_exact_json():
def test_annotation_and_explicit_schema_validation_fail_closed():
with pytest.raises(TypeError, match="missing Function annotations"):
@udf
@function
def missing(value):
return value
with pytest.raises(TypeError, match="unsupported Function annotation"):
@udf
@function
def unsupported(value: set[str]) -> str:
return ""
with pytest.raises(ValueError, match="output must be non-nullable"):
@udf
@function
def nullable_output(value: int) -> Optional[int]:
return value
with pytest.raises(TypeError, match=r"missing Function annotations: \['return'\]"):
@udf(input_schema=pa.schema([pa.field("value", pa.int64())]))
@function(input_schema=pa.schema([pa.field("value", pa.int64())]))
def partial_schema(value):
return value
with pytest.raises(ValueError, match="exactly match callable parameters"):
@udf(
@function(
input_schema=pa.schema([pa.field("other", pa.int64())]),
output_schema=pa.int64(),
)
@@ -1779,7 +1783,7 @@ def test_annotation_and_explicit_schema_validation_fail_closed():
with pytest.raises(ValueError, match="output must be non-nullable"):
@udf(
@function(
input_schema=pa.schema([pa.field("value", pa.int64())]),
output_schema=pa.field("result", pa.int64(), nullable=True),
)
@@ -1792,7 +1796,7 @@ def test_annotation_and_explicit_schema_validation_fail_closed():
]:
with pytest.raises(TypeError, match="unsupported Arrow type"):
@udf(
@function(
input_schema=pa.schema([pa.field("value", pa.int64())]),
output_schema=pa.schema([invalid_field]),
)
@@ -1801,7 +1805,7 @@ def test_annotation_and_explicit_schema_validation_fail_closed():
with pytest.raises(TypeError, match="unsupported Arrow type"):
@udf(
@function(
input_schema=pa.schema(
[pa.field("value", pa.int64(), metadata={"k": "v"})]
),
@@ -1812,7 +1816,7 @@ def test_annotation_and_explicit_schema_validation_fail_closed():
with pytest.raises(TypeError, match="unsupported Arrow type"):
@udf(
@function(
input_schema=pa.schema([pa.field("value", pa.int64())]),
output_schema=pa.field(
"result", pa.int64(), nullable=False, metadata={"k": "v"}
@@ -1824,7 +1828,7 @@ def test_annotation_and_explicit_schema_validation_fail_closed():
struct_type = pa.struct([pa.field("value", pa.int64(), nullable=False)])
with pytest.raises(TypeError, match="unsupported Arrow type"):
@udf(
@function(
input_schema=pa.schema([pa.field("value", pa.int64())]),
output_schema=pa.field(
"result", struct_type, nullable=False, metadata={"k": "v"}
@@ -1848,7 +1852,7 @@ def test_annotation_and_explicit_schema_validation_fail_closed():
]:
with pytest.raises(TypeError, match="schema metadata"):
@udf(input_schema=input_schema, output_schema=output_schema)
@function(input_schema=input_schema, output_schema=output_schema)
def schema_metadata(value):
return value
@@ -2093,8 +2097,8 @@ def test_building_a_binding_contacts_no_server():
assert state["requests"] == []
def test_vectorized_blob_udf_executes_packaged_artifact_and_publishes(tmp_path):
@udf(
def test_vectorized_blob_function_executes_packaged_artifact_and_publishes(tmp_path):
@function(
input_schema=pa.schema([lancedb.blob("image", nullable=True)]),
output_schema=lancedb.blob("copy", nullable=False),
)
@@ -2135,7 +2139,7 @@ def test_vectorized_blob_udf_executes_packaged_artifact_and_publishes(tmp_path):
)
hydrated = source.fetch_blobs("image", input_hits)
assert isinstance(hydrated, pa.LargeBinaryArray)
result = _execute_registered_array_udf_v1(
result = _execute_registered_array_function_v1(
registration,
pa.RecordBatch.from_arrays([hydrated], names=["image"]),
)
@@ -2181,7 +2185,7 @@ def test_blob_list_output_fresh_artifact_survives_local_reopen(
input_type = physical_list(pa.int64())
item = lancedb.blob("item", nullable=True)
@udf(
@function(
input_schema=pa.schema([pa.field("frames", input_type, nullable=False)]),
output_schema=pa.field("frames", declared_list(item), nullable=False),
)
@@ -2199,7 +2203,7 @@ def test_blob_list_output_fresh_artifact_survives_local_reopen(
item_field = declared["fields"][0]
assert item_field["metadata"]["ARROW:extension:name"] == "lance.blob.v2"
result = _execute_registered_array_udf_v1(
result = _execute_registered_array_function_v1(
request,
pa.RecordBatch.from_arrays(
[pa.array([[0], [1]], type=input_type)],
@@ -2264,11 +2268,11 @@ def test_a_function_namespace_path_must_be_a_list_of_segments():
def test_one_explicit_schema_infers_the_other_from_annotations():
@udf(input_schema=pa.schema([pa.field("value", pa.int64())]))
@function(input_schema=pa.schema([pa.field("value", pa.int64())]))
def explicit_input(value) -> float:
return float(value or 0)
@udf(output_schema=pa.field("result", pa.float32(), nullable=False))
@function(output_schema=pa.field("result", pa.float32(), nullable=False))
def explicit_output(value: Optional[int]):
return float(value or 0)
+65 -35
View File
@@ -20,7 +20,7 @@ import pyarrow as pa
import pytest
from lancedb import col
from lancedb.functions import FunctionVersion, udf
from lancedb.functions import FunctionDefinition, FunctionVersion, function
FIXTURES = (
Path(__file__).parents[3]
@@ -63,7 +63,7 @@ def helper_package(tmp_path, monkeypatch):
return write
@udf
@function
class scale:
def __init__(
self,
@@ -114,11 +114,11 @@ def test_class_function_declares_inputs_output_and_initialization():
assert scale.registration_request.artifact.entrypoint == "scale"
source = artifact(scale).decode()
assert source.startswith("from __future__ import annotations\n")
assert "class scale:" in source and "@udf" not in source
assert "class scale:" in source and "@function" not in source
def test_function_without_initialization_keeps_its_wire_form():
@udf
@function
def double(value: int) -> int:
return value * 2
@@ -147,11 +147,11 @@ def test_batch_annotations_require_explicit_schemas(annotation, kind, helper_pac
"""
module = helper_package("fx_batch_annotations", {"__init__.py": source})
with pytest.raises(TypeError, match="input_schema and output_schema"):
udf(module.identity)
function(module.identity)
def test_array_batch_annotations_accept_explicit_schemas():
@udf(
@function(
input_schema=pa.schema([pa.field("value", pa.int64(), nullable=False)]),
output_schema=pa.int64(),
)
@@ -168,7 +168,7 @@ def test_array_batch_annotations_accept_explicit_schemas():
assert identity(values).equals(values)
@pytest.mark.parametrize("class_udf", [False, True])
@pytest.mark.parametrize("class_function", [False, True])
@pytest.mark.parametrize("postponed", [False, True])
@pytest.mark.parametrize(
("annotation", "nullable"),
@@ -185,13 +185,13 @@ def test_array_batch_annotations_accept_explicit_schemas():
],
)
def test_blob_reader_annotations_resolve_in_the_artifact(
helper_package, monkeypatch, class_udf, postponed, annotation, nullable
helper_package, monkeypatch, class_function, postponed, annotation, nullable
):
definition = (
f"class read_blob:\n"
f" def __call__(self, video: {annotation}) -> int:\n"
f" return 0\n"
if class_udf
if class_function
else f"def read_blob(video: {annotation}) -> int:\n return 0\n"
)
module = helper_package(
@@ -202,9 +202,9 @@ def test_blob_reader_annotations_resolve_in_the_artifact(
+ "import lancedb\n"
"from typing import Optional\n"
"from lancedb import BlobReader, BlobReader as Reader\n"
"from lancedb.functions import udf\n"
"from lancedb.functions import function\n"
"MaybeReader = Optional[Reader]\n\n"
f"@udf\n{definition}"
f"@function\n{definition}"
)
},
)
@@ -221,7 +221,7 @@ def test_blob_reader_annotations_resolve_in_the_artifact(
namespace = {}
exec(compile(artifact(module.read_blob), "user_function.py", "exec"), namespace)
packaged = namespace["read_blob"]
if class_udf:
if class_function:
packaged = packaged.__call__
assert get_type_hints(packaged) == {
"video": Optional[worker.BlobReader] if nullable else worker.BlobReader,
@@ -237,11 +237,11 @@ def test_blob_reader_annotation_does_not_shadow_body_globals(
{
"__init__.py": """
from lancedb import BlobReader
from lancedb.functions import udf
from lancedb.functions import function
_lance_blob_reader = 7
@udf
@function
def read_blob(video: BlobReader) -> int:
return _lance_blob_reader
"""
@@ -280,14 +280,14 @@ def test_class_function_rejects_unsupported_initialization(init, message):
namespace,
)
with pytest.raises((TypeError, ValueError), match=message):
udf(namespace["bad"])
function(namespace["bad"])
def test_class_function_requires_call_and_a_plain_close():
with pytest.raises(TypeError, match="synchronous __call__"):
udf(NoCall)
function(NoCall)
with pytest.raises(TypeError, match="close must be a method taking no"):
udf(BadClose)
function(BadClose)
def test_nested_class_and_closure_are_rejected_with_the_reason():
@@ -299,13 +299,13 @@ def test_nested_class_and_closure_are_rejected_with_the_reason():
return nested
with pytest.raises(ValueError, match="defined at module level"):
udf(build())
function(build())
factor = 3
with pytest.raises(ValueError, match="capture closure values"):
@udf
@function
def closes_over(value: int) -> int:
return value * factor
@@ -372,10 +372,10 @@ def test_code_packages_ship_as_a_bundle_and_version_with_their_source(
"""
import fx_helpers
from fx_helpers.rate import TokenBucket
from lancedb import udf
from lancedb import function
@udf(code=[fx_helpers])
@function(code=[fx_helpers])
class limited:
def __init__(self, tpm: int):
self.limiter = TokenBucket(tpm)
@@ -445,7 +445,7 @@ def test_code_package_cache_artifacts_do_not_warn(helper_package):
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
@udf(code=[cache])
@function(code=[cache])
def cache_only(value: int) -> int:
return value
@@ -468,7 +468,7 @@ def test_code_package_nonidentifier_resource_dir_warns_at_boundary(helper_packag
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
@udf(code=[resources])
@function(code=[resources])
def resource_boundary(value: int) -> int:
return value
@@ -495,7 +495,7 @@ def test_code_rejects_submodules_duplicates_and_non_modules(helper_package):
]:
with pytest.raises((TypeError, ValueError), match=message):
@udf(code=code)
@function(code=code)
def f(value: int) -> int:
return value
@@ -514,10 +514,10 @@ def test_local_module_outside_code_warns_at_registration(tmp_path, helper_packag
textwrap.dedent(
f"""
from fx_local import normalize
from lancedb import udf
from lancedb import function
@udf({options})
@function({options})
def {name}(value: int) -> int:
return normalize(value)
"""
@@ -551,10 +551,10 @@ def test_local_module_imported_in_function_body_warns_at_registration(
(tmp_path / f"{name}.py").write_text(
textwrap.dedent(
f"""
from lancedb import udf
from lancedb import function
@udf({options})
@function({options})
def {name}(value: int) -> int:
import fx_body_local
@@ -610,20 +610,20 @@ import pyarrow as pa
import fx_mmlb
from fx_mmlb.embedding import OpenAIEmbedder
from lancedb import udf
from lancedb import function
TEXT = pa.schema([pa.field("text", pa.string())])
EMBEDDING = pa.list_(pa.field("item", pa.float64(), nullable=False))
@udf(code=[fx_mmlb], input_schema=TEXT, output_schema=EMBEDDING)
@function(code=[fx_mmlb], input_schema=TEXT, output_schema=EMBEDDING)
class openai_embedding_batch(OpenAIEmbedder):
def __call__(self, text: pa.Array) -> pa.Array:
item = pa.field("item", pa.float64(), nullable=False)
return pa.array(self.embed(text.to_pylist()), pa.list_(item))
@udf(code=[fx_mmlb])
@function(code=[fx_mmlb])
class openai_embedding(OpenAIEmbedder):
def __call__(self, text: str) -> list[float]:
return self.embed([text])[0]
@@ -695,7 +695,7 @@ def test_main_definitions_travel_by_source(tmp_path):
import math
from dataclasses import dataclass
from lancedb import udf
from lancedb import function
SCALE = 2
@@ -714,7 +714,7 @@ def test_main_definitions_travel_by_source(tmp_path):
return helper(value) * self.settings.factor
@udf
@function
class scaled(Base):
def __init__(self, factor: int):
self.settings = Settings(factor)
@@ -750,11 +750,11 @@ def test_main_lambdas_are_rejected(tmp_path):
script.write_text(
textwrap.dedent(
"""
from lancedb import udf
from lancedb import function
double = lambda value: value * 2
@udf
@function
def uses_lambda(value: int) -> int:
return double(value)
"""
@@ -765,3 +765,33 @@ def test_main_lambdas_are_rejected(tmp_path):
)
assert result.returncode != 0
assert "not lambdas, closures, or nested definitions" in result.stderr
def test_udf_alias_warns_and_packages_like_function(helper_package):
def source(decorator: str) -> str:
return f"""
import lancedb
from lancedb import function, udf
@{decorator}
def double(value: int) -> int:
return value * 2
"""
current = helper_package("fx_alias_current", {"__init__.py": source("function")})
expected = current.double.registration_request.artifact.digest
for name, decorator in (("fx_alias_bare", "udf"), ("fx_alias_attr", "lancedb.udf")):
with pytest.warns(DeprecationWarning, match="lancedb.udf is deprecated"):
module = helper_package(name, {"__init__.py": source(decorator)})
assert isinstance(module.double, FunctionDefinition)
assert "udf" not in artifact(module.double).decode()
assert module.double.registration_request.artifact.digest == expected
def test_udf_definition_alias_warns():
with pytest.warns(DeprecationWarning, match="UdfDefinition is deprecated"):
from lancedb import UdfDefinition
assert UdfDefinition is FunctionDefinition
with pytest.warns(DeprecationWarning, match="UdfDefinition is deprecated"):
from lancedb.functions import UdfDefinition as module_alias
assert module_alias is FunctionDefinition