https://github.com/python/cpython/commit/718ab662991214039626db432d60310e0e19a0ac
commit: 718ab662991214039626db432d60310e0e19a0ac
branch: main
author: Barney Gale <[email protected]>
committer: barneygale <[email protected]>
date: 2025-02-08T01:16:45Z
summary:

GH-125413: Add `pathlib.Path.info` attribute (#127730)

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()`.

files:
A Lib/pathlib/types.py
A Misc/NEWS.d/next/Library/2024-12-10-19-39-35.gh-issue-125413.wOb4yr.rst
D Lib/pathlib/_types.py
M Doc/library/pathlib.rst
M Doc/whatsnew/3.14.rst
M Lib/glob.py
M Lib/pathlib/_abc.py
M Lib/pathlib/_local.py
M Lib/pathlib/_os.py
M Lib/test/test_pathlib/test_pathlib.py
M Lib/test/test_pathlib/test_pathlib_abc.py

diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst
index 4b48880d6d9a18..8977ccfe6e4124 100644
--- a/Doc/library/pathlib.rst
+++ b/Doc/library/pathlib.rst
@@ -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.
diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst
index 5cef8999944005..0f119d10819d26 100644
--- a/Doc/whatsnew/3.14.rst
+++ b/Doc/whatsnew/3.14.rst
@@ -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
 ---
diff --git a/Lib/glob.py b/Lib/glob.py
index 690ab1b8b9fb1d..a834ea7f7ce556 100644
--- a/Lib/glob.py
+++ b/Lib/glob.py
@@ -348,7 +348,7 @@ def lexists(path):
 
     @staticmethod
     def scandir(path):
-        """Implements os.scandir().
+        """Like os.scandir(), but generates (entry, name, path) tuples.
         """
         raise NotImplementedError
 
@@ -425,23 +425,18 @@ def wildcard_selector(self, part, parts):
 
         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 @@ def select_recursive(path, exists=False):
         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 @@ def select_recursive_step(stack, match_pos):
                         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 @@ def add_slash(pathname):
             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)
diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py
index e498dc78e83b5e..d20f04fc5b6dc3 100644
--- a/Lib/pathlib/_abc.py
+++ b/Lib/pathlib/_abc.py
@@ -13,10 +13,9 @@
 
 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 @@ def full_match(self, pattern, *, case_sensitive=None):
             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 @@ def exists(self, *, follow_symlinks=True):
         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 @@ def read_text(self, encoding=None, errors=None, 
newline=None):
         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 @@ def glob(self, pattern, *, case_sensitive=None, 
recurse_symlinks=True):
         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 @@ def walk(self, top_down=True, on_error=None, 
follow_symlinks=False):
             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)
diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py
index b3ec934f7510de..07d361d7b1352c 100644
--- a/Lib/pathlib/_local.py
+++ b/Lib/pathlib/_local.py
@@ -19,7 +19,7 @@
 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 @@ def _filter_trailing_slash(self, paths):
                 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 @@ def iterdir(self):
         """
         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
diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py
index 57bcaf3d680138..c2febb773cd83a 100644
--- a/Lib/pathlib/_os.py
+++ b/Lib/pathlib/_os.py
@@ -3,6 +3,7 @@
 """
 
 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
diff --git a/Lib/pathlib/_types.py b/Lib/pathlib/types.py
similarity index 57%
rename from Lib/pathlib/_types.py
rename to Lib/pathlib/types.py
index 84032bb5b4ff1a..b781264796bf67 100644
--- a/Lib/pathlib/_types.py
+++ b/Lib/pathlib/types.py
@@ -5,7 +5,7 @@
 
 
 @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: ...
diff --git a/Lib/test/test_pathlib/test_pathlib.py 
b/Lib/test/test_pathlib/test_pathlib.py
index d64092b710a4d6..31e5306ae60538 100644
--- a/Lib/test/test_pathlib/test_pathlib.py
+++ b/Lib/test/test_pathlib/test_pathlib.py
@@ -2396,6 +2396,19 @@ def test_symlink_to_unsupported(self):
         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()
diff --git a/Lib/test/test_pathlib/test_pathlib_abc.py 
b/Lib/test/test_pathlib/test_pathlib_abc.py
index e67bead4297829..696874273a21fd 100644
--- a/Lib/test/test_pathlib/test_pathlib_abc.py
+++ b/Lib/test/test_pathlib/test_pathlib_abc.py
@@ -5,7 +5,7 @@
 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 @@ def setUp(self):
         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 @@ def close(self):
         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 @@ def test_iterdir_nodir(self):
         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 @@ def test_rglob_windows(self):
         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
index 00000000000000..9ac96179a88367
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2024-12-10-19-39-35.gh-issue-125413.wOb4yr.rst
@@ -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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: [email protected]

Reply via email to