From d7ecf4071dbc5673826b79790ce2a4cdc9238fc3 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 10 Nov 2026 10:30:20 -0700 Subject: [PATCH] Preserve behavior while narrowing Ruff exceptions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../internal/orchestration_entity_context.py | 3 +- durabletask/scheduled/schedule_status.py | 3 +- pyproject.toml | 6 -- .../scheduled/test_schedule_status.py | 40 ++++++++ .../test_orchestration_entity_context.py | 98 +++++++++++++++++++ 5 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 tests/durabletask/scheduled/test_schedule_status.py create mode 100644 tests/durabletask/test_orchestration_entity_context.py diff --git a/durabletask/internal/orchestration_entity_context.py b/durabletask/internal/orchestration_entity_context.py index 3366590d..9406cf12 100644 --- a/durabletask/internal/orchestration_entity_context.py +++ b/durabletask/internal/orchestration_entity_context.py @@ -28,7 +28,8 @@ def is_inside_critical_section(self) -> bool: def get_available_entities(self) -> Generator[EntityInstanceId, None, None]: if self.is_inside_critical_section: - for available_lock in self.available_locks: + # Do not forward send, throw, or close to the lock iterator. + for available_lock in self.available_locks: # noqa: UP028 yield available_lock def validate_suborchestration_transition(self) -> tuple[bool, str]: diff --git a/durabletask/scheduled/schedule_status.py b/durabletask/scheduled/schedule_status.py index af6ffc5c..73fcb574 100644 --- a/durabletask/scheduled/schedule_status.py +++ b/durabletask/scheduled/schedule_status.py @@ -4,7 +4,8 @@ from enum import Enum -class ScheduleStatus(str, Enum): +# StrEnum would change the public string and format representations. +class ScheduleStatus(str, Enum): # noqa: UP042 """Represents the current status of a schedule.""" UNINITIALIZED = "Uninitialized" diff --git a/pyproject.toml b/pyproject.toml index eb64b915..8494520e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,9 +134,3 @@ ignore = [ # https://github.com/microsoft/durabletask-python/issues/323 "UP017", ] - -[tool.ruff.lint.per-file-ignores] -# These suggestions can change generator protocol or public enum behavior: -# https://github.com/microsoft/durabletask-python/issues/324 -"durabletask/internal/orchestration_entity_context.py" = ["UP028"] -"durabletask/scheduled/schedule_status.py" = ["UP042"] diff --git a/tests/durabletask/scheduled/test_schedule_status.py b/tests/durabletask/scheduled/test_schedule_status.py new file mode 100644 index 00000000..e8e451e4 --- /dev/null +++ b/tests/durabletask/scheduled/test_schedule_status.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json + +import pytest + +from durabletask.scheduled import ScheduleInvalidTransitionError, ScheduleStatus + + +@pytest.mark.parametrize(("status", "name", "value", "ordinal"), [ + (ScheduleStatus.UNINITIALIZED, "UNINITIALIZED", "Uninitialized", 0), + (ScheduleStatus.ACTIVE, "ACTIVE", "Active", 1), + (ScheduleStatus.PAUSED, "PAUSED", "Paused", 2), +]) +def test_status_preserves_string_formatting_and_conversions( + status: ScheduleStatus, name: str, value: str, ordinal: int +) -> None: + formatted = f"ScheduleStatus.{name}" + assert isinstance(status, str) + assert status == value + assert str(status) == formatted + assert f"{status}" == formatted + assert format(status, ">30") == formatted.rjust(30) + assert json.dumps(status) == json.dumps(value) + assert ScheduleStatus(value) is status + assert status.to_dotnet_ordinal() == ordinal + assert ScheduleStatus.from_dotnet(ordinal) is status + assert ScheduleStatus.from_dotnet(value) is status + + +def test_transition_error_preserves_status_formatting() -> None: + error = ScheduleInvalidTransitionError( + "schedule", ScheduleStatus.PAUSED, ScheduleStatus.PAUSED, "pause_schedule") + + assert str(error) == ( + "Invalid state transition for schedule 'schedule': operation " + "'pause_schedule' cannot transition from 'ScheduleStatus.PAUSED' " + "to 'ScheduleStatus.PAUSED'." + ) diff --git a/tests/durabletask/test_orchestration_entity_context.py b/tests/durabletask/test_orchestration_entity_context.py new file mode 100644 index 00000000..f41e2385 --- /dev/null +++ b/tests/durabletask/test_orchestration_entity_context.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from collections.abc import Generator +from typing import cast + +import pytest + +from durabletask.entities import EntityInstanceId +from durabletask.internal.orchestration_entity_context import OrchestrationEntityContext + + +class _ProtocolAwareLocks(list[EntityInstanceId]): + def __init__(self, locks: list[EntityInstanceId]) -> None: + super().__init__(locks) + self.received_exception = False + self.closed = False + self.iterator = self._iterate() + + def _iterate(self) -> Generator[EntityInstanceId, None, None]: + try: + yield from super().__iter__() + except ValueError: + self.received_exception = True + raise + finally: + self.closed = True + + def __iter__(self) -> Generator[EntityInstanceId, None, None]: + return self.iterator + + +def _context_with_locks(locks: list[EntityInstanceId]) -> OrchestrationEntityContext: + context = OrchestrationEntityContext("owner") + context.critical_section_id = "section" + context.available_locks = locks + return context + + +def test_available_entities_follow_critical_section_lock_order() -> None: + first = EntityInstanceId("counter", "a") + second = EntityInstanceId("counter", "b") + context = OrchestrationEntityContext("owner") + + assert list(context.get_available_entities()) == [] + context.emit_acquire_message("section", [second, first]) + assert list(context.get_available_entities()) == [] + context.complete_acquire("section") + assert list(context.get_available_entities()) == [first, second] + + assert context.validate_operation_transition(first, one_way=False) == (True, "") + assert list(context.get_available_entities()) == [second] + context.recover_lock_after_call(first) + assert list(context.get_available_entities()) == [second, first] + + list(context.emit_lock_release_messages()) + assert list(context.get_available_entities()) == [] + + +def test_available_entities_ignores_sent_values() -> None: + locks = [EntityInstanceId("counter", key) for key in ("a", "b", "c")] + context = _context_with_locks(locks) + # The runtime accepts non-None sends even though the annotation requires None. + available = cast(Generator[EntityInstanceId, object, None], context.get_available_entities()) + + assert next(available) == locks[0] + assert available.send(None) == locks[1] + assert available.send("ignored") == locks[2] + with pytest.raises(StopIteration): + next(available) + + +def test_available_entities_does_not_forward_throw() -> None: + first = EntityInstanceId("counter", "a") + second = EntityInstanceId("counter", "b") + locks = _ProtocolAwareLocks([first, second]) + available = _context_with_locks(locks).get_available_entities() + + assert next(available) == first + with pytest.raises(ValueError, match="outer generator"): + available.throw(ValueError("outer generator")) + assert not locks.received_exception + assert not locks.closed + assert next(locks.iterator) == second + locks.iterator.close() + + +def test_available_entities_does_not_forward_close() -> None: + first = EntityInstanceId("counter", "a") + second = EntityInstanceId("counter", "b") + locks = _ProtocolAwareLocks([first, second]) + available = _context_with_locks(locks).get_available_entities() + + assert next(available) == first + available.close() + assert not locks.closed + assert next(locks.iterator) == second + locks.iterator.close()