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