Skip to content

add option to respect platform's cache directories #251

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
147 changes: 137 additions & 10 deletions src/shiv/bootstrap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from functools import partial
from importlib import import_module
from pathlib import Path
from typing import Optional, Union

from .environment import Environment
from .filelock import FileLock
Expand Down Expand Up @@ -87,24 +88,149 @@ def import_string(import_name):
raise ImportError(e)


def cache_path(archive, root_dir, build_id):
"""Returns a ~/.shiv cache directory for unzipping site-packages during bootstrap.
def is_dir_writeable(path: Path) -> bool:
"""Whether the given Path `path` is writeable or createable.

:param ZipFile archive: The zipfile object we are bootstrapping from.
Returns whether the *extant portion* of the given path is writeable.
If so, the path is either extant and writeable or its nearest extant
parent is writeable (and as such the path may be created in a
writeable form).

"""
while not path.exists():
parent = path.parent

# reliably determine whether this is the root
if parent == path:
break

path = parent

return os.access(path, os.W_OK)


#
# support for py38
#
def is_relative_to(path: Path, root: Union[Path, str]) -> bool:
"""Return True if the path is relative to another path or False."""
try:
path.relative_to(root)
except ValueError:
return False
else:
return True


def is_system_path(path: Path) -> Optional[bool]:
"""Whether the given `path` appears to be a non-user path.

Returns bool – or None if called on an unsupported platform
(_i.e._ implicitly False).

"""
if sys.platform == 'linux':
return not is_relative_to(path, '/home') and not is_relative_to(path, '/root')

if sys.platform == 'darwin':
return not is_relative_to(path, '/Users')

return None


def system_root() -> Optional[Path]:
"""The platform-preferred global/system-wide path for cached files.

Returns Path – or None if called on an unsupported platform.

"""
if sys.platform == 'linux':
return Path('/var/cache')

if sys.platform == 'darwin':
return Path('/Library/Caches')

return None


def user_root() -> Optional[Path]:
"""The platform-preferred user-specific path for cached files.

Returns Path – or None if called on an unsupported platform.

"""
if sys.platform == 'win32':
root = os.environ.get('LOCALAPPDATA', '').strip() or '~/AppData/Local'
elif sys.platform == 'linux':
root = os.environ.get('XDG_CACHE_HOME', '').strip() or '~/.cache'
elif sys.platform == 'darwin':
root = '~/Library/Caches'
else:
root = None

return Path(root).expanduser() if root is not None else None


def platform_cache(archive_path: Path, build_id: str) -> Optional[Path]:
"""Return a platform-compatible default extraction path.

* If the archive is installed to a system path and a system-wide
cache is either already populated or writeable by the current user:
then the system cache will be used.

* Otherwise: an appropriate user cache will be used, if any.

"""
#
# 1) let's see about a system_root
#
if is_system_path(archive_path):
system_base = system_root()

if system_base is not None:
root = system_base / archive_path.name

cache = cache_path(archive_path, str(root), False, build_id)
site_packages = cache / 'site-packages'

if site_packages.exists() or is_dir_writeable(cache):
return root

#
# 2) let's try a user path
#
user_base = user_root()

if user_base is not None:
return user_base / archive_path.name

return None


def cache_path(archive_path, root_dir, platform_compat, build_id):
"""Returns a shiv cache directory for unzipping site-packages during bootstrap.

:param Path archive_path: The Path of the archive we are bootstrapping from.
:param str root_dir: Optional, either a path or environment variable pointing to a SHIV_ROOT.
:param bool platform_compat: Whether to attempt to fall back to a platform-conventional root.
:param str build_id: The build id generated at zip creation.
"""

"""
if root_dir:

if root_dir.startswith("$"):
root_dir = os.environ.get(root_dir[1:], root_dir[1:])

root_dir = Path(root_dir).expanduser()
root = Path(root_dir).expanduser()
elif platform_compat:
root = platform_cache(archive_path, build_id)
else:
root = None

# platform_compat may be False *OR* platform_cache may return None
if root is None:
root = Path.home() / ".shiv"

root = root_dir or Path("~/.shiv").expanduser()
name = Path(archive.filename).resolve().name
return root / f"{name}_{build_id}"
return root / f"{archive_path.name}_{build_id}"


