Skip to content

Commit a930959

Browse files
authored
feat(secrets): add sync and async exact ID handles (#11)
* feat(secrets): add sync and async ID handles preserving name behavior * refactor(secrets): trim ID handle changes to focused SDK scope * test(secrets): consolidate SDK lifecycle coverage
1 parent 9e3d9bd commit a930959

13 files changed

Lines changed: 335 additions & 131 deletions

File tree

‎README-SDK.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The `RunloopSDK` builds on top of the underlying REST client and provides a Pyth
1010
- [Core Concepts](#core-concepts)
1111
- [RunloopSDK](#runloopsdk)
1212
- [Available Resources](#available-resources)
13+
- [Secrets: names and exact IDs](#secrets-names-and-exact-ids)
1314
- [Devbox](#devbox)
1415
- [Command Execution](#command-execution)
1516
- [Execution Management](#execution-management)
@@ -128,6 +129,20 @@ The SDK provides object-oriented interfaces for all major Runloop resources:
128129
- **`runloop.secret`** - Secret management (create, update, list, delete encrypted key-value pairs)
129130
- **`runloop.api`** - Direct access to the underlying REST API client
130131

132+
### Secrets: names and exact IDs
133+
134+
`Secret` / `AsyncSecret` objects from `create()`, `list()`, and `from_name()` always operate by name, even when they carry a captured `.id`. Use a separate `SecretById` / `AsyncSecretById` handle for one exact row:
135+
136+
```python
137+
created = runloop.secret.create(name="API_TOKEN", value="synthetic-example")
138+
assert created.id is not None
139+
exact = runloop.secret.from_id(created.id) # Lazy; never falls back to a name.
140+
exact.update("synthetic-rotated") # Returns SecretView, as do get_info() and delete().
141+
exact.delete() # Deletes only this row.
142+
```
143+
144+
For `AsyncRunloopSDK`, await the operations but not `from_id()`. The handle types are exported from `runloop_api_client.sdk`. ID handles have `.id`, not `.name`; use their instance methods rather than name-only manager helpers. Environment-secret maps still take names; pass `exact.id` explicitly for an ID-based gateway/MCP binding. Name reads/updates select the greatest ID; name deletion removes selected matches nonatomically. IDs identify mutable rows, not value versions, and existing retries can replay writes. Rotation does not rewrite running devbox environments or issued tokens. Avoid debug request logging with real credentials.
145+
131146
### Devbox
132147

133148
Object-oriented interface for working with devboxes. Created via `runloop.devbox.create()`, `runloop.devbox.create_from_blueprint_id()`, `runloop.devbox.create_from_blueprint_name()`, `runloop.devbox.create_from_snapshot()`, or `runloop.devbox.from_id()`:

‎docs/api/secret.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ Secret
88
.. automodule:: runloop_api_client.sdk.async_secret
99
:members:
1010

11+
.. automodule:: runloop_api_client.sdk.async_secret_by_id
12+
:members:
13+
1114
.. tab:: Sync
1215

1316
.. automodule:: runloop_api_client.sdk.secret
1417
:members:
18+
19+
.. automodule:: runloop_api_client.sdk.secret_by_id
20+
:members:

‎src/runloop_api_client/sdk/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from .async_agent import AsyncAgent
4444
from .async_devbox import AsyncDevbox, AsyncNamedShell
4545
from .async_secret import AsyncSecret
46+
from .secret_by_id import SecretById
4647
from .async_snapshot import AsyncSnapshot
4748
from .gateway_config import GatewayConfig
4849
from .network_policy import NetworkPolicy
@@ -51,6 +52,7 @@
5152
from .async_execution import AsyncExecution
5253
from .async_mcp_config import AsyncMcpConfig
5354
from .execution_result import ExecutionResult
55+
from .async_secret_by_id import AsyncSecretById
5456
from .async_gateway_config import AsyncGatewayConfig
5557
from .async_network_policy import AsyncNetworkPolicy
5658
from .async_storage_object import AsyncStorageObject
@@ -98,6 +100,8 @@
98100
"Blueprint",
99101
"AsyncBlueprint",
100102
"Secret",
103+
"SecretById",
104+
"AsyncSecretById",
101105
"AsyncSecret",
102106
"Snapshot",
103107
"AsyncSnapshot",

‎src/runloop_api_client/sdk/async_.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from .async_blueprint import AsyncBlueprint
4646
from .async_mcp_config import AsyncMcpConfig
4747
from ..types.secret_view import SecretView
48+
from .async_secret_by_id import AsyncSecretById
4849
from ..lib.context_loader import TarFilter, build_directory_tar
4950
from .async_gateway_config import AsyncGatewayConfig
5051
from .async_network_policy import AsyncNetworkPolicy
@@ -1006,7 +1007,7 @@ async def create(self, name: str, value: str, **options: Unpack[LongRequestOptio
10061007
... )
10071008
>>> print(f"Created secret: {secret.name}")
10081009
1009-
:param name: Globally unique secret name (must be a valid env var name)
1010+
:param name: Account-scoped secret name (must be a valid env var name)
10101011
:type name: str
10111012
:param value: Secret value to store (encrypted at rest)
10121013
:type value: str
@@ -1027,13 +1028,17 @@ def from_name(self, name: str) -> AsyncSecret:
10271028
>>> info = await secret.get_info()
10281029
>>> print(f"Secret ID: {info.id}")
10291030
1030-
:param name: The globally unique name of the secret
1031+
:param name: The literal account-scoped name of the secret
10311032
:type name: str
10321033
:return: An AsyncSecret instance (no API call made)
10331034
:rtype: AsyncSecret
10341035
"""
10351036
return AsyncSecret(self._client, name)
10361037

1038+
def from_id(self, id: str) -> AsyncSecretById:
1039+
"""Get a lazy exact-ID handle; its operations never fall back to a name."""
1040+
return AsyncSecretById(self._client, id)
1041+
10371042
async def update(
10381043
self,
10391044
secret: "AsyncSecret | str",

‎src/runloop_api_client/sdk/async_secret.py‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ class AsyncSecret:
1313
"""Asynchronous wrapper around a secret resource.
1414
1515
Secrets are encrypted key-value pairs that can be securely stored and injected
16-
into Devboxes as environment variables. Secrets are identified by their globally
17-
unique name.
16+
into Devboxes as environment variables. This wrapper always operates by its account-scoped
17+
name, even if it contains an ID from an earlier response. Reads and updates
18+
select the greatest ID at lookup; deletion removes all selected matches,
19+
nonatomically. Use ``sdk.secret.from_id(id)`` for exact row operations.
1820
1921
Example:
2022
>>> runloop = AsyncRunloopSDK()
@@ -36,7 +38,7 @@ def __init__(
3638
3739
:param client: Generated AsyncRunloop client
3840
:type client: AsyncRunloop
39-
:param name: The globally unique name of the secret
41+
:param name: The literal account-scoped name of the secret
4042
:type name: str
4143
:param id: The secret ID (optional, may not be known until get_info is called)
4244
:type id: str | None
@@ -53,7 +55,7 @@ def __repr__(self) -> str:
5355
def id(self) -> str | None:
5456
"""Return the secret ID.
5557
56-
:return: Secret ID, or None if not yet fetched from API
58+
:return: Captured response ID, or None. Fetching metadata does not refresh this property.
5759
:rtype: str | None
5860
"""
5961
return self._id
@@ -62,7 +64,7 @@ def id(self) -> str | None:
6264
def name(self) -> str:
6365
"""Return the secret name.
6466
65-
:return: Globally unique secret name
67+
:return: Literal account-scoped secret name
6668
:rtype: str
6769
"""
6870
return self._name
@@ -93,7 +95,9 @@ async def update(
9395
value: str,
9496
**options: Unpack[LongRequestOptions],
9597
) -> SecretView:
96-
"""Update this secret's value.
98+
"""Update the greatest-ID row matching this name at lookup.
99+
100+
This wrapper remains name-bound and its captured ID is unchanged.
97101
98102
Example:
99103
>>> updated = await secret.update("new-secret-value")
@@ -115,7 +119,9 @@ async def delete(
115119
self,
116120
**options: Unpack[LongRequestOptions],
117121
) -> SecretView:
118-
"""Delete this secret. This action is irreversible.
122+
"""Delete every selected row matching this name, nonatomically.
123+
124+
A concurrent create can survive. This action is irreversible.
119125
120126
Example:
121127
>>> await secret.delete()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Exact-ID Secret operations, separate from legacy name selectors."""
2+
3+
from __future__ import annotations
4+
5+
from typing_extensions import Unpack, override
6+
7+
from ._types import BaseRequestOptions, LongRequestOptions
8+
from .._client import AsyncRunloop
9+
from ..types.secret_view import SecretView
10+
11+
12+
class AsyncSecretById:
13+
"""An exact Secret row, obtained with ``sdk.secret.from_id(id)``.
14+
15+
Construction makes no request. The ID never changes, but another writer can
16+
change the value. Requests retain the generated client's retry policy and are
17+
not exactly-once operations. Use ``get_info()`` for the name and metadata;
18+
values are never returned.
19+
"""
20+
21+
def __init__(self, client: AsyncRunloop, id: str) -> None:
22+
self._client = client
23+
self._id = id
24+
25+
@override
26+
def __repr__(self) -> str:
27+
return f"<AsyncSecretById id={self._id!r}>"
28+
29+
@property
30+
def id(self) -> str:
31+
"""The exact row ID, not a name or an immutable value-version identifier."""
32+
return self._id
33+
34+
async def get_info(self, **options: Unpack[BaseRequestOptions]) -> SecretView:
35+
"""Retrieve this row's metadata. Missing IDs never fall back to names."""
36+
return await self._client.secrets.retrieve_by_id(self._id, **options)
37+
38+
async def update(self, value: str, **options: Unpack[LongRequestOptions]) -> SecretView:
39+
"""Replace this row's value, without changing running devbox environments or issued tokens."""
40+
return await self._client.secrets.update_by_id(self._id, value=value, **options)
41+
42+
async def delete(self, **options: Unpack[LongRequestOptions]) -> SecretView:
43+
"""Delete only this row. Name readers may then see an older same-name row."""
44+
return await self._client.secrets.delete_by_id(self._id, **options)

‎src/runloop_api_client/sdk/secret.py‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ class Secret:
1313
"""Synchronous wrapper around a secret resource.
1414
1515
Secrets are encrypted key-value pairs that can be securely stored and injected
16-
into Devboxes as environment variables. Secrets are identified by their globally
17-
unique name.
16+
into Devboxes as environment variables. This wrapper always operates by its account-scoped
17+
name, even if it contains an ID from an earlier response. Reads and updates
18+
select the greatest ID at lookup; deletion removes all selected matches,
19+
nonatomically. Use ``sdk.secret.from_id(id)`` for exact row operations.
1820
1921
Example:
2022
>>> runloop = RunloopSDK()
@@ -36,9 +38,9 @@ def __init__(
3638
3739
:param client: Generated Runloop client
3840
:type client: Runloop
39-
:param name: The globally unique name of the secret
41+
:param name: The literal account-scoped name of the secret
4042
:type name: str
41-
:param id: The secret ID (optional, may not be known until getInfo is called)
43+
:param id: An optional ID captured from the response that constructed this wrapper
4244
:type id: str | None
4345
"""
4446
self._client = client
@@ -53,7 +55,7 @@ def __repr__(self) -> str:
5355
def id(self) -> str | None:
5456
"""Return the secret ID.
5557
56-
:return: Secret ID, or None if not yet fetched from API
58+
:return: Captured response ID, or None. Fetching metadata does not refresh this property.
5759
:rtype: str | None
5860
"""
5961
return self._id
@@ -62,7 +64,7 @@ def id(self) -> str | None:
6264
def name(self) -> str:
6365
"""Return the secret name.
6466
65-
:return: Globally unique secret name
67+
:return: Literal account-scoped secret name
6668
:rtype: str
6769
"""
6870
return self._name
@@ -93,7 +95,9 @@ def update(
9395
value: str,
9496
**options: Unpack[LongRequestOptions],
9597
) -> SecretView:
96-
"""Update this secret's value.
98+
"""Update the greatest-ID row matching this name at lookup.
99+
100+
This wrapper remains name-bound and its captured ID is unchanged.
97101
98102
Example:
99103
>>> updated = secret.update("new-secret-value")
@@ -115,7 +119,9 @@ def delete(
115119
self,
116120
**options: Unpack[LongRequestOptions],
117121
) -> SecretView:
118-
"""Delete this secret. This action is irreversible.
122+
"""Delete every selected row matching this name, nonatomically.
123+
124+
A concurrent create can survive. This action is irreversible.
119125
120126
Example:
121127
>>> secret.delete()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Exact-ID Secret operations, separate from legacy name selectors."""
2+
3+
from __future__ import annotations
4+
5+
from typing_extensions import Unpack, override
6+
7+
from ._types import BaseRequestOptions, LongRequestOptions
8+
from .._client import Runloop
9+
from ..types.secret_view import SecretView
10+
11+
12+
class SecretById:
13+
"""An exact Secret row, obtained with ``sdk.secret.from_id(id)``.
14+
15+
Construction makes no request. The ID never changes, but another writer can
16+
change the value. Requests retain the generated client's retry policy and are
17+
not exactly-once operations. Use ``get_info()`` for the name and metadata;
18+
values are never returned.
19+
"""
20+
21+
def __init__(self, client: Runloop, id: str) -> None:
22+
self._client = client
23+
self._id = id
24+
25+
@override
26+
def __repr__(self) -> str:
27+
return f"<SecretById id={self._id!r}>"
28+
29+
@property
30+
def id(self) -> str:
31+
"""The exact row ID, not a name or an immutable value-version identifier."""
32+
return self._id
33+
34+
def get_info(self, **options: Unpack[BaseRequestOptions]) -> SecretView:
35+
"""Retrieve this row's metadata. Missing IDs never fall back to names."""
36+
return self._client.secrets.retrieve_by_id(self._id, **options)
37+
38+
def update(self, value: str, **options: Unpack[LongRequestOptions]) -> SecretView:
39+
"""Replace this row's value, without changing running devbox environments or issued tokens."""
40+
return self._client.secrets.update_by_id(self._id, value=value, **options)
41+
42+
def delete(self, **options: Unpack[LongRequestOptions]) -> SecretView:
43+
"""Delete only this row. Name readers may then see an older same-name row."""
44+
return self._client.secrets.delete_by_id(self._id, **options)

‎src/runloop_api_client/sdk/sync.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from .blueprint import Blueprint
4444
from .mcp_config import McpConfig
4545
from .._constants import DEFAULT_API_POOL_SHARDS, DEFAULT_TRANSFER_POOL_SHARDS, DEFAULT_BACKGROUND_POOL_SHARDS
46+
from .secret_by_id import SecretById
4647
from .gateway_config import GatewayConfig
4748
from .network_policy import NetworkPolicy
4849
from .storage_object import StorageObject
@@ -1031,7 +1032,7 @@ def create(self, name: str, value: str, **options: Unpack[LongRequestOptions]) -
10311032
... )
10321033
>>> print(f"Created secret: {secret.name}")
10331034
1034-
:param name: Globally unique secret name (must be a valid env var name)
1035+
:param name: Account-scoped secret name (must be a valid env var name)
10351036
:type name: str
10361037
:param value: Secret value to store (encrypted at rest)
10371038
:type value: str
@@ -1052,13 +1053,17 @@ def from_name(self, name: str) -> Secret:
10521053
>>> info = secret.get_info()
10531054
>>> print(f"Secret ID: {info.id}")
10541055
1055-
:param name: The globally unique name of the secret
1056+
:param name: The literal account-scoped name of the secret
10561057
:type name: str
10571058
:return: A Secret instance (no API call made)
10581059
:rtype: Secret
10591060
"""
10601061
return Secret(self._client, name)
10611062

1063+
def from_id(self, id: str) -> SecretById:
1064+
"""Get a lazy exact-ID handle; its operations never fall back to a name."""
1065+
return SecretById(self._client, id)
1066+
10621067
def update(
10631068
self,
10641069
secret: "Secret | str",

0 commit comments

Comments
 (0)