commands.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  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 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 = os.environ.get('CC', 'cc')
  194. cc_test = subprocess.Popen([cc, '-x', 'c', '-std=c++14', '-'],
  195. stdin=subprocess.PIPE,
  196. stdout=subprocess.PIPE,
  197. stderr=subprocess.PIPE)
  198. _, cc_err = cc_test.communicate(input=b'int main(){return 0;}')
  199. return not 'invalid argument' in str(cc_err)
  200. except:
  201. sys.stderr.write('Non-fatal exception:' +
  202. traceback.format_exc() + '\n')
  203. return False
  204. # This special conditioning is here due to difference of compiler
  205. # behavior in gcc and clang. The clang doesn't take --stdc++11
  206. # flags but gcc does. Since the setuptools of Python only support
  207. # all C or all C++ compilation, the mix of C and C++ will crash.
  208. # *By default*, macOS and FreBSD use clang and Linux use gcc
  209. #
  210. # If we are not using a permissive compiler that's OK with being
  211. # passed wrong std flags, swap out compile function by adding a filter
  212. # for it.
  213. if not compiler_ok_with_extra_std():
  214. old_compile = self.compiler._compile
  215. def new_compile(obj, src, ext, cc_args, extra_postargs, pp_opts):
  216. if src.endswith('.c'):
  217. extra_postargs = [
  218. arg for arg in extra_postargs if not '-std=c++' in arg
  219. ]
  220. elif src.endswith('.cc') or src.endswith('.cpp'):
  221. extra_postargs = [
  222. arg for arg in extra_postargs if not '-std=gnu99' in arg
  223. ]
  224. return old_compile(obj, src, ext, cc_args, extra_postargs,
  225. pp_opts)
  226. self.compiler._compile = new_compile
  227. compiler = self.compiler.compiler_type
  228. if compiler in BuildExt.C_OPTIONS:
  229. for extension in self.extensions:
  230. extension.extra_compile_args += list(
  231. BuildExt.C_OPTIONS[compiler])
  232. if compiler in BuildExt.LINK_OPTIONS:
  233. for extension in self.extensions:
  234. extension.extra_link_args += list(
  235. BuildExt.LINK_OPTIONS[compiler])
  236. if not check_and_update_cythonization(self.extensions):
  237. self.extensions = try_cythonize(self.extensions)
  238. try:
  239. build_ext.build_ext.build_extensions(self)
  240. except Exception as error:
  241. formatted_exception = traceback.format_exc()
  242. support.diagnose_build_ext_error(self, error, formatted_exception)
  243. raise CommandError(
  244. "Failed `build_ext` step:\n{}".format(formatted_exception))
  245. class Gather(setuptools.Command):
  246. """Command to gather project dependencies."""
  247. description = 'gather dependencies for grpcio'
  248. user_options = [
  249. ('test', 't', 'flag indicating to gather test dependencies'),
  250. ('install', 'i', 'flag indicating to gather install dependencies')
  251. ]
  252. def initialize_options(self):
  253. self.test = False
  254. self.install = False
  255. def finalize_options(self):
  256. # distutils requires this override.
  257. pass
  258. def run(self):
  259. if self.install and self.distribution.install_requires:
  260. self.distribution.fetch_build_eggs(
  261. self.distribution.install_requires)
  262. if self.test and self.distribution.tests_require:
  263. self.distribution.fetch_build_eggs(self.distribution.tests_require)
  264. class Clean(setuptools.Command):
  265. """Command to clean build artifacts."""
  266. description = 'Clean build artifacts.'
  267. user_options = [
  268. ('all', 'a', 'a phony flag to allow our script to continue'),
  269. ]
  270. _FILE_PATTERNS = (
  271. 'python_build',
  272. 'src/python/grpcio/__pycache__/',
  273. 'src/python/grpcio/grpc/_cython/cygrpc.cpp',
  274. 'src/python/grpcio/grpc/_cython/*.so',
  275. 'src/python/grpcio/grpcio.egg-info/',
  276. )
  277. _CURRENT_DIRECTORY = os.path.normpath(
  278. os.path.join(os.path.dirname(os.path.realpath(__file__)), "../../.."))
  279. def initialize_options(self):
  280. self.all = False
  281. def finalize_options(self):
  282. pass
  283. def run(self):
  284. for path_spec in self._FILE_PATTERNS:
  285. this_glob = os.path.normpath(
  286. os.path.join(Clean._CURRENT_DIRECTORY, path_spec))
  287. abs_paths = glob.glob(this_glob)
  288. for path in abs_paths:
  289. if not str(path).startswith(Clean._CURRENT_DIRECTORY):
  290. raise ValueError(
  291. "Cowardly refusing to delete {}.".format(path))
  292. print("Removing {}".format(os.path.relpath(path)))
  293. if os.path.isfile(path):
  294. os.remove(str(path))
  295. else:
  296. shutil.rmtree(str(path))