diff --git a/python/python/lancedb/functions.py b/python/python/lancedb/functions.py index c22bf927a..a3e56247a 100644 --- a/python/python/lancedb/functions.py +++ b/python/python/lancedb/functions.py @@ -366,6 +366,12 @@ class FunctionVersion(_RemoteValue): [SecretReference][lancedb.functions.SecretReference] carries a Secret's; they are what [get_function][lancedb.db.DBConnection.get_function] takes. An empty ``namespace_path`` is the root namespace. + + ``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. + It is ``None`` when the server does not provide a source digest. """ name: str @@ -374,6 +380,7 @@ class FunctionVersion(_RemoteValue): location: str version: _ObjectVersion image: FunctionImage + source_digest: Optional[str] = None signature: FunctionSignature secret_bindings: tuple[SecretBinding, ...] = () created_at_millis: int diff --git a/python/python/tests/test_first_class_function_slice1.py b/python/python/tests/test_first_class_function_slice1.py index 06730a5f1..64a610b53 100644 --- a/python/python/tests/test_first_class_function_slice1.py +++ b/python/python/tests/test_first_class_function_slice1.py @@ -146,6 +146,27 @@ def test_function_version_identity_is_immutable_and_exact(): FunctionVersion(**{**value, "version": invalid}) +@pytest.mark.parametrize("source_digest", [None, "sha256:source-artifact"]) +def test_function_version_preserves_source_digest(source_digest): + value = job_result("remote_function_job.json") + legacy = FunctionVersion.from_json(json.dumps(value)) + assert legacy.source_digest is None + assert "source_digest" not in json.loads(legacy.to_canonical_json()) + + value["source_digest"] = source_digest + version = FunctionVersion.from_json(json.dumps(value)) + assert version.source_digest == source_digest + assert version.image == legacy.image + encoded = version.to_canonical_json() + if source_digest is None: + assert "source_digest" not in json.loads(encoded) + else: + assert json.loads(encoded)["source_digest"] == source_digest + assert FunctionVersion.from_json(encoded) == version + with pytest.raises((TypeError, ValueError)): + version.source_digest = "sha256:changed" + + def test_function_version_binds_named_columns_as_one_immutable_application(): version = FunctionVersion.from_json( json.dumps(job_result("remote_function_job.json")) diff --git a/python/python/tests/test_first_class_function_slice2.py b/python/python/tests/test_first_class_function_slice2.py index 392becf8c..58afa9c7c 100644 --- a/python/python/tests/test_first_class_function_slice2.py +++ b/python/python/tests/test_first_class_function_slice2.py @@ -1651,6 +1651,7 @@ def _mock_remote_function_catalog(): ).read_text() )["image"], "signature": body["signature"], + "source_digest": body["artifact"]["digest"], "secret_bindings": body.get("secret_bindings", []), "created_at_millis": 1787270400000, # 2026-08-21T00:00:00Z } @@ -1760,6 +1761,9 @@ def test_remote_registration_job_and_exact_version_reopen_round_trip(): assert created == reopened assert reopened.name == "normalize_score" assert reopened.version == FUNCTION_VERSION + assert ( + reopened.source_digest == normalize_score.registration_request.artifact.digest + ) create_request = state["requests"][0][1] expected_request = json.loads( normalize_score.registration_request.to_canonical_json() @@ -1866,6 +1870,7 @@ def test_blocking_remote_registration_returns_function_version(): assert created.name == "normalize_score" assert created.version == FUNCTION_VERSION + assert created.source_digest == normalize_score.registration_request.artifact.digest assert [path for path, _ in state["requests"]] == [ "/v1/function/normalize_score/create", "/v1/jobs/describe", @@ -1885,6 +1890,9 @@ def test_remote_list_functions_paginates_and_returns_typed_versions(): functions = list(db.list_functions()) assert functions == [created] + assert functions[0].source_digest == ( + normalize_score.registration_request.artifact.digest + ) assert state["requests"] == [ ("/v1/namespace/$/function/list", {"include_definition": "true"}), ( @@ -1909,6 +1917,9 @@ async def test_async_remote_list_functions_returns_typed_versions(): functions = [item async for item in db.list_functions()] assert functions == [created] + assert functions[0].source_digest == ( + normalize_score.registration_request.artifact.digest + ) assert [path for path, _ in state["requests"]] == [ "/v1/namespace/$/function/list", "/v1/namespace/$/function/list", diff --git a/rust/lancedb/src/function.rs b/rust/lancedb/src/function.rs index db3dc1c3b..db93f0071 100644 --- a/rust/lancedb/src/function.rs +++ b/rust/lancedb/src/function.rs @@ -459,6 +459,8 @@ pub struct FunctionVersion { #[serde(deserialize_with = "deserialize_object_version")] version: String, image: FunctionImage, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_digest: Option, signature: FunctionSignature, #[serde(default, skip_serializing_if = "Vec::is_empty")] secret_bindings: Vec, @@ -503,6 +505,23 @@ impl FunctionVersion { pub fn image(&self) -> &FunctionImage { &self.image } + + /// Digest of the registered source artifact, when provided by the server. + /// + /// Compare this with [`FunctionArtifactRequest::digest`] to identify matching + /// source, independently of the built image's manifest digest. + /// + /// ``` + /// use lancedb::function::{FunctionArtifactRequest, FunctionVersion}; + /// + /// fn has_same_source(version: &FunctionVersion, artifact: &FunctionArtifactRequest) -> bool { + /// version.source_digest() == Some(artifact.digest.as_str()) + /// } + /// ``` + pub fn source_digest(&self) -> Option<&str> { + self.source_digest.as_deref() + } + pub fn signature(&self) -> &FunctionSignature { &self.signature } @@ -965,6 +984,32 @@ mod conda_environment_tests { mod tests { use super::*; + #[test] + fn function_version_preserves_source_digest() { + const VERSION: &str = include_str!( + "../tests/fixtures/first_class_functions/v1/remote_function_version.canonical.json" + ); + let mut document: Value = serde_json::from_str(VERSION).unwrap(); + let legacy = FunctionVersion::from_json(VERSION).unwrap(); + assert_eq!(legacy.source_digest(), None); + assert_eq!(legacy.to_canonical_json().unwrap(), VERSION.trim_end()); + + for digest in [Value::Null, serde_json::json!("sha256:source-artifact")] { + document["source_digest"] = digest.clone(); + let version = FunctionVersion::from_json(&document.to_string()).unwrap(); + assert_eq!(version.source_digest(), digest.as_str()); + assert_eq!(version.image(), legacy.image()); + let encoded = version.to_canonical_json().unwrap(); + let encoded_value: Value = serde_json::from_str(&encoded).unwrap(); + if digest.is_null() { + assert!(encoded_value.get("source_digest").is_none()); + } else { + assert_eq!(encoded_value["source_digest"], digest); + } + assert_eq!(FunctionVersion::from_json(&encoded).unwrap(), version); + } + } + #[test] fn blob_reader_access_survives_signature_round_trip() { let parameter: FunctionParameter = serde_json::from_value(serde_json::json!({