Skip to content
Merged
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
12 changes: 2 additions & 10 deletions src/azure_functions_logging/_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__``.

Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions src/azure_functions_logging/_metadata_helpers.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions tests/test_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Loading