mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-31 07:46:51 +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",
|
||||
|
||||
Reference in New Issue
Block a user