diff --git a/python/python/lancedb/functions.py b/python/python/lancedb/functions.py index 237ed73c2..8a19a9d37 100644 --- a/python/python/lancedb/functions.py +++ b/python/python/lancedb/functions.py @@ -222,6 +222,7 @@ class PythonEnvironmentSpec(_RemoteValue): kind: str packages: tuple[str, ...] = () + channels: tuple[str, ...] = () path: Optional[str] = None modules: tuple[str, ...] = () image: Optional[str] = None @@ -909,13 +910,25 @@ class UdfDefinition: pip: tuple[str, ...], env: Mapping[str, str], python_version: Optional[str], + conda: tuple[str, ...] = (), + conda_channels: tuple[str, ...] = (), ): function_name = name or function.__name__ if not _FUNCTION_NAME.fullmatch(function_name): raise ValueError(f"invalid Function name: {function_name!r}") - packages = tuple(sorted(set(pip))) + if pip and conda: + raise ValueError("a Function environment is pip or conda, not both") + if conda_channels and not conda: + raise ValueError("conda_channels requires conda packages") + packages = tuple(sorted(set(conda if conda else pip))) if any(not package or package != package.strip() for package in packages): - raise ValueError("pip requirements must be non-empty and trimmed") + raise ValueError("package requirements must be non-empty and trimmed") + if conda: + environment_spec = PythonEnvironmentSpec( + kind="conda", packages=packages, channels=tuple(conda_channels) + ) + else: + environment_spec = PythonEnvironmentSpec(kind="pip", packages=packages) environment = dict(env) if any( not isinstance(key, str) or not isinstance(value, str) @@ -929,7 +942,7 @@ class UdfDefinition: kind="python", python_version=python_version or f"{sys.version_info.major}.{sys.version_info.minor}", - environment=PythonEnvironmentSpec(kind="pip", packages=packages), + environment=environment_spec, env=environment, ) self._function = function @@ -976,6 +989,8 @@ def udf( pip: tuple[str, ...] | list[str] = (), env: Optional[Mapping[str, str]] = None, python_version: Optional[str] = None, + conda: tuple[str, ...] | list[str] = (), + conda_channels: tuple[str, ...] | list[str] = (), ) -> Callable[[Callable[..., Any]], UdfDefinition]: ... @@ -988,6 +1003,8 @@ def udf( pip: tuple[str, ...] | list[str] = (), env: Optional[Mapping[str, str]] = None, python_version: Optional[str] = None, + conda: tuple[str, ...] | list[str] = (), + conda_channels: tuple[str, ...] | list[str] = (), ): """Prepare a scalar Python callable for remote Function registration. @@ -1010,6 +1027,10 @@ def udf( provided together with ``input_schema``. pip : sequence of str, optional Pip requirements for the remote environment. + conda : sequence of str, optional + Conda packages for the remote environment, instead of ``pip``. + conda_channels : sequence of str, optional + Conda channels in priority order; requires ``conda``. env : mapping of str to str, optional Environment variables included in the Function definition. python_version : str, optional @@ -1049,6 +1070,8 @@ def udf( pip=tuple(pip), env={} if env is None else env, python_version=python_version, + conda=tuple(conda), + conda_channels=tuple(conda_channels), ) if function is None: diff --git a/python/python/tests/test_first_class_function_slice2.py b/python/python/tests/test_first_class_function_slice2.py index 55257d322..7ce6b6b91 100644 --- a/python/python/tests/test_first_class_function_slice2.py +++ b/python/python/tests/test_first_class_function_slice2.py @@ -69,6 +69,26 @@ def _run_packaged(definition, *args): return namespace[definition.registration_request.artifact.entrypoint](*args) +def test_udf_conda_environment(): + @udf(conda=["scipy", "numpy"], conda_channels=["conda-forge", "defaults"]) + def halve(value: float) -> float: + return value / 2 + + request = json.loads(halve.registration_request.to_canonical_json()) + assert request["runtime"]["environment"] == { + "kind": "conda", + "packages": ["numpy", "scipy"], + "channels": ["conda-forge", "defaults"], + } + pip_request = json.loads(normalize_score.registration_request.to_canonical_json()) + 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) + with pytest.raises(ValueError, match="requires conda"): + udf(name="channels", conda_channels=["conda-forge"])(lambda value: value) + + def test_udf_packages_attribute_access_and_body_imports(): @udf def word_norm(body: str) -> float: diff --git a/rust/lancedb/src/function.rs b/rust/lancedb/src/function.rs index 52c70a4b1..5366d984e 100644 --- a/rust/lancedb/src/function.rs +++ b/rust/lancedb/src/function.rs @@ -186,6 +186,9 @@ pub struct PythonEnvironmentSpec { pub kind: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub packages: Vec, + /// Conda channels in priority order; conda environments only. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub channels: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -583,3 +586,26 @@ impl RefreshColumnResult { } impl_json!(RefreshColumnResult); + +#[cfg(test)] +mod conda_environment_tests { + use super::PythonEnvironmentSpec; + + #[test] + fn conda_channels_round_trip_and_pip_stays_bare() { + let conda: PythonEnvironmentSpec = serde_json::from_str( + r#"{"kind":"conda","packages":["numpy"],"channels":["conda-forge"]}"#, + ) + .unwrap(); + assert_eq!(conda.channels, ["conda-forge"]); + assert!( + serde_json::to_string(&conda) + .unwrap() + .contains(r#""channels":["conda-forge"]"#) + ); + + let pip: PythonEnvironmentSpec = + serde_json::from_str(r#"{"kind":"pip","packages":["numpy"]}"#).unwrap(); + assert!(!serde_json::to_string(&pip).unwrap().contains("channels")); + } +}