]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-125413: Add `pathlib.Path.info` attribute (#127730)
authorBarney Gale <barney.gale@gmail.com>
Sat, 8 Feb 2025 01:16:45 +0000 (01:16 +0000)
committerGitHub <noreply@github.com>
Sat, 8 Feb 2025 01:16:45 +0000 (01:16 +0000)
Add `pathlib.Path.info` attribute, which stores an object implementing the `pathlib.types.PathInfo` protocol (also new). The object supports querying the file type and internally caching `os.stat()` results. Path objects generated by `Path.iterdir()` are initialised with status information from `os.DirEntry` objects, which is gleaned from scanning the parent directory.

The `PathInfo` protocol has four methods: `exists()`, `is_dir()`, `is_file()` and `is_symlink()`.

Doc/library/pathlib.rst
Doc/whatsnew/3.14.rst
Lib/glob.py
Lib/pathlib/_abc.py
Lib/pathlib/_local.py
Lib/pathlib/_os.py
Lib/pathlib/types.py [moved from Lib/pathlib/_types.py with 57% similarity]
Lib/test/test_pathlib/test_pathlib.py
Lib/test/test_pathlib/test_pathlib_abc.py
Misc/NEWS.d/next/Library/2024-12-10-19-39-35.gh-issue-125413.wOb4yr.rst [new file with mode: 0644]

index 4b48880d6d9a18d66937b5aff9fa7c630a092015..8977ccfe6e412494563b76e5ff5a514d84db6112 100644 (file)
@@ -1177,6 +1177,38 @@ Querying file type and status
    .. versionadded:: 3.5
 
 
+.. attribute:: Path.info
+
+   A :class:`~pathlib.types.PathInfo` object that supports querying file type
+   information. The object exposes methods that cache their results, which can
+   help reduce the number of system calls needed when switching on file type.
+   For example::
+
+      >>> p = Path('src')
+      >>> if p.info.is_symlink():
+      ...     print('symlink')
+      ... elif p.info.is_dir():
+      ...     print('directory')
+      ... elif p.info.exists():
+      ...     print('something else')
+      ... else:
+      ...     print('not found')
+      ...
+      directory
+
+   If the path was generated from :meth:`Path.iterdir` then this attribute is
+   initialized with some information about the file type gleaned from scanning
+   the parent directory. Merely accessing :attr:`Path.info` does not perform
+   any filesystem queries.
+
+   To fetch up-to-date information, it's best to call :meth:`Path.is_dir`,
+   :meth:`~Path.is_file` and :meth:`~Path.is_symlink` rather than methods of
+   this attribute. There is no way to reset the cache; instead you can create
+   a new path object with an empty info cache via ``p = Path(p)``.
+
+   .. versionadded:: 3.14
+
+
 Reading and writing files
 ^^^^^^^^^^^^^^^^^^^^^^^^^
 
@@ -1903,3 +1935,56 @@ Below is a table mapping various :mod:`os` functions to their corresponding
 .. [4] :func:`os.walk` always follows symlinks when categorizing paths into
    *dirnames* and *filenames*, whereas :meth:`Path.walk` categorizes all
    symlinks into *filenames* when *follow_symlinks* is false (the default.)
+
+
+Protocols
+---------
+
+.. module:: pathlib.types
+   :synopsis: pathlib types for static type checking
+
+
+The :mod:`pathlib.types` module provides types for static type checking.
+
+.. versionadded:: 3.14
+
+
+.. class:: PathInfo()
+
+   A :class:`typing.Protocol` describing the
+   :attr:`Path.info <pathlib.Path.info>` attribute. Implementations may
+   return cached results from their methods.
+
+   .. method:: exists(*, follow_symlinks=True)
+
+      Return ``True`` if the path is an existing file or directory, or any
+      other kind of file; return ``False`` if the path doesn't exist.
+
+      If *follow_symlinks* is ``False``, return ``True`` for symlinks without
+      checking if their targets exist.
+
+   .. method:: is_dir(*, follow_symlinks=True)
+
+      Return ``True`` if the path is a directory, or a symbolic link pointing
+      to a directory; return ``False`` if the path is (or points to) any other
+      kind of file, or if it doesn't exist.
+
+      If *follow_symlinks* is ``False``, return ``True`` only if the path
+      is a directory (without following symlinks); return ``False`` if the
+      path is any other kind of file, or if it doesn't exist.
+
+   .. method:: is_file(*, follow_symlinks=True)
+
+      Return ``True`` if the path is a file, or a symbolic link pointing to
+      a file; return ``False`` if the path is (or points to) a directory or
+      other non-file, or if it doesn't exist.
+
+      If *follow_symlinks* is ``False``, return ``True`` only if the path
+      is a file (without following symlinks); return ``False`` if the path
+      is a directory or other other non-file, or if it doesn't exist.
+
+   .. method:: is_symlink()
+
+      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.
index 5cef899994400590a6eb69a05f37b62b38fd8f6d..0f119d10819d260c9d620da64ddf058d388a7082 100644 (file)
@@ -617,6 +617,15 @@ pathlib
 
   (Contributed by Barney Gale in :gh:`73991`.)
 
+* Add :attr:`pathlib.Path.info` attribute, which stores an object
+  implementing the :class:`pathlib.types.PathInfo` protocol (also new). The
+  object supports querying the file type and internally caching
+  :func:`~os.stat` results. Path objects generated by
+  :meth:`~pathlib.Path.iterdir` are initialized with file type information
+  gleaned from scanning the parent directory.
+
+  (Contributed by Barney Gale in :gh:`125413`.)
+
 
 pdb
 ---
index 690ab1b8b9fb1d213306042c9a3a94385d81ff53..a834ea7f7ce556250baae4257a0592c43e324030 100644 (file)
@@ -348,7 +348,7 @@ class _GlobberBase:
 
     @staticmethod
     def scandir(path):
-        """Implements os.scandir().
+        """Like os.scandir(), but generates (entry, name, path) tuples.
         """
         raise NotImplementedError
 
@@ -425,23 +425,18 @@ class _GlobberBase:
 
         def select_wildcard(path, exists=False):
             try:
-                # We must close the scandir() object before proceeding to
-                # avoid exhausting file descriptors when globbing deep trees.
-                with self.scandir(path) as scandir_it:
-                    entries = list(scandir_it)
+                entries = self.scandir(path)
             except OSError:
                 pass
             else:
-                prefix = self.add_slash(path)
-                for entry in entries:
-                    if match is None or match(entry.name):
+                for entry, entry_name, entry_path in entries:
+                    if match is None or match(entry_name):
                         if dir_only:
                             try:
                                 if not entry.is_dir():
                                     continue
                             except OSError:
                                 continue
-                        entry_path = self.concat_path(prefix, entry.name)
                         if dir_only:
                             yield from select_next(entry_path, exists=True)
                         else:
@@ -483,15 +478,11 @@ class _GlobberBase:
         def select_recursive_step(stack, match_pos):
             path = stack.pop()
             try:
-                # We must close the scandir() object before proceeding to
-                # avoid exhausting file descriptors when globbing deep trees.
-                with self.scandir(path) as scandir_it:
-                    entries = list(scandir_it)
+                entries = self.scandir(path)
             except OSError:
                 pass
             else:
-                prefix = self.add_slash(path)
-                for entry in entries:
+                for entry, _entry_name, entry_path in entries:
                     is_dir = False
                     try:
                         if entry.is_dir(follow_symlinks=follow_symlinks):
@@ -500,7 +491,6 @@ class _GlobberBase:
                         pass
 
                     if is_dir or not dir_only:
-                        entry_path = self.concat_path(prefix, entry.name)
                         if match is None or match(str(entry_path), match_pos):
                             if dir_only:
                                 yield from select_next(entry_path, exists=True)
@@ -528,9 +518,16 @@ class _StringGlobber(_GlobberBase):
     """Provides shell-style pattern matching and globbing for string paths.
     """
     lexists = staticmethod(os.path.lexists)
-    scandir = staticmethod(os.scandir)
     concat_path = operator.add
 
+    @staticmethod
+    def scandir(path):
+        # We must close the scandir() object before proceeding to
+        # avoid exhausting file descriptors when globbing deep trees.
+        with os.scandir(path) as scandir_it:
+            entries = list(scandir_it)
+        return ((entry, entry.name, entry.path) for entry in entries)
+
     if os.name == 'nt':
         @staticmethod
         def add_slash(pathname):
@@ -544,3 +541,19 @@ class _StringGlobber(_GlobberBase):
             if not pathname or pathname[-1] == '/':
                 return pathname
             return f'{pathname}/'
+
+
+class _PathGlobber(_GlobberBase):
+    """Provides shell-style pattern matching and globbing for pathlib paths.
+    """
+
+    lexists = operator.methodcaller('exists', follow_symlinks=False)
+    add_slash = operator.methodcaller('joinpath', '')
+
+    @staticmethod
+    def scandir(path):
+        return ((child.info, child.name, child) for child in path.iterdir())
+
+    @staticmethod
+    def concat_path(path, text):
+        return path.with_segments(str(path) + text)
index e498dc78e83b5eb495c65aca8d038a908f04bf41..d20f04fc5b6dc38aa189b179b6cbe41a630926dd 100644 (file)
@@ -13,10 +13,9 @@ WritablePath.
 
 import functools
 import io
-import operator
 import posixpath
 from errno import EINVAL
-from glob import _GlobberBase, _no_recurse_symlinks
+from glob import _PathGlobber, _no_recurse_symlinks
 from pathlib._os import copyfileobj
 
 
@@ -76,21 +75,6 @@ def magic_open(path, mode='r', buffering=-1, encoding=None, errors=None,
     raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}")
 
 
-class PathGlobber(_GlobberBase):
-    """
-    Class providing shell-style globbing for path objects.
-    """
-
-    lexists = operator.methodcaller('exists', follow_symlinks=False)
-    add_slash = operator.methodcaller('joinpath', '')
-    scandir = operator.methodcaller('_scandir')
-
-    @staticmethod
-    def concat_path(path, text):
-        """Appends text to the given path."""
-        return path.with_segments(str(path) + text)
-
-
 class CopyReader:
     """
     Class that implements the "read" part of copying between path objects.
@@ -367,7 +351,7 @@ class JoinablePath:
             pattern = self.with_segments(pattern)
         if case_sensitive is None:
             case_sensitive = _is_case_sensitive(self.parser)
-        globber = PathGlobber(pattern.parser.sep, case_sensitive, recursive=True)
+        globber = _PathGlobber(pattern.parser.sep, case_sensitive, recursive=True)
         match = globber.compile(str(pattern))
         return match(str(self)) is not None
 
@@ -388,6 +372,14 @@ class ReadablePath(JoinablePath):
     """
     __slots__ = ()
 
+    @property
+    def info(self):
+        """
+        A PathInfo object that exposes the file type and other file attributes
+        of this path.
+        """
+        raise NotImplementedError
+
     def exists(self, *, follow_symlinks=True):
         """
         Whether this path exists.
@@ -395,26 +387,30 @@ class ReadablePath(JoinablePath):
         This method normally follows symlinks; to check whether a symlink exists,
         add the argument follow_symlinks=False.
         """
-        raise NotImplementedError
+        info = self.joinpath().info
+        return info.exists(follow_symlinks=follow_symlinks)
 
     def is_dir(self, *, follow_symlinks=True):
         """
         Whether this path is a directory.
         """
-        raise NotImplementedError
+        info = self.joinpath().info
+        return info.is_dir(follow_symlinks=follow_symlinks)
 
     def is_file(self, *, follow_symlinks=True):
         """
         Whether this path is a regular file (also True for symlinks pointing
         to regular files).
         """
-        raise NotImplementedError
+        info = self.joinpath().info
+        return info.is_file(follow_symlinks=follow_symlinks)
 
     def is_symlink(self):
         """
         Whether this path is a symbolic link.
         """
-        raise NotImplementedError
+        info = self.joinpath().info
+        return info.is_symlink()
 
     def __open_rb__(self, buffering=-1):
         """
@@ -437,15 +433,6 @@ class ReadablePath(JoinablePath):
         with magic_open(self, mode='r', encoding=encoding, errors=errors, newline=newline) as f:
             return f.read()
 
-    def _scandir(self):
-        """Yield os.DirEntry-like objects of the directory contents.
-
-        The children are yielded in arbitrary order, and the
-        special entries '.' and '..' are not included.
-        """
-        import contextlib
-        return contextlib.nullcontext(self.iterdir())
-
     def iterdir(self):
         """Yield path objects of the directory contents.
 
@@ -471,7 +458,7 @@ class ReadablePath(JoinablePath):
         else:
             case_pedantic = True
         recursive = True if recurse_symlinks else _no_recurse_symlinks
-        globber = PathGlobber(self.parser.sep, case_sensitive, case_pedantic, recursive)
+        globber = _PathGlobber(self.parser.sep, case_sensitive, case_pedantic, recursive)
         select = globber.selector(parts)
         return select(self)
 
@@ -498,18 +485,16 @@ class ReadablePath(JoinablePath):
             if not top_down:
                 paths.append((path, dirnames, filenames))
             try:
-                with path._scandir() as entries:
-                    for entry in entries:
-                        name = entry.name
-                        try:
-                            if entry.is_dir(follow_symlinks=follow_symlinks):
-                                if not top_down:
-                                    paths.append(path.joinpath(name))
-                                dirnames.append(name)
-                            else:
-                                filenames.append(name)
-                        except OSError:
-                            filenames.append(name)
+                for child in path.iterdir():
+                    try:
+                        if child.info.is_dir(follow_symlinks=follow_symlinks):
+                            if not top_down:
+                                paths.append(child)
+                            dirnames.append(child.name)
+                        else:
+                            filenames.append(child.name)
+                    except OSError:
+                        filenames.append(child.name)
             except OSError as error:
                 if on_error is not None:
                     on_error(error)
index b3ec934f7510debe3ee056f1aae0cf31213376c9..07d361d7b1352c917ec04d4715f715004ded2f62 100644 (file)
@@ -19,7 +19,7 @@ try:
 except ImportError:
     grp = None
 
-from pathlib._os import copyfile
+from pathlib._os import copyfile, PathInfo, DirEntryInfo
 from pathlib._abc import CopyReader, CopyWriter, JoinablePath, ReadablePath, WritablePath
 
 
@@ -728,13 +728,25 @@ class Path(WritablePath, ReadablePath, PurePath):
     object. You can also instantiate a PosixPath or WindowsPath directly,
     but cannot instantiate a WindowsPath on a POSIX system or vice versa.
     """
-    __slots__ = ()
+    __slots__ = ('_info',)
 
     def __new__(cls, *args, **kwargs):
         if cls is Path:
             cls = WindowsPath if os.name == 'nt' else PosixPath
         return object.__new__(cls)
 
+    @property
+    def info(self):
+        """
+        A PathInfo object that exposes the file type and other file attributes
+        of this path.
+        """
+        try:
+            return self._info
+        except AttributeError:
+            self._info = PathInfo(self)
+            return self._info
+
     def stat(self, *, follow_symlinks=True):
         """
         Return the result of the stat() system call on this path, like
@@ -909,13 +921,11 @@ class Path(WritablePath, ReadablePath, PurePath):
                 path_str = path_str[:-1]
             yield path_str
 
-    def _scandir(self):
-        """Yield os.DirEntry-like objects of the directory contents.
-
-        The children are yielded in arbitrary order, and the
-        special entries '.' and '..' are not included.
-        """
-        return os.scandir(self)
+    def _from_dir_entry(self, dir_entry, path_str):
+        path = self.with_segments(path_str)
+        path._str = path_str
+        path._info = DirEntryInfo(dir_entry)
+        return path
 
     def iterdir(self):
         """Yield path objects of the directory contents.
@@ -925,10 +935,11 @@ class Path(WritablePath, ReadablePath, PurePath):
         """
         root_dir = str(self)
         with os.scandir(root_dir) as scandir_it:
-            paths = [entry.path for entry in scandir_it]
+            entries = list(scandir_it)
         if root_dir == '.':
-            paths = map(self._remove_leading_dot, paths)
-        return map(self._from_parsed_string, paths)
+            return (self._from_dir_entry(e, e.name) for e in entries)
+        else:
+            return (self._from_dir_entry(e, e.path) for e in entries)
 
     def glob(self, pattern, *, case_sensitive=None, recurse_symlinks=False):
         """Iterate over this subtree and yield all existing files (of any
index 57bcaf3d680138797d6327bbb146dc67acaad152..c2febb773cd83a030b0e28712330efe3e48f1c92 100644 (file)
@@ -3,6 +3,7 @@ Low-level OS functionality wrappers used by pathlib.
 """
 
 from errno import *
+from stat import S_ISDIR, S_ISREG, S_ISLNK
 import os
 import sys
 try:
@@ -162,3 +163,162 @@ def copyfileobj(source_f, target_f):
     write_target = target_f.write
     while buf := read_source(1024 * 1024):
         write_target(buf)
+
+
+class _PathInfoBase:
+    __slots__ = ()
+
+    def __repr__(self):
+        path_type = "WindowsPath" if os.name == "nt" else "PosixPath"
+        return f"<{path_type}.info>"
+
+
+class _WindowsPathInfo(_PathInfoBase):
+    """Implementation of pathlib.types.PathInfo that provides status
+    information for Windows paths. Don't try to construct it yourself."""
+    __slots__ = ('_path', '_exists', '_is_dir', '_is_file', '_is_symlink')
+
+    def __init__(self, path):
+        self._path = str(path)
+
+    def exists(self, *, follow_symlinks=True):
+        """Whether this path exists."""
+        if not follow_symlinks and self.is_symlink():
+            return True
+        try:
+            return self._exists
+        except AttributeError:
+            if os.path.exists(self._path):
+                self._exists = True
+                return True
+            else:
+                self._exists = self._is_dir = self._is_file = False
+                return False
+
+    def is_dir(self, *, follow_symlinks=True):
+        """Whether this path is a directory."""
+        if not follow_symlinks and self.is_symlink():
+            return False
+        try:
+            return self._is_dir
+        except AttributeError:
+            if os.path.isdir(self._path):
+                self._is_dir = self._exists = True
+                return True
+            else:
+                self._is_dir = False
+                return False
+
+    def is_file(self, *, follow_symlinks=True):
+        """Whether this path is a regular file."""
+        if not follow_symlinks and self.is_symlink():
+            return False
+        try:
+            return self._is_file
+        except AttributeError:
+            if os.path.isfile(self._path):
+                self._is_file = self._exists = True
+                return True
+            else:
+                self._is_file = False
+                return False
+
+    def is_symlink(self):
+        """Whether this path is a symbolic link."""
+        try:
+            return self._is_symlink
+        except AttributeError:
+            self._is_symlink = os.path.islink(self._path)
+            return self._is_symlink
+
+
+class _PosixPathInfo(_PathInfoBase):
+    """Implementation of pathlib.types.PathInfo that provides status
+    information for POSIX paths. Don't try to construct it yourself."""
+    __slots__ = ('_path', '_mode')
+
+    def __init__(self, path):
+        self._path = str(path)
+        self._mode = [None, None]
+
+    def _get_mode(self, *, follow_symlinks=True):
+        idx = bool(follow_symlinks)
+        mode = self._mode[idx]
+        if mode is None:
+            try:
+                st = os.stat(self._path, follow_symlinks=follow_symlinks)
+            except (OSError, ValueError):
+                mode = 0
+            else:
+                mode = st.st_mode
+            if follow_symlinks or S_ISLNK(mode):
+                self._mode[idx] = mode
+            else:
+                # Not a symlink, so stat() will give the same result
+                self._mode = [mode, mode]
+        return mode
+
+    def exists(self, *, follow_symlinks=True):
+        """Whether this path exists."""
+        return self._get_mode(follow_symlinks=follow_symlinks) > 0
+
+    def is_dir(self, *, follow_symlinks=True):
+        """Whether this path is a directory."""
+        return S_ISDIR(self._get_mode(follow_symlinks=follow_symlinks))
+
+    def is_file(self, *, follow_symlinks=True):
+        """Whether this path is a regular file."""
+        return S_ISREG(self._get_mode(follow_symlinks=follow_symlinks))
+
+    def is_symlink(self):
+        """Whether this path is a symbolic link."""
+        return S_ISLNK(self._get_mode(follow_symlinks=False))
+
+
+PathInfo = _WindowsPathInfo if os.name == 'nt' else _PosixPathInfo
+
+
+class DirEntryInfo(_PathInfoBase):
+    """Implementation of pathlib.types.PathInfo that provides status
+    information by querying a wrapped os.DirEntry object. Don't try to
+    construct it yourself."""
+    __slots__ = ('_entry', '_exists')
+
+    def __init__(self, entry):
+        self._entry = entry
+
+    def exists(self, *, follow_symlinks=True):
+        """Whether this path exists."""
+        if not follow_symlinks:
+            return True
+        try:
+            return self._exists
+        except AttributeError:
+            try:
+                self._entry.stat()
+            except OSError:
+                self._exists = False
+            else:
+                self._exists = True
+            return self._exists
+
+    def is_dir(self, *, follow_symlinks=True):
+        """Whether this path is a directory."""
+        try:
+            return self._entry.is_dir(follow_symlinks=follow_symlinks)
+        except OSError:
+            return False
+
+    def is_file(self, *, follow_symlinks=True):
+        """Whether this path is a regular file."""
+        try:
+            return self._entry.is_file(follow_symlinks=follow_symlinks)
+        except OSError:
+            return False
+
+    def is_symlink(self):
+        """Whether this path is a symbolic link."""
+        try:
+            return self._entry.is_symlink()
+        except OSError:
+            return False
similarity index 57%
rename from Lib/pathlib/_types.py
rename to Lib/pathlib/types.py
index 84032bb5b4ff1ac6636b525a775969af9ce28b2d..b781264796bf67b0398cd052e8e1ebb92a7fe552 100644 (file)
@@ -5,7 +5,7 @@ from typing import Protocol, runtime_checkable
 
 
 @runtime_checkable
-class Parser(Protocol):
+class _PathParser(Protocol):
     """Protocol for path parsers, which do low-level path manipulation.
 
     Path parsers provide a subset of the os.path API, specifically those
@@ -17,3 +17,14 @@ class Parser(Protocol):
     def split(self, path: str) -> tuple[str, str]: ...
     def splitext(self, path: str) -> tuple[str, str]: ...
     def normcase(self, path: str) -> str: ...
+
+
+@runtime_checkable
+class PathInfo(Protocol):
+    """Protocol for path info objects, which support querying the file type.
+    Methods may return cached results.
+    """
+    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: ...
index d64092b710a4d6734a09fbb31aa34ca359d6f59b..31e5306ae60538c546ee93a7680d46994fc22b03 100644 (file)
@@ -2396,6 +2396,19 @@ class PathTest(test_pathlib_abc.DummyRWPathTest, PurePathTest):
         with self.assertRaises(pathlib.UnsupportedOperation):
             q.symlink_to(p)
 
+    @needs_symlinks
+    def test_info_is_symlink_caching(self):
+        p = self.cls(self.base)
+        q = p / 'mylink'
+        self.assertFalse(q.info.is_symlink())
+        q.symlink_to('blah')
+        self.assertFalse(q.info.is_symlink())
+
+        q = p / 'mylink'  # same path, new instance.
+        self.assertTrue(q.info.is_symlink())
+        q.unlink()
+        self.assertTrue(q.info.is_symlink())
+
     def test_stat(self):
         statA = self.cls(self.base).joinpath('fileA').stat()
         statB = self.cls(self.base).joinpath('dirB', 'fileB').stat()
index e67bead429782937a3e035decb9f8e33a2493237..696874273a21fdf1f57dda83988c2ad576912d27 100644 (file)
@@ -5,7 +5,7 @@ import errno
 import unittest
 
 from pathlib._abc import JoinablePath, ReadablePath, WritablePath, magic_open
-from pathlib._types import Parser
+from pathlib.types import _PathParser, PathInfo
 import posixpath
 
 from test.support.os_helper import TESTFN
@@ -95,7 +95,7 @@ class DummyJoinablePathTest(unittest.TestCase):
         self.altsep = self.parser.altsep
 
     def test_parser(self):
-        self.assertIsInstance(self.cls.parser, Parser)
+        self.assertIsInstance(self.cls.parser, _PathParser)
 
     def test_constructor_common(self):
         P = self.cls
@@ -849,28 +849,49 @@ class DummyWritablePathIO(io.BytesIO):
         super().close()
 
 
-class DummyReadablePath(ReadablePath, DummyJoinablePath):
-    """
-    Simple implementation of DummyReadablePath that keeps files and
-    directories in memory.
-    """
-    __slots__ = ()
+class DummyReadablePathInfo:
+    __slots__ = ('_is_dir', '_is_file')
 
-    _files = {}
-    _directories = {}
+    def __init__(self, is_dir, is_file):
+        self._is_dir = is_dir
+        self._is_file = is_file
 
     def exists(self, *, follow_symlinks=True):
-        return self.is_dir() or self.is_file()
+        return self._is_dir or self._is_file
 
     def is_dir(self, *, follow_symlinks=True):
-        return str(self).rstrip('/') in self._directories
+        return self._is_dir
 
     def is_file(self, *, follow_symlinks=True):
-        return str(self) in self._files
+        return self._is_file
 
     def is_symlink(self):
         return False
 
+
+class DummyReadablePath(ReadablePath, DummyJoinablePath):
+    """
+    Simple implementation of DummyReadablePath that keeps files and
+    directories in memory.
+    """
+    __slots__ = ('_info')
+
+    _files = {}
+    _directories = {}
+
+    def __init__(self, *segments):
+        super().__init__(*segments)
+        self._info = None
+
+    @property
+    def info(self):
+        if self._info is None:
+            path_str = str(self)
+            self._info = DummyReadablePathInfo(
+                is_dir=path_str.rstrip('/') in self._directories,
+                is_file=path_str in self._files)
+        return self._info
+
     def __open_rb__(self, buffering=-1):
         path = str(self)
         if path in self._directories:
@@ -1037,21 +1058,20 @@ class DummyReadablePathTest(DummyJoinablePathTest):
         self.assertIn(cm.exception.errno, (errno.ENOTDIR,
                                            errno.ENOENT, errno.EINVAL))
 
-    def test_scandir(self):
+    def test_iterdir_info(self):
         p = self.cls(self.base)
-        with p._scandir() as entries:
-            self.assertTrue(list(entries))
-        with p._scandir() as entries:
-            for entry in entries:
-                child = p / entry.name
-                self.assertIsNotNone(entry)
-                self.assertEqual(entry.name, child.name)
-                self.assertEqual(entry.is_symlink(),
-                                 child.is_symlink())
-                self.assertEqual(entry.is_dir(follow_symlinks=False),
-                                 child.is_dir(follow_symlinks=False))
-                if entry.name != 'brokenLinkLoop':
-                    self.assertEqual(entry.is_dir(), child.is_dir())
+        for child in p.iterdir():
+            info = child.info
+            self.assertIsInstance(info, PathInfo)
+            self.assertEqual(info.exists(), child.exists())
+            self.assertEqual(info.is_dir(), child.is_dir())
+            self.assertEqual(info.is_file(), child.is_file())
+            self.assertEqual(info.is_symlink(), child.is_symlink())
+            self.assertTrue(info.exists(follow_symlinks=False))
+            self.assertEqual(info.is_dir(follow_symlinks=False),
+                             child.is_dir(follow_symlinks=False))
+            self.assertEqual(info.is_file(follow_symlinks=False),
+                             child.is_file(follow_symlinks=False))
 
     def test_glob_common(self):
         def _check(glob, expected):
@@ -1177,6 +1197,118 @@ class DummyReadablePathTest(DummyJoinablePathTest):
         self.assertEqual(set(p.rglob("FILEd")), { P(self.base, "dirC/dirD/fileD") })
         self.assertEqual(set(p.rglob("*\\")), { P(self.base, "dirC/dirD/") })
 
+    def test_info_exists(self):
+        p = self.cls(self.base)
+        self.assertTrue(p.info.exists())
+        self.assertTrue((p / 'dirA').info.exists())
+        self.assertTrue((p / 'dirA').info.exists(follow_symlinks=False))
+        self.assertTrue((p / 'fileA').info.exists())
+        self.assertTrue((p / 'fileA').info.exists(follow_symlinks=False))
+        self.assertFalse((p / 'non-existing').info.exists())
+        self.assertFalse((p / 'non-existing').info.exists(follow_symlinks=False))
+        if self.can_symlink:
+            self.assertTrue((p / 'linkA').info.exists())
+            self.assertTrue((p / 'linkA').info.exists(follow_symlinks=False))
+            self.assertTrue((p / 'linkB').info.exists())
+            self.assertTrue((p / 'linkB').info.exists(follow_symlinks=True))
+            self.assertFalse((p / 'brokenLink').info.exists())
+            self.assertTrue((p / 'brokenLink').info.exists(follow_symlinks=False))
+            self.assertFalse((p / 'brokenLinkLoop').info.exists())
+            self.assertTrue((p / 'brokenLinkLoop').info.exists(follow_symlinks=False))
+        self.assertFalse((p / 'fileA\udfff').info.exists())
+        self.assertFalse((p / 'fileA\udfff').info.exists(follow_symlinks=False))
+        self.assertFalse((p / 'fileA\x00').info.exists())
+        self.assertFalse((p / 'fileA\x00').info.exists(follow_symlinks=False))
+
+    def test_info_exists_caching(self):
+        p = self.cls(self.base)
+        q = p / 'myfile'
+        self.assertFalse(q.info.exists())
+        self.assertFalse(q.info.exists(follow_symlinks=False))
+        if isinstance(self.cls, WritablePath):
+            q.write_text('hullo')
+            self.assertFalse(q.info.exists())
+            self.assertFalse(q.info.exists(follow_symlinks=False))
+
+    def test_info_is_dir(self):
+        p = self.cls(self.base)
+        self.assertTrue((p / 'dirA').info.is_dir())
+        self.assertTrue((p / 'dirA').info.is_dir(follow_symlinks=False))
+        self.assertFalse((p / 'fileA').info.is_dir())
+        self.assertFalse((p / 'fileA').info.is_dir(follow_symlinks=False))
+        self.assertFalse((p / 'non-existing').info.is_dir())
+        self.assertFalse((p / 'non-existing').info.is_dir(follow_symlinks=False))
+        if self.can_symlink:
+            self.assertFalse((p / 'linkA').info.is_dir())
+            self.assertFalse((p / 'linkA').info.is_dir(follow_symlinks=False))
+            self.assertTrue((p / 'linkB').info.is_dir())
+            self.assertFalse((p / 'linkB').info.is_dir(follow_symlinks=False))
+            self.assertFalse((p / 'brokenLink').info.is_dir())
+            self.assertFalse((p / 'brokenLink').info.is_dir(follow_symlinks=False))
+            self.assertFalse((p / 'brokenLinkLoop').info.is_dir())
+            self.assertFalse((p / 'brokenLinkLoop').info.is_dir(follow_symlinks=False))
+        self.assertFalse((p / 'dirA\udfff').info.is_dir())
+        self.assertFalse((p / 'dirA\udfff').info.is_dir(follow_symlinks=False))
+        self.assertFalse((p / 'dirA\x00').info.is_dir())
+        self.assertFalse((p / 'dirA\x00').info.is_dir(follow_symlinks=False))
+
+    def test_info_is_dir_caching(self):
+        p = self.cls(self.base)
+        q = p / 'mydir'
+        self.assertFalse(q.info.is_dir())
+        self.assertFalse(q.info.is_dir(follow_symlinks=False))
+        if isinstance(self.cls, WritablePath):
+            q.mkdir()
+            self.assertFalse(q.info.is_dir())
+            self.assertFalse(q.info.is_dir(follow_symlinks=False))
+
+    def test_info_is_file(self):
+        p = self.cls(self.base)
+        self.assertTrue((p / 'fileA').info.is_file())
+        self.assertTrue((p / 'fileA').info.is_file(follow_symlinks=False))
+        self.assertFalse((p / 'dirA').info.is_file())
+        self.assertFalse((p / 'dirA').info.is_file(follow_symlinks=False))
+        self.assertFalse((p / 'non-existing').info.is_file())
+        self.assertFalse((p / 'non-existing').info.is_file(follow_symlinks=False))
+        if self.can_symlink:
+            self.assertTrue((p / 'linkA').info.is_file())
+            self.assertFalse((p / 'linkA').info.is_file(follow_symlinks=False))
+            self.assertFalse((p / 'linkB').info.is_file())
+            self.assertFalse((p / 'linkB').info.is_file(follow_symlinks=False))
+            self.assertFalse((p / 'brokenLink').info.is_file())
+            self.assertFalse((p / 'brokenLink').info.is_file(follow_symlinks=False))
+            self.assertFalse((p / 'brokenLinkLoop').info.is_file())
+            self.assertFalse((p / 'brokenLinkLoop').info.is_file(follow_symlinks=False))
+        self.assertFalse((p / 'fileA\udfff').info.is_file())
+        self.assertFalse((p / 'fileA\udfff').info.is_file(follow_symlinks=False))
+        self.assertFalse((p / 'fileA\x00').info.is_file())
+        self.assertFalse((p / 'fileA\x00').info.is_file(follow_symlinks=False))
+
+    def test_info_is_file_caching(self):
+        p = self.cls(self.base)
+        q = p / 'myfile'
+        self.assertFalse(q.info.is_file())
+        self.assertFalse(q.info.is_file(follow_symlinks=False))
+        if isinstance(self.cls, WritablePath):
+            q.write_text('hullo')
+            self.assertFalse(q.info.is_file())
+            self.assertFalse(q.info.is_file(follow_symlinks=False))
+
+    def test_info_is_symlink(self):
+        p = self.cls(self.base)
+        self.assertFalse((p / 'fileA').info.is_symlink())
+        self.assertFalse((p / 'dirA').info.is_symlink())
+        self.assertFalse((p / 'non-existing').info.is_symlink())
+        if self.can_symlink:
+            self.assertTrue((p / 'linkA').info.is_symlink())
+            self.assertTrue((p / 'linkB').info.is_symlink())
+            self.assertTrue((p / 'brokenLink').info.is_symlink())
+            self.assertFalse((p / 'linkA\udfff').info.is_symlink())
+            self.assertFalse((p / 'linkA\x00').info.is_symlink())
+            self.assertTrue((p / 'brokenLinkLoop').info.is_symlink())
+        self.assertFalse((p / 'fileA\udfff').info.is_symlink())
+        self.assertFalse((p / 'fileA\x00').info.is_symlink())
+
     def test_is_dir(self):
         P = self.cls(self.base)
         self.assertTrue((P / 'dirA').is_dir())
diff --git a/Misc/NEWS.d/next/Library/2024-12-10-19-39-35.gh-issue-125413.wOb4yr.rst b/Misc/NEWS.d/next/Library/2024-12-10-19-39-35.gh-issue-125413.wOb4yr.rst
new file mode 100644 (file)
index 0000000..9ac9617
--- /dev/null
@@ -0,0 +1,6 @@
+Add :attr:`pathlib.Path.info` attribute, which stores an object
+implementing the :class:`pathlib.types.PathInfo` protocol (also new). The
+object supports querying the file type and internally caching
+:func:`~os.stat` results. Path objects generated by
+:meth:`~pathlib.Path.iterdir` are initialized with file type information
+gleaned from scanning the parent directory.