From 1fb81f53f6a3c566d91eb91b7662859755e39e1d Mon Sep 17 00:00:00 2001 From: barneygale Date: Sat, 8 Feb 2025 06:20:51 +0000 Subject: [PATCH] GH-128520: Read path metadata from `pathlib.types.PathInfo` when copying Add private `PathInfo._stat()` and `_xattrs()` methods, which are called when copying metadata to a local path. This removes all need for the `CopyReader` and `_LocalCopyReader` classes, so we delete them. The `CopyWriter` and `_LocalCopyWriter` classes are moved into `pathlib._os`, renamed to `Copier` and `LocalCopier`, and refactored so that only one copier object is created per copy operation. This internal refactor shouldn't have any user-facing impact. --- Lib/pathlib/_abc.py | 175 ++---------------------- Lib/pathlib/_local.py | 148 +------------------- Lib/pathlib/_os.py | 311 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 271 insertions(+), 363 deletions(-) diff --git a/Lib/pathlib/_abc.py b/Lib/pathlib/_abc.py index d20f04fc5b6dc3..7742e8f6804833 100644 --- a/Lib/pathlib/_abc.py +++ b/Lib/pathlib/_abc.py @@ -12,11 +12,9 @@ """ import functools -import io import posixpath -from errno import EINVAL from glob import _PathGlobber, _no_recurse_symlinks -from pathlib._os import copyfileobj +from pathlib._os import Copier, magic_open @functools.cache @@ -41,162 +39,6 @@ def _explode_path(path): return path, names -def magic_open(path, mode='r', buffering=-1, encoding=None, errors=None, - newline=None): - """ - Open the file pointed to by this path and return a file object, as - the built-in open() function does. - """ - try: - return io.open(path, mode, buffering, encoding, errors, newline) - except TypeError: - pass - cls = type(path) - text = 'b' not in mode - mode = ''.join(sorted(c for c in mode if c not in 'bt')) - if text: - try: - attr = getattr(cls, f'__open_{mode}__') - except AttributeError: - pass - else: - return attr(path, buffering, encoding, errors, newline) - - try: - attr = getattr(cls, f'__open_{mode}b__') - except AttributeError: - pass - else: - stream = attr(path, buffering) - if text: - stream = io.TextIOWrapper(stream, encoding, errors, newline) - return stream - - raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}") - - -class CopyReader: - """ - Class that implements the "read" part of copying between path objects. - An instance of this class is available from the ReadablePath._copy_reader - property. - """ - __slots__ = ('_path',) - - def __init__(self, path): - self._path = path - - _readable_metakeys = frozenset() - - def _read_metadata(self, metakeys, *, follow_symlinks=True): - """ - Returns path metadata as a dict with string keys. - """ - raise NotImplementedError - - -class CopyWriter: - """ - Class that implements the "write" part of copying between path objects. An - instance of this class is available from the WritablePath._copy_writer - property. - """ - __slots__ = ('_path',) - - def __init__(self, path): - self._path = path - - _writable_metakeys = frozenset() - - def _write_metadata(self, metadata, *, follow_symlinks=True): - """ - Sets path metadata from the given dict with string keys. - """ - raise NotImplementedError - - def _create(self, source, follow_symlinks, dirs_exist_ok, preserve_metadata): - self._ensure_distinct_path(source) - if preserve_metadata: - metakeys = self._writable_metakeys & source._copy_reader._readable_metakeys - else: - metakeys = None - if not follow_symlinks and source.is_symlink(): - self._create_symlink(source, metakeys) - elif source.is_dir(): - self._create_dir(source, metakeys, follow_symlinks, dirs_exist_ok) - else: - self._create_file(source, metakeys) - return self._path - - def _create_dir(self, source, metakeys, follow_symlinks, dirs_exist_ok): - """Copy the given directory to our path.""" - children = list(source.iterdir()) - self._path.mkdir(exist_ok=dirs_exist_ok) - for src in children: - dst = self._path.joinpath(src.name) - if not follow_symlinks and src.is_symlink(): - dst._copy_writer._create_symlink(src, metakeys) - elif src.is_dir(): - dst._copy_writer._create_dir(src, metakeys, follow_symlinks, dirs_exist_ok) - else: - dst._copy_writer._create_file(src, metakeys) - if metakeys: - metadata = source._copy_reader._read_metadata(metakeys) - if metadata: - self._write_metadata(metadata) - - def _create_file(self, source, metakeys): - """Copy the given file to our path.""" - self._ensure_different_file(source) - with magic_open(source, 'rb') as source_f: - try: - with magic_open(self._path, 'wb') as target_f: - copyfileobj(source_f, target_f) - except IsADirectoryError as e: - if not self._path.exists(): - # Raise a less confusing exception. - raise FileNotFoundError( - f'Directory does not exist: {self._path}') from e - raise - if metakeys: - metadata = source._copy_reader._read_metadata(metakeys) - if metadata: - self._write_metadata(metadata) - - def _create_symlink(self, source, metakeys): - """Copy the given symbolic link to our path.""" - self._path.symlink_to(source.readlink()) - if metakeys: - metadata = source._copy_reader._read_metadata(metakeys, follow_symlinks=False) - if metadata: - self._write_metadata(metadata, follow_symlinks=False) - - def _ensure_different_file(self, source): - """ - Raise OSError(EINVAL) if both paths refer to the same file. - """ - pass - - def _ensure_distinct_path(self, source): - """ - Raise OSError(EINVAL) if the other path is within this path. - """ - # Note: there is no straightforward, foolproof algorithm to determine - # if one directory is within another (a particularly perverse example - # would be a single network share mounted in one location via NFS, and - # in another location via CIFS), so we simply checks whether the - # other path is lexically equal to, or within, this path. - if source == self._path: - err = OSError(EINVAL, "Source and target are the same path") - elif source in self._path.parents: - err = OSError(EINVAL, "Source path is a parent of target path") - else: - return - err.filename = str(source) - err.filename2 = str(self._path) - raise err - - class JoinablePath: """Base class for pure path objects. @@ -512,22 +354,21 @@ def readlink(self): """ raise NotImplementedError - _copy_reader = property(CopyReader) - def copy(self, target, follow_symlinks=True, dirs_exist_ok=False, preserve_metadata=False): """ Recursively copy this file or directory tree to the given destination. """ - if not hasattr(target, '_copy_writer'): + if not hasattr(target, '_copier'): target = self.with_segments(target) - # Delegate to the target path's CopyWriter object. + # Delegate to the target path's copier. try: - create = target._copy_writer._create + copier = target._copier except AttributeError: raise TypeError(f"Target is not writable: {target}") from None - return create(self, follow_symlinks, dirs_exist_ok, preserve_metadata) + copier.ensure_distinct_path(self, target) + return copier(preserve_metadata, follow_symlinks, dirs_exist_ok).copy(self, target) def copy_into(self, target_dir, *, follow_symlinks=True, dirs_exist_ok=False, preserve_metadata=False): @@ -537,7 +378,7 @@ def copy_into(self, target_dir, *, follow_symlinks=True, name = self.name if not name: raise ValueError(f"{self!r} has an empty name") - elif hasattr(target_dir, '_copy_writer'): + elif hasattr(target_dir, '_copier'): target = target_dir / name else: target = self.with_segments(target_dir, name) @@ -588,4 +429,4 @@ def write_text(self, data, encoding=None, errors=None, newline=None): with magic_open(self, mode='w', encoding=encoding, errors=errors, newline=newline) as f: return f.write(data) - _copy_writer = property(CopyWriter) + _copier = Copier diff --git a/Lib/pathlib/_local.py b/Lib/pathlib/_local.py index 07d361d7b1352c..fa04bcbebb8988 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_ISDIR, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO from _collections_abc import Sequence try: @@ -19,8 +19,8 @@ except ImportError: grp = None -from pathlib._os import copyfile, PathInfo, DirEntryInfo -from pathlib._abc import CopyReader, CopyWriter, JoinablePath, ReadablePath, WritablePath +from pathlib._os import PathInfo, DirEntryInfo, LocalCopier +from pathlib._abc import JoinablePath, ReadablePath, WritablePath __all__ = [ @@ -65,141 +65,6 @@ def __repr__(self): return "<{}.parents>".format(type(self._path).__name__) -class _LocalCopyReader(CopyReader): - """This object implements the "read" part of copying local paths. Don't - try to construct it yourself. - """ - __slots__ = () - - _readable_metakeys = {'mode', 'times_ns'} - if hasattr(os.stat_result, 'st_flags'): - _readable_metakeys.add('flags') - if hasattr(os, 'listxattr'): - _readable_metakeys.add('xattrs') - _readable_metakeys = frozenset(_readable_metakeys) - - def _read_metadata(self, metakeys, *, follow_symlinks=True): - metadata = {} - if 'mode' in metakeys or 'times_ns' in metakeys or 'flags' in metakeys: - st = self._path.stat(follow_symlinks=follow_symlinks) - if 'mode' in metakeys: - metadata['mode'] = S_IMODE(st.st_mode) - if 'times_ns' in metakeys: - metadata['times_ns'] = st.st_atime_ns, st.st_mtime_ns - if 'flags' in metakeys: - metadata['flags'] = st.st_flags - if 'xattrs' in metakeys: - try: - metadata['xattrs'] = [ - (attr, os.getxattr(self._path, attr, follow_symlinks=follow_symlinks)) - for attr in os.listxattr(self._path, follow_symlinks=follow_symlinks)] - except OSError as err: - if err.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): - raise - return metadata - - -class _LocalCopyWriter(CopyWriter): - """This object implements the "write" part of copying local paths. Don't - try to construct it yourself. - """ - __slots__ = () - - _writable_metakeys = _LocalCopyReader._readable_metakeys - - def _write_metadata(self, metadata, *, follow_symlinks=True): - def _nop(*args, ns=None, follow_symlinks=None): - pass - - if follow_symlinks: - # use the real function if it exists - def lookup(name): - return getattr(os, name, _nop) - else: - # use the real function only if it exists - # *and* it supports follow_symlinks - def lookup(name): - fn = getattr(os, name, _nop) - if fn in os.supports_follow_symlinks: - return fn - return _nop - - times_ns = metadata.get('times_ns') - if times_ns is not None: - lookup("utime")(self._path, ns=times_ns, follow_symlinks=follow_symlinks) - # We must copy extended attributes before the file is (potentially) - # chmod()'ed read-only, otherwise setxattr() will error with -EACCES. - xattrs = metadata.get('xattrs') - if xattrs is not None: - for attr, value in xattrs: - try: - os.setxattr(self._path, attr, value, follow_symlinks=follow_symlinks) - except OSError as e: - if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): - raise - mode = metadata.get('mode') - if mode is not None: - try: - lookup("chmod")(self._path, mode, follow_symlinks=follow_symlinks) - except NotImplementedError: - # if we got a NotImplementedError, it's because - # * follow_symlinks=False, - # * lchown() is unavailable, and - # * either - # * fchownat() is unavailable or - # * fchownat() doesn't implement AT_SYMLINK_NOFOLLOW. - # (it returned ENOSUP.) - # therefore we're out of options--we simply cannot chown the - # symlink. give up, suppress the error. - # (which is what shutil always did in this circumstance.) - pass - flags = metadata.get('flags') - if flags is not None: - try: - lookup("chflags")(self._path, flags, follow_symlinks=follow_symlinks) - except OSError as why: - if why.errno not in (EOPNOTSUPP, ENOTSUP): - raise - - if copyfile: - # Use fast OS routine for local file copying where available. - def _create_file(self, source, metakeys): - """Copy the given file to the given target.""" - try: - source = os.fspath(source) - except TypeError: - if not isinstance(source, WritablePath): - raise - super()._create_file(source, metakeys) - else: - copyfile(source, os.fspath(self._path)) - - if os.name == 'nt': - # Windows: symlink target might not exist yet if we're copying several - # files, so ensure we pass is_dir to os.symlink(). - def _create_symlink(self, source, metakeys): - """Copy the given symlink to the given target.""" - self._path.symlink_to(source.readlink(), source.is_dir()) - if metakeys: - metadata = source._copy_reader._read_metadata(metakeys, follow_symlinks=False) - if metadata: - self._write_metadata(metadata, follow_symlinks=False) - - def _ensure_different_file(self, source): - """ - Raise OSError(EINVAL) if both paths refer to the same file. - """ - try: - if not self._path.samefile(source): - return - except (OSError, ValueError): - return - err = OSError(EINVAL, "Source and target are the same file") - err.filename = str(source) - err.filename2 = str(self._path) - raise err - - class PurePath(JoinablePath): """Base class for manipulating paths without I/O. @@ -1190,8 +1055,7 @@ def replace(self, target): os.replace(self, target) return self.with_segments(target) - _copy_reader = property(_LocalCopyReader) - _copy_writer = property(_LocalCopyWriter) + _copier = LocalCopier def move(self, target): """ @@ -1203,9 +1067,9 @@ def move(self, target): except TypeError: pass else: - if not hasattr(target, '_copy_writer'): + if not hasattr(target, '_copier'): target = self.with_segments(target_str) - target._copy_writer._ensure_different_file(self) + target._copier.ensure_different_file(self, target) try: os.replace(self, target_str) return target diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index c2febb773cd83a..b43c1d107108f2 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -3,7 +3,8 @@ """ from errno import * -from stat import S_ISDIR, S_ISREG, S_ISLNK +from stat import S_IMODE, S_ISLNK +import io import os import sys try: @@ -165,21 +166,237 @@ def copyfileobj(source_f, target_f): write_target(buf) -class _PathInfoBase: - __slots__ = () +def magic_open(path, mode='r', buffering=-1, encoding=None, errors=None, + newline=None): + """ + Open the file pointed to by this path and return a file object, as + the built-in open() function does. + """ + try: + return io.open(path, mode, buffering, encoding, errors, newline) + except TypeError: + pass + cls = type(path) + text = 'b' not in mode + mode = ''.join(sorted(c for c in mode if c not in 'bt')) + if text: + try: + attr = getattr(cls, f'__open_{mode}__') + except AttributeError: + pass + else: + return attr(path, buffering, encoding, errors, newline) - def __repr__(self): - path_type = "WindowsPath" if os.name == "nt" else "PosixPath" - return f"<{path_type}.info>" + try: + attr = getattr(cls, f'__open_{mode}b__') + except AttributeError: + pass + else: + stream = attr(path, buffering) + if text: + stream = io.TextIOWrapper(stream, encoding, errors, newline) + return stream + raise TypeError(f"{cls.__name__} can't be opened with mode {mode!r}") -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') + +class Copier: + """ + Class that implements the "write" part of copying between path objects. An + instance of this class is available from the WritablePath._copy_writer + property. + """ + + def __init__(self, preserve_metadata, follow_symlinks, dirs_exist_ok): + self.preserve_metadata = preserve_metadata + self.follow_symlinks = follow_symlinks + self.dirs_exist_ok = dirs_exist_ok + + def copy(self, source, target): + if not self.follow_symlinks and source.info.is_symlink(): + self.copy_symlink(source, target) + elif source.info.is_dir(): + self.copy_dir(source, target) + else: + self.copy_file(source, target) + return target + + def copy_dir(self, source, target): + """Copy the given directory to our path.""" + children = list(source.iterdir()) + target.mkdir(exist_ok=self.dirs_exist_ok) + for src in children: + self.copy(src, target.joinpath(src.name)) + if self.preserve_metadata: + self.copy_metadata(source, target) + + def copy_file(self, source, target): + """Copy the given file to our path.""" + self.ensure_different_file(source, target) + with magic_open(source, 'rb') as source_f: + try: + with magic_open(target, 'wb') as target_f: + copyfileobj(source_f, target_f) + except IsADirectoryError as e: + if not target.exists(): + # Raise a less confusing exception. + raise FileNotFoundError( + f'Directory does not exist: {target}') from e + raise + if self.preserve_metadata: + self.copy_metadata(source, target) + + def copy_symlink(self, source, target): + """Copy the given symbolic link to our path.""" + target.symlink_to(source.readlink()) + if self.preserve_metadata: + self.copy_symlink_metadata(source, target) + + def copy_metadata(self, source, target): + pass + + def copy_symlink_metadata(self, source, target): + pass + + @classmethod + def ensure_different_file(cls, source, target): + """ + Raise OSError(EINVAL) if both paths refer to the same file. + """ + pass + + @classmethod + def ensure_distinct_path(cls, source, target): + """ + Raise OSError(EINVAL) if the other path is within this path. + """ + # Note: there is no straightforward, foolproof algorithm to determine + # if one directory is within another (a particularly perverse example + # would be a single network share mounted in one location via NFS, and + # in another location via CIFS), so we simply checks whether the + # other path is lexically equal to, or within, this path. + if source == target: + err = OSError(EINVAL, "Source and target are the same path") + elif source in target.parents: + err = OSError(EINVAL, "Source path is a parent of target path") + else: + return + err.filename = str(source) + err.filename2 = str(target) + raise err + + +class LocalCopier(Copier): + """This object implements the "write" part of copying local paths. Don't + try to construct it yourself. + """ + + if copyfile: + # Use fast OS routine for local file copying where available. + def copy_file(self, source, target): + """Copy the given file to the given target.""" + try: + source = os.fspath(source) + except TypeError: + super().copy_file(source, target) + else: + copyfile(source, os.fspath(target)) + + if os.name == 'nt': + # Windows: symlink target might not exist yet if we're copying several + # files, so ensure we pass is_dir to os.symlink(). + def copy_symlink(self, source, target): + """Copy the given symlink to the given target.""" + target.symlink_to(source.readlink(), source.info.is_dir()) + if self.preserve_metadata: + self.copy_symlink_metadata(source, target) + + def copy_metadata(self, source, target): + info = source.info + st = info._stat() if hasattr(info, '_stat') else None + copy_times = hasattr(st, 'st_atime_ns') and hasattr(st, 'st_mtime_ns') + copy_xattrs = hasattr(info, '_xattrs') and hasattr(os, 'setxattr') + copy_mode = hasattr(st, 'st_mode') + copy_flags = hasattr(st, 'st_flags') and hasattr(os, 'chflags') + + if copy_times: + os.utime(target, ns=(st.st_atime_ns, st.st_mtime_ns)) + if copy_xattrs: + for attr, value in info._xattrs(): + try: + os.setxattr(target, attr, value) + except OSError as e: + if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): + raise + if copy_mode: + os.chmod(target, S_IMODE(st.st_mode)) + if copy_flags: + try: + os.chflags(target, st.st_flags) + except OSError as why: + if why.errno not in (EOPNOTSUPP, ENOTSUP): + raise + + def copy_symlink_metadata(self, source, target): + info = source.info + st = info._stat(follow_symlinks=False) if hasattr(info, '_stat') else None + copy_times = (hasattr(st, 'st_atime_ns') and hasattr(st, 'st_mtime_ns') and + os.utime in os.supports_follow_symlinks) + copy_xattrs = (hasattr(info, '_xattrs') and hasattr(os, 'setxattr') and + os.setxattr in os.supports_follow_symlinks) + copy_mode = hasattr(st, 'st_mode') and hasattr(os, 'lchmod') + copy_flags = (hasattr(st, 'st_flags') and hasattr(os, 'chflags') and + os.chflags in os.supports_follow_symlinks) + + if copy_times: + os.utime(target, ns=(st.st_atime_ns, st.st_mtime_ns), follow_symlinks=False) + if copy_xattrs: + for attr, value in info._xattrs(follow_symlinks=False): + try: + os.setxattr(target, attr, value, follow_symlinks=False) + except OSError as e: + if e.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): + raise + if copy_mode: + try: + os.lchmod(target, S_IMODE(st.st_mode)) + except NotImplementedError: + pass + if copy_flags: + try: + os.chflags(target, st.st_flags, follow_symlinks=False) + except OSError as why: + if why.errno not in (EOPNOTSUPP, ENOTSUP): + raise + + @classmethod + def ensure_different_file(cls, source, target): + """ + Raise OSError(EINVAL) if both paths refer to the same file. + """ + try: + if not target.samefile(source): + return + except (OSError, ValueError): + return + err = OSError(EINVAL, "Source and target are the same file") + err.filename = str(source) + err.filename2 = str(target) + raise err + + +class PathInfo: + __slots__ = ('_path', '_stat_cache', '_xattrs_cache', + '_exists', '_is_dir', '_is_file', '_is_symlink') def __init__(self, path): self._path = str(path) + self._stat_cache = [None, None] + self._xattrs_cache = [None, None] + + def __repr__(self): + path_type = "WindowsPath" if os.name == "nt" else "PosixPath" + return f"<{path_type}.info>" def exists(self, *, follow_symlinks=True): """Whether this path exists.""" @@ -231,60 +448,43 @@ def is_symlink(self): 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): + def _stat(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 + st = self._stat_cache[idx] + if st is None: + st = os.stat(self._path, follow_symlinks=follow_symlinks) + if follow_symlinks or S_ISLNK(st.st_mode): + self._stat_cache[idx] = st 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 + self._stat_cache = [st, st] + return st + + if hasattr(os, 'listxattr'): + def _xattrs(self, *, follow_symlinks=True): + idx = bool(follow_symlinks) + xattrs = self._xattrs_cache[idx] + if xattrs is None: + try: + xattrs = [ + (attr, os.getxattr(self._path, attr, follow_symlinks=follow_symlinks)) + for attr in os.listxattr(self._path, follow_symlinks=follow_symlinks)] + except OSError as err: + if err.errno not in (EPERM, ENOTSUP, ENODATA, EINVAL, EACCES): + raise + xattrs = [] + self._xattrs_cache[idx] = xattrs + return xattrs -class DirEntryInfo(_PathInfoBase): +class DirEntryInfo(PathInfo): """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') + __slots__ = ('_entry',) def __init__(self, entry): + super().__init__(entry.path) self._entry = entry def exists(self, *, follow_symlinks=True): @@ -322,3 +522,6 @@ def is_symlink(self): return self._entry.is_symlink() except OSError: return False + + def _stat(self, *, follow_symlinks=True): + return self._entry.stat(follow_symlinks=follow_symlinks)