fix(python): validate function package requirements before registration (#4971)

Function dependency typos currently reach the environment build before
failing. Validate pip requirements with
`packaging.requirements.Requirement` and conda requirements with
Rattler's MatchSpec parser when constructing a function definition,
before source packaging or registration. Errors identify the original
entry and its index.

```python
@lancedb.function(pip=["numpy", "torch>=2.0,,<3"])
def score(value: float) -> float:
    return value
# ValueError: invalid pip[1] requirement 'torch>=2.0,,<3': ...
```

Preserves pip extras, markers, named references, bare archive/git URLs,
and conda channel/build specifications. The existing native extension
exposes the conda parser, so no additional Python package is required.
Adds regression coverage for invalid and supported forms, including the
deprecated `udf` alias.
This commit is contained in:
Jack Ye
2026-11-11 22:56:36 -08:00
committed by GitHub
parent 42f11d13a2
commit ee231aad06
7 changed files with 450 additions and 7 deletions
+1
View File
@@ -37,6 +37,7 @@ pyo3-async-runtimes = { version = "0.29", features = [
"tokio-runtime",
] }
reqwest.workspace = true
rattler_conda_types = { version = "0.58.3", default-features = false }
pin-project.workspace = true
futures.workspace = true
serde.workspace = true
+1
View File
@@ -1310,3 +1310,4 @@ class AuthzObject:
def __repr__(self) -> str: ...
def validate_function_retry_policy(json: str) -> None: ...
def _validate_conda_requirement(requirement: str) -> None: ...
+31 -2
View File
@@ -44,6 +44,7 @@ from typing import (
)
import pyarrow as pa
from packaging.requirements import Requirement
from pydantic import (
AfterValidator,
BaseModel,
@@ -57,6 +58,7 @@ from pydantic import (
from .schema import blob as _blob_field
from .schema import is_blob_v2_field as _is_blob_v2_field
from ._blob_reader import BlobReader
from ._lancedb import _validate_conda_requirement
from .secrets import EnvVarSecret
_Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1)
@@ -1701,6 +1703,29 @@ def _packaged_by_source(value: Any) -> bool:
return True
def _validate_package_requirements(kind: str, packages: Sequence[str]) -> None:
for index, package in enumerate(packages):
field = f"{kind}[{index}]"
if not isinstance(package, str):
raise TypeError(f"{field} must be a string, got {package!r}")
if not package or package != package.strip():
raise ValueError(
f"{field} requirement {package!r} must be non-empty and trimmed"
)
try:
if kind == "conda":
_validate_conda_requirement(package)
else:
candidate = package
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", package):
candidate = f"dependency @ {package}"
Requirement(candidate)
except ValueError as error:
raise ValueError(
f"invalid {field} requirement {package!r}: {error}"
) from error
def _requirement_name(requirement: str) -> str:
requirement = requirement.rpartition("::")[2]
match = _REQUIREMENT_NAME.match(requirement.strip())
@@ -2242,6 +2267,7 @@ class FunctionDefinition:
raise ValueError("a Function environment is pip or conda, not both")
if conda_channels and not conda:
raise ValueError("conda_channels requires conda packages")
_validate_package_requirements("conda" if conda else "pip", conda or pip)
gpu_marker = _normalize_gpu_marker(gpu)
if gpu_class is not None:
_validate_gpu_class_name(gpu_class)
@@ -2595,9 +2621,12 @@ def function(
non-nullable. Required for batch annotations. When omitted, the output
is inferred from the return annotation.
pip : sequence of str, optional
Pip requirements for the remote environment.
Pip requirements for the remote environment. Syntax is validated at
decoration time, including extras and environment markers. Named direct
references and bare package or git URLs are supported.
conda : sequence of str, optional
Conda packages for the remote environment, instead of ``pip``.
Conda packages for the remote environment, instead of ``pip``. MatchSpec
syntax is validated at decoration time, including channels and builds.
conda_channels : sequence of str, optional
Conda channels in priority order; requires ``conda``.
apt : sequence of str, optional
@@ -0,0 +1,125 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import pytest
from lancedb import function, udf
from lancedb.functions import FunctionDefinition
def identity(value: int) -> int:
return value
@pytest.mark.parametrize(
"package",
[
"torch>=2.0,,<3",
"torch>=",
"torch=>2.0",
"torch[",
"torch; python_version >=",
"",
" torch",
"torch ",
],
)
def test_invalid_pip_requirement_fails_before_packaging(package, monkeypatch):
def unexpected_packaging(*args, **kwargs):
pytest.fail("invalid requirements must fail before packaging the function")
monkeypatch.setattr("lancedb.functions._infer_signature", unexpected_packaging)
with pytest.raises(ValueError) as error:
function(pip=["numpy", package])(identity)
assert "pip[1]" in str(error.value)
assert repr(package) in str(error.value)
@pytest.mark.parametrize(
"package",
[
"numpy>=1,,<3",
"numpy>=",
"numpy[version=]",
"numpy[unknown_field=value]",
"",
" numpy",
"numpy ",
],
)
def test_invalid_conda_requirement_names_entry(package):
with pytest.raises(ValueError) as error:
function(conda=["scipy", package])(identity)
assert "conda[1]" in str(error.value)
assert repr(package) in str(error.value)
@pytest.mark.parametrize("kind", ["pip", "conda"])
@pytest.mark.parametrize("package", [None, 42, ["numpy"]])
def test_requirement_must_be_a_string(kind, package):
with pytest.raises(TypeError, match=rf"{kind}\[1\] must be a string"):
function(**{kind: ["numpy", package]})(identity)
@pytest.mark.parametrize(
"package",
[
"numpy",
"torch>=2.0,<3",
'requests[security,socks]>=2; python_version >= "3.10"',
"numpy (>=1.26, <3)",
"six @ https://example.com/six-1.16.0.tar.gz",
"six @ git+https://github.com/benjaminp/six.git@main",
"https://example.com/six-1.16.0.tar.gz",
"git+https://github.com/benjaminp/six.git@main",
"git+ssh://git@github.com/benjaminp/six.git@main",
],
)
def test_supported_pip_requirements_are_preserved(package):
definition = function(pip=[package])(identity)
assert definition.registration_request.runtime.environment.packages == (package,)
@pytest.mark.parametrize(
"package",
[
"numpy",
"numpy=1.26",
"numpy>=1.26,<3",
"numpy=1.26=py310_0",
"numpy 1.26 py310_0",
"conda-forge::numpy>=1.26",
"conda-forge/linux-64::numpy>=1.26",
"numpy[version='>=1.26,<3',build='py310*']",
],
)
def test_supported_conda_requirements_are_preserved(package):
definition = function(conda=[package])(identity)
assert definition.registration_request.runtime.environment.packages == (package,)
def test_requirements_still_sort_and_deduplicate():
definition = function(pip=["torch>=2", "numpy", "torch>=2"])(identity)
assert definition.registration_request.runtime.environment.packages == (
"numpy",
"torch>=2",
)
def test_deprecated_udf_alias_validates_requirements():
with pytest.warns(DeprecationWarning, match="use lancedb.function"):
with pytest.raises(ValueError, match=r"pip\[0\]"):
udf(pip=["torch>=2.0,,<3"])(identity)
def test_direct_function_definition_validates_requirements():
with pytest.raises(ValueError, match=r"pip\[0\]"):
FunctionDefinition(
identity,
name=None,
input_schema=None,
output_schema=None,
pip=("torch>=2.0,,<3",),
env={},
python_version=None,
)
+1
View File
@@ -150,6 +150,7 @@ pub fn _lancedb(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(table::tokenize, m)?)?;
m.add_function(wrap_pyfunction!(permutation::async_permutation_builder, m)?)?;
m.add_function(wrap_pyfunction!(util::validate_table_name, m)?)?;
m.add_function(wrap_pyfunction!(util::validate_conda_requirement, m)?)?;
m.add_function(wrap_pyfunction!(util::validate_function_retry_policy, m)?)?;
m.add_function(wrap_pyfunction!(util::parse_field_path, m)?)?;
m.add_function(wrap_pyfunction!(query::fts_query_to_json, m)?)?;
+8
View File
@@ -9,6 +9,7 @@ use pyo3::{
exceptions::{PyRuntimeError, PyValueError},
pyfunction,
};
use rattler_conda_types::{MatchSpec, ParseStrictness};
/// A wrapper around a rust builder
///
@@ -61,6 +62,13 @@ pub fn validate_table_name(table_name: &str) -> PyResult<()> {
.map_err(|e| PyValueError::new_err(e.to_string()))
}
#[pyfunction(name = "_validate_conda_requirement")]
pub fn validate_conda_requirement(requirement: &str) -> PyResult<()> {
MatchSpec::from_str(requirement, ParseStrictness::Lenient)
.map(|_| ())
.map_err(|error| PyValueError::new_err(error.to_string()))
}
/// Parse field references with the same identifier syntax as Lance writes.
#[pyfunction(name = "_parse_field_path")]
pub fn parse_field_path(path: &str) -> PyResult<Vec<String>> {