fix(python): expose function source digests (#4729)

Function descriptions include `source_digest`, but both the Rust
response model and Python `FunctionVersion` discarded it. Clients
therefore could not compare a catalog entry with
`udf_def.registration_request.artifact.digest`.

Preserve the digest through Rust serialization and expose it on Python
`FunctionVersion`, with a documented Rust accessor. Missing or null
digests decode as `None`, keeping older responses compatible. Regression
tests cover serialization, immutability, registration jobs, get, and
synchronous/asynchronous list responses while keeping the image digest
independent.

Validation:
- Confirmed that the original Python model silently discarded a
server-provided source digest.
- Rebuilt the Python extension in the uv-managed environment; both
focused Function test files passed (106 tests).
- Focused Rust Function model tests passed (10 tests).
- Full workspace `cargo check --quiet --features remote --tests
--examples` and `cargo clippy --quiet --features remote --tests
--examples` passed.
- Rust formatting and Ruff checks/formatting for changed Python files
passed.
- Repository-wide Ruff reports 138 existing violations in unchanged
docs/CI files.

Fixes #4723

<!-- lance-gatekeeper-fix:v1 agent=c145f25136f7b908217e04bde8a2548b
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
This commit is contained in:
lancedb-gatefixer[bot]
2026-10-28 16:04:04 -07:00
committed by GitHub
parent 3035636bed
commit fc5170cbed
4 changed files with 84 additions and 0 deletions
+7
View File
@@ -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
@@ -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"))
@@ -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",
+45
View File
@@ -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<String>,
signature: FunctionSignature,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
secret_bindings: Vec<SecretBinding>,
@@ -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!({