Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c8b4968
chore(compat): add CURRENT_PY_VERSION and bump NEXT_PY_VERSION to 3.16
vlad-scherbich Aug 28, 2026
96f4866
chore(compat): TODO CURRENT_PY fail-closed sites to flip to NEXT afte…
vlad-scherbich Aug 28, 2026
a5ce70e
test(compat): drop leftover inject_hook raise test after #17849
vlad-scherbich Aug 28, 2026
5ea6a93
chore(compat): rename CURRENT/NEXT Python version constants to MAX/NE…
vlad-scherbich Aug 28, 2026
3347d45
chore(compat): share PY_GTE_MAX and PY_GTE_NEXT_MAX support predicates
vlad-scherbich Aug 28, 2026
e988dc5
chore(compat): pin monitoring and PEP 810 floors to PY_315_VERSION_INFO
vlad-scherbich Aug 28, 2026
dc6ffe5
chore(compat): drop PY_GTE_* predicates and unused NEXT_MAX string
vlad-scherbich Aug 28, 2026
33af304
fix(compat): fold 3.15-only modules with literal version checks
vlad-scherbich Aug 28, 2026
4557f4b
chore(compat): set MAX to 3.14 and fail-close wrap at NEXT_MAX
vlad-scherbich Aug 29, 2026
128591b
chore(compat): rename support bounds to MAX_PY and NEXT_MAX_PY
vlad-scherbich Aug 29, 2026
1002958
ci(py-315): stop claiming #19861 makes cp315 wheels publishable
vlad-scherbich Aug 30, 2026
1958eb4
test(compat): drop inject_hook and wrap raise asserts on 3.15
vlad-scherbich Aug 30, 2026
7a4bde7
chore(ssi): do not skip the whole lib-injection tree in hook linters
vlad-scherbich Aug 30, 2026
9eb6cf4
ci(py-315): mark the Cython pin as droppable after the image bump
vlad-scherbich Aug 28, 2026
d301bd4
ci(py-315): note lint bytecode pin is a different extra than runtime
vlad-scherbich Aug 30, 2026
e5dfa0b
ci(testrunner): mark the Cython 3.15 cache pin with TODO(py-315)
vlad-scherbich Aug 28, 2026
6b123dc
ci(riot): drop wrap() from 3.15 MAX comment
vlad-scherbich Aug 28, 2026
210381e
ci(riot): keep 3.15 starter to smoke and sourcecode
vlad-scherbich Aug 30, 2026
ed3bd06
chore(wrapping): do not fail-close wrap or lazy at NEXT_MAX_PY
vlad-scherbich Aug 30, 2026
57ccbdc
ci(py-315): pin lint bytecode to 0.19.0 on 3.15
vlad-scherbich Aug 30, 2026
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
30 changes: 24 additions & 6 deletions ddtrace/internal/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,38 @@

