From e6f2fdcbe05ea92246556ba63ae15c42b7bbd0ae Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 29 Jul 2026 10:09:32 -0600 Subject: [PATCH 1/3] Fix default purge history start time Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed5b5ae5-2c5e-4422-9786-a23ad1618634 --- CHANGELOG.md | 4 ++++ .../models/DurableOrchestrationClient.py | 6 +++++- .../models/RpcManagementOptions.py | 3 ++- .../models/test_DurableOrchestrationClient.py | 21 +++++++++++++------ tests/models/test_RpcManagementOptions.py | 10 +++++++++ 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0751fd63..70403f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes to this project will be documented in this file. - Type-hint-driven validation via `df_loads(s, expected_type=...)`: when the V2 programming model provides a return-type annotation for an activity or sub-orchestrator, the annotation is threaded through call sites so the SDK's `df_loads` can validate the deserialized payload against that type (when available). On older `azure-functions` releases the argument is accepted but ignored. - Return-type discovery for V2 decorated activities/sub-orchestrators (`azure.durable_functions.models.utils.type_discovery`): resolves the concrete return annotation from the user's registered function, used to supply `expected_type` to `df_loads`. +### Fixed + +- `purge_instance_history_by` now supplies the earliest supported creation time when `created_time_from` is omitted, avoiding HTTP 400 responses from the Durable extension. + ## 1.0.0b6 - [Create timer](https://github.com/Azure/azure-functions-durable-python/issues/35) functionality available diff --git a/azure/durable_functions/models/DurableOrchestrationClient.py b/azure/durable_functions/models/DurableOrchestrationClient.py index b6acc389..e8887887 100644 --- a/azure/durable_functions/models/DurableOrchestrationClient.py +++ b/azure/durable_functions/models/DurableOrchestrationClient.py @@ -419,7 +419,8 @@ async def purge_instance_history_by( Parameters ---------- created_time_from : Optional[datetime] - Delete orchestration history which were created after this Date. + Delete orchestration history which were created after this Date. Defaults to the + earliest datetime supported by Python. created_time_to: Optional[datetime] Delete orchestration history which were created before this Date. runtime_status: Optional[List[OrchestrationRuntimeStatus]] @@ -431,6 +432,9 @@ async def purge_instance_history_by( PurgeHistoryResult The results of the request to purge history """ + if created_time_from is None: + created_time_from = datetime.min + options = RpcManagementOptions(created_time_from=created_time_from, created_time_to=created_time_to, runtime_status=runtime_status) diff --git a/azure/durable_functions/models/RpcManagementOptions.py b/azure/durable_functions/models/RpcManagementOptions.py index b41d1493..18d43398 100644 --- a/azure/durable_functions/models/RpcManagementOptions.py +++ b/azure/durable_functions/models/RpcManagementOptions.py @@ -37,7 +37,8 @@ def _add_arg(query: List[str], name: str, value: Any): @staticmethod def _add_date_arg(query: List[str], name: str, value: Optional[datetime]): if value: - date_as_string = value.strftime(DATETIME_STRING_FORMAT) + date_format = DATETIME_STRING_FORMAT.replace("%Y", f"{value.year:04d}", 1) + date_as_string = value.strftime(date_format) RpcManagementOptions._add_arg(query, name, date_as_string) def to_url(self, base_url: Optional[str]) -> str: diff --git a/tests/models/test_DurableOrchestrationClient.py b/tests/models/test_DurableOrchestrationClient.py index 1466587c..a00ff750 100644 --- a/tests/models/test_DurableOrchestrationClient.py +++ b/tests/models/test_DurableOrchestrationClient.py @@ -351,8 +351,11 @@ async def test_delete_500_purge_instance_history(binding_string): @pytest.mark.asyncio async def test_delete_200_purge_instance_history_by(binding_string): - mock_request = MockRequest(expected_url=f"{RPC_BASE_URL}instances/?runtimeStatus=Running", - response=[200, dict(instancesDeleted=1)]) + mock_request = MockRequest( + expected_url=f"{RPC_BASE_URL}instances/" + "?createdTimeFrom=0001-01-01T00:00:00.000000Z" + "&runtimeStatus=Running", + response=[200, dict(instancesDeleted=1)]) client = DurableOrchestrationClient(binding_string) client._delete_async_request = mock_request.delete @@ -364,8 +367,11 @@ async def test_delete_200_purge_instance_history_by(binding_string): @pytest.mark.asyncio async def test_delete_404_purge_instance_history_by(binding_string): - mock_request = MockRequest(expected_url=f"{RPC_BASE_URL}instances/?runtimeStatus=Running", - response=[404, MESSAGE_404]) + mock_request = MockRequest( + expected_url=f"{RPC_BASE_URL}instances/" + "?createdTimeFrom=0001-01-01T00:00:00.000000Z" + "&runtimeStatus=Running", + response=[404, MESSAGE_404]) client = DurableOrchestrationClient(binding_string) client._delete_async_request = mock_request.delete @@ -377,8 +383,11 @@ async def test_delete_404_purge_instance_history_by(binding_string): @pytest.mark.asyncio async def test_delete_500_purge_instance_history_by(binding_string): - mock_request = MockRequest(expected_url=f"{RPC_BASE_URL}instances/?runtimeStatus=Running", - response=[500, MESSAGE_500]) + mock_request = MockRequest( + expected_url=f"{RPC_BASE_URL}instances/" + "?createdTimeFrom=0001-01-01T00:00:00.000000Z" + "&runtimeStatus=Running", + response=[500, MESSAGE_500]) client = DurableOrchestrationClient(binding_string) client._delete_async_request = mock_request.delete diff --git a/tests/models/test_RpcManagementOptions.py b/tests/models/test_RpcManagementOptions.py index 86e76ba5..c5887b49 100644 --- a/tests/models/test_RpcManagementOptions.py +++ b/tests/models/test_RpcManagementOptions.py @@ -77,3 +77,13 @@ def test_datetime_status(): f"&createdTimeTo={to_as_string}" assert_urls_match(expected=expected, result=result) + + +def test_min_datetime_status_uses_four_digit_year(): + options = RpcManagementOptions(created_time_from=datetime.min) + + result = options.to_url(RPC_BASE_URL) + + expected = f"{RPC_BASE_URL}instances/" \ + "?createdTimeFrom=0001-01-01T00:00:00.000000Z" + assert_urls_match(expected=expected, result=result) From 34e5bc9c100da19e9c76684651c1112f0b430325 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 30 Jul 2026 10:00:41 -0600 Subject: [PATCH 2/3] Use terminal status in purge tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed5b5ae5-2c5e-4422-9786-a23ad1618634 --- tests/models/test_DurableOrchestrationClient.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/models/test_DurableOrchestrationClient.py b/tests/models/test_DurableOrchestrationClient.py index a00ff750..940db737 100644 --- a/tests/models/test_DurableOrchestrationClient.py +++ b/tests/models/test_DurableOrchestrationClient.py @@ -354,13 +354,13 @@ async def test_delete_200_purge_instance_history_by(binding_string): mock_request = MockRequest( expected_url=f"{RPC_BASE_URL}instances/" "?createdTimeFrom=0001-01-01T00:00:00.000000Z" - "&runtimeStatus=Running", + "&runtimeStatus=Completed", response=[200, dict(instancesDeleted=1)]) client = DurableOrchestrationClient(binding_string) client._delete_async_request = mock_request.delete result = await client.purge_instance_history_by( - runtime_status=[OrchestrationRuntimeStatus.Running]) + runtime_status=[OrchestrationRuntimeStatus.Completed]) assert result is not None assert result.instances_deleted == 1 @@ -370,13 +370,13 @@ async def test_delete_404_purge_instance_history_by(binding_string): mock_request = MockRequest( expected_url=f"{RPC_BASE_URL}instances/" "?createdTimeFrom=0001-01-01T00:00:00.000000Z" - "&runtimeStatus=Running", + "&runtimeStatus=Completed", response=[404, MESSAGE_404]) client = DurableOrchestrationClient(binding_string) client._delete_async_request = mock_request.delete result = await client.purge_instance_history_by( - runtime_status=[OrchestrationRuntimeStatus.Running]) + runtime_status=[OrchestrationRuntimeStatus.Completed]) assert result is not None assert result.instances_deleted == 0 @@ -386,14 +386,14 @@ async def test_delete_500_purge_instance_history_by(binding_string): mock_request = MockRequest( expected_url=f"{RPC_BASE_URL}instances/" "?createdTimeFrom=0001-01-01T00:00:00.000000Z" - "&runtimeStatus=Running", + "&runtimeStatus=Completed", response=[500, MESSAGE_500]) client = DurableOrchestrationClient(binding_string) client._delete_async_request = mock_request.delete with pytest.raises(Exception): await client.purge_instance_history_by( - runtime_status=[OrchestrationRuntimeStatus.Running]) + runtime_status=[OrchestrationRuntimeStatus.Completed]) @pytest.mark.asyncio From 4933ff3016ca543ab4b51d2c2df54761677efb20 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 30 Jul 2026 10:52:52 -0600 Subject: [PATCH 3/3] Require purge history start time Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ed5b5ae5-2c5e-4422-9786-a23ad1618634 --- CHANGELOG.md | 4 ++-- .../models/DurableOrchestrationClient.py | 11 ++++++++--- .../models/RpcManagementOptions.py | 3 +-- .../models/test_DurableOrchestrationClient.py | 19 ++++++++++++++++--- tests/models/test_RpcManagementOptions.py | 10 ---------- 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70403f0d..41fbf380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ All notable changes to this project will be documented in this file. - Type-hint-driven validation via `df_loads(s, expected_type=...)`: when the V2 programming model provides a return-type annotation for an activity or sub-orchestrator, the annotation is threaded through call sites so the SDK's `df_loads` can validate the deserialized payload against that type (when available). On older `azure-functions` releases the argument is accepted but ignored. - Return-type discovery for V2 decorated activities/sub-orchestrators (`azure.durable_functions.models.utils.type_discovery`): resolves the concrete return annotation from the user's registered function, used to supply `expected_type` to `df_loads`. -### Fixed +### Changed -- `purge_instance_history_by` now supplies the earliest supported creation time when `created_time_from` is omitted, avoiding HTTP 400 responses from the Durable extension. +- `purge_instance_history_by` now raises a clear `ValueError` when the required `created_time_from` argument is omitted, instead of sending a request that the Durable extension rejects. ## 1.0.0b6 diff --git a/azure/durable_functions/models/DurableOrchestrationClient.py b/azure/durable_functions/models/DurableOrchestrationClient.py index e8887887..0806dfc7 100644 --- a/azure/durable_functions/models/DurableOrchestrationClient.py +++ b/azure/durable_functions/models/DurableOrchestrationClient.py @@ -419,8 +419,8 @@ async def purge_instance_history_by( Parameters ---------- created_time_from : Optional[datetime] - Delete orchestration history which were created after this Date. Defaults to the - earliest datetime supported by Python. + Delete orchestration history which were created after this Date. This argument + is required. created_time_to: Optional[datetime] Delete orchestration history which were created before this Date. runtime_status: Optional[List[OrchestrationRuntimeStatus]] @@ -431,9 +431,14 @@ async def purge_instance_history_by( ------- PurgeHistoryResult The results of the request to purge history + + Raises + ------ + ValueError + When `created_time_from` is not provided. """ if created_time_from is None: - created_time_from = datetime.min + raise ValueError("created_time_from is required when purging instance history") options = RpcManagementOptions(created_time_from=created_time_from, created_time_to=created_time_to, diff --git a/azure/durable_functions/models/RpcManagementOptions.py b/azure/durable_functions/models/RpcManagementOptions.py index 18d43398..b41d1493 100644 --- a/azure/durable_functions/models/RpcManagementOptions.py +++ b/azure/durable_functions/models/RpcManagementOptions.py @@ -37,8 +37,7 @@ def _add_arg(query: List[str], name: str, value: Any): @staticmethod def _add_date_arg(query: List[str], name: str, value: Optional[datetime]): if value: - date_format = DATETIME_STRING_FORMAT.replace("%Y", f"{value.year:04d}", 1) - date_as_string = value.strftime(date_format) + date_as_string = value.strftime(DATETIME_STRING_FORMAT) RpcManagementOptions._add_arg(query, name, date_as_string) def to_url(self, base_url: Optional[str]) -> str: diff --git a/tests/models/test_DurableOrchestrationClient.py b/tests/models/test_DurableOrchestrationClient.py index 940db737..b25240a7 100644 --- a/tests/models/test_DurableOrchestrationClient.py +++ b/tests/models/test_DurableOrchestrationClient.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from typing import Any import pytest @@ -353,13 +354,14 @@ async def test_delete_500_purge_instance_history(binding_string): async def test_delete_200_purge_instance_history_by(binding_string): mock_request = MockRequest( expected_url=f"{RPC_BASE_URL}instances/" - "?createdTimeFrom=0001-01-01T00:00:00.000000Z" + "?createdTimeFrom=2000-01-01T00:00:00.000000Z" "&runtimeStatus=Completed", response=[200, dict(instancesDeleted=1)]) client = DurableOrchestrationClient(binding_string) client._delete_async_request = mock_request.delete result = await client.purge_instance_history_by( + created_time_from=datetime(2000, 1, 1), runtime_status=[OrchestrationRuntimeStatus.Completed]) assert result is not None assert result.instances_deleted == 1 @@ -369,13 +371,14 @@ async def test_delete_200_purge_instance_history_by(binding_string): async def test_delete_404_purge_instance_history_by(binding_string): mock_request = MockRequest( expected_url=f"{RPC_BASE_URL}instances/" - "?createdTimeFrom=0001-01-01T00:00:00.000000Z" + "?createdTimeFrom=2000-01-01T00:00:00.000000Z" "&runtimeStatus=Completed", response=[404, MESSAGE_404]) client = DurableOrchestrationClient(binding_string) client._delete_async_request = mock_request.delete result = await client.purge_instance_history_by( + created_time_from=datetime(2000, 1, 1), runtime_status=[OrchestrationRuntimeStatus.Completed]) assert result is not None assert result.instances_deleted == 0 @@ -385,13 +388,23 @@ async def test_delete_404_purge_instance_history_by(binding_string): async def test_delete_500_purge_instance_history_by(binding_string): mock_request = MockRequest( expected_url=f"{RPC_BASE_URL}instances/" - "?createdTimeFrom=0001-01-01T00:00:00.000000Z" + "?createdTimeFrom=2000-01-01T00:00:00.000000Z" "&runtimeStatus=Completed", response=[500, MESSAGE_500]) client = DurableOrchestrationClient(binding_string) client._delete_async_request = mock_request.delete with pytest.raises(Exception): + await client.purge_instance_history_by( + created_time_from=datetime(2000, 1, 1), + runtime_status=[OrchestrationRuntimeStatus.Completed]) + + +@pytest.mark.asyncio +async def test_purge_instance_history_by_requires_created_time_from(binding_string): + client = DurableOrchestrationClient(binding_string) + + with pytest.raises(ValueError, match="created_time_from is required"): await client.purge_instance_history_by( runtime_status=[OrchestrationRuntimeStatus.Completed]) diff --git a/tests/models/test_RpcManagementOptions.py b/tests/models/test_RpcManagementOptions.py index c5887b49..86e76ba5 100644 --- a/tests/models/test_RpcManagementOptions.py +++ b/tests/models/test_RpcManagementOptions.py @@ -77,13 +77,3 @@ def test_datetime_status(): f"&createdTimeTo={to_as_string}" assert_urls_match(expected=expected, result=result) - - -def test_min_datetime_status_uses_four_digit_year(): - options = RpcManagementOptions(created_time_from=datetime.min) - - result = options.to_url(RPC_BASE_URL) - - expected = f"{RPC_BASE_URL}instances/" \ - "?createdTimeFrom=0001-01-01T00:00:00.000000Z" - assert_urls_match(expected=expected, result=result)