From 16ff1ec97005f4f1c7fae278e4009b72db905256 Mon Sep 17 00:00:00 2001 From: LucasZhou Date: Wed, 30 Sep 2026 23:29:21 -0500 Subject: [PATCH 1/3] gh-158540: Add the profiled script's directory to sys.path When a script is profiled with ``python -m profiling.sampling run script.py`` from another directory, the script cannot import modules placed next to it, because ``_sync_coordinator._execute_script()`` executes it with the working directory (added by ``_setup_environment()`` for the module case) as ``sys.path[0]`` instead of the script's own directory. Make the script's directory importable in ``_execute_script()``, matching the behavior of ``python script.py``. Add a regression test that runs the coordinator on a script importing a sibling module. --- Lib/profiling/sampling/_sync_coordinator.py | 9 ++ .../test_sync_coordinator.py | 84 +++++++++++++++++++ ...-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst | 4 + 3 files changed, 97 insertions(+) create mode 100644 Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py create mode 100644 Misc/NEWS.d/next/Library/2026-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst diff --git a/Lib/profiling/sampling/_sync_coordinator.py b/Lib/profiling/sampling/_sync_coordinator.py index a1cce314b33b19..98889894313e47 100644 --- a/Lib/profiling/sampling/_sync_coordinator.py +++ b/Lib/profiling/sampling/_sync_coordinator.py @@ -168,6 +168,15 @@ def _execute_script(script_path: str, script_args: List[str], cwd: str) -> None: if not os.path.isfile(script_path): raise TargetError(f"Script not found: {script_path}") + # gh-158540: ``python script.py`` puts the directory containing the + # script (not the current working directory) on sys.path so that modules + # next to the script can be imported. _setup_environment() added the + # working directory for module (``-m``) imports; make the script's + # directory importable as well. + script_dir = os.path.dirname(script_path) + if script_dir not in sys.path: + sys.path.insert(0, script_dir) + # Replace sys.argv to match original script call sys.argv = [script_path] + script_args diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py b/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py new file mode 100644 index 00000000000000..693166bae89a0e --- /dev/null +++ b/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py @@ -0,0 +1,84 @@ +"""Tests for the sampling profiler's synchronization coordinator.""" + +import os +import socket +import subprocess +import sys +import tempfile +import unittest + +from test.support import SHORT_TIMEOUT, requires_subprocess + + +@requires_subprocess() +class TestSyncCoordinatorScriptExecution(unittest.TestCase): + """Tests for how the coordinator executes a target script.""" + + def run_coordinator(self, cwd, target, *target_args): + """Execute *target* from *cwd* with the sync coordinator. + + Returns the ``(stdout, stderr)`` captured from the coordinator. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + server.listen(1) + server.settimeout(SHORT_TIMEOUT) + port = server.getsockname()[1] + + cmd = ( + sys.executable, + "-m", + "profiling.sampling._sync_coordinator", + str(port), + cwd, + target, + ) + tuple(target_args) + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + conn, _ = server.accept() + with conn: + # _signal_readiness() sends b"ready" before running target. + self.assertEqual(conn.recv(64), b"ready") + stdout, stderr = process.communicate(timeout=SHORT_TIMEOUT) + finally: + if process.poll() is None: + process.kill() + process.wait() + return stdout, stderr + + def test_script_directory_on_sys_path(self): + # gh-158540: The coordinator must put the directory containing the + # script (not the working directory) on sys.path, matching + # ``python script.py``, so that modules next to the script can be + # imported even when the coordinator runs from another directory. + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = os.path.join(tmpdir, "sub") + os.mkdir(script_dir) + with open(os.path.join(script_dir, "helper.py"), "w") as f: + f.write("message = 'helper imported'\n") + with open(os.path.join(script_dir, "where.py"), "w") as f: + f.write( + "import os\n" + "import sys\n" + "print('PATH0:', os.path.realpath(sys.path[0]))\n" + "import helper\n" + "print('HELPER:', helper.message)\n" + ) + + stdout, stderr = self.run_coordinator( + tmpdir, os.path.join("sub", "where.py") + ) + + self.assertNotIn("ModuleNotFoundError", stderr) + self.assertIn("HELPER: helper imported", stdout) + self.assertIn(f"PATH0: {os.path.realpath(script_dir)}", stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst b/Misc/NEWS.d/next/Library/2026-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst new file mode 100644 index 00000000000000..9cbd698cf9a96e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst @@ -0,0 +1,4 @@ +Fix :mod:`profiling.sampling` not adding the directory containing the +profiled script to :data:`sys.path`, which prevented the script from +importing modules placed next to it. The script's directory is now added, +matching the behavior of ``python script.py``. From 91c5ab3e096855f73189f267bbb66716de2563e1 Mon Sep 17 00:00:00 2001 From: he_tao <53343436+hetaozdh@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:20:18 -0500 Subject: [PATCH 2/3] Update _sync_coordinator.py Comment simplified Co-authored-by: Eduardo Villalpando Mello --- Lib/profiling/sampling/_sync_coordinator.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Lib/profiling/sampling/_sync_coordinator.py b/Lib/profiling/sampling/_sync_coordinator.py index 98889894313e47..40f5f0a8b3e658 100644 --- a/Lib/profiling/sampling/_sync_coordinator.py +++ b/Lib/profiling/sampling/_sync_coordinator.py @@ -168,11 +168,7 @@ def _execute_script(script_path: str, script_args: List[str], cwd: str) -> None: if not os.path.isfile(script_path): raise TargetError(f"Script not found: {script_path}") - # gh-158540: ``python script.py`` puts the directory containing the - # script (not the current working directory) on sys.path so that modules - # next to the script can be imported. _setup_environment() added the - # working directory for module (``-m``) imports; make the script's - # directory importable as well. + # Put directory containing the script (not the current working directory) on sys.path so that modules next to it can be imported. Similar to ``python script.py`` script_dir = os.path.dirname(script_path) if script_dir not in sys.path: sys.path.insert(0, script_dir) From 264a36c03cc21ad5188096cc881db62a2fc6d89c Mon Sep 17 00:00:00 2001 From: LucasZhou Date: Thu, 1 Oct 2026 11:43:29 -0500 Subject: [PATCH 3/3] gh-158540: Resolve symlinks when adding the script directory to sys.path ``python script.py`` resolves symlinks when computing ``sys.path[0]``, so a script reached through a symlink (``link.py -> sub/where.py``) imports modules from the real script's directory. Apply ``os.path.realpath()`` before taking the directory name, and make sure the result is placed at the front of ``sys.path`` even if it was already listed. Add a regression test for a symlinked script. --- Lib/profiling/sampling/_sync_coordinator.py | 8 ++--- .../test_sync_coordinator.py | 31 ++++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/Lib/profiling/sampling/_sync_coordinator.py b/Lib/profiling/sampling/_sync_coordinator.py index 40f5f0a8b3e658..bb0ee5b5317d69 100644 --- a/Lib/profiling/sampling/_sync_coordinator.py +++ b/Lib/profiling/sampling/_sync_coordinator.py @@ -168,10 +168,10 @@ def _execute_script(script_path: str, script_args: List[str], cwd: str) -> None: if not os.path.isfile(script_path): raise TargetError(f"Script not found: {script_path}") - # Put directory containing the script (not the current working directory) on sys.path so that modules next to it can be imported. Similar to ``python script.py`` - script_dir = os.path.dirname(script_path) - if script_dir not in sys.path: - sys.path.insert(0, script_dir) + script_dir = os.path.dirname(os.path.realpath(script_path)) + if script_dir in sys.path: + sys.path.remove(script_dir) + sys.path.insert(0, script_dir) # Replace sys.argv to match original script call sys.argv = [script_path] + script_args diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py b/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py index 693166bae89a0e..936c01e206f9fb 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py @@ -7,7 +7,7 @@ import tempfile import unittest -from test.support import SHORT_TIMEOUT, requires_subprocess +from test.support import SHORT_TIMEOUT, os_helper, requires_subprocess @requires_subprocess() @@ -79,6 +79,35 @@ def test_script_directory_on_sys_path(self): self.assertIn("HELPER: helper imported", stdout) self.assertIn(f"PATH0: {os.path.realpath(script_dir)}", stdout) + @os_helper.skip_unless_symlink + def test_symlinked_script_uses_real_directory(self): + # gh-158540: ``python script.py`` resolves symlinks when computing + # sys.path[0], so a symlinked script must import modules next to the + # real script, not next to the link. + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = os.path.join(tmpdir, "sub") + os.mkdir(script_dir) + with open(os.path.join(script_dir, "helper.py"), "w") as f: + f.write("message = 'helper imported'\n") + with open(os.path.join(script_dir, "where.py"), "w") as f: + f.write( + "import os\n" + "import sys\n" + "print('PATH0:', os.path.realpath(sys.path[0]))\n" + "import helper\n" + "print('HELPER:', helper.message)\n" + ) + os.symlink( + os.path.join("sub", "where.py"), + os.path.join(tmpdir, "link.py"), + ) + + stdout, stderr = self.run_coordinator(tmpdir, "link.py") + + self.assertNotIn("ModuleNotFoundError", stderr) + self.assertIn("HELPER: helper imported", stdout) + self.assertIn(f"PATH0: {os.path.realpath(script_dir)}", stdout) + if __name__ == "__main__": unittest.main()