def extract_site_packages(archive, target_path, compile_pyc=False, compile_workers=0, force=False):
Expand Down Expand Up @@ -190,7 +316,8 @@ def bootstrap(): # pragma: no cover
env = Environment.from_json(archive.read("environment.json").decode())

# get a site-packages directory (from env var or via build id)
site_packages = cache_path(archive, env.root, env.build_id) / "site-packages"
archive_path = Path(archive.filename).resolve()
site_packages = cache_path(archive_path, env.root, env.platform_root, env.build_id) / "site-packages"

# determine if first run or forcing extract
if not site_packages.exists() or env.force_extract:
Expand Down
2 changes: 2 additions & 0 deletions src/shiv/bootstrap/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def __init__(
script=None,
preamble=None,
root=None,
platform_root=False,
):
self.always_write_cache = always_write_cache
self.build_id = build_id
Expand All @@ -47,6 +48,7 @@ def __init__(
self.reproducible = reproducible
self.shiv_version = shiv_version
self.preamble = preamble
self.platform_root = platform_root

# properties
self._entry_point = entry_point
Expand Down
9 changes: 8 additions & 1 deletion src/shiv/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@ def copytree(src: Path, dst: Path) -> None:
"but before invoking your entry point."
),
)
@click.option("--root", type=click.Path(), help="Override the 'root' path (default is ~/.shiv).")
@click.option("--root", type=click.Path(), help="Override the 'root' path (default is ~/.shiv or platform-dependent).")
@click.option(
"--platform-root",
is_flag=True,
help="If specified, the default 'root' path will attempt to conform to platform convention (rather than ~/.shiv).",
)
@click.argument("pip_args", nargs=-1, type=click.UNPROCESSED)
def main(
output_file: str,
Expand All @@ -170,6 +175,7 @@ def main(
no_modify: bool,
preamble: Optional[str],
root: Optional[str],
platform_root: bool,
pip_args: List[str],
) -> None:
"""
Expand Down Expand Up @@ -259,6 +265,7 @@ def main(
reproducible=reproducible,
preamble=Path(preamble).name if preamble else None,
root=root,
platform_root=platform_root,
)

if no_modify:
Expand Down
32 changes: 25 additions & 7 deletions test/test_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from pathlib import Path
from site import addsitedir
from unittest import mock
from uuid import uuid4
from zipfile import ZipFile

import pytest
Expand Down Expand Up @@ -61,14 +60,33 @@ def test_argv0_is_not_zipfile(self):
assert not zipfile

def test_cache_path(self, env_var):
mock_zip = mock.MagicMock(spec=ZipFile)
mock_zip.filename = "test"
uuid = str(uuid4())

assert cache_path(mock_zip, 'foo', uuid) == Path("foo", f"test_{uuid}")
# specified root
assert cache_path(Path('/a/b/test'), 'foo', False, '1234') == Path("foo", "test_1234")

# same with envvar
with env_var("FOO", "foo"):
assert cache_path(mock_zip, '$FOO', uuid) == Path("foo", f"test_{uuid}")
assert cache_path(Path('/a/b/test'), '$FOO', False, '1234') == Path("foo", "test_1234")

# same with platform-compat otherwise enabled
assert cache_path(Path('/a/b/test'), 'foo', True, '1234') == Path("foo", "test_1234")

# platform-compat disabled and root unspecified
assert cache_path(Path('/a/b/test'), None, False, '1234') == Path.home() / ".shiv" / "test_1234"

# platform-compat enabled and root unspecified
if sys.platform == 'linux':
cache_spec = '.cache'
elif sys.platform == 'darwin':
cache_spec = 'Library/Caches'
elif sys.platform == 'win32':
cache_spec = 'AppData/Local'
else:
cache_spec = None

cache_dir = (Path.home() / ".shiv" / "test_1234" if cache_spec is None
else Path.home() / cache_spec / "test" / "test_1234")

assert cache_path(Path('/a/b/test'), None, True, '1234') == cache_dir

def test_first_sitedir_index(self):
with mock.patch.object(sys, "path", ["site-packages", "dir", "dir", "dir"]):
Expand Down