mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
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>
524 lines
18 KiB
Python
524 lines
18 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from lancedb import col
|
|
import lancedb.functions as functions
|
|
from lancedb.functions import (
|
|
FunctionApplication,
|
|
FunctionBinding,
|
|
FunctionVersion,
|
|
PythonRuntimeSpec,
|
|
SecretBinding,
|
|
RefreshColumnResult,
|
|
SecretReference,
|
|
)
|
|
from lancedb.table import AsyncTable
|
|
|
|
|
|
FIXTURES = (
|
|
Path(__file__).parents[3]
|
|
/ "rust"
|
|
/ "lancedb"
|
|
/ "tests"
|
|
/ "fixtures"
|
|
/ "first_class_functions"
|
|
/ "v1"
|
|
)
|
|
|
|
|
|
def fixture(name: str) -> str:
|
|
return (FIXTURES / name).read_text()
|
|
|
|
|
|
def job_result(name: str) -> dict:
|
|
return json.loads(fixture(name))["result"]
|
|
|
|
|
|
def assert_no_secret_values(value):
|
|
"""No client value models a resolved credential, at any nesting depth."""
|
|
if isinstance(value, dict):
|
|
for key, child in value.items():
|
|
assert key not in {
|
|
"secret_value",
|
|
"secret_values",
|
|
"resolved_secret",
|
|
"resolved_secrets",
|
|
}
|
|
assert_no_secret_values(child)
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
assert_no_secret_values(child)
|
|
|
|
|
|
def test_public_function_values_are_in_api_reference():
|
|
docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md"
|
|
rendered = docs.read_text()
|
|
for name in functions.__all__:
|
|
assert f"::: lancedb.functions.{name}" in rendered
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("fixture_name", "canonical_name", "model", "nested_result"),
|
|
[
|
|
(
|
|
"remote_function_job.json",
|
|
"remote_function_version.canonical.json",
|
|
FunctionVersion,
|
|
True,
|
|
),
|
|
(
|
|
"remote_function_application.json",
|
|
"remote_function_application.canonical.json",
|
|
FunctionApplication,
|
|
False,
|
|
),
|
|
(
|
|
"remote_function_binding.json",
|
|
"remote_function_binding.canonical.json",
|
|
FunctionBinding,
|
|
False,
|
|
),
|
|
(
|
|
"remote_initialized_function_application.json",
|
|
"remote_initialized_function_application.canonical.json",
|
|
FunctionApplication,
|
|
False,
|
|
),
|
|
(
|
|
"remote_initialized_function_binding.json",
|
|
"remote_initialized_function_binding.canonical.json",
|
|
FunctionBinding,
|
|
False,
|
|
),
|
|
(
|
|
"remote_refresh_job.json",
|
|
"remote_refresh_result.canonical.json",
|
|
RefreshColumnResult,
|
|
True,
|
|
),
|
|
(
|
|
"remote_refresh_result_without_published_version.json",
|
|
"remote_refresh_result_without_published_version.canonical.json",
|
|
RefreshColumnResult,
|
|
False,
|
|
),
|
|
],
|
|
)
|
|
def test_python_and_rust_share_remote_canonical_goldens(
|
|
fixture_name, canonical_name, model, nested_result
|
|
):
|
|
value = json.loads(fixture(fixture_name))
|
|
if nested_result:
|
|
value = value["result"]
|
|
decoded = model.from_json(json.dumps(value))
|
|
assert decoded.to_canonical_json() == fixture(canonical_name).strip()
|
|
|
|
|
|
def test_function_version_identity_is_immutable_and_exact():
|
|
value = job_result("remote_function_job.json")
|
|
version = FunctionVersion.from_json(json.dumps(value))
|
|
assert version.name == "embed"
|
|
assert version.version == "1"
|
|
assert version.image.manifest_digest.startswith("sha256:")
|
|
assert version.version != version.image.manifest_digest
|
|
assert list(version.secret_bindings) == [
|
|
SecretBinding(
|
|
kind="env", variable="HF_TOKEN", secret_ref=SecretReference(name="hf-prod")
|
|
)
|
|
]
|
|
|
|
with pytest.raises((TypeError, ValueError)):
|
|
version.version = "1"
|
|
with pytest.raises(TypeError, match="immutable"):
|
|
version.image.descriptor["format_version"] = "changed"
|
|
|
|
changed = dict(value)
|
|
changed["version"] = "2"
|
|
assert FunctionVersion(**changed) != version
|
|
assert FunctionVersion(**changed).image == version.image
|
|
for invalid in [version.image.manifest_digest, "0", "01", "-1", str(2**64)]:
|
|
with pytest.raises(ValueError):
|
|
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"))
|
|
)
|
|
|
|
application = version(text=col("documents.body"))
|
|
|
|
assert application.function.name == version.name
|
|
assert application.function.version == version.version
|
|
assert application.output is version.signature.output
|
|
assert [
|
|
(value.parameter, value.kind, value.value["path"])
|
|
for value in application.inputs
|
|
] == [("text", "column", "documents.body")]
|
|
|
|
|
|
def test_function_version_names_its_namespace_as_a_separate_field():
|
|
"""A Function is named by its parts, as a Secret is; a root one states no
|
|
namespace."""
|
|
value = job_result("remote_function_job.json")
|
|
root = FunctionVersion.from_json(json.dumps(value))
|
|
assert root.namespace_path == ()
|
|
assert "namespace_path" not in json.loads(root.to_canonical_json())
|
|
|
|
nested = FunctionVersion(**{**value, "namespace_path": ["analytics", "features"]})
|
|
assert nested.name == "embed"
|
|
assert nested.namespace_path == ("analytics", "features")
|
|
assert json.loads(nested.to_canonical_json())["namespace_path"] == [
|
|
"analytics",
|
|
"features",
|
|
]
|
|
application = nested(text=col("documents.body"))
|
|
assert application.function.name == "embed"
|
|
assert application.function.namespace_path == ("analytics", "features")
|
|
assert json.loads(application.to_canonical_json())["function"][
|
|
"namespace_path"
|
|
] == ["analytics", "features"]
|
|
|
|
|
|
def test_function_version_binding_validates_names_and_direct_columns():
|
|
version = FunctionVersion.from_json(
|
|
json.dumps(job_result("remote_function_job.json"))
|
|
)
|
|
|
|
with pytest.raises(TypeError, match=r"missing inputs: \['text'\]"):
|
|
version()
|
|
with pytest.raises(TypeError, match=r"unknown arguments: \['body'\]"):
|
|
version(text=col("text"), body=col("body"))
|
|
with pytest.raises(TypeError, match="direct col"):
|
|
version(text=col("text").lower())
|
|
|
|
|
|
def test_function_version_keeps_named_struct_outputs_in_one_application():
|
|
value = job_result("remote_function_job.json")
|
|
value["name"] = "text_features"
|
|
value["version"] = "1"
|
|
value["signature"] = {
|
|
"inputs": [
|
|
{"name": "title", "arrow_type": "utf8", "nullable": True},
|
|
{"name": "body", "arrow_type": "utf8", "nullable": True},
|
|
],
|
|
"output": {
|
|
"kind": "named_struct",
|
|
"fields": [
|
|
{
|
|
"name": "normalized_text",
|
|
"arrow_type": "utf8",
|
|
"nullable": False,
|
|
},
|
|
{
|
|
"name": "token_count",
|
|
"arrow_type": "int64",
|
|
"nullable": False,
|
|
},
|
|
],
|
|
},
|
|
}
|
|
version = FunctionVersion(**value)
|
|
|
|
application = version(body=col("body"), title=col("title")).rename(
|
|
columns={
|
|
"normalized_text": "search_text",
|
|
"token_count": "search_token_count",
|
|
}
|
|
)
|
|
|
|
assert [value.parameter for value in application.inputs] == ["title", "body"]
|
|
assert [field.name for field in application.output.fields] == [
|
|
"normalized_text",
|
|
"token_count",
|
|
]
|
|
assert dict(application.columns) == {
|
|
"normalized_text": "search_text",
|
|
"token_count": "search_token_count",
|
|
}
|
|
|
|
|
|
def test_unknown_fields_and_discriminators_are_forward_decodable():
|
|
value = job_result("remote_function_job.json")
|
|
value["future_version_metadata"] = {"retention_class": "catalog"}
|
|
value["image"]["descriptor"]["future_interface"] = {"kind": "wasm"}
|
|
value["signature"]["output"]["kind"] = "future_output_shape"
|
|
|
|
version = FunctionVersion.from_json(json.dumps(value))
|
|
assert version.image.descriptor["future_interface"] == {"kind": "wasm"}
|
|
assert json.loads(version.to_canonical_json())["image"]["descriptor"][
|
|
"future_interface"
|
|
] == {"kind": "wasm"}
|
|
assert version.signature.output.kind == "future_output_shape"
|
|
|
|
|
|
def test_function_application_uses_rename_columns_only():
|
|
application = FunctionApplication.from_json(
|
|
fixture("remote_function_application.json")
|
|
)
|
|
renamed = application.rename(columns={"normalized_text": "body_normalized"})
|
|
|
|
assert application.columns["normalized_text"] == "search_text"
|
|
assert renamed.columns["normalized_text"] == "body_normalized"
|
|
assert renamed.function == application.function
|
|
assert not hasattr(application, "rename_outputs")
|
|
with pytest.raises(TypeError, match="immutable"):
|
|
renamed.columns["normalized_text"] = "changed"
|
|
with pytest.raises(TypeError, match="immutable"):
|
|
application.inputs[0].value["path"] = "changed"
|
|
|
|
with pytest.raises(ValueError, match="unknown Function result fields"):
|
|
application.rename(columns={"missing": "search_text"})
|
|
with pytest.raises(ValueError, match="destinations must be unique"):
|
|
application.rename(columns={"normalized_text": "same", "token_count": "same"})
|
|
|
|
bare_value = json.loads(fixture("remote_function_application.json"))
|
|
bare_value.pop("columns")
|
|
bare = FunctionApplication(**bare_value)
|
|
with pytest.raises(ValueError, match="destinations must be unique"):
|
|
bare.rename(columns={"normalized_text": "token_count"})
|
|
|
|
|
|
def test_binding_and_refresh_result_keep_stable_remote_fields():
|
|
binding = FunctionBinding.from_json(fixture("remote_function_binding.json"))
|
|
assert binding.function.version == "1"
|
|
assert [output.output_ordinal for output in binding.outputs] == [0, 1]
|
|
assert binding.input_schema is not None
|
|
assert binding.output_schema is not None
|
|
|
|
result = RefreshColumnResult.from_json(
|
|
json.dumps(job_result("remote_refresh_job.json"))
|
|
)
|
|
assert result.rows_filled == result.rows_assigned
|
|
assert result.version == result.published_version
|
|
|
|
result = RefreshColumnResult.from_json(
|
|
fixture("remote_refresh_result_without_published_version.json")
|
|
)
|
|
assert result.published_version is None
|
|
assert RefreshColumnResult.from_json(result.to_canonical_json()) == result
|
|
|
|
|
|
def test_initialization_travels_on_the_application_and_binding():
|
|
application = FunctionApplication.from_json(
|
|
fixture("remote_initialized_function_application.json")
|
|
)
|
|
assert application.initialization["temperature"] == 0.25
|
|
assert application.initialization["retry"]["backoff"] == (1, 2, 4)
|
|
application._ensure_declarable()
|
|
|
|
binding = FunctionBinding.from_json(
|
|
fixture("remote_initialized_function_binding.json")
|
|
)
|
|
assert binding.initialization_row() == {
|
|
"model": "text-embedding-3-small",
|
|
"dimensions": 512,
|
|
}
|
|
assert (
|
|
FunctionBinding.from_json(
|
|
fixture("remote_function_binding.json")
|
|
).initialization_row()
|
|
is None
|
|
)
|
|
|
|
signature = FunctionVersion.from_json(
|
|
json.dumps(job_result("remote_function_job.json"))
|
|
).signature
|
|
assert signature.initialization == ()
|
|
assert "initialization" not in json.loads(signature.to_canonical_json())
|
|
|
|
|
|
def test_function_literal_numeric_domain_matches_rust():
|
|
with pytest.raises(ValueError, match="floating-point Function literals"):
|
|
FunctionApplication.from_json(fixture("remote_function_application_float.json"))
|
|
|
|
value = json.loads(fixture("remote_function_application_float.json"))
|
|
value["inputs"][0]["value"] = 2**64
|
|
with pytest.raises(ValueError, match="outside the canonical JSON range"):
|
|
FunctionApplication.from_json(json.dumps(value))
|
|
|
|
|
|
def test_empty_default_maps_have_stable_canonical_bytes():
|
|
runtime = PythonRuntimeSpec(
|
|
kind="python", python_version="3.12", environment={"kind": "pip"}
|
|
)
|
|
assert runtime.to_canonical_json() == (
|
|
'{"environment":{"kind":"pip"},"kind":"python","python_version":"3.12"}'
|
|
)
|
|
|
|
value = json.loads(fixture("remote_function_application.json"))
|
|
value.pop("columns")
|
|
application = FunctionApplication.from_json(json.dumps(value))
|
|
assert "columns" not in json.loads(application.to_canonical_json())
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["rows_assigned", "source_version"])
|
|
def test_refresh_result_rejects_non_u64_values(field):
|
|
value = job_result("remote_refresh_job.json")
|
|
value[field] = -1
|
|
with pytest.raises(ValueError):
|
|
RefreshColumnResult.from_json(json.dumps(value))
|
|
|
|
value[field] = "1"
|
|
with pytest.raises(ValueError):
|
|
RefreshColumnResult.from_json(json.dumps(value))
|
|
|
|
|
|
def test_canonical_client_values_carry_bindings_and_no_credentials():
|
|
"""A binding names a Secret; the credential behind it has no client field."""
|
|
version = FunctionVersion.from_json(
|
|
json.dumps(job_result("remote_function_job.json"))
|
|
)
|
|
canonical = json.loads(version.to_canonical_json())
|
|
assert canonical["secret_bindings"] == [
|
|
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}}
|
|
]
|
|
assert_no_secret_values(canonical)
|
|
|
|
|
|
def test_a_version_without_bindings_omits_the_field_in_both_directions():
|
|
"""A Function that binds nothing carries no ``secret_bindings`` key.
|
|
|
|
Absent decodes as an empty list, and an empty list serializes back to
|
|
absent.
|
|
"""
|
|
value = job_result("remote_function_job.json")
|
|
del value["secret_bindings"]
|
|
version = FunctionVersion.from_json(json.dumps(value))
|
|
assert list(version.secret_bindings) == []
|
|
assert "secret_bindings" not in json.loads(version.to_canonical_json())
|
|
|
|
|
|
class _FunctionDeclarationInner:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
async def add_function_columns(
|
|
self,
|
|
application_json,
|
|
output_name,
|
|
auto_refresh_enabled,
|
|
auto_refresh_interval_seconds,
|
|
):
|
|
self.calls.append(
|
|
(
|
|
json.loads(application_json),
|
|
output_name,
|
|
auto_refresh_enabled,
|
|
auto_refresh_interval_seconds,
|
|
)
|
|
)
|
|
return "declared"
|
|
|
|
|
|
def known_application() -> FunctionApplication:
|
|
value = json.loads(fixture("remote_function_application.json"))
|
|
value.pop("future_application")
|
|
return FunctionApplication(**value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_columns_routes_struct_as_one_and_multi_output_binding_atomically():
|
|
inner = _FunctionDeclarationInner()
|
|
table = AsyncTable(inner)
|
|
application = known_application()
|
|
|
|
result = await table.add_columns(
|
|
{"features": application._copy(update={"columns": {}})}
|
|
)
|
|
assert result == "declared"
|
|
assert inner.calls[-1][1] == "features"
|
|
assert inner.calls[-1][2:] == (None, None)
|
|
|
|
bare = application._copy(update={"columns": {}}).rename(
|
|
columns={"normalized_text": "search_text"}
|
|
)
|
|
result = await table.add_columns(
|
|
bare, auto_refresh_enabled=False, auto_refresh_interval_seconds=300
|
|
)
|
|
assert result == "declared"
|
|
assert inner.calls[-1][1] is None
|
|
assert inner.calls[-1][0]["columns"] == {"normalized_text": "search_text"}
|
|
assert inner.calls[-1][2:] == (False, 300)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_columns_rejects_multiple_bindings_and_unknown_newer_application():
|
|
inner = _FunctionDeclarationInner()
|
|
table = AsyncTable(inner)
|
|
application = known_application()
|
|
|
|
with pytest.raises(ValueError, match="exactly one Function binding"):
|
|
await table.add_columns({"a": application, "b": application})
|
|
|
|
future = json.loads(fixture("remote_function_application.json"))
|
|
application = FunctionApplication(**future)
|
|
with pytest.raises(ValueError, match="newer contract"):
|
|
await table.add_columns(application)
|
|
|
|
future.pop("future_application")
|
|
future["output"]["assignment"] = "cell_flag"
|
|
application = FunctionApplication(**future)
|
|
assert "assignment" not in json.loads(application.to_canonical_json())["output"]
|
|
with pytest.raises(ValueError, match="output.assignment"):
|
|
await table.add_columns(application)
|
|
assert inner.calls == []
|
|
|
|
|
|
def test_rename_requires_named_struct_and_keeps_partial_mapping_immutable():
|
|
scalar = FunctionApplication.from_json(
|
|
json.dumps(
|
|
{
|
|
"function": {
|
|
"name": "embed",
|
|
"version": "1",
|
|
"object_id": "fixture",
|
|
"location": "memory:///fixture",
|
|
"manifest_digest": (
|
|
"sha256:"
|
|
"7e22f815b6648e14f093a3979a8e5a2082fa773ebe1ec84b135cae7e84d6f8e6"
|
|
),
|
|
},
|
|
"inputs": [],
|
|
"output": {
|
|
"kind": "scalar",
|
|
"arrow_type": "list<float32>",
|
|
"nullable": False,
|
|
},
|
|
}
|
|
)
|
|
)
|
|
with pytest.raises(ValueError, match="named-struct"):
|
|
scalar.rename(columns={"value": "embedding"})
|
|
|
|
application = known_application()._copy(update={"columns": {}})
|
|
renamed = application.rename(columns={"normalized_text": "search_text"})
|
|
assert dict(application.columns) == {}
|
|
assert dict(renamed.columns) == {"normalized_text": "search_text"}
|