# -*- coding: utf-8 -*-
#
# Copyright (C) 2013-2023 Vinay Sajip.
# Licensed to the Python Software Foundation under a contributor agreement.
# See LICENSE.txt and CONTRIBUTORS.txt.
#
from io
import BytesIO
import logging
import os
import re
import struct
import sys
import time
from zipfile
import ZipInfo
from .compat
import sysconfig, detect_encoding, ZipFile
from .resources
import finder
from .util
import (FileOperator, get_export_entry, convert_path,
get_executable, get_platform, in_venv)
logger = logging.getLogger(__name__)
_DEFAULT_MANIFEST =
'''
<?xml version=
"1.0" encoding=
"UTF-8" standalone=
"yes"?>
<assembly xmlns=
"urn:schemas-microsoft-com:asm.v1" manifestVersion=
"1.0">
<assemblyIdentity version=
"1.0.0.0"
processorArchitecture=
"X86"
name=
"%s"
type=
"win32"/>
<!-- Identify the application security requirements. -->
<trustInfo xmlns=
"urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level=
"asInvoker" uiAccess=
"false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
'''.strip()
# check if Python is called on the first line with this expression
FIRST_LINE_RE = re.compile(b
'^#!.*pythonw?[0-9.]*([ \t].*)?$')
SCRIPT_TEMPLATE = r
'''# -*- coding: utf-8 -*-
import re
import sys
from %(module)s
import %(import_name)s
if __name__ ==
'__main__':
sys.argv[0] = re.sub(r
'(-script\.pyw|\.exe)?$',
'', sys.argv[0])
sys.exit(%(func)s())
'''
def enquote_executable(executable):
if ' ' in executable:
# make sure we quote only the executable in case of env
# for example /usr/bin/env "/dir with spaces/bin/jython"
# instead of "/usr/bin/env /dir with spaces/bin/jython"
# otherwise whole
if executable.startswith(
'/usr/bin/env '):
env, _executable = executable.split(
' ', 1)
if ' ' in _executable
and not _executable.startswith(
'"'):
executable =
'%s "%s"' % (env, _executable)
else:
if not executable.startswith(
'"'):
executable =
'"%s"' % executable
return executable
# Keep the old name around (for now), as there is at least one project using it!
_enquote_executable = enquote_executable
class ScriptMaker(object):
"""
A
class to copy
or create scripts
from source scripts
or callable
specifications.
"""
script_template = SCRIPT_TEMPLATE
executable =
None # for shebangs
def __init__(self,
source_dir,
target_dir,
add_launchers=
True,
dry_run=
False,
fileop=
None):
self.source_dir = source_dir
self.target_dir = target_dir
self.add_launchers = add_launchers
self.force =
False
self.clobber =
False
# It only makes sense to set mode bits on POSIX.
self.set_mode = (os.name ==
'posix')
or (os.name ==
'java'
and os._name ==
'posix')
self.variants = set((
'',
'X.Y'))
self._fileop = fileop
or FileOperator(dry_run)
self._is_nt = os.name ==
'nt' or (os.name ==
'java'
and os._name ==
'nt')
self.version_info = sys.version_info
def _get_alternate_executable(self, executable, options):
if options.get(
'gui',
False)
and self._is_nt:
# pragma: no cover
dn, fn = os.path.split(executable)
fn = fn.replace(
'python',
'pythonw')
executable = os.path.join(dn, fn)
return executable
if sys.platform.startswith(
'java'):
# pragma: no cover
def _is_shell(self, executable):
"""
Determine
if the specified executable
is a script
(contains a
#! line)
"""
try:
with open(executable)
as fp:
return fp.read(2) ==
'#!'
except (OSError, IOError):
logger.warning(
'Failed to open %s', executable)
return False
def _fix_jython_executable(self, executable):
if self._is_shell(executable):
# Workaround for Jython is not needed on Linux systems.
import java
if java.lang.System.getProperty(
'os.name') ==
'Linux':
return executable
elif executable.lower().endswith(
'jython.exe'):
# Use wrapper exe for Jython on Windows
return executable
return '/usr/bin/env %s' % executable
def _build_shebang(self, executable, post_interp):
"""
Build a shebang line.
In the simple case (on Windows,
or a shebang line
which
is not too long
or contains spaces) use a simple formulation
for
the shebang. Otherwise, use /bin/sh
as the executable,
with a contrived
shebang which allows the script to run either under Python
or sh, using
suitable quoting. Thanks to Harald Nordgren
for his input.
See also:
http://www.in-ulm.de/~mascheck/various/shebang/#length
https://hg.mozilla.org/mozilla-central/file/tip/mach
"""
if os.name !=
'posix':
simple_shebang =
True
else:
# Add 3 for '#!' prefix and newline suffix.
shebang_length = len(executable) + len(post_interp) + 3
if sys.platform ==
'darwin':
max_shebang_length = 512
else:
max_shebang_length = 127
simple_shebang = ((b
' ' not in executable)
and (shebang_length <= max_shebang_length))
if simple_shebang:
result = b
'#!' + executable + post_interp + b'\n'
else:
result = b
'#!/bin/sh\n'
result += b
"'''exec' " + executable + post_interp + b
' "$0" "$@"\n'
result += b
"' '''"
return result
def _get_shebang(self, encoding, post_interp=b
'', options=
None):
enquote =
True
if self.executable:
executable = self.executable
enquote =
False # assume this will be taken care of
elif not sysconfig.is_python_build():
executable = get_executable()
elif in_venv():
# pragma: no cover
executable = os.path.join(
sysconfig.get_path(
'scripts'),
'python%s' % sysconfig.get_config_var(
'EXE'))
else:
# pragma: no cover
if os.name ==
'nt':
# for Python builds from source on Windows, no Python executables with
# a version suffix are created, so we use python.exe
executable = os.path.join(
sysconfig.get_config_var(
'BINDIR'),
'python%s' % (sysconfig.get_config_var(
'EXE')))
else:
executable = os.path.join(
sysconfig.get_config_var(
'BINDIR'),
'python%s%s' % (sysconfig.get_config_var(
'VERSION'),
sysconfig.get_config_var(
'EXE')))
if options:
executable = self._get_alternate_executable(executable, options)
if sys.platform.startswith(
'java'):
# pragma: no cover
executable = self._fix_jython_executable(executable)
# Normalise case for Windows - COMMENTED OUT
# executable = os.path.normcase(executable)
# N.B. The normalising operation above has been commented out: See
# issue #124. Although paths in Windows are generally case-insensitive,
# they aren't always. For example, a path containing a ẞ (which is a
# LATIN CAPITAL LETTER SHARP S - U+1E9E) is normcased to ß (which is a
# LATIN SMALL LETTER SHARP S' - U+00DF). The two are not considered by
# Windows as equivalent in path names.
# If the user didn't specify an executable, it may be necessary to
# cater for executable paths with spaces (not uncommon on Windows)
if enquote:
executable = enquote_executable(executable)
# Issue #51: don't use fsencode, since we later try to
# check that the shebang is decodable using utf-8.
executable = executable.encode(
'utf-8')
# in case of IronPython, play safe and enable frames support
if (sys.platform ==
'cli' and '-X:Frames' not in post_interp
and '-X:FullFrames' not in post_interp):
# pragma: no cover
post_interp += b
' -X:Frames'
shebang = self._build_shebang(executable, post_interp)
# Python parser starts to read a script using UTF-8 until
# it gets a #coding:xxx cookie. The shebang has to be the
# first line of a file, the #coding:xxx cookie cannot be
# written before. So the shebang has to be decodable from
# UTF-8.
try:
shebang.decode(
'utf-8')
except UnicodeDecodeError:
# pragma: no cover
raise ValueError(
'The shebang (%r) is not decodable from utf-8' %
shebang)
# If the script is encoded to a custom encoding (use a
# #coding:xxx cookie), the shebang has to be decodable from
# the script encoding too.
if encoding !=
'utf-8':
try:
shebang.decode(encoding)
except UnicodeDecodeError:
# pragma: no cover
raise ValueError(
'The shebang (%r) is not decodable '
'from the script encoding (%r)' %
(shebang, encoding))
return shebang
def _get_script_text(self, entry):
return self.script_template % dict(
module=entry.prefix,
import_name=entry.suffix.split(
'.')[0],
func=entry.suffix)
manifest = _DEFAULT_MANIFEST
def get_manifest(self, exename):
base = os.path.basename(exename)
return self.manifest % base
def _write_script(self, names, shebang, script_bytes, filenames, ext):
use_launcher = self.add_launchers
and self._is_nt
linesep = os.linesep.encode(
'utf-8')
if not shebang.endswith(linesep):
shebang += linesep
if not use_launcher:
script_bytes = shebang + script_bytes
else:
# pragma: no cover
if ext ==
'py':
launcher = self._get_launcher(
't')
else:
launcher = self._get_launcher(
'w')
stream = BytesIO()
with ZipFile(stream,
'w')
as zf:
source_date_epoch = os.environ.get(
'SOURCE_DATE_EPOCH')
if source_date_epoch:
date_time = time.gmtime(int(source_date_epoch))[:6]
zinfo = ZipInfo(filename=
'__main__.py',
date_time=date_time)
zf.writestr(zinfo, script_bytes)
else:
zf.writestr(
'__main__.py', script_bytes)
zip_data = stream.getvalue()
script_bytes = launcher + shebang + zip_data
for name
in names:
outname = os.path.join(self.target_dir, name)
if use_launcher:
# pragma: no cover
n, e = os.path.splitext(outname)
if e.startswith(
'.py'):
outname = n
outname =
'%s.exe' % outname
try:
self._fileop.write_binary_file(outname, script_bytes)
except Exception:
# Failed writing an executable - it might be in use.
logger.warning(
'Failed to write executable - trying to '
'use .deleteme logic')
dfname =
'%s.deleteme' % outname
if os.path.exists(dfname):
os.remove(dfname)
# Not allowed to fail here
os.rename(outname, dfname)
# nor here
self._fileop.write_binary_file(outname, script_bytes)
logger.debug(
'Able to replace executable using '
'.deleteme logic')
try:
os.remove(dfname)
except Exception:
pass # still in use - ignore error
else:
if self._is_nt
and not outname.endswith(
'.' + ext):
# pragma: no cover
outname =
'%s.%s' % (outname, ext)
if os.path.exists(outname)
and not self.clobber:
logger.warning(
'Skipping existing file %s', outname)
continue
self._fileop.write_binary_file(outname, script_bytes)
if self.set_mode:
self._fileop.set_executable_mode([outname])
filenames.append(outname)
variant_separator =
'-'
def get_script_filenames(self, name):
result = set()
if '' in self.variants:
result.add(name)
if 'X' in self.variants:
result.add(
'%s%s' % (name, self.version_info[0]))
if 'X.Y' in self.variants:
result.add(
'%s%s%s.%s' %
(name, self.variant_separator, self.version_info[0],
self.version_info[1]))
return result
def _make_script(self, entry, filenames, options=
None):
post_interp = b
''
if options:
args = options.get(
'interpreter_args', [])
if args:
args =
' %s' %
' '.join(args)
post_interp = args.encode(
'utf-8')
shebang = self._get_shebang(
'utf-8', post_interp, options=options)
script = self._get_script_text(entry).encode(
'utf-8')
scriptnames = self.get_script_filenames(entry.name)
if options
and options.get(
'gui',
False):
ext =
'pyw'
else:
ext =
'py'
self._write_script(scriptnames, shebang, script, filenames, ext)
def _copy_script(self, script, filenames):
adjust =
False
script = os.path.join(self.source_dir, convert_path(script))
outname = os.path.join(self.target_dir, os.path.basename(script))
if not self.force
and not self._fileop.newer(script, outname):
logger.debug(
'not copying %s (up-to-date)', script)
return
# Always open the file, but ignore failures in dry-run mode --
# that way, we'll get accurate feedback if we can read the
# script.
try:
f = open(script,
'rb')
except IOError:
# pragma: no cover
if not self.dry_run:
raise
f =
None
else:
first_line = f.readline()
if not first_line:
# pragma: no cover
logger.warning(
'%s is an empty file (skipping)', script)
return
match = FIRST_LINE_RE.match(first_line.replace(b
'\r\n', b
'\n'))
if match:
adjust =
True
post_interp = match.group(1)
or b
''
if not adjust:
if f:
f.close()
self._fileop.copy_file(script, outname)
if self.set_mode:
self._fileop.set_executable_mode([outname])
filenames.append(outname)
else:
logger.info(
'copying and adjusting %s -> %s', script,
self.target_dir)
if not self._fileop.dry_run:
encoding, lines = detect_encoding(f.readline)
f.seek(0)
shebang = self._get_shebang(encoding, post_interp)
if b
'pythonw' in first_line:
# pragma: no cover
ext =
'pyw'
else:
ext =
'py'
n = os.path.basename(outname)
self._write_script([n], shebang, f.read(), filenames, ext)
if f:
f.close()
@property
def dry_run(self):
return self._fileop.dry_run
@dry_run.setter
def dry_run(self, value):
self._fileop.dry_run = value
if os.name ==
'nt' or (os.name ==
'java'
and os._name ==
'nt'):
# pragma: no cover
# Executable launcher support.
# Launchers are from https://bitbucket.org/vinay.sajip/simple_launcher/
def _get_launcher(self, kind):
if struct.calcsize(
'P') == 8:
# 64-bit
bits =
'64'
else:
bits =
'32'
platform_suffix =
'-arm' if get_platform() ==
'win-arm64' else ''
name =
'%s%s%s.exe' % (kind, bits, platform_suffix)
# Issue 31: don't hardcode an absolute package name, but
# determine it relative to the current package
distlib_package = __name__.rsplit(
'.', 1)[0]
resource = finder(distlib_package).find(name)
if not resource:
msg = (
'Unable to find resource %s in package %s' %
(name, distlib_package))
raise ValueError(msg)
return resource.bytes
# Public API follows
def make(self, specification, options=
None):
"""
Make a script.
:param specification: The specification, which
is either a valid export
entry specification (to make a script
from a
callable)
or a filename (to make a script by
copying
from a source location).
:param options: A dictionary of options controlling script generation.
:
return: A list of all absolute pathnames written to.
"""
filenames = []
entry = get_export_entry(specification)
if entry
is None:
self._copy_script(specification, filenames)
else:
self._make_script(entry, filenames, options=options)
return filenames
def make_multiple(self, specifications, options=
None):
"""
Take a list of specifications
and make scripts
from them,
:param specifications: A list of specifications.
:
return: A list of all absolute pathnames written to,
"""
filenames = []
for specification
in specifications:
filenames.extend(self.make(specification, options))
return filenames