Skip to content

Commit

Permalink
Merge branch 'main' into perf/avoid-checking-many-proxies-on-not-impl…
Browse files Browse the repository at this point in the history
…-error
  • Loading branch information
antazoey authored Feb 18, 2025
2 parents 5648920 + d504d18 commit 964635d
Show file tree
Hide file tree
Showing 6 changed files with 186 additions and 14 deletions.
28 changes: 25 additions & 3 deletions src/ape/managers/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import os
from collections.abc import Iterator
import shutil
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, Optional, Union

from ape.api.config import ApeConfig
from ape.managers.base import BaseManager
Expand Down Expand Up @@ -113,20 +114,41 @@ def extract_config(cls, manifest: "PackageManifest", **overrides) -> ApeConfig:
return ApeConfig.from_manifest(manifest, **overrides)

@contextmanager
def isolate_data_folder(self) -> Iterator[Path]:
def isolate_data_folder(
self, keep: Optional[Union[Iterable[str], str]] = None
) -> Iterator[Path]:
"""
Change Ape's DATA_FOLDER to point a temporary path,
in a context, for testing purposes. Any data
cached to disk will not persist.
Args:
keep (Optional[Union[Iterable[str], str]]): Optionally, pass in
a key of subdirectory names to include in the new isolated
data folder. For example, pass ing ``"packages"`` to avoid
having to re-download dependencies in an isolated environment.
Returns:
Iterator[Path]: The temporary data folder.
"""
original_data_folder = self.DATA_FOLDER
if in_tempdir(original_data_folder):
# Already isolated.
yield original_data_folder

else:
keep = [keep] if isinstance(keep, str) else keep or []
try:
with create_tempdir() as temp_data_folder:
# Copy in items from "keep".
for item in keep:
path_to_keep = original_data_folder / item
if not path_to_keep.is_dir():
continue

dest_path = temp_data_folder / item
shutil.copytree(path_to_keep, dest_path)

self.DATA_FOLDER = temp_data_folder
yield temp_data_folder

