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