]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-86533: Restore os.makedirs() ability to apply *mode* recursively (GH-150011)
authornessita <124304+nessita@users.noreply.github.com>
Mon, 18 May 2026 23:00:27 +0000 (20:00 -0300)
committerGitHub <noreply@github.com>
Mon, 18 May 2026 23:00:27 +0000 (23:00 +0000)
bpo-42367: Restore os.makedirs() and pathlib.mkdir() ability to apply *mode* recursively via a new parent_mode= keyword argument.

Co-authored-by: Zackery Spytz <zspytz@gmail.com>
Co-authored-by: Erlend E. Aasland <erlend@python.org>
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
Co-authored-by: Gregory P. Smith <greg@krypto.org>
Doc/library/os.rst
Doc/library/pathlib.rst
Doc/whatsnew/3.15.rst
Lib/os.py
Lib/pathlib/__init__.py
Lib/test/test_os/test_os.py
Lib/test/test_pathlib/test_pathlib.py
Misc/NEWS.d/next/Library/2025-08-30-07-44-30.gh-issue-86533.pathlib.rst [new file with mode: 0644]

index f6be2dec0729f21ce782b098a2f9b11d0d8c23b5..1a759fd9e7dc9163eb216ed864536951b422a008 100644 (file)
@@ -2549,7 +2549,8 @@ features:
       Windows now handles a *mode* of ``0o700``.
 
 
-.. function:: makedirs(name, mode=0o777, exist_ok=False)
+.. function:: makedirs(name, mode=0o777, exist_ok=False, *, \
+                       parent_mode=None)
 
    .. index::
       single: directory; creating
@@ -2567,6 +2568,12 @@ features:
    If *exist_ok* is ``False`` (the default), a :exc:`FileExistsError` is
    raised if the target directory already exists.
 
