Files
langgraph/libs/sdk-py/tests/test_crons_client.py
T
Hugo DURANDandGitHub b96f6170e7 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`.
2026-07-14 11:09:58 -04:00

699 lines
23 KiB
Python

"""Tests for the crons client."""
from __future__ import annotations
import json
from datetime import datetime, timezone
import httpx
import pytest
from langgraph_sdk.client import (
CronClient,
HttpClient,
SyncCronClient,
SyncHttpClient,
)
def _cron_payload() -> dict[str, object]:
"""Return a mock cron response payload."""
return {
"run_id": "run_123",
"thread_id": "thread_123",
"assistant_id": "asst_123",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z",
"status": "success",
"metadata": {},
"multitask_strategy": "reject",
}
@pytest.mark.asyncio
async def test_async_create_for_thread():
"""Test that CronClient.create_for_thread works without end_time."""
cron = _cron_payload()
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/threads/thread_123/runs/crons"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 0 * * *"
assert body["assistant_id"] == "asst_123"
assert "end_time" not in body # Should be filtered out by the None check
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.create_for_thread(
thread_id="thread_123",
assistant_id="asst_123",
schedule="0 0 * * *",
)
assert result == cron
@pytest.mark.asyncio
async def test_async_create_for_thread_with_end_time():
"""Test that CronClient.create_for_thread includes end_time in the payload."""
cron = _cron_payload()
end_time = datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/threads/thread_123/runs/crons"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 0 * * *"
assert body["assistant_id"] == "asst_123"
assert body["end_time"] == "2025-12-31T23:59:59+00:00"
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.create_for_thread(
thread_id="thread_123",
assistant_id="asst_123",
schedule="0 0 * * *",
end_time=end_time,
)
assert result == cron
@pytest.mark.asyncio
async def test_async_create():
"""Test that CronClient.create works without end_time."""
cron = _cron_payload()
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 12 * * *"
assert body["assistant_id"] == "asst_456"
assert "end_time" not in body # Should be filtered out by the None check
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.create(
assistant_id="asst_456",
schedule="0 12 * * *",
)
assert result == cron
@pytest.mark.asyncio
async def test_async_create_with_end_time():
"""Test that CronClient.create includes end_time in the payload."""
cron = _cron_payload()
end_time = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 12 * * *"
assert body["assistant_id"] == "asst_456"
assert body["end_time"] == "2025-06-15T12:00:00+00:00"
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.create(
assistant_id="asst_456",
schedule="0 12 * * *",
end_time=end_time,
)
assert result == cron
def test_sync_create_for_thread():
"""Test that SyncCronClient.create_for_thread works without end_time."""
cron = _cron_payload()
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/threads/thread_123/runs/crons"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 0 * * *"
assert body["assistant_id"] == "asst_123"
assert "end_time" not in body # Should be filtered out by the None check
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.create_for_thread(
thread_id="thread_123",
assistant_id="asst_123",
schedule="0 0 * * *",
)
assert result == cron
def test_sync_create_for_thread_with_end_time():
"""Test that SyncCronClient.create_for_thread includes end_time in the payload."""
cron = _cron_payload()
end_time = datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/threads/thread_123/runs/crons"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 0 * * *"
assert body["assistant_id"] == "asst_123"
assert body["end_time"] == "2025-12-31T23:59:59+00:00"
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.create_for_thread(
thread_id="thread_123",
assistant_id="asst_123",
schedule="0 0 * * *",
end_time=end_time,
)
assert result == cron
def test_sync_create():
"""Test that SyncCronClient.create works without end_time."""
cron = _cron_payload()
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 12 * * *"
assert body["assistant_id"] == "asst_456"
assert "end_time" not in body # Should be filtered out by the None check
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.create(
assistant_id="asst_456",
schedule="0 12 * * *",
)
assert result == cron
def test_sync_create_with_end_time():
"""Test that SyncCronClient.create includes end_time in the payload."""
cron = _cron_payload()
end_time = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 12 * * *"
assert body["assistant_id"] == "asst_456"
assert body["end_time"] == "2025-06-15T12:00:00+00:00"
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.create(
assistant_id="asst_456",
schedule="0 12 * * *",
end_time=end_time,
)
assert result == cron
@pytest.mark.parametrize(
"enabled_value",
[True, False],
ids=["enabled", "disabled"],
)
def test_sync_create_with_enabled_parameter(enabled_value):
"""Test that SyncCronClient.create includes enabled parameter in the payload."""
cron = _cron_payload()
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons"
body = json.loads(request.content)
assert body["schedule"] == "0 12 * * *"
assert body["assistant_id"] == "asst_456"
assert body["enabled"] == enabled_value
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.create(
assistant_id="asst_456",
schedule="0 12 * * *",
enabled=enabled_value,
)
assert result == cron
def _cron_response() -> dict[str, object]:
"""Return a mock Cron object response."""
return {
"cron_id": "cron_123",
"assistant_id": "asst_123",
"thread_id": "thread_123",
"on_run_completed": None,
"end_time": "2025-12-31T23:59:59+00:00",
"schedule": "0 10 * * *",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z",
"payload": {},
"user_id": None,
"next_run_date": "2024-01-03T10:00:00Z",
"metadata": {},
"enabled": True,
}
@pytest.mark.asyncio
async def test_async_update():
"""Test that CronClient.update works with schedule and enabled parameters."""
cron = _cron_response()
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "PATCH"
assert request.url.path == "/runs/crons/cron_123"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 10 * * *"
assert body["enabled"] is False
assert "end_time" not in body # Should be filtered out by the None check
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",
schedule="0 10 * * *",
enabled=False,
)
assert result == cron
@pytest.mark.asyncio
async def test_async_update_with_end_time():
"""Test that CronClient.update includes end_time in the payload."""
cron = _cron_response()
end_time = datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "PATCH"
assert request.url.path == "/runs/crons/cron_123"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 10 * * *"
assert body["end_time"] == "2025-12-31T23:59:59+00:00"
assert body["enabled"] is True
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",
schedule="0 10 * * *",
end_time=end_time,
enabled=True,
)
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()
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "PATCH"
assert request.url.path == "/runs/crons/cron_123"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 10 * * *"
assert body["enabled"] is False
assert "end_time" not in body # Should be filtered out by the None check
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",
schedule="0 10 * * *",
enabled=False,
)
assert result == cron
def test_sync_update_with_end_time():
"""Test that SyncCronClient.update includes end_time in the payload."""
cron = _cron_response()
end_time = datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "PATCH"
assert request.url.path == "/runs/crons/cron_123"
# Parse the request body
body = json.loads(request.content)
assert body["schedule"] == "0 10 * * *"
assert body["end_time"] == "2025-12-31T23:59:59+00:00"
assert body["enabled"] is True
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",
schedule="0 10 * * *",
end_time=end_time,
enabled=True,
)
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],
ids=["enabled", "disabled"],
)
def test_sync_update_with_enabled_parameter(enabled_value):
"""Test that SyncCronClient.update includes enabled parameter in the payload."""
cron = _cron_response()
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "PATCH"
assert request.url.path == "/runs/crons/cron_456"
body = json.loads(request.content)
assert body["enabled"] == enabled_value
assert "schedule" not in body # Only enabled is set
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_456",
enabled=enabled_value,
)
assert result == cron
@pytest.mark.asyncio
async def test_async_search_with_metadata():
"""Test that CronClient.search forwards metadata in the request body."""
cron = _cron_response()
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons/search"
body = json.loads(request.content)
assert body["metadata"] == {"owner": "alice"}
assert body["limit"] == 10
assert body["offset"] == 0
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.search(metadata={"owner": "alice"})
assert result == [cron]
@pytest.mark.asyncio
async def test_async_search_omits_empty_metadata():
"""Test that CronClient.search does not send metadata when not provided."""
cron = _cron_response()
async def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert "metadata" not in body
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)
await cron_client.search()
@pytest.mark.asyncio
async def test_async_count_with_metadata():
"""Test that CronClient.count forwards metadata in the request body."""
async def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons/count"
body = json.loads(request.content)
assert body["metadata"] == {"team": "infra"}
return httpx.Response(200, json=2)
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.count(metadata={"team": "infra"})
assert result == 2
@pytest.mark.asyncio
async def test_async_count_omits_empty_metadata():
"""Test that CronClient.count does not send metadata when not provided."""
async def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert "metadata" not in body
return httpx.Response(200, json=0)
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)
await cron_client.count()
def test_sync_search_with_metadata():
"""Test that SyncCronClient.search forwards metadata in the request body."""
cron = _cron_response()
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons/search"
body = json.loads(request.content)
assert body["metadata"] == {"owner": "alice"}
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.search(metadata={"owner": "alice"})
assert result == [cron]
def test_sync_search_omits_empty_metadata():
"""Test that SyncCronClient.search does not send metadata when not provided."""
cron = _cron_response()
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert "metadata" not in body
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)
cron_client.search()
def test_sync_count_with_metadata():
"""Test that SyncCronClient.count forwards metadata in the request body."""
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/runs/crons/count"
body = json.loads(request.content)
assert body["metadata"] == {"team": "infra"}
return httpx.Response(200, json=2)
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.count(metadata={"team": "infra"})
assert result == 2
def test_sync_count_omits_empty_metadata():
"""Test that SyncCronClient.count does not send metadata when not provided."""
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
assert "metadata" not in body
return httpx.Response(200, json=0)
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)
cron_client.count()