xref: /aosp_15_r20/external/grpc-grpc/src/python/grpcio/commands.py (revision cc02d7e222339f7a4f6ba5f422e6413f4bd931f2)
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