bdist_egg.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. """setuptools.command.bdist_egg
  2. Build .egg distributions"""
  3. from __future__ import annotations
  4. import marshal
  5. import os
  6. import re
  7. import sys
  8. import textwrap
  9. from collections.abc import Iterator
  10. from sysconfig import get_path, get_platform, get_python_version
  11. from types import CodeType
  12. from typing import TYPE_CHECKING, AnyStr, Literal
  13. from setuptools import Command
  14. from setuptools.extension import Library
  15. from .._path import StrPath, StrPathT, ensure_directory
  16. from distutils import log
  17. from distutils.dir_util import mkpath, remove_tree
  18. if TYPE_CHECKING:
  19. from _typeshed import GenericPath
  20. from typing_extensions import TypeAlias
  21. # Same as zipfile._ZipFileMode from typeshed
  22. _ZipFileMode: TypeAlias = Literal["r", "w", "x", "a"]
  23. def _get_purelib():
  24. return get_path("purelib")
  25. def strip_module(filename):
  26. if '.' in filename:
  27. filename = os.path.splitext(filename)[0]
  28. filename = filename.removesuffix('module')
  29. return filename
  30. def sorted_walk(
  31. dir: GenericPath[AnyStr],
  32. ) -> Iterator[tuple[AnyStr, list[AnyStr], list[AnyStr]]]:
  33. """Do os.walk in a reproducible way,
  34. independent of indeterministic filesystem readdir order
  35. """
  36. for base, dirs, files in os.walk(dir):
  37. dirs.sort()
  38. files.sort()
  39. yield base, dirs, files
  40. def write_stub(resource, pyfile) -> None:
  41. _stub_template = textwrap.dedent(
  42. """
  43. def __bootstrap__():
  44. global __bootstrap__, __loader__, __file__
  45. import sys, importlib.resources as irs, importlib.util
  46. with irs.as_file(irs.files(__name__).joinpath(%r)) as __file__:
  47. __loader__ = None; del __bootstrap__, __loader__
  48. spec = importlib.util.spec_from_file_location(__name__,__file__)
  49. mod = importlib.util.module_from_spec(spec)
  50. spec.loader.exec_module(mod)
  51. __bootstrap__()
  52. """
  53. ).lstrip()
  54. with open(pyfile, 'w', encoding="utf-8") as f:
  55. f.write(_stub_template % resource)
  56. class bdist_egg(Command):
  57. description = 'create an "egg" distribution'
  58. user_options = [
  59. ('bdist-dir=', 'b', "temporary directory for creating the distribution"),
  60. (
  61. 'plat-name=',
  62. 'p',
  63. "platform name to embed in generated filenames "
  64. "(by default uses `sysconfig.get_platform()`)",
  65. ),
  66. ('exclude-source-files', None, "remove all .py files from the generated egg"),
  67. (
  68. 'keep-temp',
  69. 'k',
  70. "keep the pseudo-installation tree around after "
  71. "creating the distribution archive",
  72. ),
  73. ('dist-dir=', 'd', "directory to put final built distributions in"),
  74. ('skip-build', None, "skip rebuilding everything (for testing/debugging)"),
  75. ]
  76. boolean_options = ['keep-temp', 'skip-build', 'exclude-source-files']
  77. def initialize_options(self):
  78. self.bdist_dir = None
  79. self.plat_name = None
  80. self.keep_temp = False
  81. self.dist_dir = None
  82. self.skip_build = False
  83. self.egg_output = None
  84. self.exclude_source_files = None
  85. def finalize_options(self) -> None:
  86. ei_cmd = self.ei_cmd = self.get_finalized_command("egg_info")
  87. self.egg_info = ei_cmd.egg_info
  88. if self.bdist_dir is None:
  89. bdist_base = self.get_finalized_command('bdist').bdist_base
  90. self.bdist_dir = os.path.join(bdist_base, 'egg')
  91. if self.plat_name is None:
  92. self.plat_name = get_platform()
  93. self.set_undefined_options('bdist', ('dist_dir', 'dist_dir'))
  94. if self.egg_output is None:
  95. # Compute filename of the output egg
  96. basename = ei_cmd._get_egg_basename(
  97. py_version=get_python_version(),
  98. platform=self.distribution.has_ext_modules() and self.plat_name,
  99. )
  100. self.egg_output = os.path.join(self.dist_dir, basename + '.egg')
  101. def do_install_data(self) -> None:
  102. # Hack for packages that install data to install's --install-lib
  103. self.get_finalized_command('install').install_lib = self.bdist_dir
  104. site_packages = os.path.normcase(os.path.realpath(_get_purelib()))
  105. old, self.distribution.data_files = self.distribution.data_files, []
  106. for item in old:
  107. if isinstance(item, tuple) and len(item) == 2:
  108. if os.path.isabs(item[0]):
  109. realpath = os.path.realpath(item[0])
  110. normalized = os.path.normcase(realpath)
  111. if normalized == site_packages or normalized.startswith(
  112. site_packages + os.sep
  113. ):
  114. item = realpath[len(site_packages) + 1 :], item[1]
  115. # XXX else: raise ???
  116. self.distribution.data_files.append(item)
  117. try:
  118. log.info("installing package data to %s", self.bdist_dir)
  119. self.call_command('install_data', force=False, root=None)
  120. finally:
  121. self.distribution.data_files = old
  122. def get_outputs(self):
  123. return [self.egg_output]
  124. def call_command(self, cmdname, **kw):
  125. """Invoke reinitialized command `cmdname` with keyword args"""
  126. for dirname in INSTALL_DIRECTORY_ATTRS:
  127. kw.setdefault(dirname, self.bdist_dir)
  128. kw.setdefault('skip_build', self.skip_build)
  129. cmd = self.reinitialize_command(cmdname, **kw)
  130. self.run_command(cmdname)
  131. return cmd
  132. def run(self) -> None: # noqa: C901 # is too complex (14) # FIXME
  133. # Generate metadata first
  134. self.run_command("egg_info")
  135. # We run install_lib before install_data, because some data hacks
  136. # pull their data path from the install_lib command.
  137. log.info("installing library code to %s", self.bdist_dir)
  138. instcmd = self.get_finalized_command('install')
  139. old_root = instcmd.root
  140. instcmd.root = None
  141. if self.distribution.has_c_libraries() and not self.skip_build:
  142. self.run_command('build_clib')
  143. cmd = self.call_command('install_lib', warn_dir=False)
  144. instcmd.root = old_root
  145. all_outputs, ext_outputs = self.get_ext_outputs()
  146. self.stubs = []
  147. to_compile = []
  148. for p, ext_name in enumerate(ext_outputs):
  149. filename, _ext = os.path.splitext(ext_name)
  150. pyfile = os.path.join(self.bdist_dir, strip_module(filename) + '.py')
  151. self.stubs.append(pyfile)
  152. log.info("creating stub loader for %s", ext_name)
  153. write_stub(os.path.basename(ext_name), pyfile)
  154. to_compile.append(pyfile)
  155. ext_outputs[p] = ext_name.replace(os.sep, '/')
  156. if to_compile:
  157. cmd.byte_compile(to_compile)
  158. if self.distribution.data_files:
  159. self.do_install_data()
  160. # Make the EGG-INFO directory
  161. archive_root = self.bdist_dir
  162. egg_info = os.path.join(archive_root, 'EGG-INFO')
  163. self.mkpath(egg_info)
  164. if self.distribution.scripts:
  165. script_dir = os.path.join(egg_info, 'scripts')
  166. log.info("installing scripts to %s", script_dir)
  167. self.call_command('install_scripts', install_dir=script_dir, no_ep=True)
  168. self.copy_metadata_to(egg_info)
  169. native_libs = os.path.join(egg_info, "native_libs.txt")
  170. if all_outputs:
  171. log.info("writing %s", native_libs)
  172. ensure_directory(native_libs)
  173. with open(native_libs, 'wt', encoding="utf-8") as libs_file:
  174. libs_file.write('\n'.join(all_outputs))
  175. libs_file.write('\n')
  176. elif os.path.isfile(native_libs):
  177. log.info("removing %s", native_libs)
  178. os.unlink(native_libs)
  179. write_safety_flag(os.path.join(archive_root, 'EGG-INFO'), self.zip_safe())
  180. if os.path.exists(os.path.join(self.egg_info, 'depends.txt')):
  181. log.warn(
  182. "WARNING: 'depends.txt' will not be used by setuptools 0.6!\n"
  183. "Use the install_requires/extras_require setup() args instead."
  184. )
  185. if self.exclude_source_files:
  186. self.zap_pyfiles()
  187. # Make the archive
  188. make_zipfile(
  189. self.egg_output,
  190. archive_root,
  191. verbose=self.verbose,
  192. mode=self.gen_header(),
  193. )
  194. if not self.keep_temp:
  195. remove_tree(self.bdist_dir)
  196. # Add to 'Distribution.dist_files' so that the "upload" command works
  197. getattr(self.distribution, 'dist_files', []).append((
  198. 'bdist_egg',
  199. get_python_version(),
  200. self.egg_output,
  201. ))
  202. def zap_pyfiles(self) -> None:
  203. log.info("Removing .py files from temporary directory")
  204. for base, dirs, files in walk_egg(self.bdist_dir):
  205. for name in files:
  206. path = os.path.join(base, name)
  207. if name.endswith('.py'):
  208. log.debug("Deleting %s", path)
  209. os.unlink(path)
  210. if base.endswith('__pycache__'):
  211. path_old = path
  212. pattern = r'(?P<name>.+)\.(?P<magic>[^.]+)\.pyc'
  213. m = re.match(pattern, name)
  214. # We shouldn't find any non-pyc files in __pycache__
  215. assert m is not None
  216. path_new = os.path.join(base, os.pardir, m.group('name') + '.pyc')
  217. log.info(f"Renaming file from [{path_old}] to [{path_new}]")
  218. try:
  219. os.remove(path_new)
  220. except OSError:
  221. pass
  222. os.rename(path_old, path_new)
  223. def zip_safe(self):
  224. safe = getattr(self.distribution, 'zip_safe', None)
  225. if safe is not None:
  226. return safe
  227. log.warn("zip_safe flag not set; analyzing archive contents...")
  228. return analyze_egg(self.bdist_dir, self.stubs)
  229. def gen_header(self) -> Literal["w"]:
  230. return 'w'
  231. def copy_metadata_to(self, target_dir) -> None:
  232. "Copy metadata (egg info) to the target_dir"
  233. # normalize the path (so that a forward-slash in egg_info will
  234. # match using startswith below)
  235. norm_egg_info = os.path.normpath(self.egg_info)
  236. prefix = os.path.join(norm_egg_info, '')
  237. for path in self.ei_cmd.filelist.files:
  238. if path.startswith(prefix):
  239. target = os.path.join(target_dir, path[len(prefix) :])
  240. ensure_directory(target)
  241. self.copy_file(path, target)
  242. def get_ext_outputs(self):
  243. """Get a list of relative paths to C extensions in the output distro"""
  244. all_outputs = []
  245. ext_outputs = []
  246. paths = {self.bdist_dir: ''}
  247. for base, dirs, files in sorted_walk(self.bdist_dir):
  248. all_outputs.extend(
  249. paths[base] + filename
  250. for filename in files
  251. if os.path.splitext(filename)[1].lower() in NATIVE_EXTENSIONS
  252. )
  253. for filename in dirs:
  254. paths[os.path.join(base, filename)] = paths[base] + filename + '/'
  255. if self.distribution.has_ext_modules():
  256. build_cmd = self.get_finalized_command('build_ext')
  257. for ext in build_cmd.extensions:
  258. if isinstance(ext, Library):
  259. continue
  260. fullname = build_cmd.get_ext_fullname(ext.name)
  261. filename = build_cmd.get_ext_filename(fullname)
  262. if not os.path.basename(filename).startswith('dl-'):
  263. if os.path.exists(os.path.join(self.bdist_dir, filename)):
  264. ext_outputs.append(filename)
  265. return all_outputs, ext_outputs
  266. NATIVE_EXTENSIONS: dict[str, None] = dict.fromkeys('.dll .so .dylib .pyd'.split())
  267. def walk_egg(egg_dir: StrPath) -> Iterator[tuple[str, list[str], list[str]]]:
  268. """Walk an unpacked egg's contents, skipping the metadata directory"""
  269. walker = sorted_walk(egg_dir)
  270. base, dirs, files = next(walker)
  271. if 'EGG-INFO' in dirs:
  272. dirs.remove('EGG-INFO')
  273. yield base, dirs, files
  274. yield from walker
  275. def analyze_egg(egg_dir, stubs):
  276. # check for existing flag in EGG-INFO
  277. for flag, fn in safety_flags.items():
  278. if os.path.exists(os.path.join(egg_dir, 'EGG-INFO', fn)):
  279. return flag
  280. if not can_scan():
  281. return False
  282. safe = True
  283. for base, dirs, files in walk_egg(egg_dir):
  284. for name in files:
  285. if name.endswith(('.py', '.pyw')):
  286. continue
  287. elif name.endswith(('.pyc', '.pyo')):
  288. # always scan, even if we already know we're not safe
  289. safe = scan_module(egg_dir, base, name, stubs) and safe
  290. return safe
  291. def write_safety_flag(egg_dir, safe) -> None:
  292. # Write or remove zip safety flag file(s)
  293. for flag, fn in safety_flags.items():
  294. fn = os.path.join(egg_dir, fn)
  295. if os.path.exists(fn):
  296. if safe is None or bool(safe) != flag:
  297. os.unlink(fn)
  298. elif safe is not None and bool(safe) == flag:
  299. with open(fn, 'wt', encoding="utf-8") as f:
  300. f.write('\n')
  301. safety_flags = {
  302. True: 'zip-safe',
  303. False: 'not-zip-safe',
  304. }
  305. def scan_module(egg_dir, base, name, stubs):
  306. """Check whether module possibly uses unsafe-for-zipfile stuff"""
  307. filename = os.path.join(base, name)
  308. if filename[:-1] in stubs:
  309. return True # Extension module
  310. pkg = base[len(egg_dir) + 1 :].replace(os.sep, '.')
  311. module = pkg + (pkg and '.' or '') + os.path.splitext(name)[0]
  312. skip = 16 # skip magic & reserved? & date & file size
  313. f = open(filename, 'rb')
  314. f.read(skip)
  315. code = marshal.load(f)
  316. f.close()
  317. safe = True
  318. symbols = dict.fromkeys(iter_symbols(code))
  319. for bad in ['__file__', '__path__']:
  320. if bad in symbols:
  321. log.warn("%s: module references %s", module, bad)
  322. safe = False
  323. if 'inspect' in symbols:
  324. for bad in [
  325. 'getsource',
  326. 'getabsfile',
  327. 'getfile',
  328. 'getsourcefile',
  329. 'getsourcelines',
  330. 'findsource',
  331. 'getcomments',
  332. 'getframeinfo',
  333. 'getinnerframes',
  334. 'getouterframes',
  335. 'stack',
  336. 'trace',
  337. ]:
  338. if bad in symbols:
  339. log.warn("%s: module MAY be using inspect.%s", module, bad)
  340. safe = False
  341. return safe
  342. def iter_symbols(code: CodeType) -> Iterator[str]:
  343. """Yield names and strings used by `code` and its nested code objects"""
  344. yield from code.co_names
  345. for const in code.co_consts:
  346. if isinstance(const, str):
  347. yield const
  348. elif isinstance(const, CodeType):
  349. yield from iter_symbols(const)
  350. def can_scan() -> bool:
  351. if not sys.platform.startswith('java') and sys.platform != 'cli':
  352. # CPython, PyPy, etc.
  353. return True
  354. log.warn("Unable to analyze compiled code on this platform.")
  355. log.warn(
  356. "Please ask the author to include a 'zip_safe'"
  357. " setting (either True or False) in the package's setup.py"
  358. )
  359. return False
  360. # Attribute names of options for commands that might need to be convinced to
  361. # install to the egg build directory
  362. INSTALL_DIRECTORY_ATTRS = ['install_lib', 'install_dir', 'install_data', 'install_base']
  363. def make_zipfile(
  364. zip_filename: StrPathT,
  365. base_dir,
  366. verbose: bool = False,
  367. compress=True,
  368. mode: _ZipFileMode = 'w',
  369. ) -> StrPathT:
  370. """Create a zip file from all the files under 'base_dir'. The output
  371. zip file will be named 'base_dir' + ".zip". Uses either the "zipfile"
  372. Python module (if available) or the InfoZIP "zip" utility (if installed
  373. and found on the default search path). If neither tool is available,
  374. raises DistutilsExecError. Returns the name of the output zip file.
  375. """
  376. import zipfile
  377. mkpath(os.path.dirname(zip_filename)) # type: ignore[arg-type] # python/mypy#18075
  378. log.info("creating '%s' and adding '%s' to it", zip_filename, base_dir)
  379. def visit(z, dirname, names):
  380. for name in names:
  381. path = os.path.normpath(os.path.join(dirname, name))
  382. if os.path.isfile(path):
  383. p = path[len(base_dir) + 1 :]
  384. z.write(path, p)
  385. log.debug("adding '%s'", p)
  386. compression = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
  387. z = zipfile.ZipFile(zip_filename, mode, compression=compression)
  388. for dirname, dirs, files in sorted_walk(base_dir):
  389. visit(z, dirname, files)
  390. z.close()
  391. return zip_filename