diff --git a/pex/cache/dirs.py b/pex/cache/dirs.py index 338cf61db..73871ae99 100644 --- a/pex/cache/dirs.py +++ b/pex/cache/dirs.py @@ -336,6 +336,10 @@ def iter_all(cls, pex_root=ENV): for venv_short_dir_symlink in glob.glob( CacheDir.VENVS.path("s", "*", cls.SHORT_SYMLINK_NAME, pex_root=pex_root) ): + # TODO(John Sirois): Explain or limit this Windows hack. + # See: https://github.com/pex-tool/pex/issues/2660#issuecomment-2635441311 + venv_short_dir_symlink = os.path.realpath(venv_short_dir_symlink) + if not os.path.isdir(venv_short_dir_symlink): continue diff --git a/pex/dist_metadata.py b/pex/dist_metadata.py index e1dc2ea76..1aa84fa5f 100644 --- a/pex/dist_metadata.py +++ b/pex/dist_metadata.py @@ -376,6 +376,10 @@ def iter_metadata_files( ): # type: (...) -> Iterator[MetadataFiles] + # TODO(John Sirois): Explain or limit this Windows hack. + # See: https://github.com/pex-tool/pex/issues/2660#issuecomment-2635441311 + location = os.path.realpath(location) + files = [] for metadata_type in restrict_types_to or MetadataType.values(): key = MetadataKey(metadata_type=metadata_type, location=location) diff --git a/pex/environment.py b/pex/environment.py index 44cda7487..03bfb5686 100644 --- a/pex/environment.py +++ b/pex/environment.py @@ -301,11 +301,12 @@ def iter_distributions(self, result_type_wheel_file=False): ): with identify_layout(self.source_pex) as layout: for distribution_name, fingerprint in self._pex_info.distributions.items(): - dist_relpath = os.path.join( - self._pex_info.internal_cache, distribution_name - ) yield FingerprintedDistribution( - distribution=Distribution.load(layout.wheel_file_path(dist_relpath)), + distribution=Distribution.load( + layout.wheel_file_path( + (self._pex_info.internal_cache, distribution_name) + ) + ), fingerprint=fingerprint, ) else: diff --git a/pex/layout.py b/pex/layout.py index 7b67cbfba..bff02068b 100644 --- a/pex/layout.py +++ b/pex/layout.py @@ -116,7 +116,7 @@ def dist_strip_prefix(self, dist_name): @abstractmethod def dist_size( self, - dist_relpath, # type: str + dist_relpath_components, # type: Tuple[str, ...] is_wheel_file, # type: bool ): # type: (...) -> int @@ -126,15 +126,15 @@ def dist_size( def extract_dist( self, dest_dir, # type: str - dist_relpath, # type: str + dist_relpath_components, # type: Tuple[str, ...] is_wheel_file, # type: bool ): # type: (...) -> None raise NotImplementedError() @abstractmethod - def wheel_file_path(self, dist_relpath): - # type: (str) -> str + def wheel_file_path(self, dist_relpath_components): + # type: (Tuple[str, ...]) -> str raise NotImplementedError() @abstractmethod @@ -176,7 +176,8 @@ def _install_distribution( spread_dest = InstalledWheelDir.create( wheel_name=location, install_hash=sha, pex_root=pex_info.pex_root ) - dist_relpath = os.path.join(DEPS_DIR, location) + dist_relpath_components = (DEPS_DIR, location) + dist_relpath = os.path.join(*dist_relpath_components) source = None if is_wheel_file else layout.dist_strip_prefix(location) symlink_src = os.path.relpath( spread_dest, @@ -188,7 +189,7 @@ def _install_distribution( if not spread_chroot.is_finalized(): layout.extract_dist( dest_dir=spread_chroot.work_dir, - dist_relpath=dist_relpath, + dist_relpath_components=dist_relpath_components, is_wheel_file=is_wheel_file, ) @@ -239,7 +240,7 @@ def _ensure_distributions_installed_parallel( verb_past="installed", max_jobs=max_jobs, costing_function=lambda item: layout.dist_size( - os.path.join(DEPS_DIR, item[0]), is_wheel_file=pex_info.deps_are_wheel_files + (DEPS_DIR, item[0]), is_wheel_file=pex_info.deps_are_wheel_files ), ) @@ -267,7 +268,7 @@ def _ensure_distributions_installed( install_serial = dist_count == 1 or pex_info.max_install_jobs == 1 if not install_serial and pex_info.max_install_jobs == -1: total_size = sum( - layout.dist_size(os.path.join(DEPS_DIR, location), pex_info.deps_are_wheel_files) + layout.dist_size((DEPS_DIR, location), pex_info.deps_are_wheel_files) for location in pex_info.distributions ) average_distribution_size = total_size // dist_count @@ -455,40 +456,42 @@ def dist_strip_prefix(self, dist_name): def dist_size( self, - dist_relpath, # type: str + dist_relpath_components, # type: Tuple[str, ...] is_wheel_file, # type: bool ): # type: (...) -> int + zip_relpath = "/".join(dist_relpath_components) if is_wheel_file: - return self.zfp.getinfo(dist_relpath).file_size + return self.zfp.getinfo(zip_relpath).file_size else: return sum( self.zfp.getinfo(name).file_size for name in self.names - if name.startswith(dist_relpath) + if name.startswith(zip_relpath) ) def extract_dist( self, dest_dir, # type: str - dist_relpath, # type: str + dist_relpath_components, # type: Tuple[str, ...] is_wheel_file, # type: bool ): # type: (...) -> None if is_wheel_file: from pex.pep_427 import install_wheel_chroot - install_wheel_chroot(self.wheel_file_path(dist_relpath), dest_dir) + install_wheel_chroot(self.wheel_file_path(dist_relpath_components), dest_dir) else: + zip_relpath = "/".join(dist_relpath_components) for name in self.names: - if name.startswith(dist_relpath) and not name.endswith("/"): + if name.startswith(zip_relpath) and not name.endswith("/"): self.zfp.extract(name, dest_dir) - def wheel_file_path(self, dist_relpath): - # type: (str) -> str + def wheel_file_path(self, dist_relpath_components): + # type: (Tuple[str, ...]) -> str extract_chroot = safe_mkdtemp() - self.zfp.extract(dist_relpath, extract_chroot) - return os.path.join(extract_chroot, dist_relpath) + self.zfp.extract("/".join(dist_relpath_components), extract_chroot) + return os.path.join(extract_chroot, *dist_relpath_components) def extract_code(self, dest_dir): # type: (str) -> None @@ -526,20 +529,21 @@ def extract_bootstrap(self, dest_dir): def dist_size( self, - dist_relpath, # type: str + dist_relpath_components, # type: Tuple[str, ...] is_wheel_file, # type: bool ): # type: (...) -> int - return os.path.getsize(os.path.join(self._path, dist_relpath)) + return os.path.getsize(os.path.join(self._path, *dist_relpath_components)) def extract_dist( self, dest_dir, # type: str - dist_relpath, # type: str + dist_relpath_components, # type: Tuple[str, ...] is_wheel_file, # type: bool ): # type: (...) -> None - dist_path = self.wheel_file_path(dist_relpath) + dist_relpath = os.path.join(*dist_relpath_components) + dist_path = self.wheel_file_path(dist_relpath_components) if is_wheel_file: from pex.pep_427 import install_wheel_chroot @@ -550,9 +554,9 @@ def extract_dist( with open_zip(dist_path) as zfp: zfp.extractall(dest_dir) - def wheel_file_path(self, dist_relpath): - # type: (str) -> str - return os.path.join(self._path, dist_relpath) + def wheel_file_path(self, dist_relpath_components): + # type: (Tuple[str, ...]) -> str + return os.path.join(self._path, *dist_relpath_components) def extract_code(self, dest_dir): # type: (str) -> None @@ -607,31 +611,34 @@ def extract_bootstrap(self, dest_dir): def dist_size( self, - dist_relpath, # type: str + dist_relpath_components, # type: Tuple[str, ...] is_wheel_file, # type: bool ): assert ( is_wheel_file ), "Expected loose layout install to be skipped when deps are pre-installed wheel chroots." - return os.path.getsize(os.path.join(self._path, dist_relpath)) + return os.path.getsize(os.path.join(self._path, *dist_relpath_components)) def extract_dist( self, dest_dir, - dist_relpath, # type: str + dist_relpath_components, # type: Tuple[str, ...] is_wheel_file, # type: bool ): + # type: (...) -> None assert ( is_wheel_file ), "Expected loose layout install to be skipped when deps are pre-installed wheel chroots." from pex.pep_427 import install_wheel_chroot - with TRACER.timed("Installing wheel file {}".format(dist_relpath)): - install_wheel_chroot(self.wheel_file_path(dist_relpath), dest_dir) + with TRACER.timed( + "Installing wheel file {}".format(os.path.join(*dist_relpath_components)) + ): + install_wheel_chroot(self.wheel_file_path(dist_relpath_components), dest_dir) - def wheel_file_path(self, dist_relpath): - # type: (str) -> str - return os.path.join(self._path, dist_relpath) + def wheel_file_path(self, dist_relpath_components): + # type: (Tuple[str, ...]) -> str + return os.path.join(self._path, *dist_relpath_components) def extract_code(self, dest_dir): # type: (str) -> None diff --git a/pex/pep_427.py b/pex/pep_427.py index d1c66cec2..b9eff76d0 100644 --- a/pex/pep_427.py +++ b/pex/pep_427.py @@ -13,13 +13,14 @@ from fileinput import FileInput from textwrap import dedent -from pex import pex_warnings +from pex import pex_warnings, windows from pex.common import is_pyc_file, iter_copytree, open_zip, safe_open, touch from pex.compatibility import commonpath, get_stdout_bytes_buffer from pex.dist_metadata import CallableEntryPoint, Distribution, ProjectNameAndVersion from pex.enum import Enum from pex.executables import chmod_plus_x from pex.interpreter import PythonInterpreter +from pex.os import WINDOWS from pex.pep_376 import InstalledFile, InstalledWheel, Record from pex.pep_503 import ProjectName from pex.sysconfig import SCRIPT_DIR @@ -287,8 +288,9 @@ def record_files( dist = Distribution(location=dest, metadata=wheel.dist_metadata()) entry_points = dist.get_entry_map() - for named_entry_point in itertools.chain.from_iterable( - entry_points.get(key, {}).values() for key in ("console_scripts", "gui_scripts") + for named_entry_point, gui in itertools.chain.from_iterable( + ((value, gui) for value in entry_points.get(key, {}).values()) + for key, gui in (("console_scripts", False), ("gui_scripts", True)) ): entry_point = named_entry_point.entry_point if isinstance(entry_point, CallableEntryPoint): @@ -328,9 +330,12 @@ def record_files( modname=entry_point.module, ) script_abspath = os.path.join(install_paths.scripts, named_entry_point.name) - with safe_open(script_abspath, "w") as fp: - fp.write(script) - chmod_plus_x(fp.name) + if WINDOWS: + script_abspath = windows.create_script(script_abspath, script, gui=gui) + else: + with safe_open(script_abspath, "w") as fp: + fp.write(script) + chmod_plus_x(fp.name) installed_files.append( InstalledWheel.create_installed_file(path=script_abspath, dest_dir=dest) ) diff --git a/pex/scie/configure-binding.py b/pex/scie/configure-binding.py index 0f4b74081..185a05994 100644 --- a/pex/scie/configure-binding.py +++ b/pex/scie/configure-binding.py @@ -19,20 +19,21 @@ def write_bindings( env_file, # type: str pex, # type: str - venv_dir=None, # type: Optional[str] + venv_bin_dir=None, # type: Optional[str] ): # type: (...) -> None with open(env_file, "a") as fp: print("PYTHON=" + sys.executable, file=fp) print("PEX=" + pex, file=fp) - if venv_dir: - print("VENV_BIN_DIR_PLUS_SEP=" + os.path.join(venv_dir, "bin") + os.path.sep, file=fp) + if venv_bin_dir: + print("VENV_BIN_DIR_PLUS_SEP=" + venv_bin_dir + os.path.sep, file=fp) if __name__ == "__main__": parser = ArgumentParser() - parser.add_argument( + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( "--installed-pex-dir", help=( "The final resting install directory of the PEX if it is a zipapp PEX. If left unset, " @@ -40,11 +41,12 @@ def write_bindings( "determined dynamically." ), ) + group.add_argument("--venv-bin-dir", help="The platform-specific venv bin dir name.") options = parser.parse_args() if options.installed_pex_dir: pex = os.path.realpath(options.installed_pex_dir) - venv_dir = None + venv_bin_dir = None # type: Optional[str] else: venv_dir = os.path.realpath( # N.B.: In practice, VIRTUAL_ENV should always be set by the PEX venv __main__.py @@ -52,10 +54,11 @@ def write_bindings( os.environ.get("VIRTUAL_ENV", os.path.dirname(os.path.dirname(sys.executable))) ) pex = venv_dir + venv_bin_dir = os.path.join(venv_dir, options.venv_bin_dir) write_bindings( env_file=os.environ["SCIE_BINDING_ENV"], pex=pex, - venv_dir=venv_dir, + venv_bin_dir=venv_bin_dir, ) sys.exit(0) diff --git a/pex/scie/science.py b/pex/scie/science.py index 14f73916b..bf9512029 100644 --- a/pex/scie/science.py +++ b/pex/scie/science.py @@ -123,95 +123,90 @@ def create_manifests( pex_info = pex.pex_info(include_env_overrides=False) pex_root = "{scie.bindings}/pex_root" - configure_binding_args = [filenames.pex.placeholder, filenames.configure_binding.placeholder] - # N.B.: For the venv case, we let the configure-binding calculate the installed PEX dir - # (venv dir) at runtime since it depends on the interpreter executing the venv PEX. - if not pex_info.venv: - configure_binding_args.append("--installed-pex-dir") - if pex.layout is Layout.LOOSE: - configure_binding_args.append(filenames.pex.placeholder) - else: - production_assert(pex_info.pex_hash is not None) - pex_hash = cast(str, pex_info.pex_hash) - configure_binding_args.append(UnzipDir.create(pex_hash, pex_root=pex_root).path) - - commands = [] # type: List[Dict[str, Any]] - entrypoints = configuration.options.busybox_entrypoints - if entrypoints: - pex_entry_point = parse_entry_point(pex_info.entry_point) - - def default_env(named_entry_point): - # type: (...) -> Dict[str, str] - return pex_info.inject_env if named_entry_point.entry_point == pex_entry_point else {} - - def args( - named_entry_point, # type: NamedEntryPoint - *args # type: str - ): - # type: (...) -> List[str] - all_args = ( - list(pex_info.inject_python_args) - if named_entry_point.entry_point == pex_entry_point - else [] - ) - all_args.extend(args) - if named_entry_point.entry_point == pex_entry_point: - all_args.extend(pex_info.inject_args) - return all_args - - def create_cmd(named_entry_point): - # type: (NamedEntryPoint) -> Dict[str, Any] + def create_commands(platform): + # type: (SysPlatform.Value) -> Iterator[Dict[str, Any]] + entrypoints = configuration.options.busybox_entrypoints + if entrypoints: + pex_entry_point = parse_entry_point(pex_info.entry_point) + + def default_env(named_entry_point): + # type: (...) -> Dict[str, str] + return ( + pex_info.inject_env if named_entry_point.entry_point == pex_entry_point else {} + ) - if ( - configuration.options.busybox_pex_entrypoint_env_passthrough - and named_entry_point.entry_point == pex_entry_point + def args( + named_entry_point, # type: NamedEntryPoint + *args # type: str ): - env = {"default": default_env(named_entry_point), "remove_exact": ["PEX_VENV"]} - else: - env = { - "default": default_env(named_entry_point), - "replace": {"PEX_MODULE": str(named_entry_point.entry_point)}, - "remove_exact": ["PEX_INTERPRETER", "PEX_SCRIPT", "PEX_VENV"], - } - return { - "name": named_entry_point.name, - "env": env, - "exe": "{scie.bindings.configure:PYTHON}", - "args": args(named_entry_point, "{scie.bindings.configure:PEX}"), - } - - if pex_info.venv and not configuration.options.busybox_pex_entrypoint_env_passthrough: - # N.B.: Executing the console script directly instead of bouncing through the PEX - # __main__.py using PEX_SCRIPT saves ~10ms of re-exec overhead in informal testing; so - # it's worth specializing here. - commands.extend( - { - "name": named_entry_point.name, - "env": { + # type: (...) -> List[str] + all_args = ( + list(pex_info.inject_python_args) + if named_entry_point.entry_point == pex_entry_point + else [] + ) + all_args.extend(args) + if named_entry_point.entry_point == pex_entry_point: + all_args.extend(pex_info.inject_args) + return all_args + + def create_cmd(named_entry_point): + # type: (NamedEntryPoint) -> Dict[str, Any] + + if ( + configuration.options.busybox_pex_entrypoint_env_passthrough + and named_entry_point.entry_point == pex_entry_point + ): + env = {"default": default_env(named_entry_point), "remove_exact": ["PEX_VENV"]} + else: + env = { "default": default_env(named_entry_point), - "remove_exact": ["PEX_INTERPRETER", "PEX_MODULE", "PEX_SCRIPT", "PEX_VENV"], - }, + "replace": {"PEX_MODULE": str(named_entry_point.entry_point)}, + "remove_exact": ["PEX_INTERPRETER", "PEX_SCRIPT", "PEX_VENV"], + } + return { + "name": named_entry_point.name, + "env": env, "exe": "{scie.bindings.configure:PYTHON}", - "args": args( - named_entry_point, - "{scie.bindings.configure:VENV_BIN_DIR_PLUS_SEP}" + named_entry_point.name, - ), + "args": args(named_entry_point, "{scie.bindings.configure:PEX}"), } - for named_entry_point in entrypoints.console_scripts_manifest.collect(pex) - ) + + if pex_info.venv and not configuration.options.busybox_pex_entrypoint_env_passthrough: + # N.B.: Executing the console script directly instead of bouncing through the PEX + # __main__.py using PEX_SCRIPT saves ~10ms of re-exec overhead in informal testing; so + # it's worth specializing here. + for named_entry_point in entrypoints.console_scripts_manifest.collect(pex): + yield { + "name": named_entry_point.name, + "env": { + "default": default_env(named_entry_point), + "remove_exact": [ + "PEX_INTERPRETER", + "PEX_MODULE", + "PEX_SCRIPT", + "PEX_VENV", + ], + }, + "exe": "{scie.bindings.configure:PYTHON}", + "args": args( + named_entry_point, + "{scie.bindings.configure:VENV_BIN_DIR_PLUS_SEP}" + + platform.binary_name(named_entry_point.name), + ), + } + else: + for named_entry_point in entrypoints.console_scripts_manifest.collect(pex): + yield create_cmd(named_entry_point) + for named_entry_point in entrypoints.ad_hoc_entry_points: + yield create_cmd(named_entry_point) else: - commands.extend(map(create_cmd, entrypoints.console_scripts_manifest.collect(pex))) - commands.extend(map(create_cmd, entrypoints.ad_hoc_entry_points)) - else: - commands.append( - { + yield { "env": { "remove_exact": ["PEX_VENV"], }, "exe": "{scie.bindings.configure:PYTHON}", "args": ["{scie.bindings.configure:PEX}"], } - ) lift = { "name": name, @@ -222,27 +217,25 @@ def create_cmd(named_entry_point): }, "scie_jump": {"version": SCIE_JUMP_VERSION}, "files": [{"name": filenames.configure_binding.name}, {"name": filenames.pex.name}], - "commands": commands, - "bindings": [ - { - "env": { - "remove_exact": ["PATH"], - "remove_re": ["PEX_.*", "PYTHON.*"], - "replace": { - "PEX_ROOT": pex_root, - "PEX_INTERPRETER": "1", - # We can get a warning about too-long script shebangs, but this is not - # relevant since we above run the PEX via python and not via shebang. - "PEX_EMIT_WARNINGS": "0", - }, - }, - "name": "configure", - "exe": "#{python-distribution:python}", - "args": configure_binding_args, - } - ], } # type: Dict[str, Any] + configure_binding = { + "env": { + "remove_exact": ["PATH"], + "remove_re": ["PEX_.*", "PYTHON.*"], + "replace": { + "PEX_ROOT": pex_root, + "PEX_INTERPRETER": "1", + # We can get a warning about too-long script shebangs, but this is not + # relevant since we above run the PEX via python and not via shebang. + "PEX_EMIT_WARNINGS": "0", + }, + }, + "name": "configure", + "exe": "#{python-distribution:python}", + } + + configure_binding_args = [filenames.pex.placeholder, filenames.configure_binding.placeholder] for interpreter in configuration.interpreters: manifest_path = os.path.join( safe_mkdtemp(), @@ -266,6 +259,21 @@ def create_cmd(named_entry_point): ) ) + # N.B.: For the venv case, we let the configure-binding calculate the installed PEX dir + # (venv dir) at runtime since it depends on the interpreter executing the venv PEX. + if pex_info.venv: + extra_configure_binding_args = ["--venv-bin-dir", interpreter.platform.venv_bin_dir] + else: + extra_configure_binding_args = ["--installed-pex-dir"] + if pex.layout is Layout.LOOSE: + extra_configure_binding_args.append(filenames.pex.placeholder) + else: + production_assert(pex_info.pex_hash is not None) + pex_hash = cast(str, pex_info.pex_hash) + extra_configure_binding_args.append( + UnzipDir.create(pex_hash, pex_root=pex_root).path + ) + with safe_open(manifest_path, "wb") as fp: toml.dump( { @@ -273,6 +281,13 @@ def create_cmd(named_entry_point): lift, platforms=[interpreter.platform.value], interpreters=[interpreter_config], + commands=list(create_commands(interpreter.platform)), + bindings=[ + dict( + configure_binding, + args=configure_binding_args + extra_configure_binding_args, + ) + ], ) }, fp, diff --git a/pex/sysconfig.py b/pex/sysconfig.py index 152ffce01..57668b68f 100644 --- a/pex/sysconfig.py +++ b/pex/sysconfig.py @@ -9,7 +9,7 @@ from sysconfig import get_config_var from pex.enum import Enum -from pex.os import WINDOWS, Os +from pex.os import Os from pex.typing import TYPE_CHECKING if TYPE_CHECKING: @@ -35,12 +35,6 @@ def script_name(name): return name if (ext and ext.lower() in EXE_EXTENSIONS) else name + EXE_EXTENSION -# TODO(John Sirois): Consider using `sysconfig.get_path("scripts", expand=False)` in combination -# with either sysconfig.get_config_vars() or Formatter().parse() to pick apart the script dir -# suffix from any base dir template. -SCRIPT_DIR = "Scripts" if WINDOWS else "bin" - - class _CurrentPlatform(object): def __get__(self, obj, objtype=None): # type: (...) -> SysPlatform.Value @@ -90,6 +84,11 @@ def extension(self): # type: () -> str return ".exe" if self.os is Os.WINDOWS else "" + @property + def venv_bin_dir(self): + # type: () -> str + return "Scripts" if self.os is Os.WINDOWS else "bin" + def binary_name(self, binary_name): # type: (_Text) -> _Text return "{binary_name}{extension}".format(binary_name=binary_name, extension=self.extension) @@ -128,3 +127,9 @@ def parse(cls, value): SysPlatform.seal() + + +# TODO(John Sirois): Consider using `sysconfig.get_path("scripts", expand=False)` in combination +# with either sysconfig.get_config_vars() or Formatter().parse() to pick apart the script dir +# suffix from any base dir template. +SCRIPT_DIR = SysPlatform.CURRENT.venv_bin_dir diff --git a/pex/windows/__init__.py b/pex/windows/__init__.py index 790527624..9ca4fe027 100644 --- a/pex/windows/__init__.py +++ b/pex/windows/__init__.py @@ -17,7 +17,7 @@ from pex.typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Iterator, Optional, Text + from typing import Iterator, Optional, Text, TypeVar import attr # vendor:skip else: @@ -111,16 +111,20 @@ def fetch_all_stubs(): yield _load_stub(platform=platform, gui=gui) +if TYPE_CHECKING: + _Text = TypeVar("_Text", str, Text) + + def create_script( - path, # type: Text + path, # type: _Text contents, # type: Text platform=SysPlatform.CURRENT, # type: SysPlatform.Value gui=False, # type: bool python_path=None, # type: Optional[Text] ): - # type: (...) -> None + # type: (...) -> _Text - with open("{path}.{unique}".format(path=path, unique=uuid.uuid4().hex), "wb") as fp: + with safe_open("{path}.{unique}".format(path=path, unique=uuid.uuid4().hex), "wb") as fp: fp.write(_load_stub(platform=platform, gui=gui).read_data()) with contextlib.closing(zipfile.ZipFile(fp, "a")) as zip_fp: zip_fp.writestr("__main__.py", contents.encode("utf-8"), zipfile.ZIP_STORED) @@ -130,4 +134,6 @@ def create_script( fp.write(python_path_bytes) fp.write(struct.pack("