diff --git a/.gitlab/scripts/prune-unsupported-wheels.sh b/.gitlab/scripts/prune-unsupported-wheels.sh index 5c1f2eb9ba1..f459bca0ee2 100755 --- a/.gitlab/scripts/prune-unsupported-wheels.sh +++ b/.gitlab/scripts/prune-unsupported-wheels.sh @@ -24,8 +24,9 @@ set -euo pipefail # # TODO(py-315): cp315 wheels are built by "build linux" and "build linux serverless" under # allow_failure so that 3.15 keeps producing CI signal, but they are compiled against the -# 3.15.0b1 PyThreadState layout and are ABI-broken. Drop cp315 from this list once #19861 -# makes those wheels correct and 3.15 is a supported target. +# 3.15.0b1 PyThreadState layout and are ABI-broken. #19861 does not fix that ABI; it only +# makes cp315 optional in the package validator. Drop cp315 from this list after IMAGE_TAG +# is on images that can build a correct wheel, and only once 3.15 is a supported target. UNSUPPORTED_TAGS=("cp315") if [ "$#" -eq 0 ]; then diff --git a/.gitlab/templates/cached-testrunner.yml b/.gitlab/templates/cached-testrunner.yml index 63f735d7542..d50228971a5 100644 --- a/.gitlab/templates/cached-testrunner.yml +++ b/.gitlab/templates/cached-testrunner.yml @@ -18,8 +18,8 @@ fi source $EXT_CACHE_VENV/bin/activate - # Cython 3.3.0's cp315 wheel SIGSEGVs on pre-rc1 3.15. The pyproject.toml - # [build-system] pin (#19861) does not apply to this EXT_CACHE_VENV install. + # Cython 3.3 cp315 wheels SIGSEGV on pre-rc1 3.15; pyproject.toml pin does not apply here. + # TODO(py-315): drop this pin once testrunner 3.15 is 3.15.0rc1+. if [ "$PYTHON_VERSION" = "3.15" ]; then python -m pip install cmake setuptools_rust 'Cython<3.3' else diff --git a/ddtrace/internal/bytecode_injection/__init__.py b/ddtrace/internal/bytecode_injection/__init__.py index 9e2b6d6aea9..87ee77b9323 100644 --- a/ddtrace/internal/bytecode_injection/__init__.py +++ b/ddtrace/internal/bytecode_injection/__init__.py @@ -1,4 +1,5 @@ from collections import deque +import sys from types import CodeType from types import FunctionType from typing import Any # noqa:F401 @@ -8,6 +9,7 @@ from bytecode import Instr from ddtrace.internal.assembly import Assembly +from ddtrace.internal.compat import PY_315_VERSION_INFO from ddtrace.internal.compat import PYTHON_VERSION_INFO as PY from ddtrace.internal.wrapping import get_function_code from ddtrace.internal.wrapping import set_function_code @@ -25,7 +27,10 @@ class InvalidLine(Exception): """ -if PY >= (3, 15): +# mypy only folds sys.version_info against literals. PY_315_VERSION_INFO is the +# runtime floor; the literal keeps this 3.15-only import off the 3.10 checker. +assert PY_315_VERSION_INFO == (3, 15) +if sys.version_info >= (3, 15): from ddtrace.internal import monitoring as _monitoring from ddtrace.internal.threads import Lock from ddtrace.internal.utils.inspection import linenos @@ -40,7 +45,7 @@ def __init__(self) -> None: def on_py_line(self, code: Any, line_number: int) -> Any: hooks: "list[tuple[HookType, Any]] | None" = self._hooks.get(line_number) if not hooks: - return _monitoring._DISABLE # type: ignore[has-type] + return _monitoring._DISABLE for hook, arg in hooks: hook(arg) return None diff --git a/ddtrace/internal/compat.py b/ddtrace/internal/compat.py index 05ed3cca276..9ee542c318e 100644 --- a/ddtrace/internal/compat.py +++ b/ddtrace/internal/compat.py @@ -11,20 +11,30 @@ __all__ = [ "maybe_stringify", + "MAX_PY", + "MAX_PY_VERSION", "NEXT_PY_UNSUPPORTED_MSG", - "NEXT_PY_VERSION", - "NEXT_PY_VERSION_INFO", + "NEXT_MAX_PY", + "PY_315_VERSION_INFO", "PYTHON_VERSION_INFO", ] PYTHON_VERSION_INFO = sys.version_info -# First CPython version that wrapping / bytecode injection do not support yet. -NEXT_PY_VERSION: str = "3.15" -_next_py_parts = NEXT_PY_VERSION.split(".")[:2] -NEXT_PY_VERSION_INFO: tuple[int, int] = (int(_next_py_parts[0]), int(_next_py_parts[1])) +# Last officially supported CPython. Matches requires-python <3.15. +# TODO(py-315): bump MAX_PY to (3, 15) after 3.15 GAs +MAX_PY: tuple[int, int] = (3, 14) +MAX_PY_VERSION: str = f"{MAX_PY[0]}.{MAX_PY[1]}" + +# First unsupported CPython for packaging (last-supported+1). wrap() fail-closes +# at (3, 16), not here. +NEXT_MAX_PY: tuple[int, int] = (MAX_PY[0], MAX_PY[1] + 1) NEXT_PY_UNSUPPORTED_MSG: str = "This version of CPython is not supported yet: {}.{}".format(*sys.version_info[:2]) +# CPython 3.15 introduced sys.monitoring PY_UNWIND / PEP 810 import packing. +# Not MAX_PY — do not bump this when MAX_PY moves. +PY_315_VERSION_INFO: tuple[int, int] = (3, 15) + def ensure_text(s, encoding="utf-8", errors="ignore") -> str: if isinstance(s, str): diff --git a/ddtrace/internal/coverage/instrumentation_py3_12.py b/ddtrace/internal/coverage/instrumentation_py3_12.py index eaa98080f76..28ec299e7dd 100644 --- a/ddtrace/internal/coverage/instrumentation_py3_12.py +++ b/ddtrace/internal/coverage/instrumentation_py3_12.py @@ -16,6 +16,7 @@ from bytecode import Bytecode from ddtrace.internal.bytecode_injection import HookType +from ddtrace.internal.compat import PY_315_VERSION_INFO from ddtrace.internal.coverage.import_instrumentation_py3_12 import ImportName from ddtrace.internal.coverage.import_instrumentation_py3_12 import ImportNamesByLine from ddtrace.internal.coverage.import_instrumentation_py3_12 import import_names_by_line @@ -44,7 +45,7 @@ # In Python 3.15 (PEP 810 lazy imports), IMPORT_NAME's arg is bit-packed: # bits 2+ = name index into co_names, bits 0-1 = lazy/eager flags. # So the index is arg >> 2. On 3.12-3.14, arg is a plain index (shift by 0). -_IMPORT_NAME_ARG_SHIFT = 2 if sys.version_info >= (3, 15) else 0 +_IMPORT_NAME_ARG_SHIFT = 2 if sys.version_info >= PY_315_VERSION_INFO else 0 # Detect empty modules: the bytecode pattern varies across Python versions. # Python 3.12-3.13: RESUME + RETURN_CONST @@ -56,7 +57,7 @@ # Check if file-level coverage is requested _USE_FILE_LEVEL_COVERAGE = asbool(env.get("_DD_COVERAGE_FILE_LEVEL", "true")) _ACCURATE_IMPORTS_REQUESTED = asbool(env.get("_DD_COVERAGE_ACCURATE_IMPORTS", "false")) -_USE_ACCURATE_IMPORTS = sys.version_info < (3, 15) and _ACCURATE_IMPORTS_REQUESTED +_USE_ACCURATE_IMPORTS = sys.version_info < PY_315_VERSION_INFO and _ACCURATE_IMPORTS_REQUESTED if _ACCURATE_IMPORTS_REQUESTED and not _USE_ACCURATE_IMPORTS: log.info( "_DD_COVERAGE_ACCURATE_IMPORTS is enabled, but accurate import tracking is not supported on Python %s; " diff --git a/ddtrace/internal/module.py b/ddtrace/internal/module.py index d576a4c9948..5749bae375c 100644 --- a/ddtrace/internal/module.py +++ b/ddtrace/internal/module.py @@ -829,18 +829,16 @@ def _trace(frame, event, arg): def lazy(f: t.Callable[[], None]) -> None: - from ddtrace.internal.compat import NEXT_PY_VERSION_INFO - from ddtrace.internal.compat import PYTHON_VERSION_INFO - _globals = sys._getframe(1).f_globals _initialized = False - if PYTHON_VERSION_INFO < NEXT_PY_VERSION_INFO: + # WrappingContext.wrap() (sys.monitoring) is live on 3.15; fallback from 3.16. + if sys.version_info < (3, 16): LazyWrappingContext(t.cast(FunctionType, f)).wrap() def __getattr__(name: str) -> t.Any: nonlocal _initialized - if PYTHON_VERSION_INFO >= NEXT_PY_VERSION_INFO: + if sys.version_info >= (3, 16): if not _initialized: _exec_lazy_init(t.cast(FunctionType, f), _globals) _initialized = True diff --git a/ddtrace/internal/monitoring.py b/ddtrace/internal/monitoring.py index 883bcae640a..d5355e29317 100644 --- a/ddtrace/internal/monitoring.py +++ b/ddtrace/internal/monitoring.py @@ -21,12 +21,17 @@ from typing import Optional import weakref +from ddtrace.internal.compat import PY_315_VERSION_INFO from ddtrace.internal.logger import get_logger from ddtrace.internal.threads import Lock -if sys.version_info < (3, 15): - raise ImportError("ddtrace.internal.monitoring requires Python 3.15+") +if sys.version_info < PY_315_VERSION_INFO: + raise ImportError("ddtrace.internal.monitoring requires Python %s.%s+" % PY_315_VERSION_INFO) +# mypy only folds sys.version_info against literals. A named alias leaves this +# 3.15-only body reachable under python_version 3.10, where sys.monitoring is +# missing (attr-defined / no-any-return). Keep the tuple equal to PY_315_VERSION_INFO. +assert sys.version_info >= (3, 15) log = get_logger(__name__) diff --git a/ddtrace/internal/wrapping/__init__.py b/ddtrace/internal/wrapping/__init__.py index 89e615cd238..b74b6ecb99a 100644 --- a/ddtrace/internal/wrapping/__init__.py +++ b/ddtrace/internal/wrapping/__init__.py @@ -14,7 +14,6 @@ from ddtrace.internal.assembly import Assembly from ddtrace.internal.compat import NEXT_PY_UNSUPPORTED_MSG -from ddtrace.internal.compat import NEXT_PY_VERSION_INFO from ddtrace.internal.threads import Lock from ddtrace.internal.wrapping.asyncs import wrap_async from ddtrace.internal.wrapping.generators import wrap_generator @@ -300,7 +299,8 @@ def wrap_bytecode(wrapper: Wrapper, wrapped: FunctionType) -> bc.Bytecode: return a coroutine function, and so on. The signature is also preserved to avoid breaking, e.g., usages of the ``inspect`` module. """ - if PY >= NEXT_PY_VERSION_INFO: + # wrap() trampoline is live on 3.15. Fail closed from 3.16, not NEXT_MAX_PY. + if PY >= (3, 16): raise NotImplementedError(NEXT_PY_UNSUPPORTED_MSG) code = wrapped.__code__ @@ -349,7 +349,7 @@ def wrap(f: FunctionType, wrapper: Wrapper) -> WrappedFunction: Note that this changes the behavior of the original function with the wrapper function, instead of creating a new function object. """ - if PY >= NEXT_PY_VERSION_INFO: + if PY >= (3, 16): raise NotImplementedError(NEXT_PY_UNSUPPORTED_MSG) wrapped = FunctionType( code := f.__code__, diff --git a/ddtrace/internal/wrapping/asyncs.py b/ddtrace/internal/wrapping/asyncs.py index 3ee49a2bb32..0d415dd8405 100644 --- a/ddtrace/internal/wrapping/asyncs.py +++ b/ddtrace/internal/wrapping/asyncs.py @@ -6,6 +6,7 @@ import bytecode as bc from ddtrace.internal.assembly import Assembly +from ddtrace.internal.compat import PY_315_VERSION_INFO PY = sys.version_info[:2] @@ -56,7 +57,7 @@ def _ensure_common_constant_none() -> None: ASYNC_GEN_ASSEMBLY = Assembly() ASYNC_HEAD_ASSEMBLY: Optional[Assembly] = None -if PY >= (3, 15): +if PY >= PY_315_VERSION_INFO: _ensure_common_constant_none() ASYNC_HEAD_ASSEMBLY = Assembly() ASYNC_HEAD_ASSEMBLY.parse( diff --git a/ddtrace/internal/wrapping/context.py b/ddtrace/internal/wrapping/context.py index 017a2c1c60d..d8284ab9f8e 100644 --- a/ddtrace/internal/wrapping/context.py +++ b/ddtrace/internal/wrapping/context.py @@ -16,6 +16,7 @@ from bytecode import Bytecode from ddtrace.internal.assembly import Assembly +from ddtrace.internal.compat import PY_315_VERSION_INFO from ddtrace.internal.logger import get_logger from ddtrace.internal.threads import Lock from ddtrace.internal.threads import RLock @@ -220,9 +221,11 @@ def _release_storage_var(name: str, var: StorageVar) -> None: CONTEXT_RETURN = Assembly() CONTEXT_FOOT = Assembly() +# Hard 3.16 floor: wrapping already works on 3.15 via sys.monitoring. +# NEXT_MAX_PY is 3.15 until 3.15 GAs; using it here would skip that path. if sys.version_info >= (3, 16): raise NotImplementedError("This version of Python is not supported yet") -elif sys.version_info >= (3, 15): +elif sys.version_info >= PY_315_VERSION_INFO: # We rely on sys.monitoring for wrapping, so no bytecode manipulation is # needed. pass @@ -439,8 +442,9 @@ def _release_storage_var(name: str, var: StorageVar) -> None: # (3.15+) the stack is: # monitored function → monitoring._on_py_start → uwc.on_py_start → __enter__ # so the monitored frame is three levels up. -_ENTER_FRAME_DEPTH = 3 if sys.version_info >= (3, 15) else 1 +_ENTER_FRAME_DEPTH = 3 if sys.version_info >= PY_315_VERSION_INFO else 1 +# PY_315_VERSION_INFO — mypy folds literals only if sys.version_info >= (3, 15): from ddtrace.internal import monitoring as _monitoring @@ -604,6 +608,7 @@ def unwrap(self) -> None: pass +# PY_315_VERSION_INFO — mypy folds literals only if sys.version_info >= (3, 15): # Monitoring-based instrumentation has negligible per-function overhead, so # there is no benefit to deferring wrapping until first call. On Python 3.15+ @@ -699,6 +704,7 @@ def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any: # On 3.15+ _UniversalWrappingContext also implements MonitoringEventHandler so # it can be registered directly with the multiplexer via register(code, self). +# PY_315_VERSION_INFO — mypy folds literals only if sys.version_info >= (3, 15): from ddtrace.internal.monitoring import MonitoringEventHandler as _MonitoringEventHandler @@ -817,6 +823,7 @@ def __return__(self, value: T) -> T: return t.cast(T, super().__return__(value)) + # PY_315_VERSION_INFO — mypy folds literals only if sys.version_info >= (3, 15): # Exceptions here are deliberately left uncaught (see the propagation # warning on MonitoringEventHandler), which matches bytecode-path @@ -1242,6 +1249,7 @@ def unwrap(self) -> None: _registry.pop(f, None) +# PY_315_VERSION_INFO — mypy folds literals only if sys.version_info >= (3, 15): def _finalize_monitoring_wrap( diff --git a/ddtrace/internal/wrapping/generators.py b/ddtrace/internal/wrapping/generators.py index c84322baef7..83834d52039 100644 --- a/ddtrace/internal/wrapping/generators.py +++ b/ddtrace/internal/wrapping/generators.py @@ -5,6 +5,7 @@ import bytecode as bc from ddtrace.internal.assembly import Assembly +from ddtrace.internal.compat import PY_315_VERSION_INFO PY = sys.version_info[:2] @@ -34,7 +35,7 @@ GENERATOR_ASSEMBLY = Assembly() GENERATOR_HEAD_ASSEMBLY: Optional[Assembly] = None -if PY >= (3, 15): +if PY >= PY_315_VERSION_INFO: GENERATOR_HEAD_ASSEMBLY = Assembly() GENERATOR_HEAD_ASSEMBLY.parse( r""" diff --git a/hooks/pre-commit/05-run-bandit b/hooks/pre-commit/05-run-bandit index 720d17fafe8..0f6315a103c 100755 --- a/hooks/pre-commit/05-run-bandit +++ b/hooks/pre-commit/05-run-bandit @@ -1,6 +1,6 @@ #!/bin/sh LINT_CMD="${LINT_CMD:-scripts/lint}" -staged_files=$(git diff --staged --name-only HEAD --diff-filter=ACMR | grep -E '\.py$' | grep -v '^tests/' | grep -v '^lib-injection/' | tr '\n' ' ') +staged_files=$(git diff --staged --name-only HEAD --diff-filter=ACMR | grep -E '\.py$' | grep -v '^tests/' | tr '\n' ' ') if [ -n "$staged_files" ]; then file_count=$(echo "$staged_files" | wc -w | tr -d ' ') echo "Running security scan on $file_count staged Python file(s)..." diff --git a/hooks/scripts/run-mypy.sh b/hooks/scripts/run-mypy.sh index 2563cfab004..47e6dc337be 100755 --- a/hooks/scripts/run-mypy.sh +++ b/hooks/scripts/run-mypy.sh @@ -1,6 +1,6 @@ #!/bin/sh LINT_CMD="${LINT_CMD:-scripts/lint}" -staged_files=$(git diff --staged --name-only HEAD --diff-filter=ACMR | grep -E '\.(py|pyi)$' | grep -v '^lib-injection/' | tr '\n' ' ') +staged_files=$(git diff --staged --name-only HEAD --diff-filter=ACMR | grep -E '\.(py|pyi)$' | tr '\n' ' ') if [ -n "$staged_files" ]; then # Drop .pyi stubs whose .py counterpart is also staged to avoid mypy # "Duplicate module named ..." errors. mypy discovers stubs automatically. diff --git a/lib-injection/sources/sitecustomize.py b/lib-injection/sources/sitecustomize.py index 8fc01fd0665..477d387b2b5 100644 --- a/lib-injection/sources/sitecustomize.py +++ b/lib-injection/sources/sitecustomize.py @@ -40,6 +40,8 @@ def parse_version(version): TELEMETRY_DATA = [] SCRIPT_DIR = os.path.dirname(__file__) +# Exclusive upper bound. Keep 3.16 so 3.15 is injected (#19843). Not NEXT_MAX_PY +# in compat (3.15 until 3.15 GAs). This bootstrap cannot import ddtrace. RUNTIMES_ALLOW_LIST = { "cpython": { "min": Version(version=(3, 9), constraint=""), diff --git a/pyproject.toml b/pyproject.toml index 16098ba6c53..91a052c74bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,7 @@ [build-system] requires = [ - # Cython 3.3.0 is the first stable release to publish compiled cp315 wheels, - # and its cp315 manylinux wheel is compiled against the 3.15.0rc1 PyThreadState - # layout, which grew a member in 3.15.0b4 (python/cpython#151614). Our - # manylinux2014 images still ship 3.15.0b1, so that wheel segfaults the moment - # setup.py imports Cython.Build. <3.3 has no cp315-tagged wheel, so on these - # images 3.15 resolves the pure-Python cython-*-py3-none-any.whl instead, which - # has no ABI to mismatch. Drop this once the image mirror ships a cp315 - # interpreter >= 3.15.0rc1; pypa manylinux 2026.08.04-1 is the first such tag. + # Cython 3.3 cp315 manylinux wheels SIGSEGV on the current 3.15 interpreter; <3.3 pins 3.2.x. + # TODO(py-315): drop this pin once IMAGE_TAG is on the 2026.08.04-1 images. "cython<3.3; python_version >= '3.15'", "cython; python_version < '3.15'", "cmake>=3.24.2,<3.28", @@ -135,7 +129,9 @@ lint = [ "cmake-format==0.6.13", "ruamel.yaml==0.18.6", "ast-grep-cli==0.39.4", - "bytecode==0.18.1; python_version >= '3.15'", + # Lint extra; 0.19.0 matches the runtime lower bound so uv can resolve + # both extras when requires-python includes 3.15. + "bytecode==0.19.0; python_version >= '3.15'", "bytecode==0.17.0; python_version < '3.15'", ] clean = [ diff --git a/riotfile.py b/riotfile.py index 92dea054392..88cf35b6347 100644 --- a/riotfile.py +++ b/riotfile.py @@ -75,8 +75,8 @@ def str_to_version(version: str) -> tuple[int, int]: MIN_PYTHON_VERSION = version_to_str(min(SUPPORTED_PYTHON_VERSIONS)) # 3.15 is listed so select_pys(max_version="3.15") can opt in. Default stays -# 3.14: 3.15 hashes share riot jobs with 3.9-3.14 under --exitfirst, wrap() -# still raises (#17849), and pip install -e . fails until #19904. +# 3.14 so uncapped suites do not mix 3.15 hashes into 3.9-3.14 --exitfirst jobs. +# Wrap-heavy suites stay at the default until wrap() is live on 3.15. MAX_PYTHON_VERSION = "3.14" @@ -581,7 +581,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT ), Venv( name="detect_global_locks", - pys=select_pys(max_version="3.15"), + pys=select_pys(), command="python -X importtime scripts/global-lock-detection.py", env={ "DD_TRACE_PY_ENABLE_ITR_FOR_JOB": "false", @@ -861,7 +861,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT pkgs={ "pytest-randomly": latest, }, - pys=select_pys(max_version="3.15"), + pys=select_pys(), ), Venv( name="logging", @@ -2792,7 +2792,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT pkgs={ "pytest-randomly": latest, }, - pys=select_pys(max_version="3.15"), + pys=select_pys(), env={ "DD_CIVISIBILITY_ITR_ENABLED": "0", "DD_IAST_REQUEST_SAMPLING": "100", # Override default 30% to analyze all IAST requests @@ -3699,7 +3699,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT pkgs={ "pytest-randomly": latest, }, - pys=select_pys(max_version="3.15"), + pys=select_pys(), ), Venv( name="integration_registry", diff --git a/src/native/crashtracker/crashtracker_runtime_stacks.rs b/src/native/crashtracker/crashtracker_runtime_stacks.rs index f0bb2cb43b7..44b46796754 100644 --- a/src/native/crashtracker/crashtracker_runtime_stacks.rs +++ b/src/native/crashtracker/crashtracker_runtime_stacks.rs @@ -19,11 +19,24 @@ extern "C" { /// This is signal-safe: it's just a thread-local read + comparison. /// Returns false if the GIL is not held (e.g. during a ctypes foreign function call), /// in which case it's unsafe to call any Python C API functions. -#[cfg(unix)] +/// +/// `PyGILState_Check` is not limited-API: it lives in CPython's +/// `Include/cpython/pystate.h` and is absent from `Misc/stable_abi.toml` +/// (3.15 lists Ensure / Release / GetThisThreadState only). pyo3-ffi therefore +/// omits it under `Py_LIMITED_API` (3.15 via `PYO3_USE_ABI3_FORWARD_COMPATIBILITY`). +/// This runs in a crash signal handler, so `PyGILState_Ensure` is not a +/// substitute (not async-signal-safe). On limited-API builds we cannot probe, +/// so skip Python C API use rather than acquire or guess. +#[cfg(all(unix, not(Py_LIMITED_API)))] unsafe fn gil_is_held() -> bool { pyo3_ffi::PyGILState_Check() != 0 } +#[cfg(all(unix, Py_LIMITED_API))] +unsafe fn gil_is_held() -> bool { + false +} + /************************************************************ Emit runtime stacktrace as string using _Py_DumpTracebackThreads / PyUnstable_DumpTracebackThreads diff --git a/tests/internal/test_py315_import_degrade.py b/tests/internal/test_py315_import_degrade.py index 23298bd000e..4442b4fb238 100644 --- a/tests/internal/test_py315_import_degrade.py +++ b/tests/internal/test_py315_import_degrade.py @@ -1,28 +1,45 @@ -"""Python 3.15 import-time degrade: wrapping must load, wrap() still raises. +"""Python 3.15 wrapping: trampoline plus 3.15 generator/coroutine assemblies. -Until #17849 lands bytecode wrapping for 3.15, products that import wrapping -(e.g. ModuleWatchdog) must not crash the process. wrap()/inject_hook still -raise NotImplementedError when actually used. +wrap() / wrap_bytecode() run on 3.15 and fail closed from 3.16. +@lazy uses WrappingContext.wrap() (sys.monitoring) on 3.15. inject_hook is +monitoring-based on 3.15. """ -import re +from types import CoroutineType import pytest +from ddtrace.internal.compat import MAX_PY +from ddtrace.internal.compat import MAX_PY_VERSION +from ddtrace.internal.compat import NEXT_MAX_PY from ddtrace.internal.compat import NEXT_PY_UNSUPPORTED_MSG -from ddtrace.internal.compat import NEXT_PY_VERSION -from ddtrace.internal.compat import NEXT_PY_VERSION_INFO +from ddtrace.internal.compat import PY_315_VERSION_INFO from ddtrace.internal.compat import PYTHON_VERSION_INFO -_RUNNING_VERSION = f"{PYTHON_VERSION_INFO[0]}.{PYTHON_VERSION_INFO[1]}" -_UNSUPPORTED_MSG = f"This version of CPython is not supported yet: {_RUNNING_VERSION}" +# wrap() is live on 3.15 until 3.16. Do not skipif on NEXT_MAX_PY (3.15). +_WRAP_ON_315: bool = (3, 15) <= PYTHON_VERSION_INFO[:2] < (3, 16) + +_RUNNING_VERSION: str = f"{PYTHON_VERSION_INFO[0]}.{PYTHON_VERSION_INFO[1]}" +_UNSUPPORTED_MSG: str = f"This version of CPython is not supported yet: {_RUNNING_VERSION}" def test_unsupported_msg_includes_running_version(): assert NEXT_PY_UNSUPPORTED_MSG == _UNSUPPORTED_MSG +def test_max_and_next_max_py_version_constants(): + assert MAX_PY_VERSION == "3.14" + assert MAX_PY == (3, 14) + assert NEXT_MAX_PY == (MAX_PY[0], MAX_PY[1] + 1) + assert NEXT_MAX_PY == (3, 15) + + +def test_py315_api_floor_is_not_aliased_to_max(): + assert PY_315_VERSION_INFO == (3, 15) + assert PY_315_VERSION_INFO is not MAX_PY + + def test_wrapping_modules_import(): import ddtrace.internal.bytecode_injection # noqa: F401 import ddtrace.internal.module # noqa: F401 @@ -31,21 +48,84 @@ def test_wrapping_modules_import(): import ddtrace.internal.wrapping.generators # noqa: F401 -@pytest.mark.skipif(PYTHON_VERSION_INFO < NEXT_PY_VERSION_INFO, reason=f"{NEXT_PY_VERSION} wrap() degrade") -def test_wrap_raises_not_implemented_on_315(): +@pytest.mark.skipif(not _WRAP_ON_315, reason="wrap() trampoline on 3.15") +def test_wrap_runs_on_315(): from ddtrace.internal.wrapping import wrap + seen: list[object] = [] + + def wrapper(wrapped, args, kwargs): # noqa: ANN001, ANN202 + seen.append("sync") + return wrapped(*args, **kwargs) + + def f() -> int: + return 7 + + wrap(f, wrapper) + assert f() == 7 + assert seen == ["sync"] + + def gen_wrapper(wrapped, args, kwargs): # noqa: ANN001, ANN202 + seen.append("gen") + for value in wrapped(*args, **kwargs): + yield value + + def g(): # noqa: ANN202 + yield 1 + yield 2 + + wrap(g, gen_wrapper) + assert list(g()) == [1, 2] + assert seen == ["sync", "gen"] + + +@pytest.mark.skipif(not _WRAP_ON_315, reason="wrap() coroutine on 3.15") +@pytest.mark.asyncio +async def test_wrap_coroutine_on_315(): + from ddtrace.internal.wrapping import wrap + + seen: list[object] = [] + + def wrapper(wrapped, args, kwargs): # noqa: ANN001, ANN202 + result = wrapped(*args, **kwargs) + if isinstance(result, CoroutineType): + + async def _await(coro): # noqa: ANN001, ANN202 + value = await coro + seen.append(value) + return value + + return _await(result) + seen.append(result) + return result + + async def c() -> int: + return 42 + + wrap(c, wrapper) + assert await c() == 42 + assert seen == [42] + + +def test_wrap_raises_not_implemented_on_future_py(monkeypatch): + """wrap() must fail closed from 3.16 on.""" + import ddtrace.internal.wrapping as wrapping + + monkeypatch.setattr(wrapping, "PY", (3, 16)) + def f() -> None: return None def wrapper(wrapped, args, kwargs): # noqa: ANN001, ANN202 return wrapped(*args, **kwargs) - with pytest.raises(NotImplementedError, match=re.escape(_UNSUPPORTED_MSG)): - wrap(f, wrapper) + with pytest.raises(NotImplementedError, match="not supported yet"): + wrapping.wrap(f, wrapper) + with pytest.raises(NotImplementedError, match="not supported yet"): + wrapping.wrap_bytecode(wrapper, f) -@pytest.mark.skipif(PYTHON_VERSION_INFO < NEXT_PY_VERSION_INFO, reason=f"{NEXT_PY_VERSION} lazy module degrade") +@pytest.mark.skipif(not _WRAP_ON_315, reason="lazy module wrap on 3.15") def test_lazy_module_decorator_without_bytecode_wrap(): import tests.internal.lazy as lazy_module @@ -62,7 +142,7 @@ def test_exec_lazy_init_without_source(): assert module_globals["exported"] == 123 -@pytest.mark.skipif(PYTHON_VERSION_INFO < NEXT_PY_VERSION_INFO, reason=f"{NEXT_PY_VERSION} debugging products degrade") +@pytest.mark.skipif(not _WRAP_ON_315, reason="debugging products load on 3.15") def test_debugging_products_load_without_failure(): from ddtrace.internal.products import ProductManager @@ -77,9 +157,10 @@ def test_debugging_products_load_without_failure(): assert product_name not in product_manager._failed -@pytest.mark.skipif(PYTHON_VERSION_INFO < NEXT_PY_VERSION_INFO, reason=f"{NEXT_PY_VERSION} inject_hook degrade") -def test_inject_hook_raises_not_implemented_on_315(): +@pytest.mark.skipif(not _WRAP_ON_315, reason="inject_hook on 3.15") +def test_inject_hook_does_not_raise_on_315(): from ddtrace.internal.bytecode_injection import inject_hook + from ddtrace.internal.utils.inspection import linenos def f() -> None: return None @@ -87,5 +168,4 @@ def f() -> None: def hook(_arg: object) -> None: return None - with pytest.raises(NotImplementedError, match=re.escape(_UNSUPPORTED_MSG)): - inject_hook(f, hook, f.__code__.co_firstlineno, None) + inject_hook(f, hook, min(linenos(f)), None)