Skip to content

Commit 5f99532

Browse files
committed
add option to attempt to respect platform's cache paths before using ~/.shiv
shiv build option `--platform-root` will enable runtime determination of platform- and executable-appropriate cache directory: 1. If the archive is understood to be globally-installed on the platform, and a known system-wide cache (such as `/var/cache/`) is already populated or writable by the current user, then this will be used. 2. If a platform is understood to feature a user-dedicated cache (such as `~/.cache/`) -- or specifies one via environment variable (such as `XDG_CACHE_HOME`) -- then this will be used. 3. Otherwise, `~/.shiv` will be used. And, in cases (1) and (2), the cache root directory will be named according to the name of the archive -- *not* referencing the toolset which created it (namely shiv) -- for example... Linux: * /var/cache/cowsay/cowsay_3.03/site-packages/ * ~/.cache/cowsay/cowsay_3.03/site-packages/ Darwin / OS X: * /Library/Caches/cowsay/cowsay_3.03/site-packages/ * ~/Library/Caches/cowsay/cowsay_3.03/site-packages/ Windows: * ~/AppData/Local/cowsay/cowsay_3.03/site-packages/
1 parent 0901bcc commit 5f99532

File tree

4 files changed

+172
-18
lines changed

4 files changed

+172
-18
lines changed

src/shiv/bootstrap/__init__.py

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from functools import partial
1313
from importlib import import_module
1414
from pathlib import Path
15+
from typing import Optional, Union
1516

1617
from .environment import Environment
1718
from .filelock import FileLock
@@ -87,24 +88,149 @@ def import_string(import_name):
8788
raise ImportError(e)
8889

8990

90-
def cache_path(archive, root_dir, build_id):
91-
"""Returns a ~/.shiv cache directory for unzipping site-packages during bootstrap.
91+
def is_dir_writeable(path: Path) -> bool:
92+
"""Whether the given Path `path` is writeable or createable.
9293
93-
:param ZipFile archive: The zipfile object we are bootstrapping from.
94+
Returns whether the *extant portion* of the given path is writeable.
95+
If so, the path is either extant and writeable or its nearest extant
96+
parent is writeable (and as such the path may be created in a
97+
writeable form).
98+
99+
"""
100+
while not path.exists():
101+
parent = path.parent
102+
103+
# reliably determine whether this is the root
104+
if parent == path:
105+
break
106+
107+
path = parent
108+
109+
return os.access(path, os.W_OK)
110+
111+
112+
#
113+
# support for py38
114+
#
115+
def is_relative_to(path: Path, root: Union[Path, str]) -> bool:
116+
"""Return True if the path is relative to another path or False."""
117+
try:
118+
path.relative_to(root)
119+
except ValueError:
120+
return False
121+
else:
122+
return True
123+
124+
125+
def is_system_path(path: Path) -> Optional[bool]:
126+
"""Whether the given `path` appears to be a non-user path.
127+
128+
Returns bool – or None if called on an unsupported platform
129+
(_i.e._ implicitly False).
130+
131+
"""
132+
if sys.platform == 'linux':
133+
return not is_relative_to(path, '/home') and not is_relative_to(path, '/root')
134+
135+
if sys.platform == 'darwin':
136+
return not is_relative_to(path, '/Users')
137+
138+
return None
139+
140+
141+
def system_root() -> Optional[Path]:
142+
"""The platform-preferred global/system-wide path for cached files.
143+
144+
Returns Path – or None if called on an unsupported platform.
145+
146+
"""
147+
if sys.platform == 'linux':
148+
return Path('/var/cache')
149+
150+
if sys.platform == 'darwin':
151+
return Path('/Library/Caches')
152+
153+
return None
154+
155+
156+
def user_root() -> Optional[Path]:
157+
"""The platform-preferred user-specific path for cached files.
158+
159+
Returns Path – or None if called on an unsupported platform.
160+
161+
"""
162+
if sys.platform == 'win32':
163+
root = os.environ.get('LOCALAPPDATA', '').strip() or '~/AppData/Local'
164+
elif sys.platform == 'linux':
165+
root = os.environ.get('XDG_CACHE_HOME', '').strip() or '~/.cache'
166+
elif sys.platform == 'darwin':
167+
root = '~/Library/Caches'
168+
else:
169+
root = None
170+
171+
return Path(root).expanduser() if root is not None else None
172+
173+
174+
def platform_cache(archive_path: Path, build_id: str) -> Optional[Path]:
175+
"""Return a platform-compatible default extraction path.
176+
177+
* If the archive is installed to a system path and a system-wide
178+
cache is either already populated or writeable by the current user:
179+
then the system cache will be used.
180+
181+
* Otherwise: an appropriate user cache will be used, if any.
182+
183+
"""
184+
#
185+
# 1) let's see about a system_root
186+
#
187+
if is_system_path(archive_path):
188+
system_base = system_root()
189+
190+
if system_base is not None:
191+
root = system_base / archive_path.name
192+
193+
cache = cache_path(archive_path, str(root), False, build_id)
194+
site_packages = cache / 'site-packages'
195+
196+
if site_packages.exists() or is_dir_writeable(cache):
197+
return root
198+
199+
#
200+
# 2) let's try a user path
201+
#
202+
user_base = user_root()
203+
204+
if user_base is not None:
205+
return user_base / archive_path.name
206+
207+
return None
208+
209+
210+
def cache_path(archive_path, root_dir, platform_compat, build_id):
211+
"""Returns a shiv cache directory for unzipping site-packages during bootstrap.
212+
213+
:param Path archive_path: The Path of the archive we are bootstrapping from.
94214
:param str root_dir: Optional, either a path or environment variable pointing to a SHIV_ROOT.
215+
:param bool platform_compat: Whether to attempt to fall back to a platform-conventional root.
95216
:param str build_id: The build id generated at zip creation.
96-
"""
97217
218+
"""
98219
if root_dir:
99-
100220
if root_dir.startswith("$"):
101221
root_dir = os.environ.get(root_dir[1:], root_dir[1:])
102222

