From 735223be2caf58cec23a79d645e510f93d59e33f Mon Sep 17 00:00:00 2001 From: Alexandre Perez Date: Mon, 9 Nov 2026 15:47:17 -0800 Subject: [PATCH] Support PEP 810 lazy imports (Python 3.15) With lazy imports enabled globally (`-X lazy_imports=all` or `PYTHON_LAZY_IMPORTS=all`), module-level imports can be deferred until first use, which breaks the places where debugpy relies on import side effects. `force_pydevd` never runs, and the vendored pydevd modules and `attach_pid_injected` are only resolved after their directories have been removed from `sys.path`, so debugpy fails to start or attach. `vendored()` now switches lazy imports to `"normal"` while the vendored project is on `sys.path`, and the side-effect imports use `__import__()`, which is never deferred. `tests/debugpy/test_lazy_imports.py` runs every runner with `PYTHON_LAZY_IMPORTS=all`. --- src/debugpy/__main__.py | 3 +- src/debugpy/_vendored/__init__.py | 19 +++++++++++- src/debugpy/server/__init__.py | 5 +-- src/debugpy/server/cli.py | 8 +++-- tests/debugpy/test_lazy_imports.py | 49 ++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 tests/debugpy/test_lazy_imports.py diff --git a/src/debugpy/__main__.py b/src/debugpy/__main__.py index 14b9013c5..00a2d60c2 100644 --- a/src/debugpy/__main__.py +++ b/src/debugpy/__main__.py @@ -63,7 +63,8 @@ # Do not use dirname() to walk up - this can be a relative path, e.g. ".". sys.path[0] = sys.path[0] + "/../" - import debugpy # noqa + # Not an import statement: PEP 810 lazy imports could defer it past the del below. + __import__("debugpy") del sys.path[0] from debugpy.server import cli diff --git a/src/debugpy/_vendored/__init__.py b/src/debugpy/_vendored/__init__.py index daf9f9094..1884b4e30 100644 --- a/src/debugpy/_vendored/__init__.py +++ b/src/debugpy/_vendored/__init__.py @@ -106,6 +106,20 @@ def check_modules(project, match, root=None): return unvendored, extensions +@contextlib.contextmanager +def _eager_imports(): + """A context manager under which plain imports are not deferred by PEP 810.""" + get_lazy_imports = getattr(sys, "get_lazy_imports", None) + if get_lazy_imports is None or get_lazy_imports() != "all": + yield + return + sys.set_lazy_imports("normal") + try: + yield + finally: + sys.set_lazy_imports("all") + + @contextlib.contextmanager def vendored(project, root=None): """A context manager under which the vendored project will be imported.""" @@ -114,7 +128,10 @@ def vendored(project, root=None): # Add the vendored project directory, so that it gets tried first. sys.path.insert(0, root) try: - yield root + # An import resolved after the directory is removed from sys.path would + # fail, so everything must be loaded while it is still there. + with _eager_imports(): + yield root finally: sys.path.remove(root) diff --git a/src/debugpy/server/__init__.py b/src/debugpy/server/__init__.py index 42d5367f0..0b6808686 100644 --- a/src/debugpy/server/__init__.py +++ b/src/debugpy/server/__init__.py @@ -3,5 +3,6 @@ # for license information. # "force_pydevd" must be imported first to ensure (via side effects) -# that the debugpy-vendored copy of pydevd gets used. -import debugpy._vendored.force_pydevd # noqa +# that the debugpy-vendored copy of pydevd gets used. A plain import +# statement would never run it when PEP 810 lazy imports are enabled globally. +__import__("debugpy._vendored.force_pydevd") diff --git a/src/debugpy/server/cli.py b/src/debugpy/server/cli.py index 7e935a33d..a32dc92ad 100644 --- a/src/debugpy/server/cli.py +++ b/src/debugpy/server/cli.py @@ -455,6 +455,8 @@ def attach_to_pid(): } setup = encode(json.dumps(setup)) + # __import__ rather than an import statement, which PEP 810 lazy imports could + # defer past `del sys.path[0]`. The name is encoded because pydevd rejects quotes. python_code = """ import codecs; import json; @@ -466,7 +468,7 @@ def attach_to_pid(): setup = json.loads(decode({setup})); sys.path.insert(0, script_dir); -import attach_pid_injected; +attach_pid_injected = __import__(decode({module})); del sys.path[0]; attach_pid_injected.attach(setup); @@ -474,7 +476,9 @@ def attach_to_pid(): python_code = ( python_code.replace("\r", "") .replace("\n", "") - .format(script_dir=script_dir, setup=setup) + .format( + script_dir=script_dir, setup=setup, module=encode("attach_pid_injected") + ) ) # attempt pep 768 style code injection diff --git a/tests/debugpy/test_lazy_imports.py b/tests/debugpy/test_lazy_imports.py new file mode 100644 index 000000000..3daff2645 --- /dev/null +++ b/tests/debugpy/test_lazy_imports.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in the project root +# for license information. + +import sys + +import pytest + +from tests import debug +from tests.debug import runners, targets +from tests.patterns import some + +pytestmark = pytest.mark.skipif( + not hasattr(sys, "set_lazy_imports"), + reason="PEP 810 lazy imports require Python 3.15+", +) + + +@pytest.mark.parametrize("run", runners.all) +@pytest.mark.parametrize("target", targets.all_named) +def test_lazy_imports_all(pyfile, target, run): + @pyfile + def code_to_debug(): + import sys + + import debuggee + from debuggee import backchannel + + debuggee.setup() + backchannel.send(sys.get_lazy_imports()) # @bp + + with debug.Session() as session: + session.spawn_adapter.env["PYTHON_LAZY_IMPORTS"] = "all" + env = ( + session.config.env + if run.request == "launch" + else session.spawn_debuggee.env + ) + env["PYTHON_LAZY_IMPORTS"] = "all" + + backchannel = session.open_backchannel() + with run(session, target(code_to_debug)): + session.set_breakpoints(code_to_debug, all) + + session.wait_for_stop( + "breakpoint", expected_frames=[some.dap.frame(code_to_debug, line="bp")] + ) + session.request_continue() + assert backchannel.receive() == "all"