Skip to content

Commit 91e2077

Browse files
committed
add option to abide by XDG directory specification rather than use ~/.shiv
shiv build option `--xdg` will enable **dynamic** runtime determination of appropriate XDG-compatible cache directory. For example, if the archive is globally-installed, and /var/cache/ is writable – *or* a satisfactory extant cache directory is discovered there to read – then this will be used. Alternatively, the directory specified by environment variable XDG_CACHE_HOME will be used. Finally, the directory ~/.cache/ will be used.
1 parent 0901bcc commit 91e2077

4 files changed

Lines changed: 112 additions & 9 deletions

File tree

src/shiv/bootstrap/__init__.py

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,23 +87,106 @@ def import_string(import_name):
8787
raise ImportError(e)
8888

8989

90-
def cache_path(archive, root_dir, build_id):
90+
def is_dir_writeable(path):
91+
"""Whether the given Path `path` is writeable or createable.
92+
93+
Returns whether the *extant portion* of the given path is writeable.
94+
If so, the path is either extant and writeable or its nearest extant
95+
parent is writeable (and as such the path may be created in a
96+
writeable form).
97+
98+
"""
99+
while not path.exists():
100+
parent = path.parent
101+
102+
# reliably determine whether this is the root
103+
if parent == path:
104+
break
105+
106+
path = parent
107+
108+
return os.access(path, os.W_OK)
109+
110+
111+
#
112+
# support for py38
113+
#
114+
def is_relative_to(path, root):
115+
"""Return True if the path is relative to another path or False."""
116+
try:
117+
path.relative_to(root)
118+
except ValueError:
119+
return False
120+
else:
121+
return True
122+
123+
124+
def is_system_path(path):
125+
"""Whether the given Path `path` appears to be a non-user path.
126+
127+
Returns bool – or None if called on an unsupported platform
128+
(_i.e._ implicitly False).
129+
130+
"""
131+
if sys.platform == 'linux':
132+
return not is_relative_to(path, '/home') and not is_relative_to(path, '/root')
133+
134+
if sys.platform == 'darwin':
135+
return not is_relative_to(path, '/Users')
136+
137+
138+
def xdg_root(archive,
139+
build_id,
140+
system_base='/var/cache',
141+
user_base=os.getenv('XDG_CACHE_HOME', '~/.cache')):
142+
"""Return an XDG-compatible default extraction path.
143+
144+
* If the archive is installed to a system path and `system_base` is
145+
either already populated or writeable by the current user:
146+
`system_base` will be used.
147+
148+
* Otherwise: `user_base` will be used.
149+
150+
"""
151+
archive_path = Path(archive.filename).resolve()
152+
153+
#
154+
# 1) let's see about system_base
155+
#
156+
if is_system_path(archive_path):
157+
root = Path(system_base) / archive_path.name
158+
159+
cache = cache_path(archive, str(root), False, build_id)
160+
site_packages = cache / 'site-packages'
161+
162+
if site_packages.exists() or is_dir_writeable(cache):
163+
return root
164+
165+
#
166+
# 2) at least let's try to respect XDG
167+
#
168+
return Path(user_base).expanduser() / archive_path.name
169+
170+
171+
def cache_path(archive, root_dir, xdg_compat, build_id):
91172
"""Returns a ~/.shiv cache directory for unzipping site-packages during bootstrap.
92173
93174
:param ZipFile archive: The zipfile object we are bootstrapping from.
94175
:param str root_dir: Optional, either a path or environment variable pointing to a SHIV_ROOT.
95176
:param str build_id: The build id generated at zip creation.
96177
"""
97-
98178
if root_dir:
99-
100179
if root_dir.startswith("$"):
101180
root_dir = os.environ.get(root_dir[1:], root_dir[1:])
102181

103-
root_dir = Path(root_dir).expanduser()
182+
root = Path(root_dir).expanduser()
183+
elif xdg_compat:
184+
root = xdg_root(archive, build_id)
185+
else:
186+
root = Path("~/.shiv").expanduser()
104187

105-
root = root_dir or Path("~/.shiv").expanduser()
106188
name = Path(archive.filename).resolve().name
189+
107190
return root / f"{name}_{build_id}"
108191

109192

@@ -190,7 +273,7 @@ def bootstrap(): # pragma: no cover
190273
env = Environment.from_json(archive.read("environment.json").decode())
191274

192275
# 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"
276+
site_packages = cache_path(archive, env.root, env.xdg, env.build_id) / "site-packages"
194277

195278
# determine if first run or forcing extract
196279
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+
xdg=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.xdg = xdg
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 XDG or ~/.shiv).")
158+
@click.option(
159+
"--xdg",
160+
is_flag=True,
161+
help="If specified, the default 'root' path will conform to the XDG specification (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+
xdg: 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+
xdg=xdg,
262269
)
263270

264271
if no_modify:

test/test_bootstrap.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,21 @@ def test_cache_path(self, env_var):
6565
mock_zip.filename = "test"
6666
uuid = str(uuid4())
6767

68-
assert cache_path(mock_zip, 'foo', uuid) == Path("foo", f"test_{uuid}")
68+
# specified root
69+
assert cache_path(mock_zip, 'foo', False, uuid) == Path("foo", f"test_{uuid}")
6970

71+
# same as envvar
7072
with env_var("FOO", "foo"):
71-
assert cache_path(mock_zip, '$FOO', uuid) == Path("foo", f"test_{uuid}")
73+
assert cache_path(mock_zip, '$FOO', False, uuid) == Path("foo", f"test_{uuid}")
74+
75+
# same with xdg otherwise enabled
76+
assert cache_path(mock_zip, 'foo', True, uuid) == Path("foo", f"test_{uuid}")
77+
78+
# xdg enabled and root unspecified
79+
assert cache_path(mock_zip, None, True, uuid) == Path.home() / ".cache" / "test" / f"test_{uuid}"
80+
81+
# xdg disabled and root unspecified
82+
assert cache_path(mock_zip, None, False, uuid) == Path.home() / ".shiv" / f"test_{uuid}"
7283

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

0 commit comments

Comments
 (0)