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",