makewheel.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. """
  2. Generates a wheel (.whl) file from the output of makepanda.
  3. Since the wheel requires special linking, this will only work if compiled with
  4. the `--wheel` parameter.
  5. Please keep this file work with Panda3D 1.9 until that reaches EOL.
  6. """
  7. from __future__ import print_function, unicode_literals
  8. from distutils.util import get_platform
  9. import json
  10. import sys
  11. import os
  12. from os.path import join
  13. import shutil
  14. import zipfile
  15. import hashlib
  16. import tempfile
  17. import subprocess
  18. from distutils.sysconfig import get_config_var
  19. from optparse import OptionParser
  20. from makepandacore import ColorText, LocateBinary, ParsePandaVersion, GetExtensionSuffix, SetVerbose, GetVerbose
  21. from base64 import urlsafe_b64encode
  22. default_platform = get_platform()
  23. if default_platform.startswith("linux-"):
  24. # Is this manylinux1?
  25. if os.path.isfile("/lib/libc-2.5.so") and os.path.isdir("/opt/python"):
  26. default_platform = default_platform.replace("linux", "manylinux1")
  27. def get_abi_tag():
  28. if sys.version_info >= (3, 0):
  29. soabi = get_config_var('SOABI')
  30. if soabi and soabi.startswith('cpython-'):
  31. return 'cp' + soabi.split('-')[1]
  32. elif soabi:
  33. return soabi.replace('.', '_').replace('-', '_')
  34. soabi = 'cp%d%d' % (sys.version_info[:2])
  35. debug_flag = get_config_var('Py_DEBUG')
  36. if (debug_flag is None and hasattr(sys, 'gettotalrefcount')) or debug_flag:
  37. soabi += 'd'
  38. malloc_flag = get_config_var('WITH_PYMALLOC')
  39. if malloc_flag is None or malloc_flag:
  40. soabi += 'm'
  41. if sys.version_info < (3, 3):
  42. usize = get_config_var('Py_UNICODE_SIZE')
  43. if (usize is None and sys.maxunicode == 0x10ffff) or usize == 4:
  44. soabi += 'u'
  45. return soabi
  46. def is_exe_file(path):
  47. return os.path.isfile(path) and path.lower().endswith('.exe')
  48. def is_elf_file(path):
  49. base = os.path.basename(path)
  50. return os.path.isfile(path) and '.' not in base and \
  51. open(path, 'rb').read(4) == b'\x7FELF'
  52. def is_mach_o_file(path):
  53. base = os.path.basename(path)
  54. return os.path.isfile(path) and '.' not in base and \
  55. open(path, 'rb').read(4) in (b'\xCA\xFE\xBA\xBE', b'\xBE\xBA\xFE\bCA',
  56. b'\xFE\xED\xFA\xCE', b'\xCE\xFA\xED\xFE',
  57. b'\xFE\xED\xFA\xCF', b'\xCF\xFA\xED\xFE')
  58. def is_fat_file(path):
  59. return os.path.isfile(path) and \
  60. open(path, 'rb').read(4) in (b'\xCA\xFE\xBA\xBE', b'\xBE\xBA\xFE\bCA')
  61. if sys.platform in ('win32', 'cygwin'):
  62. is_executable = is_exe_file
  63. elif sys.platform == 'darwin':
  64. is_executable = is_mach_o_file
  65. else:
  66. is_executable = is_elf_file
  67. # Other global parameters
  68. PY_VERSION = "cp{0}{1}".format(*sys.version_info)
  69. ABI_TAG = get_abi_tag()
  70. EXCLUDE_EXT = [".pyc", ".pyo", ".N", ".prebuilt", ".xcf", ".plist", ".vcproj", ".sln"]
  71. # Plug-ins to install.
  72. PLUGIN_LIBS = ["pandagl", "pandagles", "pandagles2", "pandadx9", "p3tinydisplay", "p3ptloader", "p3assimp", "p3ffmpeg", "p3openal_audio", "p3fmod_audio"]
  73. WHEEL_DATA = """Wheel-Version: 1.0
  74. Generator: makepanda
  75. Root-Is-Purelib: false
  76. Tag: {0}-{1}-{2}
  77. """
  78. METADATA = {
  79. "license": "BSD",
  80. "name": "Panda3D",
  81. "metadata_version": "2.0",
  82. "generator": "makepanda",
  83. "summary": "Panda3D is a game engine, a framework for 3D rendering and "
  84. "game development for Python and C++ programs.",
  85. "extensions": {
  86. "python.details": {
  87. "project_urls": {
  88. "Home": "https://www.panda3d.org/"
  89. },
  90. "document_names": {
  91. "license": "LICENSE.txt"
  92. },
  93. "contacts": [
  94. {
  95. "role": "author",
  96. "email": "[email protected]",
  97. "name": "Panda3D Team"
  98. }
  99. ]
  100. }
  101. },
  102. "classifiers": [
  103. "Development Status :: 5 - Production/Stable",
  104. "Intended Audience :: Developers",
  105. "Intended Audience :: End Users/Desktop",
  106. "License :: OSI Approved :: BSD License",
  107. "Operating System :: OS Independent",
  108. "Programming Language :: C++",
  109. "Programming Language :: Python",
  110. "Topic :: Games/Entertainment",
  111. "Topic :: Multimedia",
  112. "Topic :: Multimedia :: Graphics",
  113. "Topic :: Multimedia :: Graphics :: 3D Rendering"
  114. ]
  115. }
  116. PANDA3D_TOOLS_INIT = """import os, sys
  117. import panda3d
  118. if sys.platform in ('win32', 'cygwin'):
  119. path_var = 'PATH'
  120. elif sys.platform == 'darwin':
  121. path_var = 'DYLD_LIBRARY_PATH'
  122. else:
  123. path_var = 'LD_LIBRARY_PATH'
  124. dir = os.path.dirname(panda3d.__file__)
  125. del panda3d
  126. if not os.environ.get(path_var):
  127. os.environ[path_var] = dir
  128. else:
  129. os.environ[path_var] = dir + os.pathsep + os.environ[path_var]
  130. del os, sys, path_var, dir
  131. def _exec_tool(tool):
  132. import os, sys
  133. from subprocess import Popen
  134. tools_dir = os.path.dirname(__file__)
  135. handle = Popen(sys.argv, executable=os.path.join(tools_dir, tool))
  136. try:
  137. try:
  138. return handle.wait()
  139. except KeyboardInterrupt:
  140. # Give the program a chance to handle the signal gracefully.
  141. return handle.wait()
  142. except:
  143. handle.kill()
  144. handle.wait()
  145. raise
  146. # Register all the executables in this directory as global functions.
  147. {0}
  148. """
  149. def parse_dependencies_windows(data):
  150. """ Parses the given output from dumpbin /dependents to determine the list
  151. of dll's this executable file depends on. """
  152. lines = data.splitlines()
  153. li = 0
  154. while li < len(lines):
  155. line = lines[li]
  156. li += 1
  157. if line.find(' has the following dependencies') != -1:
  158. break
  159. if li < len(lines):
  160. line = lines[li]
  161. if line.strip() == '':
  162. # Skip a blank line.
  163. li += 1
  164. # Now we're finding filenames, until the next blank line.
  165. filenames = []
  166. while li < len(lines):
  167. line = lines[li]
  168. li += 1
  169. line = line.strip()
  170. if line == '':
  171. # We're done.
  172. return filenames
  173. filenames.append(line)
  174. # At least we got some data.
  175. return filenames
  176. def parse_dependencies_unix(data):
  177. """ Parses the given output from otool -XL or ldd to determine the list of
  178. libraries this executable file depends on. """
  179. lines = data.splitlines()
  180. filenames = []
  181. for l in lines:
  182. l = l.strip()
  183. if l != "statically linked":
  184. filenames.append(l.split(' ', 1)[0])
  185. return filenames
  186. def scan_dependencies(pathname):
  187. """ Checks the named file for DLL dependencies, and adds any appropriate
  188. dependencies found into pluginDependencies and dependentFiles. """
  189. if sys.platform == "darwin":
  190. command = ['otool', '-XL', pathname]
  191. elif sys.platform in ("win32", "cygwin"):
  192. command = ['dumpbin', '/dependents', pathname]
  193. else:
  194. command = ['ldd', pathname]
  195. process = subprocess.Popen(command, stdout=subprocess.PIPE, universal_newlines=True)
  196. output, unused_err = process.communicate()
  197. retcode = process.poll()
  198. if retcode:
  199. raise subprocess.CalledProcessError(retcode, command[0], output=output)
  200. filenames = None
  201. if sys.platform in ("win32", "cygwin"):
  202. filenames = parse_dependencies_windows(output)
  203. else:
  204. filenames = parse_dependencies_unix(output)
  205. if filenames is None:
  206. sys.exit("Unable to determine dependencies from %s" % (pathname))
  207. if sys.platform == "darwin" and len(filenames) > 0:
  208. # Filter out the library ID.
  209. if os.path.basename(filenames[0]).split('.', 1)[0] == os.path.basename(pathname).split('.', 1)[0]:
  210. del filenames[0]
  211. return filenames
  212. class WheelFile(object):
  213. def __init__(self, name, version, platform):
  214. self.name = name
  215. self.version = version
  216. self.platform = platform
  217. wheel_name = "{0}-{1}-{2}-{3}-{4}.whl".format(
  218. name, version, PY_VERSION, ABI_TAG, platform)
  219. print("Writing %s" % (wheel_name))
  220. self.zip_file = zipfile.ZipFile(wheel_name, 'w', zipfile.ZIP_DEFLATED)
  221. self.records = []
  222. # Used to locate dependency libraries.
  223. self.lib_path = []
  224. self.dep_paths = {}
  225. def consider_add_dependency(self, target_path, dep, search_path=None):
  226. """Considers adding a dependency library.
  227. Returns the target_path if it was added, which may be different from
  228. target_path if it was already added earlier, or None if it wasn't."""
  229. if dep in self.dep_paths:
  230. # Already considered this.
  231. return self.dep_paths[dep]
  232. self.dep_paths[dep] = None
  233. if dep.lower().startswith("python") or os.path.basename(dep).startswith("libpython"):
  234. # Don't include the Python library.
  235. return
  236. if sys.platform == "darwin" and dep.endswith(".so"):
  237. # Temporary hack for 1.9, which had link deps on modules.
  238. return
  239. source_path = None
  240. if search_path is None:
  241. search_path = self.lib_path
  242. for lib_dir in search_path:
  243. # Ignore static stuff.
  244. path = os.path.join(lib_dir, dep)
  245. if os.path.isfile(path):
  246. source_path = os.path.normpath(path)
  247. break
  248. if not source_path:
  249. # Couldn't find library in the panda3d lib dir.
  250. #print("Ignoring %s" % (dep))
  251. return
  252. self.dep_paths[dep] = target_path
  253. self.write_file(target_path, source_path)
  254. return target_path
  255. def write_file(self, target_path, source_path):
  256. """Adds the given file to the .whl file."""
  257. # If this is a .so file, we should set the rpath appropriately.
  258. temp = None
  259. ext = os.path.splitext(source_path)[1]
  260. if ext in ('.so', '.dylib') or '.so.' in os.path.basename(source_path) or \
  261. (not ext and is_executable(source_path)):
  262. # Scan and add Unix dependencies.
  263. deps = scan_dependencies(source_path)
  264. for dep in deps:
  265. # Only include dependencies with relative path. Otherwise we
  266. # end up overwriting system files like /lib/ld-linux.so.2!
  267. # Yes, it happened to me.
  268. if '/' not in dep:
  269. target_dep = os.path.dirname(target_path) + '/' + dep
  270. self.consider_add_dependency(target_dep, dep)
  271. suffix = ''
  272. if '.so' in os.path.basename(source_path):
  273. suffix = '.so'
  274. elif ext == '.dylib':
  275. suffix = '.dylib'
  276. temp = tempfile.NamedTemporaryFile(suffix=suffix, prefix='whl', delete=False)
  277. # On macOS, if no fat wheel was requested, extract the right architecture.
  278. if sys.platform == "darwin" and is_fat_file(source_path) and not self.platform.endswith("_intel"):
  279. if self.platform.endswith("_x86_64"):
  280. arch = 'x86_64'
  281. else:
  282. arch = self.platform.split('_')[-1]
  283. subprocess.call(['lipo', source_path, '-extract', arch, '-output', temp.name])
  284. else:
  285. # Otherwise, just copy it over.
  286. temp.write(open(source_path, 'rb').read())
  287. temp.write(open(source_path, 'rb').read())
  288. os.fchmod(temp.fileno(), os.fstat(temp.fileno()).st_mode | 0o111)
  289. temp.close()
  290. # Fix things like @loader_path/../lib references
  291. if sys.platform == "darwin":
  292. loader_path = [os.path.dirname(source_path)]
  293. for dep in deps:
  294. if '@loader_path' not in dep:
  295. continue
  296. dep_path = dep.replace('@loader_path', '.')
  297. target_dep = os.path.dirname(target_path) + '/' + os.path.basename(dep)
  298. target_dep = self.consider_add_dependency(target_dep, dep_path, loader_path)
  299. if not target_dep:
  300. # It won't be included, so no use adjusting the path.
  301. continue
  302. new_dep = os.path.join('@loader_path', os.path.relpath(target_dep, os.path.dirname(target_path)))
  303. subprocess.call(["install_name_tool", "-change", dep, new_dep, temp.name])
  304. else:
  305. subprocess.call(["strip", "-s", temp.name])
  306. subprocess.call(["patchelf", "--set-rpath", "$ORIGIN", temp.name])
  307. source_path = temp.name
  308. ext = ext.lower()
  309. if ext in ('.dll', '.pyd', '.exe'):
  310. # Scan and add Win32 dependencies.
  311. for dep in scan_dependencies(source_path):
  312. target_dep = os.path.dirname(target_path) + '/' + dep
  313. self.consider_add_dependency(target_dep, dep)
  314. # Calculate the SHA-256 hash and size.
  315. sha = hashlib.sha256()
  316. fp = open(source_path, 'rb')
  317. size = 0
  318. data = fp.read(1024 * 1024)
  319. while data:
  320. size += len(data)
  321. sha.update(data)
  322. data = fp.read(1024 * 1024)
  323. fp.close()
  324. # Save it in PEP-0376 format for writing out later.
  325. digest = str(urlsafe_b64encode(sha.digest()))
  326. digest = digest.rstrip('=')
  327. self.records.append("{0},sha256={1},{2}\n".format(target_path, digest, size))
  328. if GetVerbose():
  329. print("Adding %s from %s" % (target_path, source_path))
  330. self.zip_file.write(source_path, target_path)
  331. #if temp:
  332. # os.unlink(temp.name)
  333. def write_file_data(self, target_path, source_data):
  334. """Adds the given file from a string."""
  335. sha = hashlib.sha256()
  336. sha.update(source_data.encode())
  337. digest = str(urlsafe_b64encode(sha.digest()))
  338. digest = digest.rstrip('=')
  339. self.records.append("{0},sha256={1},{2}\n".format(target_path, digest, len(source_data)))
  340. if GetVerbose():
  341. print("Adding %s from data" % target_path)
  342. self.zip_file.writestr(target_path, source_data)
  343. def write_directory(self, target_dir, source_dir):
  344. """Adds the given directory recursively to the .whl file."""
  345. for root, dirs, files in os.walk(source_dir):
  346. for file in files:
  347. if os.path.splitext(file)[1] in EXCLUDE_EXT:
  348. continue
  349. source_path = os.path.join(root, file)
  350. target_path = os.path.join(target_dir, os.path.relpath(source_path, source_dir))
  351. target_path = target_path.replace('\\', '/')
  352. self.write_file(target_path, source_path)
  353. def close(self):
  354. # Write the RECORD file.
  355. record_file = "{0}-{1}.dist-info/RECORD".format(self.name, self.version)
  356. self.records.append(record_file + ",,\n")
  357. self.zip_file.writestr(record_file, "".join(self.records))
  358. self.zip_file.close()
  359. def makewheel(version, output_dir, platform=default_platform):
  360. if sys.platform not in ("win32", "darwin") and not sys.platform.startswith("cygwin"):
  361. if not LocateBinary("patchelf"):
  362. raise Exception("patchelf is required when building a Linux wheel.")
  363. platform = platform.replace('-', '_').replace('.', '_')
  364. # Global filepaths
  365. panda3d_dir = join(output_dir, "panda3d")
  366. pandac_dir = join(output_dir, "pandac")
  367. direct_dir = join(output_dir, "direct")
  368. models_dir = join(output_dir, "models")
  369. etc_dir = join(output_dir, "etc")
  370. bin_dir = join(output_dir, "bin")
  371. if sys.platform == "win32":
  372. libs_dir = join(output_dir, "bin")
  373. else:
  374. libs_dir = join(output_dir, "lib")
  375. license_src = "LICENSE"
  376. readme_src = "README.md"
  377. # Update relevant METADATA entries
  378. METADATA['version'] = version
  379. version_classifiers = [
  380. "Programming Language :: Python :: {0}".format(*sys.version_info),
  381. "Programming Language :: Python :: {0}.{1}".format(*sys.version_info),
  382. ]
  383. METADATA['classifiers'].extend(version_classifiers)
  384. # Build out the metadata
  385. details = METADATA["extensions"]["python.details"]
  386. homepage = details["project_urls"]["Home"]
  387. author = details["contacts"][0]["name"]
  388. email = details["contacts"][0]["email"]
  389. metadata = ''.join([
  390. "Metadata-Version: {metadata_version}\n" \
  391. "Name: {name}\n" \
  392. "Version: {version}\n" \
  393. "Summary: {summary}\n" \
  394. "License: {license}\n".format(**METADATA),
  395. "Home-page: {0}\n".format(homepage),
  396. "Author: {0}\n".format(author),
  397. "Author-email: {0}\n".format(email),
  398. "Platform: {0}\n".format(platform),
  399. ] + ["Classifier: {0}\n".format(c) for c in METADATA['classifiers']])
  400. # Zip it up and name it the right thing
  401. whl = WheelFile('panda3d', version, platform)
  402. whl.lib_path = [libs_dir]
  403. # Add the trees with Python modules.
  404. whl.write_directory('direct', direct_dir)
  405. # Write the panda3d tree. We use a custom empty __init__ since the
  406. # default one adds the bin directory to the PATH, which we don't have.
  407. whl.write_file_data('panda3d/__init__.py', '')
  408. ext_suffix = GetExtensionSuffix()
  409. for file in os.listdir(panda3d_dir):
  410. if file == '__init__.py':
  411. pass
  412. elif file.endswith(ext_suffix) or file.endswith('.py'):
  413. source_path = os.path.join(panda3d_dir, file)
  414. if file.endswith('.pyd') and platform.startswith('cygwin'):
  415. # Rename it to .dll for cygwin Python to be able to load it.
  416. target_path = 'panda3d/' + os.path.splitext(file)[0] + '.dll'
  417. else:
  418. target_path = 'panda3d/' + file
  419. whl.write_file(target_path, source_path)
  420. # Add plug-ins.
  421. for lib in PLUGIN_LIBS:
  422. plugin_name = 'lib' + lib
  423. if sys.platform in ('win32', 'cygwin'):
  424. plugin_name += '.dll'
  425. elif sys.platform == 'darwin':
  426. plugin_name += '.dylib'
  427. else:
  428. plugin_name += '.so'
  429. plugin_path = os.path.join(libs_dir, plugin_name)
  430. if os.path.isfile(plugin_path):
  431. whl.write_file('panda3d/' + plugin_name, plugin_path)
  432. # Add the .data directory, containing additional files.
  433. data_dir = 'panda3d-{0}.data'.format(version)
  434. #whl.write_directory(data_dir + '/data/etc', etc_dir)
  435. #whl.write_directory(data_dir + '/data/models', models_dir)
  436. # Actually, let's not. That seems to install the files to the strangest
  437. # places in the user's filesystem. Let's instead put them in panda3d.
  438. whl.write_directory('panda3d/etc', etc_dir)
  439. whl.write_directory('panda3d/models', models_dir)
  440. # Add the pandac tree for backward compatibility.
  441. for file in os.listdir(pandac_dir):
  442. if file.endswith('.py'):
  443. whl.write_file('pandac/' + file, os.path.join(pandac_dir, file))
  444. # Add a panda3d-tools directory containing the executables.
  445. entry_points = '[console_scripts]\n'
  446. entry_points += 'eggcacher = direct.directscripts.eggcacher:main\n'
  447. entry_points += 'pfreeze = direct.showutil.pfreeze:main\n'
  448. tools_init = ''
  449. for file in os.listdir(bin_dir):
  450. basename = os.path.splitext(file)[0]
  451. if basename in ('eggcacher', 'packpanda'):
  452. continue
  453. source_path = os.path.join(bin_dir, file)
  454. if is_executable(source_path):
  455. # Put the .exe files inside the panda3d-tools directory.
  456. whl.write_file('panda3d_tools/' + file, source_path)
  457. # Tell pip to create a wrapper script.
  458. funcname = basename.replace('-', '_')
  459. entry_points += '{0} = panda3d_tools:{1}\n'.format(basename, funcname)
  460. tools_init += '{0} = lambda: _exec_tool({1!r})\n'.format(funcname, file)
  461. whl.write_file_data('panda3d_tools/__init__.py', PANDA3D_TOOLS_INIT.format(tools_init))
  462. # Add the dist-info directory last.
  463. info_dir = 'panda3d-{0}.dist-info'.format(version)
  464. whl.write_file_data(info_dir + '/entry_points.txt', entry_points)
  465. whl.write_file_data(info_dir + '/metadata.json', json.dumps(METADATA, indent=4, separators=(',', ': ')))
  466. whl.write_file_data(info_dir + '/METADATA', metadata)
  467. whl.write_file_data(info_dir + '/WHEEL', WHEEL_DATA.format(PY_VERSION, ABI_TAG, platform))
  468. whl.write_file(info_dir + '/LICENSE.txt', license_src)
  469. whl.write_file(info_dir + '/README.md', readme_src)
  470. whl.write_file_data(info_dir + '/top_level.txt', 'direct\npanda3d\npandac\npanda3d_tools\n')
  471. whl.close()
  472. if __name__ == "__main__":
  473. version = ParsePandaVersion("dtool/PandaVersion.pp")
  474. parser = OptionParser()
  475. parser.add_option('', '--version', dest = 'version', help = 'Panda3D version number (default: %s)' % (version), default = version)
  476. parser.add_option('', '--outputdir', dest = 'outputdir', help = 'Makepanda\'s output directory (default: built)', default = 'built')
  477. parser.add_option('', '--verbose', dest = 'verbose', help = 'Enable verbose output', action = 'store_true', default = False)
  478. parser.add_option('', '--platform', dest = 'platform', help = 'Override platform tag (default: %s)' % (default_platform), default = get_platform())
  479. (options, args) = parser.parse_args()
  480. SetVerbose(options.verbose)
  481. makewheel(options.version, options.outputdir, options.platform)