https://github.com/python/cpython/commit/15c9d9027ef5090e58db1da21a95d11cdb5cd0a9
commit: 15c9d9027ef5090e58db1da21a95d11cdb5cd0a9
branch: main
author: Stan Ulbrych <[email protected]>
committer: hugovk <[email protected]>
date: 2025-12-15T12:16:56+02:00
summary:

gh-141081: Add a `.gitignore` file to `__pycache__` folders (#141162)

Co-authored-by: Hugo van Kemenade <[email protected]>
Co-authored-by: Brett Cannon <[email protected]>

files:
A Misc/NEWS.d/next/Library/2025-11-06-17-37-51.gh-issue-141081.NJtULs.rst
M Doc/whatsnew/3.15.rst
M Lib/importlib/_bootstrap_external.py
M Lib/py_compile.py
M Lib/test/test_compileall.py
M Lib/test/test_importlib/source/test_file_loader.py
M Lib/test/test_py_compile.py

diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index a94486dd4805bd..d9a34fe920d10d 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
   <whatsnew315-pep782>`
 * :ref:`Improved error messages <whatsnew315-improved-error-messages>`
+* :ref:`__pycache__ directories now contain a .gitignore file
+  <whatsnew315-pycache-gitignore>`
 
 
 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 b576ceb1ce9f6e..a3089de4705f73 100644
--- a/Lib/importlib/_bootstrap_external.py
+++ b/Lib/importlib/_bootstrap_external.py
@@ -967,6 +967,19 @@ def set_data(self, path, data, *, _mode=0o666):
                 _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 43d8ec90ffb6b1..b8324e7256a566 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 8384c183dd92dd..c7c44299c5a829 100644
--- a/Lib/test/test_compileall.py
+++ b/Lib/test/test_compileall.py
@@ -625,8 +625,10 @@ def f(self, ext=ext, switch=switch):
                 ['-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 5d5d4722171a8e..5e88f0dbed081e 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 @@ def test_overridden_unchecked_hash_based_pyc(self):
                 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 64387296e84621..fdfb124c051884 100644
--- a/Lib/test/test_py_compile.py
+++ b/Lib/test/test_py_compile.py
@@ -207,6 +207,16 @@ def test_quiet(self):
             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 00000000000000..2b64f68f4dfd28
--- /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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to