mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-30 23:36:52 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user