Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
10 changes: 9 additions & 1 deletion pyfakefs/fake_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
)

from pyfakefs.helpers import (
REALPATH_RESOLVES_DRIVE_RELATIVE,
is_called_from_skipped_module,
make_string_path,
matching_string,
Expand Down Expand Up @@ -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.
Expand Down
38 changes: 32 additions & 6 deletions pyfakefs/fake_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions pyfakefs/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 49 additions & 2 deletions pyfakefs/tests/fake_pathlib_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
Loading