diff --git a/libs/sdk-py/langgraph_sdk/client.py b/libs/sdk-py/langgraph_sdk/client.py index 34c7e9863..e18584c55 100644 --- a/libs/sdk-py/langgraph_sdk/client.py +++ b/libs/sdk-py/langgraph_sdk/client.py @@ -17,6 +17,7 @@ import re import sys import warnings from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence +from datetime import datetime from types import TracebackType from typing import ( Any, @@ -2982,6 +2983,7 @@ class CronClient: interrupt_after: All | list[str] | None = None, webhook: str | None = None, multitask_strategy: str | None = None, + end_time: datetime | None = None, headers: Mapping[str, str] | None = None, params: QueryParamTypes | None = None, ) -> Run: @@ -3005,6 +3007,7 @@ class CronClient: webhook: Webhook to call after LangGraph API call is done. multitask_strategy: Multitask strategy to use. Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely. headers: Optional custom headers to include with the request. params: Optional query parameters to include with the request. @@ -3040,6 +3043,7 @@ class CronClient: "interrupt_before": interrupt_before, "interrupt_after": interrupt_after, "webhook": webhook, + "end_time": end_time.isoformat() if end_time else None, } if multitask_strategy: payload["multitask_strategy"] = multitask_strategy @@ -3066,6 +3070,7 @@ class CronClient: webhook: str | None = None, on_run_completed: OnCompletionBehavior | None = None, multitask_strategy: str | None = None, + end_time: datetime | None = None, headers: Mapping[str, str] | None = None, params: QueryParamTypes | None = None, ) -> Run: @@ -3090,6 +3095,7 @@ class CronClient: clean them up. Clients are responsible for cleaning up kept threads. multitask_strategy: Multitask strategy to use. Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely. headers: Optional custom headers to include with the request. params: Optional query parameters to include with the request. @@ -3126,6 +3132,7 @@ class CronClient: "interrupt_after": interrupt_after, "webhook": webhook, "on_run_completed": on_run_completed, + "end_time": end_time.isoformat() if end_time else None, } if multitask_strategy: payload["multitask_strategy"] = multitask_strategy @@ -6284,6 +6291,7 @@ class SyncCronClient: interrupt_after: All | list[str] | None = None, webhook: str | None = None, multitask_strategy: str | None = None, + end_time: datetime | None = None, headers: Mapping[str, str] | None = None, params: QueryParamTypes | None = None, ) -> Run: @@ -6305,6 +6313,7 @@ class SyncCronClient: webhook: Webhook to call after LangGraph API call is done. multitask_strategy: Multitask strategy to use. Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely. headers: Optional custom headers to include with the request. Returns: @@ -6340,6 +6349,7 @@ class SyncCronClient: "checkpoint_during": checkpoint_during, "webhook": webhook, "multitask_strategy": multitask_strategy, + "end_time": end_time.isoformat() if end_time else None, } payload = {k: v for k, v in payload.items() if v is not None} return self.http.post( @@ -6364,6 +6374,7 @@ class SyncCronClient: webhook: str | None = None, on_run_completed: OnCompletionBehavior | None = None, multitask_strategy: str | None = None, + end_time: datetime | None = None, headers: Mapping[str, str] | None = None, params: QueryParamTypes | None = None, ) -> Run: @@ -6388,6 +6399,7 @@ class SyncCronClient: clean them up. Clients are responsible for cleaning up kept threads. multitask_strategy: Multitask strategy to use. Must be one of 'reject', 'interrupt', 'rollback', or 'enqueue'. + end_time: The time to stop running the cron job. If not provided, the cron job will run indefinitely. headers: Optional custom headers to include with the request. Returns: @@ -6425,6 +6437,7 @@ class SyncCronClient: "checkpoint_during": checkpoint_during, "on_run_completed": on_run_completed, "multitask_strategy": multitask_strategy, + "end_time": end_time.isoformat() if end_time else None, } payload = {k: v for k, v in payload.items() if v is not None} return self.http.post( diff --git a/libs/sdk-py/tests/test_crons_client.py b/libs/sdk-py/tests/test_crons_client.py new file mode 100644 index 000000000..128727599 --- /dev/null +++ b/libs/sdk-py/tests/test_crons_client.py @@ -0,0 +1,278 @@ +"""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