Skip to content

Commit d340389

Browse files
committed
GH-139174: Add pathlib.Path.info.mode()
Add `mode()` method to the `PathInfo` protocol. A concrete implementation is available via `Path.info.mode()`. This method caches and returns the Posix file permissions, i.e. `st_mode` from the stat struct with its file format bits masked out. In the tests for the pathlib ABCs, add `mode()` methods to our `ZipPathInfo` and `LocalPathInfo` classes, and test them from `test_read`.
1 parent 3eec897 commit d340389

File tree

9 files changed

+104
-10
lines changed

9 files changed

+104
-10
lines changed

Doc/library/pathlib.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,3 +1978,10 @@ The :mod:`pathlib.types` module provides types for static type checking.
19781978
Return ``True`` if the path is a symbolic link (even if broken); return
19791979
``False`` if the path is a directory or any kind of file, or if it
19801980
doesn't exist.
1981+
1982+
.. method:: mode(*, follow_symlinks=True)
1983+
1984+
Return the file permissions as an integer, i.e. the
1985+
:attr:`~os.stat_result.st_mode` with the file format bits masked out.
1986+
1987+
.. versionadded:: next

Doc/whatsnew/3.15.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,14 @@ os.path
406406
(Contributed by Petr Viktorin for :cve:`2025-4517`.)
407407

408408

409+
pathlib
410+
-------
411+
412+
* Add :meth:`pathlib.types.PathInfo.mode`, which returns the Posix file
413+
permissions as an integer.
414+
(Contributed by Barney Gale in :gh:`139174`.)
415+
416+
409417
resource
410418
--------
411419

Lib/pathlib/_os.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -330,13 +330,10 @@ def copy_info(info, target, follow_symlinks=True):
330330
if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES):
331331
raise
332332

333-
copy_posix_permissions = (
334-
hasattr(info, '_posix_permissions') and
335-
(follow_symlinks or os.chmod in os.supports_follow_symlinks))
336-
if copy_posix_permissions:
337-
posix_permissions = info._posix_permissions(follow_symlinks=follow_symlinks)
333+
if follow_symlinks or os.chmod in os.supports_follow_symlinks:
334+
mode = info.mode(follow_symlinks=follow_symlinks)
338335
try:
339-
os.chmod(target, posix_permissions, follow_symlinks=follow_symlinks)
336+
os.chmod(target, mode, follow_symlinks=follow_symlinks)
340337
except NotImplementedError:
341338
# if we got a NotImplementedError, it's because
342339
# * follow_symlinks=False,
@@ -407,7 +404,7 @@ def _stat(self, *, follow_symlinks=True, ignore_errors=False):
407404
raise
408405
return self._lstat_result
409406

410-
def _posix_permissions(self, *, follow_symlinks=True):
407+
def mode(self, *, follow_symlinks=True):
411408
"""Return the POSIX file permissions."""
412409
return S_IMODE(self._stat(follow_symlinks=follow_symlinks).st_mode)
413410

Lib/pathlib/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def exists(self, *, follow_symlinks: bool = True) -> bool: ...
5959
def is_dir(self, *, follow_symlinks: bool = True) -> bool: ...
6060
def is_file(self, *, follow_symlinks: bool = True) -> bool: ...
6161
def is_symlink(self) -> bool: ...
62+
def mode(self, *, follow_symlinks: bool = True) -> int: ...
6263

6364

6465
class _PathGlobber(_GlobberBase):

Lib/test/test_pathlib/support/local_path.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import os
10+
from stat import S_IMODE
1011

1112
from . import is_pypi
1213
from .lexical_path import LexicalPath
@@ -89,19 +90,25 @@ def readbytes(self, p):
8990
with open(p, 'rb') as f:
9091
return f.read()
9192

93+
def getmode(self, p):
94+
return S_IMODE(os.lstat(p).st_mode)
95+
9296

9397
class LocalPathInfo(PathInfo):
9498
"""
9599
Simple implementation of PathInfo for a local path
96100
"""
97-
__slots__ = ('_path', '_exists', '_is_dir', '_is_file', '_is_symlink')
101+
__slots__ = ('_path', '_exists', '_is_dir', '_is_file', '_is_symlink',
102+
'_stat_result', '_lstat_result')
98103

99104
def __init__(self, path):
100105
self._path = os.fspath(path)
101106
self._exists = None
102107
self._is_dir = None
103108
self._is_file = None
104109
self._is_symlink = None
110+
self._stat_result = None
111+
self._lstat_result = None
105112

106113
def exists(self, *, follow_symlinks=True):
107114
"""Whether this path exists."""
@@ -133,6 +140,19 @@ def is_symlink(self):
133140
self._is_symlink = os.path.islink(self._path)
134141
return self._is_symlink
135142

143+
def _stat(self, follow_symlinks):
144+
if follow_symlinks:
145+
if not self._stat_result:
146+
self._stat_result = os.stat(self._path)
147+
return self._stat_result
148+
else:
149+
if not self._lstat_result:
150+
self._lstat_result = os.lstat(self._path)
151+
return self._lstat_result
152+
153+
def mode(self, *, follow_symlinks=True):
154+
return S_IMODE(self._stat(follow_symlinks).st_mode)
155+
136156

