Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f5f7dbb
feat: widen requires-python to include Python 3.15
vlad-scherbich Aug 28, 2026
f97e354
chore(compat): pin monitoring and PEP 810 floors to PY_315_VERSION_INFO
vlad-scherbich Aug 28, 2026
19ba9ae
chore(compat): drop PY_GTE_* predicates and unused NEXT_MAX string
vlad-scherbich Aug 28, 2026
5e3a235
fix(compat): fold 3.15-only modules with literal version checks
vlad-scherbich Aug 28, 2026
fd6a8c2
chore(compat): set MAX to 3.14 and fail-close wrap at NEXT_MAX
vlad-scherbich Aug 29, 2026
5a3ecbb
chore(compat): rename support bounds to MAX_PY and NEXT_MAX_PY
vlad-scherbich Aug 29, 2026
c46e394
ci(py-315): stop claiming #19861 makes cp315 wheels publishable
vlad-scherbich Aug 30, 2026
04da0b1
test(compat): drop inject_hook and wrap raise asserts on 3.15
vlad-scherbich Aug 30, 2026
05f7a8f
chore(ssi): do not skip the whole lib-injection tree in hook linters
vlad-scherbich Aug 30, 2026
bd35d01
ci(py-315): mark the Cython pin as droppable after the image bump
vlad-scherbich Aug 28, 2026
5faabf5
ci(py-315): note lint bytecode pin is a different extra than runtime
vlad-scherbich Aug 30, 2026
26865be
ci(testrunner): mark the Cython 3.15 cache pin with TODO(py-315)
vlad-scherbich Aug 28, 2026
9003388
ci(riot): drop wrap() from 3.15 MAX comment
vlad-scherbich Aug 28, 2026
fc201f6
ci(riot): keep 3.15 starter to smoke and sourcecode
vlad-scherbich Aug 30, 2026
44f4160
chore(wrapping): do not fail-close wrap or lazy at NEXT_MAX_PY
vlad-scherbich Aug 30, 2026
0ce87e5
docs: drop 3.15 support and SSI enablement renos
vlad-scherbich Aug 30, 2026
5be226c
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
5 changes: 3 additions & 2 deletions .gitlab/scripts/prune-unsupported-wheels.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .gitlab/templates/cached-testrunner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions ddtrace/internal/bytecode_injection/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections import deque
import sys
from types import CodeType
from types import FunctionType
from typing import Any # noqa:F401
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
34 changes: 13 additions & 21 deletions ddtrace/internal/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,29 @@

__all__ = [
"maybe_stringify",
"MAX_PY",
"MAX_PY_VERSION",
"MAX_PY_VERSION_INFO",
"NEXT_PY_UNSUPPORTED_MSG",
"NEXT_MAX_PY_VERSION",
"NEXT_MAX_PY_VERSION_INFO",
"PY_GTE_MAX",
"PY_GTE_NEXT_MAX",
"NEXT_MAX_PY",
"PY_315_VERSION_INFO",
"PYTHON_VERSION_INFO",
]

PYTHON_VERSION_INFO = sys.version_info

# 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]}"

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)
# 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])

# 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
# 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:
Expand Down
6 changes: 3 additions & 3 deletions ddtrace/internal/coverage/instrumentation_py3_12.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +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.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
Expand Down Expand Up @@ -45,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 >= MAX_PY_VERSION_INFO 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
Expand All @@ -57,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 < MAX_PY_VERSION_INFO 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; "
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,17 +829,16 @@ def _trace(frame, event, arg):


def lazy(f: t.Callable[[], None]) -> None:
from ddtrace.internal.compat import PY_GTE_MAX

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

if not PY_GTE_MAX:
# 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 PY_GTE_MAX:
if sys.version_info >= (3, 16):
if not _initialized:
_exec_lazy_init(t.cast(FunctionType, f), _globals)
_initialized = True
Expand Down
11 changes: 7 additions & 4 deletions ddtrace/internal/monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@
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.compat import PY_315_VERSION_INFO
from ddtrace.internal.logger import get_logger
from ddtrace.internal.threads import Lock


if sys.version_info < MAX_PY_VERSION_INFO:
raise ImportError("ddtrace.internal.monitoring requires Python %s+" % MAX_PY_VERSION)
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__)

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,6 @@

from ddtrace.internal.assembly import Assembly
from ddtrace.internal.compat import NEXT_PY_UNSUPPORTED_MSG
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 +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_GTE_MAX:
# 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__
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_GTE_MAX:
if PY >= (3, 16):
raise NotImplementedError(NEXT_PY_UNSUPPORTED_MSG)
wrapped = FunctionType(
code := f.__code__,
Expand Down
3 changes: 2 additions & 1 deletion ddtrace/internal/wrapping/asyncs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 11 additions & 4 deletions ddtrace/internal/wrapping/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from bytecode import Bytecode

from ddtrace.internal.assembly import Assembly
from ddtrace.internal.compat import PY_GTE_NEXT_MAX
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
Expand Down Expand Up @@ -221,9 +221,11 @@ def _release_storage_var(name: str, var: StorageVar) -> None:
CONTEXT_RETURN = Assembly()
CONTEXT_FOOT = Assembly()

if PY_GTE_NEXT_MAX:
# 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
Expand Down Expand Up @@ -440,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

Expand Down Expand Up @@ -605,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+
Expand Down Expand Up @@ -700,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

Expand Down Expand Up @@ -818,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
Expand Down Expand Up @@ -1243,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(
Expand Down
3 changes: 2 additions & 1 deletion ddtrace/internal/wrapping/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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"""
Expand Down
2 changes: 1 addition & 1 deletion hooks/pre-commit/05-run-bandit
Original file line number Diff line number Diff line change
@@ -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)..."
Expand Down
2 changes: 1 addition & 1 deletion hooks/scripts/run-mypy.sh
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
7 changes: 3 additions & 4 deletions lib-injection/sources/sitecustomize.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,12 @@ 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)
# 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=""),
"max": Version(version=NEXT_MAX_PY_VERSION_INFO, constraint=""),
"max": Version(version=(3, 16), constraint=""),
}
}

Expand Down
17 changes: 7 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -25,7 +19,7 @@ license = { text = "LICENSE.BSD3" }
# ddtrace has many sub-components which can break when CPython internals
# change over versions, e.g. profiling. So we explicitly mark incompatibility
# with versions we don't support yet.
requires-python = ">=3.9,<3.15"
requires-python = ">=3.9,<3.16"
authors = [
{ name = "Datadog, Inc.", email = "dev@datadoghq.com" },
]
Expand All @@ -40,6 +34,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
]
dependencies = [
"bytecode>=0.19.0,<1; python_version>='3.15.0'",
Expand Down Expand Up @@ -135,7 +130,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 = [
Expand Down
Loading
Loading