mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
A package shipped through `code=` currently loses its non-Python files.
This breaks vendored packages that read JSON configuration, manifests,
prompts, or binary resources after deployment. Include regular package
resources in a separate base64-encoded `python_bundle.resources` map,
preserving exact bytes and relative paths. Both source and resources
contribute to the artifact digest. UTF-8 Python source bytes retain
their original line endings as well, so manifests that hash vendor code
continue to match.
For example, `@function(code=[my_package])` can use
`importlib.resources.files("my_package").joinpath("models.json").read_text()`
on the worker. Hidden files and caches are excluded; symlinks and unsafe
paths warn instead of following files outside the package. Source-only
bundles retain their existing wire form. Resource bundles require
matching server support.
Adds an isolated-import regression with a vendored subpackage, JSON
metadata, binary data, SHA256 verification, preserved CRLF bytes, and
resource-only version changes. Local builds and tests are disabled by
request. Ruff formatting and linting run locally; CI runs the tests.
3006 lines
116 KiB
Python
3006 lines
116 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
"""Canonical Function values exchanged with LanceDB Enterprise services.
|
|
|
|
These immutable models contain client/wire state only. Catalog persistence,
|
|
environment bake, secret resolution, and execution are owned by LanceDB Enterprise.
|
|
``RefreshColumnResult`` is also the backend-neutral result of a local
|
|
expression-backed refresh job.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import builtins
|
|
import base64
|
|
import functools
|
|
import hashlib
|
|
import importlib.util
|
|
import inspect
|
|
import symtable
|
|
import json
|
|
import linecache
|
|
import math
|
|
import os
|
|
import re
|
|
import sys
|
|
import textwrap
|
|
import types
|
|
import warnings
|
|
from collections.abc import Mapping, Sequence
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from typing import (
|
|
Annotated,
|
|
Any,
|
|
Callable,
|
|
Literal,
|
|
Optional,
|
|
TYPE_CHECKING,
|
|
Union,
|
|
get_args,
|
|
get_origin,
|
|
get_type_hints,
|
|
overload,
|
|
)
|
|
|
|
import pyarrow as pa
|
|
from packaging.requirements import Requirement
|
|
from pydantic import (
|
|
AfterValidator,
|
|
BaseModel,
|
|
ConfigDict,
|
|
Field,
|
|
conint,
|
|
field_validator,
|
|
model_validator,
|
|
)
|
|
|
|
from .schema import blob as _blob_field
|
|
from .schema import is_blob_v2_field as _is_blob_v2_field
|
|
from ._blob_reader import BlobReader
|
|
from ._lancedb import _validate_conda_requirement
|
|
from .secrets import EnvVarSecret
|
|
|
|
_Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1)
|
|
_UInt32 = conint(strict=True, ge=0, le=2**32 - 1)
|
|
_UInt64 = conint(strict=True, ge=0, le=2**64 - 1)
|
|
_GPU_CLASS_NAME = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
_GPU_CLASS_ERROR = (
|
|
"gpu_class must be a lowercase name of at most 63 letters, digits, or "
|
|
"hyphens, and cannot start or end with a hyphen"
|
|
)
|
|
|
|
|
|
def _validate_gpu_wire_marker(value: Any) -> bool:
|
|
if value is not True:
|
|
raise ValueError("runtime.gpu must be true")
|
|
return True
|
|
|
|
|
|
def _normalize_gpu_marker(value: bool) -> Optional[bool]:
|
|
if not isinstance(value, bool):
|
|
raise ValueError("gpu must be a boolean")
|
|
return True if value else None
|
|
|
|
|
|
def _validate_gpu_class_name(value: Any) -> str:
|
|
if not isinstance(value, str) or not _GPU_CLASS_NAME.fullmatch(value):
|
|
raise ValueError(_GPU_CLASS_ERROR)
|
|
return value
|
|
|
|
|
|
def _validate_batch_size(value: Any) -> Optional[int]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise ValueError("batch_size must be an integer")
|
|
if not 1 <= value <= 2**32 - 1:
|
|
raise ValueError("batch_size must be between 1 and 4294967295")
|
|
return value
|
|
|
|
|
|
def _validate_max_concurrency(value: Any) -> Optional[int]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise ValueError("max_concurrency must be an integer")
|
|
if not 1 <= value <= 2**32 - 1:
|
|
raise ValueError("max_concurrency must be between 1 and 4294967295")
|
|
return value
|
|
|
|
|
|
def _validate_row_timeout(value: Any) -> Optional[float]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise ValueError("row_timeout_seconds must be a number")
|
|
value = float(value)
|
|
if not math.isfinite(value) or value <= 0:
|
|
raise ValueError("row_timeout_seconds must be positive and finite")
|
|
return value
|
|
|
|
|
|
class _FrozenDict(dict):
|
|
def _immutable(self, *args, **kwargs):
|
|
raise TypeError("remote canonical values are immutable")
|
|
|
|
__setitem__ = _immutable
|
|
__delitem__ = _immutable
|
|
clear = _immutable
|
|
pop = _immutable
|
|
popitem = _immutable
|
|
setdefault = _immutable
|
|
update = _immutable
|
|
|
|
def __ior__(self, other):
|
|
self._immutable()
|
|
|
|
|
|
def _freeze_value(value):
|
|
if isinstance(value, Mapping):
|
|
return _FrozenDict({key: _freeze_value(child) for key, child in value.items()})
|
|
if isinstance(value, (list, tuple)):
|
|
return tuple(_freeze_value(child) for child in value)
|
|
return value
|
|
|
|
|
|
def _validate_literal(value):
|
|
if isinstance(value, float):
|
|
raise ValueError(
|
|
"floating-point Function literals are not part of the Slice 1 "
|
|
"canonical wire contract"
|
|
)
|
|
if isinstance(value, int) and not isinstance(value, bool):
|
|
if not -(2**63) <= value <= 2**64 - 1:
|
|
raise ValueError(
|
|
"Function integer literal is outside the canonical JSON range"
|
|
)
|
|
elif isinstance(value, Mapping):
|
|
for child in value.values():
|
|
_validate_literal(child)
|
|
elif isinstance(value, (list, tuple)):
|
|
for child in value:
|
|
_validate_literal(child)
|
|
return value
|
|
|
|
|
|
def _known_wire_value(value):
|
|
if isinstance(value, _RemoteValue):
|
|
return value._known_dict()
|
|
if isinstance(value, Mapping):
|
|
return {key: _known_wire_value(child) for key, child in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_known_wire_value(child) for child in value]
|
|
return value
|
|
|
|
|
|
class _RemoteValue(BaseModel):
|
|
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
|
|
@model_validator(mode="after")
|
|
def _freeze_mappings(self):
|
|
for name, value in self.__dict__.items():
|
|
object.__setattr__(self, name, _freeze_value(value))
|
|
return self
|
|
|
|
@classmethod
|
|
def from_json(cls, payload: str):
|
|
return cls.model_validate_json(payload)
|
|
|
|
def _known_dict(self) -> dict[str, Any]:
|
|
known = {}
|
|
for name, field in self.__class__.model_fields.items():
|
|
value = getattr(self, name)
|
|
if value is None:
|
|
continue
|
|
if not field.is_required():
|
|
default_factory = field.default_factory
|
|
if default_factory is not None and value == default_factory():
|
|
continue
|
|
if default_factory is None and value == field.default:
|
|
continue
|
|
known[name] = _known_wire_value(value)
|
|
return known
|
|
|
|
def _copy(self, *, update: Mapping[str, Any]):
|
|
update = {name: _freeze_value(value) for name, value in update.items()}
|
|
return self.model_copy(update=update)
|
|
|
|
def to_canonical_json(self) -> str:
|
|
return json.dumps(
|
|
self._known_dict(),
|
|
ensure_ascii=False,
|
|
allow_nan=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
|
|
|
|
class _OpenRemoteValue(_RemoteValue):
|
|
"""Forward-readable value whose extras stay out of canonical encoding."""
|
|
|
|
model_config = ConfigDict(extra="allow", frozen=True)
|
|
|
|
def _unknown_field_names(self) -> set[str]:
|
|
return set((self.__pydantic_extra__ or {}).keys())
|
|
|
|
|
|
class FunctionRetryPolicy(_RemoteValue):
|
|
"""Bound retries of selected errors inside one Enterprise Function call.
|
|
|
|
``max_attempts`` includes the first call. Matchers are alternatives;
|
|
``transient=True`` selects connection/timeouts, HTTP 429/503 and the
|
|
provider codes ``SlowDown`` and ``ServiceUnavailable``. Exception names
|
|
may be qualified, and subclasses and wrapped causes also match.
|
|
|
|
Backoff doubles with full jitter, bounded by ``max_backoff_seconds``.
|
|
Retry-After above that bound ends retries. A failed batch is retried as
|
|
one call: under ``skip``, exhaustion nulls every row in that call.
|
|
Completed calls are never repeated. Only opt in when repeating partial
|
|
work is safe; this does not make external side effects exactly once.
|
|
|
|
>>> policy = FunctionRetryPolicy(transient=True, max_attempts=3)
|
|
>>> policy.max_attempts
|
|
3
|
|
>>> disabled = FunctionRetryPolicy(max_attempts=1)
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
max_attempts: Annotated[int, Field(strict=True)] = 3
|
|
initial_backoff_seconds: Annotated[float, Field(strict=True)] = 0.2
|
|
max_backoff_seconds: Annotated[float, Field(strict=True)] = 30.0
|
|
exception_types: tuple[str, ...] = ()
|
|
message_pattern: Optional[str] = None
|
|
error_codes: tuple[str, ...] = ()
|
|
transient: Annotated[bool, Field(strict=True)] = False
|
|
honor_retry_after: Annotated[bool, Field(strict=True)] = True
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_policy(self):
|
|
from ._lancedb import validate_function_retry_policy
|
|
|
|
validate_function_retry_policy(self.model_dump_json())
|
|
return self
|
|
|
|
|
|
class FunctionArtifact(_RemoteValue):
|
|
"""Content-addressed Python artifact identity."""
|
|
|
|
kind: str
|
|
digest: str
|
|
entrypoint: str
|
|
|
|
|
|
class FunctionArtifactContent(_RemoteValue):
|
|
"""Encoded artifact bytes uploaded during remote registration."""
|
|
|
|
encoding: str
|
|
data: str
|
|
|
|
|
|
class PythonAdapterSpec(_RemoteValue):
|
|
"""Internal scalar-callable to Arrow-batch adapter selection."""
|
|
|
|
kind: str
|
|
version: _UInt32
|
|
|
|
|
|
class FunctionArtifactRequest(_RemoteValue):
|
|
"""Source artifact uploaded while registering a Function."""
|
|
|
|
kind: str
|
|
digest: str
|
|
entrypoint: str
|
|
content: FunctionArtifactContent
|
|
adapter: PythonAdapterSpec
|
|
|
|
|
|
class FunctionParameter(_RemoteValue):
|
|
name: str
|
|
arrow_type: str
|
|
nullable: bool
|
|
blob_access: Optional[Literal["bytes", "reader"]] = None
|
|
|
|
|
|
class FunctionResultField(_OpenRemoteValue):
|
|
name: str
|
|
arrow_type: str
|
|
nullable: bool
|
|
|
|
|
|
class FunctionOutput(_OpenRemoteValue):
|
|
"""Scalar or ordered named-struct output; unknown kinds remain decodable."""
|
|
|
|
kind: str
|
|
arrow_type: Optional[str] = None
|
|
nullable: Optional[bool] = None
|
|
fields: tuple[FunctionResultField, ...] = ()
|
|
|
|
|
|
class SecretReference(_RemoteValue):
|
|
"""Where a Secret lives, carried as its parts rather than as one string.
|
|
|
|
A joined id would need a delimiter, and a delimiter has to be excluded from
|
|
every name and segment forever, agreed on by both sides, and re-agreed each
|
|
time either grows a new way to be configured. Naming the parts settles all
|
|
of that: nothing here is parsed, so nothing can parse two ways.
|
|
"""
|
|
|
|
name: str
|
|
namespace_path: tuple[str, ...] = ()
|
|
|
|
|
|
class SecretBinding(_RemoteValue):
|
|
"""How a Secret reaches the Function that binds it.
|
|
|
|
One list rather than a field per delivery mode: a binding is the concept,
|
|
and how it arrives is a property of one. ``kind`` is open, so a binding a
|
|
newer service introduces decodes here instead of failing the whole
|
|
FunctionVersion.
|
|
"""
|
|
|
|
kind: str
|
|
variable: Optional[str] = None
|
|
secret_ref: Optional[SecretReference] = None
|
|
|
|
|
|
class FunctionSignature(_RemoteValue):
|
|
"""Ordered inputs, output, and initialization fields of a Function.
|
|
|
|
``initialization`` lists the fields of the single row each Function
|
|
instance is created with. Values come from each binding, not from the
|
|
Function version; the tuple is empty when the Function takes none.
|
|
"""
|
|
|
|
inputs: tuple[FunctionParameter, ...]
|
|
output: FunctionOutput
|
|
initialization: tuple[FunctionParameter, ...] = ()
|
|
|
|
|
|
class PythonEnvironmentSpec(_RemoteValue):
|
|
"""One LanceDB Enterprise-managed Python environment source."""
|
|
|
|
kind: str
|
|
packages: tuple[str, ...] = ()
|
|
channels: tuple[str, ...] = ()
|
|
path: Optional[str] = None
|
|
modules: tuple[str, ...] = ()
|
|
image: Optional[str] = None
|
|
system_packages: tuple[str, ...] = ()
|
|
|
|
|
|
_HUGGING_FACE_MODEL_REPO = re.compile(
|
|
r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$"
|
|
)
|
|
_HUGGING_FACE_REVISION = re.compile(r"^[0-9a-f]{40}$")
|
|
|
|
|
|
class HuggingFaceModel(_RemoteValue):
|
|
"""One immutable Hugging Face model snapshot required by a Function.
|
|
|
|
``revision`` is a full 40-character lowercase commit SHA, not a branch or
|
|
tag. A Function may declare each repository once; separate versions of the
|
|
same repository use separate Function versions. The service prepares the
|
|
snapshot before initialization, so the model can load without a revision
|
|
argument or a Hugging Face Hub request::
|
|
|
|
@function(
|
|
pip=["sentence-transformers==3.3.1"],
|
|
models=[HuggingFaceModel(
|
|
repo="sentence-transformers/all-MiniLM-L6-v2",
|
|
revision="1110a243fdf4706b3f48f1d95db1a4f5529b4d41",
|
|
)],
|
|
)
|
|
class Embed:
|
|
def __init__(self):
|
|
from sentence_transformers import SentenceTransformer
|
|
|
|
self.model = SentenceTransformer(
|
|
"sentence-transformers/all-MiniLM-L6-v2", device="cpu"
|
|
)
|
|
|
|
def __call__(self, text: str) -> list[float]:
|
|
return self.model.encode(text).tolist()
|
|
"""
|
|
|
|
repo: str = Field(
|
|
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$"
|
|
)
|
|
revision: str
|
|
|
|
@field_validator("repo")
|
|
@classmethod
|
|
def _validate_repo(cls, value: str) -> str:
|
|
if not _HUGGING_FACE_MODEL_REPO.fullmatch(value) or any(
|
|
part in {".", ".."} for part in value.split("/")
|
|
):
|
|
raise ValueError("model repo must be a Hugging Face owner/name")
|
|
return value
|
|
|
|
@field_validator("revision", mode="before")
|
|
@classmethod
|
|
def _validate_revision(cls, value: Any) -> str:
|
|
if not isinstance(value, str) or not _HUGGING_FACE_REVISION.fullmatch(value):
|
|
raise ValueError(
|
|
"model revision must be a full 40-character lowercase commit SHA"
|
|
)
|
|
return value
|
|
|
|
|
|
class PythonRuntimeSpec(_RemoteValue):
|
|
"""Remote runtime definition with environment values.
|
|
|
|
V1 supports ``kind="python"``. Newer runtime kinds remain readable, while
|
|
their unknown payload fields are intentionally not retained by the client.
|
|
``kind="python_v2"`` requires ``gpu=True`` and may name an operator-defined
|
|
``gpu_class``.
|
|
"""
|
|
|
|
kind: str
|
|
python_version: Optional[str] = None
|
|
environment: Optional[PythonEnvironmentSpec] = None
|
|
env: Optional[Mapping[str, str]] = None
|
|
gpu: Optional[bool] = None
|
|
gpu_class: Optional[str] = None
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def _discard_unknown_runtime_payload(cls, value):
|
|
if isinstance(value, Mapping):
|
|
kind = value.get("kind")
|
|
if isinstance(kind, str) and kind not in {"python", "python_v2"}:
|
|
return {"kind": kind}
|
|
return value
|
|
|
|
@field_validator("gpu", mode="before")
|
|
@classmethod
|
|
def _validate_gpu_marker(cls, value):
|
|
if value is None:
|
|
return None
|
|
return _validate_gpu_wire_marker(value)
|
|
|
|
@field_validator("gpu_class", mode="before")
|
|
@classmethod
|
|
def _validate_gpu_class(cls, value):
|
|
if value is None:
|
|
return None
|
|
return _validate_gpu_class_name(value)
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_runtime_kind(self):
|
|
if self.kind == "python":
|
|
if self.python_version is None:
|
|
raise ValueError("python runtime requires python_version")
|
|
if self.environment is None:
|
|
raise ValueError("python runtime requires environment")
|
|
if self.gpu is not None:
|
|
raise ValueError("python runtime with gpu requires kind='python_v2'")
|
|
if self.gpu_class is not None:
|
|
raise ValueError(
|
|
"python runtime with gpu_class requires kind='python_v2'"
|
|
)
|
|
elif self.kind == "python_v2":
|
|
if self.python_version is None:
|
|
raise ValueError("python_v2 runtime requires python_version")
|
|
if self.environment is None:
|
|
raise ValueError("python_v2 runtime requires environment")
|
|
if self.gpu is None:
|
|
raise ValueError("python_v2 runtime requires gpu")
|
|
else:
|
|
object.__setattr__(self, "python_version", None)
|
|
object.__setattr__(self, "environment", None)
|
|
object.__setattr__(self, "env", None)
|
|
object.__setattr__(self, "gpu", None)
|
|
object.__setattr__(self, "gpu_class", None)
|
|
return self
|
|
|
|
@property
|
|
def digest(self) -> str:
|
|
"""SHA-256 identity of the declared runtime, before building an image.
|
|
|
|
Covers Python version, environment sources, environment variables, and
|
|
GPU settings. Package and system-package order is ignored; conda channel
|
|
order is preserved. Compare with ``runtime_digest`` on
|
|
[FunctionVersion][lancedb.functions.FunctionVersion].
|
|
This identifies build inputs, not the resolved contents of unpinned
|
|
dependencies or the deployment's default base image.
|
|
"""
|
|
if self.kind not in {"python", "python_v2"}:
|
|
raise ValueError(f"cannot digest unknown runtime kind {self.kind!r}")
|
|
value = self._known_dict()
|
|
if not value.get("env"):
|
|
value.pop("env", None)
|
|
environment = value["environment"]
|
|
for field in ("packages", "system_packages"):
|
|
if field in environment:
|
|
environment[field] = sorted(environment[field])
|
|
encoded = json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
allow_nan=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
|
|
|
|
|
class FunctionImage(_RemoteValue):
|
|
"""A complete OCI Function image identified by its exact manifest digest."""
|
|
|
|
manifest_digest: str
|
|
descriptor: Mapping[str, Any]
|
|
source: bool
|
|
models: tuple[HuggingFaceModel, ...] = ()
|
|
|
|
|
|
def _validate_object_version(value: str) -> str:
|
|
if int(value) > 2**64 - 1:
|
|
raise ValueError("Function version exceeds uint64")
|
|
return value
|
|
|
|
|
|
_ObjectVersion = Annotated[
|
|
str,
|
|
Field(strict=True, pattern=r"^[1-9][0-9]*$"),
|
|
AfterValidator(_validate_object_version),
|
|
]
|
|
|
|
|
|
class FunctionVersion(_RemoteValue):
|
|
"""A pinned object revision, independent of its executable image digest.
|
|
|
|
Unknown top-level fields from newer servers are accepted for forward
|
|
decoding, but they are not exposed on this value and are omitted from
|
|
``to_canonical_json()``. Known fields retain their complete values, while
|
|
unknown fields inside the open ``image.descriptor`` mapping remain
|
|
available.
|
|
|
|
``name`` and ``namespace_path`` are the Function's parts, as a
|
|
[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
|
|
``definition.registration_request.artifact.digest`` to check whether a local
|
|
[FunctionDefinition][lancedb.functions.FunctionDefinition] has the same source.
|
|
It does not cover dependencies, environment variables, Python version, or
|
|
GPU settings. Equal source digests alone do not mean a version can be reused.
|
|
It is ``None`` when the server does not provide a source digest.
|
|
|
|
``runtime_digest`` identifies the declared environment inputs. Compare it
|
|
with ``definition.registration_request.runtime.digest`` to check whether
|
|
the local runtime matches. It is ``None`` for images built before runtime
|
|
identity was exposed. A missing digest cannot establish a match. Reusing a
|
|
version also requires matching the source and all other registration inputs,
|
|
including signature, Secret bindings, models, and execution controls.
|
|
|
|
``gpu_class`` is the operator-defined GPU pool fixed into this Function
|
|
version, or ``None`` when registration used the deployment default.
|
|
|
|
``batch_size``, ``max_concurrency``, ``row_timeout_seconds``, and
|
|
``result_stability`` retain the execution controls reported by the
|
|
catalog. They are ``None`` for versions registered before the
|
|
corresponding declaration.
|
|
"""
|
|
|
|
name: str
|
|
namespace_path: tuple[str, ...] = ()
|
|
object_id: str
|
|
location: str
|
|
version: _ObjectVersion
|
|
image: FunctionImage
|
|
source_digest: Optional[str] = None
|
|
runtime_digest: Optional[str] = None
|
|
signature: FunctionSignature
|
|
secret_bindings: tuple[SecretBinding, ...] = ()
|
|
created_at_millis: int
|
|
metadata: Mapping[str, str]
|
|
disabled: bool
|
|
gpu_class: Optional[str] = None
|
|
batch_size: Optional[int] = None
|
|
max_concurrency: Optional[int] = None
|
|
row_timeout_seconds: Optional[float] = None
|
|
result_stability: Optional[str] = None
|
|
|
|
@field_validator("batch_size", mode="before")
|
|
@classmethod
|
|
def _validate_batch_size(cls, value):
|
|
return _validate_batch_size(value)
|
|
|
|
@field_validator("max_concurrency", mode="before")
|
|
@classmethod
|
|
def _validate_max_concurrency(cls, value):
|
|
return _validate_max_concurrency(value)
|
|
|
|
@field_validator("row_timeout_seconds", mode="before")
|
|
@classmethod
|
|
def _validate_row_timeout(cls, value):
|
|
return _validate_row_timeout(value)
|
|
|
|
def __call__(self, **arguments: Any) -> FunctionApplication:
|
|
"""Bind this exact version to named table columns.
|
|
|
|
Every input must be a direct [lancedb.col][lancedb.expr.col]
|
|
reference. A Function that declares initialization fields takes their
|
|
values as further keyword arguments; they are constants of this
|
|
binding, fixed for every row, and every instance the binding runs is
|
|
created with them. A non-nullable field without a default must be
|
|
given; an omitted nullable field is null, and the Function's own
|
|
default applies to a null value.
|
|
|
|
The returned application is immutable and retains a named-struct
|
|
output as one binding, so every row's sibling values come from one
|
|
logical Function evaluation. Map result fields to table columns with
|
|
[FunctionApplication.rename][lancedb.functions.FunctionApplication.rename],
|
|
then pass the application to
|
|
[Table.add_columns][lancedb.table.Table.add_columns].
|
|
|
|
Examples
|
|
--------
|
|
>>> from lancedb import col
|
|
>>> application = function( # doctest: +SKIP
|
|
... title=col("title"),
|
|
... body=col("body"),
|
|
... ).rename(columns={
|
|
... "normalized_text": "search_text",
|
|
... "token_count": "search_token_count",
|
|
... })
|
|
>>> table.add_columns(application) # doctest: +SKIP
|
|
>>> table.add_columns({ # doctest: +SKIP
|
|
... "embedding": embed(text=col("body"), model="small", dimensions=512)
|
|
... })
|
|
"""
|
|
from lancedb.expr import Expr
|
|
|
|
parameters = tuple(parameter.name for parameter in self.signature.inputs)
|
|
fields = {field.name: field for field in self.signature.initialization}
|
|
missing = [parameter for parameter in parameters if parameter not in arguments]
|
|
missing_initialization = [
|
|
name
|
|
for name, field in fields.items()
|
|
if not field.nullable and name not in arguments
|
|
]
|
|
unknown = sorted(set(arguments) - set(parameters) - set(fields))
|
|
if missing or missing_initialization or unknown:
|
|
details = []
|
|
if missing:
|
|
details.append(f"missing inputs: {missing!r}")
|
|
if missing_initialization:
|
|
details.append(f"missing initialization: {missing_initialization!r}")
|
|
if unknown:
|
|
details.append(f"unknown arguments: {unknown!r}")
|
|
raise TypeError("invalid Function arguments (" + "; ".join(details) + ")")
|
|
|
|
bindings = []
|
|
for parameter in parameters:
|
|
value = arguments[parameter]
|
|
if not isinstance(value, Expr) or value._column_path is None:
|
|
raise TypeError(
|
|
f"Function input {parameter!r} must be a direct col(...) reference"
|
|
)
|
|
bindings.append(
|
|
ApplicationInput(
|
|
parameter=parameter,
|
|
kind="column",
|
|
value={"path": value._column_path},
|
|
)
|
|
)
|
|
initialization = {}
|
|
for name in fields:
|
|
if name in arguments:
|
|
initialization[name] = _initialization_value(name, arguments[name])
|
|
return FunctionApplication(
|
|
function=FunctionVersionRef(
|
|
name=self.name,
|
|
namespace_path=self.namespace_path,
|
|
object_id=self.object_id,
|
|
location=self.location,
|
|
version=self.version,
|
|
manifest_digest=self.image.manifest_digest,
|
|
),
|
|
inputs=tuple(bindings),
|
|
output=self.signature.output,
|
|
initialization=initialization,
|
|
)
|
|
|
|
|
|
def _initialization_value(name: str, value: Any) -> Any:
|
|
"""One initialization value as plain JSON; the service checks its type."""
|
|
from lancedb.expr import Expr
|
|
|
|
def plain(value):
|
|
if isinstance(value, Expr):
|
|
raise TypeError(
|
|
f"initialization field {name!r} takes a constant, not a column "
|
|
"expression; every row of a binding shares one initialization"
|
|
)
|
|
if value is None or type(value) in (bool, int, str):
|
|
return value
|
|
if type(value) is float:
|
|
if not math.isfinite(value):
|
|
raise ValueError(
|
|
f"initialization field {name!r} must be finite, got {value!r}"
|
|
)
|
|
return value
|
|
if isinstance(value, (list, tuple)):
|
|
return [plain(child) for child in value]
|
|
if isinstance(value, Mapping) and all(isinstance(key, str) for key in value):
|
|
return {key: plain(child) for key, child in value.items()}
|
|
raise TypeError(
|
|
f"initialization field {name!r} takes booleans, numbers, strings, "
|
|
f"lists, and string-keyed mappings, got {type(value).__name__}"
|
|
)
|
|
|
|
return plain(value)
|
|
|
|
|
|
class FunctionRegistrationRequest(_RemoteValue):
|
|
"""Stable remote registration envelope produced by :func:`function`.
|
|
|
|
Credential values deliberately have no field here. The only secret-shaped
|
|
thing a client sends is ``secret_bindings``: the name of a Secret the
|
|
database already holds, which the remote service resolves at execution.
|
|
"""
|
|
|
|
name: str
|
|
artifact: FunctionArtifactRequest
|
|
signature: FunctionSignature
|
|
runtime: PythonRuntimeSpec
|
|
batch_size: Optional[int] = None
|
|
max_concurrency: Optional[int] = None
|
|
row_timeout_seconds: Optional[float] = None
|
|
retry_policy: Optional[FunctionRetryPolicy] = None
|
|
secret_bindings: tuple[SecretBinding, ...] = ()
|
|
models: tuple[HuggingFaceModel, ...] = ()
|
|
|
|
@field_validator("batch_size", mode="before")
|
|
@classmethod
|
|
def _validate_batch_size(cls, value):
|
|
return _validate_batch_size(value)
|
|
|
|
@field_validator("max_concurrency", mode="before")
|
|
@classmethod
|
|
def _validate_max_concurrency(cls, value):
|
|
return _validate_max_concurrency(value)
|
|
|
|
@field_validator("row_timeout_seconds", mode="before")
|
|
@classmethod
|
|
def _validate_row_timeout(cls, value):
|
|
return _validate_row_timeout(value)
|
|
|
|
|
|
class FunctionVersionRef(_OpenRemoteValue):
|
|
name: str
|
|
namespace_path: tuple[str, ...] = ()
|
|
object_id: str
|
|
location: str
|
|
version: _ObjectVersion
|
|
manifest_digest: str
|
|
|
|
|
|
class ApplicationInput(_OpenRemoteValue):
|
|
"""One parameter value.
|
|
|
|
Slice 1 freezes integers, strings, booleans, nulls, arrays, and objects.
|
|
Floating-point literal encoding is deferred until Python authoring is
|
|
introduced with a language-neutral numeric representation.
|
|
"""
|
|
|
|
parameter: str
|
|
kind: str
|
|
value: Any
|
|
|
|
@field_validator("value")
|
|
@classmethod
|
|
def _validate_value(cls, value):
|
|
return _validate_literal(value)
|
|
|
|
|
|
class FunctionApplication(_OpenRemoteValue):
|
|
"""Immutable pre-declaration application of an exact Function version.
|
|
|
|
A named-struct output remains one application through table
|
|
declaration and execution.
|
|
[FunctionApplication.rename][lancedb.functions.FunctionApplication.rename]
|
|
records the result-field to table-column mapping without splitting sibling
|
|
outputs into separate UDF calls.
|
|
"""
|
|
|
|
function: FunctionVersionRef
|
|
inputs: tuple[ApplicationInput, ...]
|
|
output: FunctionOutput
|
|
columns: Mapping[str, str] = Field(default_factory=dict)
|
|
# Transport JSON: the service validates it against the Function's
|
|
# initialization schema and persists the Arrow row in the binding, so it is
|
|
# never hashed and floating-point values are allowed.
|
|
initialization: Mapping[str, Any] = Field(default_factory=dict)
|
|
|
|
def _known_dict(self) -> dict[str, Any]:
|
|
value = super()._known_dict()
|
|
for name in self._unknown_field_names():
|
|
value.pop(name, None)
|
|
return value
|
|
|
|
def _ensure_declarable(self) -> None:
|
|
unknown = {f"application.{name}" for name in self._unknown_field_names()}
|
|
unknown.update(
|
|
f"function.{name}" for name in self.function._unknown_field_names()
|
|
)
|
|
for index, input_value in enumerate(self.inputs):
|
|
unknown.update(
|
|
f"inputs[{index}].{name}" for name in input_value._unknown_field_names()
|
|
)
|
|
unknown.update(f"output.{name}" for name in self.output._unknown_field_names())
|
|
for index, field in enumerate(self.output.fields):
|
|
unknown.update(
|
|
f"output.fields[{index}].{name}"
|
|
for name in field._unknown_field_names()
|
|
)
|
|
if unknown:
|
|
raise ValueError(
|
|
"Function application contains fields from a newer contract: "
|
|
f"{sorted(unknown)!r}"
|
|
)
|
|
|
|
def rename(self, *, columns: Mapping[str, str]) -> FunctionApplication:
|
|
"""Return a copy with result-field to table-column aliases."""
|
|
if self.output.kind != "named_struct":
|
|
raise ValueError("rename(columns=...) requires a named-struct application")
|
|
result_fields = {field.name for field in self.output.fields}
|
|
unknown = set(columns) - result_fields
|
|
if unknown:
|
|
raise ValueError(f"unknown Function result fields: {sorted(unknown)!r}")
|
|
merged = dict(self.columns)
|
|
merged.update(columns)
|
|
destinations = tuple(
|
|
merged.get(field.name, field.name) for field in self.output.fields
|
|
)
|
|
if len(set(destinations)) != len(destinations):
|
|
raise ValueError("FunctionApplication rename destinations must be unique")
|
|
return self._copy(update={"columns": merged})
|
|
|
|
|
|
class InputBinding(_RemoteValue):
|
|
parameter: str
|
|
field_id: _Int32
|
|
field_path: str
|
|
arrow_type: str
|
|
nullable: bool
|
|
|
|
|
|
class OutputMapping(_RemoteValue):
|
|
"""One stable result-field mapping."""
|
|
|
|
result_field: str
|
|
output_name: str
|
|
output_field_id: _Int32
|
|
output_ordinal: _UInt32
|
|
arrow_type: str
|
|
nullable: bool
|
|
|
|
|
|
class AssignmentMapping(_RemoteValue):
|
|
"""Internal physical column preserving flattened struct validity."""
|
|
|
|
output_name: str
|
|
output_field_id: _Int32
|
|
|
|
|
|
class FunctionBinding(_RemoteValue):
|
|
"""Immutable Function binding persisted by the Enterprise table service."""
|
|
|
|
binding_id: str
|
|
function: FunctionVersionRef
|
|
inputs: tuple[InputBinding, ...]
|
|
outputs: tuple[OutputMapping, ...]
|
|
assignment: Optional[AssignmentMapping] = None
|
|
input_schema: Optional[Mapping[str, Any]] = None
|
|
output_schema: Optional[Mapping[str, Any]] = None
|
|
initialization: Optional[str] = None
|
|
"""Standard base64 of the validated one-row Arrow IPC initialization stream."""
|
|
|
|
def initialization_row(self) -> Optional[dict[str, Any]]:
|
|
"""The initialization values this binding creates instances with."""
|
|
if self.initialization is None:
|
|
return None
|
|
reader = pa.ipc.open_stream(base64.b64decode(self.initialization))
|
|
return reader.read_all().to_pylist()[0]
|
|
|
|
|
|
class RefreshColumnResult(_RemoteValue):
|
|
"""Terminal result of an expression-backed or Function-backed refresh Job.
|
|
|
|
LanceDB Embedded jobs produce this value in process. LanceDB Enterprise
|
|
decodes the same value from the durable server-job terminal payload.
|
|
|
|
Row counters describe logical rows planned for this refresh, not user-code
|
|
calls or retry attempts. ``rows_assigned`` counts rows successfully written
|
|
with an output value, ``rows_failed`` counts rows whose computation failed,
|
|
and ``rows_remaining`` counts planned rows neither assigned nor terminally
|
|
skipped. Rows already counted in ``rows_failed`` are covered and are not
|
|
counted again in ``rows_remaining``.
|
|
|
|
For example, a refresh over 2,001 logical rows that assigns 1,891 values and
|
|
fails 110 rows reports ``rows_assigned=1,891``, ``rows_failed=110``, and
|
|
``rows_remaining=0``. Its failure-rate denominator is those 2,001 logical
|
|
rows, not the number of execution attempts.
|
|
"""
|
|
|
|
rows_assigned: _UInt64
|
|
"""Number of logical rows successfully assigned an output value by this
|
|
refresh. Each row counts once, regardless of retry attempts, and failed rows
|
|
are not included."""
|
|
|
|
rows_failed: _UInt64
|
|
"""Number of logical rows whose computation failed. Rows deferred because
|
|
an input was not ready are not failures."""
|
|
|
|
rows_remaining: _UInt64
|
|
"""Number of planned logical rows neither assigned nor terminally skipped.
|
|
Rows already counted in ``rows_failed`` are covered and are not counted
|
|
again."""
|
|
|
|
source_version: _UInt64
|
|
"""Exact table version the refresh read."""
|
|
|
|
published_version: Optional[_UInt64] = None
|
|
"""Table version made visible by the refresh, when one was published."""
|
|
|
|
@property
|
|
def rows_filled(self) -> int:
|
|
"""Deprecated compatibility alias for :attr:`rows_assigned`.
|
|
|
|
The value is the same successful logical-row count; it does not count
|
|
execution attempts.
|
|
"""
|
|
return self.rows_assigned
|
|
|
|
@property
|
|
def version(self) -> Optional[int]:
|
|
"""Deprecated compatibility alias for :attr:`published_version`."""
|
|
return self.published_version
|
|
|
|
|
|
_FUNCTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
# Digest-pinned only; the server checks the host and repository grammar.
|
|
_IMAGE_REFERENCE = re.compile(r"^[^/@\s]+/[^@\s]+@sha256:[0-9a-f]{64}$")
|
|
|
|
_FUNCTION_BLOB_V2_TYPE = "blob_v2"
|
|
_ARROW_EXTENSION_NAME_KEY = "ARROW:extension:name"
|
|
_BLOB_V2_EXTENSION_NAME = "lance.blob.v2"
|
|
_NESTED_BLOB_COLLECTION_ERROR = (
|
|
"unsupported Arrow type for Function signature: Blob v2 fields nested under "
|
|
"collection types are not supported"
|
|
)
|
|
|
|
|
|
_GRAMMAR_PRIMITIVES = (
|
|
(pa.bool_(), "bool"),
|
|
(pa.int8(), "int8"),
|
|
(pa.int16(), "int16"),
|
|
(pa.int32(), "int32"),
|
|
(pa.int64(), "int64"),
|
|
(pa.uint8(), "uint8"),
|
|
(pa.uint16(), "uint16"),
|
|
(pa.uint32(), "uint32"),
|
|
(pa.uint64(), "uint64"),
|
|
(pa.float16(), "float16"),
|
|
(pa.float32(), "float32"),
|
|
(pa.float64(), "float64"),
|
|
(pa.string(), "utf8"),
|
|
(pa.large_string(), "large_utf8"),
|
|
(pa.binary(), "binary"),
|
|
(pa.large_binary(), "large_binary"),
|
|
(pa.date32(), "date32"),
|
|
(pa.date64(), "date64"),
|
|
)
|
|
|
|
|
|
def _canonical_arrow_type(
|
|
data_type: pa.DataType, *, collection_blobs: bool = False
|
|
) -> str:
|
|
"""The compact Function grammar, or canonical exact JSON for nested types."""
|
|
grammar = _grammar_arrow_type(data_type)
|
|
if grammar is not None:
|
|
return grammar
|
|
exact = _exact_arrow_type(data_type, collection_blobs=collection_blobs)
|
|
return json.dumps(exact, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
def _grammar_arrow_type(data_type: pa.DataType) -> Optional[str]:
|
|
for candidate, name in _GRAMMAR_PRIMITIVES:
|
|
if data_type == candidate:
|
|
return name
|
|
if pa.types.is_list(data_type) or pa.types.is_large_list(data_type):
|
|
item = _grammar_list_item(data_type)
|
|
if item is None:
|
|
return None
|
|
prefix = "list" if pa.types.is_list(data_type) else "large_list"
|
|
return f"{prefix}<{item}>"
|
|
if pa.types.is_fixed_size_list(data_type) and data_type.list_size > 0:
|
|
item = _grammar_list_item(data_type)
|
|
if item is not None:
|
|
return f"fixed_size_list<{item}, {data_type.list_size}>"
|
|
return None
|
|
|
|
|
|
def _grammar_list_item(data_type: pa.DataType) -> Optional[str]:
|
|
"""The grammar names only the item type; it always means a non-nullable
|
|
child called `item`, so other child properties require exact JSON."""
|
|
child = data_type.value_field
|
|
if child.name != "item" or child.nullable or child.metadata:
|
|
return None
|
|
return _grammar_arrow_type(child.type)
|
|
|
|
|
|
def _validate_exact_arrow_field(field: pa.Field) -> None:
|
|
if not field.name:
|
|
raise TypeError(
|
|
"unsupported Arrow type for Function signature: field names "
|
|
"must not be empty"
|
|
)
|
|
if _is_blob_v2_field(field):
|
|
if not _has_supported_blob_v2_layout(field):
|
|
raise TypeError(
|
|
"unsupported Arrow type for Function signature: lance.blob.v2 "
|
|
f"requires a supported Blob storage layout, got {field}"
|
|
)
|
|
metadata = {
|
|
(key.decode() if isinstance(key, bytes) else key): (
|
|
value.decode() if isinstance(value, bytes) else value
|
|
)
|
|
for key, value in (field.metadata or {}).items()
|
|
}
|
|
if metadata and metadata != {
|
|
_ARROW_EXTENSION_NAME_KEY: _BLOB_V2_EXTENSION_NAME
|
|
}:
|
|
raise TypeError(
|
|
"unsupported Arrow type for Function signature: lance.blob.v2 "
|
|
"field metadata must contain only its canonical extension marker"
|
|
)
|
|
elif field.metadata:
|
|
raise TypeError(
|
|
"unsupported Arrow type for Function signature: field metadata "
|
|
f"is not supported, got {field}"
|
|
)
|
|
|
|
|
|
def _has_supported_blob_v2_layout(field: pa.Field) -> bool:
|
|
data_type = field.type
|
|
if isinstance(data_type, pa.ExtensionType):
|
|
data_type = data_type.storage_type
|
|
if not pa.types.is_struct(data_type):
|
|
return False
|
|
|
|
fields = tuple(data_type)
|
|
|
|
def matches(spec, compare_nullable) -> bool:
|
|
return len(fields) == len(spec) and all(
|
|
actual.name == name
|
|
and actual.type == expected_type
|
|
and (not check_nullable or actual.nullable == nullable)
|
|
for actual, (name, expected_type, nullable), check_nullable in zip(
|
|
fields, spec, compare_nullable
|
|
)
|
|
)
|
|
|
|
logical_minimal = (
|
|
("data", pa.large_binary(), True),
|
|
("uri", pa.utf8(), True),
|
|
)
|
|
logical_full = logical_minimal + (
|
|
("position", pa.uint64(), True),
|
|
("size", pa.uint64(), True),
|
|
)
|
|
prepared = (
|
|
("kind", pa.uint8(), True),
|
|
("data", pa.large_binary(), True),
|
|
("uri", pa.utf8(), True),
|
|
("blob_id", pa.uint32(), True),
|
|
("blob_size", pa.uint64(), True),
|
|
("position", pa.uint64(), True),
|
|
)
|
|
descriptor = (
|
|
("kind", pa.uint8(), False),
|
|
("position", pa.uint64(), False),
|
|
("size", pa.uint64(), False),
|
|
("blob_id", pa.uint32(), False),
|
|
("blob_uri", pa.utf8(), False),
|
|
)
|
|
return (
|
|
matches(logical_minimal, (True, True))
|
|
or matches(logical_full, (True, True, False, False))
|
|
or matches(prepared, (True,) * len(prepared))
|
|
or matches(descriptor, (False,) * len(descriptor))
|
|
)
|
|
|
|
|
|
def _canonical_arrow_field(field: pa.Field, *, collection_blobs: bool = False) -> str:
|
|
"""``collection_blobs`` admits Blob v2 under a list, which only outputs support."""
|
|
_validate_exact_arrow_field(field)
|
|
if _is_blob_v2_field(field):
|
|
return _FUNCTION_BLOB_V2_TYPE
|
|
return _canonical_arrow_type(field.type, collection_blobs=collection_blobs)
|
|
|
|
|
|
def _blob_storage_type(field: pa.Field) -> pa.DataType:
|
|
data_type = field.type
|
|
if isinstance(data_type, pa.ExtensionType):
|
|
return data_type.storage_type
|
|
return data_type
|
|
|
|
|
|
def _exact_blob_storage_type(field: pa.Field) -> dict[str, Any]:
|
|
storage = _blob_storage_type(field)
|
|
if not pa.types.is_struct(storage):
|
|
raise TypeError(
|
|
"unsupported Arrow type for Function signature: lance.blob.v2 "
|
|
"requires struct storage"
|
|
)
|
|
return {
|
|
"type": "struct",
|
|
"fields": [
|
|
{
|
|
"name": child.name,
|
|
"nullable": child.nullable,
|
|
"type": _exact_arrow_type(child.type),
|
|
}
|
|
for child in storage
|
|
],
|
|
}
|
|
|
|
|
|
def _data_type_has_blob_v2(data_type: pa.DataType) -> bool:
|
|
if pa.types.is_struct(data_type):
|
|
return any(
|
|
_is_blob_v2_field(field) or _data_type_has_blob_v2(field.type)
|
|
for field in data_type
|
|
)
|
|
if (
|
|
pa.types.is_list(data_type)
|
|
or pa.types.is_large_list(data_type)
|
|
or pa.types.is_fixed_size_list(data_type)
|
|
):
|
|
field = data_type.value_field
|
|
return _is_blob_v2_field(field) or _data_type_has_blob_v2(field.type)
|
|
if pa.types.is_map(data_type):
|
|
return any(
|
|
_is_blob_v2_field(field) or _data_type_has_blob_v2(field.type)
|
|
for field in (data_type.key_field, data_type.item_field)
|
|
)
|
|
return False
|
|
|
|
|
|
def _exact_arrow_field(
|
|
field: pa.Field,
|
|
*,
|
|
inside_collection: bool = False,
|
|
collection_blobs: bool = False,
|
|
) -> dict[str, Any]:
|
|
_validate_exact_arrow_field(field)
|
|
if _is_blob_v2_field(field):
|
|
if inside_collection and not collection_blobs:
|
|
raise TypeError(_NESTED_BLOB_COLLECTION_ERROR)
|
|
return {
|
|
"name": field.name,
|
|
"nullable": field.nullable,
|
|
"type": _exact_blob_storage_type(field),
|
|
"metadata": {
|
|
_ARROW_EXTENSION_NAME_KEY: _BLOB_V2_EXTENSION_NAME,
|
|
},
|
|
}
|
|
value = {
|
|
"name": field.name,
|
|
"nullable": field.nullable,
|
|
"type": _exact_arrow_type(
|
|
field.type,
|
|
inside_collection=inside_collection,
|
|
collection_blobs=collection_blobs,
|
|
),
|
|
}
|
|
return value
|
|
|
|
|
|
def _exact_arrow_type(
|
|
data_type: pa.DataType,
|
|
*,
|
|
inside_collection: bool = False,
|
|
collection_blobs: bool = False,
|
|
) -> dict[str, Any]:
|
|
for candidate, name in _GRAMMAR_PRIMITIVES:
|
|
if data_type == candidate:
|
|
return {"type": name}
|
|
if pa.types.is_struct(data_type):
|
|
fields = list(data_type)
|
|
names = [field.name for field in fields]
|
|
if not fields or len(set(names)) != len(names):
|
|
raise TypeError(
|
|
"unsupported Arrow type for Function signature: structs must have "
|
|
"non-empty, uniquely named fields"
|
|
)
|
|
return {
|
|
"type": "struct",
|
|
"fields": [
|
|
_exact_arrow_field(
|
|
field,
|
|
inside_collection=inside_collection,
|
|
collection_blobs=collection_blobs,
|
|
)
|
|
for field in fields
|
|
],
|
|
}
|
|
if (
|
|
pa.types.is_list(data_type)
|
|
or pa.types.is_large_list(data_type)
|
|
or pa.types.is_fixed_size_list(data_type)
|
|
):
|
|
if pa.types.is_fixed_size_list(data_type):
|
|
if data_type.value_field.name != "item":
|
|
raise TypeError(
|
|
"unsupported Arrow type for Function signature: fixed-size list "
|
|
"items must be named 'item'"
|
|
)
|
|
if data_type.list_size <= 0:
|
|
raise TypeError(
|
|
f"unsupported Arrow type for Function signature: {data_type}"
|
|
)
|
|
value: dict[str, Any] = {
|
|
"type": (
|
|
"list"
|
|
if pa.types.is_list(data_type)
|
|
else "large_list"
|
|
if pa.types.is_large_list(data_type)
|
|
else "fixed_size_list"
|
|
),
|
|
"fields": [
|
|
_exact_arrow_field(
|
|
data_type.value_field,
|
|
inside_collection=True,
|
|
# Lance stores Blob v2 under list and large_list only.
|
|
collection_blobs=collection_blobs
|
|
and not pa.types.is_fixed_size_list(data_type),
|
|
)
|
|
],
|
|
}
|
|
if pa.types.is_fixed_size_list(data_type):
|
|
value["length"] = data_type.list_size
|
|
return value
|
|
if pa.types.is_map(data_type) and _data_type_has_blob_v2(data_type):
|
|
raise TypeError(_NESTED_BLOB_COLLECTION_ERROR)
|
|
raise TypeError(f"unsupported Arrow type for Function signature: {data_type}")
|
|
|
|
|
|
def _list_of(item: pa.DataType) -> pa.DataType:
|
|
return pa.list_(pa.field("item", item, nullable=False))
|
|
|
|
|
|
def _annotation_type(annotation: Any) -> tuple[pa.DataType, bool]:
|
|
nullable = False
|
|
origin = get_origin(annotation)
|
|
if origin in (Union, types.UnionType):
|
|
arguments = get_args(annotation)
|
|
non_none = tuple(
|
|
argument for argument in arguments if argument is not type(None)
|
|
)
|
|
if len(non_none) != 1 or len(non_none) == len(arguments):
|
|
raise TypeError(f"unsupported union annotation: {annotation!r}")
|
|
annotation = non_none[0]
|
|
nullable = True
|
|
|
|
origin = get_origin(annotation)
|
|
if origin is Annotated:
|
|
base, *metadata = get_args(annotation)
|
|
arrow_types = [value for value in metadata if isinstance(value, pa.DataType)]
|
|
if len(arrow_types) != 1:
|
|
raise TypeError(
|
|
"Annotated Function types require exactly one PyArrow DataType"
|
|
)
|
|
# A struct value is written as a dict, which has no Arrow type of its
|
|
# own; the Annotated metadata supplies it.
|
|
if base is dict or get_origin(base) is dict:
|
|
base_nullable = False
|
|
else:
|
|
_, base_nullable = _annotation_type(base)
|
|
return arrow_types[0], nullable or base_nullable
|
|
|
|
if isinstance(annotation, pa.DataType):
|
|
return annotation, nullable
|
|
if annotation is pa.Array or annotation is pa.RecordBatch:
|
|
raise TypeError(
|
|
f"cannot infer Arrow types from Function annotation {annotation!r}; "
|
|
"provide input_schema and output_schema together for batch callables"
|
|
)
|
|
if annotation is bool:
|
|
return pa.bool_(), nullable
|
|
if annotation is int:
|
|
return pa.int64(), nullable
|
|
if annotation is float:
|
|
return pa.float64(), nullable
|
|
if annotation is str:
|
|
return pa.string(), nullable
|
|
if annotation is bytes:
|
|
return pa.binary(), nullable
|
|
if annotation is date:
|
|
return pa.date32(), nullable
|
|
if annotation is datetime:
|
|
return pa.timestamp("us"), nullable
|
|
if get_origin(annotation) is list:
|
|
arguments = get_args(annotation)
|
|
if len(arguments) != 1:
|
|
raise TypeError(f"unsupported list annotation: {annotation!r}")
|
|
value_type, value_nullable = _annotation_type(arguments[0])
|
|
if value_nullable:
|
|
raise TypeError("nullable Function list elements are not supported")
|
|
return _list_of(value_type), nullable
|
|
raise TypeError(f"unsupported Function annotation: {annotation!r}")
|
|
|
|
|
|
def _validate_callable(function: Any) -> None:
|
|
if hasattr(function, "__wrapped__"):
|
|
raise TypeError(
|
|
"decorated wrappers are not supported; put caching inside the function "
|
|
"body or use a class Function with a cache in __init__"
|
|
)
|
|
if inspect.isgeneratorfunction(function) or inspect.isasyncgenfunction(function):
|
|
raise TypeError(
|
|
"generator Functions are not supported; return a value instead of "
|
|
"yielding, or return a list for multiple values"
|
|
)
|
|
if inspect.isfunction(function) and function.__name__ == "<lambda>":
|
|
raise TypeError("lambda Functions are not supported; use a named def instead")
|
|
|
|
|
|
def _callable_parameters(function: Callable[..., Any]) -> tuple[inspect.Parameter, ...]:
|
|
parameters = tuple(inspect.signature(function).parameters.values())
|
|
for parameter in parameters:
|
|
if parameter.kind in (
|
|
inspect.Parameter.POSITIONAL_ONLY,
|
|
inspect.Parameter.VAR_POSITIONAL,
|
|
inspect.Parameter.VAR_KEYWORD,
|
|
):
|
|
raise TypeError("Function callables require named, non-variadic parameters")
|
|
if parameter.default is not inspect.Parameter.empty:
|
|
raise TypeError(
|
|
f"Function callable defaults are not supported: {parameter.name!r}; "
|
|
"move configuration parameters to a class Function's __init__ "
|
|
"and pass them as binding-time initialization values"
|
|
)
|
|
return parameters
|
|
|
|
|
|
def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutput:
|
|
if isinstance(output, pa.Schema):
|
|
if output.metadata:
|
|
raise TypeError("Function output schema metadata is not supported")
|
|
fields = tuple(output)
|
|
elif (
|
|
isinstance(output, pa.Field)
|
|
and not _is_blob_v2_field(output)
|
|
and pa.types.is_struct(output.type)
|
|
):
|
|
_validate_exact_arrow_field(output)
|
|
if output.nullable:
|
|
raise ValueError("Function output must be non-nullable")
|
|
fields = tuple(output.type)
|
|
elif isinstance(output, pa.DataType) and pa.types.is_struct(output):
|
|
fields = tuple(output)
|
|
else:
|
|
field = (
|
|
output
|
|
if isinstance(output, pa.Field)
|
|
else pa.field("result", output, nullable=False)
|
|
)
|
|
if not isinstance(field, pa.Field):
|
|
raise TypeError(
|
|
"output_schema must be a PyArrow DataType, Field, or Schema"
|
|
)
|
|
_validate_exact_arrow_field(field)
|
|
if field.nullable:
|
|
raise ValueError("Function output must be non-nullable")
|
|
return FunctionOutput(
|
|
kind="scalar",
|
|
arrow_type=_canonical_arrow_field(field, collection_blobs=True),
|
|
nullable=False,
|
|
)
|
|
|
|
if not fields:
|
|
raise ValueError("named-struct Function output must contain at least one field")
|
|
for field in fields:
|
|
_validate_exact_arrow_field(field)
|
|
names = [field.name for field in fields]
|
|
if len(set(names)) != len(names):
|
|
raise ValueError("Function output field names must be unique")
|
|
return FunctionOutput(
|
|
kind="named_struct",
|
|
fields=tuple(
|
|
FunctionResultField(
|
|
name=field.name,
|
|
arrow_type=_canonical_arrow_field(field, collection_blobs=True),
|
|
nullable=field.nullable,
|
|
)
|
|
for field in fields
|
|
),
|
|
)
|
|
|
|
|
|
def _infer_signature(
|
|
function: Callable[..., Any],
|
|
input_schema: Optional[pa.Schema],
|
|
output_schema: Optional[pa.DataType | pa.Field | pa.Schema],
|
|
*,
|
|
method: bool = False,
|
|
) -> FunctionSignature:
|
|
parameters = _callable_parameters(function)
|
|
if method:
|
|
parameters = parameters[1:]
|
|
|
|
try:
|
|
annotations = get_type_hints(function, include_extras=True)
|
|
except Exception as error:
|
|
raise TypeError(f"failed to resolve Function annotations: {error}") from error
|
|
|
|
def input_parameter(field):
|
|
annotation = annotations.get(field.name)
|
|
if get_origin(annotation) in (Union, types.UnionType):
|
|
choices = [
|
|
value for value in get_args(annotation) if value is not type(None)
|
|
]
|
|
if len(choices) == 1:
|
|
annotation = choices[0]
|
|
arrow_type = _canonical_arrow_field(field)
|
|
if annotation is BlobReader:
|
|
if arrow_type != _FUNCTION_BLOB_V2_TYPE:
|
|
raise TypeError(
|
|
f"BlobReader input '{field.name}' requires a Blob V2 field"
|
|
)
|
|
access = "reader"
|
|
elif annotation is bytes:
|
|
if arrow_type != _FUNCTION_BLOB_V2_TYPE and not (
|
|
pa.types.is_binary(field.type) or pa.types.is_large_binary(field.type)
|
|
):
|
|
raise TypeError(
|
|
f"bytes input '{field.name}' requires binary or Blob V2"
|
|
)
|
|
access = "bytes"
|
|
else:
|
|
access = "bytes" if arrow_type == _FUNCTION_BLOB_V2_TYPE else None
|
|
return FunctionParameter(
|
|
name=field.name,
|
|
arrow_type=arrow_type,
|
|
nullable=field.nullable,
|
|
blob_access=access,
|
|
)
|
|
|
|
if input_schema is not None:
|
|
if not isinstance(input_schema, pa.Schema):
|
|
raise TypeError("input_schema must be a PyArrow Schema")
|
|
if input_schema.metadata:
|
|
raise TypeError("Function input schema metadata is not supported")
|
|
for field in input_schema:
|
|
_validate_exact_arrow_field(field)
|
|
expected = tuple(parameter.name for parameter in parameters)
|
|
actual = tuple(input_schema.names)
|
|
if actual != expected:
|
|
raise ValueError(
|
|
"input_schema fields must exactly match callable parameters in order: "
|
|
f"expected {expected!r}, got {actual!r}"
|
|
)
|
|
inputs = tuple(input_parameter(field) for field in input_schema)
|
|
else:
|
|
inputs = None
|
|
|
|
missing = []
|
|
if inputs is None:
|
|
missing += [p.name for p in parameters if p.name not in annotations]
|
|
if output_schema is None and "return" not in annotations:
|
|
missing.append("return")
|
|
if missing:
|
|
raise TypeError(f"missing Function annotations: {missing!r}")
|
|
if output_schema is not None:
|
|
output = _function_output(output_schema)
|
|
else:
|
|
output_type, output_nullable = _annotation_type(annotations["return"])
|
|
if output_nullable:
|
|
raise ValueError("Function output must be non-nullable")
|
|
output = _function_output(pa.field("result", output_type, nullable=False))
|
|
if inputs is not None:
|
|
return FunctionSignature(inputs=inputs, output=output)
|
|
|
|
inputs = []
|
|
for parameter in parameters:
|
|
annotation = annotations[parameter.name]
|
|
base = annotation
|
|
nullable = False
|
|
if get_origin(base) in (Union, types.UnionType):
|
|
choices = [value for value in get_args(base) if value is not type(None)]
|
|
if len(choices) == 1:
|
|
base, nullable = choices[0], True
|
|
if base is BlobReader:
|
|
inputs.append(
|
|
input_parameter(_blob_field(parameter.name, nullable=nullable))
|
|
)
|
|
continue
|
|
data_type, nullable = _annotation_type(annotation)
|
|
inputs.append(
|
|
input_parameter(pa.field(parameter.name, data_type, nullable=nullable))
|
|
)
|
|
return FunctionSignature(inputs=tuple(inputs), output=output)
|
|
|
|
|
|
def _is_vectorized_callable(function: Callable[..., Any], *, method: bool) -> bool:
|
|
parameters = _callable_parameters(function)
|
|
if method:
|
|
parameters = parameters[1:]
|
|
annotations = get_type_hints(function, include_extras=True)
|
|
annotated = tuple(annotations.get(parameter.name) for parameter in parameters)
|
|
return (len(annotated) == 1 and annotated[0] is pa.RecordBatch) or (
|
|
bool(annotated) and all(annotation is pa.Array for annotation in annotated)
|
|
)
|
|
|
|
|
|
# Both spellings strip from packaged source; `udf` is the deprecated alias.
|
|
_DECORATOR_NAMES = frozenset({"function", "udf"})
|
|
|
|
|
|
def _is_function_decorator(node: ast.expr) -> bool:
|
|
if isinstance(node, ast.Call):
|
|
node = node.func
|
|
return (isinstance(node, ast.Name) and node.id in _DECORATOR_NAMES) or (
|
|
isinstance(node, ast.Attribute) and node.attr in _DECORATOR_NAMES
|
|
)
|
|
|
|
|
|
def _literal_source(value: Any, name: str) -> str:
|
|
if value is None or type(value) in (bool, int, str, bytes):
|
|
return repr(value)
|
|
if type(value) is float and math.isfinite(value):
|
|
return repr(value)
|
|
if type(value) is tuple:
|
|
children = ", ".join(_literal_source(child, name) for child in value)
|
|
if len(value) == 1:
|
|
children += ","
|
|
return f"({children})"
|
|
raise TypeError(
|
|
"Function source references an unsupported global value of type "
|
|
f"{type(value).__name__} in {name!r}. Only None, bool, int, str, bytes, "
|
|
"finite float, and tuples of these literals can be captured. "
|
|
"Define this value inside the function or class body instead."
|
|
)
|
|
|
|
|
|
_DYNAMIC_NAMESPACE_ACCESS = frozenset(
|
|
{"globals", "locals", "vars", "eval", "exec", "compile", "__import__"}
|
|
)
|
|
# Modules that hand out namespaces (`sys.modules`, `builtins`, importers,
|
|
# introspection). The artifact's module namespace holds only the names it was
|
|
# packaged with, so reaching around it cannot be represented.
|
|
_NAMESPACE_MODULES = frozenset(
|
|
{"sys", "builtins", "importlib", "inspect", "gc", "ctypes", "types"}
|
|
)
|
|
|
|
|
|
_NAMESPACE_HELPER_HINT = (
|
|
"Move this code into an importable helper module supplied with code=, pip=, "
|
|
"or conda=, then import and call the helper from the Function."
|
|
)
|
|
|
|
|
|
def _namespace_import_error(modules: Sequence[str]) -> ValueError:
|
|
return ValueError(
|
|
f"@function cannot package imports from namespace-access modules: {modules!r}. "
|
|
f"Imports of {', '.join(sorted(_NAMESPACE_MODULES))} (including submodules) "
|
|
"are blocked because they can access names outside the packaged "
|
|
"Function's reconstructed module namespace. " + _NAMESPACE_HELPER_HINT
|
|
)
|
|
|
|
|
|
def _namespace_imports(definition: Union[ast.FunctionDef, ast.ClassDef]) -> list[str]:
|
|
found: set[str] = set()
|
|
for node in ast.walk(definition):
|
|
if isinstance(node, ast.Import):
|
|
found.update(
|
|
alias.name
|
|
for alias in node.names
|
|
if alias.name.split(".")[0] in _NAMESPACE_MODULES
|
|
)
|
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
if node.module.split(".")[0] in _NAMESPACE_MODULES:
|
|
found.add(node.module)
|
|
return sorted(found)
|
|
|
|
|
|
def _module_references(module_source: str) -> set[str]:
|
|
"""Names `module_source` loads from module scope, by Python's own scope
|
|
analysis on the exact text that ships: loads inside any definition, plus
|
|
what the definition statement itself evaluates at module scope (bases,
|
|
decorators, defaults). Free variables belong to an enclosing scope inside
|
|
the definition, and postponed annotations are not runtime loads."""
|
|
|
|
def visit(table: symtable.SymbolTable, found: set[str]) -> None:
|
|
for symbol in table.get_symbols():
|
|
if symbol.is_global() and (
|
|
symbol.is_referenced() or symbol.is_declared_global()
|
|
):
|
|
found.add(symbol.get_name())
|
|
for child in table.get_children():
|
|
visit(child, found)
|
|
|
|
found: set[str] = set()
|
|
module = symtable.symtable(module_source, "<function>", "exec")
|
|
for symbol in module.get_symbols():
|
|
if (
|
|
symbol.is_referenced()
|
|
and not symbol.is_assigned()
|
|
and not symbol.is_imported()
|
|
):
|
|
found.add(symbol.get_name())
|
|
for table in module.get_children():
|
|
visit(table, found)
|
|
return found
|
|
|
|
|
|
_SOURCE_HEADER = "from __future__ import annotations"
|
|
_SOURCE_ENTRY_FILE = "user_function.py"
|
|
_PYTHON_CALLABLE_ARTIFACT = "python_callable"
|
|
_PYTHON_BUNDLE_ARTIFACT = "python_bundle"
|
|
# Modules the Function image places beside user code.
|
|
_RESERVED_SOURCE_MODULES = frozenset(
|
|
{"user_function", "lance_source", "lance_callable", "lance_blob"}
|
|
)
|
|
# The service accepts bundle paths made of these components only.
|
|
_SOURCE_PATH_COMPONENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
_REQUIREMENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
|
|
|
|
|
|
def _class_source(cls: type) -> str:
|
|
"""Source of a module-level class.
|
|
|
|
`inspect.getsource` finds a class through its module's file, which a
|
|
notebook cell or a doctest does not have. A method's code object records
|
|
where the class body really is, so that is consulted first.
|
|
"""
|
|
for member in vars(cls).values():
|
|
if isinstance(member, (staticmethod, classmethod)):
|
|
member = member.__func__
|
|
code = (
|
|
getattr(inspect.unwrap(member), "__code__", None)
|
|
if callable(member)
|
|
else None
|
|
)
|
|
if code is None:
|
|
continue
|
|
lines = linecache.getlines(code.co_filename)
|
|
try:
|
|
module = ast.parse("".join(lines))
|
|
except SyntaxError:
|
|
continue
|
|
for node in module.body:
|
|
if (
|
|
isinstance(node, ast.ClassDef)
|
|
and node.name == cls.__name__
|
|
and node.lineno <= code.co_firstlineno <= node.end_lineno
|
|
):
|
|
start = min(
|
|
[node.lineno]
|
|
+ [decorator.lineno for decorator in node.decorator_list]
|
|
)
|
|
return "".join(lines[start - 1 : node.end_lineno])
|
|
return inspect.getsource(cls)
|
|
|
|
|
|
def _definition_node(
|
|
target: Any, *, entry: bool
|
|
) -> Union[ast.FunctionDef, ast.ClassDef]:
|
|
try:
|
|
source = textwrap.dedent(
|
|
_class_source(target)
|
|
if inspect.isclass(target)
|
|
else inspect.getsource(target)
|
|
)
|
|
except (OSError, TypeError) as error:
|
|
raise ValueError(
|
|
f"@function requires inspectable Python source for {target.__qualname__!r}"
|
|
) from error
|
|
module = ast.parse(source)
|
|
is_class = inspect.isclass(target)
|
|
definitions = [
|
|
node
|
|
for node in module.body
|
|
if isinstance(
|
|
node,
|
|
(ast.ClassDef,) if is_class else (ast.FunctionDef, ast.AsyncFunctionDef),
|
|
)
|
|
and node.name == target.__name__
|
|
]
|
|
if len(definitions) != 1 or isinstance(definitions[0], ast.AsyncFunctionDef):
|
|
raise ValueError(
|
|
"@function source must contain exactly one "
|
|
+ ("class" if is_class else "synchronous function")
|
|
)
|
|
definition = definitions[0]
|
|
if entry:
|
|
if any(
|
|
not _is_function_decorator(decorator)
|
|
for decorator in definition.decorator_list
|
|
):
|
|
raise ValueError("@function cannot package additional Python decorators")
|
|
definition.decorator_list = []
|
|
return definition
|
|
|
|
|
|
def _definition_namespace(target: Any) -> Mapping[str, Any]:
|
|
"""The module namespace a function or class resolves its globals in."""
|
|
if inspect.isclass(target):
|
|
module = sys.modules.get(target.__module__)
|
|
if module is None:
|
|
raise ValueError(
|
|
f"@function cannot resolve the module of {target.__qualname__!r}"
|
|
)
|
|
namespace = vars(module)
|
|
environment = namespace.get("__builtins__", builtins)
|
|
environment = (
|
|
vars(environment)
|
|
if isinstance(environment, types.ModuleType)
|
|
else environment
|
|
)
|
|
else:
|
|
namespace = target.__globals__
|
|
environment = target.__builtins__
|
|
# The artifact runs under the standard builtins; only the exact mapping is
|
|
# provably equivalent (a subclass or copy can change lookups and hooks).
|
|
if environment is not vars(builtins):
|
|
raise ValueError("@function cannot package a non-standard builtins environment")
|
|
return namespace
|
|
|
|
|
|
def _is_self_reference(target: Any, namespace: Mapping[str, Any], name: str) -> bool:
|
|
"""`name` inside the definition means the definition itself unless the
|
|
module has since bound it to something else."""
|
|
if name != target.__name__:
|
|
return False
|
|
bound = namespace.get(name, target)
|
|
if bound is target:
|
|
return True
|
|
# The decorator's own result is the one wrapper known to behave like
|
|
# `target`; any other binding may differ from the packaged definition.
|
|
return type(bound) is FunctionDefinition and bound._function is target
|
|
|
|
|
|
def _packaged_by_source(value: Any) -> bool:
|
|
"""Functions and classes defined in `__main__` -- notebook cells and
|
|
scripts -- have no module a worker can import, so their source travels."""
|
|
if not (inspect.isfunction(value) or inspect.isclass(value)):
|
|
return False
|
|
if getattr(value, "__module__", None) != "__main__":
|
|
return False
|
|
qualname = value.__qualname__
|
|
if "<" in qualname or "." in qualname:
|
|
raise ValueError(
|
|
f"@function cannot package {qualname!r}: only module-level functions and "
|
|
"classes travel with a Function, not lambdas, closures, or nested "
|
|
"definitions"
|
|
)
|
|
return True
|
|
|
|
|
|
def _validate_package_requirements(kind: str, packages: Sequence[str]) -> None:
|
|
for index, package in enumerate(packages):
|
|
field = f"{kind}[{index}]"
|
|
if not isinstance(package, str):
|
|
raise TypeError(f"{field} must be a string, got {package!r}")
|
|
if not package or package != package.strip():
|
|
raise ValueError(
|
|
f"{field} requirement {package!r} must be non-empty and trimmed"
|
|
)
|
|
try:
|
|
if kind == "conda":
|
|
_validate_conda_requirement(package)
|
|
else:
|
|
candidate = package
|
|
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", package):
|
|
candidate = f"dependency @ {package}"
|
|
Requirement(candidate)
|
|
except ValueError as error:
|
|
raise ValueError(
|
|
f"invalid {field} requirement {package!r}: {error}"
|
|
) from error
|
|
|
|
|
|
def _requirement_name(requirement: str) -> str:
|
|
requirement = requirement.rpartition("::")[2]
|
|
match = _REQUIREMENT_NAME.match(requirement.strip())
|
|
return _normalized_distribution(match.group(0) if match else requirement)
|
|
|
|
|
|
def _normalized_distribution(name: str) -> str:
|
|
return re.sub(r"[-_.]+", "_", name).lower()
|
|
|
|
|
|
def _installed_roots() -> tuple[str, ...]:
|
|
import os
|
|
import site
|
|
import sysconfig
|
|
|
|
roots = set(site.getsitepackages())
|
|
roots.add(site.getusersitepackages())
|
|
paths = sysconfig.get_paths()
|
|
for key in ("purelib", "platlib", "stdlib", "platstdlib"):
|
|
if key in paths:
|
|
roots.add(paths[key])
|
|
return tuple(os.path.realpath(root) + os.sep for root in roots)
|
|
|
|
|
|
def _module_locations(module: types.ModuleType) -> tuple[str, ...]:
|
|
import os
|
|
|
|
file = getattr(module, "__file__", None)
|
|
if file:
|
|
return (os.path.realpath(file),)
|
|
return tuple(os.path.realpath(path) for path in getattr(module, "__path__", ()))
|
|
|
|
|
|
def _module_locations_for_name(module_name: str) -> tuple[str, ...]:
|
|
"""Where a top-level module would be imported from, without importing it
|
|
when it is not already loaded."""
|
|
import os
|
|
|
|
top = module_name.partition(".")[0]
|
|
module = sys.modules.get(top)
|
|
if module is not None:
|
|
return _module_locations(module)
|
|
try:
|
|
spec = importlib.util.find_spec(top)
|
|
except (AttributeError, ImportError, ValueError):
|
|
return ()
|
|
if spec is None:
|
|
return ()
|
|
if spec.submodule_search_locations:
|
|
return tuple(os.path.realpath(path) for path in spec.submodule_search_locations)
|
|
if spec.origin and spec.origin not in {"built-in", "frozen"}:
|
|
return (os.path.realpath(spec.origin),)
|
|
return ()
|
|
|
|
|
|
class _SourcePackager:
|
|
"""Builds the entry module of a Function artifact.
|
|
|
|
The module holds the definition plus exactly the module-level names it
|
|
references: modules as imports, importable classes and functions as
|
|
imports, literals inline, and functions and classes defined in
|
|
``__main__`` by source, recursively, dependencies first.
|
|
"""
|
|
|
|
def __init__(self, code_modules: frozenset[str], requirements: frozenset[str]):
|
|
self._code_modules = code_modules
|
|
self._requirements = requirements
|
|
self._imports: dict[str, str] = {}
|
|
self._definitions: list[str] = []
|
|
self._packaged: set[int] = set()
|
|
self._names: dict[str, Any] = {}
|
|
self._warned_imports: set[str] = set()
|
|
|
|
def package(self, target: Any, signature: FunctionSignature) -> str:
|
|
entry = self._definition(target, entry=True, signature=signature)
|
|
parts = [_SOURCE_HEADER]
|
|
if self._imports:
|
|
parts.extend(["", *(self._imports[name] for name in sorted(self._imports))])
|
|
for definition in self._definitions:
|
|
parts.extend(["", definition])
|
|
parts.extend(["", entry, ""])
|
|
return "\n".join(parts)
|
|
|
|
def _definition(
|
|
self, target: Any, *, entry: bool, signature: Optional[FunctionSignature] = None
|
|
) -> str:
|
|
definition = _definition_node(target, entry=entry)
|
|
if not inspect.isclass(target) and inspect.getclosurevars(target).nonlocals:
|
|
raise ValueError(
|
|
"@function cannot package functions that capture closure values"
|
|
)
|
|
namespace = _definition_namespace(target)
|
|
reader_name = (
|
|
self._blob_annotations(definition, signature, namespace)
|
|
if signature is not None
|
|
else None
|
|
)
|
|
source = ast.unparse(definition)
|
|
references = _module_references(f"{_SOURCE_HEADER}\n\n{source}\n")
|
|
dynamic = sorted(references & _DYNAMIC_NAMESPACE_ACCESS)
|
|
if dynamic:
|
|
raise ValueError(
|
|
f"@function cannot package dynamic namespace access: {dynamic!r}. "
|
|
"These operations can access names outside the packaged "
|
|
"Function's reconstructed module namespace. Reference dependencies "
|
|
"explicitly. " + _NAMESPACE_HELPER_HINT
|
|
)
|
|
namespace_imports = _namespace_imports(definition)
|
|
if namespace_imports:
|
|
raise _namespace_import_error(namespace_imports)
|
|
self._warn_unshipped_direct_imports(definition)
|
|
self._packaged.add(id(target))
|
|
self._claim(target.__name__, ("definition", id(target)))
|
|
if reader_name is not None:
|
|
self._bind(reader_name, BlobReader)
|
|
unresolved = []
|
|
for name in sorted(references):
|
|
if name == target.__name__:
|
|
if not _is_self_reference(target, namespace, name):
|
|
raise ValueError(
|
|
f"@function cannot package {name!r}: the module binds that "
|
|
"name to another value, which the artifact's own "
|
|
"definition would shadow"
|
|
)
|
|
continue
|
|
if name in namespace:
|
|
self._bind(name, namespace[name])
|
|
elif not hasattr(builtins, name):
|
|
unresolved.append(name)
|
|
if unresolved:
|
|
raise ValueError(
|
|
f"@function source contains unresolved global names: {unresolved!r}"
|
|
)
|
|
return source
|
|
|
|
def _blob_annotations(
|
|
self,
|
|
definition: Union[ast.FunctionDef, ast.ClassDef],
|
|
signature: FunctionSignature,
|
|
namespace: Mapping[str, Any],
|
|
) -> Optional[str]:
|
|
"""Bind reader inputs to the worker type, including postponed aliases."""
|
|
readers = {
|
|
parameter.name: parameter
|
|
for parameter in signature.inputs
|
|
if parameter.blob_access == "reader"
|
|
}
|
|
if not readers:
|
|
return None
|
|
call = definition
|
|
if isinstance(definition, ast.ClassDef):
|
|
call = next(
|
|
(
|
|
member
|
|
for member in definition.body
|
|
if isinstance(member, ast.FunctionDef) and member.name == "__call__"
|
|
),
|
|
None,
|
|
)
|
|
if call is None:
|
|
return None
|
|
# Avoid shadowing globals used by the body or other packaged definitions.
|
|
name = "_lance_blob_reader"
|
|
while name in namespace or name in self._names or name == definition.name:
|
|
name += "_"
|
|
for argument in (*call.args.args, *call.args.kwonlyargs):
|
|
if argument.arg in readers:
|
|
annotation = name
|
|
if readers[argument.arg].nullable:
|
|
annotation += " | None"
|
|
argument.annotation = ast.parse(annotation, mode="eval").body
|
|
return name
|
|
|
|
def _claim(self, name: str, meaning: Any) -> None:
|
|
claimed = self._names.setdefault(name, meaning)
|
|
if claimed != meaning:
|
|
raise ValueError(
|
|
f"@function cannot package {name!r}: packaged definitions bind that "
|
|
"name to different values"
|
|
)
|
|
|
|
def _bind(self, name: str, value: Any) -> None:
|
|
if _packaged_by_source(value):
|
|
if value.__name__ != name:
|
|
raise ValueError(
|
|
f"@function packages {value.__qualname__!r} by source, so it "
|
|
f"must be referenced by its own name, not {name!r}"
|
|
)
|
|
if id(value) not in self._packaged:
|
|
self._definitions.append(self._definition(value, entry=False))
|
|
self._claim(name, ("definition", id(value)))
|
|
return
|
|
line = self._global_source(name, value)
|
|
self._claim(name, line)
|
|
self._imports[name] = line
|
|
|
|
def _global_source(self, name: str, value: Any) -> str:
|
|
"""One module-level line that rebinds `name` to `value` in the artifact:
|
|
an import for modules and importable classes/functions, a literal
|
|
otherwise."""
|
|
if value is BlobReader:
|
|
return f"from lance_blob import BlobReader as {name}"
|
|
if isinstance(value, types.ModuleType):
|
|
if value.__name__.split(".")[0] in _NAMESPACE_MODULES:
|
|
raise _namespace_import_error([value.__name__])
|
|
try:
|
|
imported = importlib.import_module(value.__name__)
|
|
except ImportError:
|
|
imported = None
|
|
if imported is not value:
|
|
raise TypeError(
|
|
f"Function source references module {name!r} that does not "
|
|
f"import as {value.__name__!r}"
|
|
)
|
|
self._require_importable(value.__name__)
|
|
return f"import {value.__name__} as {name}"
|
|
module_name = getattr(value, "__module__", None)
|
|
qualname = getattr(value, "__qualname__", None)
|
|
if (
|
|
isinstance(module_name, str)
|
|
and isinstance(qualname, str)
|
|
and module_name != "__main__"
|
|
and "." not in qualname
|
|
and "<" not in qualname
|
|
):
|
|
try:
|
|
imported = getattr(importlib.import_module(module_name), qualname)
|
|
except (ImportError, AttributeError):
|
|
imported = None
|
|
if imported is value:
|
|
self._require_importable(module_name)
|
|
return f"from {module_name} import {qualname} as {name}"
|
|
return f"{name} = {_literal_source(value, name)}"
|
|
|
|
def _require_importable(self, module_name: str) -> None:
|
|
"""Warn about an import the Function's environment may not be able to
|
|
satisfy.
|
|
|
|
An unresolved module warns only when the Function declares no package
|
|
dependencies. A local source module warns unless ``code=`` ships it or
|
|
a declared requirement matches its top-level name.
|
|
|
|
A warning rather than an error: a distribution name need not match
|
|
the module it installs, so a declared package can still provide the
|
|
module, and callers may rewrite the packaged source before
|
|
registering it."""
|
|
top = module_name.partition(".")[0]
|
|
if (
|
|
top in self._code_modules
|
|
or top in sys.stdlib_module_names
|
|
or top in sys.builtin_module_names
|
|
or _normalized_distribution(top) in self._requirements
|
|
or top in self._warned_imports
|
|
):
|
|
return
|
|
locations = _module_locations_for_name(top)
|
|
if not locations:
|
|
if not self._requirements:
|
|
self._warned_imports.add(top)
|
|
warnings.warn(
|
|
f"@function source imports {top!r}, which is unavailable in the "
|
|
"client environment and has no declared pip/conda dependencies. "
|
|
"Remote availability cannot be verified; declare the required "
|
|
"package or confirm that the base image provides it.",
|
|
UserWarning,
|
|
)
|
|
return
|
|
roots = _installed_roots()
|
|
if any(location.startswith(roots) for location in locations):
|
|
return
|
|
self._warned_imports.add(top)
|
|
warnings.warn(
|
|
f"@function source imports {module_name!r} from {locations[0]}, a local "
|
|
"module the Function's environment may not be able to import; ship "
|
|
f"it with code=[{top}] or declare the package that provides it in "
|
|
"pip/conda",
|
|
UserWarning,
|
|
)
|
|
|
|
def _warn_unshipped_direct_imports(
|
|
self, definition: Union[ast.FunctionDef, ast.ClassDef]
|
|
) -> None:
|
|
"""Warn for body imports that resolve to local, undeclared source.
|
|
|
|
Imports from site-packages are deliberately not guessed about: the
|
|
declared distribution name need not match the imported module, and the
|
|
client cannot inspect the remote base image.
|
|
"""
|
|
for node in ast.walk(definition):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
self._require_importable(alias.name)
|
|
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
self._require_importable(node.module)
|
|
|
|
|
|
def _code_files(
|
|
modules: Sequence[Any],
|
|
) -> tuple[dict[str, str], dict[str, str], frozenset[str]]:
|
|
"""Source and byte-preserving package resources on the Function import path."""
|
|
files: dict[str, str] = {}
|
|
resources: dict[str, str] = {}
|
|
names: set[str] = set()
|
|
for module in modules:
|
|
if not isinstance(module, types.ModuleType):
|
|
raise TypeError(
|
|
f"code= takes imported modules or packages, got {type(module).__name__}"
|
|
)
|
|
name = module.__name__
|
|
if "." in name:
|
|
raise ValueError(
|
|
f"code= takes top-level modules or packages; pass "
|
|
f"{name.partition('.')[0]!r} instead of {name!r}"
|
|
)
|
|
if (
|
|
name in _RESERVED_SOURCE_MODULES
|
|
or name == "__main__"
|
|
or name in sys.stdlib_module_names
|
|
or not _SOURCE_PATH_COMPONENT.fullmatch(name)
|
|
):
|
|
raise ValueError(f"code= cannot ship module {name!r}")
|
|
if name in names:
|
|
raise ValueError(f"code= lists module {name!r} more than once")
|
|
names.add(name)
|
|
paths = list(getattr(module, "__path__", ()))
|
|
if paths:
|
|
if len(paths) != 1 or not getattr(module, "__file__", None):
|
|
raise ValueError(
|
|
f"code= cannot ship namespace package {name!r}; give it an "
|
|
"__init__.py"
|
|
)
|
|
root = Path(paths[0])
|
|
sources = []
|
|
skipped = set()
|
|
for directory, subdirectories, filenames in os.walk(root):
|
|
kept = []
|
|
for child in sorted(subdirectories):
|
|
path = Path(directory) / child
|
|
if child == "__pycache__" or child.startswith("."):
|
|
continue
|
|
if not path.is_symlink() and re.fullmatch(
|
|
r"[A-Za-z0-9_][A-Za-z0-9_.-]*", child
|
|
):
|
|
kept.append(child)
|
|
else:
|
|
relative = path.relative_to(root)
|
|
skipped.add(f"{name}/{relative.as_posix()}/")
|
|
subdirectories[:] = kept
|
|
for filename in sorted(filenames):
|
|
path = Path(directory) / filename
|
|
stem, suffix = os.path.splitext(filename)
|
|
if suffix in {".pyc", ".pyo"} or filename.startswith("."):
|
|
continue
|
|
relative = path.relative_to(root)
|
|
key = f"{name}/{relative.as_posix()}"
|
|
if (
|
|
path.is_symlink()
|
|
or not path.is_file()
|
|
or not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]*", filename)
|
|
):
|
|
skipped.add(key)
|
|
elif suffix == ".py":
|
|
if all(
|
|
_SOURCE_PATH_COMPONENT.fullmatch(part)
|
|
for part in relative.with_suffix("").parts
|
|
):
|
|
sources.append(path)
|
|
else:
|
|
skipped.add(key)
|
|
else:
|
|
resources[key] = base64.b64encode(path.read_bytes()).decode(
|
|
"ascii"
|
|
)
|
|
if skipped:
|
|
warnings.warn(
|
|
"code= cannot ship these package paths: "
|
|
f"{', '.join(sorted(skipped))}. "
|
|
"Use regular files with safe relative names and importable "
|
|
"Python module names.",
|
|
UserWarning,
|
|
)
|
|
for path in sources:
|
|
relative = path.relative_to(root).as_posix()
|
|
files[f"{name}/{relative}"] = _source_text(path)
|
|
else:
|
|
file = getattr(module, "__file__", None)
|
|
if not file or not file.endswith(".py"):
|
|
raise ValueError(f"code= ships Python source; module {name!r} has none")
|
|
files[f"{name}.py"] = _source_text(Path(file))
|
|
return files, resources, frozenset(names)
|
|
|
|
|
|
def _source_text(path) -> str:
|
|
try:
|
|
return path.read_bytes().decode("utf-8")
|
|
except UnicodeDecodeError as error:
|
|
raise ValueError(f"code= ships UTF-8 source; {path} is not UTF-8") from error
|
|
|
|
|
|
def _initialization_type(data_type: pa.DataType) -> bool:
|
|
"""Types with one unambiguous JSON and SQL literal spelling."""
|
|
if data_type in (
|
|
pa.bool_(),
|
|
pa.int8(),
|
|
pa.int16(),
|
|
pa.int32(),
|
|
pa.int64(),
|
|
pa.uint8(),
|
|
pa.uint16(),
|
|
pa.uint32(),
|
|
pa.uint64(),
|
|
pa.float32(),
|
|
pa.float64(),
|
|
pa.string(),
|
|
pa.large_string(),
|
|
):
|
|
return True
|
|
if (
|
|
pa.types.is_list(data_type)
|
|
or pa.types.is_large_list(data_type)
|
|
or pa.types.is_fixed_size_list(data_type)
|
|
):
|
|
return _initialization_type(data_type.value_type)
|
|
if pa.types.is_struct(data_type):
|
|
return all(_initialization_type(field.type) for field in data_type)
|
|
return False
|
|
|
|
|
|
def _initialization_signature(cls: type) -> tuple[FunctionParameter, ...]:
|
|
"""The initialization fields a class Function's ``__init__`` declares."""
|
|
if cls.__init__ is object.__init__:
|
|
return ()
|
|
parameters = tuple(inspect.signature(cls.__init__).parameters.values())[1:]
|
|
for parameter in parameters:
|
|
if parameter.kind not in (
|
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
inspect.Parameter.KEYWORD_ONLY,
|
|
):
|
|
raise TypeError(
|
|
"Function initialization parameters must be named and "
|
|
f"non-variadic: {parameter.name!r}"
|
|
)
|
|
try:
|
|
annotations = get_type_hints(cls.__init__, include_extras=True)
|
|
except Exception as error:
|
|
raise TypeError(
|
|
f"failed to resolve Function initialization annotations: {error}"
|
|
) from error
|
|
missing = [
|
|
parameter.name for parameter in parameters if parameter.name not in annotations
|
|
]
|
|
if missing:
|
|
raise TypeError(f"missing Function initialization annotations: {missing!r}")
|
|
fields = []
|
|
for parameter in parameters:
|
|
data_type, nullable = _annotation_type(annotations[parameter.name])
|
|
if not _initialization_type(data_type):
|
|
raise TypeError(
|
|
f"Function initialization parameter {parameter.name!r} has type "
|
|
f"{data_type}; initialization takes booleans, integers, floats, "
|
|
"strings, and lists or structs of them"
|
|
)
|
|
# A parameter with a default may be omitted from a binding; a null
|
|
# value then takes the default.
|
|
if parameter.default is not inspect.Parameter.empty:
|
|
nullable = True
|
|
fields.append(
|
|
FunctionParameter(
|
|
name=parameter.name,
|
|
arrow_type=_canonical_arrow_field(
|
|
pa.field(parameter.name, data_type, nullable=nullable)
|
|
),
|
|
nullable=nullable,
|
|
)
|
|
)
|
|
return tuple(fields)
|
|
|
|
|
|
def _class_member(cls: type, name: str) -> Any:
|
|
for klass in cls.__mro__:
|
|
if klass is not object and name in vars(klass):
|
|
return vars(klass)[name]
|
|
return None
|
|
|
|
|
|
def _class_call(cls: type) -> Callable[..., Any]:
|
|
call = _class_member(cls, "__call__")
|
|
_validate_callable(call)
|
|
if not inspect.isfunction(call) or inspect.iscoroutinefunction(call):
|
|
raise TypeError("a class Function must define a synchronous __call__ method")
|
|
close = _class_member(cls, "close")
|
|
if close is not None and (
|
|
not inspect.isfunction(close) or len(inspect.signature(close).parameters) != 1
|
|
):
|
|
raise TypeError("a class Function's close must be a method taking no arguments")
|
|
return call
|
|
|
|
|
|
class FunctionDefinition:
|
|
"""A Python callable prepared for remote Function registration.
|
|
|
|
Instances are created with :func:`function`. Calling an instance is ordinary
|
|
local Python: a decorated function is called directly, while a decorated
|
|
class is constructed using its initialization fields and the returned
|
|
instance is called with each input. Reuse the instance and call its
|
|
``close`` method when it defines one. This public call does not create a
|
|
remote application or build the declared remote runtime, and it does not
|
|
need the private implementation attribute.
|
|
|
|
Remote execution adapts the callable to the internal Arrow batch ABI
|
|
described by the registration artifact. By default it invokes the callable
|
|
once per row. A callable whose parameters are all annotated as
|
|
``pyarrow.Array`` receives whole Arrow arrays and returns a
|
|
``pyarrow.Array`` instead. This authoring choice is independent of the
|
|
internal artifact adapter in the registration request. GPU Functions should
|
|
use the Arrow form when the model supports batches: a scalar GPU callable is
|
|
invoked once per row and leaves the model's own batching unavailable.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
function: Any,
|
|
*,
|
|
name: Optional[str],
|
|
input_schema: Optional[pa.Schema],
|
|
output_schema: Optional[pa.DataType | pa.Field | pa.Schema],
|
|
pip: tuple[str, ...],
|
|
env: Mapping[str, str],
|
|
python_version: Optional[str],
|
|
batch_size: Optional[int] = None,
|
|
max_concurrency: Optional[int] = None,
|
|
row_timeout_seconds: Optional[float] = None,
|
|
retry_policy: Optional[FunctionRetryPolicy] = None,
|
|
gpu: bool = False,
|
|
gpu_class: Optional[str] = None,
|
|
conda: tuple[str, ...] = (),
|
|
conda_channels: tuple[str, ...] = (),
|
|
apt: tuple[str, ...] = (),
|
|
image: Optional[str] = None,
|
|
code: tuple[types.ModuleType, ...] = (),
|
|
models: tuple[HuggingFaceModel, ...] = (),
|
|
):
|
|
is_class = inspect.isclass(function)
|
|
if not is_class and (
|
|
not inspect.isfunction(function) or inspect.iscoroutinefunction(function)
|
|
):
|
|
_validate_callable(function)
|
|
raise TypeError(
|
|
"@function requires a synchronous Python function or a class"
|
|
)
|
|
if pip and conda:
|
|
raise ValueError("a Function environment is pip or conda, not both")
|
|
if conda_channels and not conda:
|
|
raise ValueError("conda_channels requires conda packages")
|
|
_validate_package_requirements("conda" if conda else "pip", conda or pip)
|
|
gpu_marker = _normalize_gpu_marker(gpu)
|
|
if gpu_class is not None:
|
|
_validate_gpu_class_name(gpu_class)
|
|
if gpu_marker is None:
|
|
raise ValueError("gpu_class requires gpu=True")
|
|
packages = tuple(sorted(set(conda if conda else pip)))
|
|
system_packages = tuple(sorted(set(apt)))
|
|
if any(
|
|
not package or package != package.strip()
|
|
for package in packages + system_packages
|
|
):
|
|
raise ValueError("package requirements must be non-empty and trimmed")
|
|
if image is not None and not _IMAGE_REFERENCE.fullmatch(image):
|
|
raise ValueError(
|
|
f"Function image {image!r} must be host/repository@sha256:<digest>"
|
|
)
|
|
if any(not isinstance(model, HuggingFaceModel) for model in models):
|
|
raise TypeError("models must contain HuggingFaceModel values")
|
|
repos = [model.repo for model in models]
|
|
duplicates = sorted({repo for repo in repos if repos.count(repo) > 1})
|
|
if duplicates:
|
|
raise ValueError(
|
|
"a Function declares each Hugging Face repository once; "
|
|
f"duplicated: {duplicates!r}"
|
|
)
|
|
if conda:
|
|
environment_spec = PythonEnvironmentSpec(
|
|
kind="conda",
|
|
packages=packages,
|
|
channels=tuple(conda_channels),
|
|
image=image,
|
|
system_packages=system_packages,
|
|
)
|
|
else:
|
|
environment_spec = PythonEnvironmentSpec(
|
|
kind="pip",
|
|
packages=packages,
|
|
image=image,
|
|
system_packages=system_packages,
|
|
)
|
|
environment = dict(env)
|
|
if any(
|
|
not isinstance(key, str) or not isinstance(value, str)
|
|
for key, value in environment.items()
|
|
):
|
|
raise TypeError("Function env keys and values must be strings")
|
|
reserved = sorted(
|
|
set(environment).intersection(
|
|
{"HF_HOME", "HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE"}
|
|
)
|
|
)
|
|
if models and reserved:
|
|
raise ValueError(
|
|
"model Functions reserve environment variables "
|
|
f"{reserved!r}; the worker sets them for the private snapshot"
|
|
)
|
|
if not is_class:
|
|
_validate_callable(function)
|
|
function_name = name or function.__name__
|
|
if not _FUNCTION_NAME.fullmatch(function_name):
|
|
raise ValueError(f"invalid Function name: {function_name!r}")
|
|
if is_class:
|
|
if "<" in function.__qualname__ or "." in function.__qualname__:
|
|
raise ValueError(
|
|
"@function classes must be defined at module level; nested classes "
|
|
"would capture their enclosing scope"
|
|
)
|
|
signature = _infer_signature(
|
|
_class_call(function), input_schema, output_schema, method=True
|
|
)
|
|
initialization = _initialization_signature(function)
|
|
shared = sorted(
|
|
{field.name for field in initialization}
|
|
& {parameter.name for parameter in signature.inputs}
|
|
)
|
|
if shared:
|
|
raise ValueError(
|
|
"Function initialization parameters and inputs share names "
|
|
f"{shared!r}; a binding passes both as keyword arguments"
|
|
)
|
|
signature = signature._copy(update={"initialization": initialization})
|
|
else:
|
|
signature = _infer_signature(function, input_schema, output_schema)
|
|
if gpu_marker is not None:
|
|
call = _class_call(function) if is_class else function
|
|
if not _is_vectorized_callable(call, method=is_class):
|
|
warnings.warn(
|
|
f"GPU Function {function_name!r} uses a scalar callable, so each "
|
|
"row invokes it separately and its model batch is not inferred. "
|
|
"For GPU throughput, annotate every input as pyarrow.Array or "
|
|
"take one pyarrow.RecordBatch, provide input_schema and "
|
|
"output_schema, and batch inside that callable.",
|
|
UserWarning,
|
|
stacklevel=3,
|
|
)
|
|
code_files, code_resources, code_modules = _code_files(code)
|
|
entry_source = _SourcePackager(
|
|
code_modules, frozenset(_requirement_name(package) for package in packages)
|
|
).package(function, signature)
|
|
if code_files:
|
|
kind = _PYTHON_BUNDLE_ARTIFACT
|
|
bundle = {"files": {**code_files, _SOURCE_ENTRY_FILE: entry_source}}
|
|
if code_resources:
|
|
bundle["resources"] = code_resources
|
|
source = json.dumps(
|
|
bundle,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
else:
|
|
kind = _PYTHON_CALLABLE_ARTIFACT
|
|
source = entry_source.encode("utf-8")
|
|
digest = f"sha256:{hashlib.sha256(source).hexdigest()}"
|
|
runtime = PythonRuntimeSpec(
|
|
kind="python_v2" if gpu_marker is not None else "python",
|
|
python_version=python_version
|
|
or f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
environment=environment_spec,
|
|
env=environment,
|
|
gpu=gpu_marker,
|
|
gpu_class=gpu_class,
|
|
)
|
|
self._function = function
|
|
self._request = FunctionRegistrationRequest(
|
|
name=function_name,
|
|
artifact=FunctionArtifactRequest(
|
|
kind=kind,
|
|
digest=digest,
|
|
entrypoint=function.__name__,
|
|
content=FunctionArtifactContent(
|
|
encoding="base64",
|
|
data=base64.b64encode(source).decode("ascii"),
|
|
),
|
|
adapter=PythonAdapterSpec(
|
|
kind="scalar_to_arrow_batch",
|
|
version=1,
|
|
),
|
|
),
|
|
signature=signature,
|
|
runtime=runtime,
|
|
batch_size=batch_size,
|
|
max_concurrency=max_concurrency,
|
|
row_timeout_seconds=row_timeout_seconds,
|
|
retry_policy=retry_policy,
|
|
models=models,
|
|
)
|
|
# A class's own __dict__ holds its methods; copying it would shadow
|
|
# this wrapper's attributes.
|
|
functools.update_wrapper(
|
|
self, function, updated=() if is_class else functools.WRAPPER_UPDATES
|
|
)
|
|
|
|
@property
|
|
def registration_request(self) -> FunctionRegistrationRequest:
|
|
"""The immutable request sent by ``create_function_async``.
|
|
|
|
Carries no secret bindings. Binding is a registration-time decision,
|
|
so a Function bound to Secrets is registered through :meth:`bind_secrets`,
|
|
which is what ``create_function`` calls.
|
|
"""
|
|
return self._request
|
|
|
|
def bind_secrets(
|
|
self, secrets: Optional[Sequence[EnvVarSecret]]
|
|
) -> FunctionRegistrationRequest:
|
|
"""The registration request for this definition bound to ``secrets``.
|
|
|
|
Binding does not change the Function's source: each
|
|
[EnvVarSecret][lancedb.secrets.EnvVarSecret] names a Secret and the
|
|
environment variable its value should arrive in, and the Function reads
|
|
that variable the way it already did. Whether the named Secrets exist is
|
|
the server's answer, not this one.
|
|
"""
|
|
bindings = () if secrets is None else tuple(secrets)
|
|
wrong_type = [
|
|
binding for binding in bindings if not isinstance(binding, EnvVarSecret)
|
|
]
|
|
if wrong_type:
|
|
kinds = sorted({type(binding).__name__ for binding in wrong_type})
|
|
raise TypeError(
|
|
f"Function secrets must be EnvVarSecret values, not {kinds!r}; a "
|
|
"credential value is never sent to this API"
|
|
)
|
|
variables = [binding.env_variable for binding in bindings]
|
|
duplicates = sorted({name for name in variables if variables.count(name) > 1})
|
|
if duplicates:
|
|
raise ValueError(
|
|
"a Function binds each environment variable once; duplicated: "
|
|
f"{duplicates!r}"
|
|
)
|
|
# `env` is ordinary configuration carried in the definition, so a name in
|
|
# both would have a value visible in the Function's record and a value
|
|
# that is not. Refuse rather than pick.
|
|
environment = self._request.runtime.env or {}
|
|
overlap = sorted(set(environment) & set(variables))
|
|
if overlap:
|
|
raise ValueError(
|
|
f"Function env and secret bindings must be disjoint: {overlap!r}"
|
|
)
|
|
if not bindings:
|
|
return self._request
|
|
# Sorted, because the list is carried in the FunctionVersion hash and a
|
|
# caller's argument order is not part of what a Function is.
|
|
resolved = tuple(
|
|
sorted(
|
|
(
|
|
SecretBinding(
|
|
kind="env",
|
|
variable=binding.env_variable,
|
|
secret_ref=SecretReference(
|
|
name=binding.secret_name,
|
|
namespace_path=tuple(binding.secret_namespace_path),
|
|
),
|
|
)
|
|
for binding in bindings
|
|
),
|
|
key=lambda binding: (binding.kind, binding.variable or ""),
|
|
)
|
|
)
|
|
return self._request._copy(update={"secret_bindings": resolved})
|
|
|
|
def __call__(self, *args, **kwargs):
|
|
return self._function(*args, **kwargs)
|
|
|
|
|
|
@overload
|
|
def function(target: Callable[..., Any]) -> FunctionDefinition: ...
|
|
|
|
|
|
@overload
|
|
def function(
|
|
target: None = None,
|
|
*,
|
|
name: Optional[str] = None,
|
|
input_schema: Optional[pa.Schema] = None,
|
|
output_schema: Optional[pa.DataType | pa.Field | pa.Schema] = None,
|
|
pip: tuple[str, ...] | list[str] = (),
|
|
env: Optional[Mapping[str, str]] = None,
|
|
python_version: Optional[str] = None,
|
|
batch_size: Optional[int] = None,
|
|
max_concurrency: Optional[int] = None,
|
|
row_timeout_seconds: Optional[float] = None,
|
|
retry_policy: Optional[FunctionRetryPolicy] = None,
|
|
gpu: bool = False,
|
|
gpu_class: Optional[str] = None,
|
|
conda: tuple[str, ...] | list[str] = (),
|
|
conda_channels: tuple[str, ...] | list[str] = (),
|
|
apt: tuple[str, ...] | list[str] = (),
|
|
image: Optional[str] = None,
|
|
code: Sequence[types.ModuleType] = (),
|
|
models: Sequence[HuggingFaceModel] = (),
|
|
) -> Callable[[Callable[..., Any]], FunctionDefinition]: ...
|
|
|
|
|
|
def function(
|
|
target: Optional[Callable[..., Any]] = None,
|
|
*,
|
|
name: Optional[str] = None,
|
|
input_schema: Optional[pa.Schema] = None,
|
|
output_schema: Optional[pa.DataType | pa.Field | pa.Schema] = None,
|
|
pip: tuple[str, ...] | list[str] = (),
|
|
env: Optional[Mapping[str, str]] = None,
|
|
python_version: Optional[str] = None,
|
|
batch_size: Optional[int] = None,
|
|
max_concurrency: Optional[int] = None,
|
|
row_timeout_seconds: Optional[float] = None,
|
|
retry_policy: Optional[FunctionRetryPolicy] = None,
|
|
gpu: bool = False,
|
|
gpu_class: Optional[str] = None,
|
|
conda: tuple[str, ...] | list[str] = (),
|
|
conda_channels: tuple[str, ...] | list[str] = (),
|
|
apt: tuple[str, ...] | list[str] = (),
|
|
image: Optional[str] = None,
|
|
code: Sequence[types.ModuleType] = (),
|
|
models: Sequence[HuggingFaceModel] = (),
|
|
):
|
|
"""Prepare a Python function or class for remote Function registration.
|
|
|
|
Input and output signatures are inferred from supported annotations. A
|
|
parameter annotated ``T`` is non-nullable and binds only a non-nullable
|
|
column; annotate ``Optional[T]`` to bind a nullable one, and it receives
|
|
``None`` for a null value. PyArrow fields are nullable unless declared
|
|
otherwise, so most columns need ``Optional``. For Arrow types annotations
|
|
cannot express precisely, pass ``input_schema``, ``output_schema``, or
|
|
both; the one omitted is inferred. Scalar outputs must be non-nullable.
|
|
Every named-struct field may be nullable; Enterprise preserves the
|
|
struct's validity when the result is expanded into sibling columns.
|
|
|
|
**Large values.** A ``bytes`` parameter receives the whole value, which
|
|
is held in memory with its row and counts against the server's per-row
|
|
input limit. To pass large binary values, such as video, store them in a
|
|
Blob V2 column and annotate the parameter ``BlobReader``, which streams.
|
|
|
|
**Functions and classes.** A decorated function is called once per row,
|
|
or once per batch when its parameters are annotated ``pyarrow.Array`` or
|
|
it takes one ``pyarrow.RecordBatch``. These batch annotations require both
|
|
``input_schema`` and ``output_schema`` explicitly: the annotations do not
|
|
specify Arrow element types or fields. This also applies to a class's
|
|
``__call__`` method. A decorated class is the same callable with state:
|
|
each remote instance calls ``__init__`` once, calls
|
|
``__call__`` for every row or batch of every input it processes, and calls
|
|
``close()``, if defined, once when it retires. Load models, open clients,
|
|
and build rate limiters in ``__init__``; every batch of the instance then
|
|
reuses them. An instance serves many batches of one binding, so state is
|
|
shared across them, but not across instances or workers.
|
|
|
|
Functions and ``__call__`` methods must return a value. Generators,
|
|
including async generators, are not supported; return a list to produce
|
|
multiple values for a list output. Lambdas and decorated wrappers such as
|
|
``functools.lru_cache`` are not supported. Use a named ``def`` and keep
|
|
caches inside the body or in a class instance initialized by ``__init__``.
|
|
Callable input parameters cannot have defaults; move configurable values
|
|
to class initialization fields instead.
|
|
|
|
**Initialization.** The annotated parameters of a class's ``__init__``
|
|
are the Function's initialization fields. Their values belong to each
|
|
binding, not to the Function: ``function(text=col("body"), model="small")``
|
|
binds column ``body`` and initializes every instance with ``model="small"``,
|
|
and another column can bind the same Function version with other values.
|
|
Initialization fields take booleans, integers, floats, strings, and lists
|
|
or structs of them (``Annotated[dict, pa.struct(...)]``). A parameter with
|
|
a default may be omitted from a binding; a null value then takes the
|
|
default. Use Secrets, not initialization, for credentials.
|
|
|
|
**What travels with the Function.** The artifact is a snapshot of the
|
|
decorated source plus exactly the module-level names it references:
|
|
|
|
- modules, and classes and functions importable from an installed
|
|
package, become imports; the package must be declared in ``pip`` or
|
|
``conda``;
|
|
- literals (``None``, ``bool``, ``int``, ``str``, ``bytes``, finite
|
|
``float``, and tuples containing only these literals) are inlined;
|
|
- functions and classes defined in ``__main__``, such as notebook cells,
|
|
are packaged by source, together with what they reference;
|
|
- modules and packages listed in ``code`` are shipped as Python source and
|
|
imported normally, so helpers shared by several Functions live in one
|
|
place.
|
|
|
|
A module imported by the packaged source -- including a direct ``import``
|
|
inside a function or method body -- draws a warning at registration when
|
|
the client cannot resolve its top-level module and declares no package
|
|
dependencies, or when it resolves to local source outside ``code`` and
|
|
no declared requirement matches the imported module name. The warning
|
|
does not assert that the remote environment lacks the module:
|
|
``pip``/``conda`` distribution names need not match the modules they
|
|
install, and the client cannot inspect the contents of a base or custom
|
|
image. Warnings follow normal Python warning filters and may be promoted
|
|
to errors. Declare every third-party import in ``pip`` or ``conda`` even
|
|
when it happens to be installed on the registration host.
|
|
Other global values, including Arrow types and schemas, lists, dictionaries,
|
|
sets, and NumPy arrays, cannot be captured as literals. Define them inside
|
|
the function or class body instead.
|
|
Closures, lambdas, nested definitions, mutable module-level objects, and a
|
|
non-standard ``__builtins__`` are rejected where they can be seen and
|
|
otherwise unsupported.
|
|
|
|
**Namespace restrictions.** The packaged source cannot import ``sys``,
|
|
``builtins``, ``importlib``, ``inspect``, ``gc``, ``ctypes``, or ``types``,
|
|
including submodules such as ``ctypes.util`` and ``importlib.metadata``.
|
|
Referencing a module-level alias for one of these modules is also rejected.
|
|
References to ``globals``, ``locals``, ``vars``, ``eval``, ``exec``,
|
|
``compile``, or ``__import__`` are rejected as dynamic namespace access.
|
|
These restrictions are conservative: even ordinary uses such as
|
|
``ctypes.util.find_library`` or ``sys.version`` are blocked because these
|
|
modules can reach names outside the reconstructed Function namespace.
|
|
Move such code into an importable helper module supplied with ``code=``,
|
|
``pip=``, or ``conda=``, then import and call the helper from the Function.
|
|
Helpers imported normally keep their own module namespace; these checks do
|
|
not scan their bodies. A helper defined in ``__main__`` is packaged by source
|
|
and is subject to the same checks.
|
|
|
|
Changing any packaged source, including a module in ``code``, produces a
|
|
new Function version; the environment built from ``pip`` or ``conda`` is
|
|
reused.
|
|
|
|
Parameters
|
|
----------
|
|
function : function or class, optional
|
|
The synchronous callable, or the class whose instances are called. For
|
|
a function, annotate every parameter as ``pyarrow.Array`` to opt into
|
|
vectorized execution; a matching return annotation documents that the
|
|
callable returns an Arrow array.
|
|
name : str, optional
|
|
The remote Function name. Defaults to the callable name.
|
|
Must start with an ASCII letter or underscore and contain only ASCII
|
|
letters, digits, and underscores.
|
|
input_schema : pyarrow.Schema, optional
|
|
Explicit input fields in the exact order of the callable parameters.
|
|
Required for batch annotations. When omitted, inputs are inferred from
|
|
the parameter annotations.
|
|
output_schema : pyarrow.DataType, pyarrow.Field, or pyarrow.Schema, optional
|
|
Explicit scalar or named-struct output. Scalar outputs must be
|
|
non-nullable. Required for batch annotations. When omitted, the output
|
|
is inferred from the return annotation.
|
|
pip : sequence of str, optional
|
|
Pip requirements for the remote environment. Syntax is validated at
|
|
decoration time, including extras and environment markers. Named direct
|
|
references and bare package or git URLs are supported.
|
|
conda : sequence of str, optional
|
|
Conda packages for the remote environment, instead of ``pip``. MatchSpec
|
|
syntax is validated at decoration time, including channels and builds.
|
|
conda_channels : sequence of str, optional
|
|
Conda channels in priority order; requires ``conda``.
|
|
apt : sequence of str, optional
|
|
Debian packages (``"name"`` or ``"name=version"``) added to the remote
|
|
image before the Python environment, for shared libraries pip and
|
|
conda cannot provide, such as FFmpeg's for ``torchcodec``. Packages
|
|
are unpacked without running their maintainer scripts. Works with
|
|
``pip`` or ``conda``; requires image execution.
|
|
image : str, optional
|
|
A digest-pinned registry image (``"host/repository@sha256:<digest>"``)
|
|
the remote environment is built on instead of the default base. Pip
|
|
requirements install only what the image lacks.
|
|
env : mapping of str to str, optional
|
|
Environment variables included in the Function definition. Not for
|
|
credentials -- these are ordinary configuration, stored with the
|
|
Function and visible wherever it is. Prefer initialization for
|
|
values that differ between bindings.
|
|
python_version : str, optional
|
|
Remote Python major/minor version. Defaults to the client version.
|
|
batch_size : int, optional
|
|
Maximum number of input rows supplied in one execution batch. Must be
|
|
between 1 and 4294967295. Remote execution may use smaller batches.
|
|
When omitted, the service chooses the batch size adaptively.
|
|
max_concurrency : int, optional
|
|
Maximum user-code calls in flight for one Function within one logical
|
|
execution. Must be between 1 and 4294967295. A refresh can override
|
|
this default for that execution only. Omitting it preserves the
|
|
existing behavior. This is not an account-wide quota.
|
|
row_timeout_seconds : float, optional
|
|
Positive, finite deadline in seconds for each Python call: one row for
|
|
scalar Functions and one batch for vectorized Functions. This does not
|
|
govern class initialization. When omitted, the service applies its
|
|
default per-call budget.
|
|
retry_policy : FunctionRetryPolicy, optional
|
|
Retry selected failed calls inside the Function, with bounded attempts
|
|
and backoff. A refresh may override this default. Retrying a partial
|
|
call must be safe; completed calls are not repeated. For batch Functions,
|
|
skip after exhaustion applies to the whole failed call.
|
|
gpu : bool, default False
|
|
Whether every remote execution requires a GPU. The execution platform
|
|
selects one compatible GPU for each worker. The requirement is part of
|
|
the immutable Function version.
|
|
gpu_class : str, optional
|
|
Operator-defined GPU class for every execution of this Function, such
|
|
as ``"a10g"`` or ``"h100"``. Requires ``gpu=True`` and follows the
|
|
lowercase 63-character DNS-label rule. The class is part of the
|
|
immutable Function version; per-refresh aggregate GPU budgets are
|
|
selected through ``refresh_column`` instead of registration.
|
|
code : sequence of module, optional
|
|
Top-level modules or packages shipped with the Function and importable
|
|
by their own names. Package resources, including binary data and
|
|
vendored subpackages, retain their relative paths and exact bytes.
|
|
Source and resource changes both create a new Function version.
|
|
Caches and hidden paths are excluded. Symlinks and unsafe paths are
|
|
skipped with a warning. Compiled dependencies must match the remote
|
|
runtime; use ``pip=`` or ``conda=`` for platform-specific distributions
|
|
and ``models=`` for large model snapshots. Resource bundles require
|
|
server support for the ``python_bundle.resources`` field.
|
|
models : sequence of HuggingFaceModel, optional
|
|
Immutable Hugging Face repository snapshots prepared in the worker
|
|
node cache before user code starts. A Function may declare a
|
|
repository once. Different revisions of one repository use different
|
|
Function versions. Model Functions run offline with a private
|
|
``HF_HOME``; bind ``HF_TOKEN`` through Secrets when the repository
|
|
requires authentication.
|
|
|
|
Returns
|
|
-------
|
|
FunctionDefinition
|
|
A callable definition accepted by
|
|
:meth:`lancedb.db.DBConnection.create_function`,
|
|
:meth:`lancedb.db.AsyncConnection.create_function_async` and
|
|
:meth:`lancedb.db.DBConnection.create_function_async`.
|
|
|
|
Examples
|
|
--------
|
|
>>> from lancedb import function
|
|
>>> @function(pip=["numpy==2.2.0"])
|
|
... def score(value: float) -> float:
|
|
... return value * 2
|
|
>>> score(1.5)
|
|
3.0
|
|
>>> import pyarrow as pa
|
|
>>> @function(
|
|
... input_schema=pa.schema([pa.field("value", pa.int64(), nullable=False)]),
|
|
... output_schema=pa.int64(),
|
|
... )
|
|
... def batch_identity(value: pa.Array) -> pa.Array:
|
|
... return value
|
|
>>> batch_identity(pa.array([1, 2, 3])).to_pylist()
|
|
[1, 2, 3]
|
|
>>> @function(pip=["cupy-cuda12x"], gpu=True, gpu_class="a10g")
|
|
... def gpu_score(value: int) -> int:
|
|
... return value * 2
|
|
>>> gpu_score.registration_request.runtime.gpu
|
|
True
|
|
>>> gpu_score.registration_request.runtime.gpu_class
|
|
'a10g'
|
|
>>> @function
|
|
... class scale:
|
|
... def __init__(self, factor: float, offset: float = 0.0):
|
|
... self.factor, self.offset = factor, offset
|
|
... def __call__(self, value: float) -> float:
|
|
... return value * self.factor + self.offset
|
|
... def close(self):
|
|
... self.factor = 0.0
|
|
>>> scale_fn = scale(factor=2.0)
|
|
>>> scale_fn(1.5)
|
|
3.0
|
|
>>> scale_fn(2.0)
|
|
4.0
|
|
>>> scale_fn.close()
|
|
>>> [field.name for field in scale.registration_request.signature.initialization]
|
|
['factor', 'offset']
|
|
>>> import lancedb
|
|
>>> @function(
|
|
... input_schema=pa.schema([lancedb.blob("image", nullable=False)]),
|
|
... output_schema=lancedb.blob("copy", nullable=False),
|
|
... )
|
|
... def copy_blobs(image: pa.Array) -> pa.Array:
|
|
... return image
|
|
|
|
**Local execution.** A function definition is callable with its decorated
|
|
input signature and runs the original function in the current Python
|
|
environment. A class definition is called with its initialization
|
|
arguments and constructs one instance; call that instance for row inputs,
|
|
reuse it as needed, and call ``close`` if it defines that method. This path
|
|
does not create a registered
|
|
[FunctionVersion][lancedb.functions.FunctionVersion], build the declared
|
|
remote environment, resolve Secrets, or run the remote Arrow adapter or
|
|
sandbox. A registered ``FunctionVersion`` call is different: it binds
|
|
``col(...)`` inputs and returns a remote
|
|
[FunctionApplication][lancedb.functions.FunctionApplication].
|
|
|
|
```python
|
|
scaled = scale(factor=2.0)
|
|
try:
|
|
assert scaled(1.5) == 3.0
|
|
assert scaled(2.0) == 4.0
|
|
finally:
|
|
scaled.close()
|
|
```
|
|
|
|
Blob v2 input is exposed as ``pyarrow.LargeBinaryArray`` in vectorized
|
|
mode (and as ``bytes`` in row mode). Return a ``LargeBinaryArray`` with the
|
|
declared nullability. Use :func:`lancedb.blob` in the explicit schemas;
|
|
plain ``pyarrow.large_binary()`` does not carry Blob v2 semantics.
|
|
|
|
A Blob v2 field may sit at the top level or inside a struct in inputs and
|
|
outputs. Only outputs may nest it in a list or large list (for example
|
|
``pa.list_(lancedb.blob("item"))``, returned as a list of ``bytes``);
|
|
fixed-size lists and maps are not supported. List-nested Blob v2 values
|
|
are queryable as Blob descriptors, but the row-id blob fetch APIs and
|
|
``blob_mode="bytes"`` cannot fetch payloads inside lists.
|
|
"""
|
|
|
|
def decorate(target: Any) -> FunctionDefinition:
|
|
return FunctionDefinition(
|
|
target,
|
|
name=name,
|
|
input_schema=input_schema,
|
|
output_schema=output_schema,
|
|
pip=tuple(pip),
|
|
env={} if env is None else env,
|
|
python_version=python_version,
|
|
batch_size=batch_size,
|
|
max_concurrency=max_concurrency,
|
|
row_timeout_seconds=row_timeout_seconds,
|
|
retry_policy=retry_policy,
|
|
gpu=gpu,
|
|
gpu_class=gpu_class,
|
|
conda=tuple(conda),
|
|
conda_channels=tuple(conda_channels),
|
|
apt=tuple(apt),
|
|
image=image,
|
|
code=tuple(code),
|
|
models=tuple(models),
|
|
)
|
|
|
|
if target is None:
|
|
return decorate
|
|
return decorate(target)
|
|
|
|
|
|
__all__ = [
|
|
"AssignmentMapping",
|
|
"ApplicationInput",
|
|
"FunctionApplication",
|
|
"FunctionArtifact",
|
|
"FunctionArtifactContent",
|
|
"FunctionArtifactRequest",
|
|
"FunctionBinding",
|
|
"FunctionOutput",
|
|
"FunctionParameter",
|
|
"FunctionRegistrationRequest",
|
|
"FunctionRetryPolicy",
|
|
"FunctionResultField",
|
|
"FunctionSignature",
|
|
"FunctionImage",
|
|
"FunctionVersion",
|
|
"FunctionVersionRef",
|
|
"HuggingFaceModel",
|
|
"InputBinding",
|
|
"OutputMapping",
|
|
"PythonEnvironmentSpec",
|
|
"PythonAdapterSpec",
|
|
"PythonRuntimeSpec",
|
|
"RefreshColumnResult",
|
|
"FunctionDefinition",
|
|
]
|
|
|
|
|
|
def udf(*args: Any, **kwargs: Any) -> Any:
|
|
"""Deprecated alias of :func:`function`."""
|
|
warnings.warn(
|
|
"lancedb.udf is deprecated; use lancedb.function",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
return function(*args, **kwargs)
|
|
|
|
|
|
UDF_DEFINITION_DEPRECATION = (
|
|
"lancedb.UdfDefinition is deprecated; use lancedb.FunctionDefinition"
|
|
)
|
|
|
|
|
|
def __getattr__(name: str) -> Any:
|
|
if name == "UdfDefinition":
|
|
warnings.warn(UDF_DEFINITION_DEPRECATION, DeprecationWarning, stacklevel=2)
|
|
return FunctionDefinition
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
UdfDefinition = FunctionDefinition
|