From 15c9d9027ef5090e58db1da21a95d11cdb5cd0a9 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych <89152624+StanFromIreland@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:16:56 +0000 Subject: [PATCH] gh-141081: Add a `.gitignore` file to `__pycache__` folders (#141162) Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Co-authored-by: Brett Cannon --- Doc/whatsnew/3.15.rst | 8 ++++++++ Lib/importlib/_bootstrap_external.py | 13 +++++++++++++ Lib/py_compile.py | 8 ++++++++ Lib/test/test_compileall.py | 6 ++++-- .../test_importlib/source/test_file_loader.py | 15 +++++++++++++++ Lib/test/test_py_compile.py | 10 ++++++++++ ...2025-11-06-17-37-51.gh-issue-141081.NJtULs.rst | 2 ++ 7 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-11-06-17-37-51.gh-issue-141081.NJtULs.rst diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index a94486dd4805..d9a34fe920d1 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -74,6 +74,8 @@ Summary -- Release highlights * :pep:`782`: :ref:`A new PyBytesWriter C API to create a Python bytes object ` * :ref:`Improved error messages ` +* :ref:`__pycache__ directories now contain a .gitignore file + ` New features @@ -397,6 +399,12 @@ Other language changes for any class. (Contributed by Serhiy Storchaka in :gh:`41779`.) +.. _whatsnew315-pycache-gitignore: + +* :file:`__pycache__` directories now contain a :file:`.gitignore` file for Git + that ignores their contents. + (Contributed by Stan Ulbrych in :gh:`141081`.) + New modules =========== diff --git a/Lib/importlib/_bootstrap_external.py b/Lib/importlib/_bootstrap_external.py index b576ceb1ce9f..a3089de4705f 100644 --- a/Lib/importlib/_bootstrap_external.py +++ b/Lib/importlib/_bootstrap_external.py @@ -967,6 +967,19 @@ class SourceFileLoader(FileLoader, SourceLoader): _bootstrap._verbose_message('could not create {!r}: {!r}', parent, exc) return + + if part == _PYCACHE: + gitignore = _path_join(parent, '.gitignore') + try: + _path_stat(gitignore) + except FileNotFoundError: + gitignore_content = b'# Created by CPython\n*\n' + try: + _write_atomic(gitignore, gitignore_content, _mode) + except OSError: + pass + except OSError: + pass try: _write_atomic(path, data, _mode) _bootstrap._verbose_message('created {!r}', path) diff --git a/Lib/py_compile.py b/Lib/py_compile.py index 43d8ec90ffb6..b8324e7256a5 100644 --- a/Lib/py_compile.py +++ b/Lib/py_compile.py @@ -155,6 +155,14 @@ def compile(file, cfile=None, dfile=None, doraise=False, optimize=-1, dirname = os.path.dirname(cfile) if dirname: os.makedirs(dirname) + if os.path.basename(dirname) == '__pycache__': + gitignore = os.path.join(dirname, '.gitignore') + if not os.path.exists(gitignore): + try: + with open(gitignore, 'wb') as f: + f.write(b'# Created by CPython\n*\n') + except OSError: + pass except FileExistsError: pass if invalidation_mode == PycInvalidationMode.TIMESTAMP: diff --git a/Lib/test/test_compileall.py b/Lib/test/test_compileall.py index 8384c183dd92..c7c44299c5a8 100644 --- a/Lib/test/test_compileall.py +++ b/Lib/test/test_compileall.py @@ -625,8 +625,10 @@ class CommandLineTestsBase: ['-m', 'compileall', '-q', self.pkgdir])) # Verify the __pycache__ directory contents. self.assertTrue(os.path.exists(self.pkgdir_cachedir)) - expected = sorted(base.format(sys.implementation.cache_tag, ext) - for base in ('__init__.{}.{}', 'bar.{}.{}')) + expected = ['.gitignore'] + sorted( + base.format(sys.implementation.cache_tag, ext) + for base in ('__init__.{}.{}', 'bar.{}.{}') + ) self.assertEqual(sorted(os.listdir(self.pkgdir_cachedir)), expected) # Make sure there are no .pyc files in the source directory. self.assertFalse([fn for fn in os.listdir(self.pkgdir) diff --git a/Lib/test/test_importlib/source/test_file_loader.py b/Lib/test/test_importlib/source/test_file_loader.py index 5d5d4722171a..5e88f0dbed08 100644 --- a/Lib/test/test_importlib/source/test_file_loader.py +++ b/Lib/test/test_importlib/source/test_file_loader.py @@ -180,6 +180,21 @@ class SimpleTest: data[8:16], ) + @util.writes_bytecode_files + def test_gitignore_in_pycache(self): + with util.create_modules('_temp') as mapping: + source = mapping['_temp'] + loader = self.machinery.SourceFileLoader('_temp', source) + mod = types.ModuleType('_temp') + mod.__spec__ = self.util.spec_from_loader('_temp', loader) + loader.exec_module(mod) + pyc = os.path.dirname(self.util.cache_from_source(source)) + gitignore = os.path.join(pyc, '.gitignore') + self.assertTrue(os.path.exists(gitignore)) + with open(gitignore, 'rb') as f: + t = f.read() + self.assertEqual(t, b'# Created by CPython\n*\n') + (Frozen_SimpleTest, Source_SimpleTest diff --git a/Lib/test/test_py_compile.py b/Lib/test/test_py_compile.py index 64387296e846..fdfb124c0518 100644 --- a/Lib/test/test_py_compile.py +++ b/Lib/test/test_py_compile.py @@ -207,6 +207,16 @@ class PyCompileTestsBase: with self.assertRaises(py_compile.PyCompileError): py_compile.compile(bad_coding, doraise=True, quiet=1) + def test_gitignore_created(self): + py_compile.compile(self.source_path) + self.assertTrue(os.path.exists(self.cache_path)) + pyc = os.path.dirname(self.cache_path) + gitignore = os.path.join(pyc, '.gitignore') + self.assertTrue(os.path.exists(gitignore)) + with open(gitignore, 'rb') as f: + text = f.read() + self.assertEqual(text, b'# Created by CPython\n*\n') + class PyCompileTestsWithSourceEpoch(PyCompileTestsBase, unittest.TestCase, diff --git a/Misc/NEWS.d/next/Library/2025-11-06-17-37-51.gh-issue-141081.NJtULs.rst b/Misc/NEWS.d/next/Library/2025-11-06-17-37-51.gh-issue-141081.NJtULs.rst new file mode 100644 index 000000000000..2b64f68f4dfd --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-11-06-17-37-51.gh-issue-141081.NJtULs.rst @@ -0,0 +1,2 @@ +When ``__pycache__`` directories are created, they now contain a +``.gitignore`` file that ignores their contents. -- 2.47.3