Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions azure/durable_functions/decorators/durable_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion tests/models/test_Decorators.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import sys

import azure.durable_functions as df
import azure.functions as func
import json
Expand All @@ -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)
Expand Down Expand Up @@ -121,4 +132,38 @@ def dummy_function(req, my_client, message):
"type": "durableClient"
}
]
})
})


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