Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Doc/library/pathlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1978,3 +1978,10 @@ The :mod:`pathlib.types` module provides types for static type checking.
Return ``True`` if the path is a symbolic link (even if broken); return
``False`` if the path is a directory or any kind of file, or if it
doesn't exist.

.. method:: mode(*, follow_symlinks=True)

Return the file permissions as an integer, i.e. the
:attr:`~os.stat_result.st_mode` with the file format bits masked out.

.. versionadded:: next
8 changes: 8 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,14 @@ os.path
(Contributed by Petr Viktorin for :cve:`2025-4517`.)


pathlib
-------

* Add :meth:`pathlib.types.PathInfo.mode`, which returns the Posix file
permissions as an integer.
(Contributed by Barney Gale in :gh:`139174`.)


resource
--------

Expand Down
11 changes: 4 additions & 7 deletions Lib/pathlib/_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,13 +330,10 @@ def copy_info(info, target, follow_symlinks=True):
if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES):
raise

copy_posix_permissions = (
hasattr(info, '_posix_permissions') and
(follow_symlinks or os.chmod in os.supports_follow_symlinks))
if copy_posix_permissions:
posix_permissions = info._posix_permissions(follow_symlinks=follow_symlinks)
if follow_symlinks or os.chmod in os.supports_follow_symlinks:
mode = info.mode(follow_symlinks=follow_symlinks)
try:
os.chmod(target, posix_permissions, follow_symlinks=follow_symlinks)
os.chmod(target, mode, follow_symlinks=follow_symlinks)
except NotImplementedError:
# if we got a NotImplementedError, it's because
# * follow_symlinks=False,
Expand Down Expand Up @@ -407,7 +404,7 @@ def _stat(self, *, follow_symlinks=True, ignore_errors=False):
raise
return self._lstat_result

def _posix_permissions(self, *, follow_symlinks=True):
def mode(self, *, follow_symlinks=True):
"""Return the POSIX file permissions."""
return S_IMODE(self._stat(follow_symlinks=follow_symlinks).st_mode)

Expand Down
1 change: 1 addition & 0 deletions Lib/pathlib/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def exists(self, *, follow_symlinks: bool = True) -> bool: ...
def is_dir(self, *, follow_symlinks: bool = True) -> bool: ...
def is_file(self, *, follow_symlinks: bool = True) -> bool: ...
def is_symlink(self) -> bool: ...
def mode(self, *, follow_symlinks: bool = True) -> int: ...


class _PathGlobber(_GlobberBase):
Expand Down
22 changes: 21 additions & 1 deletion Lib/test/test_pathlib/support/local_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""

import os
from stat import S_IMODE

from . import is_pypi
from .lexical_path import LexicalPath
Expand Down Expand Up @@ -89,19 +90,25 @@ def readbytes(self, p):
with open(p, 'rb') as f:
return f.read()

def getmode(self, p):
return S_IMODE(os.lstat(p).st_mode)


class LocalPathInfo(PathInfo):
"""
Simple implementation of PathInfo for a local path
"""
__slots__ = ('_path', '_exists', '_is_dir', '_is_file', '_is_symlink')
__slots__ = ('_path', '_exists', '_is_dir', '_is_file', '_is_symlink',
'_stat_result', '_lstat_result')

def __init__(self, path):
self._path = os.fspath(path)
self._exists = None
self._is_dir = None
self._is_file = None
self._is_symlink = None
self._stat_result = None
self._lstat_result = None

def exists(self, *, follow_symlinks=True):
"""Whether this path exists."""
Expand Down Expand Up @@ -133,6 +140,19 @@ def is_symlink(self):
self._is_symlink = os.path.islink(self._path)
return self._is_symlink

def _stat(self, follow_symlinks):
if follow_symlinks:
if not self._stat_result:
self._stat_result = os.stat(self._path)
return self._stat_result
else:
if not self._lstat_result:
self._lstat_result = os.lstat(self._path)
return self._lstat_result

def mode(self, *, follow_symlinks=True):
return S_IMODE(self._stat(follow_symlinks).st_mode)


