mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-12 08:12:28 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00c6d4001 | ||
|
|
404b91d4d6 | ||
|
|
d6bcfe52a9 | ||
|
|
82cf9f3b96 | ||
|
|
762eb0f44f | ||
|
|
b9c0446421 | ||
|
|
d67585c245 | ||
|
|
2e34d2742b | ||
|
|
4d09f8ce26 | ||
|
|
7b29fb2f51 |
@@ -125,6 +125,10 @@ listing a storage directory.
|
|||||||
|
|
||||||
::: lancedb.functions.UdfDefinition
|
::: lancedb.functions.UdfDefinition
|
||||||
|
|
||||||
|
::: lancedb.secrets.EnvVarSecret
|
||||||
|
|
||||||
|
::: lancedb.secrets.SecretInfo
|
||||||
|
|
||||||
::: lancedb.functions.FunctionRegistrationRequest
|
::: lancedb.functions.FunctionRegistrationRequest
|
||||||
|
|
||||||
::: lancedb.functions.FunctionArtifactRequest
|
::: lancedb.functions.FunctionArtifactRequest
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ from .functions import (
|
|||||||
UdfDefinition as UdfDefinition,
|
UdfDefinition as UdfDefinition,
|
||||||
udf as udf,
|
udf as udf,
|
||||||
)
|
)
|
||||||
|
from .secrets import EnvVarSecret as EnvVarSecret
|
||||||
|
from .secrets import SecretInfo as SecretInfo
|
||||||
from .materialized_view import (
|
from .materialized_view import (
|
||||||
AsyncMaterializedView,
|
AsyncMaterializedView,
|
||||||
MaterializedView,
|
MaterializedView,
|
||||||
|
|||||||
@@ -153,6 +153,17 @@ class Connection(object):
|
|||||||
async def get_function(self, name: str, version: str) -> str: ...
|
async def get_function(self, name: str, version: str) -> str: ...
|
||||||
async def list_functions(self) -> List[str]: ...
|
async def list_functions(self) -> List[str]: ...
|
||||||
async def drop_function(self, name: str, version: str) -> bool: ...
|
async def drop_function(self, name: str, version: str) -> bool: ...
|
||||||
|
async def create_secret(
|
||||||
|
self, name: str, value: str, namespace_path: List[str]
|
||||||
|
) -> None: ...
|
||||||
|
async def alter_secret(
|
||||||
|
self, name: str, value: str, namespace_path: List[str]
|
||||||
|
) -> None: ...
|
||||||
|
async def list_secrets(self, namespace_path: List[str]) -> List[str]: ...
|
||||||
|
async def drop_secret(self, name: str, namespace_path: List[str]) -> None: ...
|
||||||
|
async def describe_secret(
|
||||||
|
self, name: str, namespace_path: List[str]
|
||||||
|
) -> Dict[str, str]: ...
|
||||||
async def list_jobs(self) -> List[JobInfo]: ...
|
async def list_jobs(self) -> List[JobInfo]: ...
|
||||||
async def cancel_job(self, job_id: str) -> bool: ...
|
async def cancel_job(self, job_id: str) -> bool: ...
|
||||||
async def execute_query_async(
|
async def execute_query_async(
|
||||||
|
|||||||
+210
-11
@@ -17,6 +17,7 @@ from typing import (
|
|||||||
List,
|
List,
|
||||||
Literal,
|
Literal,
|
||||||
Optional,
|
Optional,
|
||||||
|
Sequence,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -57,6 +58,12 @@ from .materialized_view import (
|
|||||||
SelectArg,
|
SelectArg,
|
||||||
normalize_select,
|
normalize_select,
|
||||||
)
|
)
|
||||||
|
from .secrets import (
|
||||||
|
EnvVarSecret,
|
||||||
|
SecretInfo,
|
||||||
|
validate_namespace_path,
|
||||||
|
validate_secret_name,
|
||||||
|
)
|
||||||
from .table import (
|
from .table import (
|
||||||
AsyncTable,
|
AsyncTable,
|
||||||
LanceTable,
|
LanceTable,
|
||||||
@@ -692,15 +699,47 @@ class DBConnection(EnforceOverrides):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError("serialize is not supported for this connection type")
|
raise NotImplementedError("serialize is not supported for this connection type")
|
||||||
|
|
||||||
def create_function(self, definition: UdfDefinition) -> FunctionVersion:
|
def create_function(
|
||||||
|
self,
|
||||||
|
definition: UdfDefinition,
|
||||||
|
*,
|
||||||
|
secrets: Optional[Sequence[EnvVarSecret]] = None,
|
||||||
|
) -> FunctionVersion:
|
||||||
"""Register a scalar Python UDF and wait for its immutable version.
|
"""Register a scalar Python UDF and wait for its immutable version.
|
||||||
|
|
||||||
This is the blocking counterpart of :meth:`create_function_async`.
|
This is the blocking counterpart of :meth:`create_function_async`.
|
||||||
Local connections raise ``NotImplementedError``.
|
Local connections raise ``NotImplementedError``.
|
||||||
"""
|
|
||||||
return self.create_function_async(definition).wait()
|
|
||||||
|
|
||||||
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]:
|
Parameters
|
||||||
|
----------
|
||||||
|
definition : UdfDefinition
|
||||||
|
A callable decorated with [udf][lancedb.udf].
|
||||||
|
secrets : sequence of EnvVarSecret, optional
|
||||||
|
One [EnvVarSecret][lancedb.secrets.EnvVarSecret] per credential the
|
||||||
|
Function needs, each naming a Secret and the environment variable
|
||||||
|
its value arrives in. The Function's source is unchanged by this;
|
||||||
|
it reads the variable the way it already did.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
```python
|
||||||
|
db.create_secret("openai-prod", os.environ["OPENAI_API_KEY"])
|
||||||
|
db.create_function(
|
||||||
|
analyze_caption,
|
||||||
|
secrets=[
|
||||||
|
EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
return self.create_function_async(definition, secrets=secrets).wait()
|
||||||
|
|
||||||
|
def create_function_async(
|
||||||
|
self,
|
||||||
|
definition: UdfDefinition,
|
||||||
|
*,
|
||||||
|
secrets: Optional[Sequence[EnvVarSecret]] = None,
|
||||||
|
) -> Job[FunctionVersion]:
|
||||||
"""Register a scalar Python UDF through the remote Function catalog.
|
"""Register a scalar Python UDF through the remote Function catalog.
|
||||||
|
|
||||||
Submission returns a typed job. The immutable Function version becomes
|
Submission returns a typed job. The immutable Function version becomes
|
||||||
@@ -745,6 +784,70 @@ class DBConnection(EnforceOverrides):
|
|||||||
"Function catalog operations are not supported for this connection type"
|
"Function catalog operations are not supported for this connection type"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def create_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
"""Create a named Secret in this database.
|
||||||
|
|
||||||
|
Fails if the name is taken, so a create never silently becomes a
|
||||||
|
rotation. Nothing reads the value back: it is bound to a Function by
|
||||||
|
name and resolved by the service when that Function runs. Local
|
||||||
|
connections raise ``NotImplementedError``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Secret operations are not supported for this connection type"
|
||||||
|
)
|
||||||
|
|
||||||
|
def alter_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
"""Replace the credential behind an existing Secret.
|
||||||
|
|
||||||
|
Fails if it does not exist. Every Function bound to the Secret uses the
|
||||||
|
new value from its next job, and no new Function version is created --
|
||||||
|
which is how a rotation reaches columns pinned to a version registered
|
||||||
|
before it. Local connections raise ``NotImplementedError``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Secret operations are not supported for this connection type"
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||||
|
"""The names of every Secret in this database.
|
||||||
|
|
||||||
|
Names only. No method returns a stored credential, by construction
|
||||||
|
rather than by policy. Local connections raise ``NotImplementedError``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Secret operations are not supported for this connection type"
|
||||||
|
)
|
||||||
|
|
||||||
|
def drop_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
"""Drop a Secret.
|
||||||
|
|
||||||
|
Functions bound to it fail at their next job, naming the Secret; that
|
||||||
|
is the revocation path. The name becomes free to reuse, and a new
|
||||||
|
Secret under it is picked up by everything still bound to that name.
|
||||||
|
Local connections raise ``NotImplementedError``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Secret operations are not supported for this connection type"
|
||||||
|
)
|
||||||
|
|
||||||
|
def describe_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> SecretInfo:
|
||||||
|
"""What this database records about a Secret: name and timestamps.
|
||||||
|
|
||||||
|
Never the value -- there is no code path that could return one. Local
|
||||||
|
connections raise ``NotImplementedError``.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Secret operations are not supported for this connection type"
|
||||||
|
)
|
||||||
|
|
||||||
def open_job(self, job_id: str) -> Job:
|
def open_job(self, job_id: str) -> Job:
|
||||||
"""Open a server-side job by id, returning a handle with its record
|
"""Open a server-side job by id, returning a handle with its record
|
||||||
already populated.
|
already populated.
|
||||||
@@ -1457,8 +1560,13 @@ class LanceDBConnection(DBConnection):
|
|||||||
return Job(LOOP.run(self._conn.open_job(job_id)))
|
return Job(LOOP.run(self._conn.open_job(job_id)))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]:
|
def create_function_async(
|
||||||
job = LOOP.run(self._conn.create_function_async(definition))
|
self,
|
||||||
|
definition: UdfDefinition,
|
||||||
|
*,
|
||||||
|
secrets: Optional[Sequence[EnvVarSecret]] = None,
|
||||||
|
) -> Job[FunctionVersion]:
|
||||||
|
job = LOOP.run(self._conn.create_function_async(definition, secrets=secrets))
|
||||||
return Job(job)
|
return Job(job)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1473,6 +1581,34 @@ class LanceDBConnection(DBConnection):
|
|||||||
def drop_function(self, name: str, *, version: str) -> bool:
|
def drop_function(self, name: str, *, version: str) -> bool:
|
||||||
return LOOP.run(self._conn.drop_function(name, version=version))
|
return LOOP.run(self._conn.drop_function(name, version=version))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def create_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def alter_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||||
|
return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def drop_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def describe_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> SecretInfo:
|
||||||
|
return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def list_jobs(self) -> List[JobInfo]:
|
def list_jobs(self) -> List[JobInfo]:
|
||||||
"""List server-side jobs across the database's tables."""
|
"""List server-side jobs across the database's tables."""
|
||||||
@@ -2265,18 +2401,23 @@ class AsyncConnection(object):
|
|||||||
return AsyncJob(await self._inner.open_job(job_id))
|
return AsyncJob(await self._inner.open_job(job_id))
|
||||||
|
|
||||||
async def create_function_async(
|
async def create_function_async(
|
||||||
self, definition: UdfDefinition
|
self,
|
||||||
|
definition: UdfDefinition,
|
||||||
|
*,
|
||||||
|
secrets: Optional[Sequence[EnvVarSecret]] = None,
|
||||||
) -> AsyncJob[FunctionVersion]:
|
) -> AsyncJob[FunctionVersion]:
|
||||||
"""Register a scalar Python UDF through the remote Function catalog.
|
"""Register a scalar Python UDF through the remote Function catalog.
|
||||||
|
|
||||||
The returned typed job resolves to the immutable Function version.
|
The returned typed job resolves to the immutable Function version.
|
||||||
Local connections raise ``NotImplementedError``.
|
``secrets`` is a sequence of
|
||||||
|
[EnvVarSecret][lancedb.secrets.EnvVarSecret], each naming a Secret and
|
||||||
|
the environment variable its value arrives in. Local connections raise
|
||||||
|
``NotImplementedError``.
|
||||||
"""
|
"""
|
||||||
if not isinstance(definition, UdfDefinition):
|
if not isinstance(definition, UdfDefinition):
|
||||||
raise TypeError("create_function_async requires a @udf definition")
|
raise TypeError("create_function_async requires a @udf definition")
|
||||||
inner = await self._inner.create_function_async(
|
request = definition.bind_secrets(secrets)
|
||||||
definition.registration_request.to_canonical_json()
|
inner = await self._inner.create_function_async(request.to_canonical_json())
|
||||||
)
|
|
||||||
return _typed_job(inner, FunctionVersion.from_json)
|
return _typed_job(inner, FunctionVersion.from_json)
|
||||||
|
|
||||||
async def get_function(self, name: str, *, version: str) -> FunctionVersion:
|
async def get_function(self, name: str, *, version: str) -> FunctionVersion:
|
||||||
@@ -2298,6 +2439,64 @@ class AsyncConnection(object):
|
|||||||
"""Drop one exact immutable Function version from the remote catalog."""
|
"""Drop one exact immutable Function version from the remote catalog."""
|
||||||
return await self._inner.drop_function(name, version)
|
return await self._inner.drop_function(name, version)
|
||||||
|
|
||||||
|
async def create_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
"""Create a named Secret in this database.
|
||||||
|
|
||||||
|
Fails if the name is taken, so a create never silently becomes a
|
||||||
|
rotation. Nothing reads the value back.
|
||||||
|
"""
|
||||||
|
await self._inner.create_secret(
|
||||||
|
validate_secret_name(name),
|
||||||
|
value,
|
||||||
|
list(validate_namespace_path(namespace_path)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def alter_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
"""Replace the credential behind an existing Secret.
|
||||||
|
|
||||||
|
Fails if it does not exist. Bound Functions use the new value from
|
||||||
|
their next job, with no new Function version.
|
||||||
|
"""
|
||||||
|
await self._inner.alter_secret(
|
||||||
|
validate_secret_name(name),
|
||||||
|
value,
|
||||||
|
list(validate_namespace_path(namespace_path)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_secrets(
|
||||||
|
self, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""The names of every Secret in this database. Names only."""
|
||||||
|
return await self._inner.list_secrets(
|
||||||
|
list(validate_namespace_path(namespace_path))
|
||||||
|
)
|
||||||
|
|
||||||
|
async def drop_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
"""Drop a Secret. Bound Functions fail at their next job."""
|
||||||
|
await self._inner.drop_secret(
|
||||||
|
validate_secret_name(name), list(validate_namespace_path(namespace_path))
|
||||||
|
)
|
||||||
|
|
||||||
|
async def describe_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> SecretInfo:
|
||||||
|
"""What this database records about a Secret. Never the value."""
|
||||||
|
name, created_at_millis, updated_at_millis = await self._inner.describe_secret(
|
||||||
|
validate_secret_name(name),
|
||||||
|
list(validate_namespace_path(namespace_path)),
|
||||||
|
)
|
||||||
|
return SecretInfo(
|
||||||
|
name=name,
|
||||||
|
created_at_millis=created_at_millis,
|
||||||
|
updated_at_millis=updated_at_millis,
|
||||||
|
)
|
||||||
|
|
||||||
async def list_jobs(self) -> List[JobInfo]:
|
async def list_jobs(self) -> List[JobInfo]:
|
||||||
"""List server-side jobs across the database's tables."""
|
"""List server-side jobs across the database's tables."""
|
||||||
return await self._inner.list_jobs()
|
return await self._inner.list_jobs()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"""Canonical Function values exchanged with LanceDB Enterprise services.
|
"""Canonical Function values exchanged with LanceDB Enterprise services.
|
||||||
|
|
||||||
These immutable models contain client/wire state only. Catalog persistence,
|
These immutable models contain client/wire state only. Catalog persistence,
|
||||||
environment bake, and execution are owned by Sophon.
|
environment bake, secret resolution, and execution are owned by Sophon.
|
||||||
``RefreshColumnResult`` is also the backend-neutral result of a local
|
``RefreshColumnResult`` is also the backend-neutral result of a local
|
||||||
expression-backed refresh job.
|
expression-backed refresh job.
|
||||||
"""
|
"""
|
||||||
@@ -25,7 +25,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
import types
|
import types
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import (
|
from typing import (
|
||||||
Annotated,
|
Annotated,
|
||||||
@@ -50,6 +50,7 @@ from pydantic import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .schema import is_blob_v2_field as _is_blob_v2_field
|
from .schema import is_blob_v2_field as _is_blob_v2_field
|
||||||
|
from .secrets import EnvVarSecret
|
||||||
|
|
||||||
_Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1)
|
_Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1)
|
||||||
_UInt32 = conint(strict=True, ge=0, le=2**32 - 1)
|
_UInt32 = conint(strict=True, ge=0, le=2**32 - 1)
|
||||||
@@ -226,6 +227,33 @@ class FunctionOutput(_OpenRemoteValue):
|
|||||||
fields: tuple[FunctionResultField, ...] = ()
|
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):
|
class FunctionSignature(_RemoteValue):
|
||||||
inputs: tuple[FunctionParameter, ...]
|
inputs: tuple[FunctionParameter, ...]
|
||||||
output: FunctionOutput
|
output: FunctionOutput
|
||||||
@@ -309,6 +337,7 @@ class FunctionVersion(_RemoteValue):
|
|||||||
runtime: PythonRuntimeSpec
|
runtime: PythonRuntimeSpec
|
||||||
runtime_digest: str
|
runtime_digest: str
|
||||||
environment_digest: str
|
environment_digest: str
|
||||||
|
secret_bindings: tuple[SecretBinding, ...] = ()
|
||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
def __call__(self, **inputs: Any) -> FunctionApplication:
|
def __call__(self, **inputs: Any) -> FunctionApplication:
|
||||||
@@ -370,12 +399,18 @@ class FunctionVersion(_RemoteValue):
|
|||||||
|
|
||||||
|
|
||||||
class FunctionRegistrationRequest(_RemoteValue):
|
class FunctionRegistrationRequest(_RemoteValue):
|
||||||
"""Stable remote registration envelope produced by :func:`udf`."""
|
"""Stable remote registration envelope produced by :func:`udf`.
|
||||||
|
|
||||||
|
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
|
name: str
|
||||||
artifact: FunctionArtifactRequest
|
artifact: FunctionArtifactRequest
|
||||||
signature: FunctionSignature
|
signature: FunctionSignature
|
||||||
runtime: PythonRuntimeSpec
|
runtime: PythonRuntimeSpec
|
||||||
|
secret_bindings: tuple[SecretBinding, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
class FunctionVersionRef(_OpenRemoteValue):
|
class FunctionVersionRef(_OpenRemoteValue):
|
||||||
@@ -524,6 +559,8 @@ class RefreshColumnResult(_RemoteValue):
|
|||||||
|
|
||||||
|
|
||||||
_FUNCTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
|
_FUNCTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
|
||||||
|
_DECLARED_SECRET = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
_FUNCTION_BLOB_V2_TYPE = "blob_v2"
|
_FUNCTION_BLOB_V2_TYPE = "blob_v2"
|
||||||
_ARROW_EXTENSION_NAME_KEY = "ARROW:extension:name"
|
_ARROW_EXTENSION_NAME_KEY = "ARROW:extension:name"
|
||||||
_BLOB_V2_EXTENSION_NAME = "lance.blob.v2"
|
_BLOB_V2_EXTENSION_NAME = "lance.blob.v2"
|
||||||
@@ -1265,9 +1302,73 @@ class UdfDefinition:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def registration_request(self) -> FunctionRegistrationRequest:
|
def registration_request(self) -> FunctionRegistrationRequest:
|
||||||
"""The immutable request sent by ``create_function_async``."""
|
"""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
|
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,
|
||||||
|
namespace_path=tuple(binding.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):
|
def __call__(self, *args, **kwargs):
|
||||||
return self._function(*args, **kwargs)
|
return self._function(*args, **kwargs)
|
||||||
|
|
||||||
@@ -1332,7 +1433,9 @@ def udf(
|
|||||||
conda_channels : sequence of str, optional
|
conda_channels : sequence of str, optional
|
||||||
Conda channels in priority order; requires ``conda``.
|
Conda channels in priority order; requires ``conda``.
|
||||||
env : mapping of str to str, optional
|
env : mapping of str to str, optional
|
||||||
Environment variables included in the Function definition.
|
Environment variables included in the Function definition. Not for
|
||||||
|
credentials -- these are ordinary configuration, stored with the
|
||||||
|
Function and visible wherever it is.
|
||||||
python_version : str, optional
|
python_version : str, optional
|
||||||
Remote Python major/minor version. Defaults to the client version.
|
Remote Python major/minor version. Defaults to the client version.
|
||||||
gpu : bool, default False
|
gpu : bool, default False
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
Dict,
|
||||||
|
Iterable,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import warnings
|
import warnings
|
||||||
@@ -29,6 +38,7 @@ from ..job import AsyncJob, Job
|
|||||||
from ..sql import Query as SqlQuery
|
from ..sql import Query as SqlQuery
|
||||||
from ..sql import QueryDescription
|
from ..sql import QueryDescription
|
||||||
from ..materialized_view import MaterializedView, SelectArg
|
from ..materialized_view import MaterializedView, SelectArg
|
||||||
|
from ..secrets import EnvVarSecret, SecretInfo
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .._lancedb import JobInfo
|
from .._lancedb import JobInfo
|
||||||
@@ -746,8 +756,14 @@ class RemoteDBConnection(DBConnection):
|
|||||||
return Job(LOOP.run(self._conn.open_job(job_id)))
|
return Job(LOOP.run(self._conn.open_job(job_id)))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]:
|
def create_function_async(
|
||||||
return Job(LOOP.run(self._conn.create_function_async(definition)))
|
self,
|
||||||
|
definition: UdfDefinition,
|
||||||
|
*,
|
||||||
|
secrets: Optional[Sequence[EnvVarSecret]] = None,
|
||||||
|
) -> Job[FunctionVersion]:
|
||||||
|
job = LOOP.run(self._conn.create_function_async(definition, secrets=secrets))
|
||||||
|
return Job(job)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def get_function(self, name: str, *, version: str) -> FunctionVersion:
|
def get_function(self, name: str, *, version: str) -> FunctionVersion:
|
||||||
@@ -761,6 +777,34 @@ class RemoteDBConnection(DBConnection):
|
|||||||
def drop_function(self, name: str, *, version: str) -> bool:
|
def drop_function(self, name: str, *, version: str) -> bool:
|
||||||
return LOOP.run(self._conn.drop_function(name, version=version))
|
return LOOP.run(self._conn.drop_function(name, version=version))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def create_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def alter_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def describe_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> SecretInfo:
|
||||||
|
return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||||
|
return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def drop_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def list_jobs(self) -> List["JobInfo"]:
|
def list_jobs(self) -> List["JobInfo"]:
|
||||||
"""List server-side jobs across the database's tables."""
|
"""List server-side jobs across the database's tables."""
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||||
|
|
||||||
|
"""Named Secrets, and the bindings that deliver them to Functions.
|
||||||
|
|
||||||
|
A Secret is a database-scoped named credential. Nothing in this module holds a
|
||||||
|
value: :class:`EnvVarSecret` names one and says which environment variable it
|
||||||
|
should arrive in, and the value is resolved by the remote service when a
|
||||||
|
Function bound to it runs. No API returns a stored credential, by construction
|
||||||
|
rather than by policy -- there is no code path that could.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# The same characters LanceDB already admits in a namespace or table name, and
|
||||||
|
# no positional rule on top of them: a segment may begin with `_`, `-` or `.`
|
||||||
|
# today, so anything narrower would put Secrets out of reach inside namespaces
|
||||||
|
# that already exist. Matches the service, which admits the same set.
|
||||||
|
_SECRET_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,255}$")
|
||||||
|
_ENV_VARIABLE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_secret_name(name: str) -> str:
|
||||||
|
"""Check a Secret name locally and return it unchanged."""
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise TypeError(f"Secret name must be a string, not {type(name).__name__}")
|
||||||
|
if not _SECRET_NAME.fullmatch(name):
|
||||||
|
raise ValueError(f"invalid Secret name: {name!r}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def validate_namespace_path(namespace_path=None):
|
||||||
|
"""Check a namespace path locally and return it as a tuple.
|
||||||
|
|
||||||
|
``None`` and ``[]`` both mean the root namespace. Segments follow the same
|
||||||
|
rule as Secret names: a binding carries the path and the name as separate
|
||||||
|
fields, so neither is ever parsed out of the other.
|
||||||
|
"""
|
||||||
|
if namespace_path is None:
|
||||||
|
return ()
|
||||||
|
if isinstance(namespace_path, str):
|
||||||
|
raise TypeError(
|
||||||
|
"namespace_path must be a list of segments, not a string; "
|
||||||
|
f"did you mean [{namespace_path!r}]?"
|
||||||
|
)
|
||||||
|
segments = tuple(namespace_path)
|
||||||
|
for segment in segments:
|
||||||
|
if not isinstance(segment, str):
|
||||||
|
raise TypeError(
|
||||||
|
f"namespace path segment must be a string, not {type(segment).__name__}"
|
||||||
|
)
|
||||||
|
if not _SECRET_NAME.fullmatch(segment):
|
||||||
|
raise ValueError(f"invalid namespace path segment: {segment!r}")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def validate_env_variable(name: str) -> str:
|
||||||
|
"""Check an environment variable name locally and return it unchanged."""
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise TypeError(
|
||||||
|
f"environment variable name must be a string, not {type(name).__name__}"
|
||||||
|
)
|
||||||
|
if not _ENV_VARIABLE.fullmatch(name):
|
||||||
|
raise ValueError(f"invalid environment variable name: {name!r}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class EnvVarSecret:
|
||||||
|
"""A Secret bound to the environment variable a Function's library reads.
|
||||||
|
|
||||||
|
Pass these in the ``secrets`` sequence of
|
||||||
|
[DBConnection.create_function][lancedb.db.DBConnection.create_function]. The
|
||||||
|
Function's source is unchanged by binding: it reads ``OPENAI_API_KEY`` the
|
||||||
|
way it always did, and the binding is what puts a value there.
|
||||||
|
|
||||||
|
This is a local value. Constructing it contacts no server, so it always
|
||||||
|
succeeds and says nothing about whether the Secret exists; that is checked
|
||||||
|
at registration, where a mistyped Secret name surfaces as a clear "does not
|
||||||
|
exist" naming both the Secret and the variable bound to it. A mistyped
|
||||||
|
*variable* name cannot be caught anywhere -- nothing knows which variables a
|
||||||
|
Function reads -- so it surfaces on the first rows instead.
|
||||||
|
|
||||||
|
The type exists so a credential cannot be passed by accident. A bare string
|
||||||
|
in the same position is a plausible-looking mistake with the opposite
|
||||||
|
meaning, and it reads identically in a diff.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
secret : str
|
||||||
|
The Secret's database-scoped name.
|
||||||
|
env_variable : str
|
||||||
|
The environment variable the Function reads it from.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
>>> from lancedb import EnvVarSecret
|
||||||
|
>>> binding = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
|
||||||
|
>>> binding.secret, binding.env_variable
|
||||||
|
('openai-prod', 'OPENAI_API_KEY')
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_secret", "_env_variable", "_namespace_path")
|
||||||
|
|
||||||
|
def __init__(self, secret: str, env_variable: str, *, namespace_path=None):
|
||||||
|
self._secret = validate_secret_name(secret)
|
||||||
|
self._env_variable = validate_env_variable(env_variable)
|
||||||
|
self._namespace_path = validate_namespace_path(namespace_path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secret(self) -> str:
|
||||||
|
"""The Secret's database-scoped name."""
|
||||||
|
return self._secret
|
||||||
|
|
||||||
|
@property
|
||||||
|
def env_variable(self) -> str:
|
||||||
|
"""The environment variable the value is delivered in."""
|
||||||
|
return self._env_variable
|
||||||
|
|
||||||
|
@property
|
||||||
|
def namespace_path(self):
|
||||||
|
"""The namespace path the Secret is addressed within, root when empty."""
|
||||||
|
return list(self._namespace_path)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
path = (
|
||||||
|
f", namespace_path={list(self._namespace_path)!r}"
|
||||||
|
if self._namespace_path
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"EnvVarSecret(secret={self._secret!r}, "
|
||||||
|
f"env_variable={self._env_variable!r}{path})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(other, EnvVarSecret)
|
||||||
|
and other._secret == self._secret
|
||||||
|
and other._env_variable == self._env_variable
|
||||||
|
and other._namespace_path == self._namespace_path
|
||||||
|
)
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return hash(
|
||||||
|
(EnvVarSecret, self._secret, self._env_variable, self._namespace_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SecretInfo:
|
||||||
|
"""What a database records about a Secret. Never its value.
|
||||||
|
|
||||||
|
Returned by
|
||||||
|
[DBConnection.describe_secret][lancedb.db.DBConnection.describe_secret].
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_name", "_created_at_millis", "_updated_at_millis")
|
||||||
|
|
||||||
|
def __init__(self, name: str, created_at_millis: int, updated_at_millis: int):
|
||||||
|
self._name = name
|
||||||
|
self._created_at_millis = created_at_millis
|
||||||
|
self._updated_at_millis = updated_at_millis
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""The Secret's database-scoped name."""
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_at_millis(self) -> int:
|
||||||
|
"""When the Secret was created, in milliseconds since the Unix epoch."""
|
||||||
|
return self._created_at_millis
|
||||||
|
|
||||||
|
@property
|
||||||
|
def updated_at_millis(self) -> int:
|
||||||
|
"""When the Secret's value was last rotated, in epoch milliseconds.
|
||||||
|
|
||||||
|
The only observable that a rotation landed: no API returns a credential,
|
||||||
|
so a caller confirms ``alter_secret`` took effect by watching this move.
|
||||||
|
"""
|
||||||
|
return self._updated_at_millis
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, value: dict) -> "SecretInfo":
|
||||||
|
return cls(
|
||||||
|
name=value["name"],
|
||||||
|
created_at_millis=value["created_at_millis"],
|
||||||
|
updated_at_millis=value["updated_at_millis"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"SecretInfo(name={self._name!r}, "
|
||||||
|
f"created_at_millis={self._created_at_millis!r}, "
|
||||||
|
f"updated_at_millis={self._updated_at_millis!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(other, SecretInfo)
|
||||||
|
and other._name == self._name
|
||||||
|
and other._created_at_millis == self._created_at_millis
|
||||||
|
and other._updated_at_millis == self._updated_at_millis
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EnvVarSecret",
|
||||||
|
"SecretInfo",
|
||||||
|
"validate_env_variable",
|
||||||
|
"validate_secret_name",
|
||||||
|
]
|
||||||
@@ -13,7 +13,9 @@ from lancedb.functions import (
|
|||||||
FunctionBinding,
|
FunctionBinding,
|
||||||
FunctionVersion,
|
FunctionVersion,
|
||||||
PythonRuntimeSpec,
|
PythonRuntimeSpec,
|
||||||
|
SecretBinding,
|
||||||
RefreshColumnResult,
|
RefreshColumnResult,
|
||||||
|
SecretReference,
|
||||||
)
|
)
|
||||||
from lancedb.table import AsyncTable
|
from lancedb.table import AsyncTable
|
||||||
|
|
||||||
@@ -37,6 +39,22 @@ def job_result(name: str) -> dict:
|
|||||||
return json.loads(fixture(name))["result"]
|
return json.loads(fixture(name))["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_secret_values(value):
|
||||||
|
"""No client value models a resolved credential, at any nesting depth."""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, child in value.items():
|
||||||
|
assert key not in {
|
||||||
|
"secret_value",
|
||||||
|
"secret_values",
|
||||||
|
"resolved_secret",
|
||||||
|
"resolved_secrets",
|
||||||
|
}
|
||||||
|
assert_no_secret_values(child)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for child in value:
|
||||||
|
assert_no_secret_values(child)
|
||||||
|
|
||||||
|
|
||||||
def test_public_function_values_are_in_api_reference():
|
def test_public_function_values_are_in_api_reference():
|
||||||
docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md"
|
docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md"
|
||||||
rendered = docs.read_text()
|
rendered = docs.read_text()
|
||||||
@@ -94,6 +112,11 @@ def test_function_version_identity_is_immutable_and_exact():
|
|||||||
version = FunctionVersion.from_json(json.dumps(value))
|
version = FunctionVersion.from_json(json.dumps(value))
|
||||||
assert version.name == "embed"
|
assert version.name == "embed"
|
||||||
assert version.version == "fv_01K3EXACT"
|
assert version.version == "fv_01K3EXACT"
|
||||||
|
assert list(version.secret_bindings) == [
|
||||||
|
SecretBinding(
|
||||||
|
kind="env", variable="HF_TOKEN", secret_ref=SecretReference(name="hf-prod")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
with pytest.raises((TypeError, ValueError)):
|
with pytest.raises((TypeError, ValueError)):
|
||||||
version.version = "fv_changed"
|
version.version = "fv_changed"
|
||||||
@@ -276,6 +299,27 @@ def test_refresh_result_rejects_non_u64_values(field):
|
|||||||
RefreshColumnResult.from_json(json.dumps(value))
|
RefreshColumnResult.from_json(json.dumps(value))
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_client_values_carry_bindings_and_no_credentials():
|
||||||
|
"""A binding names a Secret; the credential behind it has no client field."""
|
||||||
|
version = FunctionVersion.from_json(
|
||||||
|
json.dumps(job_result("remote_function_job.json"))
|
||||||
|
)
|
||||||
|
canonical = json.loads(version.to_canonical_json())
|
||||||
|
assert canonical["secret_bindings"] == [
|
||||||
|
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}}
|
||||||
|
]
|
||||||
|
assert_no_secret_values(canonical)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_version_without_bindings_keeps_the_original_wire_shape():
|
||||||
|
"""Every Function registered before Secrets existed serializes unchanged."""
|
||||||
|
value = job_result("remote_function_job.json")
|
||||||
|
del value["secret_bindings"]
|
||||||
|
version = FunctionVersion.from_json(json.dumps(value))
|
||||||
|
assert list(version.secret_bindings) == []
|
||||||
|
assert "secret_bindings" not in json.loads(version.to_canonical_json())
|
||||||
|
|
||||||
|
|
||||||
class _FunctionDeclarationInner:
|
class _FunctionDeclarationInner:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import types
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -23,11 +24,14 @@ import pytest
|
|||||||
import lancedb
|
import lancedb
|
||||||
from lancedb.functions import (
|
from lancedb.functions import (
|
||||||
PythonRuntimeSpec,
|
PythonRuntimeSpec,
|
||||||
|
SecretBinding,
|
||||||
|
SecretReference,
|
||||||
UdfDefinition,
|
UdfDefinition,
|
||||||
_canonical_arrow_type,
|
_canonical_arrow_type,
|
||||||
_GRAMMAR_PRIMITIVES,
|
_GRAMMAR_PRIMITIVES,
|
||||||
udf,
|
udf,
|
||||||
)
|
)
|
||||||
|
from lancedb.secrets import EnvVarSecret
|
||||||
|
|
||||||
THRESHOLD = 20
|
THRESHOLD = 20
|
||||||
_CACHE = None
|
_CACHE = None
|
||||||
@@ -53,6 +57,15 @@ def normalize_score(value: float) -> float:
|
|||||||
return value / 100.0
|
return value / 100.0
|
||||||
|
|
||||||
|
|
||||||
|
@udf(
|
||||||
|
pip=["openai==3.7.0"],
|
||||||
|
env={"MODE": "test"},
|
||||||
|
python_version="3.12",
|
||||||
|
)
|
||||||
|
def analyze_caption(caption: str) -> str:
|
||||||
|
return caption.strip()
|
||||||
|
|
||||||
|
|
||||||
def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
|
def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
|
||||||
assert isinstance(normalize_score, UdfDefinition)
|
assert isinstance(normalize_score, UdfDefinition)
|
||||||
assert normalize_score(25.0) == 0.25
|
assert normalize_score(25.0) == 0.25
|
||||||
@@ -69,6 +82,278 @@ def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_bound_udf_matches_its_shared_registration_golden():
|
||||||
|
assert analyze_caption(" hello ") == "hello"
|
||||||
|
bound = analyze_caption.bind_secrets(
|
||||||
|
[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
bound.to_canonical_json()
|
||||||
|
== (FIXTURES / "remote_function_secret_registration_request.canonical.json")
|
||||||
|
.read_text()
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_namespaced_binding_records_the_path_and_the_name():
|
||||||
|
"""A binding names the parts, so nothing has to be parsed back out.
|
||||||
|
|
||||||
|
A root binding carries no path at all, which is what keeps its wire shape
|
||||||
|
identical to one written before namespaces existed.
|
||||||
|
"""
|
||||||
|
root = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
|
||||||
|
assert root.namespace_path == []
|
||||||
|
|
||||||
|
nested = EnvVarSecret(
|
||||||
|
secret="openai-prod",
|
||||||
|
env_variable="OPENAI_API_KEY",
|
||||||
|
namespace_path=["prod", "vision"],
|
||||||
|
)
|
||||||
|
assert nested.namespace_path == ["prod", "vision"]
|
||||||
|
assert nested != root
|
||||||
|
|
||||||
|
bound = analyze_caption.bind_secrets([nested])
|
||||||
|
assert list(bound.secret_bindings) == [
|
||||||
|
SecretBinding(
|
||||||
|
kind="env",
|
||||||
|
variable="OPENAI_API_KEY",
|
||||||
|
secret_ref=SecretReference(
|
||||||
|
name="openai-prod", namespace_path=("prod", "vision")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
at_root = analyze_caption.bind_secrets([root])
|
||||||
|
assert list(at_root.secret_bindings) == [
|
||||||
|
SecretBinding(
|
||||||
|
kind="env",
|
||||||
|
variable="OPENAI_API_KEY",
|
||||||
|
secret_ref=SecretReference(name="openai-prod"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# A root binding carries no path at all on the wire.
|
||||||
|
canonical = json.loads(at_root.to_canonical_json())
|
||||||
|
assert canonical["secret_bindings"] == [
|
||||||
|
{
|
||||||
|
"kind": "env",
|
||||||
|
"variable": "OPENAI_API_KEY",
|
||||||
|
"secret_ref": {"name": "openai-prod"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_namespace_path_is_validated_locally():
|
||||||
|
# The charset is the service's, not a delimiter's: a reference is never
|
||||||
|
# joined, so a segment cannot make anything parse two ways.
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
EnvVarSecret(
|
||||||
|
secret="openai-prod", env_variable="K", namespace_path=["with$delim"]
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
EnvVarSecret(secret="openai-prod", env_variable="K", namespace_path=["a/b"])
|
||||||
|
# A bare string is a plausible mistake with the wrong meaning.
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
EnvVarSecret(secret="openai-prod", env_variable="K", namespace_path="prod")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unbound_request_carries_no_binding_at_all():
|
||||||
|
"""Binding is a registration-time decision, so the definition holds none.
|
||||||
|
|
||||||
|
The decorator declares nothing about secrets, which is what makes the PRD's
|
||||||
|
claim true: a Function's source and its registration request are identical
|
||||||
|
whether or not a credential is later bound to it.
|
||||||
|
"""
|
||||||
|
unbound = json.loads(analyze_caption.registration_request.to_canonical_json())
|
||||||
|
assert "secret_bindings" not in unbound
|
||||||
|
assert "OPENAI_API_KEY" not in json.dumps(unbound)
|
||||||
|
|
||||||
|
|
||||||
|
def test_binding_a_secret_leaves_the_packaged_artifact_untouched():
|
||||||
|
"""The artifact is source bytes and nothing else, with or without secrets."""
|
||||||
|
bound = analyze_caption.bind_secrets(
|
||||||
|
[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||||
|
)
|
||||||
|
assert bound.artifact == analyze_caption.registration_request.artifact
|
||||||
|
assert bound.artifact.digest == analyze_caption.registration_request.artifact.digest
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_function_declaring_no_secret_is_registered_exactly_as_before():
|
||||||
|
"""The compatibility claim: nothing about the no-secret path moves."""
|
||||||
|
assert (
|
||||||
|
normalize_score.bind_secrets(None).to_canonical_json()
|
||||||
|
== normalize_score.registration_request.to_canonical_json()
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
"secret_bindings"
|
||||||
|
not in normalize_score.registration_request.to_canonical_json()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_function_binds_each_variable_once():
|
||||||
|
with pytest.raises(ValueError, match="binds each environment variable once"):
|
||||||
|
analyze_caption.bind_secrets(
|
||||||
|
[
|
||||||
|
EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY"),
|
||||||
|
EnvVarSecret(secret="openai-staging", env_variable="OPENAI_API_KEY"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bindings_may_not_collide_with_plain_configuration():
|
||||||
|
"""`env` is stored with the Function; a Secret is not. Refuse, do not pick."""
|
||||||
|
with pytest.raises(ValueError, match="must be disjoint"):
|
||||||
|
analyze_caption.bind_secrets(
|
||||||
|
[EnvVarSecret(secret="mode-prod", env_variable="MODE")]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_binding_envelope_reaches_the_service_for_it_to_judge():
|
||||||
|
"""Binding rules are the service's: it owns the runtime the names land in.
|
||||||
|
|
||||||
|
The client sends what it was given, so a rule it duplicated could disagree
|
||||||
|
with the service's without either side noticing. What is checked here is
|
||||||
|
that the envelope arrives intact -- the shape the service judges is the
|
||||||
|
shape the caller wrote.
|
||||||
|
"""
|
||||||
|
with _mock_remote_function_catalog() as (host, state):
|
||||||
|
db = lancedb.connect(
|
||||||
|
"db://dev",
|
||||||
|
api_key="fake",
|
||||||
|
host_override=host,
|
||||||
|
client_config={"retry_config": {"retries": 0}},
|
||||||
|
)
|
||||||
|
bindings = [
|
||||||
|
EnvVarSecret(secret=f"secret-{index}", env_variable=f"TOKEN_{index}")
|
||||||
|
for index in range(17)
|
||||||
|
]
|
||||||
|
db.create_function(normalize_score, secrets=bindings)
|
||||||
|
|
||||||
|
sent = state["requests"][0][1]
|
||||||
|
assert len(sent["secret_bindings"]) == 17
|
||||||
|
assert {
|
||||||
|
"kind": "env",
|
||||||
|
"variable": "TOKEN_0",
|
||||||
|
"secret_ref": {"name": "secret-0"},
|
||||||
|
} in sent["secret_bindings"]
|
||||||
|
|
||||||
|
|
||||||
|
_SECRET_DEBUG_LOG_SOURCE = """
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import lancedb
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||||
|
payload = json.dumps({}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
|
||||||
|
|
||||||
|
server = http.server.ThreadingHTTPServer(("localhost", 0), Handler)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
db = lancedb.connect(
|
||||||
|
"db://dev",
|
||||||
|
api_key="API_KEY_SENTINEL",
|
||||||
|
host_override="http://localhost:%d" % server.server_address[1],
|
||||||
|
client_config={"retry_config": {"retries": 0}},
|
||||||
|
)
|
||||||
|
db.create_secret("openai-prod", "SECRET_VALUE_SENTINEL")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_credential_never_reaches_a_debug_log(tmp_path):
|
||||||
|
"""The logger sees the serialized body, so no value-side redaction reaches it.
|
||||||
|
|
||||||
|
Runs in a subprocess because the Rust logger reads ``LANCEDB_LOG`` once, at
|
||||||
|
import.
|
||||||
|
"""
|
||||||
|
script = tmp_path / "write_secret.py"
|
||||||
|
script.write_text(_SECRET_DEBUG_LOG_SOURCE)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(script)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env={**os.environ, "LANCEDB_LOG": "debug"},
|
||||||
|
)
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
|
# Without this the test passes when debug logging is simply off.
|
||||||
|
assert "Sending request_id=" in output, output
|
||||||
|
assert "SECRET_VALUE_SENTINEL" not in output
|
||||||
|
assert "API_KEY_SENTINEL" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_credential_value_is_rejected_in_the_binding_position():
|
||||||
|
"""The one mistake the typed binding exists to stop."""
|
||||||
|
with pytest.raises(TypeError, match="EnvVarSecret"):
|
||||||
|
analyze_caption.bind_secrets(["sk-live-0001"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("secret", "variable", "message"),
|
||||||
|
[
|
||||||
|
("openai-prod", "not-a-var", "invalid environment variable name"),
|
||||||
|
("openai-prod", "API-TOKEN", "invalid environment variable name"),
|
||||||
|
("not a name", "API_TOKEN", "invalid Secret name"),
|
||||||
|
("openai$prod", "API_TOKEN", "invalid Secret name"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_binding_validates_both_names_locally(secret, variable, message):
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
EnvVarSecret(secret=secret, env_variable=variable)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_secret_name_admits_what_a_namespace_name_does():
|
||||||
|
"""A Secret has to be nameable wherever a namespace already is.
|
||||||
|
|
||||||
|
LanceDB namespace and table names are `[A-Za-z0-9_.-]` with no rule about
|
||||||
|
which character comes first, so a name may lead with `_`, `-` or `.`.
|
||||||
|
Anything narrower here would leave Secrets unaddressable inside namespaces
|
||||||
|
that already exist -- the reason periods are admitted is the reason the
|
||||||
|
edges are too.
|
||||||
|
"""
|
||||||
|
for name in ["openai.prod.v1", ".hidden", "_internal", "-lead", "trailing."]:
|
||||||
|
binding = EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY")
|
||||||
|
assert binding.secret == name
|
||||||
|
|
||||||
|
for name in ["", "with/slash", "with$delimiter", "a" * 256]:
|
||||||
|
with pytest.raises(ValueError, match="invalid Secret name"):
|
||||||
|
EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY")
|
||||||
|
|
||||||
|
# A namespace segment follows the same rule, and LanceDB already admits
|
||||||
|
# these shapes as namespace names -- so a Secret is addressable inside one.
|
||||||
|
for segment in [".hidden", "_internal", "-lead", "trailing."]:
|
||||||
|
binding = EnvVarSecret(
|
||||||
|
secret="openai-prod",
|
||||||
|
env_variable="OPENAI_API_KEY",
|
||||||
|
namespace_path=[segment],
|
||||||
|
)
|
||||||
|
assert binding.namespace_path == [segment]
|
||||||
|
|
||||||
|
for segment in ["", "with/slash", "with$delimiter"]:
|
||||||
|
with pytest.raises(ValueError, match="invalid namespace path segment"):
|
||||||
|
EnvVarSecret(
|
||||||
|
secret="openai-prod",
|
||||||
|
env_variable="OPENAI_API_KEY",
|
||||||
|
namespace_path=[segment],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _main_udf_source(
|
def _main_udf_source(
|
||||||
*, threshold: int = 20, input_annotation: str = "int", comparison: str = ">="
|
*, threshold: int = 20, input_annotation: str = "int", comparison: str = ">="
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -1230,6 +1515,7 @@ def _mock_remote_function_catalog():
|
|||||||
"runtime": body["runtime"],
|
"runtime": body["runtime"],
|
||||||
"runtime_digest": "sha256:runtime",
|
"runtime_digest": "sha256:runtime",
|
||||||
"environment_digest": "sha256:environment",
|
"environment_digest": "sha256:environment",
|
||||||
|
"secret_bindings": body.get("secret_bindings", []),
|
||||||
"created_at": "2026-08-21T00:00:00Z",
|
"created_at": "2026-08-21T00:00:00Z",
|
||||||
}
|
}
|
||||||
response = {"job_id": "job-register"}
|
response = {"job_id": "job-register"}
|
||||||
@@ -1270,6 +1556,21 @@ def _mock_remote_function_catalog():
|
|||||||
"version": "fv_exact",
|
"version": "fv_exact",
|
||||||
}
|
}
|
||||||
response = {"dropped": True}
|
response = {"dropped": True}
|
||||||
|
elif self.path in ("/v1/secrets/create", "/v1/secrets/alter"):
|
||||||
|
assert set(body) == {"name", "value"}
|
||||||
|
response = {}
|
||||||
|
elif self.path == "/v1/secrets/list":
|
||||||
|
if "page_token" not in body:
|
||||||
|
response = {
|
||||||
|
"secrets": [{"name": "openai-prod"}],
|
||||||
|
"page_token": "next",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
assert body["page_token"] == "next"
|
||||||
|
response = {"secrets": [{"name": "hf-prod"}]}
|
||||||
|
elif self.path == "/v1/secrets/drop":
|
||||||
|
assert body == {"name": "openai-prod"}
|
||||||
|
response = {}
|
||||||
else:
|
else:
|
||||||
status = 404
|
status = 404
|
||||||
response = {"error": "not found"}
|
response = {"error": "not found"}
|
||||||
@@ -1312,6 +1613,83 @@ def test_remote_registration_job_and_exact_version_reopen_round_trip():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_registration_sends_bindings_and_never_a_credential():
|
||||||
|
with _mock_remote_function_catalog() as (host, state):
|
||||||
|
db = lancedb.connect(
|
||||||
|
"db://dev",
|
||||||
|
api_key="fake",
|
||||||
|
host_override=host,
|
||||||
|
client_config={"retry_config": {"retries": 0}},
|
||||||
|
)
|
||||||
|
created = db.create_function(
|
||||||
|
analyze_caption,
|
||||||
|
secrets=[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list(created.secret_bindings) == [
|
||||||
|
SecretBinding(
|
||||||
|
kind="env",
|
||||||
|
variable="OPENAI_API_KEY",
|
||||||
|
secret_ref=SecretReference(name="openai-prod"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
path, create_request = state["requests"][0]
|
||||||
|
assert path == "/v1/functions/create"
|
||||||
|
assert create_request["secret_bindings"] == [
|
||||||
|
{
|
||||||
|
"kind": "env",
|
||||||
|
"variable": "OPENAI_API_KEY",
|
||||||
|
"secret_ref": {"name": "openai-prod"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
# The request names a Secret and carries nothing that could be one.
|
||||||
|
assert create_request == json.loads(
|
||||||
|
analyze_caption.bind_secrets(
|
||||||
|
[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||||
|
).to_canonical_json()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_secret_verbs_round_trip():
|
||||||
|
with _mock_remote_function_catalog() as (host, state):
|
||||||
|
db = lancedb.connect(
|
||||||
|
"db://dev",
|
||||||
|
api_key="fake",
|
||||||
|
host_override=host,
|
||||||
|
client_config={"retry_config": {"retries": 0}},
|
||||||
|
)
|
||||||
|
assert db.create_secret("openai-prod", "sk-live-0001") is None
|
||||||
|
assert db.alter_secret("openai-prod", "sk-live-0002") is None
|
||||||
|
assert db.list_secrets() == ["openai-prod", "hf-prod"]
|
||||||
|
assert db.drop_secret("openai-prod") is None
|
||||||
|
|
||||||
|
routes = [path for path, _ in state["requests"]]
|
||||||
|
assert routes == [
|
||||||
|
"/v1/secrets/create",
|
||||||
|
"/v1/secrets/alter",
|
||||||
|
"/v1/secrets/list",
|
||||||
|
"/v1/secrets/list",
|
||||||
|
"/v1/secrets/drop",
|
||||||
|
]
|
||||||
|
assert state["requests"][0][1] == {"name": "openai-prod", "value": "sk-live-0001"}
|
||||||
|
# The listing returns names, and the client has no way to ask for more.
|
||||||
|
assert state["requests"][2][1] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_building_a_binding_contacts_no_server():
|
||||||
|
"""A binding is a local value: it says nothing about whether the Secret exists.
|
||||||
|
|
||||||
|
Existence is the server's answer at registration, where a mistyped name is a
|
||||||
|
clear error rather than a client-side check that was already stale.
|
||||||
|
"""
|
||||||
|
with _mock_remote_function_catalog() as (_host, state):
|
||||||
|
binding = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
|
||||||
|
assert binding.secret == "openai-prod"
|
||||||
|
assert binding.env_variable == "OPENAI_API_KEY"
|
||||||
|
|
||||||
|
assert state["requests"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_blocking_remote_registration_returns_function_version():
|
def test_blocking_remote_registration_returns_function_version():
|
||||||
with _mock_remote_function_catalog() as (host, state):
|
with _mock_remote_function_catalog() as (host, state):
|
||||||
db = lancedb.connect(
|
db = lancedb.connect(
|
||||||
|
|||||||
@@ -704,6 +704,75 @@ impl Connection {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_secret(
|
||||||
|
self_: PyRef<'_, Self>,
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
|
let inner = self_.get_inner()?.clone();
|
||||||
|
future_into_py(self_.py(), async move {
|
||||||
|
inner
|
||||||
|
.create_secret(name, value, &namespace_path)
|
||||||
|
.await
|
||||||
|
.infer_error()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn alter_secret(
|
||||||
|
self_: PyRef<'_, Self>,
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
|
let inner = self_.get_inner()?.clone();
|
||||||
|
future_into_py(self_.py(), async move {
|
||||||
|
inner
|
||||||
|
.alter_secret(name, value, &namespace_path)
|
||||||
|
.await
|
||||||
|
.infer_error()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_secrets(
|
||||||
|
self_: PyRef<'_, Self>,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
|
let inner = self_.get_inner()?.clone();
|
||||||
|
future_into_py(self_.py(), async move {
|
||||||
|
inner.list_secrets(&namespace_path).await.infer_error()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drop_secret(
|
||||||
|
self_: PyRef<'_, Self>,
|
||||||
|
name: String,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
|
let inner = self_.get_inner()?.clone();
|
||||||
|
future_into_py(self_.py(), async move {
|
||||||
|
inner.drop_secret(name, &namespace_path).await.infer_error()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name and timestamps as a plain tuple. `SecretInfo` carries no value, so
|
||||||
|
/// there is none to filter out here. Timestamps stay integers rather than
|
||||||
|
/// going through a string, so the caller can compare two without parsing.
|
||||||
|
pub fn describe_secret(
|
||||||
|
self_: PyRef<'_, Self>,
|
||||||
|
name: String,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
|
let inner = self_.get_inner()?.clone();
|
||||||
|
future_into_py(self_.py(), async move {
|
||||||
|
let info = inner
|
||||||
|
.describe_secret(name, &namespace_path)
|
||||||
|
.await
|
||||||
|
.infer_error()?;
|
||||||
|
Ok((info.name, info.created_at_millis, info.updated_at_millis))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_jobs(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
pub fn list_jobs(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
||||||
let inner = self_.get_inner()?.clone();
|
let inner = self_.get_inner()?.clone();
|
||||||
future_into_py(self_.py(), async move {
|
future_into_py(self_.py(), async move {
|
||||||
|
|||||||
Generated
+98
-95
@@ -10,6 +10,9 @@ resolution-markers = [
|
|||||||
"python_full_version < '3.11'",
|
"python_full_version < '3.11'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[options]
|
||||||
|
prerelease-mode = "allow"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "accelerate"
|
name = "accelerate"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
@@ -799,7 +802,7 @@ name = "cuda-bindings"
|
|||||||
version = "13.3.1"
|
version = "13.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cuda-pathfinder", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
{ name = "cuda-pathfinder" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" },
|
||||||
@@ -834,37 +837,37 @@ wheels = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
cublas = [
|
cublas = [
|
||||||
{ name = "nvidia-cublas", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-cublas" },
|
||||||
]
|
]
|
||||||
cudart = [
|
cudart = [
|
||||||
{ name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-cuda-runtime" },
|
||||||
]
|
]
|
||||||
cufft = [
|
cufft = [
|
||||||
{ name = "nvidia-cufft", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-cufft" },
|
||||||
]
|
]
|
||||||
cufile = [
|
cufile = [
|
||||||
{ name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
|
{ name = "nvidia-cufile" },
|
||||||
]
|
]
|
||||||
cupti = [
|
cupti = [
|
||||||
{ name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-cuda-cupti" },
|
||||||
]
|
]
|
||||||
curand = [
|
curand = [
|
||||||
{ name = "nvidia-curand", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-curand" },
|
||||||
]
|
]
|
||||||
cusolver = [
|
cusolver = [
|
||||||
{ name = "nvidia-cusolver", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-cusolver" },
|
||||||
]
|
]
|
||||||
cusparse = [
|
cusparse = [
|
||||||
{ name = "nvidia-cusparse", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-cusparse" },
|
||||||
]
|
]
|
||||||
nvjitlink = [
|
nvjitlink = [
|
||||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-nvjitlink" },
|
||||||
]
|
]
|
||||||
nvrtc = [
|
nvrtc = [
|
||||||
{ name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-cuda-nvrtc" },
|
||||||
]
|
]
|
||||||
nvtx = [
|
nvtx = [
|
||||||
{ name = "nvidia-nvtx", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or sys_platform == 'linux'" },
|
{ name = "nvidia-nvtx" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1023,7 +1026,7 @@ name = "exceptiongroup"
|
|||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1440,16 +1443,16 @@ resolution-markers = [
|
|||||||
"python_full_version < '3.11'",
|
"python_full_version < '3.11'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cachetools", marker = "python_full_version < '3.11'" },
|
{ name = "cachetools" },
|
||||||
{ name = "certifi", marker = "python_full_version < '3.11'" },
|
{ name = "certifi" },
|
||||||
{ name = "httpx", marker = "python_full_version < '3.11'" },
|
{ name = "httpx" },
|
||||||
{ name = "ibm-cos-sdk", marker = "python_full_version < '3.11'" },
|
{ name = "ibm-cos-sdk" },
|
||||||
{ name = "lomond", marker = "python_full_version < '3.11'" },
|
{ name = "lomond" },
|
||||||
{ name = "packaging", marker = "python_full_version < '3.11'" },
|
{ name = "packaging" },
|
||||||
{ name = "pandas", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "pandas", version = "2.2.3", source = { registry = "https://pypi.org/simple" } },
|
||||||
{ name = "requests", marker = "python_full_version < '3.11'" },
|
{ name = "requests" },
|
||||||
{ name = "tabulate", marker = "python_full_version < '3.11'" },
|
{ name = "tabulate" },
|
||||||
{ name = "urllib3", marker = "python_full_version < '3.11'" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/56/2e3df38a1f13062095d7bde23c87a92f3898982993a15186b1bfecbd206f/ibm_watsonx_ai-1.3.42.tar.gz", hash = "sha256:ee5be59009004245d957ce97d1227355516df95a2640189749487614fef674ff", size = 688651, upload-time = "2025-10-01T13:35:41.527Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/56/2e3df38a1f13062095d7bde23c87a92f3898982993a15186b1bfecbd206f/ibm_watsonx_ai-1.3.42.tar.gz", hash = "sha256:ee5be59009004245d957ce97d1227355516df95a2640189749487614fef674ff", size = 688651, upload-time = "2025-10-01T13:35:41.527Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1468,17 +1471,17 @@ resolution-markers = [
|
|||||||
"python_full_version == '3.11.*'",
|
"python_full_version == '3.11.*'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cachetools", marker = "python_full_version >= '3.11'" },
|
{ name = "cachetools" },
|
||||||
{ name = "certifi", marker = "python_full_version >= '3.11'" },
|
{ name = "certifi" },
|
||||||
{ name = "httpx", marker = "python_full_version >= '3.11'" },
|
{ name = "httpx" },
|
||||||
{ name = "ibm-cos-sdk", marker = "python_full_version >= '3.11'" },
|
{ name = "ibm-cos-sdk" },
|
||||||
{ name = "lomond", marker = "python_full_version >= '3.11'" },
|
{ name = "lomond" },
|
||||||
{ name = "packaging", marker = "python_full_version >= '3.11'" },
|
{ name = "packaging" },
|
||||||
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
|
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
|
||||||
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
|
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
|
||||||
{ name = "requests", marker = "python_full_version >= '3.11'" },
|
{ name = "requests" },
|
||||||
{ name = "tabulate", marker = "python_full_version >= '3.11'" },
|
{ name = "tabulate" },
|
||||||
{ name = "urllib3", marker = "python_full_version >= '3.11'" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/29/a3/c756b534696ab2f3f29882fdb7ca7198b7a5c94e10c0a3a327853d6d6b79/ibm_watsonx_ai-1.5.14.tar.gz", hash = "sha256:a756488bd57e87c0fc51be42dcba871143cfe0ac1e805c497c5047e1e4f13e9d", size = 735804, upload-time = "2026-06-22T12:32:43.85Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/29/a3/c756b534696ab2f3f29882fdb7ca7198b7a5c94e10c0a3a327853d6d6b79/ibm_watsonx_ai-1.5.14.tar.gz", hash = "sha256:a756488bd57e87c0fc51be42dcba871143cfe0ac1e805c497c5047e1e4f13e9d", size = 735804, upload-time = "2026-06-22T12:32:43.85Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1554,17 +1557,17 @@ resolution-markers = [
|
|||||||
"python_full_version < '3.11'",
|
"python_full_version < '3.11'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "decorator", marker = "python_full_version < '3.11'" },
|
{ name = "decorator" },
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
{ name = "exceptiongroup" },
|
||||||
{ name = "jedi", marker = "python_full_version < '3.11'" },
|
{ name = "jedi" },
|
||||||
{ name = "matplotlib-inline", marker = "python_full_version < '3.11'" },
|
{ name = "matplotlib-inline" },
|
||||||
{ name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
{ name = "prompt-toolkit", marker = "python_full_version < '3.11'" },
|
{ name = "prompt-toolkit" },
|
||||||
{ name = "pygments", marker = "python_full_version < '3.11'" },
|
{ name = "pygments" },
|
||||||
{ name = "stack-data", marker = "python_full_version < '3.11'" },
|
{ name = "stack-data" },
|
||||||
{ name = "traitlets", marker = "python_full_version < '3.11'" },
|
{ name = "traitlets" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1583,18 +1586,18 @@ resolution-markers = [
|
|||||||
"python_full_version == '3.11.*'",
|
"python_full_version == '3.11.*'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "decorator", marker = "python_full_version >= '3.11'" },
|
{ name = "decorator" },
|
||||||
{ name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" },
|
{ name = "ipython-pygments-lexers" },
|
||||||
{ name = "jedi", marker = "python_full_version >= '3.11'" },
|
{ name = "jedi" },
|
||||||
{ name = "matplotlib-inline", marker = "python_full_version >= '3.11'" },
|
{ name = "matplotlib-inline" },
|
||||||
{ name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
{ name = "prompt-toolkit", marker = "python_full_version >= '3.11'" },
|
{ name = "prompt-toolkit" },
|
||||||
{ name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
|
{ name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
|
||||||
{ name = "pygments", marker = "python_full_version >= '3.11'" },
|
{ name = "pygments" },
|
||||||
{ name = "stack-data", marker = "python_full_version >= '3.11'" },
|
{ name = "stack-data" },
|
||||||
{ name = "traitlets", marker = "python_full_version >= '3.11'" },
|
{ name = "traitlets" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1606,7 +1609,7 @@ name = "ipython-pygments-lexers"
|
|||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pygments", marker = "python_full_version >= '3.11'" },
|
{ name = "pygments" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -2858,7 +2861,7 @@ name = "nvidia-cudnn-cu13"
|
|||||||
version = "9.19.0.56"
|
version = "9.19.0.56"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
{ name = "nvidia-cublas" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" },
|
||||||
@@ -2870,7 +2873,7 @@ name = "nvidia-cufft"
|
|||||||
version = "12.0.0.61"
|
version = "12.0.0.61"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
{ name = "nvidia-nvjitlink" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
||||||
@@ -2900,9 +2903,9 @@ name = "nvidia-cusolver"
|
|||||||
version = "12.0.4.66"
|
version = "12.0.4.66"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-cublas", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
{ name = "nvidia-cublas" },
|
||||||
{ name = "nvidia-cusparse", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
{ name = "nvidia-cusparse" },
|
||||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
{ name = "nvidia-nvjitlink" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
||||||
@@ -2914,7 +2917,7 @@ name = "nvidia-cusparse"
|
|||||||
version = "12.6.3.3"
|
version = "12.6.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
{ name = "nvidia-nvjitlink" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
||||||
@@ -3091,10 +3094,10 @@ resolution-markers = [
|
|||||||
"python_full_version < '3.11'",
|
"python_full_version < '3.11'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||||
{ name = "python-dateutil", marker = "python_full_version < '3.11'" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "pytz", marker = "python_full_version < '3.11'" },
|
{ name = "pytz" },
|
||||||
{ name = "tzdata", marker = "python_full_version < '3.11'" },
|
{ name = "tzdata" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -3143,11 +3146,11 @@ resolution-markers = [
|
|||||||
"python_full_version == '3.11.*'",
|
"python_full_version == '3.11.*'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14'" },
|
||||||
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" },
|
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" },
|
||||||
{ name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "pytz", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
|
{ name = "pytz" },
|
||||||
{ name = "tzdata", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
|
{ name = "tzdata" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -3210,9 +3213,9 @@ resolution-markers = [
|
|||||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
|
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } },
|
||||||
{ name = "python-dateutil", marker = "python_full_version >= '3.14'" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "tzdata", marker = "(python_full_version >= '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.14' and sys_platform == 'win32')" },
|
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -3320,7 +3323,7 @@ name = "pexpect"
|
|||||||
version = "4.9.0"
|
version = "4.9.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "ptyprocess", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
{ name = "ptyprocess" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -3912,8 +3915,8 @@ crypto = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pylance"
|
name = "pylance"
|
||||||
version = "7.0.0"
|
version = "9.0.0rc1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.fury.io/lance-format/" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "lance-namespace" },
|
{ name = "lance-namespace" },
|
||||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
@@ -3922,12 +3925,12 @@ dependencies = [
|
|||||||
{ name = "pyarrow" },
|
{ name = "pyarrow" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/2f64921bf346e7075aef24a72595db44821724a3d89a9a92dd24e79632aa/pylance-7.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:98422021975be76e72b1572f41b8c9abb3bee5bdc9bfa5e9ce731110a65ed4d1", size = 62134146, upload-time = "2026-05-27T21:59:37.459Z" },
|
{ url = "https://pypi.fury.io/lance-format/-/ver_vEHBE/pylance-9.0.0rc1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0b6b02a1808bb3072ee7fe4e36614cae6f86302513e73ec7f55b2234a963b24" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/1c/c5a01bee0160b55d9a98895cbd33091d038f0a0995b121ab72e629008d02/pylance-7.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bec86ee5b6fbd8bfc493e653f0a1fba0303cfe5492b9b46fc25ab908edc7183", size = 65373684, upload-time = "2026-05-27T22:04:01.584Z" },
|
{ url = "https://pypi.fury.io/lance-format/-/ver_1Jipm4/pylance-9.0.0rc1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30f0ebf0d88034301819eb964f9236ce555aaa58e7ab89c5975a3e2250bbb405" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/da/1fe8b8f7dbfe734d76af76acc994fc360a0d0c79a4874ef69f5a72a58fe3/pylance-7.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881491432c53184e52f8d1db8d5f872f39a03f36fb104bec77b33d379519d8b5", size = 69458555, upload-time = "2026-05-27T22:16:50.567Z" },
|
{ url = "https://pypi.fury.io/lance-format/-/ver_IvKxo/pylance-9.0.0rc1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44609ea2615ea6e684b85478d1694af2026458f61cf7895ecc75e238bfd17aa8" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/f0/dd505cf3fd0226ab9d94759acd713125af1d3bfacfd80bbd52e3b9f89509/pylance-7.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:18453999e7fff4f76b16d6b7882c9df0628bd142ff95e2461bd7dd5ee3fe0af3", size = 65394430, upload-time = "2026-05-27T22:05:30.923Z" },
|
{ url = "https://pypi.fury.io/lance-format/-/ver_2hidj1/pylance-9.0.0rc1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:182167a8dba9eeabffbffd53bd5b8548613d4d459b7cd7b34a840dd00cbb806f" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/ba/2357b81034f28eb00790e258ed140289a6a887a7468ca9df6349fd186b27/pylance-7.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:04a58051d408c60fe76d41a220dcaf8fea8fb6d1aa0ca78a709b60bc3cc8d19a", size = 69473470, upload-time = "2026-05-27T22:17:18.935Z" },
|
{ url = "https://pypi.fury.io/lance-format/-/ver_1dFx3r/pylance-9.0.0rc1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a63b11e814b7eab758bcaf0d6f97eb05ea86203d9fb0af718c462c24c7d6c9c" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/ec/5c00b6303a67d787f9475141832cbdc513d674ac3dcaeef8a7b169905e65/pylance-7.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:467d4864af047eaab4e1370e2f1e88e2c6f507c079874421116cb41d78bc3629", size = 74792863, upload-time = "2026-05-27T22:19:23.875Z" },
|
{ url = "https://pypi.fury.io/lance-format/-/ver_2a8dSh/pylance-9.0.0rc1-cp310-abi3-win_amd64.whl", hash = "sha256:2ff8b953ae2b0550490c1a7efd210aa91bc223d200ffac28849056cfd7436d97" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4683,10 +4686,10 @@ resolution-markers = [
|
|||||||
"python_full_version < '3.11'",
|
"python_full_version < '3.11'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "joblib", marker = "python_full_version < '3.11'" },
|
{ name = "joblib" },
|
||||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||||
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } },
|
||||||
{ name = "threadpoolctl", marker = "python_full_version < '3.11'" },
|
{ name = "threadpoolctl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -4734,13 +4737,13 @@ resolution-markers = [
|
|||||||
"python_full_version == '3.11.*'",
|
"python_full_version == '3.11.*'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "joblib", marker = "python_full_version >= '3.11'" },
|
{ name = "joblib" },
|
||||||
{ name = "narwhals", marker = "python_full_version >= '3.11'" },
|
{ name = "narwhals" },
|
||||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||||
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
|
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||||
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||||
{ name = "threadpoolctl", marker = "python_full_version >= '3.11'" },
|
{ name = "threadpoolctl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -4784,7 +4787,7 @@ resolution-markers = [
|
|||||||
"python_full_version < '3.11'",
|
"python_full_version < '3.11'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -4843,7 +4846,7 @@ resolution-markers = [
|
|||||||
"python_full_version == '3.11.*'",
|
"python_full_version == '3.11.*'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -4920,7 +4923,7 @@ resolution-markers = [
|
|||||||
"python_full_version >= '3.12' and python_full_version < '3.14'",
|
"python_full_version >= '3.12' and python_full_version < '3.14'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" } },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ use crate::remote::{
|
|||||||
OPT_REMOTE_SQL_HOST_OVERRIDE,
|
OPT_REMOTE_SQL_HOST_OVERRIDE,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use crate::secrets::SecretInfo;
|
||||||
use lance::io::ObjectStoreParams;
|
use lance::io::ObjectStoreParams;
|
||||||
pub use lance_file::version::LanceFileVersion;
|
pub use lance_file::version::LanceFileVersion;
|
||||||
#[cfg(feature = "remote")]
|
#[cfg(feature = "remote")]
|
||||||
@@ -586,6 +587,7 @@ impl Connection {
|
|||||||
/// Registration is remote-only and always asynchronous. Waiting on the
|
/// Registration is remote-only and always asynchronous. Waiting on the
|
||||||
/// returned typed job yields the durable [`crate::function::FunctionVersion`].
|
/// returned typed job yields the durable [`crate::function::FunctionVersion`].
|
||||||
/// Local databases return [`Error::NotSupported`].
|
/// Local databases return [`Error::NotSupported`].
|
||||||
|
///
|
||||||
pub async fn create_function_async(
|
pub async fn create_function_async(
|
||||||
&self,
|
&self,
|
||||||
request: crate::function::FunctionRegistrationRequest,
|
request: crate::function::FunctionRegistrationRequest,
|
||||||
@@ -645,6 +647,81 @@ impl Connection {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a named Secret in this database.
|
||||||
|
///
|
||||||
|
/// Fails if the name is taken, so a create can never silently become a
|
||||||
|
/// rotation. There is no API that reads a stored credential back; the only
|
||||||
|
/// consumer is a Function that binds the Secret by name. Local databases
|
||||||
|
/// return [`Error::NotSupported`].
|
||||||
|
pub async fn create_secret(
|
||||||
|
&self,
|
||||||
|
name: impl AsRef<str>,
|
||||||
|
value: impl AsRef<str>,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.internal
|
||||||
|
.create_secret(name.as_ref(), value.as_ref(), namespace_path)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the credential behind an existing Secret.
|
||||||
|
///
|
||||||
|
/// Fails if it does not exist. Every Function bound to the Secret resolves
|
||||||
|
/// the new value from its next execution, and no new Function version is
|
||||||
|
/// minted -- which is what lets a rotation reach columns pinned to a
|
||||||
|
/// version registered before it. Local databases return
|
||||||
|
/// [`Error::NotSupported`].
|
||||||
|
pub async fn alter_secret(
|
||||||
|
&self,
|
||||||
|
name: impl AsRef<str>,
|
||||||
|
value: impl AsRef<str>,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.internal
|
||||||
|
.alter_secret(name.as_ref(), value.as_ref(), namespace_path)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The names of every Secret in this database.
|
||||||
|
///
|
||||||
|
/// Names only. No path in this API returns a stored credential, by
|
||||||
|
/// construction rather than by policy. Local databases return
|
||||||
|
/// [`Error::NotSupported`].
|
||||||
|
pub async fn list_secrets(&self, namespace_path: &[String]) -> Result<Vec<String>> {
|
||||||
|
self.internal.list_secrets(namespace_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a Secret.
|
||||||
|
///
|
||||||
|
/// Functions bound to it fail at their next job, naming the Secret; that
|
||||||
|
/// is the revocation path. The name becomes free to reuse, and a new
|
||||||
|
/// Secret under it is picked up by everything still bound to that name.
|
||||||
|
/// Local databases return [`Error::NotSupported`].
|
||||||
|
pub async fn drop_secret(
|
||||||
|
&self,
|
||||||
|
name: impl AsRef<str>,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.internal
|
||||||
|
.drop_secret(name.as_ref(), namespace_path)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this database records about one Secret: its name and timestamps.
|
||||||
|
///
|
||||||
|
/// Never the value. The type it returns has no field for one, so this is a
|
||||||
|
/// property of the API rather than of what the caller chooses to read.
|
||||||
|
/// Local databases return [`Error::NotSupported`].
|
||||||
|
pub async fn describe_secret(
|
||||||
|
&self,
|
||||||
|
name: impl AsRef<str>,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<SecretInfo> {
|
||||||
|
self.internal
|
||||||
|
.describe_secret(name.as_ref(), namespace_path)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Rename a table in the database.
|
/// Rename a table in the database.
|
||||||
///
|
///
|
||||||
/// This is only supported in LanceDB Cloud.
|
/// This is only supported in LanceDB Cloud.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use lance_namespace::models::{
|
|||||||
|
|
||||||
use crate::data::scannable::Scannable;
|
use crate::data::scannable::Scannable;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use crate::secrets::SecretInfo;
|
||||||
use crate::table::{BaseTable, WriteOptions};
|
use crate::table::{BaseTable, WriteOptions};
|
||||||
|
|
||||||
pub mod listing;
|
pub mod listing;
|
||||||
@@ -249,6 +250,12 @@ fn function_catalog_not_supported<T>() -> Result<T> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn secret_catalog_not_supported<T>() -> Result<T> {
|
||||||
|
Err(crate::error::Error::NotSupported {
|
||||||
|
message: "Secret operations are not supported by this database".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The `Database` trait defines the interface for database implementations.
|
/// The `Database` trait defines the interface for database implementations.
|
||||||
///
|
///
|
||||||
/// A database is responsible for managing tables and their metadata.
|
/// A database is responsible for managing tables and their metadata.
|
||||||
@@ -317,6 +324,44 @@ pub trait Database:
|
|||||||
async fn drop_function(&self, _name: &str, _version: &str) -> Result<bool> {
|
async fn drop_function(&self, _name: &str, _version: &str) -> Result<bool> {
|
||||||
function_catalog_not_supported()
|
function_catalog_not_supported()
|
||||||
}
|
}
|
||||||
|
/// Create a named Secret in this database. Fails if the name is taken, so
|
||||||
|
/// a create can never silently become a rotation.
|
||||||
|
async fn create_secret(
|
||||||
|
&self,
|
||||||
|
_name: &str,
|
||||||
|
_value: &str,
|
||||||
|
_namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
|
secret_catalog_not_supported()
|
||||||
|
}
|
||||||
|
/// Replace the credential behind an existing Secret. Fails if it does not
|
||||||
|
/// exist. Every Function bound to it resolves the new value from its next
|
||||||
|
/// execution, with no new Function version.
|
||||||
|
async fn alter_secret(
|
||||||
|
&self,
|
||||||
|
_name: &str,
|
||||||
|
_value: &str,
|
||||||
|
_namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
|
secret_catalog_not_supported()
|
||||||
|
}
|
||||||
|
/// The names of every Secret in this database.
|
||||||
|
///
|
||||||
|
/// Names only. No API path returns a stored credential, by construction
|
||||||
|
/// rather than by policy.
|
||||||
|
async fn list_secrets(&self, _namespace_path: &[String]) -> Result<Vec<String>> {
|
||||||
|
secret_catalog_not_supported()
|
||||||
|
}
|
||||||
|
/// Drop a Secret. Functions bound to it fail at their next job, which is
|
||||||
|
/// the revocation path.
|
||||||
|
async fn drop_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<()> {
|
||||||
|
secret_catalog_not_supported()
|
||||||
|
}
|
||||||
|
/// What the database records about one Secret: its name and timestamps,
|
||||||
|
/// never its value.
|
||||||
|
async fn describe_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<SecretInfo> {
|
||||||
|
secret_catalog_not_supported()
|
||||||
|
}
|
||||||
/// Open a job by id, returning a handle with its record already
|
/// Open a job by id, returning a handle with its record already
|
||||||
/// populated. Fails with [`crate::Error::JobNotFound`] when the server has
|
/// populated. Fails with [`crate::Error::JobNotFound`] when the server has
|
||||||
/// no such job.
|
/// no such job.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! backend-neutral terminal result of a computed-column refresh.
|
//! backend-neutral terminal result of a computed-column refresh.
|
||||||
//!
|
//!
|
||||||
//! This module contains client/wire values only. Catalog persistence,
|
//! This module contains client/wire values only. Catalog persistence,
|
||||||
//! environment bake, and execution are owned by Sophon.
|
//! environment bake, secret resolution, and execution are owned by Sophon.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -409,6 +409,8 @@ pub struct FunctionVersion {
|
|||||||
runtime: PythonRuntimeSpec,
|
runtime: PythonRuntimeSpec,
|
||||||
runtime_digest: String,
|
runtime_digest: String,
|
||||||
environment_digest: String,
|
environment_digest: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
secret_bindings: Vec<SecretBinding>,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +443,16 @@ impl FunctionVersion {
|
|||||||
&self.environment_digest
|
&self.environment_digest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Declared environment variable name to the Secret each one resolves.
|
||||||
|
///
|
||||||
|
/// Bindings are part of this version's identity; the credentials behind
|
||||||
|
/// them are not, and resolve at execution. Rotating a bound Secret
|
||||||
|
/// therefore changes what the same version runs with, and no value has a
|
||||||
|
/// field in this model.
|
||||||
|
pub fn secret_bindings(&self) -> &[SecretBinding] {
|
||||||
|
&self.secret_bindings
|
||||||
|
}
|
||||||
|
|
||||||
pub fn created_at(&self) -> &str {
|
pub fn created_at(&self) -> &str {
|
||||||
&self.created_at
|
&self.created_at
|
||||||
}
|
}
|
||||||
@@ -481,13 +493,167 @@ pub struct FunctionArtifactRequest {
|
|||||||
pub adapter: PythonAdapterSpec,
|
pub adapter: PythonAdapterSpec,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 costs one
|
||||||
|
/// object and settles all of that: nothing here is parsed, so nothing can parse
|
||||||
|
/// two ways.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
pub struct SecretReference {
|
||||||
|
pub name: String,
|
||||||
|
/// The namespace holding the Secret. Empty is the root, and is omitted from
|
||||||
|
/// the wire so a root binding carries no trace of a feature it does not use.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub namespace_path: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretReference {
|
||||||
|
/// A Secret in the root namespace.
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
namespace_path: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Secret in `namespace_path`.
|
||||||
|
pub fn in_namespace(name: impl Into<String>, namespace_path: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
namespace_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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. A mode added later is a variant
|
||||||
|
/// here, and the rules that are per-Function -- how many Secrets a Function may
|
||||||
|
/// bind, which ones it needs -- stay answerable from one place.
|
||||||
|
///
|
||||||
|
/// Unknown kinds decode rather than failing the whole FunctionVersion, as
|
||||||
|
/// [`PythonRuntimeSpec`] does for runtimes. The payload is intentionally not
|
||||||
|
/// retained: the client does not proxy catalog values.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum SecretBinding {
|
||||||
|
/// Delivered as an environment variable, which the UDF's library already
|
||||||
|
/// reads. The variable is the delivery target; the Secret is what fills it.
|
||||||
|
Env {
|
||||||
|
variable: String,
|
||||||
|
/// Named `secret_ref` rather than `secret` because a Job payload is
|
||||||
|
/// scanned server-side for credential-shaped keys, and a key called
|
||||||
|
/// `secret` trips that guard whatever it actually holds.
|
||||||
|
secret_ref: SecretReference,
|
||||||
|
},
|
||||||
|
/// A binding kind introduced by a newer server.
|
||||||
|
Unrecognized { kind: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretBinding {
|
||||||
|
/// The wire discriminator reported by Sophon.
|
||||||
|
pub fn kind(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Env { .. } => "env",
|
||||||
|
Self::Unrecognized { kind } => kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The environment variable this binding fills, or `None` for a kind that
|
||||||
|
/// does not deliver through one.
|
||||||
|
pub fn variable(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::Env { variable, .. } => Some(variable),
|
||||||
|
Self::Unrecognized { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Secret bound, or `None` for a kind this client cannot read.
|
||||||
|
pub fn secret(&self) -> Option<&SecretReference> {
|
||||||
|
match self {
|
||||||
|
Self::Env { secret_ref, .. } => Some(secret_ref),
|
||||||
|
Self::Unrecognized { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EnvSecretBindingWire {
|
||||||
|
variable: String,
|
||||||
|
secret_ref: SecretReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SecretBinding {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||||
|
let value = Value::deserialize(deserializer)?;
|
||||||
|
let kind = value
|
||||||
|
.get("kind")
|
||||||
|
.ok_or_else(|| de::Error::missing_field("kind"))?
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| de::Error::custom("secret binding kind must be a string"))?
|
||||||
|
.to_string();
|
||||||
|
match kind.as_str() {
|
||||||
|
"env" => {
|
||||||
|
let wire: EnvSecretBindingWire =
|
||||||
|
serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||||
|
Ok(Self::Env {
|
||||||
|
variable: wire.variable,
|
||||||
|
secret_ref: wire.secret_ref,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Ok(Self::Unrecognized { kind }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for SecretBinding {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct EnvBindingRef<'a> {
|
||||||
|
kind: &'static str,
|
||||||
|
variable: &'a str,
|
||||||
|
secret_ref: &'a SecretReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UnrecognizedBindingRef<'a> {
|
||||||
|
kind: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Self::Env {
|
||||||
|
variable,
|
||||||
|
secret_ref,
|
||||||
|
} => EnvBindingRef {
|
||||||
|
kind: "env",
|
||||||
|
variable,
|
||||||
|
secret_ref,
|
||||||
|
}
|
||||||
|
.serialize(serializer),
|
||||||
|
Self::Unrecognized { kind } => UnrecognizedBindingRef { kind }.serialize(serializer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable request envelope for remote immutable Function registration.
|
/// Stable request envelope for remote immutable Function registration.
|
||||||
|
///
|
||||||
|
/// 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 Sophon resolves inside the remote runtime.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct FunctionRegistrationRequest {
|
pub struct FunctionRegistrationRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub artifact: FunctionArtifactRequest,
|
pub artifact: FunctionArtifactRequest,
|
||||||
pub signature: FunctionSignature,
|
pub signature: FunctionSignature,
|
||||||
pub runtime: PythonRuntimeSpec,
|
pub runtime: PythonRuntimeSpec,
|
||||||
|
/// Declared environment variable name to the Secret it binds. A binding is
|
||||||
|
/// a reference: whether the Secret exists is answered when a column is
|
||||||
|
/// declared against this version, not here.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub secret_bindings: Vec<SecretBinding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_json!(FunctionRegistrationRequest);
|
impl_json!(FunctionRegistrationRequest);
|
||||||
@@ -749,3 +915,80 @@ mod conda_environment_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Canonical form is what the FunctionVersion hash is taken over, so key
|
||||||
|
/// order must come from the keys and not from however serde happened to
|
||||||
|
/// emit them. Nesting is included because the sort is recursive.
|
||||||
|
#[test]
|
||||||
|
fn canonical_json_sorts_keys_at_every_depth() {
|
||||||
|
let value = serde_json::json!({
|
||||||
|
"runtime": {"kind": "python", "env": {"B": "2", "A": "1"}},
|
||||||
|
"artifact": {"digest": "sha256:x"},
|
||||||
|
"name": "embed",
|
||||||
|
});
|
||||||
|
let mut out = String::new();
|
||||||
|
write_canonical_json(&value, &mut out).expect("canonical JSON");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
r#"{"artifact":{"digest":"sha256:x"},"name":"embed","runtime":{"env":{"A":"1","B":"2"},"kind":"python"}}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrays are ordered by the caller, so canonicalization must leave them
|
||||||
|
/// alone -- sorting them would change what a signature means.
|
||||||
|
#[test]
|
||||||
|
fn canonical_json_preserves_array_order() {
|
||||||
|
let value = serde_json::json!({"inputs": ["b", "a", "c"]});
|
||||||
|
let mut out = String::new();
|
||||||
|
write_canonical_json(&value, &mut out).expect("canonical JSON");
|
||||||
|
|
||||||
|
assert_eq!(out, r#"{"inputs":["b","a","c"]}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A float has no single canonical spelling, so two clients could hash the
|
||||||
|
/// same literal differently. Rejected at any depth rather than rounded.
|
||||||
|
#[test]
|
||||||
|
fn validate_literal_rejects_floats_at_any_depth() {
|
||||||
|
for value in [
|
||||||
|
serde_json::json!(1.5),
|
||||||
|
serde_json::json!([1, [2, 3.5]]),
|
||||||
|
serde_json::json!({"a": {"b": 0.25}}),
|
||||||
|
] {
|
||||||
|
let error = validate_literal(&value).expect_err("floats are not canonical");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("floating-point"),
|
||||||
|
"unexpected error: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for value in [
|
||||||
|
serde_json::json!(1),
|
||||||
|
serde_json::json!("1.5"),
|
||||||
|
serde_json::json!([1, {"a": true}]),
|
||||||
|
serde_json::json!(null),
|
||||||
|
] {
|
||||||
|
validate_literal(&value).expect("non-float literals are canonical");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unknown keys are how a newer server's payload reaches an older client,
|
||||||
|
/// so the check has to be exact about which level it is looking at.
|
||||||
|
#[test]
|
||||||
|
fn has_unknown_keys_only_inspects_the_level_it_is_given() {
|
||||||
|
let value = serde_json::json!({"name": "embed", "version": "fv_1"});
|
||||||
|
assert!(!has_unknown_keys(&value, &["name", "version"]));
|
||||||
|
assert!(has_unknown_keys(&value, &["name"]));
|
||||||
|
|
||||||
|
// A nested unknown is not this level's business.
|
||||||
|
let nested = serde_json::json!({"name": {"unexpected": 1}});
|
||||||
|
assert!(!has_unknown_keys(&nested, &["name"]));
|
||||||
|
|
||||||
|
// A non-object has no keys to be unknown.
|
||||||
|
assert!(!has_unknown_keys(&serde_json::json!("embed"), &["name"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ pub mod query;
|
|||||||
#[cfg(feature = "remote")]
|
#[cfg(feature = "remote")]
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod rerankers;
|
pub mod rerankers;
|
||||||
|
pub mod secrets;
|
||||||
pub mod sql;
|
pub mod sql;
|
||||||
pub mod table;
|
pub mod table;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -404,6 +404,20 @@ fn validate_dns_hostname(hostname: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a request's body may appear in a debug log.
|
||||||
|
///
|
||||||
|
/// The API that built the body decides. The transport cannot know which
|
||||||
|
/// payloads are credentials, and a list of routes here would have to be kept in
|
||||||
|
/// step with endpoints defined elsewhere -- so the knowledge lives with the
|
||||||
|
/// call that has it.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum BodyLogging {
|
||||||
|
/// Log the body at debug, as every request did before Secrets existed.
|
||||||
|
Allowed,
|
||||||
|
/// Never log the body. For a request whose body is a credential.
|
||||||
|
Suppressed,
|
||||||
|
}
|
||||||
|
|
||||||
impl RestfulLanceDbClient<Sender> {
|
impl RestfulLanceDbClient<Sender> {
|
||||||
fn get_timeout(passed: Option<Duration>, env_var: &str) -> Result<Option<Duration>> {
|
fn get_timeout(passed: Option<Duration>, env_var: &str) -> Result<Option<Duration>> {
|
||||||
if let Some(passed) = passed {
|
if let Some(passed) = passed {
|
||||||
@@ -610,12 +624,14 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
|
|||||||
) -> Result<HeaderMap> {
|
) -> Result<HeaderMap> {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
if !api_key.is_empty() {
|
if !api_key.is_empty() {
|
||||||
headers.insert(
|
// `log_request` prints the request's Debug, which prints headers.
|
||||||
HeaderName::from_static("x-api-key"),
|
// Marking the value sensitive is what makes that print `Sensitive`
|
||||||
HeaderValue::from_str(api_key).map_err(|_| Error::InvalidInput {
|
// instead of the key itself.
|
||||||
message: "non-ascii api key provided".to_string(),
|
let mut key = HeaderValue::from_str(api_key).map_err(|_| Error::InvalidInput {
|
||||||
})?,
|
message: "non-ascii api key provided".to_string(),
|
||||||
);
|
})?;
|
||||||
|
key.set_sensitive(true);
|
||||||
|
headers.insert(HeaderName::from_static("x-api-key"), key);
|
||||||
}
|
}
|
||||||
if region == "local" {
|
if region == "local" {
|
||||||
let host = format!("{}.local.api.lancedb.com", db_name);
|
let host = format!("{}.local.api.lancedb.com", db_name);
|
||||||
@@ -725,6 +741,22 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send(&self, req: RequestBuilder) -> Result<(String, Response)> {
|
pub async fn send(&self, req: RequestBuilder) -> Result<(String, Response)> {
|
||||||
|
self.send_logging(req, BodyLogging::Allowed).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a request whose body must never reach a debug log.
|
||||||
|
///
|
||||||
|
/// The body is built by the caller, so only the caller knows it holds a
|
||||||
|
/// credential; `log_request` sees serialized bytes and cannot tell.
|
||||||
|
pub async fn send_suppressing_body(&self, req: RequestBuilder) -> Result<(String, Response)> {
|
||||||
|
self.send_logging(req, BodyLogging::Suppressed).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_logging(
|
||||||
|
&self,
|
||||||
|
req: RequestBuilder,
|
||||||
|
body_logging: BodyLogging,
|
||||||
|
) -> Result<(String, Response)> {
|
||||||
let (client, request) = req.build_split();
|
let (client, request) = req.build_split();
|
||||||
let mut request = request.unwrap();
|
let mut request = request.unwrap();
|
||||||
let request_id = self.extract_request_id(&mut request);
|
let request_id = self.extract_request_id(&mut request);
|
||||||
@@ -732,7 +764,7 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
|
|||||||
// Apply dynamic headers before sending
|
// Apply dynamic headers before sending
|
||||||
request = self.apply_dynamic_headers(request).await?;
|
request = self.apply_dynamic_headers(request).await?;
|
||||||
|
|
||||||
self.log_request(&request, &request_id);
|
self.log_request(&request, &request_id, body_logging);
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.sender
|
.sender
|
||||||
@@ -795,7 +827,7 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
|
|||||||
// Apply dynamic headers before each retry attempt
|
// Apply dynamic headers before each retry attempt
|
||||||
request = self.apply_dynamic_headers(request).await?;
|
request = self.apply_dynamic_headers(request).await?;
|
||||||
|
|
||||||
self.log_request(&request, &request_id);
|
self.log_request(&request, &request_id, BodyLogging::Allowed);
|
||||||
|
|
||||||
let response = self.sender.send(&c, request).await.map(|r| (r.status(), r));
|
let response = self.sender.send(&c, request).await.map(|r| (r.status(), r));
|
||||||
|
|
||||||
@@ -839,13 +871,18 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn log_request(&self, request: &Request, request_id: &String) {
|
fn log_request(&self, request: &Request, request_id: &String, body_logging: BodyLogging) {
|
||||||
if log::log_enabled!(log::Level::Debug) {
|
if log::log_enabled!(log::Level::Debug) {
|
||||||
let content_type = request
|
let content_type = request
|
||||||
.headers()
|
.headers()
|
||||||
.get("content-type")
|
.get("content-type")
|
||||||
.map(|v| v.to_str().unwrap());
|
.map(|v| v.to_str().unwrap());
|
||||||
if content_type == Some("application/json") {
|
if body_logging == BodyLogging::Suppressed {
|
||||||
|
debug!(
|
||||||
|
"Sending request_id={}: {:?} with body suppressed",
|
||||||
|
request_id, request
|
||||||
|
);
|
||||||
|
} else if content_type == Some("application/json") {
|
||||||
let body = request.body().as_ref().unwrap().as_bytes().unwrap();
|
let body = request.body().as_ref().unwrap().as_bytes().unwrap();
|
||||||
let body = String::from_utf8_lossy(body);
|
let body = String::from_utf8_lossy(body);
|
||||||
debug!(
|
debug!(
|
||||||
@@ -1192,6 +1229,41 @@ mod tests {
|
|||||||
assert_eq!(headers.get("x-api-key").unwrap(), "api-key");
|
assert_eq!(headers.get("x-api-key").unwrap(), "api-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `log_request` prints the request's Debug, and Debug for a request prints
|
||||||
|
/// its headers. Marking the value sensitive is the only thing standing
|
||||||
|
/// between the API key and every debug line; assert on the header map's own
|
||||||
|
/// Debug, which is what that printing reduces to.
|
||||||
|
#[test]
|
||||||
|
fn test_api_key_is_redacted_in_debug_output() {
|
||||||
|
let headers = RestfulLanceDbClient::<Sender>::default_headers(
|
||||||
|
"sk-live-sentinel",
|
||||||
|
"us-east-1",
|
||||||
|
"db-name",
|
||||||
|
false,
|
||||||
|
&RemoteOptions::default(),
|
||||||
|
None,
|
||||||
|
&ClientConfig::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(headers.get("x-api-key").unwrap(), "sk-live-sentinel");
|
||||||
|
assert!(
|
||||||
|
!format!("{:?}", headers).contains("sk-live-sentinel"),
|
||||||
|
"the API key must not survive Debug formatting"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A suppressed body is suppressed whatever the content type says, and an
|
||||||
|
/// allowed one is logged exactly as it was before Secrets existed.
|
||||||
|
#[test]
|
||||||
|
fn test_body_logging_is_decided_by_the_caller() {
|
||||||
|
assert_ne!(BodyLogging::Allowed, BodyLogging::Suppressed);
|
||||||
|
// `send` and `send_suppressing_body` differ only in what they pass, so
|
||||||
|
// the enum is the whole contract: a caller states its intent and the
|
||||||
|
// transport does not infer one from the route.
|
||||||
|
assert_eq!(BodyLogging::Allowed, BodyLogging::Allowed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rejects_invalid_cloud_dns_hostname() {
|
fn test_rejects_invalid_cloud_dns_hostname() {
|
||||||
let invalid_database_names = ["a".repeat(64), "invalid..database".to_string()];
|
let invalid_database_names = ["a".repeat(64), "invalid..database".to_string()];
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use crate::function::{FunctionRegistrationRequest, FunctionVersion};
|
|||||||
use crate::job::Job;
|
use crate::job::Job;
|
||||||
use crate::remote::job::{RemoteJob, job_state_to_client};
|
use crate::remote::job::{RemoteJob, job_state_to_client};
|
||||||
use crate::remote::util::stream_as_body;
|
use crate::remote::util::stream_as_body;
|
||||||
|
use crate::secrets::SecretInfo;
|
||||||
use crate::table::BaseTable;
|
use crate::table::BaseTable;
|
||||||
|
|
||||||
use super::client::{
|
use super::client::{
|
||||||
@@ -277,6 +278,22 @@ pub struct RemoteHostOverrides {
|
|||||||
pub sql: Option<String>,
|
pub sql: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach a namespace path to a Secret request body.
|
||||||
|
///
|
||||||
|
/// A root path is omitted rather than sent empty, so a root request is byte
|
||||||
|
/// identical to one from a client that predates namespace addressing.
|
||||||
|
fn add_namespace_path(body: &mut serde_json::Value, namespace_path: &[String]) {
|
||||||
|
if namespace_path.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body["namespace_path"] = serde_json::Value::Array(
|
||||||
|
namespace_path
|
||||||
|
.iter()
|
||||||
|
.map(|segment| serde_json::Value::String(segment.clone()))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
impl RemoteDatabase {
|
impl RemoteDatabase {
|
||||||
pub(crate) fn try_new(
|
pub(crate) fn try_new(
|
||||||
uri: &str,
|
uri: &str,
|
||||||
@@ -352,6 +369,24 @@ impl RemoteDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<S: HttpSend> RemoteDatabase<S> {
|
impl<S: HttpSend> RemoteDatabase<S> {
|
||||||
|
/// Post a request whose body carries a credential.
|
||||||
|
///
|
||||||
|
/// Shared by the create and alter verbs, which declare their own request
|
||||||
|
/// types: the two mean different things to the service and are free to
|
||||||
|
/// diverge, so what they share is the posting and not the shape.
|
||||||
|
///
|
||||||
|
/// The value is a request field and never a path segment or query
|
||||||
|
/// parameter, which keeps it out of access logs and proxy traces.
|
||||||
|
async fn post_secret_write<T: serde::Serialize>(&self, route: &str, body: &T) -> Result<()> {
|
||||||
|
let req = self.client.post(route).json(body);
|
||||||
|
// This call is what says the body is a credential. Nothing downstream
|
||||||
|
// can tell from the bytes, and a route list in the transport would have
|
||||||
|
// to be kept in step with endpoints declared here.
|
||||||
|
let (request_id, response) = self.client.send_suppressing_body(req).await?;
|
||||||
|
self.client.check_response(&request_id, response).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn submit_drop_table(
|
async fn submit_drop_table(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -570,6 +605,59 @@ struct RemoteDropFunctionResponse {
|
|||||||
dropped: bool,
|
dropped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a Secret under a name the database does not yet hold.
|
||||||
|
///
|
||||||
|
/// Declared separately from the alter request although the two are identical
|
||||||
|
/// today: they are different operations to the service -- one refuses an
|
||||||
|
/// existing name, the other requires it -- and either may grow a field the
|
||||||
|
/// other has no meaning for.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct RemoteCreateSecretRequest<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
value: &'a str,
|
||||||
|
/// Omitted at the root, so a request from a client that predates namespaces
|
||||||
|
/// is byte-identical to one that does not use them.
|
||||||
|
#[serde(skip_serializing_if = "<[String]>::is_empty")]
|
||||||
|
namespace_path: &'a [String],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the credential behind a Secret the database already holds.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct RemoteAlterSecretRequest<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
value: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "<[String]>::is_empty")]
|
||||||
|
namespace_path: &'a [String],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of a Secret listing. A struct rather than an inline object so the
|
||||||
|
/// request and the response are declared the same way -- a reader of one finds
|
||||||
|
/// the other.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct RemoteListSecretsRequest<'a> {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
page_token: Option<String>,
|
||||||
|
/// Omitted at the root, so a listing from a client that predates namespaces
|
||||||
|
/// is byte-identical to one that does not use them.
|
||||||
|
#[serde(skip_serializing_if = "<[String]>::is_empty")]
|
||||||
|
namespace_path: &'a [String],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct RemoteListSecretsResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
secrets: Vec<RemoteListedSecret>,
|
||||||
|
#[serde(default)]
|
||||||
|
page_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An object rather than a bare name so a later listing can carry a Secret's
|
||||||
|
/// type or last-updated time without breaking this one.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct RemoteListedSecret {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Bound on `list_jobs` page walking; a warning is logged when the listing
|
/// Bound on `list_jobs` page walking; a warning is logged when the listing
|
||||||
/// is truncated at this many pages.
|
/// is truncated at this many pages.
|
||||||
const MAX_LIST_JOBS_PAGES: usize = 100;
|
const MAX_LIST_JOBS_PAGES: usize = 100;
|
||||||
@@ -671,6 +759,85 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
|
|||||||
Ok(response.dropped)
|
Ok(response.dropped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_secret(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
value: &str,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.post_secret_write(
|
||||||
|
"/v1/secrets/create",
|
||||||
|
&RemoteCreateSecretRequest {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
namespace_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn alter_secret(&self, name: &str, value: &str, namespace_path: &[String]) -> Result<()> {
|
||||||
|
self.post_secret_write(
|
||||||
|
"/v1/secrets/alter",
|
||||||
|
&RemoteAlterSecretRequest {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
namespace_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_secrets(&self, namespace_path: &[String]) -> Result<Vec<String>> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
let mut page_token: Option<String> = None;
|
||||||
|
let mut seen_page_tokens = HashSet::new();
|
||||||
|
loop {
|
||||||
|
let body = RemoteListSecretsRequest {
|
||||||
|
page_token: page_token.clone(),
|
||||||
|
namespace_path,
|
||||||
|
};
|
||||||
|
let req = self.client.post("/v1/secrets/list").json(&body);
|
||||||
|
let (request_id, response) = self.client.send(req).await?;
|
||||||
|
let response = self.client.check_response(&request_id, response).await?;
|
||||||
|
let status = response.status();
|
||||||
|
let response: RemoteListSecretsResponse =
|
||||||
|
response.json().await.err_to_http(request_id.clone())?;
|
||||||
|
names.extend(response.secrets.into_iter().map(|secret| secret.name));
|
||||||
|
let Some(next_page_token) = response.page_token.filter(|token| !token.is_empty())
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if !seen_page_tokens.insert(next_page_token.clone()) {
|
||||||
|
return Err(Error::Http {
|
||||||
|
source: "Secret listing response repeated a page_token".into(),
|
||||||
|
request_id,
|
||||||
|
status_code: Some(status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
page_token = Some(next_page_token);
|
||||||
|
}
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drop_secret(&self, name: &str, namespace_path: &[String]) -> Result<()> {
|
||||||
|
let mut body = serde_json::json!({ "name": name });
|
||||||
|
add_namespace_path(&mut body, namespace_path);
|
||||||
|
let req = self.client.post("/v1/secrets/drop").json(&body);
|
||||||
|
let (request_id, response) = self.client.send(req).await?;
|
||||||
|
self.client.check_response(&request_id, response).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn describe_secret(&self, name: &str, namespace_path: &[String]) -> Result<SecretInfo> {
|
||||||
|
let mut body = serde_json::json!({ "name": name });
|
||||||
|
add_namespace_path(&mut body, namespace_path);
|
||||||
|
let req = self.client.post("/v1/secrets/describe").json(&body);
|
||||||
|
let (request_id, response) = self.client.send(req).await?;
|
||||||
|
let response = self.client.check_response(&request_id, response).await?;
|
||||||
|
response.json().await.err_to_http(request_id)
|
||||||
|
}
|
||||||
|
|
||||||
async fn open_job(&self, job_id: &str) -> Result<Job> {
|
async fn open_job(&self, job_id: &str) -> Result<Job> {
|
||||||
let handle = super::job::RemoteJob::new(self.client.clone(), job_id.to_string());
|
let handle = super::job::RemoteJob::new(self.client.clone(), job_id.to_string());
|
||||||
match crate::job::JobHandle::describe(&handle).await {
|
match crate::job::JobHandle::describe(&handle).await {
|
||||||
@@ -2781,6 +2948,113 @@ mod tests {
|
|||||||
assert_eq!(batches[0].schema(), schema);
|
assert_eq!(batches[0].schema(), schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_and_alter_secret_send_the_value_in_the_request_body() {
|
||||||
|
for (route, call) in [("/v1/secrets/create", true), ("/v1/secrets/alter", false)] {
|
||||||
|
let conn = Connection::new_with_handler(move |request| {
|
||||||
|
assert_eq!(request.method(), &reqwest::Method::POST);
|
||||||
|
assert_eq!(request.url().path(), route);
|
||||||
|
// Never a path segment or query parameter, which is what keeps
|
||||||
|
// it out of access logs and proxy traces.
|
||||||
|
assert!(request.url().query().is_none(), "{:?}", request.url());
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||||
|
assert_eq!(body["name"], "openai-prod");
|
||||||
|
assert_eq!(body["value"], "sk-live-0001");
|
||||||
|
http::Response::builder().status(200).body("{}").unwrap()
|
||||||
|
});
|
||||||
|
if call {
|
||||||
|
conn.create_secret("openai-prod", "sk-live-0001", &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
conn.alter_secret("openai-prod", "sk-live-0001", &[])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_secrets_walks_pages_and_returns_names_only() {
|
||||||
|
let conn = Connection::new_with_handler(|request| {
|
||||||
|
assert_eq!(request.url().path(), "/v1/secrets/list");
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||||
|
let page = body.get("page_token").and_then(|token| token.as_str());
|
||||||
|
let body = match page {
|
||||||
|
None => r#"{"secrets":[{"name":"openai-prod"}],"page_token":"p2"}"#,
|
||||||
|
Some("p2") => r#"{"secrets":[{"name":"hf-prod"}]}"#,
|
||||||
|
Some(other) => panic!("unexpected page token: {other}"),
|
||||||
|
};
|
||||||
|
http::Response::builder().status(200).body(body).unwrap()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
conn.list_secrets(&[]).await.unwrap(),
|
||||||
|
vec!["openai-prod".to_string(), "hf-prod".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server that keeps handing back the same token would otherwise spin
|
||||||
|
/// forever.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_secrets_rejects_a_repeated_page_token() {
|
||||||
|
let conn = Connection::new_with_handler(|_| {
|
||||||
|
http::Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#)
|
||||||
|
.unwrap()
|
||||||
|
});
|
||||||
|
let error = conn.list_secrets(&[]).await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("repeated a page_token"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_drop_secret_posts_the_name_alone() {
|
||||||
|
let conn = Connection::new_with_handler(|request| {
|
||||||
|
assert_eq!(request.url().path(), "/v1/secrets/drop");
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||||
|
assert_eq!(body, serde_json::json!({"name": "openai-prod"}));
|
||||||
|
http::Response::builder().status(200).body("{}").unwrap()
|
||||||
|
});
|
||||||
|
conn.drop_secret("openai-prod", &[]).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A namespace path is sent when there is one and omitted when there is
|
||||||
|
/// not, so a root request stays byte identical to one from a client that
|
||||||
|
/// predates namespace addressing -- which is what lets the parameter ship
|
||||||
|
/// before every server implements it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_a_namespace_path_is_sent_only_when_it_is_not_root() {
|
||||||
|
let conn = Connection::new_with_handler(|request| {
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "openai-prod",
|
||||||
|
"namespace_path": ["prod", "vision"],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
http::Response::builder().status(200).body("{}").unwrap()
|
||||||
|
});
|
||||||
|
conn.drop_secret("openai-prod", &["prod".to_string(), "vision".to_string()])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let conn = Connection::new_with_handler(|request| {
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||||
|
assert!(body.get("namespace_path").is_none(), "{body}");
|
||||||
|
http::Response::builder().status(200).body("{}").unwrap()
|
||||||
|
});
|
||||||
|
conn.drop_secret("openai-prod", &[]).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_function_async_sends_canonical_request_and_decodes_typed_job() {
|
async fn test_create_function_async_sends_canonical_request_and_decodes_typed_job() {
|
||||||
const REQUEST: &str = include_str!(
|
const REQUEST: &str = include_str!(
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||||
|
|
||||||
|
//! Named Secrets: database-scoped credentials a Function binds by name.
|
||||||
|
//!
|
||||||
|
//! Nothing here holds a credential. The verbs live on
|
||||||
|
//! [`crate::connection::Connection`], and none of them returns a value -- by
|
||||||
|
//! construction rather than by policy, so there is no code path that could.
|
||||||
|
//! What a Function records is a binding, in [`crate::function`].
|
||||||
|
|
||||||
|
/// What a database records about a Secret. Never its value.
|
||||||
|
///
|
||||||
|
/// Returned by [`crate::connection::Connection::describe_secret`]. There is no
|
||||||
|
/// field for the credential and no method that could produce one.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
|
||||||
|
pub struct SecretInfo {
|
||||||
|
/// The Secret's database-scoped name.
|
||||||
|
pub name: String,
|
||||||
|
/// When the Secret was created, in milliseconds since the Unix epoch.
|
||||||
|
pub created_at_millis: i64,
|
||||||
|
/// When the Secret's value was last rotated, in milliseconds since the Unix
|
||||||
|
/// epoch.
|
||||||
|
///
|
||||||
|
/// This is the only observable that a rotation landed: no API returns a
|
||||||
|
/// credential, so a caller confirms `alter_secret` took effect by watching
|
||||||
|
/// this move.
|
||||||
|
pub updated_at_millis: i64,
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ use std::fs;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use lancedb::function::{
|
use lancedb::function::{
|
||||||
FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult,
|
FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, SecretBinding,
|
||||||
|
SecretReference,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -20,6 +21,26 @@ fn job_result(name: &str) -> Value {
|
|||||||
serde_json::from_str::<Value>(&fixture(name)).expect("remote Job fixture")["result"].clone()
|
serde_json::from_str::<Value>(&fixture(name)).expect("remote Job fixture")["result"].clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// No client value models a resolved credential, at any nesting depth.
|
||||||
|
fn assert_no_secret_values(value: &Value) {
|
||||||
|
match value {
|
||||||
|
Value::Object(values) => {
|
||||||
|
for (key, value) in values {
|
||||||
|
assert!(
|
||||||
|
!matches!(
|
||||||
|
key.as_str(),
|
||||||
|
"secret_value" | "secret_values" | "resolved_secret" | "resolved_secrets"
|
||||||
|
),
|
||||||
|
"client canonical value must not model resolved secret material"
|
||||||
|
);
|
||||||
|
assert_no_secret_values(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(values) => values.iter().for_each(assert_no_secret_values),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn function_version_job_result_matches_shared_canonical_golden() {
|
fn function_version_job_result_matches_shared_canonical_golden() {
|
||||||
let result = job_result("remote_function_job.json");
|
let result = job_result("remote_function_job.json");
|
||||||
@@ -28,6 +49,13 @@ fn function_version_job_result_matches_shared_canonical_golden() {
|
|||||||
assert_eq!(version.name(), "embed");
|
assert_eq!(version.name(), "embed");
|
||||||
assert_eq!(version.version(), "fv_01K3EXACT");
|
assert_eq!(version.version(), "fv_01K3EXACT");
|
||||||
assert_eq!(version.runtime_digest(), "sha256:runtime");
|
assert_eq!(version.runtime_digest(), "sha256:runtime");
|
||||||
|
assert_eq!(
|
||||||
|
version.secret_bindings(),
|
||||||
|
[SecretBinding::Env {
|
||||||
|
variable: "HF_TOKEN".to_string(),
|
||||||
|
secret_ref: SecretReference::new("hf-prod"),
|
||||||
|
}]
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
version.to_canonical_json().expect("canonical JSON"),
|
version.to_canonical_json().expect("canonical JSON"),
|
||||||
fixture("remote_function_version.canonical.json").trim()
|
fixture("remote_function_version.canonical.json").trim()
|
||||||
@@ -142,3 +170,74 @@ fn floating_point_application_literals_are_rejected_consistently() {
|
|||||||
.contains("floating-point Function literals")
|
.contains("floating-point Function literals")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_client_values_carry_bindings_and_no_credentials() {
|
||||||
|
let result = job_result("remote_function_job.json");
|
||||||
|
let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result");
|
||||||
|
let canonical: Value = serde_json::from_str(
|
||||||
|
&version
|
||||||
|
.to_canonical_json()
|
||||||
|
.expect("canonical FunctionVersion"),
|
||||||
|
)
|
||||||
|
.expect("canonical JSON");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
canonical["secret_bindings"],
|
||||||
|
serde_json::json!([{"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}}])
|
||||||
|
);
|
||||||
|
assert_no_secret_values(&canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A binding kind a newer server introduces must not fail the whole version.
|
||||||
|
///
|
||||||
|
/// This is the cost the union pays for being one field: an unknown variant is
|
||||||
|
/// a decode error unless it is caught, so it is caught -- and the payload is
|
||||||
|
/// dropped rather than retained, as `PythonRuntimeSpec` does, because the
|
||||||
|
/// client does not proxy catalog values.
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_binding_kind_is_forward_decodable() {
|
||||||
|
let mut result = job_result("remote_function_job.json");
|
||||||
|
result["secret_bindings"] = serde_json::json!([
|
||||||
|
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}},
|
||||||
|
{"kind": "file", "path": "/run/secrets/tok", "secret_ref": {"name": "hf-prod"}},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let version = FunctionVersion::from_json(&result.to_string()).expect("future binding kind");
|
||||||
|
|
||||||
|
let kinds = version
|
||||||
|
.secret_bindings()
|
||||||
|
.iter()
|
||||||
|
.map(|binding| binding.kind())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(kinds, ["env", "file"]);
|
||||||
|
assert_eq!(version.secret_bindings()[1].variable(), None);
|
||||||
|
assert_eq!(version.secret_bindings()[1].secret(), None);
|
||||||
|
|
||||||
|
// The unknown kind round-trips as its discriminator and nothing more.
|
||||||
|
let canonical: Value =
|
||||||
|
serde_json::from_str(&version.to_canonical_json().expect("canonical")).expect("JSON");
|
||||||
|
assert_eq!(
|
||||||
|
canonical["secret_bindings"][1],
|
||||||
|
serde_json::json!({"kind": "file"})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Function registered before Secrets existed serializes unchanged.
|
||||||
|
#[test]
|
||||||
|
fn a_version_without_bindings_keeps_the_original_wire_shape() {
|
||||||
|
let mut result = job_result("remote_function_job.json");
|
||||||
|
result
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("Function version object")
|
||||||
|
.remove("secret_bindings");
|
||||||
|
let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result");
|
||||||
|
|
||||||
|
assert!(version.secret_bindings().is_empty());
|
||||||
|
assert!(
|
||||||
|
!version
|
||||||
|
.to_canonical_json()
|
||||||
|
.expect("canonical FunctionVersion")
|
||||||
|
.contains("secret_bindings")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use std::fs;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use lancedb::Error;
|
use lancedb::Error;
|
||||||
use lancedb::function::FunctionRegistrationRequest;
|
use lancedb::function::{FunctionRegistrationRequest, SecretBinding, SecretReference};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
fn fixture(name: &str) -> String {
|
fn fixture(name: &str) -> String {
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
@@ -14,6 +15,26 @@ fn fixture(name: &str) -> String {
|
|||||||
fs::read_to_string(path).expect("fixture must be readable")
|
fs::read_to_string(path).expect("fixture must be readable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A registration request never models a resolved credential, at any depth.
|
||||||
|
fn assert_no_secret_values(value: &Value) {
|
||||||
|
match value {
|
||||||
|
Value::Object(values) => {
|
||||||
|
for (key, value) in values {
|
||||||
|
assert!(
|
||||||
|
!matches!(
|
||||||
|
key.as_str(),
|
||||||
|
"secret_value" | "secret_values" | "resolved_secret" | "resolved_secrets"
|
||||||
|
),
|
||||||
|
"registration requests must not model resolved secret material"
|
||||||
|
);
|
||||||
|
assert_no_secret_values(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(values) => values.iter().for_each(assert_no_secret_values),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registration_request_matches_shared_canonical_golden() {
|
fn registration_request_matches_shared_canonical_golden() {
|
||||||
let request = FunctionRegistrationRequest::from_json(&fixture(
|
let request = FunctionRegistrationRequest::from_json(&fixture(
|
||||||
@@ -22,10 +43,45 @@ fn registration_request_matches_shared_canonical_golden() {
|
|||||||
.expect("registration request");
|
.expect("registration request");
|
||||||
assert_eq!(request.name, "normalize_score");
|
assert_eq!(request.name, "normalize_score");
|
||||||
assert_eq!(request.artifact.adapter.kind, "scalar_to_arrow_batch");
|
assert_eq!(request.artifact.adapter.kind, "scalar_to_arrow_batch");
|
||||||
|
// The unchanged path: a Function that binds nothing serializes today's
|
||||||
|
// bytes, with no `secret_bindings` key at all.
|
||||||
|
assert!(request.secret_bindings.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.to_canonical_json().expect("canonical request"),
|
request.to_canonical_json().expect("canonical request"),
|
||||||
fixture("remote_function_registration_request.canonical.json").trim()
|
fixture("remote_function_registration_request.canonical.json").trim()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let value: Value =
|
||||||
|
serde_json::from_str(&request.to_canonical_json().expect("canonical request"))
|
||||||
|
.expect("request JSON");
|
||||||
|
assert_no_secret_values(&value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same shared golden as the Python suite builds from `@udf(secrets=...)`
|
||||||
|
/// plus `bind_secrets`, so both clients agree byte for byte on a bound request.
|
||||||
|
#[test]
|
||||||
|
fn secret_bound_registration_request_matches_shared_canonical_golden() {
|
||||||
|
let request = FunctionRegistrationRequest::from_json(&fixture(
|
||||||
|
"remote_function_secret_registration_request.json",
|
||||||
|
))
|
||||||
|
.expect("registration request");
|
||||||
|
assert_eq!(request.name, "analyze_caption");
|
||||||
|
assert_eq!(
|
||||||
|
request.secret_bindings,
|
||||||
|
[SecretBinding::Env {
|
||||||
|
variable: "OPENAI_API_KEY".to_string(),
|
||||||
|
secret_ref: SecretReference::new("openai-prod"),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request.to_canonical_json().expect("canonical request"),
|
||||||
|
fixture("remote_function_secret_registration_request.canonical.json").trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
let value: Value =
|
||||||
|
serde_json::from_str(&request.to_canonical_json().expect("canonical request"))
|
||||||
|
.expect("request JSON");
|
||||||
|
assert_no_secret_values(&value);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+37
-7
@@ -3,7 +3,9 @@
|
|||||||
"job_type": "create_function",
|
"job_type": "create_function",
|
||||||
"job_state": "DONE",
|
"job_state": "DONE",
|
||||||
"creation_ms": 1787270400000,
|
"creation_ms": 1787270400000,
|
||||||
"spec": {"name": "embed"},
|
"spec": {
|
||||||
|
"name": "embed"
|
||||||
|
},
|
||||||
"result": {
|
"result": {
|
||||||
"name": "embed",
|
"name": "embed",
|
||||||
"version": "fv_01K3EXACT",
|
"version": "fv_01K3EXACT",
|
||||||
@@ -13,18 +15,46 @@
|
|||||||
"entrypoint": "embed"
|
"entrypoint": "embed"
|
||||||
},
|
},
|
||||||
"signature": {
|
"signature": {
|
||||||
"inputs": [{"name": "text", "arrow_type": "utf8", "nullable": true}],
|
"inputs": [
|
||||||
"output": {"kind": "scalar", "arrow_type": "list<float32>", "nullable": false}
|
{
|
||||||
|
"name": "text",
|
||||||
|
"arrow_type": "utf8",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"output": {
|
||||||
|
"kind": "scalar",
|
||||||
|
"arrow_type": "list<float32>",
|
||||||
|
"nullable": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"kind": "python",
|
"kind": "python",
|
||||||
"python_version": "3.12",
|
"python_version": "3.12",
|
||||||
"environment": {"kind": "pip", "packages": ["sentence-transformers>=3"]},
|
"environment": {
|
||||||
"env": {"TOKENIZERS_PARALLELISM": "false"}
|
"kind": "pip",
|
||||||
|
"packages": [
|
||||||
|
"sentence-transformers>=3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"TOKENIZERS_PARALLELISM": "false"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"runtime_digest": "sha256:runtime",
|
"runtime_digest": "sha256:runtime",
|
||||||
"environment_digest": "sha256:environment",
|
"environment_digest": "sha256:environment",
|
||||||
"created_at": "2026-08-21T00:00:00Z"
|
"created_at": "2026-08-21T00:00:00Z",
|
||||||
|
"secret_bindings": [
|
||||||
|
{
|
||||||
|
"kind": "env",
|
||||||
|
"variable": "HF_TOKEN",
|
||||||
|
"secret_ref": {
|
||||||
|
"name": "hf-prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"future_job": {"trace_id": "trace-1"}
|
"future_job": {
|
||||||
|
"trace_id": "trace-1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"artifact":{"adapter":{"kind":"scalar_to_arrow_batch","version":1},"content":{"data":"ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK","encoding":"base64"},"digest":"sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077","entrypoint":"analyze_caption","kind":"python_callable"},"name":"analyze_caption","runtime":{"env":{"MODE":"test"},"environment":{"kind":"pip","packages":["openai==3.7.0"]},"kind":"python","python_version":"3.12"},"secret_bindings":[{"kind":"env","secret_ref":{"name":"openai-prod"},"variable":"OPENAI_API_KEY"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"caption","nullable":false}],"output":{"arrow_type":"utf8","kind":"scalar","nullable":false}}}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"artifact": {
|
||||||
|
"adapter": {
|
||||||
|
"kind": "scalar_to_arrow_batch",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"data": "ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK",
|
||||||
|
"encoding": "base64"
|
||||||
|
},
|
||||||
|
"digest": "sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077",
|
||||||
|
"entrypoint": "analyze_caption",
|
||||||
|
"kind": "python_callable"
|
||||||
|
},
|
||||||
|
"name": "analyze_caption",
|
||||||
|
"runtime": {
|
||||||
|
"env": {
|
||||||
|
"MODE": "test"
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"kind": "pip",
|
||||||
|
"packages": [
|
||||||
|
"openai==3.7.0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"kind": "python",
|
||||||
|
"python_version": "3.12"
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"arrow_type": "utf8",
|
||||||
|
"name": "caption",
|
||||||
|
"nullable": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"output": {
|
||||||
|
"arrow_type": "utf8",
|
||||||
|
"kind": "scalar",
|
||||||
|
"nullable": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"secret_bindings": [
|
||||||
|
{
|
||||||
|
"kind": "env",
|
||||||
|
"variable": "OPENAI_API_KEY",
|
||||||
|
"secret_ref": {
|
||||||
|
"name": "openai-prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"}
|
{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","secret_bindings":[{"kind":"env","secret_ref":{"name":"hf-prod"},"variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"}
|
||||||
|
|||||||
Reference in New Issue
Block a user