feat(python): support exist_ok when creating secrets (#4978)

Add `exist_ok=False` to synchronous and asynchronous Python
`create_secret`. Passing `True` uses `CREATE SECRET IF NOT EXISTS`,
preserving an existing credential. The default continues to fail on an
existing name; rotation stays explicit through `alter_secret`.

```python
db.create_secret("api-key", value, exist_ok=True)
await async_db.create_secret("api-key", value, exist_ok=True)
```

The Rust API gains an awaitable builder with `.exist_ok(true)` while
existing `create_secret(...).await` calls keep working. Includes Python
SQL round-trip coverage and Rust coverage for direct and deferred SQL
responses.
This commit is contained in:
Jack Ye
2026-11-12 08:03:11 -08:00
committed by GitHub
parent ced4799ad5
commit 9e03bf97d4
12 changed files with 269 additions and 38 deletions
+6 -1
View File
@@ -228,7 +228,12 @@ class Connection(object):
self, name: str, namespace_path: Optional[List[str]] = None
) -> None: ...
async def create_secret(
self, name: str, value: str, namespace_path: Optional[List[str]] = None
self,
name: str,
value: str,
namespace_path: Optional[List[str]] = None,
*,
exist_ok: bool = False,
) -> None: ...
async def alter_secret(
self, name: str, value: str, namespace_path: Optional[List[str]] = None
+40 -8
View File
@@ -1158,14 +1158,24 @@ class DBConnection(EnforceOverrides):
)
def create_secret(
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
self,
name: str,
value: str,
*,
exist_ok: bool = False,
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
Fails if the name is taken unless ``exist_ok=True``, which leaves the
existing value unchanged. Use :meth:`alter_secret` to rotate a Secret.
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``.
Examples
--------
>>> db.create_secret("api-key", "credential", exist_ok=True) # doctest: +SKIP
"""
raise NotImplementedError(
_append_doc_url(
@@ -2492,9 +2502,18 @@ class LanceDBConnection(DBConnection):
@override
def create_secret(
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
self,
name: str,
value: str,
*,
exist_ok: bool = False,
namespace_path: Optional[List[str]] = None,
) -> None:
LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path))
LOOP.run(
self._conn.create_secret(
name, value, exist_ok=exist_ok, namespace_path=namespace_path
)
)
@override
def alter_secret(
@@ -3949,17 +3968,30 @@ class AsyncConnection(object):
)
async def create_secret(
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
self,
name: str,
value: str,
*,
exist_ok: bool = False,
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.
Fails if the name is taken unless ``exist_ok=True``, which leaves the
existing value unchanged. Use :meth:`alter_secret` to rotate a Secret.
Nothing reads the value back.
Examples
--------
>>> await db.create_secret( # doctest: +SKIP
... "api-key", "credential", exist_ok=True
... )
"""
await self._inner.create_secret(
validate_secret_name(name),
value,
list(validate_namespace_path(namespace_path)),
exist_ok=exist_ok,
)
async def alter_secret(
+11 -2
View File
@@ -1129,9 +1129,18 @@ class RemoteDBConnection(DBConnection):
@override
def create_secret(
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
self,
name: str,
value: str,
*,
exist_ok: bool = False,
namespace_path: Optional[List[str]] = None,
) -> None:
LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path))
LOOP.run(
self._conn.create_secret(
name, value, exist_ok=exist_ok, namespace_path=namespace_path
)
)
@override
def alter_secret(
+50
View File
@@ -293,6 +293,56 @@ def test_parameters_reach_the_server_as_the_values_that_were_sent():
assert echoed.column("k").to_pylist() == [10]
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize(
("exist_ok", "status"),
[
(None, "created"),
(False, "created"),
(True, "created"),
(True, "already_exists"),
],
)
async def test_create_secret_exist_ok_reaches_sql(asynchronous, exist_ok, status):
flight = pytest.importorskip("pyarrow.flight")
class SecretServer(flight.FlightServerBase):
def __init__(self):
super().__init__("grpc://127.0.0.1:0")
self.writes = []
def do_exchange(self, context, descriptor, reader, writer):
self.writes.append((descriptor.command, reader.read_all()))
result = pa.table({"status": [status]})
writer.begin(result.schema)
writer.write_table(result)
writer.close()
with SecretServer() as server:
options = {
"api_key": "test-key",
"host_override": "http://localhost:10024",
"sql_host_override": f"grpc://127.0.0.1:{server.port}",
}
kwargs = {"namespace_path": ["team", "production"]}
if exist_ok is not None:
kwargs["exist_ok"] = exist_ok
if asynchronous:
db = await lancedb.connect_async("db://analytics", **options)
await db.create_secret("api-key", "credential's value", **kwargs)
else:
db = lancedb.connect("db://analytics", **options)
db.create_secret("api-key", "credential's value", **kwargs)
((command, parameters),) = server.writes
prefix = "CREATE SECRET IF NOT EXISTS" if exist_ok else "CREATE SECRET"
sql = f'{prefix} "api-key" TYPE = GENERIC_STRING SECRET_STRING = $1'
assert sql.encode() in command
assert b"credential's value" not in command
assert parameters.column(0).to_pylist() == ["credential's value"]
@pytest.mark.parametrize("job_id", [None, "sql-job-1"])
def test_sql_input_error_metadata_crosses_native_boundary(job_id):
flight = pytest.importorskip("pyarrow.flight")
+3 -1
View File
@@ -1036,18 +1036,20 @@ impl Connection {
})
}
#[pyo3(signature = (name, value, namespace_path=None))]
#[pyo3(signature = (name, value, namespace_path=None, *, exist_ok=false))]
pub fn create_secret(
self_: PyRef<'_, Self>,
name: String,
value: String,
namespace_path: Option<Vec<String>>,
exist_ok: bool,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.get_inner()?.clone();
let namespace_path = namespace_path.unwrap_or_default();
future_into_py(self_.py(), async move {
inner
.create_secret(name, value, &namespace_path)
.exist_ok(exist_ok)
.await
.infer_error()
})