diff --git a/CHANGELOG.md b/CHANGELOG.md index 0751fd6..ccd2c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,70 +1,370 @@ # Changelog -All notable changes to this project will be documented in this file. +All notable changes to this project are documented in this file. Release dates +and yanked-release status are based on the +[azure-functions-durable package on PyPI](https://pypi.org/project/azure-functions-durable/#history). ## Unreleased ### Added -- Client operation correlation logging: `FunctionInvocationId` is now propagated via HTTP headers to the host for client operations, enabling correlation with host logs. -- Centralized JSON serialization module (`azure.durable_functions.models.utils.df_serialization`): all serialization/deserialization of user payloads (orchestrator inputs/outputs, activity arguments and results, sub-orchestrator payloads, entity inputs/outputs, and client inputs) now flows through `df_dumps` / `df_loads`, replacing scattered `json.dumps(…, default=_serialize_custom_object)` / `json.loads(…, object_hook=_deserialize_custom_object)` calls. This module is a thin shim over the Azure Functions SDK: when the installed `azure-functions` exposes `df_dumps` / `df_loads` (the centralized serializers with type-validation and strict-typing support), they are used directly so our serialization matches the SDK's `ActivityTriggerConverter` at the host boundary; otherwise it falls back to the legacy `_serialize_custom_object` / `_deserialize_custom_object` hooks, which keeps both sides symmetric. The wire format is **unchanged** — builtins serialize to plain JSON and custom objects continue to use the `{"__class__", "__module__", "__data__"}` convention. -- 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`. +- Added `DurableFunctionsCompatibilityWarning`, emitted at import time for + applications using the legacy `function.json` programming model unless + `requirements.txt` demonstrably restricts `azure-functions-durable` below + version 2. -## 1.0.0b6 +## 1.6.0 - 2026-07-09 -- [Create timer](https://github.com/Azure/azure-functions-durable-python/issues/35) functionality available +### Added + +- Added `restart` support to `DurableOrchestrationClient`. +- Added type-aware payload serialization and deserialization through the + centralized `df_dumps` and `df_loads` APIs. +- Added Python 3.13 and 3.14 support. + +### Changed + +- `is_replaying` is now determined from orchestration history. +- Raised the minimum supported Python version to 3.10 and dropped Python 3.9. +- Updated dependencies to address known CVEs. + +## 1.5.0 - 2026-02-04 + +### Added + +- Added orchestration version overrides to orchestration-start APIs. +- Propagated `FunctionInvocationId` through durable client HTTP operations for + log correlation. + +### Changed + +- Reused `aiohttp.ClientSession` instances to reduce connection contention. +- Renamed and expanded the OpenAI Agent SDK integration preview. + +## 1.4.0 - 2025-09-24 + +### Added + +- Added the OpenAI Agent SDK integration preview for durable, stateful agents. + +### Changed + +- Corrected the integration name and updated its documentation and samples. + +## 1.4.0rc2 - 2025-09-23 + +- Published a release candidate of the OpenAI Agent SDK integration preview + with an OpenAI compatibility note. + +## 1.3.3 - 2025-08-21 + +### Added + +- Added the `version` property to `DurableOrchestrationContext`. + +### Changed + +- Updated `aiohttp` to 3.12.14. + +## 1.3.2 - 2025-06-17 + +### Fixed + +- Fixed long timers when using Durable Task Scheduler or MSSQL storage + providers. + +## 1.3.1 - 2025-06-12 + +### Fixed + +- Added dependencies omitted from 1.3.0 so the package installs successfully. +- Corrected package metadata to consistently require Python 3.9 or later. + +## 1.3.0 - 2025-06-12 [YANKED] + +This release was yanked from PyPI because required dependencies were missing. +Use 1.3.1 or later instead. + +### Added + +- Added support for long timers. +- Added helpers for unit testing orchestrators, entities, and durable clients. +- Added OpenTelemetry distributed tracing for orchestrations and entities. + +### Changed + +- Raised the minimum supported Python version to 3.9. +- Updated `requests` and `aiohttp`. + +### Fixed + +- Fixed `task_any` returning the same task multiple times in some cases. +- Improved handling of exceptions without an error message. + +## 1.2.10 - 2024-10-22 + +### Changed + +- Improved `call_http` content handling. +- Made `EntityId` equality compare entity names and keys. + +### Fixed + +- Fixed orchestrators returning JSON-serializable objects. +- Improved the error raised for invalid orchestration output. + +## 1.2.9 - 2024-02-14 + +### Added + +- Added durable client APIs for suspending and resuming orchestrations. + +### Fixed + +- Fixed deserialization of history events whose result is `None`. + +## 1.2.8 - 2023-11-07 + +### Fixed + +- Added a compatibility layer for the new `azure-functions` Settings API, + fixing the startup failure introduced in 1.2.7. + +## 1.2.7 - 2023-11-06 [YANKED] + +This release was yanked from PyPI because it could fail during application +startup. Use 1.2.8 or later instead. + +### Changed + +- Added support for the new `azure-functions` Settings API. + +## 1.2.6 - 2023-09-07 + +### Fixed + +- Prevented already scheduled tasks from being added to the open-tasks list. + +## 1.2.5 - 2023-08-08 + +### Fixed + +- Allowed repeated `task_any` calls over progressively smaller lists of + already scheduled tasks. + +## 1.2.4 - 2023-06-15 + +### Added + +- Added initial Durable Functions blueprint support. +- Added safer activity and sub-orchestrator invocation by function name for + the Python v2 programming model. + +## 1.2.3 - 2023-05-03 + +### Changed + +- Improved the error message returned by the durable client `terminate` API. + +## 1.2.2 - 2023-01-25 + +### Fixed + +- Exported the Python v2 programming model APIs only when the installed Azure + Functions worker supports them. + +## 1.2.1 - 2022-12-06 + +### Fixed + +- Raised the minimum `azure-functions` dependency to 1.12.0 for Python v2 + programming model support. -## 1.0.0b5 +## 1.2.0 - 2022-12-06 -- [Object serialization](https://github.com/Azure/azure-functions-durable-python/issues/90) made available -- [Can set custom status](https://github.com/Azure/azure-functions-durable-python/issues/117) of orchestration +### Added + +- Added preview support for the Azure Functions Python v2 programming model. + +## 1.1.6 - 2022-08-04 + +### Fixed + +- Fixed a regression in the `is_replaying` flag. +- Allowed subtasks to be yielded multiple times. + +## 1.1.5 - 2022-07-01 + +### Changed + +- Excluded the `azure` namespace package from builds to support dependency + isolation in the Azure Functions Python worker. + +## 1.1.4 - 2022-05-24 + +### Fixed + +- Allowed timers in `task_all` and `task_any` compound tasks to be cancelled + safely. +- Fixed orchestrations becoming stuck after the final failure of a retry API. + +## 1.1.3 - 2021-11-09 + +### Fixed + +- Allowed entity operation timeouts to be caught by orchestrators. -## 1.0.0b3-b4 -- Release to test CD pipeline with push to PyPI +## 1.1.2 - 2021-09-24 -## 1.0.0b2 +### Fixed + +- Allowed entity operation exceptions to be caught by orchestrators. +- Added missing timer task properties. + +## 1.1.1 - 2021-09-14 ### Fixed -- [Remove staticmethod definitions](https://github.com/Azure/azure-functions-durable-python/issues/65) -## 1.0.0b1 +- Fixed an edge case in retry APIs after a task failure. +- Allowed `task_all` to accept an empty task list. + +## 1.1.0 - 2021-08-17 + +### Changed + +- Replaced orchestration replay with a linear-time algorithm for improved + performance at scale. + +### Fixed + +- Fixed string input serialization in `continue_as_new`. + +## 1.0.3 - 2021-07-22 + +### Fixed + +- Fixed nondeterminism errors when passing a fixed instance ID to a + sub-orchestration. + +## 1.0.1 - 2021-06-25 ### Added -The following orchestration patterns are added: +- Added `read_entity_state` to `DurableOrchestrationClient`. -- Function Chaining -- Fan In Fan Out -- Async HTTP APIs -- Human Interaction +### Changed + +- Added support for the newer orchestration replay algorithm. -#### API Parity -- CallActivity -- CallActivityWithRetry -- Task.all -- Task.any -- waitForExternalEvent -- continueAsNew -- callHttp -- currentUtcDateTime -- newUuid -- createCheckStatusResponse -- getStatus -- getStatusAll -- getStatusBy -- purgeInstanceHistory -- purgeInstanceHistoryBy -- raiseEvent -- startNew -- terminate -- waitForCompletionOrCreateCheckStatusResponse +### Fixed + +- Prevented tasks from being scheduled again when yielded more than once. + +## 1.0.0 - 2021-03-05 + +First stable release of Durable Functions for Python. + +### Added + +- Added `new_guid` for deterministic UUID generation. +- Exported `OrchestrationRuntimeStatus` for status comparisons. +- Added a warning for applications configured with Extension Bundles v1. + +## 1.0.0b12 - 2020-12-19 + +### Added + +- Added Durable Entities support. ### Changed -N/A + +- Dropped support for Extension Bundles v1. ### Fixed -N/A -### Removed -N/A +- Fixed serialization of `datetime` objects. +- Fixed intermittent `None` values from `current_utc_datetime`. + +## 1.0.0b11 - 2020-11-19 + +### Fixed + +- Added custom object serialization to `call_activity_with_retry`. + +## 1.0.0b10 - 2020-10-20 + +### Fixed + +- Fixed managed identity token source metadata for end-to-end authentication. + +## 1.0.0b9 - 2020-09-18 + +### Fixed + +- Improved out-of-process error reporting so SDK errors are surfaced instead + of a generic null-reference error. + +## 1.0.0b8 - 2020-09-14 + +### Added + +- Added the experimental rewind API. +- Added enum-based orchestration runtime statuses. +- Added the `is_replaying` flag. + +### Fixed + +- Fixed local `purge_instance_history` failures. +- Improved retry behavior to prevent nondeterminism after errors. + +## 1.0.0b7 - 2020-08-05 + +### Added + +- Added sub-orchestrations. +- Added `create_http_management_payload`. +- Added type annotations and improved IntelliSense. + +### Fixed + +- Fixed `continue_as_new` failing to restart an orchestrator. +- Fixed Boolean return-value serialization. +- Fixed timer matching for closely spaced timestamps. + +## 1.0.0b6 - 2020-06-18 + +### Added + +- Added the `create_timer` API. + +## 1.0.0b5 - 2020-06-11 + +### Added + +- Added custom object serialization. +- Added custom orchestration status. + +## 1.0.0b4 - 2020-03-19 + +- Republished the third beta to correct the release pipeline. + +## 1.0.0b3 - 2020-03-19 + +- Validated the automated PyPI release pipeline. + +## 1.0.0b2 - 2020-03-14 + +### Fixed + +- Removed incorrect `staticmethod` declarations. + +## 1.0.0b1 - 2020-03-13 + +Initial public beta of Durable Functions for Python. + +### Added + +- Added function chaining, fan-out/fan-in, asynchronous HTTP APIs, and human + interaction orchestration patterns. +- Added orchestration APIs for activities, retries, compound tasks, external + events, HTTP calls, instance management, history queries, and purging. diff --git a/azure/durable_functions/__init__.py b/azure/durable_functions/__init__.py index a38fdba..01a6cde 100644 --- a/azure/durable_functions/__init__.py +++ b/azure/durable_functions/__init__.py @@ -12,11 +12,29 @@ from .models.RetryOptions import RetryOptions from .models.TokenSource import ManagedIdentityTokenSource import json +import logging +import os from pathlib import Path +import re import sys import warnings +_LOGGER = logging.getLogger(__name__) +_DURABLE_REQUIREMENT = re.compile( + r"^azure[-_.]functions[-_.]durable(?:\[[^\]]+\])?\s*" + r"(?P(?:===|~=|==|!=|<=|>=|<|>).*)?$", + re.IGNORECASE, +) +_VERSION_SPECIFIER = re.compile( + r"(===|~=|==|<=|>=|!=|<|>)\s*([^,\s]+)" +) + + +class DurableFunctionsCompatibilityWarning(UserWarning): + """Warn about application configurations incompatible with future releases.""" + + def validate_extension_bundles(): """Raise a warning if host.json contains bundle-range V1. @@ -57,9 +75,121 @@ def validate_extension_bundles(): warnings.warn(message) +def _find_function_app_root(): + """Find the function app root without searching parent directories.""" + candidates = [] + script_root = os.environ.get("AzureWebJobsScriptRoot") + if script_root: + candidates.append(Path(script_root)) + candidates.append(Path.cwd()) + + for candidate in candidates: + if (candidate / "host.json").is_file(): + return candidate + return None + + +def _release_parts(version): + """Return the numeric release prefix of a version specifier.""" + match = re.match(r"^v?(\d+(?:\.\d+)*)", version) + if match is None: + return () + return tuple(int(part) for part in match.group(1).split(".")) + + +def _specifier_excludes_v2(specifier): + """Return whether a requirement specifier demonstrably excludes version 2.""" + for operator, version in _VERSION_SPECIFIER.findall(specifier): + release = _release_parts(version) + if not release: + continue + + if operator in ("==", "===") and release[0] < 2: + return True + if operator == "~=" and release[0] < 2: + return True + if operator == "<": + if release[0] < 2: + return True + if release[0] == 2 and all(part == 0 for part in release[1:]): + return True + if operator == "<=" and release[0] < 2: + return True + + return False + + +def _requirements_exclude_v2(app_root): + """Return whether requirements.txt demonstrably restricts the SDK below 2.""" + requirements_path = app_root / "requirements.txt" + try: + requirements = requirements_path.read_text(encoding="utf-8") + except FileNotFoundError: + return False + except (OSError, UnicodeError): + _LOGGER.debug( + "Unable to inspect %s for an azure-functions-durable version constraint.", + requirements_path, + exc_info=True, + ) + return False + + for raw_line in requirements.splitlines(): + line = re.sub(r"\s+#.*$", "", raw_line).strip() + # Evaluating PEP 508 markers requires packaging. Ignore conditional + # pins rather than incorrectly treating a non-applicable pin as safe. + if ";" in line: + continue + match = _DURABLE_REQUIREMENT.match(line) + if match and _specifier_excludes_v2(match.group("specifier") or ""): + return True + return False + + +def _uses_v1_programming_model(app_root): + """Return whether a direct child contains legacy function metadata.""" + try: + with os.scandir(app_root) as entries: + return any( + entry.is_dir() and Path(entry.path, "function.json").is_file() + for entry in entries + ) + except OSError: + _LOGGER.debug( + "Unable to inspect %s for legacy function metadata.", + app_root, + exc_info=True, + ) + return False + + +def validate_v1_programming_model(): + """Warn users of the unsupported functions.json programming model.""" + app_root = _find_function_app_root() + if app_root is None or _requirements_exclude_v2(app_root): + return + + if _uses_v1_programming_model(app_root): + message = ( + "Your application uses the legacy Python v1 programming model, " + "which relies on function.json files. This programming model is " + "not supported by azure-functions-durable 2.x. Migrate to the " + "Python v2 programming model before upgrading, or pin " + "`azure-functions-durable<2` in your requirements.txt file." + ) + warnings.warn( + message, + DurableFunctionsCompatibilityWarning, + stacklevel=2, + ) + + # Validate that users are not in extension bundles V1 validate_extension_bundles() +# Warn users whose applications will not be compatible with version 2.x +validate_v1_programming_model() + __all__ = [ 'Orchestrator', 'Entity', @@ -67,6 +197,7 @@ def validate_extension_bundles(): 'DurableOrchestrationClient', 'DurableEntityContext', 'DurableOrchestrationContext', + 'DurableFunctionsCompatibilityWarning', 'ManagedIdentityTokenSource', 'OrchestrationRuntimeStatus', 'RetryOptions' diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..2e4c88e --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,139 @@ +import importlib +import warnings + +import pytest + +import azure.durable_functions as df + + +def create_function_app(tmp_path, requirement, legacy=True): + (tmp_path / "host.json").write_text("{}") + (tmp_path / "requirements.txt").write_text(requirement) + + if legacy: + function_dir = tmp_path / "MyFunction" + function_dir.mkdir() + (function_dir / "function.json").write_text("{}") + else: + (tmp_path / "function_app.py").write_text("") + + +@pytest.mark.parametrize( + "requirement", + [ + "azure-functions-durable", + "azure-functions-durable>=1.2", + "azure-functions-durable<2.1", + "azure-functions-durable<=2", + "azure-functions-durable==2.*", + "azure-functions-durable~=2.0", + ( + 'azure-functions-durable<2 ; python_version < "3.9"\n' + 'azure-functions-durable ; python_version >= "3.9"' + ), + ], +) +def test_unpinned_v1_programming_model_warns(monkeypatch, tmp_path, requirement): + create_function_app(tmp_path, requirement) + + monkeypatch.chdir(tmp_path) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.reload(df) + + compatibility_warnings = [ + warning for warning in caught + if warning.category is df.DurableFunctionsCompatibilityWarning + ] + + assert len(compatibility_warnings) == 1 + assert "legacy Python v1 programming model" in str( + compatibility_warnings[0].message + ) + assert "azure-functions-durable<2" in str( + compatibility_warnings[0].message + ) + + +@pytest.mark.parametrize( + "requirement", + [ + "azure-functions-durable<2", + "azure-functions-durable>=1.2,<2", + "azure-functions-durable==1.6.0", + "azure-functions-durable==1.*", + "azure-functions-durable~=1.6", + "Azure.Functions_Durable >= 1.2, < 2 # remain on v1", + ], +) +def test_pinned_v1_programming_model_does_not_warn( + monkeypatch, + tmp_path, + requirement, +): + create_function_app(tmp_path, requirement) + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + df, + "_uses_v1_programming_model", + lambda app_root: pytest.fail("pinned apps should not scan functions"), + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + df.validate_v1_programming_model() + + assert caught == [] + + +def test_v1_programming_model_warns_during_pytest(monkeypatch, tmp_path): + create_function_app(tmp_path, "azure-functions-durable") + + monkeypatch.chdir(tmp_path) + + with pytest.warns(df.DurableFunctionsCompatibilityWarning): + df.validate_v1_programming_model() + + +def test_undecodable_requirements_does_not_break_import(monkeypatch, tmp_path): + create_function_app(tmp_path, "azure-functions-durable") + (tmp_path / "requirements.txt").write_bytes(b"\xff") + + monkeypatch.chdir(tmp_path) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.reload(df) + + assert any( + warning.category is df.DurableFunctionsCompatibilityWarning + for warning in caught + ) + + +def test_directory_without_host_json_does_not_warn(monkeypatch, tmp_path): + function_dir = tmp_path / "MyFunction" + function_dir.mkdir() + (function_dir / "function.json").write_text("{}") + + monkeypatch.chdir(tmp_path) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + df.validate_v1_programming_model() + + assert caught == [] + + +def test_v2_programming_model_does_not_warn(monkeypatch, tmp_path): + create_function_app(tmp_path, "azure-functions-durable", legacy=False) + + monkeypatch.chdir(tmp_path) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.reload(df) + + assert caught == []