From c662930429438b4937688fe447069e8485c391fd Mon Sep 17 00:00:00 2001 From: Vlad Scherbich Date: Fri, 21 Aug 2026 07:51:19 +0300 Subject: [PATCH 1/3] chore(profiling): update Python profiling collectors for py3.15 # Conflicts: # ddtrace/internal/monitoring.py # ddtrace/internal/wrapping/asyncs.py --- ddtrace/internal/monitoring.py | 15 +++- ddtrace/profiling/_asyncio.py | 47 ++++++------ ddtrace/profiling/collector/asyncio.py | 77 +++++++++++--------- ddtrace/profiling/collector/exception.py | 14 +++- ddtrace/profiling/collector/stack.py | 11 ++- ddtrace/profiling/collector/threading.py | 92 +++++++++++++----------- tests/profiling/test_scheduler.py | 5 +- 7 files changed, 162 insertions(+), 99 deletions(-) diff --git a/ddtrace/internal/monitoring.py b/ddtrace/internal/monitoring.py index f63994cd711..e7ae535c81a 100644 --- a/ddtrace/internal/monitoring.py +++ b/ddtrace/internal/monitoring.py @@ -142,6 +142,7 @@ def _events_for_handler(handler: MonitoringEventHandler) -> int: return events + class _Entry(NamedTuple): handler: MonitoringEventHandler events: int # pre-computed from _events_for_handler @@ -274,7 +275,19 @@ def _on_py_line(code: CodeType, line_number: int) -> Optional[object]: def _set_local_events(tool_id: int, code: CodeType, events: int) -> None: - sys.monitoring.set_local_events(tool_id, code, events) + # TODO(py-315): Pre-release Python 3.15 builds may reject PY_UNWIND + # as a local event. Fall back without it when the full set is invalid; + # PY_UNWIND is still registered as a global callback via _setup() so + # exception handling degrades gracefully rather than crashing. + try: + sys.monitoring.set_local_events(tool_id, code, events) + except ValueError: + fallback = events & ~_E.PY_UNWIND + if fallback != events: + sys.monitoring.set_local_events(tool_id, code, fallback) + else: + raise + def _rearm_local_events(tool_id: int, code: CodeType, events: int) -> None: diff --git a/ddtrace/profiling/_asyncio.py b/ddtrace/profiling/_asyncio.py index 133dab9ddb5..259bae9eaeb 100644 --- a/ddtrace/profiling/_asyncio.py +++ b/ddtrace/profiling/_asyncio.py @@ -119,19 +119,21 @@ def _( @partial(wrap, sys.modules["asyncio"].tasks._GatheringFuture.__init__) def _(f: typing.Callable[..., None], args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> None: + f(*args, **kwargs) + children = get_argument_value(args, kwargs, 1, "children") + assert children is not None # nosec: assert is used for typing + + # TODO(py-315): current_task() raises RuntimeError on Python 3.15+ when there + # is no running event loop (e.g. asyncio.gather() called outside an async + # context to build a coroutine for later scheduling). In that case there is + # no parent task to link from, so we skip link_tasks entirely. try: - return f(*args, **kwargs) - finally: - children: list[aio.Future[typing.Any]] = typing.cast( - "list[aio.Future[typing.Any]]", get_argument_value(args, kwargs, 1, "children") - ) - assert children is not None # nosec: assert is used for typing - - if globals()["get_running_loop"]() is not None: - parent: typing.Optional[aio.Task[typing.Any]] = globals()["current_task"]() - if parent is not None: - for child in children: - stack.link_tasks(parent, child) + parent = globals()["current_task"]() + except RuntimeError: + return + if parent is not None: + for child in children: + stack.link_tasks(parent, child) @partial(wrap, sys.modules["asyncio"].tasks._wait) def _( @@ -139,15 +141,20 @@ def _( args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any], ) -> typing.Any: + result = f(*args, **kwargs) + futures = typing.cast(set["aio.Future[typing.Any]"], get_argument_value(args, kwargs, 0, "fs")) + + # TODO(py-315): same guard as the _GatheringFuture wrapper above — _wait may + # also be invoked outside a running loop. Skip link_tasks when current_task() + # raises. try: - return f(*args, **kwargs) - finally: - futures = typing.cast("set[aio.Future[typing.Any]]", get_argument_value(args, kwargs, 0, "fs")) - - if globals()["get_running_loop"]() is not None: - parent = typing.cast("aio.Task[typing.Any]", globals()["current_task"]()) - for future in futures: - stack.link_tasks(parent, future) + parent = typing.cast("aio.Task[typing.Any]", globals()["current_task"]()) + except RuntimeError: + return result + if parent is not None: + for future in futures: + stack.link_tasks(parent, future) + return result @partial(wrap, sys.modules["asyncio"].tasks.as_completed) def _( diff --git a/ddtrace/profiling/collector/asyncio.py b/ddtrace/profiling/collector/asyncio.py index d8afdf93aeb..47933c6bcec 100644 --- a/ddtrace/profiling/collector/asyncio.py +++ b/ddtrace/profiling/collector/asyncio.py @@ -3,52 +3,63 @@ import asyncio from types import ModuleType -from . import _lock +try: + from . import _lock -class _ProfiledAsyncioLock(_lock._ProfiledLock): - pass + class _ProfiledAsyncioLock(_lock._ProfiledLock): + pass + class _ProfiledAsyncioSemaphore(_lock._ProfiledLock): + pass -class _ProfiledAsyncioSemaphore(_lock._ProfiledLock): - pass + class _ProfiledAsyncioBoundedSemaphore(_lock._ProfiledLock): + pass + class _ProfiledAsyncioCondition(_lock._ProfiledLock): + pass -class _ProfiledAsyncioBoundedSemaphore(_lock._ProfiledLock): - pass + class AsyncioLockCollector(_lock.LockCollector): + """Record asyncio.Lock usage.""" + PROFILED_LOCK_CLASS: type[_ProfiledAsyncioLock] = _ProfiledAsyncioLock + MODULE: ModuleType = asyncio + PATCHED_LOCK_NAME: str = "Lock" -class _ProfiledAsyncioCondition(_lock._ProfiledLock): - pass + class AsyncioSemaphoreCollector(_lock.LockCollector): + """Record asyncio.Semaphore usage.""" + PROFILED_LOCK_CLASS: type[_ProfiledAsyncioSemaphore] = _ProfiledAsyncioSemaphore + MODULE: ModuleType = asyncio + PATCHED_LOCK_NAME: str = "Semaphore" -class AsyncioLockCollector(_lock.LockCollector): - """Record asyncio.Lock usage.""" + class AsyncioBoundedSemaphoreCollector(_lock.LockCollector): + """Record asyncio.BoundedSemaphore usage.""" - PROFILED_LOCK_CLASS: type[_ProfiledAsyncioLock] = _ProfiledAsyncioLock - MODULE: ModuleType = asyncio - PATCHED_LOCK_NAME: str = "Lock" + PROFILED_LOCK_CLASS: type[_ProfiledAsyncioBoundedSemaphore] = _ProfiledAsyncioBoundedSemaphore + MODULE: ModuleType = asyncio + PATCHED_LOCK_NAME: str = "BoundedSemaphore" + class AsyncioConditionCollector(_lock.LockCollector): + """Record asyncio.Condition usage.""" -class AsyncioSemaphoreCollector(_lock.LockCollector): - """Record asyncio.Semaphore usage.""" + PROFILED_LOCK_CLASS: type[_ProfiledAsyncioCondition] = _ProfiledAsyncioCondition + MODULE: ModuleType = asyncio + PATCHED_LOCK_NAME: str = "Condition" - PROFILED_LOCK_CLASS: type[_ProfiledAsyncioSemaphore] = _ProfiledAsyncioSemaphore - MODULE: ModuleType = asyncio - PATCHED_LOCK_NAME: str = "Semaphore" +except ImportError: + # TODO(py-315): _lock is a Cython extension that is not compiled for all Python + # versions (e.g. Python 3.15 before the manylinux image carries it). When it + # is absent the asyncio lock collectors are unavailable. Defining stubs that + # raise CollectorUnavailable lets profiler.py discover and gracefully skip them + # rather than failing at import time. + from ddtrace.profiling.collector import Collector as _Collector + from ddtrace.profiling.collector import CollectorUnavailable as _CollectorUnavailable + class AsyncioLockCollector(_Collector): # type: ignore[no-redef] + def start(self) -> None: + raise _CollectorUnavailable -class AsyncioBoundedSemaphoreCollector(_lock.LockCollector): - """Record asyncio.BoundedSemaphore usage.""" - - PROFILED_LOCK_CLASS: type[_ProfiledAsyncioBoundedSemaphore] = _ProfiledAsyncioBoundedSemaphore - MODULE: ModuleType = asyncio - PATCHED_LOCK_NAME: str = "BoundedSemaphore" - - -class AsyncioConditionCollector(_lock.LockCollector): - """Record asyncio.Condition usage.""" - - PROFILED_LOCK_CLASS: type[_ProfiledAsyncioCondition] = _ProfiledAsyncioCondition - MODULE: ModuleType = asyncio - PATCHED_LOCK_NAME: str = "Condition" + AsyncioSemaphoreCollector = AsyncioLockCollector # type: ignore[assignment,misc] + AsyncioBoundedSemaphoreCollector = AsyncioLockCollector # type: ignore[assignment,misc] + AsyncioConditionCollector = AsyncioLockCollector # type: ignore[assignment,misc] diff --git a/ddtrace/profiling/collector/exception.py b/ddtrace/profiling/collector/exception.py index af0877d07d7..851974b84b4 100644 --- a/ddtrace/profiling/collector/exception.py +++ b/ddtrace/profiling/collector/exception.py @@ -1,4 +1,16 @@ -from ddtrace.profiling.collector._exception import ExceptionCollector +try: + from ddtrace.profiling.collector._exception import ExceptionCollector +except ImportError: + # TODO(py-315): _exception is a Cython extension not compiled for all Python + # versions (e.g. Python 3.15 before the manylinux image carries it). Define + # a stub so profiler.py can import this module and skip the collector via + # CollectorUnavailable rather than failing at import time. + from ddtrace.profiling.collector import Collector as _Collector + from ddtrace.profiling.collector import CollectorUnavailable as _CollectorUnavailable + + class ExceptionCollector(_Collector): # type: ignore[no-redef] + def start(self) -> None: + raise _CollectorUnavailable __all__ = ["ExceptionCollector"] diff --git a/ddtrace/profiling/collector/stack.py b/ddtrace/profiling/collector/stack.py index 406e875eef1..c72ff8305fd 100644 --- a/ddtrace/profiling/collector/stack.py +++ b/ddtrace/profiling/collector/stack.py @@ -13,7 +13,16 @@ from ddtrace.internal.datadog.profiling import stack from ddtrace.internal.settings.profiling import config from ddtrace.profiling import collector -from ddtrace.profiling.collector import _task + + +try: + from ddtrace.profiling.collector import _task +except ImportError: + # TODO(py-315): _task is a Cython extension not compiled for all Python versions. + # Provide a no-op stub so StackCollector can be imported on Python 3.15. + import types as _types + + _task = _types.SimpleNamespace(initialize_gevent_support=lambda: None) # type: ignore[assignment] from ddtrace.profiling.collector import threading from ddtrace.trace import Tracer diff --git a/ddtrace/profiling/collector/threading.py b/ddtrace/profiling/collector/threading.py index 2d15d124b85..2ec931f1a9e 100644 --- a/ddtrace/profiling/collector/threading.py +++ b/ddtrace/profiling/collector/threading.py @@ -6,67 +6,75 @@ from ddtrace.internal.datadog.profiling import stack from ddtrace.internal.settings.profiling import config -from . import _lock +try: + from . import _lock -class _ProfiledThreadingLock(_lock._ProfiledLock): - pass + class _ProfiledThreadingLock(_lock._ProfiledLock): + pass + class _ProfiledThreadingRLock(_lock._ProfiledLock): + pass -class _ProfiledThreadingRLock(_lock._ProfiledLock): - pass + class _ProfiledThreadingSemaphore(_lock._ProfiledLock): + pass + class _ProfiledThreadingBoundedSemaphore(_lock._ProfiledLock): + pass -class _ProfiledThreadingSemaphore(_lock._ProfiledLock): - pass + class _ProfiledThreadingCondition(_lock._ProfiledLock): + pass + class ThreadingLockCollector(_lock.LockCollector): + """Record threading.Lock usage.""" -class _ProfiledThreadingBoundedSemaphore(_lock._ProfiledLock): - pass + PROFILED_LOCK_CLASS: type[_ProfiledThreadingLock] = _ProfiledThreadingLock + MODULE: ModuleType = threading + PATCHED_LOCK_NAME: str = "Lock" + class ThreadingRLockCollector(_lock.LockCollector): + """Record threading.RLock usage.""" -class _ProfiledThreadingCondition(_lock._ProfiledLock): - pass + PROFILED_LOCK_CLASS: type[_ProfiledThreadingRLock] = _ProfiledThreadingRLock + MODULE: ModuleType = threading + PATCHED_LOCK_NAME: str = "RLock" + class ThreadingSemaphoreCollector(_lock.LockCollector): + """Record threading.Semaphore usage.""" -class ThreadingLockCollector(_lock.LockCollector): - """Record threading.Lock usage.""" + PROFILED_LOCK_CLASS: type[_ProfiledThreadingSemaphore] = _ProfiledThreadingSemaphore + MODULE: ModuleType = threading + PATCHED_LOCK_NAME: str = "Semaphore" - PROFILED_LOCK_CLASS: type[_ProfiledThreadingLock] = _ProfiledThreadingLock - MODULE: ModuleType = threading - PATCHED_LOCK_NAME: str = "Lock" + class ThreadingBoundedSemaphoreCollector(_lock.LockCollector): + """Record threading.BoundedSemaphore usage.""" + PROFILED_LOCK_CLASS: type[_ProfiledThreadingBoundedSemaphore] = _ProfiledThreadingBoundedSemaphore + MODULE: ModuleType = threading + PATCHED_LOCK_NAME: str = "BoundedSemaphore" -class ThreadingRLockCollector(_lock.LockCollector): - """Record threading.RLock usage.""" + class ThreadingConditionCollector(_lock.LockCollector): + """Record threading.Condition usage.""" - PROFILED_LOCK_CLASS: type[_ProfiledThreadingRLock] = _ProfiledThreadingRLock - MODULE: ModuleType = threading - PATCHED_LOCK_NAME: str = "RLock" + PROFILED_LOCK_CLASS: type[_ProfiledThreadingCondition] = _ProfiledThreadingCondition + MODULE: ModuleType = threading + PATCHED_LOCK_NAME: str = "Condition" +except ImportError: + # TODO(py-315): _lock is a Cython extension not compiled for all Python versions + # (e.g. Python 3.15 before the manylinux image carries it). Stubs raise + # CollectorUnavailable so profiler.py skips them gracefully. + from ddtrace.profiling.collector import Collector as _Collector + from ddtrace.profiling.collector import CollectorUnavailable as _CollectorUnavailable -class ThreadingSemaphoreCollector(_lock.LockCollector): - """Record threading.Semaphore usage.""" + class ThreadingLockCollector(_Collector): # type: ignore[no-redef] + def start(self) -> None: + raise _CollectorUnavailable - PROFILED_LOCK_CLASS: type[_ProfiledThreadingSemaphore] = _ProfiledThreadingSemaphore - MODULE: ModuleType = threading - PATCHED_LOCK_NAME: str = "Semaphore" - - -class ThreadingBoundedSemaphoreCollector(_lock.LockCollector): - """Record threading.BoundedSemaphore usage.""" - - PROFILED_LOCK_CLASS: type[_ProfiledThreadingBoundedSemaphore] = _ProfiledThreadingBoundedSemaphore - MODULE: ModuleType = threading - PATCHED_LOCK_NAME: str = "BoundedSemaphore" - - -class ThreadingConditionCollector(_lock.LockCollector): - """Record threading.Condition usage.""" - - PROFILED_LOCK_CLASS: type[_ProfiledThreadingCondition] = _ProfiledThreadingCondition - MODULE: ModuleType = threading - PATCHED_LOCK_NAME: str = "Condition" + ThreadingRLockCollector = ThreadingLockCollector # type: ignore[assignment,misc] + ThreadingSemaphoreCollector = ThreadingLockCollector # type: ignore[assignment,misc] + ThreadingBoundedSemaphoreCollector = ThreadingLockCollector # type: ignore[assignment,misc] + ThreadingConditionCollector = ThreadingLockCollector # type: ignore[assignment,misc] # Also patch threading.Thread so echion can track thread lifetimes diff --git a/tests/profiling/test_scheduler.py b/tests/profiling/test_scheduler.py index 75a0c77c098..9cbb2e2bda3 100644 --- a/tests/profiling/test_scheduler.py +++ b/tests/profiling/test_scheduler.py @@ -34,7 +34,10 @@ def call_me(): raise Exception("LOL") s = scheduler.Scheduler(before_flush=call_me) - s.flush() + # Patch ddup.upload so the test only checks scheduler logging behaviour and + # doesn't attempt a real upload (which would log a writer error and pollute caplog). + with mock.patch("ddtrace.profiling.scheduler.ddup.upload"): + s.flush() assert caplog.record_tuples == [ (("ddtrace.profiling.scheduler", logging.ERROR, "Scheduler before_flush hook failed")) ] From a97e57334fa182a4b153317eab38e70c9d48eea1 Mon Sep 17 00:00:00 2001 From: Vlad Scherbich Date: Thu, 23 Jul 2026 10:28:48 -0400 Subject: [PATCH 2/3] typing: annotate profiling collectors for py3.15 --- ddtrace/internal/monitoring.py | 2 +- ddtrace/profiling/_asyncio.py | 16 +++++++++++----- ddtrace/profiling/collector/stack.py | 5 ++++- tests/profiling/test_scheduler.py | 6 ++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/ddtrace/internal/monitoring.py b/ddtrace/internal/monitoring.py index e7ae535c81a..10e7ee87525 100644 --- a/ddtrace/internal/monitoring.py +++ b/ddtrace/internal/monitoring.py @@ -282,7 +282,7 @@ def _set_local_events(tool_id: int, code: CodeType, events: int) -> None: try: sys.monitoring.set_local_events(tool_id, code, events) except ValueError: - fallback = events & ~_E.PY_UNWIND + fallback: int = events & ~_E.PY_UNWIND if fallback != events: sys.monitoring.set_local_events(tool_id, code, fallback) else: diff --git a/ddtrace/profiling/_asyncio.py b/ddtrace/profiling/_asyncio.py index 259bae9eaeb..9ac02e80dec 100644 --- a/ddtrace/profiling/_asyncio.py +++ b/ddtrace/profiling/_asyncio.py @@ -120,7 +120,9 @@ def _( @partial(wrap, sys.modules["asyncio"].tasks._GatheringFuture.__init__) def _(f: typing.Callable[..., None], args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> None: f(*args, **kwargs) - children = get_argument_value(args, kwargs, 1, "children") + children: list[aio.Future[typing.Any]] = typing.cast( + "list[aio.Future[typing.Any]]", get_argument_value(args, kwargs, 1, "children") + ) assert children is not None # nosec: assert is used for typing # TODO(py-315): current_task() raises RuntimeError on Python 3.15+ when there @@ -128,7 +130,7 @@ def _(f: typing.Callable[..., None], args: tuple[typing.Any, ...], kwargs: dict[ # context to build a coroutine for later scheduling). In that case there is # no parent task to link from, so we skip link_tasks entirely. try: - parent = globals()["current_task"]() + parent: typing.Optional[aio.Task[typing.Any]] = globals()["current_task"]() except RuntimeError: return if parent is not None: @@ -141,14 +143,18 @@ def _( args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any], ) -> typing.Any: - result = f(*args, **kwargs) - futures = typing.cast(set["aio.Future[typing.Any]"], get_argument_value(args, kwargs, 0, "fs")) + result: tuple[set[aio.Future[typing.Any]], set[aio.Future[typing.Any]]] = f(*args, **kwargs) + futures: set[aio.Future[typing.Any]] = typing.cast( + "set[aio.Future[typing.Any]]", get_argument_value(args, kwargs, 0, "fs") + ) # TODO(py-315): same guard as the _GatheringFuture wrapper above — _wait may # also be invoked outside a running loop. Skip link_tasks when current_task() # raises. try: - parent = typing.cast("aio.Task[typing.Any]", globals()["current_task"]()) + parent: typing.Optional[aio.Task[typing.Any]] = typing.cast( + "aio.Task[typing.Any]", globals()["current_task"]() + ) except RuntimeError: return result if parent is not None: diff --git a/ddtrace/profiling/collector/stack.py b/ddtrace/profiling/collector/stack.py index c72ff8305fd..bf2c45ef4ef 100644 --- a/ddtrace/profiling/collector/stack.py +++ b/ddtrace/profiling/collector/stack.py @@ -22,7 +22,10 @@ # Provide a no-op stub so StackCollector can be imported on Python 3.15. import types as _types - _task = _types.SimpleNamespace(initialize_gevent_support=lambda: None) # type: ignore[assignment] + def _initialize_gevent_support() -> None: + return None + + _task = _types.SimpleNamespace(initialize_gevent_support=_initialize_gevent_support) # type: ignore[assignment] from ddtrace.profiling.collector import threading from ddtrace.trace import Tracer diff --git a/tests/profiling/test_scheduler.py b/tests/profiling/test_scheduler.py index 9cbb2e2bda3..53e1406bbae 100644 --- a/tests/profiling/test_scheduler.py +++ b/tests/profiling/test_scheduler.py @@ -2,6 +2,8 @@ import logging from unittest import mock +import pytest + from ddtrace.profiling import scheduler @@ -29,8 +31,8 @@ def call_me(): assert x["OK"] -def test_before_flush_failure(caplog): - def call_me(): +def test_before_flush_failure(caplog: pytest.LogCaptureFixture) -> None: + def call_me() -> None: raise Exception("LOL") s = scheduler.Scheduler(before_flush=call_me) From ee28a3ffc25a02001df4bf6245d5f62904abfe48 Mon Sep 17 00:00:00 2001 From: Vlad Scherbich Date: Fri, 21 Aug 2026 07:52:05 +0300 Subject: [PATCH 3/3] ci(profiling): wire py3.15 into build matrix, riotfile, and CI Profiling-native py3.15 job, setup.py guards, and crashtracker 3.15 opt-in. Rebased onto the #17849 split stack (PR 17624). # Conflicts: # riotfile.py # setup.py --- .../workflows/generate-package-versions.yml | 5 +++ .gitlab-ci.yml | 16 +++++++ .gitlab/templates/build-base-venvs.yml | 2 + .riot/requirements/1c6cb02.txt | 34 ++++++++++++++ .riot/requirements/222bcd0.txt | 45 +++++++++++++++++++ .riot/requirements/95077af.txt | 33 ++++++++++++++ .riot/requirements/e26245b.txt | 37 +++++++++++++++ riotfile.py | 7 +-- scripts/requirements_to_csv.py | 4 +- setup.py | 11 ++++- 10 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 .riot/requirements/1c6cb02.txt create mode 100644 .riot/requirements/222bcd0.txt create mode 100644 .riot/requirements/95077af.txt create mode 100644 .riot/requirements/e26245b.txt diff --git a/.github/workflows/generate-package-versions.yml b/.github/workflows/generate-package-versions.yml index 955d1552cb4..e742ecfb564 100644 --- a/.github/workflows/generate-package-versions.yml +++ b/.github/workflows/generate-package-versions.yml @@ -53,6 +53,11 @@ jobs: with: python-version: "3.14" + - name: Setup Python 3.15 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.15" + - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7620cfeedf2..cc047127451 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -703,6 +703,22 @@ profiling_native: - PYTHON_VERSION: ["3.12", "3.14"] SANITIZER: ["valgrind"] +# AIDEV-TODO(py315): fold 3.15 back into the profiling_native matrix once the +# dd/images/dd-trace-py/profiling_native base image installs a Python 3.15 +# pyenv version. Today it only carries 3.9–3.14, so PYENV_VERSION=3.15 fails +# with "pyenv: version `3.15' is not installed". allow_failure keeps the +# pipeline green while the upstream image catches up; delete this job and +# re-add "3.15" to the PYTHON_VERSION arrays above once the image is updated. +profiling_native_py315: + extends: .profiling_native_base + allow_failure: true + retry: 2 + rules: !reference [profiling_native, rules] + parallel: + matrix: + - PYTHON_VERSION: ["3.15"] + SANITIZER: ["safety", "thread", "", "valgrind"] + test-dd-sts: stage: tests needs: [] diff --git a/.gitlab/templates/build-base-venvs.yml b/.gitlab/templates/build-base-venvs.yml index dc32b3e40d8..797b73938df 100644 --- a/.gitlab/templates/build-base-venvs.yml +++ b/.gitlab/templates/build-base-venvs.yml @@ -49,3 +49,5 @@ build_base_venvs: - core.* - ddtrace/**/*.so* - .riot/venv_* + - ddtrace/internal/datadog/profiling/test/test_* + - ddtrace/internal/datadog/profiling/test/py315/test_* diff --git a/.riot/requirements/1c6cb02.txt b/.riot/requirements/1c6cb02.txt new file mode 100644 index 00000000000..b9724685738 --- /dev/null +++ b/.riot/requirements/1c6cb02.txt @@ -0,0 +1,34 @@ +# +# This file is autogenerated by pip-compile with Python 3.15 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1c6cb02.in +# +attrs==26.1.0 +coverage[toml]==7.13.5 +gunicorn==25.3.0 +hypothesis==6.45.0 +iniconfig==2.3.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +mock==5.2.0 +numpy==2.4.4 +opentracing==2.4.0 +packaging==26.1 +pluggy==1.6.0 +protobuf==7.34.1 +py-cpuinfo==8.0.0 +pygments==2.20.0 +pytest==9.0.3 +pytest-asyncio==0.21.1 +pytest-benchmark==5.2.3 +pytest-cov==7.1.0 +pytest-cpp==2.6.0 +pytest-mock==3.15.1 +pytest-randomly==4.1.0 +referencing==0.37.0 +rpds-py==0.30.0 +sortedcontainers==2.4.0 +uvloop==0.22.1 +uwsgi==2.0.31 +zstandard==0.25.0 diff --git a/.riot/requirements/222bcd0.txt b/.riot/requirements/222bcd0.txt new file mode 100644 index 00000000000..4ec85bb3a9d --- /dev/null +++ b/.riot/requirements/222bcd0.txt @@ -0,0 +1,45 @@ +# +# This file is autogenerated by pip-compile with Python 3.15 +# by the following command: +# +<<<<<<<< HEAD:.riot/requirements/222bcd0.txt +# pip-compile --allow-unsafe --no-annotate .riot/requirements/222bcd0.in +# +attrs==26.1.0 +cloudpickle==3.1.2 +coverage[toml]==7.14.3 +execnet==2.1.2 +gevent==26.5.0 +greenlet==3.5.3 +httpretty==1.1.4 +======== +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1857594.in +# +attrs==26.1.0 +coverage[toml]==7.13.5 +>>>>>>>> 49c1ffeaab (ci(profiling): wire py3.15 into build matrix, riotfile, and CI):.riot/requirements/1857594.txt +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +opentracing==2.4.0 +packaging==26.1 +pluggy==1.6.0 +pyfakefs==6.2.0 +pygments==2.20.0 +pytest==8.4.2 +pytest-asyncio==0.23.8 +pytest-cov==7.1.0 +pytest-mock==3.15.1 +pytest-randomly==4.1.0 +python-json-logger==2.0.7 +sortedcontainers==2.4.0 +<<<<<<<< HEAD:.riot/requirements/222bcd0.txt +uwsgi==2.0.31 +wrapt==2.2.2 +======== +>>>>>>>> 49c1ffeaab (ci(profiling): wire py3.15 into build matrix, riotfile, and CI):.riot/requirements/1857594.txt +zope-event==5.0 +zope-interface==7.2 + +# The following packages are considered to be unsafe in a requirements file: +setuptools==81.0.0 diff --git a/.riot/requirements/95077af.txt b/.riot/requirements/95077af.txt new file mode 100644 index 00000000000..cb9c2e03246 --- /dev/null +++ b/.riot/requirements/95077af.txt @@ -0,0 +1,33 @@ +# +# This file is autogenerated by pip-compile with Python 3.15 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/95077af.in +# +attrs==26.1.0 +coverage[toml]==7.13.5 +gunicorn==25.3.0 +hypothesis==6.45.0 +iniconfig==2.3.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +mock==5.2.0 +numpy==2.4.4 +opentracing==2.4.0 +packaging==26.1 +pluggy==1.6.0 +protobuf==7.34.1 +py-cpuinfo==8.0.0 +pygments==2.20.0 +pytest==9.0.3 +pytest-asyncio==0.21.1 +pytest-benchmark==5.2.3 +pytest-cov==7.1.0 +pytest-cpp==2.6.0 +pytest-mock==3.15.1 +pytest-randomly==4.1.0 +referencing==0.37.0 +rpds-py==0.30.0 +sortedcontainers==2.4.0 +uwsgi==2.0.31 +zstandard==0.25.0 diff --git a/.riot/requirements/e26245b.txt b/.riot/requirements/e26245b.txt new file mode 100644 index 00000000000..9fc91691061 --- /dev/null +++ b/.riot/requirements/e26245b.txt @@ -0,0 +1,37 @@ +# +# This file is autogenerated by pip-compile with Python 3.15 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/e26245b.in +# +attrs==26.1.0 +coverage[toml]==7.13.5 +gevent==26.4.0 +greenlet==3.4.0 +gunicorn[gevent]==25.3.0 +hypothesis==6.45.0 +iniconfig==2.3.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +mock==5.2.0 +numpy==2.4.4 +opentracing==2.4.0 +packaging==26.1 +pluggy==1.6.0 +protobuf==7.34.1 +py-cpuinfo==8.0.0 +pygments==2.20.0 +pytest==9.0.3 +pytest-asyncio==0.21.1 +pytest-benchmark==5.2.3 +pytest-cov==7.1.0 +pytest-cpp==2.6.0 +pytest-mock==3.15.1 +pytest-randomly==4.1.0 +referencing==0.37.0 +rpds-py==0.30.0 +sortedcontainers==2.4.0 +uwsgi==2.0.31 +zope-event==6.1 +zope-interface==8.3 +zstandard==0.25.0 diff --git a/riotfile.py b/riotfile.py index 10c3c9e6eaf..e783918bd63 100644 --- a/riotfile.py +++ b/riotfile.py @@ -591,7 +591,8 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT pys=select_pys(min_version="3.9", max_version="3.11"), ), Venv( - pys=select_pys(min_version="3.12"), + # TODO(py-315): 3.15 explicitly opted in for crashtracker native validation. + pys=select_pys(min_version="3.12", max_version="3.14") + ["3.15"], env={ "PYTHONWARNINGS": "ignore:This process:DeprecationWarning::", }, @@ -2197,7 +2198,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT }, ), Venv( - pys="3.14", + pys=["3.14", "3.15"], pkgs={ "grpcio": ">=1.75.0", }, @@ -3811,7 +3812,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT ), ], ), - # Python 3.14 - protobuf 4.22.0 is not compatible (TypeError: Metaclasses with custom tp_new) + # Python 3.14+ - protobuf 4.22.0 is not compatible (TypeError: Metaclasses with custom tp_new) Venv( pys="3.14", pkgs={"uwsgi": latest}, diff --git a/scripts/requirements_to_csv.py b/scripts/requirements_to_csv.py index ed25c87b9ef..50d041e511b 100644 --- a/scripts/requirements_to_csv.py +++ b/scripts/requirements_to_csv.py @@ -2,7 +2,7 @@ import os import re -import toml +import toml # type: ignore[import-untyped] def requirements_to_csv(): @@ -52,7 +52,7 @@ def process_deps(dependencies): if "lib-injection" in path: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", newline="") as f: - writer = csv.writer(f) + writer = csv.writer(f, lineterminator="\n") writer.writerows(rows) diff --git a/setup.py b/setup.py index 40749a6a119..994a07fe1b8 100644 --- a/setup.py +++ b/setup.py @@ -130,6 +130,15 @@ CARGO_TARGET_DIR = NATIVE_CRATE.absolute() / f"target{sys.version_info.major}.{sys.version_info.minor}" DD_CARGO_ARGS = shlex.split(os.getenv("DD_CARGO_ARGS", "")) +# TODO(py-315): pyo3-build-config 0.27.x (max Python 3.14) may be resolved by cargo +# if the lock file is regenerated without --locked (e.g. in some CI cache scenarios). +# Setting PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 tells pyo3-build-config to bypass +# the max-version check and build via the stable ABI, which is correct since we +# already use py_limited_api="auto" in the RustExtension definition. +# pyo3 0.28+ supports Python 3.15 natively, so this is only a safety net. +if sys.version_info >= (3, 15): + os.environ.setdefault("PYO3_USE_ABI3_FORWARD_COMPATIBILITY", "1") + def _env_truthy(name: str, default: str = "0") -> bool: return os.getenv(name, default).lower() in ("1", "yes", "on", "true") @@ -1830,7 +1839,7 @@ def check_rust_toolchain(): ), ] - if sys.version_info < (3, 15): + if sys.version_info < (3, 16): _cython_sources += [ CythonExtension( "ddtrace.profiling._threading",