diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index b885e4a5..aa371805 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -220,6 +220,16 @@ async def df_client_middleware(*args, **kwargs): # Invoke user code with rich DF Client binding return await user_code(*args, **kwargs) + # functools.wraps on Python 3.14 (PEP 649/749) copies __annotate__ rather than + # __annotations__, which re-derives the original client annotation and defeats the + # str override above. Re-apply it on the wrapper and drop __annotate__ so both the + # dict-based and __annotate__-based readers (the worker uses the latter on 3.14) agree. + _ann = dict(getattr(df_client_middleware, "__annotations__", {})) # materializes on 3.14 + _ann[parameter_name] = str + df_client_middleware.__annotations__ = _ann + if hasattr(df_client_middleware, "__annotate__"): + df_client_middleware.__annotate__ = None + # Todo: This feels awkward - however, there are two reasons that I can't naively implement # this in the same way as entities and orchestrators: # 1. We intentionally wrap this exported signature with @wraps, to preserve the original diff --git a/tests/models/test_Decorators.py b/tests/models/test_Decorators.py index 0c753f77..018ac424 100644 --- a/tests/models/test_Decorators.py +++ b/tests/models/test_Decorators.py @@ -1,3 +1,5 @@ +import sys + import azure.durable_functions as df import azure.functions as func import json @@ -8,6 +10,15 @@ def get_user_code(app): assert len(functions) == 1 return functions[0] + +def get_built_function(app): + """Return the built callable the worker introspects for binding type-checks. + + ``_add_rich_client`` wraps the user function; the wrapper is stored on the + indexed ``Function`` object at ``._func``. + """ + return get_user_code(app)._func + def assert_json(user_code, expected_dict): user_code_json = json.dumps(json.loads(str(user_code)), sort_keys=True) expected_json = json.dumps(expected_dict, sort_keys=True) @@ -121,4 +132,38 @@ def dummy_function(req, my_client, message): "type": "durableClient" } ] - }) \ No newline at end of file + }) + + +def test_durable_client_input_annotation_overridden_to_str(app): + """The client-binding parameter annotation reads as ``str`` on the built function. + + The worker type-checks the ``durableClient`` binding against the client + parameter's annotation. ``_add_rich_client`` forces that annotation to + ``str`` so the rich ``DurableOrchestrationClient`` object passes validation. + + On Python 3.14 (PEP 649/749) ``functools.wraps`` copies ``__annotate__`` + rather than the already-patched ``__annotations__`` dict, so the wrapper + re-derives the original ``DurableOrchestrationClient`` annotation and binding + validation fails (``FunctionLoadError`` -> host 503). This asserts the ``str`` + override survives on the wrapper the worker actually reads, via both the + dict-based reader and the ``__annotate__``-based reader used on 3.14. + """ + + @app.durable_client_input(client_name="client") + @app.route(route="orchestrators/{functionName}") + async def dummy_function(req: func.HttpRequest, + client: df.DurableOrchestrationClient): + pass + + built_fn = get_built_function(app) + + # Dict-based reader (used on <=3.13, still consulted on 3.14). + assert built_fn.__annotations__["client"] is str + + # __annotate__-based reader: what the worker uses on Python 3.14. + if sys.version_info >= (3, 14): + import annotationlib + annotations = annotationlib.get_annotations( + built_fn, format=annotationlib.Format.FORWARDREF) + assert annotations["client"] is str