+   If *parent_mode* is not ``None``, it is used as the mode for any
+   newly-created, intermediate-level directories.  Like *mode*, it is
+   combined with the process's umask value; see :ref:`the mkdir()
+   description <mkdir_modebits>`.  Otherwise, intermediate directories are
+   created with the default mode, which is also subject to the umask.
+
    .. note::
 
       :func:`makedirs` will become confused if the path elements to create
@@ -2593,6 +2600,11 @@ features:
       The *mode* argument no longer affects the file permission bits of
       newly created intermediate-level directories.
 
+   .. versionadded:: 3.15
+      The *parent_mode* parameter. To match the behavior from Python 3.6 and
+      earlier (where *mode* was applied to all created directories), pass
+      ``parent_mode=mode``.
+
 
 .. function:: mkfifo(path, mode=0o666, *, dir_fd=None)
 
index 923cd4a2c4c1f1ec65097baa8b416aa0af3669bc..ab92c142c37a4f6cfd52b5c675d838de2076e633 100644 (file)
@@ -1518,7 +1518,8 @@ Creating files and directories
       :meth:`~Path.write_bytes` methods are often used to create files.
 
 
-.. method:: Path.mkdir(mode=0o777, parents=False, exist_ok=False)
+.. method:: Path.mkdir(mode=0o777, parents=False, exist_ok=False, *, \
+                       parent_mode=None)
 
    Create a new directory at this given path.  If *mode* is given, it is
    combined with the process's ``umask`` value to determine the file mode
@@ -1529,6 +1530,12 @@ Creating files and directories
    as needed; they are created with the default permissions without taking
    *mode* into account (mimicking the POSIX ``mkdir -p`` command).
 
+   If *parent_mode* is not ``None``, it is used as the mode for any
+   newly-created, intermediate-level directories when *parents* is true.
+   Like *mode*, it is combined with the process's ``umask`` value.
+   Otherwise, intermediate directories are created with the default
+   permissions (also subject to the umask).
+
    If *parents* is false (the default), a missing parent raises
    :exc:`FileNotFoundError`.
 
@@ -1542,6 +1549,9 @@ Creating files and directories
    .. versionchanged:: 3.5
       The *exist_ok* parameter was added.
 
+   .. versionadded:: 3.15
+      The *parent_mode* parameter.
+
 
 .. method:: Path.symlink_to(target, target_is_directory=False)
 
index a048e51512d59259d741dbe50126a157a61138fe..93d9b18d5856349eb7c4eed13fe20105f21e5976 100644 (file)
@@ -1285,6 +1285,10 @@ os
   glibc versions 2.28 and later.
   (Contributed by Jeffrey Bosboom and Victor Stinner in :gh:`83714`.)
 
+* :func:`os.makedirs` function now has a *parent_mode* parameter that allows
+  specifying the mode for intermediate directories. This can be used to match
+  the behavior from Python 3.6 and earlier by passing ``parent_mode=mode``.
+  (Contributed by Zackery Spytz and Gregory P. Smith in :gh:`86533`.)
 
 os.path
 -------
@@ -2057,6 +2061,10 @@ importlib.resources
 pathlib
 -------
 
+* :meth:`pathlib.Path.mkdir` now has a *parent_mode* parameter that allows
+  specifying the mode for intermediate directories when ``parents=True``.
+  (Contributed by Gregory P. Smith in :gh:`86533`.)
+
 * Removed deprecated :meth:`!pathlib.PurePath.is_reserved`.
   Use :func:`os.path.isreserved` to detect reserved paths on Windows.
   (Contributed by Nikita Sobolev in :gh:`133875`.)
index 52cbc5bc85864e7370e888d0781af2cf74618faa..1ca4648cc95c3eeb0b10491249db96bacae66853 100644 (file)
--- a/Lib/os.py
+++ b/Lib/os.py
@@ -219,14 +219,17 @@ SEEK_END = 2
 # Super directory utilities.
 # (Inspired by Eric Raymond; the doc strings are mostly his)
 
-def makedirs(name, mode=0o777, exist_ok=False):
-    """makedirs(name [, mode=0o777][, exist_ok=False])
+def makedirs(name, mode=0o777, exist_ok=False, *, parent_mode=None):
+    """makedirs(name [, mode=0o777][, exist_ok=False][, parent_mode=None])
 
     Super-mkdir; create a leaf directory and all intermediate ones.  Works like
     mkdir, except that any intermediate path segment (not just the rightmost)
     will be created if it does not exist. If the target directory already
     exists, raise an OSError if exist_ok is False. Otherwise no exception is
-    raised.  This is recursive.
+    raised.  If parent_mode is not None, it will be used as the mode for any
+    newly-created, intermediate-level directories. Otherwise, intermediate
+    directories are created with the default permissions (respecting umask).
+    This is recursive.
 
     """
     head, tail = path.split(name)
@@ -234,7 +237,11 @@ def makedirs(name, mode=0o777, exist_ok=False):
         head, tail = path.split(head)
     if head and tail and not path.exists(head):
         try:
-            makedirs(head, exist_ok=exist_ok)
+            if parent_mode is not None:
+                makedirs(head, mode=parent_mode, exist_ok=exist_ok,
+                         parent_mode=parent_mode)
+            else:
+                makedirs(head, exist_ok=exist_ok)
         except FileExistsError:
             # Defeats race condition when another thread created the path
             pass
index 295f633824a6ef219361240088f6728b9436eb26..ffec9c545ee11f88451d25ac8c8f1056cb95a803 100644 (file)
@@ -1204,7 +1204,7 @@ class Path(PurePath):
         fd = os.open(self, flags, mode)
         os.close(fd)
 
-    def mkdir(self, mode=0o777, parents=False, exist_ok=False):
+    def mkdir(self, mode=0o777, parents=False, exist_ok=False, *, parent_mode=None):
         """
         Create a new directory at this given path.
         """
@@ -1213,7 +1213,11 @@ class Path(PurePath):
         except FileNotFoundError:
             if not parents or self.parent == self:
                 raise
-            self.parent.mkdir(parents=True, exist_ok=True)
+            if parent_mode is not None:
+                self.parent.mkdir(mode=parent_mode, parents=True, exist_ok=True,
+                                  parent_mode=parent_mode)
+            else:
+                self.parent.mkdir(parents=True, exist_ok=True)
             self.mkdir(mode, parents=False, exist_ok=exist_ok)
         except OSError:
             # Cannot rely on checking for EEXIST, since the operating system
index e71c28424e095f2596a3e43aba41d88d88d907b7..68cb32cd40be30679b088183519a50181ae5c4ee 100644 (file)
@@ -2139,6 +2139,94 @@ class MakedirTests(unittest.TestCase):
                 self.assertEqual(os.stat(path).st_mode & 0o777, 0o555)
                 self.assertEqual(os.stat(parent).st_mode & 0o777, 0o775)
 
+    @unittest.skipIf(
+        support.is_emscripten or support.is_wasi,
+        "umask is not implemented on Emscripten/WASI."
+    )
+    @unittest.skipIf(
+        sys.platform == "android",
+        "Android filesystem may not honor requested permissions."
+    )
+    def test_mode_with_parent_mode(self):
+        # Test the parent_mode parameter
+        parent = os.path.join(os_helper.TESTFN, 'dir1')
+        path = os.path.join(parent, 'dir2')
+        with os_helper.temp_umask(0o002):
+            # Specify mode for both leaf and parent directories
+            os.makedirs(path, 0o770, parent_mode=0o750)
+            self.assertTrue(os.path.exists(path))
+            self.assertTrue(os.path.isdir(path))
+            if os.name != 'nt':
+                # Leaf directory gets the mode parameter
+                self.assertEqual(os.stat(path).st_mode & 0o777, 0o770)
+                # Parent directory gets the parent_mode parameter
+                self.assertEqual(os.stat(parent).st_mode & 0o777, 0o750)
+
+    @unittest.skipIf(
+        support.is_emscripten or support.is_wasi,
+        "umask is not implemented on Emscripten/WASI."
+    )
+    @unittest.skipIf(
+        sys.platform == "android",
+        "Android filesystem may not honor requested permissions."
+    )
+    def test_parent_mode_deep_hierarchy(self):
+        # Test parent_mode with deep directory hierarchy
+        base = os.path.join(os_helper.TESTFN, 'dir1', 'dir2', 'dir3')
+        with os_helper.temp_umask(0o002):
+            os.makedirs(base, 0o755, parent_mode=0o700)
+            self.assertTrue(os.path.exists(base))
+            if os.name != 'nt':
+                # Check that all parent directories have parent_mode
+                level1 = os.path.join(os_helper.TESTFN, 'dir1')
+                level2 = os.path.join(level1, 'dir2')
+                self.assertEqual(os.stat(level1).st_mode & 0o777, 0o700)
+                self.assertEqual(os.stat(level2).st_mode & 0o777, 0o700)
+                # Leaf directory has the regular mode
+                self.assertEqual(os.stat(base).st_mode & 0o777, 0o755)
+
+    @unittest.skipIf(
+        support.is_emscripten or support.is_wasi,
+        "umask is not implemented on Emscripten/WASI."
+    )
+    @unittest.skipIf(
+        sys.platform == "android",
+        "Android filesystem may not honor requested permissions."
+    )
+    def test_parent_mode_same_as_mode(self):
+        # Test emulating Python 3.6 behavior by setting parent_mode=mode
+        parent = os.path.join(os_helper.TESTFN, 'dir1')
+        path = os.path.join(parent, 'dir2')
+        with os_helper.temp_umask(0o002):
+            os.makedirs(path, 0o705, parent_mode=0o705)
+            self.assertTrue(os.path.exists(path))
+            if os.name != 'nt':
+                # Both directories should have the same mode
+                self.assertEqual(os.stat(path).st_mode & 0o777, 0o705)
+                self.assertEqual(os.stat(parent).st_mode & 0o777, 0o705)
+
+    @unittest.skipIf(
+        support.is_emscripten or support.is_wasi,
+        "umask is not implemented on Emscripten/WASI."
+    )
+    @unittest.skipIf(
+        sys.platform == "android",
+        "Android filesystem may not honor requested permissions."
+    )
+    def test_parent_mode_combined_with_umask(self):
+        # parent_mode, like mode, is combined with the process umask; it does
+        # not bypass it.
+        parent = os.path.join(os_helper.TESTFN, 'dir1')
+        path = os.path.join(parent, 'dir2')
+        with os_helper.temp_umask(0o022):
+            os.makedirs(path, 0o777, parent_mode=0o777)
+            self.assertTrue(os.path.isdir(path))
+            if os.name != 'nt':
+                # 0o777 is masked down to 0o755 by the 0o022 umask, for both
+                # the leaf (mode) and the parent (parent_mode).
+                self.assertEqual(os.stat(path).st_mode & 0o777, 0o755)
+                self.assertEqual(os.stat(parent).st_mode & 0o777, 0o755)
+
     @unittest.skipIf(
         support.is_wasi,
         "WASI's umask is a stub."
@@ -2212,15 +2300,9 @@ class MakedirTests(unittest.TestCase):
         )
 
     def tearDown(self):
-        path = os.path.join(os_helper.TESTFN, 'dir1', 'dir2', 'dir3',
-                            'dir4', 'dir5', 'dir6')
-        # If the tests failed, the bottom-most directory ('../dir6')
-        # may not have been created, so we look for the outermost directory
-        # that exists.
-        while not os.path.exists(path) and path != os_helper.TESTFN:
-            path = os.path.dirname(path)
-
-        os.removedirs(path)
+        # Remove the whole tree regardless of which sub-directories a test
+        # created and regardless of their permission bits.
+        os_helper.rmtree(os_helper.TESTFN)
 
 
 @unittest.skipUnless(hasattr(os, "chown"), "requires os.chown()")
index 09d1b5d725e5ba72f27804205111e917323c1206..5d13d5aadc741264ab91f417caf421f815641426 100644 (file)
@@ -2492,6 +2492,113 @@ class PathTest(PurePathTest):
                 self.assertNotIn(str(p12), concurrently_created)
             self.assertTrue(p.exists())
 
+    @unittest.skipIf(
+        is_emscripten or is_wasi,
+        "umask is not implemented on Emscripten/WASI."
+    )
+    @unittest.skipIf(
+        sys.platform == "android",
+        "Android filesystem may not honor requested permissions."
+    )
+    def test_mkdir_parents_umask(self):
+        # Test that parent directories respect umask when parent_mode is not set
+        p = self.cls(self.base, 'umasktest', 'child')
+        self.assertFalse(p.exists())
+        if os.name != 'nt':
+            with os_helper.temp_umask(0o002):
+                p.mkdir(0o755, parents=True)
+                self.assertTrue(p.exists())
+                # Leaf directory gets the specified mode
+                self.assertEqual(p.stat().st_mode & 0o777, 0o755)
+                # Parent directory respects umask (0o777 & ~0o002 = 0o775)
+                self.assertEqual(p.parent.stat().st_mode & 0o777, 0o775)
+
+    @unittest.skipIf(
+        is_emscripten or is_wasi,
+        "umask is not implemented on Emscripten/WASI."
+    )
+    @unittest.skipIf(
+        sys.platform == "android",
+        "Android filesystem may not honor requested permissions."
+    )
+    def test_mkdir_with_parent_mode(self):
+        # Test the parent_mode parameter
+        p = self.cls(self.base, 'newdirPM', 'subdirPM')
+        self.assertFalse(p.exists())
+        if os.name != 'nt':
+            # Specify different modes for parent and leaf directories
+            p.mkdir(0o755, parents=True, parent_mode=0o750)
+            self.assertTrue(p.exists())
+            self.assertTrue(p.is_dir())
+            # Leaf directory gets the mode parameter
+            self.assertEqual(p.stat().st_mode & 0o777, 0o755)
+            # Parent directory gets the parent_mode parameter
+            self.assertEqual(p.parent.stat().st_mode & 0o777, 0o750)
+
+    @unittest.skipIf(
+        is_emscripten or is_wasi,
+        "umask is not implemented on Emscripten/WASI."
+    )
+    @unittest.skipIf(
+        sys.platform == "android",
+        "Android filesystem may not honor requested permissions."
+    )
+    def test_mkdir_parent_mode_deep_hierarchy(self):
+        # Test parent_mode with deep directory hierarchy
+        p = self.cls(self.base, 'level1PM', 'level2PM', 'level3PM')
+        self.assertFalse(p.exists())
+        if os.name != 'nt':
+            p.mkdir(0o755, parents=True, parent_mode=0o700)
+            self.assertTrue(p.exists())
+            # Check that all parent directories have parent_mode
+            level1 = self.cls(self.base, 'level1PM')
+            level2 = level1 / 'level2PM'
+            self.assertEqual(level1.stat().st_mode & 0o777, 0o700)
+            self.assertEqual(level2.stat().st_mode & 0o777, 0o700)
+            # Leaf directory has the regular mode
+            self.assertEqual(p.stat().st_mode & 0o777, 0o755)
+
+    @unittest.skipIf(
+        is_emscripten or is_wasi,
+        "umask is not implemented on Emscripten/WASI."
+    )
+    @unittest.skipIf(
+        sys.platform == "android",
+        "Android filesystem may not honor requested permissions."
+    )
+    def test_mkdir_parent_mode_combined_with_umask(self):
+        # parent_mode, like mode, is combined with the process umask; it does
+        # not bypass it.
+        p = self.cls(self.base, 'umaskPM', 'child')
+        self.assertFalse(p.exists())
+        if os.name != 'nt':
+            with os_helper.temp_umask(0o022):
+                p.mkdir(0o777, parents=True, parent_mode=0o777)
+                self.assertTrue(p.exists())
+                # 0o777 is masked down to 0o755 by the 0o022 umask, for both
+                # the leaf (mode) and the parent (parent_mode).
+                self.assertEqual(p.stat().st_mode & 0o777, 0o755)
+                self.assertEqual(p.parent.stat().st_mode & 0o777, 0o755)
+
+    @unittest.skipIf(
+        is_emscripten or is_wasi,
+        "umask is not implemented on Emscripten/WASI."
+    )
+    @unittest.skipIf(
+        sys.platform == "android",
+        "Android filesystem may not honor requested permissions."
+    )
+    def test_mkdir_parent_mode_same_as_mode(self):
+        # Test setting parent_mode same as mode
+        p = self.cls(self.base, 'samedirPM', 'subdirPM')
+        self.assertFalse(p.exists())
+        if os.name != 'nt':
+            p.mkdir(0o705, parents=True, parent_mode=0o705)
+            self.assertTrue(p.exists())
+            # Both directories should have the same mode
+            self.assertEqual(p.stat().st_mode & 0o777, 0o705)
+            self.assertEqual(p.parent.stat().st_mode & 0o777, 0o705)
+
     @needs_symlinks
     def test_symlink_to(self):
         P = self.cls(self.base)
diff --git a/Misc/NEWS.d/next/Library/2025-08-30-07-44-30.gh-issue-86533.pathlib.rst b/Misc/NEWS.d/next/Library/2025-08-30-07-44-30.gh-issue-86533.pathlib.rst
new file mode 100644 (file)
index 0000000..9c32671
--- /dev/null
@@ -0,0 +1,4 @@
+The :func:`os.makedirs` function and :meth:`pathlib.Path.mkdir` method now have
+a *parent_mode* parameter to specify the mode for intermediate directories when
+creating parent directories. This allows one to match the behavior from Python
+3.6 and earlier for :func:`os.makedirs`.