diff --git a/CHANGES.md b/CHANGES.md index cd10f4fb..f47f05c5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,9 @@ The released versions correspond to PyPI releases. ### Fixes +* fixed `Path.glob()` not finding literal pattern parts with a different case + in a case-insensitive filesystem (e.g. macOS) under Python 3.14 + (see [#1347](https://github.com/pytest-dev/pyfakefs/issues/1347)) * fixed resolving Windows drive-relative paths (see [#1324](https://github.com/pytest-dev/pyfakefs/issues/1324)) * fixed `os.path.sameopenfile` using real file descriptors instead of fake ones (see [#1346](https://github.com/pytest-dev/pyfakefs/issues/1346)) diff --git a/pyfakefs/fake_path.py b/pyfakefs/fake_path.py index 13c41743..933eca8f 100644 --- a/pyfakefs/fake_path.py +++ b/pyfakefs/fake_path.py @@ -38,6 +38,7 @@ ) from pyfakefs.helpers import ( + REALPATH_RESOLVES_DRIVE_RELATIVE, is_called_from_skipped_module, make_string_path, matching_string, @@ -385,7 +386,14 @@ def realpath(self, filename: AnyStr, strict: bool | None = None) -> AnyStr: filename = make_string_path(filename) path, _ = self._join_real_path(filename[:0], filename, {}) - return self.abspath(path) + path = self.abspath(path) + if REALPATH_RESOLVES_DRIVE_RELATIVE and self.filesystem.is_windows_fs: + drive, rest = self.splitdrive(path) + if len(drive) == 2 and not self.filesystem.starts_with_sep(rest): + # the path is relative to another drive (see CPython gh-102475) + sep = self.filesystem.get_path_separator(path) + path = drive.upper() + sep + rest + return path def samefile(self, path1: AnyStr, path2: AnyStr) -> bool: """Return whether path1 and path2 point to the same file. diff --git a/pyfakefs/fake_pathlib.py b/pyfakefs/fake_pathlib.py index 050c0930..e99e0366 100644 --- a/pyfakefs/fake_pathlib.py +++ b/pyfakefs/fake_pathlib.py @@ -1038,14 +1038,40 @@ def __init__(self, *args): # a similar check is used in the implementations of glob, match and full_match, to check for # case sensitivity if not given, so we provide it under Posix as True def glob(self, pattern, *, case_sensitive=None, recurse_symlinks=False): - if case_sensitive is None: - case_sensitive = True - return super().glob( # pytype: disable=wrong-keyword-args - pattern, - case_sensitive=case_sensitive, - recurse_symlinks=recurse_symlinks, + if case_sensitive is not None: + return super().glob( # pytype: disable=wrong-keyword-args + pattern, + case_sensitive=case_sensitive, + recurse_symlinks=recurse_symlinks, + ) + # Passing `case_sensitive=True` would also make CPython match + # literal (non-wildcard) pattern parts via scandir. Without an + # explicit argument, it instead checks literal parts by looking + # them up in the filesystem, so they are found in a + # case-insensitive filesystem (e.g. under macOS). + # This mirrors `pathlib.Path.glob` with `case_pedantic=False`. + from glob import ( # pytype: disable=import-error + _no_recurse_symlinks, + _StringGlobber, ) + # pytype: disable=attribute-error + sys.audit("pathlib.Path.glob", self, pattern) + parts = self._parse_pattern(pattern) + recursive = True if recurse_symlinks else _no_recurse_symlinks + globber = _StringGlobber(self.parser.sep, True, False, recursive) + select = globber.selector(parts[::-1]) + root = str(self) + paths = select(self.parser.join(root, "")) + if root == ".": + paths = map(self._remove_leading_dot, paths) + if parts[-1] == "": + paths = map(self._remove_trailing_slash, paths) + elif parts[-1] == "**": + paths = self._filter_trailing_slash(paths) + return map(self._from_parsed_string, paths) + # pytype: enable=attribute-error + def match(self, path_pattern, *, case_sensitive=None): if case_sensitive is None: case_sensitive = True diff --git a/pyfakefs/helpers.py b/pyfakefs/helpers.py index b78d768f..9855c6d2 100644 --- a/pyfakefs/helpers.py +++ b/pyfakefs/helpers.py @@ -37,6 +37,13 @@ IS_PYPY = platform.python_implementation() == "PyPy" IS_WIN = sys.platform == "win32" IN_DOCKER = os.path.exists("/.dockerenv") +# under Windows, `os.path.realpath` resolves a path relative to another drive +# against the root of that drive (changed in Python 3.13.16, 3.14.8 and 3.15.1) +REALPATH_RESOLVES_DRIVE_RELATIVE = ( + sys.version_info >= (3, 15, 1) + or (3, 14, 8) <= sys.version_info < (3, 15) + or (3, 13, 16) <= sys.version_info < (3, 14) +) PERM_READ = 0o400 # Read permission bit. PERM_WRITE = 0o200 # Write permission bit. diff --git a/pyfakefs/tests/fake_pathlib_test.py b/pyfakefs/tests/fake_pathlib_test.py index e3efc440..2ecd80b1 100644 --- a/pyfakefs/tests/fake_pathlib_test.py +++ b/pyfakefs/tests/fake_pathlib_test.py @@ -33,7 +33,7 @@ from pyfakefs import fake_filesystem, fake_filesystem_unittest, fake_os, fake_pathlib from pyfakefs.fake_filesystem import FakeFilesystem, OSType from pyfakefs.fake_pathlib import FakePathlibModule -from pyfakefs.helpers import IS_PYPY, is_root +from pyfakefs.helpers import IS_PYPY, REALPATH_RESOLVES_DRIVE_RELATIVE, is_root from pyfakefs.tests.skipped_pathlib import ( check_exists_pathlib, read_bytes_pathlib, @@ -680,7 +680,10 @@ def test_resolve_relative_windows_path_with_invalid_drive(self): path = self.path(r"x:test\test_data\dummy1.xml") self.assertFalse(path.is_absolute()) resolved = path.resolve(strict=False) - self.assertEqual(r"x:test\test_data\dummy1.xml", str(resolved)) + if REALPATH_RESOLVES_DRIVE_RELATIVE: + self.assertEqual(r"X:\test\test_data\dummy1.xml", str(resolved)) + else: + self.assertEqual(r"x:test\test_data\dummy1.xml", str(resolved)) def test_stat_file_in_unreadable_dir(self): self.check_posix_only() @@ -1155,6 +1158,50 @@ def test_glob_case_posix(self): [self.path(self.make_path("foo", "setup.py"))], ) + @unittest.skipIf( + sys.version_info < (3, 13), "literal glob parts are looked up since Python 3.13" + ) + def test_glob_literal_case_insensitive_fs(self): + # regression test for #1347 + self.check_posix_only() + if sys.platform == "win32" and sys.version_info < (3, 14): + self.skipTest(reason="Ignoring inconsistent path delimiters") + self.check_case_insensitive_fs() + self.create_file(self.make_path("foo", "B.TXT")) + self.create_file(self.make_path("foo", "Sub", "x.py")) + path = self.path(self.make_path("foo")) + # literal pattern parts are looked up in the filesystem + self.assertEqual( + [self.path(self.make_path("foo", "b.txt"))], list(path.glob("b.txt")) + ) + self.assertEqual( + [self.path(self.make_path("foo", "sub", "x.py"))], + list(path.glob("sub/*.py")), + ) + # wildcard parts are still matched case-sensitively under Posix + self.assertEqual([], list(path.glob("*.txt"))) + # explicit arguments take precedence + self.assertEqual([], list(path.glob("b.txt", case_sensitive=True))) + self.assertEqual( + [self.path(self.make_path("foo", "B.TXT"))], + list(path.glob("*.txt", case_sensitive=False)), + ) + + @unittest.skipIf( + sys.version_info < (3, 13), "literal glob parts are looked up since Python 3.13" + ) + def test_glob_literal_case_sensitive_fs(self): + self.check_posix_only() + self.check_case_sensitive_fs() + self.create_file(self.make_path("foo", "B.TXT")) + path = self.path(self.make_path("foo")) + self.assertEqual([], list(path.glob("b.txt"))) + self.assertEqual([], list(path.glob("*.txt"))) + self.assertEqual( + [self.path(self.make_path("foo", "B.TXT"))], + list(path.glob("b.txt", case_sensitive=False)), + ) + class RealPathlibPathFileOperationTest(FakePathlibPathFileOperationTest): def use_real_fs(self):