class ReadableLocalPath(_ReadablePath, LexicalPath):
"""
Expand Down
25 changes: 23 additions & 2 deletions Lib/test/test_pathlib/support/zip_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,22 @@ def teardown(self, root):
root.zip_file.close()

def create_file(self, path, data=b''):
path.zip_file.writestr(vfspath(path), data)
zip_info = zipfile.ZipInfo(vfspath(path))
zip_info.external_attr |= 0o644 << 16
zip_info.external_attr |= stat.S_IFREG << 16
path.zip_file.writestr(zip_info, data)

def create_dir(self, path):
zip_info = zipfile.ZipInfo(vfspath(path) + '/')
zip_info.external_attr |= 0o755 << 16
zip_info.external_attr |= stat.S_IFDIR << 16
zip_info.external_attr |= stat.FILE_ATTRIBUTE_DIRECTORY
path.zip_file.writestr(zip_info, '')

def create_symlink(self, path, target):
zip_info = zipfile.ZipInfo(vfspath(path))
zip_info.external_attr = stat.S_IFLNK << 16
zip_info.external_attr |= 0o644 << 16
zip_info.external_attr |= stat.S_IFLNK << 16
path.zip_file.writestr(zip_info, target.encode())

def create_hierarchy(self, p):
Expand Down Expand Up @@ -89,6 +94,12 @@ def islink(self, p):
return False
return stat.S_ISLNK(info.external_attr >> 16)

def getmode(self, p):
if p.info.is_dir(follow_symlinks=False):
return 0o755
else:
return 0o644


class MissingZipPathInfo(PathInfo):
"""
Expand All @@ -111,6 +122,9 @@ def is_symlink(self):
def resolve(self):
return self

def mode(self, follow_symlinks=True):
raise FileNotFoundError(errno.ENOENT, "File not found", self)


missing_zip_path_info = MissingZipPathInfo()

Expand Down Expand Up @@ -160,6 +174,13 @@ def is_symlink(self):
else:
return False

def mode(self, *, follow_symlinks=True):
if follow_symlinks and self.is_symlink():
return self.resolve().mode()
elif self.zip_info is None:
return 0o755
return stat.S_IMODE(self.zip_info.external_attr >> 16)

def resolve(self, path=None, create=False, follow_symlinks=True):
"""
Traverse zip hierarchy (parents, children and symlinks) starting
Expand Down
23 changes: 23 additions & 0 deletions Lib/test/test_pathlib/test_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -2562,6 +2562,29 @@ def test_info_is_symlink_caching(self):
q.unlink()
self.assertTrue(q.info.is_symlink())

def test_info_mode_caching(self):
p = self.cls(self.base)

q = p / 'myfile'
self.assertRaises(FileNotFoundError, q.info.mode)
q.write_text('hullo')
mode = stat.S_IMODE(os.stat(q).st_mode)
self.assertEqual(q.info.mode(), mode)
new_mode = mode & ~0o222 # clear writable bit.
os.chmod(q, new_mode)
try:
self.assertEqual(q.info.mode(), mode)

q = p / 'myfile' # same path, new instance.
self.assertEqual(q.info.mode(), new_mode)
finally:
os.chmod(q, mode)
os.unlink(q)
self.assertEqual(q.info.mode(), new_mode)

q = p / 'myfile' # same path, new instance.
self.assertRaises(FileNotFoundError, q.info.mode)

def test_stat(self):
statA = self.cls(self.base).joinpath('fileA').stat()
statB = self.cls(self.base).joinpath('dirB', 'fileB').stat()
Expand Down
19 changes: 19 additions & 0 deletions Lib/test/test_pathlib/test_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,25 @@ def test_info_is_symlink(self):
self.assertFalse((p / 'fileA\udfff').info.is_symlink())
self.assertFalse((p / 'fileA\x00').info.is_symlink())

def test_info_mode(self):
p = self.root
file_mode = self.ground.getmode(p.joinpath('fileA'))
dir_mode = self.ground.getmode(p.joinpath('dirA'))
self.assertEqual(p.joinpath('fileA').info.mode(), file_mode)
self.assertEqual(p.joinpath('dirA').info.mode(), dir_mode)
self.assertRaises(FileNotFoundError, p.joinpath('non-existing').info.mode)
if self.ground.can_symlink:
file_link_mode = self.ground.getmode(p.joinpath('linkA'))
dir_link_mode = self.ground.getmode(p.joinpath('linkB'))
self.assertEqual(p.joinpath('linkA').info.mode(follow_symlinks=False), file_link_mode)
self.assertEqual(p.joinpath('linkA').info.mode(), file_mode)
self.assertEqual(p.joinpath('linkB').info.mode(follow_symlinks=False), dir_link_mode)
self.assertEqual(p.joinpath('linkB').info.mode(), dir_mode)
self.assertEqual(p.joinpath('brokenLink').info.mode(follow_symlinks=False), file_link_mode)
self.assertRaises(FileNotFoundError, p.joinpath('brokenLink').info.mode)
self.assertEqual(p.joinpath('brokenLinkLoop').info.mode(follow_symlinks=False), file_link_mode)
self.assertRaises(OSError, p.joinpath('brokenLinkLoop').info.mode)


class ZipPathReadTest(ReadTestBase, unittest.TestCase):
ground = ZipPathGround(ReadableZipPath)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add :meth:`pathlib.types.PathInfo.mode`, which returns the Posix file
permissions as an integer.
Loading