build_py.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. from functools import partial
  2. from glob import glob
  3. from distutils.util import convert_path
  4. import distutils.command.build_py as orig
  5. import os
  6. import fnmatch
  7. import textwrap
  8. import io
  9. import distutils.errors
  10. import itertools
  11. import stat
  12. import warnings
  13. from pathlib import Path
  14. from typing import Dict, Iterable, Iterator, List, Optional, Tuple
  15. from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
  16. from setuptools.extern.more_itertools import unique_everseen
  17. def make_writable(target):
  18. os.chmod(target, os.stat(target).st_mode | stat.S_IWRITE)
  19. class build_py(orig.build_py):
  20. """Enhanced 'build_py' command that includes data files with packages
  21. The data files are specified via a 'package_data' argument to 'setup()'.
  22. See 'setuptools.dist.Distribution' for more details.
  23. Also, this version of the 'build_py' command allows you to specify both
  24. 'py_modules' and 'packages' in the same setup operation.
  25. """
  26. editable_mode: bool = False
  27. existing_egg_info_dir: Optional[str] = None #: Private API, internal use only.
  28. def finalize_options(self):
  29. orig.build_py.finalize_options(self)
  30. self.package_data = self.distribution.package_data
  31. self.exclude_package_data = self.distribution.exclude_package_data or {}
  32. if 'data_files' in self.__dict__:
  33. del self.__dict__['data_files']
  34. self.__updated_files = []
  35. def copy_file(self, infile, outfile, preserve_mode=1, preserve_times=1,
  36. link=None, level=1):
  37. # Overwrite base class to allow using links
  38. if link:
  39. infile = str(Path(infile).resolve())
  40. outfile = str(Path(outfile).resolve())
  41. return super().copy_file(infile, outfile, preserve_mode, preserve_times,
  42. link, level)
  43. def run(self):
  44. """Build modules, packages, and copy data files to build directory"""
  45. if not (self.py_modules or self.packages) or self.editable_mode:
  46. return
  47. if self.py_modules:
  48. self.build_modules()
  49. if self.packages:
  50. self.build_packages()
  51. self.build_package_data()
  52. # Only compile actual .py files, using our base class' idea of what our
  53. # output files are.
  54. self.byte_compile(orig.build_py.get_outputs(self, include_bytecode=0))
  55. def __getattr__(self, attr):
  56. "lazily compute data files"
  57. if attr == 'data_files':
  58. self.data_files = self._get_data_files()
  59. return self.data_files
  60. return orig.build_py.__getattr__(self, attr)
  61. def build_module(self, module, module_file, package):
  62. outfile, copied = orig.build_py.build_module(self, module, module_file, package)
  63. if copied:
  64. self.__updated_files.append(outfile)
  65. return outfile, copied
  66. def _get_data_files(self):
  67. """Generate list of '(package,src_dir,build_dir,filenames)' tuples"""
  68. self.analyze_manifest()
  69. return list(map(self._get_pkg_data_files, self.packages or ()))
  70. def get_data_files_without_manifest(self):
  71. """
  72. Generate list of ``(package,src_dir,build_dir,filenames)`` tuples,
  73. but without triggering any attempt to analyze or build the manifest.
  74. """
  75. # Prevent eventual errors from unset `manifest_files`
  76. # (that would otherwise be set by `analyze_manifest`)
  77. self.__dict__.setdefault('manifest_files', {})
  78. return list(map(self._get_pkg_data_files, self.packages or ()))
  79. def _get_pkg_data_files(self, package):
  80. # Locate package source directory
  81. src_dir = self.get_package_dir(package)
  82. # Compute package build directory
  83. build_dir = os.path.join(*([self.build_lib] + package.split('.')))
  84. # Strip directory from globbed filenames
  85. filenames = [
  86. os.path.relpath(file, src_dir)
  87. for file in self.find_data_files(package, src_dir)
  88. ]
  89. return package, src_dir, build_dir, filenames
  90. def find_data_files(self, package, src_dir):
  91. """Return filenames for package's data files in 'src_dir'"""
  92. patterns = self._get_platform_patterns(
  93. self.package_data,
  94. package,
  95. src_dir,
  96. )
  97. globs_expanded = map(partial(glob, recursive=True), patterns)
  98. # flatten the expanded globs into an iterable of matches
  99. globs_matches = itertools.chain.from_iterable(globs_expanded)
  100. glob_files = filter(os.path.isfile, globs_matches)
  101. files = itertools.chain(
  102. self.manifest_files.get(package, []),
  103. glob_files,
  104. )
  105. return self.exclude_data_files(package, src_dir, files)
  106. def get_outputs(self, include_bytecode=1) -> List[str]:
  107. """See :class:`setuptools.commands.build.SubCommand`"""
  108. if self.editable_mode:
  109. return list(self.get_output_mapping().keys())
  110. return super().get_outputs(include_bytecode)
  111. def get_output_mapping(self) -> Dict[str, str]:
  112. """See :class:`setuptools.commands.build.SubCommand`"""
  113. mapping = itertools.chain(
  114. self._get_package_data_output_mapping(),
  115. self._get_module_mapping(),
  116. )
  117. return dict(sorted(mapping, key=lambda x: x[0]))
  118. def _get_module_mapping(self) -> Iterator[Tuple[str, str]]:
  119. """Iterate over all modules producing (dest, src) pairs."""
  120. for (package, module, module_file) in self.find_all_modules():
  121. package = package.split('.')
  122. filename = self.get_module_outfile(self.build_lib, package, module)
  123. yield (filename, module_file)
  124. def _get_package_data_output_mapping(self) -> Iterator[Tuple[str, str]]:
  125. """Iterate over package data producing (dest, src) pairs."""
  126. for package, src_dir, build_dir, filenames in self.data_files:
  127. for filename in filenames:
  128. target = os.path.join(build_dir, filename)
  129. srcfile = os.path.join(src_dir, filename)
  130. yield (target, srcfile)
  131. def build_package_data(self):
  132. """Copy data files into build directory"""
  133. for target, srcfile in self._get_package_data_output_mapping():
  134. self.mkpath(os.path.dirname(target))
  135. _outf, _copied = self.copy_file(srcfile, target)
  136. make_writable(target)
  137. def analyze_manifest(self):
  138. self.manifest_files = mf = {}
  139. if not self.distribution.include_package_data:
  140. return
  141. src_dirs = {}
  142. for package in self.packages or ():
  143. # Locate package source directory
  144. src_dirs[assert_relative(self.get_package_dir(package))] = package
  145. if (
  146. getattr(self, 'existing_egg_info_dir', None)
  147. and Path(self.existing_egg_info_dir, "SOURCES.txt").exists()
  148. ):
  149. egg_info_dir = self.existing_egg_info_dir
  150. manifest = Path(egg_info_dir, "SOURCES.txt")
  151. files = manifest.read_text(encoding="utf-8").splitlines()
  152. else:
  153. self.run_command('egg_info')
  154. ei_cmd = self.get_finalized_command('egg_info')
  155. egg_info_dir = ei_cmd.egg_info
  156. files = ei_cmd.filelist.files
  157. check = _IncludePackageDataAbuse()
  158. for path in self._filter_build_files(files, egg_info_dir):
  159. d, f = os.path.split(assert_relative(path))
  160. prev = None
  161. oldf = f
  162. while d and d != prev and d not in src_dirs:
  163. prev = d
  164. d, df = os.path.split(d)
  165. f = os.path.join(df, f)
  166. if d in src_dirs:
  167. if f == oldf:
  168. if check.is_module(f):
  169. continue # it's a module, not data
  170. else:
  171. importable = check.importable_subpackage(src_dirs[d], f)
  172. if importable:
  173. check.warn(importable)
  174. mf.setdefault(src_dirs[d], []).append(path)
  175. def _filter_build_files(self, files: Iterable[str], egg_info: str) -> Iterator[str]:
  176. """
  177. ``build_meta`` may try to create egg_info outside of the project directory,
  178. and this can be problematic for certain plugins (reported in issue #3500).
  179. Extensions might also include between their sources files created on the
  180. ``build_lib`` and ``build_temp`` directories.
  181. This function should filter this case of invalid files out.
  182. """
  183. build = self.get_finalized_command("build")
  184. build_dirs = (egg_info, self.build_lib, build.build_temp, build.build_base)
  185. norm_dirs = [os.path.normpath(p) for p in build_dirs if p]
  186. for file in files:
  187. norm_path = os.path.normpath(file)
  188. if not os.path.isabs(file) or all(d not in norm_path for d in norm_dirs):
  189. yield file
  190. def get_data_files(self):
  191. pass # Lazily compute data files in _get_data_files() function.
  192. def check_package(self, package, package_dir):
  193. """Check namespace packages' __init__ for declare_namespace"""
  194. try:
  195. return self.packages_checked[package]
  196. except KeyError:
  197. pass
  198. init_py = orig.build_py.check_package(self, package, package_dir)
  199. self.packages_checked[package] = init_py
  200. if not init_py or not self.distribution.namespace_packages:
  201. return init_py
  202. for pkg in self.distribution.namespace_packages:
  203. if pkg == package or pkg.startswith(package + '.'):
  204. break
  205. else:
  206. return init_py
  207. with io.open(init_py, 'rb') as f:
  208. contents = f.read()
  209. if b'declare_namespace' not in contents:
  210. raise distutils.errors.DistutilsError(
  211. "Namespace package problem: %s is a namespace package, but "
  212. "its\n__init__.py does not call declare_namespace()! Please "
  213. 'fix it.\n(See the setuptools manual under '
  214. '"Namespace Packages" for details.)\n"' % (package,)
  215. )
  216. return init_py
  217. def initialize_options(self):
  218. self.packages_checked = {}
  219. orig.build_py.initialize_options(self)
  220. self.editable_mode = False
  221. self.existing_egg_info_dir = None
  222. def get_package_dir(self, package):
  223. res = orig.build_py.get_package_dir(self, package)
  224. if self.distribution.src_root is not None:
  225. return os.path.join(self.distribution.src_root, res)
  226. return res
  227. def exclude_data_files(self, package, src_dir, files):
  228. """Filter filenames for package's data files in 'src_dir'"""
  229. files = list(files)
  230. patterns = self._get_platform_patterns(
  231. self.exclude_package_data,
  232. package,
  233. src_dir,
  234. )
  235. match_groups = (fnmatch.filter(files, pattern) for pattern in patterns)
  236. # flatten the groups of matches into an iterable of matches
  237. matches = itertools.chain.from_iterable(match_groups)
  238. bad = set(matches)
  239. keepers = (fn for fn in files if fn not in bad)
  240. # ditch dupes
  241. return list(unique_everseen(keepers))
  242. @staticmethod
  243. def _get_platform_patterns(spec, package, src_dir):
  244. """
  245. yield platform-specific path patterns (suitable for glob
  246. or fn_match) from a glob-based spec (such as
  247. self.package_data or self.exclude_package_data)
  248. matching package in src_dir.
  249. """
  250. raw_patterns = itertools.chain(
  251. spec.get('', []),
  252. spec.get(package, []),
  253. )
  254. return (
  255. # Each pattern has to be converted to a platform-specific path
  256. os.path.join(src_dir, convert_path(pattern))
  257. for pattern in raw_patterns
  258. )
  259. def assert_relative(path):
  260. if not os.path.isabs(path):
  261. return path
  262. from distutils.errors import DistutilsSetupError
  263. msg = (
  264. textwrap.dedent(
  265. """
  266. Error: setup script specifies an absolute path:
  267. %s
  268. setup() arguments must *always* be /-separated paths relative to the
  269. setup.py directory, *never* absolute paths.
  270. """
  271. ).lstrip()
  272. % path
  273. )
  274. raise DistutilsSetupError(msg)
  275. class _IncludePackageDataAbuse:
  276. """Inform users that package or module is included as 'data file'"""
  277. MESSAGE = """\
  278. Installing {importable!r} as data is deprecated, please list it in `packages`.
  279. !!\n\n
  280. ############################
  281. # Package would be ignored #
  282. ############################
  283. Python recognizes {importable!r} as an importable package,
  284. but it is not listed in the `packages` configuration of setuptools.
  285. {importable!r} has been automatically added to the distribution only
  286. because it may contain data files, but this behavior is likely to change
  287. in future versions of setuptools (and therefore is considered deprecated).
  288. Please make sure that {importable!r} is included as a package by using
  289. the `packages` configuration field or the proper discovery methods
  290. (for example by using `find_namespace_packages(...)`/`find_namespace:`
  291. instead of `find_packages(...)`/`find:`).
  292. You can read more about "package discovery" and "data files" on setuptools
  293. documentation page.
  294. \n\n!!
  295. """
  296. def __init__(self):
  297. self._already_warned = set()
  298. def is_module(self, file):
  299. return file.endswith(".py") and file[:-len(".py")].isidentifier()
  300. def importable_subpackage(self, parent, file):
  301. pkg = Path(file).parent
  302. parts = list(itertools.takewhile(str.isidentifier, pkg.parts))
  303. if parts:
  304. return ".".join([parent, *parts])
  305. return None
  306. def warn(self, importable):
  307. if importable not in self._already_warned:
  308. msg = textwrap.dedent(self.MESSAGE).format(importable=importable)
  309. warnings.warn(msg, SetuptoolsDeprecationWarning, stacklevel=2)
  310. self._already_warned.add(importable)