__all__ = [
"maybe_stringify",
"MAX_PY_VERSION",
"MAX_PY_VERSION_INFO",
"NEXT_PY_UNSUPPORTED_MSG",
"NEXT_PY_VERSION",
"NEXT_PY_VERSION_INFO",
"NEXT_MAX_PY_VERSION",
"NEXT_MAX_PY_VERSION_INFO",
"PY_GTE_MAX",
"PY_GTE_NEXT_MAX",
"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]))

def _py_version_info(version: str) -> tuple[int, int]:
major, minor = version.split(".")[:2]
return (int(major), int(minor))


# Upper bound of CPythons we code against.
MAX_PY_VERSION: str = "3.15"
MAX_PY_VERSION_INFO: tuple[int, int] = _py_version_info(MAX_PY_VERSION)

# First CPython past MAX (exclusive upper bound / fail-closed default).
NEXT_MAX_PY_VERSION: str = "3.16"
NEXT_MAX_PY_VERSION_INFO: tuple[int, int] = _py_version_info(NEXT_MAX_PY_VERSION)
NEXT_PY_UNSUPPORTED_MSG: str = "This version of CPython is not supported yet: {}.{}".format(*sys.version_info[:2])

# Support / fail-closed predicates (not API/bytecode introduction floors).
# TODO(py-315): compare to NEXT_MAX_PY_VERSION_INFO after 3.15 GAs
PY_GTE_MAX: bool = PYTHON_VERSION_INFO >= MAX_PY_VERSION_INFO
PY_GTE_NEXT_MAX: bool = PYTHON_VERSION_INFO >= NEXT_MAX_PY_VERSION_INFO
Comment thread
vlad-scherbich marked this conversation as resolved.
Outdated


def ensure_text(s, encoding="utf-8", errors="ignore") -> str:
if isinstance(s, str):
Expand Down
5 changes: 3 additions & 2 deletions ddtrace/internal/coverage/instrumentation_py3_12.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from bytecode import Bytecode

from ddtrace.internal.bytecode_injection import HookType
from ddtrace.internal.compat import MAX_PY_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
Expand Down Expand Up @@ -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 >= MAX_PY_VERSION_INFO else 0

# Detect empty modules: the bytecode pattern varies across Python versions.
# Python 3.12-3.13: RESUME + RETURN_CONST
Expand All @@ -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 < MAX_PY_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; "
Expand Down
7 changes: 3 additions & 4 deletions ddtrace/internal/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -829,18 +829,17 @@ 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
from ddtrace.internal.compat import PY_GTE_MAX

_globals = sys._getframe(1).f_globals
_initialized = False

if PYTHON_VERSION_INFO < NEXT_PY_VERSION_INFO:
if not PY_GTE_MAX:
LazyWrappingContext(t.cast(FunctionType, f)).wrap()

def __getattr__(name: str) -> t.Any:
nonlocal _initialized
if PYTHON_VERSION_INFO >= NEXT_PY_VERSION_INFO:
if PY_GTE_MAX:
if not _initialized:
_exec_lazy_init(t.cast(FunctionType, f), _globals)
_initialized = True
Expand Down
6 changes: 4 additions & 2 deletions ddtrace/internal/monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
from typing import Optional
import weakref

from ddtrace.internal.compat import MAX_PY_VERSION
from ddtrace.internal.compat import MAX_PY_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 < MAX_PY_VERSION_INFO:
raise ImportError("ddtrace.internal.monitoring requires Python %s+" % MAX_PY_VERSION)
Comment thread
vlad-scherbich marked this conversation as resolved.
Outdated

log = get_logger(__name__)

Expand Down
6 changes: 3 additions & 3 deletions ddtrace/internal/wrapping/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

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.compat import PY_GTE_MAX
from ddtrace.internal.threads import Lock
from ddtrace.internal.wrapping.asyncs import wrap_async
from ddtrace.internal.wrapping.generators import wrap_generator
Expand Down Expand Up @@ -300,7 +300,7 @@ 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:
if PY_GTE_MAX:
raise NotImplementedError(NEXT_PY_UNSUPPORTED_MSG)

code = wrapped.__code__
Expand Down Expand Up @@ -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_GTE_MAX:
raise NotImplementedError(NEXT_PY_UNSUPPORTED_MSG)
wrapped = FunctionType(
code := f.__code__,
Expand Down
3 changes: 2 additions & 1 deletion ddtrace/internal/wrapping/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from bytecode import Bytecode

from ddtrace.internal.assembly import Assembly
from ddtrace.internal.compat import PY_GTE_NEXT_MAX
from ddtrace.internal.logger import get_logger
from ddtrace.internal.threads import Lock
from ddtrace.internal.threads import RLock
Expand Down Expand Up @@ -220,7 +221,7 @@ def _release_storage_var(name: str, var: StorageVar) -> None:
CONTEXT_RETURN = Assembly()
CONTEXT_FOOT = Assembly()

if sys.version_info >= (3, 16):
if PY_GTE_NEXT_MAX:
raise NotImplementedError("This version of Python is not supported yet")
elif sys.version_info >= (3, 15):
# We rely on sys.monitoring for wrapping, so no bytecode manipulation is
Expand Down
5 changes: 4 additions & 1 deletion lib-injection/sources/sitecustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ def parse_version(version):

TELEMETRY_DATA = []
SCRIPT_DIR = os.path.dirname(__file__)
# Exclusive upper bound. Same tuple as NEXT_MAX_PY_VERSION_INFO in ddtrace.internal.compat
# (this bootstrap cannot import ddtrace).
NEXT_MAX_PY_VERSION_INFO = (3, 16)
RUNTIMES_ALLOW_LIST = {
"cpython": {
"min": Version(version=(3, 9), constraint=""),
"max": Version(version=(3, 16), constraint=""),
"max": Version(version=NEXT_MAX_PY_VERSION_INFO, constraint=""),
}
}

Expand Down
42 changes: 20 additions & 22 deletions tests/internal/test_py315_import_degrade.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
"""Python 3.15 import-time degrade: wrapping must load, wrap() still raises.

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.
Products that import wrapping (e.g. ModuleWatchdog) must not crash the process.
wrap() still fail-closes with NotImplementedError when actually used.
"""

import re

import pytest

from ddtrace.internal.compat import MAX_PY_VERSION
from ddtrace.internal.compat import MAX_PY_VERSION_INFO
from ddtrace.internal.compat import NEXT_MAX_PY_VERSION
from ddtrace.internal.compat import NEXT_MAX_PY_VERSION_INFO
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_GTE_MAX
from ddtrace.internal.compat import PY_GTE_NEXT_MAX
from ddtrace.internal.compat import PYTHON_VERSION_INFO


Expand All @@ -23,6 +26,15 @@ 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.15"
assert MAX_PY_VERSION_INFO == (3, 15)
assert NEXT_MAX_PY_VERSION == "3.16"
assert NEXT_MAX_PY_VERSION_INFO == (3, 16)
assert PY_GTE_MAX == (PYTHON_VERSION_INFO >= MAX_PY_VERSION_INFO)
assert PY_GTE_NEXT_MAX == (PYTHON_VERSION_INFO >= NEXT_MAX_PY_VERSION_INFO)


def test_wrapping_modules_import():
import ddtrace.internal.bytecode_injection # noqa: F401
import ddtrace.internal.module # noqa: F401
Expand All @@ -31,7 +43,7 @@ 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")
@pytest.mark.skipif(not PY_GTE_MAX, reason=f"{MAX_PY_VERSION} wrap() degrade")
def test_wrap_raises_not_implemented_on_315():
from ddtrace.internal.wrapping import wrap

Expand All @@ -45,7 +57,7 @@ def wrapper(wrapped, args, kwargs): # noqa: ANN001, ANN202
wrap(f, wrapper)


@pytest.mark.skipif(PYTHON_VERSION_INFO < NEXT_PY_VERSION_INFO, reason=f"{NEXT_PY_VERSION} lazy module degrade")
@pytest.mark.skipif(not PY_GTE_MAX, reason=f"{MAX_PY_VERSION} lazy module degrade")
def test_lazy_module_decorator_without_bytecode_wrap():
import tests.internal.lazy as lazy_module

Expand All @@ -62,7 +74,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 PY_GTE_MAX, reason=f"{MAX_PY_VERSION} debugging products degrade")
def test_debugging_products_load_without_failure():
from ddtrace.internal.products import ProductManager

Expand All @@ -75,17 +87,3 @@ def test_debugging_products_load_without_failure():
"live-debugger",
):
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():
from ddtrace.internal.bytecode_injection import inject_hook

def f() -> None:
return 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)
Loading