From f9f32b49a655b31b72014a5bf3e382065221bd38 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:13:15 -0700 Subject: [PATCH] fix(tests): qualify traced function names on Python 3.10 --- tests/sdk_function_trace/profiler.py | 57 ++++++++++++++++++++--- tests/sdk_function_trace/test_profiler.py | 31 +++++++++++- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/tests/sdk_function_trace/profiler.py b/tests/sdk_function_trace/profiler.py index c71c74ab0d3..44697e7b9f6 100644 --- a/tests/sdk_function_trace/profiler.py +++ b/tests/sdk_function_trace/profiler.py @@ -2,11 +2,12 @@ from __future__ import annotations import sys import threading -from collections.abc import Generator, Sequence +from collections.abc import Generator, Iterator, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass +from functools import lru_cache from pathlib import Path -from types import CodeType, FrameType, FunctionType +from types import CodeType, FrameType, FunctionType, MappingProxyType from typing import Final @@ -27,11 +28,11 @@ class PythonProfiler: def __call__(self, frame: FrameType, event: str, _arg: object) -> None: if event != "call" or frame in self._seen_frames: return - function_name: Final = self.function_name(frame.f_code) + function_name: Final = self.function_name(frame) if function_name is None: return ancestors: Final = tuple( - name for ancestor in _frame_ancestors(frame) if (name := self.function_name(ancestor.f_code)) is not None + name for ancestor in _frame_ancestors(frame) if (name := self.function_name(ancestor)) is not None ) self._seen_frames.add(frame) self.events.append( @@ -42,13 +43,57 @@ class PythonProfiler: ) ) - def function_name(self, code: CodeType) -> str | None: + def function_name(self, frame: FrameType) -> str | None: + code: Final = frame.f_code if self._source_root is None: return self._names_by_code.get(code) if not code.co_filename.startswith(self._source_root): return None relative: Final = code.co_filename.removeprefix(self._source_root) - return f"{relative}:{code.co_firstlineno} {getattr(code, 'co_qualname', code.co_name)}" + return f"{relative}:{code.co_firstlineno} {_qualified_name(frame)}" + + +def _qualified_name(frame: FrameType) -> str: + code: Final = frame.f_code + native: Final = getattr(code, "co_qualname", None) + if isinstance(native, str): + return native + module_name: Final = frame.f_globals.get("__name__") + if not isinstance(module_name, str): + return code.co_name + return _module_qualnames(module_name).get(code, code.co_name) + + +@lru_cache(maxsize=None) +def _module_qualnames(module_name: str) -> Mapping[CodeType, str]: + module: Final = sys.modules.get(module_name) + if module is None: + return MappingProxyType({}) + return MappingProxyType(dict(_declared_functions(vars(module), frozenset()))) + + +def _declared_functions(namespace: Mapping[str, object], visited: frozenset[int]) -> Iterator[tuple[CodeType, str]]: + for attribute in tuple(namespace.values()): + for value in _accessors(attribute): + if isinstance(value, FunctionType): + yield from ((wrapped.__code__, wrapped.__qualname__) for wrapped in _unwrapped(value)) + elif isinstance(value, type) and id(value) not in visited: + yield from _declared_functions(dict(vars(value)), visited | {id(value)}) + + +def _unwrapped(function: FunctionType) -> Iterator[FunctionType]: + yield function + inner: Final = getattr(function, "__wrapped__", None) + if isinstance(inner, FunctionType): + yield from _unwrapped(inner) + + +def _accessors(value: object) -> tuple[object, ...]: + if isinstance(value, (staticmethod, classmethod)): + return (value.__func__,) + if isinstance(value, property): + return tuple(accessor for accessor in (value.fget, value.fset, value.fdel) if accessor is not None) + return (value,) def _frame_ancestors(frame: FrameType) -> Generator[FrameType]: diff --git a/tests/sdk_function_trace/test_profiler.py b/tests/sdk_function_trace/test_profiler.py index 10a266fb1e8..21a1ab134b2 100644 --- a/tests/sdk_function_trace/test_profiler.py +++ b/tests/sdk_function_trace/test_profiler.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio import sys +from collections.abc import Callable +from functools import wraps from pathlib import Path from types import FunctionType -from typing import Final, cast +from typing import Final, ParamSpec, TypeVar, cast import pytest @@ -14,7 +16,18 @@ from tests.sdk_function_trace import ( TraceStep, assert_function_trace_parity, ) -from tests.sdk_function_trace.profiler import profile_python +from tests.sdk_function_trace.profiler import _module_qualnames, profile_python + +_P = ParamSpec("_P") +_T = TypeVar("_T") + + +def _passthrough(function: Callable[_P, _T]) -> Callable[_P, _T]: + @wraps(function) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + return function(*args, **kwargs) + + return wrapper class First: @@ -29,6 +42,20 @@ class Second: return None +class Decorated: + @_passthrough + def call(self) -> None: + return None + + +def test_source_profiler_qualifies_decorated_methods_by_class() -> None: + with profile_python(source_root=Path(__file__).parent) as profiler: + Decorated().call() + + assert any(event.function.endswith(" Decorated.call") for event in profiler.events) + assert _module_qualnames(__name__)[cast(FunctionType, Decorated.call.__wrapped__).__code__] == "Decorated.call" + + def test_profiler_matches_code_objects_and_keeps_repeated_calls() -> None: with profile_python((First.run,)) as profiler: Second.run()