diff --git a/src/shiv/bootstrap/__init__.py b/src/shiv/bootstrap/__init__.py index 6f0c584..716e80a 100644 --- a/src/shiv/bootstrap/__init__.py +++ b/src/shiv/bootstrap/__init__.py @@ -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 @@ -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): @@ -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: diff --git a/src/shiv/bootstrap/environment.py b/src/shiv/bootstrap/environment.py index 74cd53b..9c44322 100644 --- a/src/shiv/bootstrap/environment.py +++ b/src/shiv/bootstrap/environment.py @@ -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 @@ -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 diff --git a/src/shiv/cli.py b/src/shiv/cli.py index 8f5c93e..332e3e3 100644 --- a/src/shiv/cli.py +++ b/src/shiv/cli.py @@ -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, @@ -170,6 +175,7 @@ def main( no_modify: bool, preamble: Optional[str], root: Optional[str], + platform_root: bool, pip_args: List[str], ) -> None: """ @@ -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: diff --git a/test/test_bootstrap.py b/test/test_bootstrap.py index 09eaabf..7158e02 100644 --- a/test/test_bootstrap.py +++ b/test/test_bootstrap.py @@ -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 @@ -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"]):