Skip to content

Commit 2b65308

Browse files
committed
install-wheel: add support for specifying python startup flags
``` gpep517 install-wheel --shebang-flags='-s' ``` will cause all /usr/bin scripts to pass `-s` to the interpreter in its shebang. Useful for ensuring that system commands do not import mismatched modules from `pip install --user`. Also useful in theory to produce programs that run via -OO, something the python ecosystem rarely remembers is possible because scripts cannot be easily fine-tuned and short of changing the entrypoint script you cannot activate -O. Perhaps we could change that.
1 parent cc88681 commit 2b65308

File tree

2 files changed

+172
-2
lines changed

2 files changed

+172
-2
lines changed

gpep517/__main__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,18 +236,20 @@ def parse_optimize_arg(val):
236236

237237
def install_wheel_impl(args, wheel: Path):
238238
from installer import install
239-
from installer.destinations import SchemeDictionaryDestination
240239
from installer.sources import WheelFile
241240
from installer.utils import get_launcher_kind
242241

242+
from .scheme import Gpep517WheelDestination
243+
243244
with WheelFile.open(wheel) as source:
244-
dest = SchemeDictionaryDestination(
245+
dest = Gpep517WheelDestination(
245246
install_scheme_dict(args.prefix or DEFAULT_PREFIX,
246247
source.distribution),
247248
str(args.interpreter),
248249
get_launcher_kind(),
249250
bytecode_optimization_levels=args.optimize,
250251
destdir=str(args.destdir),
252+
script_flags=args.interpreter_flags,
251253
)
252254
logger.info(f"Installing {wheel} into {args.destdir}")
253255
install(source, dest, {})
@@ -355,6 +357,9 @@ def add_install_args(parser):
355357
"to compile bytecode for (default: none), pass 'all' "
356358
"to enable all known optimization levels (currently: "
357359
f"{', '.join(str(x) for x in ALL_OPT_LEVELS)})")
360+
group.add_argument('--shebang-flags',
361+
help='Additional python flags to pass at startup '
362+
'(e.g. `python -s`)')
358363

359364

360365
def main(argv=sys.argv):

