From 6c342df17bc3fd46d8142829d9f4a8f718d005a4 Mon Sep 17 00:00:00 2001 From: anthony sottile Date: Tue, 31 Mar 2026 16:15:39 -0400 Subject: [PATCH 1/9] Idempotent `.pth` execution in `site.addsitedir` --- Lib/site.py | 16 ++++++------- Lib/test/test_site.py | 24 +++++++++++++++++++ ...6-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst | 1 + 3 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst diff --git a/Lib/site.py b/Lib/site.py index 30015b3f26b4b3..2ad5d53975529f 100644 --- a/Lib/site.py +++ b/Lib/site.py @@ -242,14 +242,14 @@ def addsitedir(sitedir, known_paths=None): if not sitedircase in known_paths: sys.path.append(sitedir) # Add path component known_paths.add(sitedircase) - try: - names = os.listdir(sitedir) - except OSError: - return - names = [name for name in names - if name.endswith(".pth") and not name.startswith(".")] - for name in sorted(names): - addpackage(sitedir, name, known_paths) + try: + names = os.listdir(sitedir) + except OSError: + return + names = [name for name in names + if name.endswith(".pth") and not name.startswith(".")] + for name in sorted(names): + addpackage(sitedir, name, known_paths) if reset: known_paths = None return known_paths diff --git a/Lib/test/test_site.py b/Lib/test/test_site.py index e7dc5e2611c2de..72fec8e1fd7dd4 100644 --- a/Lib/test/test_site.py +++ b/Lib/test/test_site.py @@ -116,6 +116,7 @@ def pth_file_tests(self, pth_file): "%s not in sys.modules" % pth_file.imported) self.assertIn(site.makepath(pth_file.good_dir_path)[0], sys.path) self.assertFalse(os.path.exists(pth_file.bad_dir_path)) + self.assertFalse(os.path.exists(pth_file.idempotent_fail_path)) def test_addpackage(self): # Make sure addpackage() imports if the line starts with 'import', @@ -199,6 +200,19 @@ def test_addsitedir(self): finally: pth_file.cleanup() + def test_addsitedir_idempotent(self): + pth_file = PthFile() + pth_file.cleanup(prep=True) + + try: + pth_file.create() + dirs = set() + dirs = site.addsitedir(pth_file.base_dir, dirs) + dirs = site.addsitedir(pth_file.base_dir, dirs) + self.pth_file_tests(pth_file) + finally: + pth_file.cleanup(prep=True) + def test_addsitedir_dotfile(self): pth_file = PthFile('.dotfile') pth_file.cleanup(prep=True) @@ -414,6 +428,7 @@ def __init__(self, filename_base=TESTFN, imported="time", self.bad_dirname = bad_dirname self.good_dir_path = os.path.join(self.base_dir, self.good_dirname) self.bad_dir_path = os.path.join(self.base_dir, self.bad_dirname) + self.idempotent_fail_path = os.path.join(self.base_dir, 'idempotent') def create(self): """Create a .pth file with a comment, blank lines, an ``import @@ -430,6 +445,13 @@ def create(self): try: print("#import @bad module name", file=FILE) print("\n", file=FILE) + + PROG = f'''\ +if {self.imported!r} in sys.modules: + open({self.idempotent_fail_path!r}, 'a+').close() +''' + print(f"import sys; exec({PROG!r})", file=FILE) + print("import %s" % self.imported, file=FILE) print(self.good_dirname, file=FILE) print(self.bad_dirname, file=FILE) @@ -454,6 +476,8 @@ def cleanup(self, prep=False): os.rmdir(self.good_dir_path) if os.path.exists(self.bad_dir_path): os.rmdir(self.bad_dir_path) + if os.path.exists(self.idempotent_fail_path): + os.remove(self.idempotent_fail_path) class ImportSideEffectTests(unittest.TestCase): """Test side-effects from importing 'site'.""" diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst new file mode 100644 index 00000000000000..a14e47435be567 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst @@ -0,0 +1 @@ +Idempotent ``.pth`` file exection in :meth:`site.addsitedir`. From c44e1920baadeaa3df0c5d427d77421c7e287eff Mon Sep 17 00:00:00 2001 From: anthony sottile Date: Sun, 5 Apr 2026 14:23:31 -0400 Subject: [PATCH 2/9] potentially fix monkeypatch leak? --- Lib/test/test_site.py | 2 +- .../2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_site.py b/Lib/test/test_site.py index 72fec8e1fd7dd4..efea68b8831ce2 100644 --- a/Lib/test/test_site.py +++ b/Lib/test/test_site.py @@ -211,7 +211,7 @@ def test_addsitedir_idempotent(self): dirs = site.addsitedir(pth_file.base_dir, dirs) self.pth_file_tests(pth_file) finally: - pth_file.cleanup(prep=True) + pth_file.cleanup() def test_addsitedir_dotfile(self): pth_file = PthFile('.dotfile') diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst index a14e47435be567..74ad68e844a997 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst @@ -1 +1 @@ -Idempotent ``.pth`` file exection in :meth:`site.addsitedir`. +Avoid re-executing ``.pth`` files when :meth:`site.addsitedir` is called for for a known directory. From f077abd6fb14051c1a0dc9851adbb34c34c8c9d3 Mon Sep 17 00:00:00 2001 From: anthony sottile Date: Sun, 5 Apr 2026 14:44:39 -0400 Subject: [PATCH 3/9] fix blind copy paste of recommendation --- .../2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst index 74ad68e844a997..3ef21b11d3ea5c 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst @@ -1 +1 @@ -Avoid re-executing ``.pth`` files when :meth:`site.addsitedir` is called for for a known directory. +Avoid re-executing ``.pth`` files when :meth:`site.addsitedir` is called for a known directory. From aeee2ff942def6cd192b763f57a9f9fbdcbe2fc5 Mon Sep 17 00:00:00 2001 From: anthony sottile Date: Tue, 7 Apr 2026 16:04:09 -0400 Subject: [PATCH 4/9] Update 2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst --- .../2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst index 3ef21b11d3ea5c..596ca89958c9ed 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-03-31-16-15-15.gh-issue-75723.BZ4Rsn.rst @@ -1 +1 @@ -Avoid re-executing ``.pth`` files when :meth:`site.addsitedir` is called for a known directory. +Avoid re-executing ``.pth`` files when :func:`site.addsitedir` is called for a known directory. From 66ebfc9cc504ac653015bef2b1dd116bf5ca9eb7 Mon Sep 17 00:00:00 2001 From: anthony sottile Date: Sun, 3 May 2026 13:56:44 -0400 Subject: [PATCH 5/9] fix implicit merge conflict with 24c4aecc1674414d3dc3238625802778c4ad29d2 --- Lib/test/test_site.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Lib/test/test_site.py b/Lib/test/test_site.py index 71f4d5486c5afb..04e7aff209d453 100644 --- a/Lib/test/test_site.py +++ b/Lib/test/test_site.py @@ -207,14 +207,11 @@ def test_addsitedir_idempotent(self): pth_file = PthFile() pth_file.cleanup(prep=True) - try: - pth_file.create() + with pth_file.create(): dirs = set() dirs = site.addsitedir(pth_file.base_dir, dirs) dirs = site.addsitedir(pth_file.base_dir, dirs) self.pth_file_tests(pth_file) - finally: - pth_file.cleanup() def test_addsitedir_dotfile(self): pth_file = PthFile('.dotfile') From d96a414caa0abc0e58858f61d43c332496545585 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 8 May 2026 12:58:59 -0700 Subject: [PATCH 6/9] Add failing tests for gh-75723 Based on @asottle branch !asottle-gh-75723 but refactored for `main`. This will need a different backport. --- Lib/test/test_site.py | 100 ++++++++++++++++++++++++++---------------- 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/Lib/test/test_site.py b/Lib/test/test_site.py index 04e7aff209d453..a0f6a3119f57b7 100644 --- a/Lib/test/test_site.py +++ b/Lib/test/test_site.py @@ -118,7 +118,6 @@ def pth_file_tests(self, pth_file): "%s not in sys.modules" % pth_file.imported) self.assertIn(site.makepath(pth_file.good_dir_path)[0], sys.path) self.assertFalse(os.path.exists(pth_file.bad_dir_path)) - self.assertFalse(os.path.exists(pth_file.idempotent_fail_path)) def test_addpackage(self): # Make sure addpackage() imports if the line starts with 'import', @@ -197,22 +196,13 @@ def test_addsitedir_explicit_flush(self): pth_file.cleanup(prep=True) with pth_file.create(): # Pass defer_processing_start_files=True to prevent flushing. - site.addsitedir(pth_file.base_dir, set(), - defer_processing_start_files=True) + site.addsitedir( + pth_file.base_dir, set(), + defer_processing_start_files=True) self.assertNotIn(pth_file.imported, sys.modules) site.process_startup_files() self.pth_file_tests(pth_file) - def test_addsitedir_idempotent(self): - pth_file = PthFile() - pth_file.cleanup(prep=True) - - with pth_file.create(): - dirs = set() - dirs = site.addsitedir(pth_file.base_dir, dirs) - dirs = site.addsitedir(pth_file.base_dir, dirs) - self.pth_file_tests(pth_file) - def test_addsitedir_dotfile(self): pth_file = PthFile('.dotfile') # Ensure we have a clean slate. @@ -422,7 +412,6 @@ def __init__(self, filename_base=TESTFN, imported="time", self.bad_dirname = bad_dirname self.good_dir_path = os.path.join(self.base_dir, self.good_dirname) self.bad_dir_path = os.path.join(self.base_dir, self.bad_dirname) - self.idempotent_fail_path = os.path.join(self.base_dir, 'idempotent') @contextlib.contextmanager def create(self): @@ -435,22 +424,14 @@ def create(self): Used as a context manager: self.cleanup() is called on exit. """ - FILE = open(self.file_path, 'w') - try: - print("#import @bad module name", file=FILE) - print("\n", file=FILE) - - PROG = f'''\ -if {self.imported!r} in sys.modules: - open({self.idempotent_fail_path!r}, 'a+').close() -''' - print(f"import sys; exec({PROG!r})", file=FILE) - - print("import %s" % self.imported, file=FILE) - print(self.good_dirname, file=FILE) - print(self.bad_dirname, file=FILE) - finally: - FILE.close() + with open(self.file_path, 'w') as fp: + print(f"""\ +#import @bad module name +import {self.imported} +{self.good_dirname} +{self.bad_dirname} +""", file=fp) + os.mkdir(self.good_dir_path) try: yield self @@ -474,8 +455,6 @@ def cleanup(self, prep=False): os.rmdir(self.good_dir_path) if os.path.exists(self.bad_dir_path): os.rmdir(self.bad_dir_path) - if os.path.exists(self.idempotent_fail_path): - os.remove(self.idempotent_fail_path) class ImportSideEffectTests(unittest.TestCase): """Test side-effects from importing 'site'.""" @@ -965,6 +944,16 @@ def _make_pth(self, content, name='testpkg'): f.write(content) return basename + def _make_mod(self, contents, name='mod'): + """Write an importable .py, returning the module directory.""" + extdir = os.path.join(self.sitedir, 'extdir') + os.mkdir(extdir) + modpath = os.path.join(extdir, f'{name}.py') + with open(modpath, 'w') as fp: + fp.write(contents) + self.addCleanup(sys.modules.pop, name, None) + return extdir + def _all_entrypoints(self): """Flatten _pending_entrypoints dict into a list of (filename, entry) tuples.""" result = [] @@ -1441,18 +1430,12 @@ def test_pth_path_is_available_to_start_entrypoint(self): # point may live in a module reachable only via a .pth-extended # path. If the flush phases were inverted, resolving the entry # point would fail with ModuleNotFoundError. - extdir = os.path.join(self.sitedir, 'extdir') - os.mkdir(extdir) - modpath = os.path.join(extdir, 'mod.py') - with open(modpath, 'w') as f: - f.write("""\ + extdir = self._make_mod("""\ called = False def hook(): global called called = True """) - self.addCleanup(sys.modules.pop, 'mod', None) - # extdir is not on sys.path; only the .pth file makes it so. self.assertNotIn(extdir, sys.path) self._make_pth("extdir\n", name='extlib') @@ -1468,6 +1451,45 @@ def hook(): "entry point did not run; .pth path was likely not applied " "before .start entry-point execution") + # --- bugs --- + + # gh-75723 + def test_addsitdir_idempotent_pth(self): + # Adding the same sitedir twice with a known_paths, should not + # process .pth files twice. + extdir = self._make_mod("""\ +_pth_count = 0 +""") + self._make_pth(f"""\ +{extdir} +import mod; mod._pth_count += 1 +""") + dirs = set() + dirs = site.addsitedir(self.sitedir, dirs) + dirs = site.addsitedir(self.sitedir, dirs) + import mod + self.assertEqual(mod._pth_count, 1) + + def test_addsitdir_idempotent_start(self): + # Adding the same sitedir twice with a known_paths, should not + # process .pth files twice. + extdir = self._make_mod("""\ +_pth_count = 0 +def increment(): + global _pth_count + _pth_count += 1 +""") + self._make_pth(f"""\ +{extdir} +""") + self._make_start("""\ +mod:increment +""") + dirs = set() + dirs = site.addsitedir(self.sitedir, dirs) + dirs = site.addsitedir(self.sitedir, dirs) + import mod + self.assertEqual(mod._pth_count, 1) if __name__ == "__main__": unittest.main() From 61ba38991d46854d4ff8976ca9f12125f09c8218 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 8 May 2026 13:08:52 -0700 Subject: [PATCH 7/9] Repair gh-75723 The original fix is here: https://github.com/python/cpython/pull/147951 but I'm refactoring a bit for `main`. --- Lib/site.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Lib/site.py b/Lib/site.py index 2142495a887b75..cb1108dbaf1f81 100644 --- a/Lib/site.py +++ b/Lib/site.py @@ -387,9 +387,15 @@ def addsitedir(sitedir, known_paths=None, *, defer_processing_start_files=False) else: reset = False sitedir, sitedircase = makepath(sitedir) - if not sitedircase in known_paths: - sys.path.append(sitedir) # Add path component + + # If the normcase'd new sitedir isn't already known, append it to + # sys.path, keep a record of it, and process all .pth and .start files + # found in that directory. If the new sitedir is known, be sure not + # to process all of those twice! gh-75723 + if sitedircase not in known_paths: + sys.path.append(sitedir) known_paths.add(sitedircase) + try: names = os.listdir(sitedir) except OSError: @@ -422,7 +428,7 @@ def addsitedir(sitedir, known_paths=None, *, defer_processing_start_files=False) process_startup_files() if reset: - known_paths = None + return None return known_paths From d1dc0ee71a0bbc9e350b6df525a1d4986e47e92e Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 8 May 2026 13:50:26 -0700 Subject: [PATCH 8/9] Refactor _make_mod() so we can use it to create package modules too --- Lib/test/test_site.py | 60 +++++++++++++++++-------------------------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/Lib/test/test_site.py b/Lib/test/test_site.py index a0f6a3119f57b7..9990b88548fc7c 100644 --- a/Lib/test/test_site.py +++ b/Lib/test/test_site.py @@ -944,14 +944,26 @@ def _make_pth(self, content, name='testpkg'): f.write(content) return basename - def _make_mod(self, contents, name='mod'): - """Write an importable .py, returning the module directory.""" + def _make_mod(self, contents, name='mod', *, package=False, on_path=False): + """Write an importable module (or package), returning its parent dir.""" extdir = os.path.join(self.sitedir, 'extdir') - os.mkdir(extdir) - modpath = os.path.join(extdir, f'{name}.py') + os.makedirs(extdir, exist_ok=True) + + # Put the code in a package's dunder-init or flat module. + if package: + pkgdir = os.path.join(extdir, name) + os.mkdir(pkgdir) + modpath = os.path.join(pkgdir, '__init__.py') + else: + modpath = os.path.join(extdir, f'{name}.py') + with open(modpath, 'w') as fp: fp.write(contents) + self.addCleanup(sys.modules.pop, name, None) + if on_path: + # Don't worry, DirsOnSysPath() in setUp() will clean this up. + sys.path.insert(0, extdir) return extdir def _all_entrypoints(self): @@ -1178,18 +1190,12 @@ def test_read_pth_file_locale_fallback(self): def test_execute_entrypoints_with_callable(self): # Entrypoint with callable is invoked. - mod_dir = os.path.join(self.sitedir, 'epmod') - os.mkdir(mod_dir) - init_file = os.path.join(mod_dir, '__init__.py') - with open(init_file, 'w') as f: - f.write("""\ + self._make_mod("""\ called = False def startup(): global called called = True -""") - sys.path.insert(0, self.sitedir) - self.addCleanup(sys.modules.pop, 'epmod', None) +""", name='epmod', package=True, on_path=True) fullname = os.path.join(self.sitedir, 'epmod.start') site._pending_entrypoints[fullname] = ['epmod:startup'] site._execute_start_entrypoints() @@ -1228,16 +1234,10 @@ def test_execute_entrypoints_strict_syntax_rejection(self): def test_execute_entrypoints_callable_error(self): # Callable that raises prints traceback but continues. - mod_dir = os.path.join(self.sitedir, 'badmod') - os.mkdir(mod_dir) - init_file = os.path.join(mod_dir, '__init__.py') - with open(init_file, 'w') as f: - f.write("""\ + self._make_mod("""\ def fail(): raise RuntimeError("boom") -""") - sys.path.insert(0, self.sitedir) - self.addCleanup(sys.modules.pop, 'badmod', None) +""", name='badmod', package=True, on_path=True) fullname = os.path.join(self.sitedir, 'badmod.start') site._pending_entrypoints[fullname] = ['badmod:fail'] with captured_stderr() as err: @@ -1247,18 +1247,12 @@ def fail(): def test_execute_entrypoints_duplicates_called_twice(self): # PEP 829: duplicate entry points execute multiple times. - mod_dir = os.path.join(self.sitedir, 'countmod') - os.mkdir(mod_dir) - init_file = os.path.join(mod_dir, '__init__.py') - with open(init_file, 'w') as f: - f.write("""\ + self._make_mod("""\ call_count = 0 def bump(): global call_count call_count += 1 -""") - sys.path.insert(0, self.sitedir) - self.addCleanup(sys.modules.pop, 'countmod', None) +""", name='countmod', package=True, on_path=True) fullname = os.path.join(self.sitedir, 'countmod.start') site._pending_entrypoints[fullname] = [ 'countmod:bump', 'countmod:bump'] @@ -1289,18 +1283,12 @@ def test_exec_imports_not_suppressed_by_different_start(self): def test_exec_imports_suppressed_by_empty_matching_start(self): self._make_start("", name='foo') self._make_pth("import epmod; epmod.startup()", name='foo') - mod_dir = os.path.join(self.sitedir, 'epmod') - os.mkdir(mod_dir) - init_file = os.path.join(mod_dir, '__init__.py') - with open(init_file, 'w') as f: - f.write("""\ + self._make_mod("""\ called = False def startup(): global called called = True -""") - sys.path.insert(0, self.sitedir) - self.addCleanup(sys.modules.pop, 'epmod', None) +""", name='epmod', package=True, on_path=True) site._read_pth_file(self.sitedir, 'foo.pth', set()) site._read_start_file(self.sitedir, 'foo.start') site._exec_imports() From d825329aa15973a309287129e71eb379dfaa0109 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 8 May 2026 13:58:33 -0700 Subject: [PATCH 9/9] Add myself to CODEOWNERS for the site module --- .github/CODEOWNERS | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f4ffa24edca453..709b434b067958 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -573,9 +573,9 @@ Lib/shutil.py @giampaolo Lib/test/test_shutil.py @giampaolo # Site -Lib/site.py @FFY00 -Lib/test/test_site.py @FFY00 -Doc/library/site.rst @FFY00 +Lib/site.py @FFY00 @warsaw +Lib/test/test_site.py @FFY00 @warsaw +Doc/library/site.rst @FFY00 @warsaw # string.templatelib Doc/library/string.templatelib.rst @lysnikolaou @AA-Turner