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
- improve handling of admin user under Windows
- add os.isreserved
- changed os.path.isabs under Windows
  • Loading branch information
mrbean-bremen committed Jun 23, 2024
1 parent f56a4c6 commit d75b2ce
Show file tree
Hide file tree
Showing 16 changed files with 204 additions and 71 deletions.
7 changes: 5 additions & 2 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
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
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
16 changes: 15 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,19 @@ 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]).replace(
self.alternative_path_separator, self.path_separator
)
from os.path import _isreservedname

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
50 changes: 44 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 @@ -88,15 +101,37 @@ def fake_open(
# 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 +236,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
49 changes: 37 additions & 12 deletions pyfakefs/fake_pathlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@
import errno
import fnmatch
import functools
import glob
import inspect
import ntpath
import os
import pathlib
import posixpath
import re
import sys
import warnings
from pathlib import PurePath
from typing import Callable, List
from urllib.parse import quote_from_bytes as urlquote_from_bytes
Expand Down Expand Up @@ -70,23 +72,25 @@ def init_module(filesystem):
fake_pure_nt_flavour.altsep = "/"
FakePathlibModule.PureWindowsPath._flavour = fake_pure_nt_flavour
else:
# in Python 3.12, the flavour is no longer an own class,
# in Python > 3.11, the flavour is no longer a separate class,
# but points to the os-specific path module (posixpath/ntpath)
fake_os = FakeOsModule(filesystem)
FakePathlibModule.PosixPath._flavour = fake_os.path
FakePathlibModule.WindowsPath._flavour = fake_os.path
parser_name = "_flavour" if sys.version_info < (3, 13) else "parser"

setattr(FakePathlibModule.PosixPath, parser_name, fake_os.path)
setattr(FakePathlibModule.WindowsPath, parser_name, fake_os.path)

# Pure POSIX path separators must be filesystem independent.
fake_pure_posix_os = FakeOsModule(filesystem)
fake_pure_posix_os.path.sep = "/"
fake_pure_posix_os.path.altsep = None
FakePathlibModule.PurePosixPath._flavour = fake_pure_posix_os.path
setattr(FakePathlibModule.PurePosixPath, parser_name, fake_pure_posix_os.path)

# Pure Windows path separators must be filesystem independent.
fake_pure_nt_os = FakeOsModule(filesystem)
fake_pure_nt_os.path.sep = "\\"
fake_pure_nt_os.path.altsep = "/"
FakePathlibModule.PureWindowsPath._flavour = fake_pure_nt_os.path
setattr(FakePathlibModule.PureWindowsPath, parser_name, fake_pure_nt_os.path)


def _wrap_strfunc(strfunc):
Expand Down Expand Up @@ -578,6 +582,13 @@ def _init(self, template=None):
# only needed until Python 3.8
self._closed = False

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

def _glob_selector(self, parts, case_sensitive, recurse_symlinks):
# make sure we get the patched version of the globber
self._globber = glob._StringGlobber
return super()._glob_selector(parts, case_sensitive, recurse_symlinks)

def _path(self):
"""Returns the underlying path string as used by the fake
filesystem.
Expand Down Expand Up @@ -805,13 +816,21 @@ def is_absolute(self):
return os.path.isabs(self._path())

def is_reserved(self):
if not self.filesystem.is_windows_fs or not self._tail:
return False
if self._tail[0].startswith("\\\\"):
# UNC paths are never reserved.
if not self.filesystem.is_windows_fs:
return False
name = self._tail[-1].partition(".")[0].partition(":")[0].rstrip(" ")
return name.upper() in pathlib._WIN_RESERVED_NAMES
if sys.version_info < (3, 13):
if not self._tail or self._tail[0].startswith("\\\\"):
# UNC paths are never reserved.
return False
name = self._tail[-1].partition(".")[0].partition(":")[0].rstrip(" ")
return name.upper() in pathlib._WIN_RESERVED_NAMES
warnings.warn(
"pathlib.PurePath.is_reserved() is deprecated and scheduled "
"for removal in Python 3.15. Use os.path.isreserved() to detect "
"reserved paths on Windows.",
DeprecationWarning,
)
return self.filesystem.isreserved(self._path())


class FakePathlibModule:
Expand All @@ -837,11 +856,15 @@ class PurePosixPath(PurePath):
paths"""

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

class PureWindowsPath(PurePath):
"""A subclass of PurePath, that represents Windows filesystem paths"""

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

class WindowsPath(FakePath, PureWindowsPath):
"""A subclass of Path and PureWindowsPath that represents
Expand Down Expand Up @@ -933,8 +956,10 @@ class RealPath(pathlib.Path):
if os.name == "nt"
else pathlib._PosixFlavour() # type:ignore
) # type:ignore
else:
elif sys.version_info < (3, 13):
_flavour = ntpath if os.name == "nt" else posixpath
else:
parser = ntpath if os.name == "nt" else posixpath

def __new__(cls, *args, **kwargs):
"""Creates the correct subclass based on OS."""
Expand Down
1 change: 1 addition & 0 deletions pyfakefs/fake_scandir.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ def scandir(filesystem, path=""):
Raises:
OSError: if the target is not a directory.
"""
print(f"fake scandir({path})")
return ScanDirIter(filesystem, path)


Expand Down
Loading

0 comments on commit d75b2ce

Please sign in to comment.