""" Generates a wheel (.whl) file from the output of makepanda. Since the wheel requires special linking, this will only work if compiled with the `--wheel` parameter. Please keep this file work with Panda3D 1.9 until that reaches EOL. """ from __future__ import print_function, unicode_literals from distutils.util import get_platform import json import sys import os from os.path import join import shutil import zipfile import hashlib import tempfile import subprocess from distutils.sysconfig import get_config_var from optparse import OptionParser from makepandacore import ColorText, LocateBinary, ParsePandaVersion, GetExtensionSuffix, SetVerbose, GetVerbose, GetMetadataValue from base64 import urlsafe_b64encode def get_abi_tag(): if sys.version_info >= (3, 0): soabi = get_config_var('SOABI') if soabi and soabi.startswith('cpython-'): return 'cp' + soabi.split('-')[1] elif soabi: return soabi.replace('.', '_').replace('-', '_') soabi = 'cp%d%d' % (sys.version_info[:2]) debug_flag = get_config_var('Py_DEBUG') if (debug_flag is None and hasattr(sys, 'gettotalrefcount')) or debug_flag: soabi += 'd' malloc_flag = get_config_var('WITH_PYMALLOC') if malloc_flag is None or malloc_flag: soabi += 'm' if sys.version_info < (3, 3): usize = get_config_var('Py_UNICODE_SIZE') if (usize is None and sys.maxunicode == 0x10ffff) or usize == 4: soabi += 'u' return soabi def is_exe_file(path): return os.path.isfile(path) and path.lower().endswith('.exe') def is_elf_file(path): base = os.path.basename(path) return os.path.isfile(path) and '.' not in base and \ open(path, 'rb').read(4) == b'\x7FELF' def is_macho_or_fat_file(path): base = os.path.basename(path) return os.path.isfile(path) and '.' not in base and \ open(path, 'rb').read(4) in (b'\xFE\xED\xFA\xCE', b'\xCE\xFA\xED\xFE', b'\xFE\xED\xFA\xCF', b'\xCF\xFA\xED\xFE', b'\xCA\xFE\xBA\xBE', b'\xBE\xBA\xFE\xCA', b'\xCA\xFE\xBA\xBF', b'\xBF\xBA\xFE\xCA') def is_fat_file(path): return os.path.isfile(path) and \ open(path, 'rb').read(4) in (b'\xCA\xFE\xBA\xBE', b'\xBE\xBA\xFE\xCA', b'\xCA\xFE\xBA\xBF', b'\xBF\xBA\xFE\xCA') def get_python_ext_module_dir(): import _ctypes return os.path.dirname(_ctypes.__file__) if sys.platform in ('win32', 'cygwin'): is_executable = is_exe_file elif sys.platform == 'darwin': is_executable = is_macho_or_fat_file else: is_executable = is_elf_file # Other global parameters PY_VERSION = "cp{0}{1}".format(*sys.version_info) ABI_TAG = get_abi_tag() EXCLUDE_EXT = [".pyc", ".pyo", ".N", ".prebuilt", ".xcf", ".plist", ".vcproj", ".sln"] # Plug-ins to install. PLUGIN_LIBS = ["pandagl", "pandagles", "pandagles2", "pandadx9", "p3tinydisplay", "p3ptloader", "p3assimp", "p3ffmpeg", "p3openal_audio", "p3fmod_audio"] # Libraries included in manylinux ABI that should be ignored. See PEP 513/571. MANYLINUX_LIBS = [ "libgcc_s.so.1", "libstdc++.so.6", "libm.so.6", "libdl.so.2", "librt.so.1", "libcrypt.so.1", "libc.so.6", "libnsl.so.1", "libutil.so.1", "libpthread.so.0", "libresolv.so.2", "libX11.so.6", "libXext.so.6", "libXrender.so.1", "libICE.so.6", "libSM.so.6", "libGL.so.1", "libgobject-2.0.so.0", "libgthread-2.0.so.0", "libglib-2.0.so.0", # These are not mentioned in manylinux1 spec but should nonetheless always # be excluded. "linux-vdso.so.1", "linux-gate.so.1", "ld-linux.so.2", ] # Binaries to never scan for dependencies on non-Windows systems. IGNORE_UNIX_DEPS_OF = [ "panda3d_tools/pstats", ] WHEEL_DATA = """Wheel-Version: 1.0 Generator: makepanda Root-Is-Purelib: false Tag: {0}-{1}-{2} """ METADATA = { "license": GetMetadataValue('license'), "name": GetMetadataValue('name'), "metadata_version": "2.0", "generator": "makepanda", "summary": GetMetadataValue('description'), "extensions": { "python.details": { "project_urls": { "Home": GetMetadataValue('url'), }, "document_names": { "license": "LICENSE.txt" }, "contacts": [ { "role": "author", "name": GetMetadataValue('author'), "email": GetMetadataValue('author_email'), } ] } }, "classifiers": GetMetadataValue('classifiers'), } PANDA3D_TOOLS_INIT = """import os, sys import panda3d if sys.platform in ('win32', 'cygwin'): path_var = 'PATH' elif sys.platform == 'darwin': path_var = 'DYLD_LIBRARY_PATH' else: path_var = 'LD_LIBRARY_PATH' dir = os.path.dirname(panda3d.__file__) del panda3d if not os.environ.get(path_var): os.environ[path_var] = dir else: os.environ[path_var] = dir + os.pathsep + os.environ[path_var] del os, sys, path_var, dir def _exec_tool(tool): import os, sys from subprocess import Popen tools_dir = os.path.dirname(__file__) handle = Popen(sys.argv, executable=os.path.join(tools_dir, tool)) try: try: return handle.wait() except KeyboardInterrupt: # Give the program a chance to handle the signal gracefully. return handle.wait() except: handle.kill() handle.wait() raise # Register all the executables in this directory as global functions. {0} """ def parse_dependencies_windows(data): """ Parses the given output from dumpbin /dependents to determine the list of dll's this executable file depends on. """ lines = data.splitlines() li = 0 while li < len(lines): line = lines[li] li += 1 if line.find(' has the following dependencies') != -1: break if li < len(lines): line = lines[li] if line.strip() == '': # Skip a blank line. li += 1 # Now we're finding filenames, until the next blank line. filenames = [] while li < len(lines): line = lines[li] li += 1 line = line.strip() if line == '': # We're done. return filenames filenames.append(line) # At least we got some data. return filenames def parse_dependencies_unix(data): """ Parses the given output from otool -XL or ldd to determine the list of libraries this executable file depends on. """ lines = data.splitlines() filenames = [] for l in lines: l = l.strip() if l != "statically linked": filenames.append(l.split(' ', 1)[0]) return filenames def scan_dependencies(pathname): """ Checks the named file for DLL dependencies, and adds any appropriate dependencies found into pluginDependencies and dependentFiles. """ if sys.platform == "darwin": command = ['otool', '-XL', pathname] elif sys.platform in ("win32", "cygwin"): command = ['dumpbin', '/dependents', pathname] else: command = ['ldd', pathname] process = subprocess.Popen(command, stdout=subprocess.PIPE, universal_newlines=True) output, unused_err = process.communicate() retcode = process.poll() if retcode: raise subprocess.CalledProcessError(retcode, command[0], output=output) filenames = None if sys.platform in ("win32", "cygwin"): filenames = parse_dependencies_windows(output) else: filenames = parse_dependencies_unix(output) if filenames is None: sys.exit("Unable to determine dependencies from %s" % (pathname)) if sys.platform == "darwin" and len(filenames) > 0: # Filter out the library ID. if os.path.basename(filenames[0]).split('.', 1)[0] == os.path.basename(pathname).split('.', 1)[0]: del filenames[0] return filenames class WheelFile(object): def __init__(self, name, version, platform): self.name = name self.version = version self.platform = platform wheel_name = "{0}-{1}-{2}-{3}-{4}.whl".format( name, version, PY_VERSION, ABI_TAG, platform) print("Writing %s" % (wheel_name)) self.zip_file = zipfile.ZipFile(wheel_name, 'w', zipfile.ZIP_DEFLATED) self.records = [] # Used to locate dependency libraries. self.lib_path = [] self.dep_paths = {} self.ignore_deps = set() def consider_add_dependency(self, target_path, dep, search_path=None): """Considers adding a dependency library. Returns the target_path if it was added, which may be different from target_path if it was already added earlier, or None if it wasn't.""" if dep in self.dep_paths: # Already considered this. return self.dep_paths[dep] self.dep_paths[dep] = None if dep in self.ignore_deps or dep.lower().startswith("python") or os.path.basename(dep).startswith("libpython"): # Don't include the Python library, or any other explicit ignore. if GetVerbose(): print("Ignoring {0} (explicitly ignored)".format(dep)) return if sys.platform == "darwin" and dep.endswith(".so"): # Temporary hack for 1.9, which had link deps on modules. return if sys.platform == "darwin" and dep.startswith("/System/"): return if dep.startswith('/'): source_path = dep else: source_path = None if search_path is None: search_path = self.lib_path for lib_dir in search_path: # Ignore static stuff. path = os.path.join(lib_dir, dep) if os.path.isfile(path): source_path = os.path.normpath(path) break if not source_path: # Couldn't find library in the panda3d lib dir. if GetVerbose(): print("Ignoring {0} (not in search path)".format(dep)) return self.dep_paths[dep] = target_path self.write_file(target_path, source_path) return target_path def write_file(self, target_path, source_path): """Adds the given file to the .whl file.""" orig_source_path = source_path # If this is a .so file, we should set the rpath appropriately. temp = None basename, ext = os.path.splitext(source_path) if ext in ('.so', '.dylib') or '.so.' in os.path.basename(source_path) or \ (not ext and is_executable(source_path)): # Scan and add Unix dependencies. if target_path not in IGNORE_UNIX_DEPS_OF: deps = scan_dependencies(source_path) else: deps = [] for dep in deps: # Only include dependencies with relative path. Otherwise we # end up overwriting system files like /lib/ld-linux.so.2! # Yes, it happened to me. if '/' not in dep: target_dep = os.path.dirname(target_path) + '/' + dep self.consider_add_dependency(target_dep, dep) suffix = '' if '.so' in os.path.basename(source_path): suffix = '.so' elif ext == '.dylib': suffix = '.dylib' temp = tempfile.NamedTemporaryFile(suffix=suffix, prefix='whl', delete=False) # On macOS, if no fat wheel was requested, extract the right architecture. if sys.platform == "darwin" and is_fat_file(source_path) \ and not self.platform.endswith("_intel") \ and "_fat" not in self.platform: if self.platform.endswith("_x86_64"): arch = 'x86_64' else: arch = self.platform.split('_')[-1] subprocess.call(['lipo', source_path, '-extract', arch, '-output', temp.name]) else: # Otherwise, just copy it over. temp.write(open(source_path, 'rb').read()) os.fchmod(temp.fileno(), os.fstat(temp.fileno()).st_mode | 0o111) temp.close() # Fix things like @loader_path/../lib references if sys.platform == "darwin": if source_path.endswith('deploy-stubw'): deps_path = '@executable_path/../Frameworks' else: deps_path = '@loader_path' loader_path = [os.path.dirname(source_path)] for dep in deps: if dep.endswith('/Python'): # If this references the Python framework, change it # to reference libpython instead. new_dep = deps_path + '/libpython{0}.{1}.dylib'.format(*sys.version_info) else: if '@loader_path' not in dep: continue dep_path = dep.replace('@loader_path', '.') target_dep = os.path.dirname(target_path) + '/' + os.path.basename(dep) target_dep = self.consider_add_dependency(target_dep, dep_path, loader_path) if not target_dep: # It won't be included, so no use adjusting the path. continue new_dep = os.path.join(deps_path, os.path.relpath(target_dep, os.path.dirname(target_path))) subprocess.call(["install_name_tool", "-change", dep, new_dep, temp.name]) else: subprocess.call(["strip", "-s", temp.name]) subprocess.call(["patchelf", "--set-rpath", "$ORIGIN", temp.name]) source_path = temp.name ext = ext.lower() if ext in ('.dll', '.pyd', '.exe'): # Scan and add Win32 dependencies. for dep in scan_dependencies(source_path): target_dep = os.path.dirname(target_path) + '/' + dep self.consider_add_dependency(target_dep, dep) # Calculate the SHA-256 hash and size. sha = hashlib.sha256() fp = open(source_path, 'rb') size = 0 data = fp.read(1024 * 1024) while data: size += len(data) sha.update(data) data = fp.read(1024 * 1024) fp.close() # Save it in PEP-0376 format for writing out later. digest = urlsafe_b64encode(sha.digest()).decode('ascii') digest = digest.rstrip('=') self.records.append("{0},sha256={1},{2}\n".format(target_path, digest, size)) if GetVerbose(): print("Adding {0} from {1}".format(target_path, orig_source_path)) self.zip_file.write(source_path, target_path) #if temp: # os.unlink(temp.name) def write_file_data(self, target_path, source_data): """Adds the given file from a string.""" sha = hashlib.sha256() sha.update(source_data.encode()) digest = urlsafe_b64encode(sha.digest()).decode('ascii') digest = digest.rstrip('=') self.records.append("{0},sha256={1},{2}\n".format(target_path, digest, len(source_data))) if GetVerbose(): print("Adding %s from data" % target_path) self.zip_file.writestr(target_path, source_data) def write_directory(self, target_dir, source_dir): """Adds the given directory recursively to the .whl file.""" for root, dirs, files in os.walk(source_dir): for file in files: if os.path.splitext(file)[1] in EXCLUDE_EXT: continue source_path = os.path.join(root, file) target_path = os.path.join(target_dir, os.path.relpath(source_path, source_dir)) target_path = target_path.replace('\\', '/') self.write_file(target_path, source_path) def close(self): # Write the RECORD file. record_file = "{0}-{1}.dist-info/RECORD".format(self.name, self.version) self.records.append(record_file + ",,\n") self.zip_file.writestr(record_file, "".join(self.records)) self.zip_file.close() def makewheel(version, output_dir, platform=None): if sys.platform not in ("win32", "darwin") and not sys.platform.startswith("cygwin"): if not LocateBinary("patchelf"): raise Exception("patchelf is required when building a Linux wheel.") if platform is None: # Determine the platform from the build. platform_dat = os.path.join(output_dir, 'tmp', 'platform.dat') if os.path.isfile(platform_dat): platform = open(platform_dat, 'r').read().strip() else: print("Could not find platform.dat in build directory") platform = get_platform() if platform.startswith("linux-"): # Is this manylinux1? if os.path.isfile("/lib/libc-2.5.so") and os.path.isdir("/opt/python"): platform = platform.replace("linux", "manylinux1") platform = platform.replace('-', '_').replace('.', '_') # Global filepaths panda3d_dir = join(output_dir, "panda3d") pandac_dir = join(output_dir, "pandac") direct_dir = join(output_dir, "direct") models_dir = join(output_dir, "models") etc_dir = join(output_dir, "etc") bin_dir = join(output_dir, "bin") if sys.platform == "win32": libs_dir = join(output_dir, "bin") else: libs_dir = join(output_dir, "lib") license_src = "LICENSE" readme_src = "README.md" # Update relevant METADATA entries METADATA['version'] = version version_classifiers = [ "Programming Language :: Python :: {0}".format(*sys.version_info), "Programming Language :: Python :: {0}.{1}".format(*sys.version_info), ] METADATA['classifiers'].extend(version_classifiers) # Build out the metadata details = METADATA["extensions"]["python.details"] homepage = details["project_urls"]["Home"] author = details["contacts"][0]["name"] email = details["contacts"][0]["email"] metadata = ''.join([ "Metadata-Version: {metadata_version}\n" \ "Name: {name}\n" \ "Version: {version}\n" \ "Summary: {summary}\n" \ "License: {license}\n".format(**METADATA), "Home-page: {0}\n".format(homepage), "Author: {0}\n".format(author), "Author-email: {0}\n".format(email), "Platform: {0}\n".format(platform), ] + ["Classifier: {0}\n".format(c) for c in METADATA['classifiers']]) # Zip it up and name it the right thing whl = WheelFile('panda3d', version, platform) whl.lib_path = [libs_dir] if sys.platform == "win32": whl.lib_path.append(join(output_dir, "python", "DLLs")) if platform.startswith("manylinux"): # On manylinux1, we pick up all libraries except for the ones specified # by the manylinux1 ABI. whl.lib_path.append("/usr/local/lib") if platform.endswith("_x86_64"): whl.lib_path += ["/lib64", "/usr/lib64"] else: whl.lib_path += ["/lib", "/usr/lib"] whl.ignore_deps.update(MANYLINUX_LIBS) # Add the trees with Python modules. whl.write_directory('direct', direct_dir) # Write the panda3d tree. We use a custom empty __init__ since the # default one adds the bin directory to the PATH, which we don't have. whl.write_file_data('panda3d/__init__.py', """"Python bindings for the Panda3D libraries" __version__ = '{0}' """.format(version)) # Copy the extension modules from the panda3d directory. ext_suffix = GetExtensionSuffix() for file in os.listdir(panda3d_dir): if file == '__init__.py': pass elif file.endswith('.py') or (file.endswith(ext_suffix) and '.' not in file[:-len(ext_suffix)]): source_path = os.path.join(panda3d_dir, file) if file.endswith('.pyd') and platform.startswith('cygwin'): # Rename it to .dll for cygwin Python to be able to load it. target_path = 'panda3d/' + os.path.splitext(file)[0] + '.dll' else: target_path = 'panda3d/' + file whl.write_file(target_path, source_path) # And copy the extension modules from the Python installation into the # deploy_libs directory, for use by deploy-ng. ext_suffix = '.pyd' if sys.platform in ('win32', 'cygwin') else '.so' ext_mod_dir = get_python_ext_module_dir() for file in os.listdir(ext_mod_dir): if file.endswith(ext_suffix): source_path = os.path.join(ext_mod_dir, file) if file.endswith('.pyd') and platform.startswith('cygwin'): # Rename it to .dll for cygwin Python to be able to load it. target_path = 'deploy_libs/' + os.path.splitext(file)[0] + '.dll' else: target_path = 'deploy_libs/' + file whl.write_file(target_path, source_path) # Add plug-ins. for lib in PLUGIN_LIBS: plugin_name = 'lib' + lib if sys.platform in ('win32', 'cygwin'): plugin_name += '.dll' elif sys.platform == 'darwin': plugin_name += '.dylib' else: plugin_name += '.so' plugin_path = os.path.join(libs_dir, plugin_name) if os.path.isfile(plugin_path): whl.write_file('panda3d/' + plugin_name, plugin_path) # Add the .data directory, containing additional files. data_dir = 'panda3d-{0}.data'.format(version) #whl.write_directory(data_dir + '/data/etc', etc_dir) #whl.write_directory(data_dir + '/data/models', models_dir) # Actually, let's not. That seems to install the files to the strangest # places in the user's filesystem. Let's instead put them in panda3d. whl.write_directory('panda3d/etc', etc_dir) whl.write_directory('panda3d/models', models_dir) # Add the pandac tree for backward compatibility. for file in os.listdir(pandac_dir): if file.endswith('.py'): whl.write_file('pandac/' + file, os.path.join(pandac_dir, file)) # Add a panda3d-tools directory containing the executables. entry_points = '[console_scripts]\n' entry_points += 'eggcacher = direct.directscripts.eggcacher:main\n' entry_points += 'pfreeze = direct.showutil.pfreeze:main\n' tools_init = '' for file in os.listdir(bin_dir): basename = os.path.splitext(file)[0] if basename in ('eggcacher', 'packpanda'): continue source_path = os.path.join(bin_dir, file) if is_executable(source_path): # Put the .exe files inside the panda3d-tools directory. whl.write_file('panda3d_tools/' + file, source_path) # Tell pip to create a wrapper script. funcname = basename.replace('-', '_') entry_points += '{0} = panda3d_tools:{1}\n'.format(basename, funcname) tools_init += '{0} = lambda: _exec_tool({1!r})\n'.format(funcname, file) entry_points += '[distutils.commands]\n' entry_points += 'build_apps = direct.showutil.dist:build_apps\n' entry_points += 'bdist_apps = direct.showutil.dist:bdist_apps\n' whl.write_file_data('panda3d_tools/__init__.py', PANDA3D_TOOLS_INIT.format(tools_init)) # Add the dist-info directory last. info_dir = 'panda3d-{0}.dist-info'.format(version) whl.write_file_data(info_dir + '/entry_points.txt', entry_points) whl.write_file_data(info_dir + '/metadata.json', json.dumps(METADATA, indent=4, separators=(',', ': '))) whl.write_file_data(info_dir + '/METADATA', metadata) whl.write_file_data(info_dir + '/WHEEL', WHEEL_DATA.format(PY_VERSION, ABI_TAG, platform)) whl.write_file(info_dir + '/LICENSE.txt', license_src) whl.write_file(info_dir + '/README.md', readme_src) whl.write_file_data(info_dir + '/top_level.txt', 'direct\npanda3d\npandac\npanda3d_tools\n') # Add libpython for deployment if sys.platform in ('win32', 'cygwin'): pylib_name = 'python{0}{1}.dll'.format(*sys.version_info) pylib_path = os.path.join(get_config_var('BINDIR'), pylib_name) elif sys.platform == 'darwin': pylib_name = 'libpython{0}.{1}.dylib'.format(*sys.version_info) pylib_path = os.path.join(get_config_var('LIBDIR'), pylib_name) else: pylib_name = get_config_var('LDLIBRARY') pylib_path = os.path.join(get_config_var('LIBDIR'), pylib_name) whl.write_file('deploy_libs/' + pylib_name, pylib_path) whl.close() if __name__ == "__main__": version = ParsePandaVersion("dtool/PandaVersion.pp") parser = OptionParser() parser.add_option('', '--version', dest = 'version', help = 'Panda3D version number (default: %s)' % (version), default = version) parser.add_option('', '--outputdir', dest = 'outputdir', help = 'Makepanda\'s output directory (default: built)', default = 'built') parser.add_option('', '--verbose', dest = 'verbose', help = 'Enable verbose output', action = 'store_true', default = False) parser.add_option('', '--platform', dest = 'platform', help = 'Override platform tag', default = None) (options, args) = parser.parse_args() SetVerbose(options.verbose) makewheel(options.version, options.outputdir, options.platform)