From f3dc912cdf1c1a6d92c8e2965b77e135d132d703 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Mon, 20 Jul 2026 23:13:24 +0900 Subject: [PATCH] refactor(context): extract shared worker-compat metadata helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce _metadata_helpers.py housing the canonical copy_identity_attrs primitive and SAFE_IDENTITY_ATTRS tuple — the genuinely duplicated worker-compat logic shared with azure-functions-validation. _decorator.py now delegates identity-attribute copying to this primitive while keeping its package-specific signature/annotation mirroring. Closes #216 --- src/azure_functions_logging/_decorator.py | 12 +--- .../_metadata_helpers.py | 55 +++++++++++++++++++ tests/test_decorator.py | 31 +++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 src/azure_functions_logging/_metadata_helpers.py diff --git a/src/azure_functions_logging/_decorator.py b/src/azure_functions_logging/_decorator.py index 78e15c5..e905657 100644 --- a/src/azure_functions_logging/_decorator.py +++ b/src/azure_functions_logging/_decorator.py @@ -14,17 +14,13 @@ from ._context import inject_context, restore_context from ._metadata import LoggingMetadata, read_logging_metadata, set_logging_metadata +from ._metadata_helpers import copy_identity_attrs _F = TypeVar("_F", bound=Callable[..., Any]) _DEFAULT_PARAM = "context" - - -_SAFE_COPY_ATTRS = ("__name__", "__qualname__", "__doc__", "__module__") - - def _copy_safe_metadata(wrapper: Callable[..., Any], func: Callable[..., Any]) -> None: """Copy safe metadata from ``func`` onto ``wrapper`` without setting ``__wrapped__``. @@ -40,11 +36,7 @@ def _copy_safe_metadata(wrapper: Callable[..., Any], func: Callable[..., Any]) - It still mirrors ``__signature__`` and ``__annotations__`` so the worker can introspect parameter names/types for trigger binding. """ - for attr in _SAFE_COPY_ATTRS: - try: - object.__setattr__(wrapper, attr, getattr(func, attr)) - except (AttributeError, TypeError): # pragma: no cover - pass + copy_identity_attrs(wrapper, func) try: wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined] except (TypeError, ValueError): # pragma: no cover diff --git a/src/azure_functions_logging/_metadata_helpers.py b/src/azure_functions_logging/_metadata_helpers.py new file mode 100644 index 0000000..ed0f0ca --- /dev/null +++ b/src/azure_functions_logging/_metadata_helpers.py @@ -0,0 +1,55 @@ +"""Canonical worker-compatibility metadata helpers. + +This module houses the primitive shared across the Azure Functions Python DX +Toolkit for copying identity attributes from a user handler onto a decorator +wrapper **without** tripping the Azure Functions worker's function-indexing +heuristics. + +The primitive is intentionally kept as a small, dependency-free unit so the +**same shape** can be mirrored verbatim across sibling packages +(``azure-functions-logging``, ``azure-functions-validation``, ...). These +packages are independent PyPI distributions with no shared base dependency, so +"shared" here means a canonical, synced definition rather than a common import. + +Ref: https://github.com/yeongseon/azure-functions-logging/issues/216 +""" + +from __future__ import annotations + +from typing import Any, Callable + +# Identity attributes copied from the wrapped function onto the wrapper. +# +# ``functools.wraps`` / ``functools.update_wrapper`` are deliberately NOT used: +# +# * they set ``__wrapped__ = func`` — the Azure Functions worker may follow it +# during function indexing and bind the original (un-wrapped) handler instead +# of the wrapper, defeating the decorator; +# * they copy ``__dict__`` — sharing the dict object aliases +# ``wrapper.__dict__`` with ``func.__dict__``, so later ``setattr`` calls +# (e.g. ``_azure_functions_metadata``) leak onto the original ``func``. +SAFE_IDENTITY_ATTRS: tuple[str, ...] = ( + "__name__", + "__qualname__", + "__doc__", + "__module__", +) + + +def copy_identity_attrs( + wrapper: Callable[..., Any], + func: Callable[..., Any], + attrs: tuple[str, ...] = SAFE_IDENTITY_ATTRS, +) -> None: + """Copy safe identity attributes from ``func`` onto ``wrapper`` in place. + + Copies only the attributes in ``attrs`` (identity metadata) and neither + sets ``__wrapped__`` nor copies ``__dict__``. Signature and annotation + handling is intentionally left to the caller because it is + package-specific (some packages hide parameters, others preserve them). + """ + for attr in attrs: + try: + object.__setattr__(wrapper, attr, getattr(func, attr)) + except (AttributeError, TypeError): # pragma: no cover + pass diff --git a/tests/test_decorator.py b/tests/test_decorator.py index 22de628..29bdaf0 100644 --- a/tests/test_decorator.py +++ b/tests/test_decorator.py @@ -258,3 +258,34 @@ def handler(req: object, context: object) -> bool | None: ) result = handler("req", ctx2) assert result is False + + +# --------------------------------------------------------------------------- +# 7. Shared worker-compat metadata helper +# --------------------------------------------------------------------------- + + +class TestCopyIdentityAttrs: + """The shared ``copy_identity_attrs`` primitive must not leak state.""" + + def test_copies_identity_without_wrapped_or_dict_alias(self) -> None: + from azure_functions_logging._metadata_helpers import ( + SAFE_IDENTITY_ATTRS, + copy_identity_attrs, + ) + + def func(req: object, context: object) -> None: + """Original docstring.""" + + def wrapper(*args: object, **kwargs: object) -> None: + pass + + copy_identity_attrs(wrapper, func) + + for attr in SAFE_IDENTITY_ATTRS: + assert getattr(wrapper, attr) == getattr(func, attr) + # __wrapped__ must NOT be set (defeats worker indexing otherwise). + assert not hasattr(wrapper, "__wrapped__") + # __dict__ must not be aliased: mutating wrapper must not touch func. + wrapper.__dict__["_marker"] = 1 + assert "_marker" not in func.__dict__