1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2013-2026 Vinay Sajip.
4# Licensed to the Python Software Foundation under a contributor agreement.
5# See LICENSE.txt and CONTRIBUTORS.txt.
6#
7from io import BytesIO
8import logging
9import os
10import re
11import struct
12import sys
13import time
14from zipfile import ZipInfo
15
16from . import DistlibException
17from .compat import sysconfig, detect_encoding, ZipFile
18from .resources import finder
19from .util import (FileOperator, get_export_entry, convert_path, get_executable, get_platform, in_venv,
20 is_in_directory)
21
22logger = logging.getLogger(__name__)
23
24_DEFAULT_MANIFEST = '''
25<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
26<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
27 <assemblyIdentity version="1.0.0.0"
28 processorArchitecture="X86"
29 name="%s"
30 type="win32"/>
31
32 <!-- Identify the application security requirements. -->
33 <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
34 <security>
35 <requestedPrivileges>
36 <requestedExecutionLevel level="asInvoker" uiAccess="false"/>
37 </requestedPrivileges>
38 </security>
39 </trustInfo>
40</assembly>'''.strip()
41
42# check if Python is called on the first line with this expression
43FIRST_LINE_RE = re.compile(b'^#!.*pythonw?[0-9.]*([ \t].*)?$')
44SCRIPT_TEMPLATE = r'''# -*- coding: utf-8 -*-
45import re
46import sys
47if __name__ == '__main__':
48 from %(module)s import %(import_name)s
49 sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
50 sys.exit(%(func)s())
51'''
52
53# Pre-fetch the contents of all executable wrapper stubs.
54# This is to address https://github.com/pypa/pip/issues/12666.
55# When updating pip, we rename the old pip in place before installing the
56# new version. If we try to fetch a wrapper *after* that rename, the finder
57# machinery will be confused as the package is no longer available at the
58# location where it was imported from. So we load everything into memory in
59# advance.
60
61if os.name == 'nt' or (os.name == 'java' and os._name == 'nt'):
62 # Issue 31: don't hardcode an absolute package name, but
63 # determine it relative to the current package
64 DISTLIB_PACKAGE = __name__.rsplit('.', 1)[0]
65
66 WRAPPERS = {
67 r.name: r.bytes
68 for r in finder(DISTLIB_PACKAGE).iterator("")
69 if r.name.endswith(".exe")
70 }
71
72
73def enquote_executable(executable):
74 if ' ' in executable:
75 # make sure we quote only the executable in case of env
76 # for example /usr/bin/env "/dir with spaces/bin/jython"
77 # instead of "/usr/bin/env /dir with spaces/bin/jython"
78 # otherwise whole
79 if executable.startswith('/usr/bin/env '):
80 env, _executable = executable.split(' ', 1)
81 if ' ' in _executable and not _executable.startswith('"'):
82 executable = '%s "%s"' % (env, _executable)
83 else:
84 if not executable.startswith('"'):
85 executable = '"%s"' % executable
86 return executable
87
88
89# Keep the old name around (for now), as there is at least one project using it!
90_enquote_executable = enquote_executable
91
92
93class ScriptMaker(object):
94 """
95 A class to copy or create scripts from source scripts or callable
96 specifications.
97 """
98 script_template = SCRIPT_TEMPLATE
99
100 executable = None # for shebangs
101
102 def __init__(self, source_dir, target_dir, add_launchers=True, dry_run=False, fileop=None):
103 self.source_dir = source_dir
104 self.target_dir = target_dir
105 self.add_launchers = add_launchers
106 self.force = False
107 self.clobber = False
108 # It only makes sense to set mode bits on POSIX.
109 self.set_mode = (os.name == 'posix') or (os.name == 'java' and os._name == 'posix')
110 self.variants = set(('', 'X.Y'))
111 self._fileop = fileop or FileOperator(dry_run)
112
113 self._is_nt = os.name == 'nt' or (os.name == 'java' and os._name == 'nt')
114 self.version_info = sys.version_info
115
116 def _get_alternate_executable(self, executable, options):
117 if options.get('gui', False) and self._is_nt: # pragma: no cover
118 dn, fn = os.path.split(executable)
119 fn = fn.replace('python', 'pythonw')
120 executable = os.path.join(dn, fn)
121 return executable
122
123 if sys.platform.startswith('java'): # pragma: no cover
124
125 def _is_shell(self, executable):
126 """
127 Determine if the specified executable is a script
128 (contains a #! line)
129 """
130 try:
131 with open(executable) as fp:
132 return fp.read(2) == '#!'
133 except (OSError, IOError):
134 logger.warning('Failed to open %s', executable)
135 return False
136
137 def _fix_jython_executable(self, executable):
138 if self._is_shell(executable):
139 # Workaround for Jython is not needed on Linux systems.
140 import java
141
142 if java.lang.System.getProperty('os.name') == 'Linux':
143 return executable
144 elif executable.lower().endswith('jython.exe'):
145 # Use wrapper exe for Jython on Windows
146 return executable
147 return '/usr/bin/env %s' % executable
148
149 def _build_shebang(self, executable, post_interp):
150 """
151 Build a shebang line. In the simple case (on Windows, or a shebang line
152 which is not too long or contains spaces) use a simple formulation for
153 the shebang. Otherwise, use /bin/sh as the executable, with a contrived
154 shebang which allows the script to run either under Python or sh, using
155 suitable quoting. Thanks to Harald Nordgren for his input.
156
157 See also: http://www.in-ulm.de/~mascheck/various/shebang/#length
158 https://hg.mozilla.org/mozilla-central/file/tip/mach
159 """
160 if os.name != 'posix':
161 simple_shebang = True
162 elif getattr(sys, "cross_compiling", False):
163 # In a cross-compiling environment, the shebang will likely be a
164 # script; this *must* be invoked with the "safe" version of the
165 # shebang, or else using os.exec() to run the entry script will
166 # fail, raising "OSError 8 [Errno 8] Exec format error".
167 simple_shebang = False
168 else:
169 # Add 3 for '#!' prefix and newline suffix.
170 shebang_length = len(executable) + len(post_interp) + 3
171 if sys.platform == 'darwin':
172 max_shebang_length = 512
173 else:
174 max_shebang_length = 127
175 simple_shebang = ((b' ' not in executable) and (shebang_length <= max_shebang_length))
176
177 if simple_shebang:
178 result = b'#!' + executable + post_interp + b'\n'
179 else:
180 result = b'#!/bin/sh\n'
181 result += b"'''exec' " + executable + post_interp + b' "$0" "$@"\n'
182 result += b"' '''\n"
183 return result
184
185 def _get_shebang(self, encoding, post_interp=b'', options=None):
186 enquote = True
187 if self.executable:
188 executable = self.executable
189 enquote = False # assume this will be taken care of
190 elif not sysconfig.is_python_build():
191 executable = get_executable()
192 elif in_venv(): # pragma: no cover
193 executable = os.path.join(sysconfig.get_path('scripts'), 'python%s' % sysconfig.get_config_var('EXE'))
194 else: # pragma: no cover
195 if os.name == 'nt':
196 # for Python builds from source on Windows, no Python executables with
197 # a version suffix are created, so we use python.exe
198 executable = os.path.join(sysconfig.get_config_var('BINDIR'),
199 'python%s' % (sysconfig.get_config_var('EXE')))
200 else:
201 executable = os.path.join(
202 sysconfig.get_config_var('BINDIR'),
203 'python%s%s' % (sysconfig.get_config_var('VERSION'), sysconfig.get_config_var('EXE')))
204 if options:
205 executable = self._get_alternate_executable(executable, options)
206
207 if sys.platform.startswith('java'): # pragma: no cover
208 executable = self._fix_jython_executable(executable)
209
210 # Normalise case for Windows - COMMENTED OUT
211 # executable = os.path.normcase(executable)
212 # N.B. The normalising operation above has been commented out: See
213 # issue #124. Although paths in Windows are generally case-insensitive,
214 # they aren't always. For example, a path containing a ẞ (which is a
215 # LATIN CAPITAL LETTER SHARP S - U+1E9E) is normcased to ß (which is a
216 # LATIN SMALL LETTER SHARP S' - U+00DF). The two are not considered by
217 # Windows as equivalent in path names.
218
219 # If the user didn't specify an executable, it may be necessary to
220 # cater for executable paths with spaces (not uncommon on Windows)
221 if enquote:
222 executable = enquote_executable(executable)
223 # Issue #51: don't use fsencode, since we later try to
224 # check that the shebang is decodable using utf-8.
225 executable = executable.encode('utf-8')
226 # in case of IronPython, play safe and enable frames support
227 if (sys.platform == 'cli' and '-X:Frames' not in post_interp and
228 '-X:FullFrames' not in post_interp): # pragma: no cover
229 post_interp += b' -X:Frames'
230 shebang = self._build_shebang(executable, post_interp)
231 # Python parser starts to read a script using UTF-8 until
232 # it gets a #coding:xxx cookie. The shebang has to be the
233 # first line of a file, the #coding:xxx cookie cannot be
234 # written before. So the shebang has to be decodable from
235 # UTF-8.
236 try:
237 shebang.decode('utf-8')
238 except UnicodeDecodeError: # pragma: no cover
239 raise ValueError('The shebang (%r) is not decodable from utf-8' % shebang)
240 # If the script is encoded to a custom encoding (use a
241 # #coding:xxx cookie), the shebang has to be decodable from
242 # the script encoding too.
243 if encoding != 'utf-8':
244 try:
245 shebang.decode(encoding)
246 except UnicodeDecodeError: # pragma: no cover
247 raise ValueError('The shebang (%r) is not decodable '
248 'from the script encoding (%r)' % (shebang, encoding))
249 return shebang
250
251 def _get_script_text(self, entry):
252 return self.script_template % dict(
253 module=entry.prefix, import_name=entry.suffix.split('.')[0], func=entry.suffix)
254
255 manifest = _DEFAULT_MANIFEST
256
257 def get_manifest(self, exename):
258 base = os.path.basename(exename)
259 return self.manifest % base
260
261 def _write_script(self, names, shebang, script_bytes, filenames, ext):
262 use_launcher = self.add_launchers and self._is_nt
263 if not use_launcher:
264 script_bytes = shebang + script_bytes
265 else: # pragma: no cover
266 if ext == 'py':
267 launcher = self._get_launcher('t')
268 else:
269 launcher = self._get_launcher('w')
270 stream = BytesIO()
271 with ZipFile(stream, 'w') as zf:
272 source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH')
273 if source_date_epoch:
274 date_time = time.gmtime(int(source_date_epoch))[:6]
275 zinfo = ZipInfo(filename='__main__.py', date_time=date_time)
276 zf.writestr(zinfo, script_bytes)
277 else:
278 zf.writestr('__main__.py', script_bytes)
279 zip_data = stream.getvalue()
280 script_bytes = launcher + shebang + zip_data
281 for name in names:
282 outname = os.path.abspath(os.path.join(self.target_dir, name))
283 if not is_in_directory(outname, self.target_dir):
284 raise DistlibException('Attempt to escape script directory')
285 if use_launcher: # pragma: no cover
286 n, e = os.path.splitext(outname)
287 if e.startswith('.py'):
288 outname = n
289 outname = '%s.exe' % outname
290 try:
291 self._fileop.write_binary_file(outname, script_bytes)
292 except Exception:
293 # Failed writing an executable - it might be in use.
294 logger.warning('Failed to write executable - trying to '
295 'use .deleteme logic')
296 dfname = '%s.deleteme' % outname
297 if os.path.exists(dfname):
298 os.remove(dfname) # Not allowed to fail here
299 os.rename(outname, dfname) # nor here
300 self._fileop.write_binary_file(outname, script_bytes)
301 logger.debug('Able to replace executable using '
302 '.deleteme logic')
303 try:
304 os.remove(dfname)
305 except Exception:
306 pass # still in use - ignore error
307 else:
308 if self._is_nt and not outname.endswith('.' + ext): # pragma: no cover
309 outname = '%s.%s' % (outname, ext)
310 if os.path.exists(outname) and not self.clobber:
311 logger.warning('Skipping existing file %s', outname)
312 continue
313 self._fileop.write_binary_file(outname, script_bytes)
314 if self.set_mode:
315 self._fileop.set_executable_mode([outname])
316 filenames.append(outname)
317
318 variant_separator = '-'
319
320 def get_script_filenames(self, name):
321 result = set()
322 if '' in self.variants:
323 result.add(name)
324 if 'X' in self.variants:
325 result.add('%s%s' % (name, self.version_info[0]))
326 if 'X.Y' in self.variants:
327 result.add('%s%s%s.%s' % (name, self.variant_separator, self.version_info[0], self.version_info[1]))
328 return result
329
330 def _make_script(self, entry, filenames, options=None):
331 post_interp = b''
332 if options:
333 args = options.get('interpreter_args', [])
334 if args:
335 args = ' %s' % ' '.join(args)
336 post_interp = args.encode('utf-8')
337 shebang = self._get_shebang('utf-8', post_interp, options=options)
338 script = self._get_script_text(entry).encode('utf-8')
339 scriptnames = self.get_script_filenames(entry.name)
340 if options and options.get('gui', False):
341 ext = 'pyw'
342 else:
343 ext = 'py'
344 self._write_script(scriptnames, shebang, script, filenames, ext)
345
346 def _copy_script(self, script, filenames):
347 adjust = False
348 script = os.path.join(self.source_dir, convert_path(script))
349 outname = os.path.join(self.target_dir, os.path.basename(script))
350 if not self.force and not self._fileop.newer(script, outname):
351 logger.debug('not copying %s (up-to-date)', script)
352 return
353
354 # Always open the file, but ignore failures in dry-run mode --
355 # that way, we'll get accurate feedback if we can read the
356 # script.
357 try:
358 f = open(script, 'rb')
359 except IOError: # pragma: no cover
360 if not self.dry_run:
361 raise
362 f = None
363 else:
364 first_line = f.readline()
365 if not first_line: # pragma: no cover
366 logger.warning('%s is an empty file (skipping)', script)
367 return
368
369 match = FIRST_LINE_RE.match(first_line.replace(b'\r\n', b'\n'))
370 if match:
371 adjust = True
372 post_interp = match.group(1) or b''
373
374 if not adjust:
375 if f:
376 f.close()
377 self._fileop.copy_file(script, outname)
378 if self.set_mode:
379 self._fileop.set_executable_mode([outname])
380 filenames.append(outname)
381 else:
382 logger.info('copying and adjusting %s -> %s', script, self.target_dir)
383 if not self._fileop.dry_run:
384 encoding, lines = detect_encoding(f.readline)
385 f.seek(0)
386 shebang = self._get_shebang(encoding, post_interp)
387 if b'pythonw' in first_line: # pragma: no cover
388 ext = 'pyw'
389 else:
390 ext = 'py'
391 n = os.path.basename(outname)
392 self._write_script([n], shebang, f.read(), filenames, ext)
393 if f:
394 f.close()
395
396 @property
397 def dry_run(self):
398 return self._fileop.dry_run
399
400 @dry_run.setter
401 def dry_run(self, value):
402 self._fileop.dry_run = value
403
404 if os.name == 'nt' or (os.name == 'java' and os._name == 'nt'): # pragma: no cover
405 # Executable launcher support.
406 # Launchers are from https://bitbucket.org/vinay.sajip/simple_launcher/
407
408 def _get_launcher(self, kind):
409 if struct.calcsize('P') == 8: # 64-bit
410 bits = '64'
411 else:
412 bits = '32'
413 platform_suffix = '-arm' if get_platform() == 'win-arm64' else ''
414 name = '%s%s%s.exe' % (kind, bits, platform_suffix)
415 if name not in WRAPPERS:
416 msg = ('Unable to find resource %s in package %s' %
417 (name, DISTLIB_PACKAGE))
418 raise ValueError(msg)
419 return WRAPPERS[name]
420
421 # Public API follows
422
423 def make(self, specification, options=None):
424 """
425 Make a script.
426
427 :param specification: The specification, which is either a valid export
428 entry specification (to make a script from a
429 callable) or a filename (to make a script by
430 copying from a source location).
431 :param options: A dictionary of options controlling script generation.
432 :return: A list of all absolute pathnames written to.
433 """
434 filenames = []
435 entry = get_export_entry(specification)
436 if entry is None:
437 self._copy_script(specification, filenames)
438 else:
439 self._make_script(entry, filenames, options=options)
440 return filenames
441
442 def make_multiple(self, specifications, options=None):
443 """
444 Take a list of specifications and make scripts from them,
445 :param specifications: A list of specifications.
446 :return: A list of all absolute pathnames written to,
447 """
448 filenames = []
449 for specification in specifications:
450 filenames.extend(self.make(specification, options))
451 return filenames