gpep517/scheme.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2020 Pradyun Gedam
2+
# SPDX-License-Identifier: MIT
3+
4+
# Most of this file is copied from https://github.com/pypa/installer/
5+
# with minor tweaks where classes don't have hooks to control their
6+
# internals.
7+
8+
from __future__ import annotations
9+
10+
import contextlib
11+
import io
12+
import os
13+
import shlex
14+
import zipfile
15+
import typing as T
16+
17+
from installer.destinations import SchemeDictionaryDestination
18+
from installer.scripts import Script
19+
from installer.utils import Scheme
20+
21+
if T.TYPE_CHECKING:
22+
from installer.records import RecordEntry
23+
from installer.scripts import LauncherKind, ScriptSection
24+
25+
# Borrowed from https://github.com/python/cpython/blob/v3.9.1/Lib/shutil.py#L52
26+
_WINDOWS = os.name == "nt"
27+
_COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024
28+
29+
_SCRIPT_TEMPLATE = '''
30+
# -*- coding: utf-8 -*-
31+
import re
32+
import sys
33+
from {module} import {import_name}
34+
if __name__ == '__main__':
35+
sys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])
36+
sys.exit({func_path}())
37+
'''
38+
39+
40+
def build_shebang(executable: str, forlauncher: bool,
41+
post_interp: str = '') -> bytes:
42+
"""Copy of installer.scripts.build_shebang, that supports overriding flags.
43+
44+
Basically revert some exclusions from the original distlib code.
45+
"""
46+
post_interp_ = ' ' + post_interp.lstrip() if post_interp else ''
47+
48+
if forlauncher:
49+
simple = True
50+
else:
51+
# some systems support more than 127 but 127 is what is portable
52+
# - https://www.in-ulm.de/~mascheck/various/shebang/#length
53+
length = len(executable) + len(post_interp_) + 3
54+
simple = ' ' not in executable and length <= 127
55+
56+
if forlauncher or simple:
57+
shebang = '#!' + executable + post_interp_
58+
else:
59+
quoted = shlex.quote(executable)
60+
# Shebang support for an executable with a space in it is
61+
# under-specified and platform-dependent, so we use a clever hack to
62+
# generate a script to run in ``/bin/sh`` that should work on all
63+
# reasonably modern platforms.
64+
shebang = '#!/bin/sh\n'
65+
66+
# This is polyglot code, that is valid sh to re-exec the file with a
67+
# new command interpreter, but also a python triple-quoted comment
68+
# string. Since shell only supports single/double quotes, the sequence
69+
# '''exec' ...... ''' can comment out code. The "exec" command has
70+
# unnecessary but syntactically valid sh command quoting. All lines
71+
# after the exec line are not parsed.
72+
shebang += f"'''exec' {quoted}{post_interp_}" + ' "$0" "$@"\n'
73+
shebang += "'''"
74+
return shebang.encode('utf-8')
75+
76+
77+
@contextlib.contextmanager
78+
def fix_shebang(stream: T.BinaryIO, interpreter: str,
79+
flags: str = '') -> T.Iterator[T.BinaryIO]:
80+
"""Copy of installer.utils.fix_shebang, that supports overriding flags."""
81+
82+
if flags:
83+
flags = f' {flags}'
84+
85+
stream.seek(0)
86+
if stream.read(8) == b'#!python':
87+
new_stream = io.BytesIO()
88+
# write our new shebang
89+
# gpep517: use build_shebang
90+
new_stream.write(build_shebang(interpreter, False, flags) + b'\n')
91+
# copy the rest of the stream
92+
stream.seek(0)
93+
stream.readline() # skip first line
94+
while True:
95+
buf = stream.read(_COPY_BUFSIZE)
96+
if not buf:
97+
break
98+
new_stream.write(buf)
99+
new_stream.seek(0)
100+
yield new_stream
101+
new_stream.close()
102+
else:
103+
stream.seek(0)
104+
yield stream
105+
106+
107+
class Gpep517Script(Script):
108+
def generate(self, executable: str, kind: LauncherKind,
109+
flags: str = '') -> T.Tuple[str, bytes]:
110+
"""Generate the executable for the script
111+
112+
Either a python script or a win32 launcher exe with a python
113+
script embedded as a zipapp.
114+
"""
115+
# XXX: undocumented self._get_launcher_data
116+
launcher = self._get_launcher_data(kind)
117+
shebang = build_shebang(executable, bool(launcher), flags)
118+
code = _SCRIPT_TEMPLATE.format(
119+
module=self.module,
120+
import_name=self.attr.split('.')[0],
121+
func_path=self.attr
122+
).encode('utf-8')
123+
124+
if launcher is None:
125+
return (self.name, shebang + b'\n' + code)
126+
127+
stream = io.BytesIO()
128+
with zipfile.ZipFile(stream, 'w') as zf:
129+
zf.writestr('__main__.py', code)
130+
name = f'{self.name}.exe'
131+
data = launcher + shebang + b'\n' + stream.getvalue()
132+
return (name, data)
133+
134+
135+
class Gpep517WheelDestination(SchemeDictionaryDestination):
136+
def __init__(self, *args, script_flags='', **kwargs):
137+
super().__init__(*args, **kwargs)
138+
self.script_flags = script_flags
139+
140+
def write_file(self, scheme: Scheme, path: T.Union[str, os.PathLike[str]],
141+
stream: T.BinaryIO, is_executable: bool) -> RecordEntry:
142+
spath = os.fspath(path)
143+
144+
if scheme == 'scripts':
145+
with fix_shebang(stream, self.interpreter, self.script_flags) as s:
146+
return self.write_to_fs(scheme, spath, s, is_executable)
147+
return self.write_to_fs(scheme, spath, stream, is_executable)
148+
149+
def write_script(self, name: str, module: str, attr: str,
150+
section: ScriptSection) -> RecordEntry:
151+
script = Gpep517Script(name, module, attr, section)
152+
script_name, data = script.generate(self.interpreter, self.script_kind,
153+
self.script_flags)
154+
155+
with io.BytesIO(data) as stream:
156+
scheme = Scheme('scripts')
157+
entry = self.write_to_fs(scheme, script_name, stream, True)
158+
159+
# XXX: undocumented self._path_with_destdir
160+
path = self._path_with_destdir(Scheme('scripts'), script_name)
161+
mode = os.stat(path).st_mode
162+
mode |= (mode & 0o444) >> 2
163+
os.chmod(path, mode)
164+
165+
return entry

0 commit comments

Comments
 (0)