Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preliminary support for Python 3.13 #1032

Merged
merged 1 commit into from
Jun 27, 2024
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
19 changes: 13 additions & 6 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 @@ -85,16 +88,20 @@ jobs:
fi
shell: bash
- name: Install extra dependencies
if: ${{ matrix.python-version != 'pypy-3.10' }}
if: ${{ matrix.python-version != 'pypy-3.10' && ( matrix.python-version != '3.13-dev' || matrix.os != 'windows-latest' ) }}
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' }}
if: ${{ matrix.python-version != 'pypy-3.10' && ( matrix.python-version != '3.13-dev' || matrix.os != 'windows-latest' ) }}
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
33 changes: 32 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,36 @@ def listdir(self, target_directory: AnyStr) -> List[AnyStr]:
def __str__(self) -> str:
return str(self.root_dir)

if sys.version_info >= (3, 13):
# used for emulating Windows
_WIN_RESERVED_NAMES = frozenset(
{"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}
| {f"COM{c}" for c in "123456789\xb9\xb2\xb3"}
| {f"LPT{c}" for c in "123456789\xb9\xb2\xb3"}
)

def isreserved(self, path):
if not self.is_windows_fs:
return False

def is_reserved_name(name):
if sys.platform == "win32":
from os.path import _isreservedname # type: ignore[import-error]

return _isreservedname(name)
return name in self._WIN_RESERVED_NAMES

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
)

return any(
is_reserved_name(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
21 changes: 19 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,14 @@ def splitroot(self, path: AnyStr):
"""
return self.filesystem.splitroot(path)

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

def isreserved(self, path):
if not self.filesystem.is_windows_fs:
raise AttributeError("module 'os' has no attribute 'isreserved'")

return self.filesystem.isreserved(path)

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

Expand Down
Loading