diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst index 79e0b7f09eaa77..f7fc43751a8d46 100644 --- a/Doc/library/pathlib.rst +++ b/Doc/library/pathlib.rst @@ -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 diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 424e23ab354245..dc992ad1f40c4c 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -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 -------- diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index 6508a9bca0d72b..0798ed91b41f68 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -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, @@ -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) diff --git a/Lib/pathlib/types.py b/Lib/pathlib/types.py index fea0dd305fe2a3..16e89606c2564c 100644 --- a/Lib/pathlib/types.py +++ b/Lib/pathlib/types.py @@ -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): diff --git a/Lib/test/test_pathlib/support/local_path.py b/Lib/test/test_pathlib/support/local_path.py index ddfd6fd419533c..cd494a6312a8f5 100644 --- a/Lib/test/test_pathlib/support/local_path.py +++ b/Lib/test/test_pathlib/support/local_path.py @@ -7,6 +7,7 @@ """ import os +from stat import S_IMODE from . import is_pypi from .lexical_path import LexicalPath @@ -89,12 +90,16 @@ 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) @@ -102,6 +107,8 @@ def __init__(self, path): 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.""" @@ -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): """ diff --git a/Lib/test/test_pathlib/support/zip_path.py b/Lib/test/test_pathlib/support/zip_path.py index 90b939b6a59010..e2250835244d98 100644 --- a/Lib/test/test_pathlib/support/zip_path.py +++ b/Lib/test/test_pathlib/support/zip_path.py @@ -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): @@ -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): """ @@ -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() @@ -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 diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index a1105aae6351b6..b4ce9812171baf 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -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() diff --git a/Lib/test/test_pathlib/test_read.py b/Lib/test/test_pathlib/test_read.py index 16fb555b2aee05..5e03f461175167 100644 --- a/Lib/test/test_pathlib/test_read.py +++ b/Lib/test/test_pathlib/test_read.py @@ -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) diff --git a/Misc/NEWS.d/next/Library/2025-09-20-17-13-47.gh-issue-139174.mpuJHy.rst b/Misc/NEWS.d/next/Library/2025-09-20-17-13-47.gh-issue-139174.mpuJHy.rst new file mode 100644 index 00000000000000..4a2878fbc1eadb --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-20-17-13-47.gh-issue-139174.mpuJHy.rst @@ -0,0 +1,2 @@ +Add :meth:`pathlib.types.PathInfo.mode`, which returns the Posix file +permissions as an integer.