Skip to content

Commit

Permalink
Preliminary support for Python 3.13
Browse files Browse the repository at this point in the history
- account for changes in pathlib implementation
- reload glob to account for changed implementation
- improve handling of admin user under Windows
- add os.isreserved
- change os.path.isabs under Windows
- add worksaround for recursion with linecache
  • Loading branch information
mrbean-bremen committed Jun 26, 2024
1 parent f56a4c6 commit 3ef4953
Show file tree
Hide file tree
Showing 16 changed files with 276 additions and 83 deletions.
15 changes: 11 additions & 4 deletions .github/workflows/testsuite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ name: Testsuite
on:
[push, pull_request]

defaults:
run:
shell: bash

jobs:
pytype:
runs-on: ubuntu-latest
Expand All @@ -28,7 +32,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"]
python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12", "3.13-dev"]
include:
- python-version: "pypy-3.7"
os: ubuntu-latest
Expand All @@ -53,7 +57,6 @@ jobs:
run: |
python -m pip install --upgrade pip
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
echo "dir=$(pip cache dir)" >> $env:GITHUB_OUTPUT
- name: Cache dependencies
id: cache-dep
Expand Down Expand Up @@ -89,12 +92,16 @@ jobs:
run: |
pip install -r extra_requirements.txt
pip install -r legacy_requirements.txt
pip install zstandard cffi # needed to test #910
if [[ '${{ matrix.python-version != '3.13-dev' ]]; then
pip install zstandard cffi # needed to test #910
fi
shell: bash
- name: Run unit tests with extra packages as non-root user
if: ${{ matrix.python-version != 'pypy-3.10' }}
run: |
export PYTHON_ZSTANDARD_IMPORT_POLICY=cffi # needed to test #910
if [[ '${{ matrix.python-version != '3.13-dev' ]]; then
export PYTHON_ZSTANDARD_IMPORT_POLICY=cffi # needed to test #910
fi
python -m pyfakefs.tests.all_tests
shell: bash
- name: Run performance tests
Expand Down
6 changes: 4 additions & 2 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ The released versions correspond to PyPI releases.

## Unreleased

### Fixes
### Enhancements
* added preliminary support for Python 3.13 (tested with beta2) (see [#1017](../../issues/1017))

* Use real open calls for remaining `pathlib` functions so that it works nice with skippedmodules (see #1012)
### Fixes
* use real open calls for remaining `pathlib` functions so that it works nice with skippedmodules (see [#1012](../../issues/1012))

## [Version 5.5.0](https://pypi.python.org/pypi/pyfakefs/5.5.0) (2024-05-12)
Deprecates the usage of `pathlib2` and `scandir`.
Expand Down
2 changes: 1 addition & 1 deletion legacy_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
# Note that the usage of these modules is deprecated, and their support
# will be removed in pyfakefs 6.0
pathlib2>=2.3.2
scandir>=1.8
scandir>=1.8; python_version < '3.13' # not (yet) available for Python 3.13
3 changes: 2 additions & 1 deletion pyfakefs/fake_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
AnyString,
get_locale_encoding,
_OpenModes,
is_root,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -587,7 +588,7 @@ def remove_entry(self, pathname_name: str, recursive: bool = True) -> None:
pathname_name = self._normalized_entryname(pathname_name)
entry = self.get_entry(pathname_name)
if self.filesystem.is_windows_fs:
if entry.st_mode & helpers.PERM_WRITE == 0:
if not is_root() and entry.st_mode & helpers.PERM_WRITE == 0:
self.filesystem.raise_os_error(errno.EACCES, pathname_name)
if self.filesystem.has_open_file(entry):
raise_error = True
Expand Down
18 changes: 17 additions & 1 deletion pyfakefs/fake_filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -2559,9 +2559,10 @@ def create_symlink(
# resolve the link path only if it is not a link itself
if not self.islink(link_path):
link_path = self.resolve_path(link_path)
permission = helpers.PERM_DEF_FILE if self.is_windows_fs else helpers.PERM_DEF
return self.create_file_internally(
link_path,
st_mode=S_IFLNK | helpers.PERM_DEF,
st_mode=S_IFLNK | permission,
contents=link_target_path,
create_missing_dirs=create_missing_dirs,
apply_umask=self.is_macos,
Expand Down Expand Up @@ -3045,6 +3046,21 @@ def listdir(self, target_directory: AnyStr) -> List[AnyStr]:
def __str__(self) -> str:
return str(self.root_dir)

if sys.version_info >= (3, 13):

def isreserved(self, path):
path = os.fsdecode(self.splitroot(path)[2])
if self.alternative_path_separator is not None:
path = path.replace(
self.alternative_path_separator, self.path_separator
)
from os.path import _isreservedname # type: ignore[import-error]

return any(
_isreservedname(name)
for name in reversed(path.split(self.path_separator))
)

def _add_standard_streams(self) -> None:
self.add_open_file(StandardStreamWrapper(sys.stdin))
self.add_open_file(StandardStreamWrapper(sys.stdout))
Expand Down
15 changes: 15 additions & 0 deletions pyfakefs/fake_filesystem_unittest.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import doctest
import functools
import genericpath
import glob
import inspect
import io
import linecache
Expand Down Expand Up @@ -589,6 +590,9 @@ def __init__(
self.modules_to_reload: List[ModuleType] = (
[] if sys.platform == "win32" else [tempfile]
)
if sys.version_info >= (3, 13):
# need to reload glob which holds references to os functions
self.modules_to_reload.append(glob)
if modules_to_reload is not None:
self.modules_to_reload.extend(modules_to_reload)
self.patch_default_args = patch_default_args
Expand Down Expand Up @@ -685,6 +689,11 @@ def _init_fake_module_classes(self) -> None:
"io": fake_io.FakeIoModule,
"pathlib": fake_pathlib.FakePathlibModule,
}
if sys.version_info >= (3, 13):
# for Python 3.13, we need both pathlib (path with __init__.py) and
# pathlib._local (has the actual implementation);
# depending on how pathlib is imported, either may be used
self._fake_module_classes["pathlib._local"] = fake_pathlib.FakePathlibModule
if IS_PYPY or sys.version_info >= (3, 12):
# in PyPy and later cpython versions, the module is referenced as _io
self._fake_module_classes["_io"] = fake_io.FakeIoModule2
Expand All @@ -697,7 +706,13 @@ def _init_fake_module_classes(self) -> None:
# be contained in - this allows for alternative modules like
# `pathlib` and `pathlib2`
self._class_modules["Path"] = ["pathlib"]
if sys.version_info >= (3, 13):
self._class_modules["Path"].append("pathlib._local")
self._unfaked_module_classes["pathlib"] = fake_pathlib.RealPathlibModule
if sys.version_info >= (3, 13):
self._unfaked_module_classes["pathlib._local"] = (
fake_pathlib.RealPathlibModule
)
if pathlib2:
self._fake_module_classes["pathlib2"] = (
fake_legacy_modules.FakePathlib2Module
Expand Down
70 changes: 64 additions & 6 deletions pyfakefs/fake_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@
"x+": (False, True, True, False, False, True),
}

real_call_line_no = None


def _real_call_line_no():
global real_call_line_no
if real_call_line_no is None:
fake_io_source = os.path.join(os.path.dirname(__file__), "fake_io.py")
for i, line in enumerate(io.open(fake_io_source)):
if "return self._io_module.open_code(path)" in line:
real_call_line_no = i + 1
break
return real_call_line_no


def fake_open(
filesystem: "FakeFilesystem",
Expand All @@ -83,20 +96,62 @@ def fake_open(
"""Redirect the call to FakeFileOpen.
See FakeFileOpen.call() for description.
"""
# since Python 3.13, we can run into a recursion in some instances here:
# traceback calls linecache.update_cache, which loads 'os' dynamically,
# which will be patched by the dynamic patcher and ends up here again;
# for these instances, we use a shortcut check here
if (
isinstance(file, str)
and file.endswith(("fake_open.py", "fake_io.py"))
and os.path.split(os.path.dirname(file))[1] == "pyfakefs"
):
return io.open( # pytype: disable=wrong-arg-count
file,
mode,
buffering,
encoding,
errors,
newline,
closefd,
opener,
)

# workaround for built-in open called from skipped modules (see #552)
# as open is not imported explicitly, we cannot patch it for
# specific modules; instead we check if the caller is a skipped
# module (should work in most cases)
stack = traceback.extract_stack(limit=3)

# handle the case that we try to call the original `open_code`
# and get here instead (since Python 3.12)
from_open_code = (
sys.version_info >= (3, 12)
and stack[0].name == "open_code"
and stack[0].line == "return self._io_module.open_code(path)"
)
# TODO: use a more generic approach (see other PR #1025)
if sys.version_info >= (3, 13):
# TODO: check if stacktrace line is still not filled in final version
from_open_code = (
stack[0].name == "open_code" and stack[0].lineno == _real_call_line_no()
)
elif sys.version_info >= (3, 12):
from_open_code = (
stack[0].name == "open_code"
and stack[0].line == "return self._io_module.open_code(path)"
)
else:
from_open_code = False

module_name = os.path.splitext(stack[0].filename)[0]
module_name = module_name.replace(os.sep, ".")
if sys.version_info >= (3, 13) and module_name.endswith(
("pathlib._abc", "pathlib._local")
):
stack = traceback.extract_stack(limit=6)
frame = 2
# in Python 3.13, pathlib is implemented in 2 sub-modules that may call
# each other, so we have to look further in the stack
while frame >= 0 and module_name.endswith(("pathlib._abc", "pathlib._local")):
module_name = os.path.splitext(stack[frame].filename)[0]
module_name = module_name.replace(os.sep, ".")
frame -= 1

if from_open_code or any(
[module_name == sn or module_name.endswith("." + sn) for sn in skip_names]
):
Expand Down Expand Up @@ -201,7 +256,10 @@ def call(
# the pathlib opener is defined in a Path instance that may not be
# patched under some circumstances; as it just calls standard open(),
# we may ignore it, as it would not change the behavior
if opener is not None and opener.__module__ != "pathlib":
if opener is not None and opener.__module__ not in (
"pathlib",
"pathlib._local",
):
# opener shall return a file descriptor, which will be handled
# here as if directly passed
file_ = opener(file_, self._open_flags_from_open_modes(open_modes))
Expand Down
18 changes: 16 additions & 2 deletions pyfakefs/fake_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,19 @@ def getsize(self, path: AnyStr):

def isabs(self, path: AnyStr) -> bool:
"""Return True if path is an absolute pathname."""
empty = matching_string(path, "")
if self.filesystem.is_windows_fs:
path = self.splitdrive(path)[1]
drive, path = self.splitdrive(path)
else:
drive = empty
path = make_string_path(path)
return self.filesystem.starts_with_sep(path)
if not self.filesystem.starts_with_sep(path):
return False
if self.filesystem.is_windows_fs and sys.version_info >= (3, 13):
# from Python 3.13 on, a path under Windows starting with a single separator
# (e.g. not a drive and not an UNC path) is no more considered absolute
return drive != empty
return True

def isdir(self, path: AnyStr) -> bool:
"""Determine if path identifies a directory."""
Expand Down Expand Up @@ -204,6 +213,11 @@ def splitroot(self, path: AnyStr):
"""
return self.filesystem.splitroot(path)

if sys.version_info >= (3, 13):

def isreserved(self, path):
return self.filesystem.isreserved(path)

def getmtime(self, path: AnyStr) -> float:
"""Returns the modification time of the fake file.
Expand Down
Loading

0 comments on commit 3ef4953

Please sign in to comment.