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

[WIP] Make fake filesystem, fake os and fake os.path respect additional_skip_names #1025

Merged
merged 7 commits into from
Aug 15, 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
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ default_language_version:

repos:
- repo: "https://github.com/asottile/pyupgrade"
rev: "v3.16.0"
rev: "v3.17.0"
hooks:
- id: "pyupgrade"
name: "Enforce Python 3.7+ idioms"
args:
- "--py37-plus"

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.5.4"
rev: "v0.5.7"
hooks:
- id: ruff
args: ["--fix"]
Expand Down Expand Up @@ -46,7 +46,7 @@ repos:
language: python
files: \.py$
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
rev: v1.11.1
hooks:
- id: mypy
exclude: (docs|pyfakefs/tests)
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ The released versions correspond to PyPI releases.
* the default for `FakeFilesystem.shuffle_listdir_results` will change to `True` to reflect
the real filesystem behavior

## Unreleased

### Enhancements

- refactor the implementation of `additional_skip_names` parameter to make it work with more modules (see [#1023](../../issues/1023))

## [Version 5.6.0](https://pypi.python.org/pypi/pyfakefs/5.6.0) (2024-07-12)
Adds preliminary Python 3.13 support.

Expand Down
4 changes: 2 additions & 2 deletions pyfakefs/fake_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,8 +603,8 @@ def remove_entry(self, pathname_name: str, recursive: bool = True) -> None:
st = traceback.extract_stack(limit=6)
if sys.version_info < (3, 10):
if (
st[1].name == "TemporaryFile"
and st[1].line == "_os.unlink(name)"
st[0].name == "TemporaryFile"
and st[0].line == "_os.unlink(name)"
):
raise_error = False
else:
Expand Down
12 changes: 6 additions & 6 deletions pyfakefs/fake_filesystem_unittest.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ def __init__(
set_uid(1)
set_gid(1)

self._skip_names = self.SKIPNAMES.copy()
self.skip_names = self.SKIPNAMES.copy()
# save the original open function for use in pytest plugin
self.original_open = open
self.patch_open_code = patch_open_code
Expand All @@ -582,7 +582,7 @@ def __init__(
cast(ModuleType, m).__name__ if inspect.ismodule(m) else cast(str, m)
for m in additional_skip_names
]
self._skip_names.update(skip_names)
self.skip_names.update(skip_names)

self._fake_module_classes: Dict[str, Any] = {}
self._unfaked_module_classes: Dict[str, Any] = {}
Expand Down Expand Up @@ -628,8 +628,8 @@ def __init__(
if patched_module_names != self.PATCHED_MODULE_NAMES:
self.__class__.PATCHED_MODULE_NAMES = patched_module_names
clear_cache = True
if self._skip_names != self.ADDITIONAL_SKIP_NAMES:
self.__class__.ADDITIONAL_SKIP_NAMES = self._skip_names
if self.skip_names != self.ADDITIONAL_SKIP_NAMES:
self.__class__.ADDITIONAL_SKIP_NAMES = self.skip_names
clear_cache = True
if patch_default_args != self.PATCH_DEFAULT_ARGS:
self.__class__.PATCH_DEFAULT_ARGS = patch_default_args
Expand Down Expand Up @@ -875,7 +875,7 @@ def _find_modules(self) -> None:
pass
continue
skipped = module in self.SKIPMODULES or any(
[sn.startswith(module.__name__) for sn in self._skip_names]
[sn.startswith(module.__name__) for sn in self.skip_names]
)
module_items = module.__dict__.copy().items()

Expand Down Expand Up @@ -922,7 +922,7 @@ def _refresh(self) -> None:
for name in self._fake_module_classes:
self.fake_modules[name] = self._fake_module_classes[name](self.fs)
if hasattr(self.fake_modules[name], "skip_names"):
self.fake_modules[name].skip_names = self._skip_names
self.fake_modules[name].skip_names = self.skip_names
self.fake_modules[PATH_MODULE] = self.fake_modules["os"].path
for name in self._unfaked_module_classes:
self.unfaked_modules[name] = self._unfaked_module_classes[name]()
Expand Down
16 changes: 13 additions & 3 deletions pyfakefs/fake_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

from pyfakefs.fake_file import AnyFileWrapper
from pyfakefs.fake_open import fake_open
from pyfakefs.helpers import IS_PYPY
from pyfakefs.helpers import IS_PYPY, is_called_from_skipped_module

if TYPE_CHECKING:
from pyfakefs.fake_filesystem import FakeFilesystem
Expand Down Expand Up @@ -180,6 +180,16 @@ def lockf(
) -> Any:
pass

def __getattr__(self, name):
def __getattribute__(self, name):
"""Forwards any unfaked calls to the standard fcntl module."""
return getattr(self._fcntl_module, name)
fs: FakeFilesystem = object.__getattribute__(self, "filesystem")
fnctl_module = object.__getattribute__(self, "_fcntl_module")
if fs.patcher:
if is_called_from_skipped_module(
skip_names=fs.patcher.skip_names,
case_sensitive=fs.is_case_sensitive,
):
# remove the `self` argument for FakeOsModule methods
return getattr(fnctl_module, name)

return object.__getattribute__(self, name)
39 changes: 4 additions & 35 deletions pyfakefs/fake_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import io
import os
import sys
import traceback
from stat import (
S_ISDIR,
)
Expand All @@ -44,6 +43,7 @@
)
from pyfakefs.helpers import (
AnyString,
is_called_from_skipped_module,
is_root,
PERM_READ,
PERM_WRITE,
Expand Down Expand Up @@ -86,40 +86,9 @@ def fake_open(
"""Redirect the call to FakeFileOpen.
See FakeFileOpen.call() for description.
"""

# 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)
# TODO: use a more generic approach (see PR #1025)
if 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]
if is_called_from_skipped_module(
skip_names=skip_names,
case_sensitive=filesystem.is_case_sensitive,
):
return io_open( # pytype: disable=wrong-arg-count
file,
Expand Down
48 changes: 31 additions & 17 deletions pyfakefs/fake_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from pyfakefs.fake_scandir import scandir, walk, ScanDirIter
from pyfakefs.helpers import (
FakeStatResult,
is_called_from_skipped_module,
is_int_type,
is_byte_string,
make_string_path,
Expand Down Expand Up @@ -1409,27 +1410,40 @@ def __getattr__(self, name: str) -> Any:
return getattr(self.os_module, name)


if sys.version_info > (3, 10):
sassanh marked this conversation as resolved.
Show resolved Hide resolved
def handle_original_call(f: Callable) -> Callable:
"""Decorator used for real pathlib Path methods to ensure that
real os functions instead of faked ones are used.
Applied to all non-private methods of `FakeOsModule`."""

def handle_original_call(f: Callable) -> Callable:
"""Decorator used for real pathlib Path methods to ensure that
real os functions instead of faked ones are used.
Applied to all non-private methods of `FakeOsModule`."""
@functools.wraps(f)
def wrapped(*args, **kwargs):
should_use_original = FakeOsModule.use_original

@functools.wraps(f)
def wrapped(*args, **kwargs):
if FakeOsModule.use_original:
# remove the `self` argument for FakeOsModule methods
if args and isinstance(args[0], FakeOsModule):
args = args[1:]
return getattr(os, f.__name__)(*args, **kwargs)
return f(*args, **kwargs)
if not should_use_original and args:
self = args[0]
fs: FakeFilesystem = self.filesystem
if self.filesystem.patcher:
skip_names = fs.patcher.skip_names
if is_called_from_skipped_module(
skip_names=skip_names,
case_sensitive=fs.is_case_sensitive,
):
should_use_original = True

return wrapped
if should_use_original:
# remove the `self` argument for FakeOsModule methods
if args and isinstance(args[0], FakeOsModule):
args = args[1:]
return getattr(os, f.__name__)(*args, **kwargs)

for name, fn in inspect.getmembers(FakeOsModule, inspect.isfunction):
if not fn.__name__.startswith("_"):
setattr(FakeOsModule, name, handle_original_call(fn))
return f(*args, **kwargs)

return wrapped


for name, fn in inspect.getmembers(FakeOsModule, inspect.isfunction):
if not fn.__name__.startswith("_"):
setattr(FakeOsModule, name, handle_original_call(fn))


@contextmanager
Expand Down
38 changes: 38 additions & 0 deletions pyfakefs/fake_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"""Faked ``os.path`` module replacement. See ``fake_filesystem`` for usage."""

import errno
import functools
import inspect
import os
import sys
from stat import (
Expand All @@ -23,6 +25,7 @@
)
from types import ModuleType
from typing import (
Callable,
List,
Optional,
Union,
Expand All @@ -36,6 +39,7 @@
)

from pyfakefs.helpers import (
is_called_from_skipped_module,
make_string_path,
to_string,
matching_string,
Expand Down Expand Up @@ -553,3 +557,37 @@ def _isdir(self, path: AnyStr) -> bool:
def __getattr__(self, name: str) -> Any:
"""Forwards any non-faked calls to the real nt module."""
return getattr(self.nt_module, name)


def handle_original_call(f: Callable) -> Callable:
"""Decorator used for real pathlib Path methods to ensure that
real os functions instead of faked ones are used.
Applied to all non-private methods of `FakePathModule`."""

@functools.wraps(f)
def wrapped(*args, **kwargs):
if args:
self = args[0]
should_use_original = self.os.use_original
if not should_use_original and self.filesystem.patcher:
skip_names = self.filesystem.patcher.skip_names
if is_called_from_skipped_module(
skip_names=skip_names,
case_sensitive=self.filesystem.is_case_sensitive,
):
should_use_original = True

if should_use_original:
# remove the `self` argument for FakePathModule methods
if args and isinstance(args[0], FakePathModule):
args = args[1:]
return getattr(os.path, f.__name__)(*args, **kwargs)

return f(*args, **kwargs)

return wrapped


for name, fn in inspect.getmembers(FakePathModule, inspect.isfunction):
if not fn.__name__.startswith("_"):
setattr(FakePathModule, name, handle_original_call(fn))
Loading