mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)?)?;
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
Reference in New Issue
Block a user