diff --git a/src/debugpy/__main__.py b/src/debugpy/__main__.py index 14b9013c..00a2d60c 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 daf9f909..1884b4e3 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 42d5367f..0b680868 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 7e935a33..a32dc92a 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 00000000..3daff264 --- /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"