Skip to content

Commit ef7df4d

Browse files
authored
feat: send change reasons from all SDKs (#115)
Rust, Python and TypeScript clients gain with_reason / withReason, a derived client that sends the percent-encoded Everruns-Change-Reason header so the server records the reason in entity change history. Blank reasons send no header; the original client is unchanged.
1 parent bba8f42 commit ef7df4d

15 files changed

Lines changed: 561 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,29 @@ client = Everruns(api_key="evr_pat_...", org_id="org_...")
189189
const client = new Everruns({ apiKey: "evr_pat_...", orgId: "org_..." });
190190
```
191191

192+
## Change Reasons
193+
194+
Mutations can carry a reason that the server records in the changed entity's
195+
history. Derive a client scoped to the reason; the original stays unchanged:
196+
197+
```rust
198+
// Rust
199+
client.with_reason("retire the unused agent").agents().delete("agent_123").await?;
200+
```
201+
202+
```python
203+
# Python
204+
await client.with_reason("retire the unused agent").agents.delete("agent_123")
205+
```
206+
207+
```typescript
208+
// TypeScript
209+
await client.withReason("retire the unused agent").agents.delete("agent_123");
210+
```
211+
212+
The reason is sent as the `Everruns-Change-Reason` header, UTF-8
213+
percent-encoded; the server accepts up to 1000 characters.
214+
192215
## Error Handling
193216

194217
Consistent error types across all SDKs:

‎python/README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ Or with an explicit token and organization:
119119
client = Everruns(api_key="evr_pat_...", org_id="org_...")
120120
```
121121

122+
## Change Reasons
123+
124+
Record why a change was made. `with_reason` derives a client that sends the
125+
reason with every request it makes; the server stores it in the changed
126+
entity's history. The derived client shares the original's connection, and the
127+
original stays unchanged:
128+
129+
```python
130+
await client.with_reason("retire the unused agent").agents.delete("agent_123")
131+
132+
scoped = client.with_reason("rotate prompts for the Q3 launch")
133+
await scoped.agents.apply(agent_id, "assistant", "You are concise.")
134+
```
135+
136+
The reason travels in the `Everruns-Change-Reason` header, UTF-8
137+
percent-encoded. A blank reason sends no header. The server trims the reason and rejects it with HTTP 400 (`invalid_change_reason`) when it is longer than 1000 characters, contains control characters other than newline and tab, or looks like it contains a credential. Closing a derived
138+
client is a no-op; close the client it came from.
139+
122140
## Agent Versions
123141

124142
```python

‎python/everruns_sdk/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"""
1010

1111
from everruns_sdk.auth import ApiKey
12-
from everruns_sdk.client import Everruns
12+
from everruns_sdk.client import CHANGE_REASON_HEADER, MAX_CHANGE_REASON_CHARS, Everruns
1313
from everruns_sdk.errors import (
1414
ApiError,
1515
AuthenticationError,
@@ -94,6 +94,8 @@
9494

9595
__all__ = [
9696
"Everruns",
97+
"CHANGE_REASON_HEADER",
98+
"MAX_CHANGE_REASON_CHARS",
9799
"ApiKey",
98100
"EverrunsError",
99101
"ApiError",

‎python/everruns_sdk/client.py‎

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import asyncio
66
import os
77
from typing import Any, Literal, Optional
8-
from urllib.parse import urlencode
8+
from urllib.parse import quote, urlencode
99

1010
import httpx
1111

@@ -79,6 +79,23 @@
7979

8080
DEFAULT_BASE_URL = "https://app.everruns.com/api"
8181

82+
#: Request header carrying a change reason; the server records it in the
83+
#: changed entity's history.
84+
CHANGE_REASON_HEADER = "Everruns-Change-Reason"
85+
86+
#: Longest change reason the server accepts, in characters.
87+
MAX_CHANGE_REASON_CHARS = 1000
88+
89+
90+
def _encode_change_reason(reason: str) -> str:
91+
"""Percent-encode a change reason as UTF-8.
92+
93+
Only RFC 3986 unreserved characters stay bare: the encoding the server's
94+
decoder and the Everruns CLI use. ``+`` is encoded rather than read as a
95+
space.
96+
"""
97+
return quote(reason, safe="", encoding="utf-8")
98+
8299

83100
def _is_html_response(body: str) -> bool:
84101
"""Check if the body looks like an HTML response."""
@@ -149,6 +166,9 @@ def __init__(
149166

150167
self._api_key = ApiKey(api_key)
151168
self._org_id = _validate_org_id(org_id)
169+
# Set only on clients derived with `with_reason`, which share `_client`.
170+
self._change_reason: Optional[str] = None
171+
self._owns_client = True
152172
# Ensure base URL has trailing slash for correct URL joining.
153173
# httpx follows RFC 3986: without trailing slash, relative paths
154174
# replace the last path segment instead of appending.
@@ -161,6 +181,32 @@ def __init__(
161181
timeout=30.0,
162182
)
163183

184+
def with_reason(self, reason: str) -> "Everruns":
185+
"""Derive a client that records ``reason`` as the change reason of every request.
186+
187+
The derived client shares this client's connection pool and settings;
188+
this client is left unchanged. The reason travels in the
189+
``Everruns-Change-Reason`` header, UTF-8 percent-encoded, and lands in
190+
the changed entity's history. A blank reason sends no header.
191+
192+
The server trims the reason and rejects (HTTP 400,
193+
``invalid_change_reason``) one longer than ``MAX_CHANGE_REASON_CHARS``
194+
characters, one with control characters other than newline and tab,
195+
or one that looks like it contains a credential.
196+
197+
The derived client does not own the connection: closing it is a no-op;
198+
close the client it was derived from.
199+
200+
Example:
201+
>>> await client.with_reason("retire the unused agent").agents.delete("agent_123")
202+
"""
203+
derived = object.__new__(Everruns)
204+
derived.__dict__.update(self.__dict__)
205+
trimmed = reason.strip()
206+
derived._change_reason = _encode_change_reason(trimmed) if trimmed else None
207+
derived._owns_client = False
208+
return derived
209+
164210
@property
165211
def agents(self) -> AgentsClient:
166212
"""Get the agents client."""
@@ -231,23 +277,31 @@ def _auth_headers(self, *, content_type: Optional[str] = "application/json") ->
231277
headers = {"Authorization": self._api_key.value}
232278
if content_type is not None:
233279
headers["Content-Type"] = content_type
280+
headers.update(self._reason_headers())
234281
if self._org_id is not None:
235282
headers["X-Org-Id"] = self._org_id
236283
return headers
237284

285+
def _reason_headers(self) -> dict[str, str]:
286+
# Per request, because derived clients share one httpx client whose
287+
# default headers carry no reason.
288+
if self._change_reason is None:
289+
return {}
290+
return {CHANGE_REASON_HEADER: self._change_reason}
291+
238292
async def _get(self, path: str) -> Any:
239-
resp = await self._client.get(self._url(path))
293+
resp = await self._client.get(self._url(path), headers=self._reason_headers())
240294
return await self._handle_response(resp)
241295

242296
async def _get_text(self, path: str) -> str:
243-
resp = await self._client.get(self._url(path))
297+
resp = await self._client.get(self._url(path), headers=self._reason_headers())
244298
if resp.is_success:
245299
return resp.text
246300
await self._raise_error(resp)
247301
return "" # unreachable
248302

249303
async def _post(self, path: str, data: Any) -> Any:
250-
resp = await self._client.post(self._url(path), json=data)
304+
resp = await self._client.post(self._url(path), json=data, headers=self._reason_headers())
251305
return await self._handle_response(resp)
252306

253307
async def _post_text(self, path: str, content: str) -> Any:
@@ -259,25 +313,25 @@ async def _post_text(self, path: str, content: str) -> Any:
259313
return await self._handle_response(resp)
260314

261315
async def _patch(self, path: str, data: Any) -> Any:
262-
resp = await self._client.patch(self._url(path), json=data)
316+
resp = await self._client.patch(self._url(path), json=data, headers=self._reason_headers())
263317
return await self._handle_response(resp)
264318

265319
async def _put(self, path: str, data: Any) -> Any:
266-
resp = await self._client.put(self._url(path), json=data)
320+
resp = await self._client.put(self._url(path), json=data, headers=self._reason_headers())
267321
return await self._handle_response(resp)
268322

269323
async def _put_empty(self, path: str) -> None:
270-
resp = await self._client.put(self._url(path))
324+
resp = await self._client.put(self._url(path), headers=self._reason_headers())
271325
if not resp.is_success:
272326
await self._raise_error(resp)
273327

274328
async def _delete(self, path: str) -> None:
275-
resp = await self._client.delete(self._url(path))
329+
resp = await self._client.delete(self._url(path), headers=self._reason_headers())
276330
if not resp.is_success:
277331
await self._raise_error(resp)
278332

279333
async def _delete_json(self, path: str) -> Any:
280-
resp = await self._client.delete(self._url(path))
334+
resp = await self._client.delete(self._url(path), headers=self._reason_headers())
281335
return await self._handle_response(resp)
282336

283337
async def _handle_response(self, resp: httpx.Response) -> Any:
@@ -299,8 +353,9 @@ async def _raise_error(self, resp: httpx.Response) -> None:
299353
raise ApiError.from_response(resp.status_code, body)
300354

301355
async def close(self) -> None:
302-
"""Close the HTTP client."""
303-
await self._client.aclose()
356+
"""Close the HTTP client (a no-op on a client derived with ``with_reason``)."""
357+
if self._owns_client:
358+
await self._client.aclose()
304359

305360
async def __aenter__(self) -> "Everruns":
306361
return self

‎python/tests/test_client.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,74 @@ async def test_client_sends_org_id_header():
152152
assert route.calls[0].request.headers["X-Org-Id"] == "org_123"
153153

154154

155+
@pytest.mark.asyncio
156+
@respx.mock
157+
async def test_with_reason_sends_encoded_change_reason_header():
158+
route = respx.delete("https://app.everruns.com/api/v1/agents/agent_1").mock(
159+
return_value=httpx.Response(204)
160+
)
161+
162+
client = Everruns(api_key="evr_test_key", org_id="org_123")
163+
try:
164+
await client.with_reason(" retire \u00fcnused agent \u2713 ").agents.delete("agent_1")
165+
finally:
166+
await client.close()
167+
168+
request = route.calls[0].request
169+
assert request.headers["Everruns-Change-Reason"] == "retire%20%C3%BCnused%20agent%20%E2%9C%93"
170+
assert request.headers["X-Org-Id"] == "org_123"
171+
assert request.headers["Authorization"]
172+
173+
174+
@pytest.mark.asyncio
175+
@respx.mock
176+
async def test_with_reason_covers_text_bodies():
177+
route = respx.post("https://app.everruns.com/api/v1/agents/import").mock(
178+
return_value=httpx.Response(200, json=_agent_response())
179+
)
180+
181+
client = Everruns(api_key="evr_test_key")
182+
try:
183+
await client.with_reason("a+b/c").agents.import_agent("name: a")
184+
finally:
185+
await client.close()
186+
187+
assert route.calls[0].request.headers["Everruns-Change-Reason"] == "a%2Bb%2Fc"
188+
189+
190+
@pytest.mark.asyncio
191+
@respx.mock
192+
async def test_original_client_sends_no_change_reason():
193+
route = respx.delete("https://app.everruns.com/api/v1/agents/agent_1").mock(
194+
return_value=httpx.Response(204)
195+
)
196+
197+
client = Everruns(api_key="evr_test_key")
198+
derived = client.with_reason("only on the derived client")
199+
try:
200+
await client.agents.delete("agent_1")
201+
await derived.close() # no-op: the derived client does not own the connection
202+
await client.with_reason(" ").agents.delete("agent_1")
203+
finally:
204+
await client.close()
205+
206+
assert route.call_count == 2
207+
for call in route.calls:
208+
assert "Everruns-Change-Reason" not in call.request.headers
209+
assert derived._client is client._client
210+
211+
212+
def test_change_reason_encoding_matches_server_decoder():
213+
from everruns_sdk.client import _encode_change_reason
214+
215+
assert _encode_change_reason("make it kid friendly \u2713") == (
216+
"make%20it%20kid%20friendly%20%E2%9C%93"
217+
)
218+
assert _encode_change_reason("a+b/c?d=e&f") == "a%2Bb%2Fc%3Fd%3De%26f"
219+
assert _encode_change_reason("line\nnext") == "line%0Anext"
220+
assert _encode_change_reason("Az09-_.~") == "Az09-_.~"
221+
222+
155223
def test_capabilities_subclient():
156224
"""Test that capabilities sub-client is available."""
157225
client = Everruns(api_key="evr_test_key")

‎rust/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,27 @@ let client = Everruns::builder()
191191
.build()?;
192192
```
193193

194+
## Change Reasons
195+
196+
Record why a change was made. `with_reason` derives a client that sends the
197+
reason with every request it makes; the server stores it in the changed
198+
entity's history. The derived client shares the original's connection pool, and
199+
the original stays unchanged:
200+
201+
```rust
202+
client
203+
.with_reason("retire the unused agent")
204+
.agents()
205+
.delete("agent_123")
206+
.await?;
207+
208+
let scoped = client.with_reason("rotate prompts for the Q3 launch");
209+
scoped.agents().apply(&id, "assistant", "You are concise.").await?;
210+
```
211+
212+
The reason travels in the `Everruns-Change-Reason` header, UTF-8
213+
percent-encoded. A blank reason sends no header. The server trims the reason and rejects it with HTTP 400 (`invalid_change_reason`) when it is longer than 1000 characters, contains control characters other than newline and tab, or looks like it contains a credential.
214+
194215
## Streaming Events
195216

196217
The SDK supports SSE streaming with automatic reconnection:

0 commit comments

Comments
 (0)