commands.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. # Copyright 2015 gRPC authors.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. """Provides distutils command classes for the GRPC Python setup process."""
  15. # NOTE(https://github.com/grpc/grpc/issues/24028): allow setuptools to monkey
  16. # patch distutils
  17. import setuptools # isort:skip
  18. import glob
  19. import os
  20. import os.path
  21. import shutil
  22. import subprocess
  23. import sys
  24. import sysconfig
  25. import traceback
  26. from setuptools.command import build_ext
  27. from setuptools.command import build_py
  28. import support
  29. PYTHON_STEM = os.path.dirname(os.path.abspath(__file__))
  30. GRPC_STEM = os.path.abspath(PYTHON_STEM + '../../../../')
  31. PROTO_STEM = os.path.join(GRPC_STEM, 'src', 'proto')
  32. PROTO_GEN_STEM = os.path.join(GRPC_STEM, 'src', 'python', 'gens')
  33. CYTHON_STEM = os.path.join(PYTHON_STEM, 'grpc', '_cython')
  34. class CommandError(Exception):
  35. """Simple exception class for GRPC custom commands."""
  36. # TODO(atash): Remove this once PyPI has better Linux bdist support. See
  37. # https://bitbucket.org/pypa/pypi/issues/120/binary-wheels-for-linux-are-not-supported
  38. def _get_grpc_custom_bdist(decorated_basename, target_bdist_basename):
  39. """Returns a string path to a bdist file for Linux to install.
  40. If we can retrieve a pre-compiled bdist from online, uses it. Else, emits a
  41. warning and builds from source.
  42. """
  43. # TODO(atash): somehow the name that's returned from `wheel` is different
  44. # between different versions of 'wheel' (but from a compatibility standpoint,
  45. # the names are compatible); we should have some way of determining name
  46. # compatibility in the same way `wheel` does to avoid having to rename all of
  47. # the custom wheels that we build/upload to GCS.
  48. # Break import style to ensure that setup.py has had a chance to install the
  49. # relevant package.
  50. from six.moves.urllib import request
  51. decorated_path = decorated_basename + GRPC_CUSTOM_BDIST_EXT
  52. try:
  53. url = BINARIES_REPOSITORY + '/{target}'.format(target=decorated_path)
  54. bdist_data = request.urlopen(url).read()
  55. except IOError as error:
  56. raise CommandError('{}\n\nCould not find the bdist {}: {}'.format(
  57. traceback.format_exc(), decorated_path, error.message))
  58. # Our chosen local bdist path.
  59. bdist_path = target_bdist_basename + GRPC_CUSTOM_BDIST_EXT
  60. try:
  61. with open(bdist_path, 'w') as bdist_file:
  62. bdist_file.write(bdist_data)
  63. except IOError as error:
  64. raise CommandError('{}\n\nCould not write grpcio bdist: {}'.format(
  65. traceback.format_exc(), error.message))
  66. return bdist_path
  67. class SphinxDocumentation(setuptools.Command):
  68. """Command to generate documentation via sphinx."""
  69. description = 'generate sphinx documentation'
  70. user_options = []
  71. def initialize_options(self):
  72. pass
  73. def finalize_options(self):
  74. pass
  75. def run(self):
  76. # We import here to ensure that setup.py has had a chance to install the
  77. # relevant package eggs first.
  78. import sphinx.cmd.build
  79. source_dir = os.path.join(GRPC_STEM, 'doc', 'python', 'sphinx')
  80. target_dir = os.path.join(GRPC_STEM, 'doc', 'build')
  81. exit_code = sphinx.cmd.build.build_main(
  82. ['-b', 'html', '-W', '--keep-going', source_dir, target_dir])
  83. if exit_code != 0:
  84. raise CommandError(
  85. "Documentation generation has warnings or errors")
  86. class BuildProjectMetadata(setuptools.Command):
  87. """Command to generate project metadata in a module."""
  88. description = 'build grpcio project metadata files'
  89. user_options = []
  90. def initialize_options(self):
  91. pass
  92. def finalize_options(self):
  93. pass
  94. def run(self):
  95. with open(os.path.join(PYTHON_STEM, 'grpc/_grpcio_metadata.py'),
  96. 'w') as module_file:
  97. module_file.write('__version__ = """{}"""'.format(
  98. self.distribution.get_version()))
  99. class BuildPy(build_py.build_py):
  100. """Custom project build command."""
  101. def run(self):
  102. self.run_command('build_project_metadata')
  103. build_py.build_py.run(self)
  104. def _poison_extensions(extensions, message):
  105. """Includes a file that will always fail to compile in all extensions."""
  106. poison_filename = os.path.join(PYTHON_STEM, 'poison.c')
  107. with open(poison_filename, 'w') as poison:
  108. poison.write('#error {}'.format(message))
  109. for extension in extensions:
  110. extension.sources = [poison_filename]
  111. def check_and_update_cythonization(extensions):
  112. """Replace .pyx files with their generated counterparts and return whether or
  113. not cythonization still needs to occur."""
  114. for extension in extensions:
  115. generated_pyx_sources = []
  116. other_sources = []
  117. for source in extension.sources:
  118. base, file_ext = os.path.splitext(source)
  119. if file_ext == '.pyx':
  120. generated_pyx_source = next((base + gen_ext for gen_ext in (
  121. '.c',
  122. '.cpp',
  123. ) if os.path.isfile(base + gen_ext)), None)
  124. if generated_pyx_source:
  125. generated_pyx_sources.append(generated_pyx_source)
  126. else:
  127. sys.stderr.write('Cython-generated files are missing...\n')
  128. return False
  129. else:
  130. other_sources.append(source)
  131. extension.sources = generated_pyx_sources + other_sources
  132. sys.stderr.write('Found cython-generated files...\n')
  133. return True
  134. def try_cythonize(extensions, linetracing=False, mandatory=True):
  135. """Attempt to cythonize the extensions.
  136. Args:
  137. extensions: A list of `distutils.extension.Extension`.
  138. linetracing: A bool indicating whether or not to enable linetracing.
  139. mandatory: Whether or not having Cython-generated files is mandatory. If it
  140. is, extensions will be poisoned when they can't be fully generated.
  141. """
  142. try:
  143. # Break import style to ensure we have access to Cython post-setup_requires
  144. import Cython.Build
  145. except ImportError:
  146. if mandatory:
  147. sys.stderr.write(
  148. "This package needs to generate C files with Cython but it cannot. "
  149. "Poisoning extension sources to disallow extension commands...")
  150. _poison_extensions(
  151. extensions,
  152. "Extensions have been poisoned due to missing Cython-generated code."
  153. )
  154. return extensions
  155. cython_compiler_directives = {}
  156. if linetracing:
  157. additional_define_macros = [('CYTHON_TRACE_NOGIL', '1')]
  158. cython_compiler_directives['linetrace'] = True
  159. return Cython.Build.cythonize(
  160. extensions,
  161. include_path=[
  162. include_dir for extension in extensions
  163. for include_dir in extension.include_dirs
  164. ] + [CYTHON_STEM],
  165. compiler_directives=cython_compiler_directives)
  166. class BuildExt(build_ext.build_ext):
  167. """Custom build_ext command to enable compiler-specific flags."""
  168. C_OPTIONS = {
  169. 'unix': ('-pthread',),
  170. 'msvc': (),
  171. }
  172. LINK_OPTIONS = {}
  173. def get_ext_filename(self, ext_name):
  174. # since python3.5, python extensions' shared libraries use a suffix that corresponds to the value
  175. # of sysconfig.get_config_var('EXT_SUFFIX') and contains info about the architecture the library targets.
  176. # E.g. on x64 linux the suffix is ".cpython-XYZ-x86_64-linux-gnu.so"
  177. # When crosscompiling python wheels, we need to be able to override this suffix
  178. # so that the resulting file name matches the target architecture and we end up with a well-formed
  179. # wheel.
  180. filename = build_ext.build_ext.get_ext_filename(self, ext_name)
  181. orig_ext_suffix = sysconfig.get_config_var('EXT_SUFFIX')
  182. new_ext_suffix = os.getenv('GRPC_PYTHON_OVERRIDE_EXT_SUFFIX')
  183. if new_ext_suffix and filename.endswith(orig_ext_suffix):
  184. filename = filename[:-len(orig_ext_suffix)] + new_ext_suffix
  185. return filename
  186. def build_extensions(self):
  187. def compiler_ok_with_extra_std():
  188. """Test if default compiler is okay with specifying c++ version
  189. when invoked in C mode. GCC is okay with this, while clang is not.
  190. """
  191. try:
  192. # TODO(lidiz) Remove the generated a.out for success tests.
  193. cc_test = subprocess.Popen(['cc', '-x', 'c', '-std=c++14', '-'],
  194. stdin=subprocess.PIPE,
  195. stdout=subprocess.PIPE,
  196. stderr=subprocess.PIPE)
  197. _, cc_err = cc_test.communicate(input=b'int main(){return 0;}')
  198. return not 'invalid argument' in str(cc_err)
  199. except:
  200. sys.stderr.write('Non-fatal exception:' +
  201. traceback.format_exc() + '\n')
  202. return False
  203. # This special conditioning is here due to difference of compiler
  204. # behavior in gcc and clang. The clang doesn't take --stdc++11
  205. # flags but gcc does. Since the setuptools of Python only support
  206. # all C or all C++ compilation, the mix of C and C++ will crash.
  207. # *By default*, macOS and FreBSD use clang and Linux use gcc
  208. #
  209. # If we are not using a permissive compiler that's OK with being
  210. # passed wrong std flags, swap out compile function by adding a filter
  211. # for it.
  212. if not compiler_ok_with_extra_std():
  213. old_compile = self.compiler._compile
  214. def new_compile(obj, src, ext, cc_args, extra_postargs, pp_opts):
  215. if src.endswith('.c'):
  216. extra_postargs = [
  217. arg for arg in extra_postargs if not '-std=c++' in arg
  218. ]
  219. elif src.endswith('.cc') or src.endswith('.cpp'):
  220. extra_postargs = [
  221. arg for arg in extra_postargs if not '-std=gnu99' in arg
  222. ]
  223. return old_compile(obj, src, ext, cc_args, extra_postargs,
  224. pp_opts)
  225. self.compiler._compile = new_compile
  226. compiler = self.compiler.compiler_type
  227. if compiler in BuildExt.C_OPTIONS:
  228. for extension in self.extensions:
  229. extension.extra_compile_args += list(
  230. BuildExt.C_OPTIONS[compiler])
  231. if compiler in BuildExt.LINK_OPTIONS:
  232. for extension in self.extensions:
  233. extension.extra_link_args += list(
  234. BuildExt.LINK_OPTIONS[compiler])
  235. if not check_and_update_cythonization(self.extensions):
  236. self.extensions = try_cythonize(self.extensions)
  237. try:
  238. build_ext.build_ext.build_extensions(self)
  239. except Exception as error:
  240. formatted_exception = traceback.format_exc()
  241. support.diagnose_build_ext_error(self, error, formatted_exception)
  242. raise CommandError(
  243. "Failed `build_ext` step:\n{}".format(formatted_exception))
  244. class Gather(setuptools.Command):
  245. """Command to gather project dependencies."""
  246. description = 'gather dependencies for grpcio'
  247. user_options = [
  248. ('test', 't', 'flag indicating to gather test dependencies'),
  249. ('install', 'i', 'flag indicating to gather install dependencies')
  250. ]
  251. def initialize_options(self):
  252. self.test = False
  253. self.install = False
  254. def finalize_options(self):
  255. # distutils requires this override.
  256. pass
  257. def run(self):
  258. if self.install and self.distribution.install_requires:
  259. self.distribution.fetch_build_eggs(
  260. self.distribution.install_requires)
  261. if self.test and self.distribution.tests_require:
  262. self.distribution.fetch_build_eggs(self.distribution.tests_require)
  263. class Clean(setuptools.Command):
  264. """Command to clean build artifacts."""
  265. description = 'Clean build artifacts.'
  266. user_options = [
  267. ('all', 'a', 'a phony flag to allow our script to continue'),
  268. ]
  269. _FILE_PATTERNS = (
  270. 'python_build',
  271. 'src/python/grpcio/__pycache__/',
  272. 'src/python/grpcio/grpc/_cython/cygrpc.cpp',
  273. 'src/python/grpcio/grpc/_cython/*.so',
  274. 'src/python/grpcio/grpcio.egg-info/',
  275. )
  276. _CURRENT_DIRECTORY = os.path.normpath(
  277. os.path.join(os.path.dirname(os.path.realpath(__file__)), "../../.."))
  278. def initialize_options(self):
  279. self.all = False
  280. def finalize_options(self):
  281. pass
  282. def run(self):
  283. for path_spec in self._FILE_PATTERNS:
  284. this_glob = os.path.normpath(
  285. os.path.join(Clean._CURRENT_DIRECTORY, path_spec))
  286. abs_paths = glob.glob(this_glob)
  287. for path in abs_paths:
  288. if not str(path).startswith(Clean._CURRENT_DIRECTORY):
  289. raise ValueError(
  290. "Cowardly refusing to delete {}.".format(path))
  291. print("Removing {}".format(os.path.relpath(path)))
  292. if os.path.isfile(path):
  293. os.remove(str(path))
  294. else:
  295. shutil.rmtree(str(path))