• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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        # This is to let UnixCompiler get either C or C++ compiler options depending on the source.
252        # Note that this doesn't work for MSVCCompiler and will be handled by _spawn_patch.py.
253        old_compile = self.compiler._compile
254
255        def new_compile(obj, src, ext, cc_args, extra_postargs, pp_opts):
256            if src.endswith(".c"):
257                extra_postargs = [
258                    arg for arg in extra_postargs if arg != "-std=c++17"
259                ]
260            elif src.endswith((".cc", ".cpp")):
261                extra_postargs = [
262                    arg for arg in extra_postargs if arg != "-std=c11"
263                ]
264            return old_compile(obj, src, ext, cc_args, extra_postargs, pp_opts)
265
266        self.compiler._compile = new_compile
267
268        compiler = self.compiler.compiler_type
269        if compiler in BuildExt.C_OPTIONS:
270            for extension in self.extensions:
271                extension.extra_compile_args += list(
272                    BuildExt.C_OPTIONS[compiler]
273                )
274        if compiler in BuildExt.LINK_OPTIONS:
275            for extension in self.extensions:
276                extension.extra_link_args += list(
277                    BuildExt.LINK_OPTIONS[compiler]
278                )
279        if not check_and_update_cythonization(self.extensions):
280            self.extensions = try_cythonize(self.extensions)
281        try:
282            build_ext.build_ext.build_extensions(self)
283        except Exception as error:
284            formatted_exception = traceback.format_exc()
285            support.diagnose_build_ext_error(self, error, formatted_exception)
286            raise CommandError(
287                "Failed `build_ext` step:\n{}".format(formatted_exception)
288            )
289
290
291class Gather(setuptools.Command):
292    """Command to gather project dependencies."""
293
294    description = "gather dependencies for grpcio"
295    user_options = [
296        ("test", "t", "flag indicating to gather test dependencies"),
297        ("install", "i", "flag indicating to gather install dependencies"),
298    ]
299
300    def initialize_options(self):
301        self.test = False
302        self.install = False
303
304    def finalize_options(self):
305        # distutils requires this override.
306        pass
307
308    def run(self):
309        pass
310
311
312class Clean(setuptools.Command):
313    """Command to clean build artifacts."""
314
315    description = "Clean build artifacts."
316    user_options = [
317        ("all", "a", "a phony flag to allow our script to continue"),
318    ]
319
320    _FILE_PATTERNS = (
321        "pyb",
322        "src/python/grpcio/__pycache__/",
323        "src/python/grpcio/grpc/_cython/cygrpc.cpp",
324        "src/python/grpcio/grpc/_cython/*.so",
325        "src/python/grpcio/grpcio.egg-info/",
326    )
327    _CURRENT_DIRECTORY = os.path.normpath(
328        os.path.join(os.path.dirname(os.path.realpath(__file__)), "../../..")
329    )
330
331    def initialize_options(self):
332        self.all = False
333
334    def finalize_options(self):
335        pass
336
337    def run(self):
338        for path_spec in self._FILE_PATTERNS:
339            this_glob = os.path.normpath(
340                os.path.join(Clean._CURRENT_DIRECTORY, path_spec)
341            )
342            abs_paths = glob.glob(this_glob)
343            for path in abs_paths:
344                if not str(path).startswith(Clean._CURRENT_DIRECTORY):
345                    raise ValueError(
346                        "Cowardly refusing to delete {}.".format(path)
347                    )
348                print("Removing {}".format(os.path.relpath(path)))
349                if os.path.isfile(path):
350                    os.remove(str(path))
351                else:
352                    shutil.rmtree(str(path))
353