103-
root_dir = Path(root_dir).expanduser()
223+
root = Path(root_dir).expanduser()
224+
elif platform_compat:
225+
root = platform_cache(archive_path, build_id)
226+
else:
227+
root = None
228+
229+
# platform_compat may be False *OR* platform_cache may return None
230+
if root is None:
231+
root = Path.home() / ".shiv"
104232

105-
root = root_dir or Path("~/.shiv").expanduser()
106-
name = Path(archive.filename).resolve().name
107-
return root / f"{name}_{build_id}"
233+
return root / f"{archive_path.name}_{build_id}"
108234

109235

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

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

195322
# determine if first run or forcing extract
196323
if not site_packages.exists() or env.force_extract:

src/shiv/bootstrap/environment.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def __init__(
3838
script=None,
3939
preamble=None,
4040
root=None,
41+
platform_root=False,
4142
):
4243
self.always_write_cache = always_write_cache
4344
self.build_id = build_id
@@ -47,6 +48,7 @@ def __init__(
4748
self.reproducible = reproducible
4849
self.shiv_version = shiv_version
4950
self.preamble = preamble
51+
self.platform_root = platform_root
5052

5153
# properties
5254
self._entry_point = entry_point

src/shiv/cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,12 @@ def copytree(src: Path, dst: Path) -> None:
154154
"but before invoking your entry point."
155155
),
156156
)
157-
@click.option("--root", type=click.Path(), help="Override the 'root' path (default is ~/.shiv).")
157+
@click.option("--root", type=click.Path(), help="Override the 'root' path (default is ~/.shiv or platform-dependent).")
158+
@click.option(
159+
"--platform-root",
160+
is_flag=True,
161+
help="If specified, the default 'root' path will attempt to conform to platform convention (rather than ~/.shiv).",
162+
)
158163
@click.argument("pip_args", nargs=-1, type=click.UNPROCESSED)
159164
def main(
160165
output_file: str,
@@ -170,6 +175,7 @@ def main(
170175
no_modify: bool,
171176
preamble: Optional[str],
172177
root: Optional[str],
178+
platform_root: bool,
173179
pip_args: List[str],
174180
) -> None:
175181
"""
@@ -259,6 +265,7 @@ def main(
259265
reproducible=reproducible,
260266
preamble=Path(preamble).name if preamble else None,
261267
root=root,
268+
platform_root=platform_root,
262269
)
263270

264271
if no_modify:

test/test_bootstrap.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from pathlib import Path
77
from site import addsitedir
88
from unittest import mock
9-
from uuid import uuid4
109
from zipfile import ZipFile
1110

1211
import pytest
@@ -61,14 +60,33 @@ def test_argv0_is_not_zipfile(self):
6160
assert not zipfile
6261

6362
def test_cache_path(self, env_var):
64-
mock_zip = mock.MagicMock(spec=ZipFile)
65-
mock_zip.filename = "test"
66-
uuid = str(uuid4())
67-
68-
assert cache_path(mock_zip, 'foo', uuid) == Path("foo", f"test_{uuid}")
63+
# specified root
64+
assert cache_path(Path('/a/b/test'), 'foo', False, '1234') == Path("foo", "test_1234")
6965

66+
# same with envvar
7067
with env_var("FOO", "foo"):
71-
assert cache_path(mock_zip, '$FOO', uuid) == Path("foo", f"test_{uuid}")
68+
assert cache_path(Path('/a/b/test'), '$FOO', False, '1234') == Path("foo", "test_1234")
69+
70+
# same with platform-compat otherwise enabled
71+
assert cache_path(Path('/a/b/test'), 'foo', True, '1234') == Path("foo", "test_1234")
72+
73+
# platform-compat disabled and root unspecified
74+
assert cache_path(Path('/a/b/test'), None, False, '1234') == Path.home() / ".shiv" / "test_1234"
75+
76+
# platform-compat enabled and root unspecified
77+
if sys.platform == 'linux':
78+
cache_spec = '.cache'
79+
elif sys.platform == 'darwin':
80+
cache_spec = 'Library/Caches'
81+
elif sys.platform == 'win32':
82+
cache_spec = 'AppData/Local'
83+
else:
84+
cache_spec = None
85+
86+
cache_dir = (Path.home() / ".shiv" / "test_1234" if cache_spec is None
87+
else Path.home() / cache_spec / "test" / "test_1234")
88+
89+
assert cache_path(Path('/a/b/test'), None, True, '1234') == cache_dir
7290

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

0 commit comments

Comments
 (0)