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 ` 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 72abfebd46f2b9..832e05e34759db 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -568,6 +568,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 38bc660e0aeb30..0258a93724fca6 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -12,10 +12,9 @@ """ import functools -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 @@ -41,21 +40,6 @@ def _explode_path(path): return path, names -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 copying between path objects. An instance of this @@ -355,7 +339,7 @@ def match(self, path_pattern, *, case_sensitive=None): return False if len(path_parts) > len(pattern_parts) and path_pattern.anchor: return False - globber = PathGlobber(sep, case_sensitive) + globber = _PathGlobber(sep, case_sensitive) for path_part, pattern_part in zip(path_parts, pattern_parts): match = globber.compile(pattern_part) if match(path_part) is None: @@ -371,7 +355,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 @@ -392,6 +376,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. @@ -399,26 +391,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(self, mode='r', buffering=-1, encoding=None, errors=None, newline=None): @@ -442,15 +438,6 @@ def read_text(self, encoding=None, errors=None, newline=None): with self.open(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. @@ -476,7 +463,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) @@ -503,18 +490,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 d6afb31424265c..7b783f80e674ed 100644 --- a/Lib/pathlib/_local.py +++ b/Lib/pathlib/_local.py @@ -7,7 +7,7 @@ from errno import * from glob import _StringGlobber, _no_recurse_symlinks from itertools import chain -from stat import S_IMODE, S_ISDIR, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO +from stat import S_IMODE, S_ISDIR, S_ISREG, S_ISLNK, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO from _collections_abc import Sequence try: @@ -65,6 +65,165 @@ def __repr__(self): return "<{}.parents>".format(type(self._path).__name__) +class _PathInfoBase: + __slots__ = () + + def __repr__(self): + path_type = "WindowsPath" if os.name == "nt" else "PosixPath" + return f"<{path_type}.info>" + + +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 + + +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 _LocalCopyWriter(CopyWriter): """This object implements the Path.copy callable. Don't try to construct it yourself.""" @@ -692,13 +851,25 @@ class Path(WritablePath, 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 @@ -852,13 +1023,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. @@ -868,10 +1037,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/_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 ad5a9f9c8de9d6..2758f0a0c42668 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -2309,6 +2309,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 6ba012e0208a53..aca8b3e2b0e3b8 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 -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 @@ -931,18 +931,39 @@ def close(self): super().close() +class DummyPathInfo: + __slots__ = ('_is_dir', '_is_file') + + 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 + + def is_dir(self, *, follow_symlinks=True): + return self._is_dir + + def is_file(self, *, follow_symlinks=True): + return self._is_file + + def is_symlink(self): + return False + + class DummyReadablePath(ReadablePath): """ Simple implementation of DummyReadablePath that keeps files and directories in memory. """ - __slots__ = ('_segments') + __slots__ = ('_segments', '_info') _files = {} _directories = {} def __init__(self, *segments): self._segments = segments + self._info = None def __str__(self): if self._segments: @@ -963,17 +984,14 @@ def __repr__(self): def with_segments(self, *pathsegments): return type(self)(*pathsegments) - def exists(self, *, follow_symlinks=True): - return self.is_dir() or self.is_file() - - def is_dir(self, *, follow_symlinks=True): - return str(self).rstrip('/') in self._directories - - def is_file(self, *, follow_symlinks=True): - return str(self) in self._files - - def is_symlink(self): - return False + @property + def info(self): + if self._info is None: + path_str = str(self) + self._info = DummyPathInfo( + is_dir=path_str.rstrip('/') in self._directories, + is_file=path_str in self._files) + return self._info def open(self, mode='r', buffering=-1, encoding=None, errors=None, newline=None): @@ -1150,21 +1168,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): @@ -1290,6 +1307,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.