Expand Down
63 changes: 55 additions & 8 deletions src/ape/managers/project.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
import os
import random
import shutil
from collections.abc import Callable, Iterable, Iterator
Expand Down Expand Up @@ -32,6 +31,7 @@
)
from ape.utils.misc import SOURCE_EXCLUDE_PATTERNS, log_instead_of_fail
from ape.utils.os import (
ChangeDirectory,
clean_path,
create_tempdir,
get_all_files_in_directory,
Expand Down Expand Up @@ -1021,6 +1021,25 @@ def remove(self, package_id: str, version: str):
manifest_file = self.get_manifest_path(package_id, version)
manifest_file.unlink(missing_ok=True)

@contextmanager
def isolate_changes(self):
"""
Allows you to make changes affecting the Ape packages cache in a context.
For example, temporarily install local "dev" packages for testing purposes.
"""
with create_tempdir() as tmpdir:
packages_cache = tmpdir / "packages"
packages_cache.parent.mkdir(parents=True, exist_ok=True)
if self.root.is_dir():
shutil.copytree(self.root, packages_cache)
try:
yield
finally:
shutil.rmtree(self.root)
if packages_cache.is_dir():
# Restore.
shutil.copytree(packages_cache, self.root)


def _version_to_options(version: str) -> tuple[str, ...]:
if version.startswith("v"):
Expand Down Expand Up @@ -2592,22 +2611,50 @@ def chdir(self, path: Optional[Path] = None):
path (Path): The path of the new project.
If not given, defaults to the project's path.
"""
path = path or self.path
os.chdir(path)
if self.path == path:
return # Already setup.
return ChangeDirectory(
self.path,
path or self.path,
on_push=self._handle_path_changed,
on_pop=self._handle_path_restored,
)

def _handle_path_changed(self, path: Path) -> dict:
cache: dict = {
"__dict__": {},
"_session_source_change_check": self._session_source_change_check,
"_config_override": self._config_override,
"_base_path": self._base_path,
"manifest_path": self.manifest_path,
"_manifest": self._manifest,
}

# New path: clear cached properties.
for attr in list(self.__dict__.keys()):
if isinstance(getattr(type(self), attr, None), cached_property):
del self.__dict__[attr]
cache["__dict__"][attr] = self.__dict__.pop(attr)

# Re-initialize
self._session_source_change_check = set()
self._config_override = {}
self._base_path = Path(path).resolve()
self.manifest_path = self._base_path / ".build" / "__local__.json"

if self.manifest_path.name == "__local__.json":
self.manifest_path = self._base_path / ".build" / "__local__.json"

self._manifest = self.load_manifest()
return cache

def _handle_path_restored(self, cache: dict) -> None:
self.__dict__ = {**(self.__dict__ or {}), **cache.get("__dict__", {})}
self._session_source_change_check = cache.get("_session_source_change_check", set())
self._config_override = cache.get("_config_override", {})
if base_path := self._base_path:
self._base_path = base_path

if manifest_path := cache.get("manifest_path"):
self.manifest_path = manifest_path

if manifest := cache.get("_manifest"):
self._manifest = manifest

@contextmanager
def within_project_path(self):
Expand Down
46 changes: 46 additions & 0 deletions src/ape/utils/os.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,3 +463,49 @@ def within_directory(directory: Path):
finally:
if Path.cwd() != here:
os.chdir(here)


class ChangeDirectory:
"""
A context-manager for changing a directory. Initializing it
will still change the directory, but you can optionally exit
out of it to restore back to the original directory. Additionally,
provides hooks to run when each of these events occur.
"""

def __init__(
self,
original_path: Path,
new_path: Path,
chdir: Optional[Callable[[Path], None]] = None,
on_push: Optional[Callable[[Path], dict]] = None,
on_pop: Optional[Callable[[dict], None]] = None,
):
self.original_path = original_path
self.new_path = new_path
self._on_push = on_push
self._on_pop = on_pop
self._chdir = chdir or os.chdir

# Initiate the change now so you can still use this class
# on methods that are not intended to be used in a context.
if self.original_path != self.new_path:
self._chdir(new_path)
self._cache: dict = {} if self._on_push is None else self._on_push(new_path)
self._did_change = True
else:
self._cache = {}
self._did_change = False

def __enter__(self):
return self.new_path

def __exit__(self, *args):
if not self._did_change:
# Don't do anything. Nothing changed.
return

# Handle the return to the original path.
self._chdir(self.original_path)
if self._on_pop:
self._on_pop(self._cache)
42 changes: 42 additions & 0 deletions tests/functional/test_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import re
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional, Union

Expand Down Expand Up @@ -689,3 +690,44 @@ def test_project_level_settings(project):
assert project.config.my_string == "my_string"
assert project.config.my_int == 123
assert project.config.my_bool is True


def test_isolate_data_folder(config):
original_data_folder = config.DATA_FOLDER
madeup_path = Path.home() / ".ape" / "__madeup__"
config.DATA_FOLDER = madeup_path
try:
with config.isolate_data_folder():
assert config.DATA_FOLDER != original_data_folder
finally:
config.DATA_FOLDER = original_data_folder


def test_isolate_data_folder_already_isolated(config):
data_folder = config.DATA_FOLDER
with config.isolate_data_folder():
# Already isolated, so there is no change.
assert config.DATA_FOLDER == data_folder


def test_isolate_data_folder_keep(config):
original_data_folder = config.DATA_FOLDER
madeup_path = Path.home() / ".ape" / "__core_aoe_test__"
madeup_path.mkdir(parents=True, exist_ok=True)
sub_dir = madeup_path / "subdir"
file = sub_dir / "file.txt"
sub_dir.mkdir(parents=True, exist_ok=True)
file.write_text("search for 'test_isolate_data_folder_keep' ape's tests.")

config.DATA_FOLDER = madeup_path
try:
with config.isolate_data_folder(keep="subdir"):
assert config.DATA_FOLDER != original_data_folder

expected_file = config.DATA_FOLDER / "subdir" / "file.txt"
assert expected_file.is_file()

finally:
config.DATA_FOLDER = original_data_folder
if madeup_path.is_dir():
shutil.rmtree(str(madeup_path), ignore_errors=True)
8 changes: 8 additions & 0 deletions tests/functional/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,14 @@ def test_get_project_path_unneeded_v_prefix(self, cache, data_folder):
actual = cache.get_project_path("project-test-2", "v1.0.0")
assert actual == path

def test_isolate_cache_changes(self, cache):
dep = LocalDependency(name="isotestdep", local=Path("isotestdep"), version="v1.0.0")
with cache.isolate_changes():
path = cache.cache_api(dep)
assert path.is_file()

assert not path.is_file()


class TestLocalDependency:
NAME = "testlocaldep"
Expand Down
13 changes: 10 additions & 3 deletions tests/functional/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -1065,9 +1065,16 @@ def test_chdir(project):
project.chdir(new_path)
assert project.path == new_path

# Undo.
project.chdir(original_path)
assert project.path == original_path
# Undo.
project.chdir(original_path)
assert project.path == original_path

# Show you can also use it in a context.
with project.chdir(new_path):
assert project.path == new_path

# It should have automatically undone.
assert project.path == original_path


def test_within_project_path():
Expand Down

0 comments on commit 964635d

Please sign in to comment.