137157
class ReadableLocalPath(_ReadablePath, LexicalPath):
138158
"""

Lib/test/test_pathlib/support/zip_path.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,22 @@ def teardown(self, root):
3535
root.zip_file.close()
3636

3737
def create_file(self, path, data=b''):
38-
path.zip_file.writestr(vfspath(path), data)
38+
zip_info = zipfile.ZipInfo(vfspath(path))
39+
zip_info.external_attr |= 0o644 << 16
40+
zip_info.external_attr |= stat.S_IFREG << 16
41+
path.zip_file.writestr(zip_info, data)
3942

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

4650
def create_symlink(self, path, target):
4751
zip_info = zipfile.ZipInfo(vfspath(path))
48-
zip_info.external_attr = stat.S_IFLNK << 16
52+
zip_info.external_attr |= 0o644 << 16
53+
zip_info.external_attr |= stat.S_IFLNK << 16
4954
path.zip_file.writestr(zip_info, target.encode())
5055

5156
def create_hierarchy(self, p):
@@ -89,6 +94,12 @@ def islink(self, p):
8994
return False
9095
return stat.S_ISLNK(info.external_attr >> 16)
9196

97+
def getmode(self, p):
98+
if p.info.is_dir(follow_symlinks=False):
99+
return 0o755
100+
else:
101+
return 0o644
102+
92103

93104
class MissingZipPathInfo(PathInfo):
94105
"""
@@ -111,6 +122,9 @@ def is_symlink(self):
111122
def resolve(self):
112123
return self
113124

125+
def mode(self, follow_symlinks=True):
126+
raise FileNotFoundError(errno.ENOENT, "File not found", self)
127+
114128

115129
missing_zip_path_info = MissingZipPathInfo()
116130

@@ -160,6 +174,13 @@ def is_symlink(self):
160174
else:
161175
return False
162176

177+
def mode(self, *, follow_symlinks=True):
178+
if follow_symlinks and self.is_symlink():
179+
return self.resolve().mode()
180+
elif self.zip_info is None:
181+
return 0o755
182+
return stat.S_IMODE(self.zip_info.external_attr >> 16)
183+
163184
def resolve(self, path=None, create=False, follow_symlinks=True):
164185
"""
165186
Traverse zip hierarchy (parents, children and symlinks) starting

Lib/test/test_pathlib/test_pathlib.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2562,6 +2562,26 @@ def test_info_is_symlink_caching(self):
25622562
q.unlink()
25632563
self.assertTrue(q.info.is_symlink())
25642564

2565+
def test_info_mode_caching(self):
2566+
p = self.cls(self.base)
2567+
2568+
q = p / 'myfile'
2569+
self.assertRaises(FileNotFoundError, q.info.mode)
2570+
q.write_text('hullo')
2571+
mode = stat.S_IMODE(os.stat(q).st_mode)
2572+
self.assertEqual(q.info.mode(), mode)
2573+
new_mode = mode & ~0o222 # clear writable bit.
2574+
os.chmod(q, new_mode)
2575+
self.assertEqual(q.info.mode(), mode)
2576+
2577+
q = p / 'myfile' # same path, new instance.
2578+
self.assertEqual(q.info.mode(), new_mode)
2579+
os.unlink(q)
2580+
self.assertEqual(q.info.mode(), new_mode)
2581+
2582+
q = p / 'myfile' # same path, new instance.
2583+
self.assertRaises(FileNotFoundError, q.info.mode)
2584+
25652585
def test_stat(self):
25662586
statA = self.cls(self.base).joinpath('fileA').stat()
25672587
statB = self.cls(self.base).joinpath('dirB', 'fileB').stat()

Lib/test/test_pathlib/test_read.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,24 @@ def test_info_is_symlink(self):
329329
self.assertFalse((p / 'fileA\udfff').info.is_symlink())
330330
self.assertFalse((p / 'fileA\x00').info.is_symlink())
331331

332+
def test_info_mode(self):
333+
p = self.root
334+
file_mode = self.ground.getmode(p.joinpath('fileA'))
335+
dir_mode = self.ground.getmode(p.joinpath('dirA'))
336+
self.assertEqual(p.joinpath('fileA').info.mode(), file_mode)
337+
self.assertEqual(p.joinpath('dirA').info.mode(), dir_mode)
338+
self.assertRaises(FileNotFoundError, p.joinpath('non-existing').info.mode)
339+
if self.ground.can_symlink:
340+
link_mode = self.ground.getmode(p.joinpath('linkA'))
341+
self.assertEqual(p.joinpath('linkA').info.mode(follow_symlinks=False), link_mode)
342+
self.assertEqual(p.joinpath('linkA').info.mode(), file_mode)
343+
self.assertEqual(p.joinpath('linkB').info.mode(follow_symlinks=False), link_mode)
344+
self.assertEqual(p.joinpath('linkB').info.mode(), dir_mode)
345+
self.assertEqual(p.joinpath('brokenLink').info.mode(follow_symlinks=False), link_mode)
346+
self.assertRaises(FileNotFoundError, p.joinpath('brokenLink').info.mode)
347+
self.assertEqual(p.joinpath('brokenLinkLoop').info.mode(follow_symlinks=False), link_mode)
348+
self.assertRaises(OSError, p.joinpath('brokenLinkLoop').info.mode)
349+
332350

333351
class ZipPathReadTest(ReadTestBase, unittest.TestCase):
334352
ground = ZipPathGround(ReadableZipPath)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add :meth:`pathlib.types.PathInfo.mode`, which returns the Posix file
2+
permissions as an integer.

0 commit comments

Comments
 (0)