fix(sdk-py): support clearing cron end_time via update(end_time=None) (#8334)

`CronClient.update` / `SyncCronClient.update` stripped all `None`-valued
fields from the PATCH body, so `update(end_time=None)` could never clear
a previously set cron end time. This routes an explicit `None` through
as `end_time: null` (via the existing `NOT_PROVIDED` sentinel), while an
omitted argument still leaves the value unchanged.

Verified with `make format`, `make lint`, and `make test` in
`libs/sdk-py` (493 passed), including two new tests asserting the PATCH
body carries an explicit null `end_time`.
This commit is contained in:
Hugo DURAND
2026-07-14 11:09:58 -04:00
committed by GitHub
parent 55ec2f2193
commit b96f6170e7
3 changed files with 73 additions and 8 deletions
+12 -4
View File
@@ -8,7 +8,11 @@ from datetime import datetime, tzinfo
from typing import Any
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
from langgraph_sdk._shared.utilities import (
NOT_PROVIDED,
_quote_path_param,
_resolve_timezone,
)
from langgraph_sdk.schema import (
All,
Config,
@@ -324,7 +328,7 @@ class CronClient:
cron_id: str,
*,
schedule: str | None = None,
end_time: datetime | None = None,
end_time: datetime | None = NOT_PROVIDED,
input: Input | None = None,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
@@ -348,7 +352,8 @@ class CronClient:
cron_id: The cron ID to update.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC unless a timezone is specified.
end_time: The end date to stop running the cron.
end_time: The end date to stop running the cron. Pass ``None`` to
clear a previously set end time; omit to leave it unchanged.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
@@ -386,7 +391,6 @@ class CronClient:
"""
payload = {
"schedule": schedule,
"end_time": end_time.isoformat() if end_time else None,
"input": input,
"metadata": metadata,
"config": config,
@@ -403,6 +407,10 @@ class CronClient:
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
# An explicit end_time=None clears the end time; NOT_PROVIDED leaves it
# unchanged. Inject after the None-strip so the explicit null survives.
if end_time is not NOT_PROVIDED:
payload["end_time"] = end_time.isoformat() if end_time is not None else None
return await self.http.patch(
f"/runs/crons/{_quote_path_param(cron_id)}",
json=payload,
+12 -4
View File
@@ -7,7 +7,11 @@ from collections.abc import Mapping, Sequence
from datetime import datetime, tzinfo
from typing import Any
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
from langgraph_sdk._shared.utilities import (
NOT_PROVIDED,
_quote_path_param,
_resolve_timezone,
)
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
All,
@@ -313,7 +317,7 @@ class SyncCronClient:
cron_id: str,
*,
schedule: str | None = None,
end_time: datetime | None = None,
end_time: datetime | None = NOT_PROVIDED,
input: Input | None = None,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
@@ -337,7 +341,8 @@ class SyncCronClient:
cron_id: The cron ID to update.
schedule: The cron schedule to execute this job on.
Schedules are interpreted in UTC unless a timezone is specified.
end_time: The end date to stop running the cron.
end_time: The end date to stop running the cron. Pass ``None`` to
clear a previously set end time; omit to leave it unchanged.
input: The input to the graph.
metadata: Metadata to assign to the cron job runs.
config: The configuration for the assistant.
@@ -375,7 +380,6 @@ class SyncCronClient:
"""
payload = {
"schedule": schedule,
"end_time": end_time.isoformat() if end_time else None,
"input": input,
"metadata": metadata,
"config": config,
@@ -392,6 +396,10 @@ class SyncCronClient:
"durability": durability,
}
payload = {k: v for k, v in payload.items() if v is not None}
# An explicit end_time=None clears the end time; NOT_PROVIDED leaves it
# unchanged. Inject after the None-strip so the explicit null survives.
if end_time is not NOT_PROVIDED:
payload["end_time"] = end_time.isoformat() if end_time is not None else None
return self.http.patch(
f"/runs/crons/{_quote_path_param(cron_id)}",
json=payload,
+49
View File
@@ -396,6 +396,32 @@ async def test_async_update_with_end_time():
assert result == cron
@pytest.mark.asyncio
async def test_async_update_clears_end_time():
"""Test that CronClient.update sends an explicit null end_time to clear it."""
cron = _cron_response()
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "PATCH"
assert request.url.path == "/runs/crons/cron_123"
body = json.loads(request.content)
assert "end_time" in body # Explicit None must survive the None-strip
assert body["end_time"] is None
return httpx.Response(200, json=cron)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
http_client = HttpClient(client)
cron_client = CronClient(http_client)
result = await cron_client.update(cron_id="cron_123", end_time=None)
assert result == cron
def test_sync_update():
"""Test that SyncCronClient.update works with schedule and enabled parameters."""
cron = _cron_response()
@@ -456,6 +482,29 @@ def test_sync_update_with_end_time():
assert result == cron
def test_sync_update_clears_end_time():
"""Test that SyncCronClient.update sends an explicit null end_time to clear it."""
cron = _cron_response()
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "PATCH"
assert request.url.path == "/runs/crons/cron_123"
body = json.loads(request.content)
assert "end_time" in body # Explicit None must survive the None-strip
assert body["end_time"] is None
return httpx.Response(200, json=cron)
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
http_client = SyncHttpClient(client)
cron_client = SyncCronClient(http_client)
result = cron_client.update(cron_id="cron_123", end_time=None)
assert result == cron
@pytest.mark.parametrize(
"enabled_value",
[True, False],