| # | |
| # Licensed to the Apache Software Foundation (ASF) under one or more | |
| # contributor license agreements. See the NOTICE file distributed with | |
| # this work for additional information regarding copyright ownership. | |
| # The ASF licenses this file to You under the Apache License, Version 2.0 | |
| # (the "License"); you may not use this file except in compliance with | |
| # the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| # | |
| """ | |
| Generates Python proto modules and grpc stubs for Beam protos. | |
| """ | |
| import argparse | |
| import contextlib | |
| import glob | |
| import importlib.resources | |
| import inspect | |
| import logging | |
| import os | |
| import platform | |
| import re | |
| import shutil | |
| import sys | |
| from collections import defaultdict | |
| from importlib import import_module | |
| LOG = logging.getLogger() | |
| LOG.setLevel(logging.INFO) | |
| LICENSE_HEADER = """ | |
| # | |
| # Licensed to the Apache Software Foundation (ASF) under one or more | |
| # contributor license agreements. See the NOTICE file distributed with | |
| # this work for additional information regarding copyright ownership. | |
| # The ASF licenses this file to You under the Apache License, Version 2.0 | |
| # (the "License"); you may not use this file except in compliance with | |
| # the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| # | |
| """ | |
| NO_PROMISES_NOTICE = """ | |
| \"\"\" | |
| For internal use only; no backwards-compatibility guarantees. | |
| Automatically generated when running python -m build. | |
| \"\"\" | |
| """ | |
| def clean_path(path): | |
| return os.path.realpath(os.path.abspath(path)) | |
| # These paths are relative to the project root | |
| BEAM_PROTO_PATHS = [ | |
| os.path.join('model', 'pipeline', 'src', 'main', 'proto'), | |
| os.path.join('model', 'job-management', 'src', 'main', 'proto'), | |
| os.path.join('model', 'fn-execution', 'src', 'main', 'proto'), | |
| os.path.join('model', 'interactive', 'src', 'main', 'proto'), | |
| ] | |
| PYTHON_SDK_ROOT = os.path.dirname(clean_path(__file__)) | |
| PROJECT_ROOT = clean_path(os.path.join(PYTHON_SDK_ROOT, '..', '..')) | |
| PYTHON_OUTPUT_PATH = os.path.join( | |
| PYTHON_SDK_ROOT, 'apache_beam', 'portability', 'api') | |
| MODEL_RESOURCES = [ | |
| os.path.normpath(( | |
| 'model/fn-execution/src/main/resources/org/' | |
| 'apache/beam/model/fnexecution/v1/standard_coders.yaml')), | |
| ] | |
| class PythonPath(object): | |
| def __init__(self, path: str, front: bool = False): | |
| self._path = path | |
| self._front = front | |
| def __enter__(self): | |
| if not self._path: | |
| return | |
| self._sys_path = sys.path.copy() | |
| if self._front: | |
| sys.path.insert(0, self._path) | |
| else: | |
| sys.path.append(self._path) | |
| def __exit__(self, exc_type, exc_val, exc_tb): | |
| if not self._path: | |
| return | |
| sys.path = self._sys_path | |
| def generate_urn_files(out_dir, api_path): | |
| """ | |
| Create python files with statically defined URN constants. | |
| Creates a <proto>_pb2_urn.py file for each <proto>_pb2.py file that contains | |
| an enum type. | |
| This works by importing each api.<proto>_pb2 module created by `protoc`, | |
| inspecting the module's contents, and generating a new side-car urn module. | |
| This is executed at build time rather than dynamically on import to ensure | |
| that it is compatible with static type checkers like mypy. | |
| """ | |
| from google.protobuf import message | |
| from google.protobuf.internal import api_implementation | |
| if api_implementation.Type() == 'python': | |
| from google.protobuf.internal import containers | |
| repeated_types = ( | |
| list, | |
| containers.RepeatedScalarFieldContainer, | |
| containers.RepeatedCompositeFieldContainer) | |
| elif api_implementation.Type() == 'upb': | |
| from google._upb import _message | |
| repeated_types = ( | |
| list, | |
| _message.RepeatedScalarContainer, | |
| _message.RepeatedCompositeContainer) | |
| elif api_implementation.Type() == 'cpp': | |
| from google.protobuf.pyext import _message | |
| repeated_types = ( | |
| list, | |
| _message.RepeatedScalarContainer, | |
| _message.RepeatedCompositeContainer) | |
| else: | |
| raise TypeError( | |
| "Unknown proto implementation: " + api_implementation.Type()) | |
| class Context(object): | |
| INDENT = ' ' | |
| CAP_SPLIT = re.compile('([A-Z][^A-Z]*|^[a-z]+)') | |
| def __init__(self, indent=0): | |
| self.lines = [] | |
| self.imports = set() | |
| self.empty_types = set() | |
| self._indent = indent | |
| @contextlib.contextmanager | |
| def indent(self): | |
| self._indent += 1 | |
| yield | |
| self._indent -= 1 | |
| def prepend(self, s): | |
| if s: | |
| self.lines.insert(0, (self.INDENT * self._indent) + s + '\n') | |
| else: | |
| self.lines.insert(0, '\n') | |
| def line(self, s): | |
| if s: | |
| self.lines.append((self.INDENT * self._indent) + s + '\n') | |
| else: | |
| self.lines.append('\n') | |
| def import_type(self, typ): | |
| modname = typ.__module__ | |
| if modname in ('__builtin__', 'builtin'): | |
| return typ.__name__ | |
| else: | |
| self.imports.add(modname) | |
| _, modname = modname.rsplit('.', 1) | |
| return modname + '.' + typ.__name__ | |
| @staticmethod | |
| def is_message_type(obj): | |
| return isinstance(obj, type) and \ | |
| issubclass(obj, message.Message) | |
| @staticmethod | |
| def is_enum_type(obj): | |
| return type(obj).__name__ == 'EnumTypeWrapper' | |
| def python_repr(self, obj): | |
| if isinstance(obj, message.Message): | |
| return self.message_repr(obj) | |
| elif isinstance(obj, repeated_types): | |
| return '[%s]' % ', '.join(self.python_repr(x) for x in obj) | |
| else: | |
| return repr(obj) | |
| def empty_type(self, typ): | |
| name = ( | |
| 'EMPTY_' + | |
| '_'.join(x.upper() for x in self.CAP_SPLIT.findall(typ.__name__))) | |
| self.empty_types.add('%s = %s()' % (name, self.import_type(typ))) | |
| return name | |
| def message_repr(self, msg): | |
| parts = [] | |
| for field, value in msg.ListFields(): | |
| parts.append('%s=%s' % (field.name, self.python_repr(value))) | |
| if parts: | |
| return '%s(%s)' % (self.import_type(type(msg)), ', '.join(parts)) | |
| else: | |
| return self.empty_type(type(msg)) | |
| def write_enum(self, enum_name, enum, indent): | |
| ctx = Context(indent=indent) | |
| with ctx.indent(): | |
| for enum_value_name in enum.values_by_name: | |
| enum_value_descriptor = enum.values_by_name[enum_value_name] | |
| extensions = enum_value_descriptor.GetOptions().Extensions | |
| prop = ( | |
| extensions[beam_runner_api_pb2.beam_urn], | |
| extensions[beam_runner_api_pb2.beam_constant], | |
| extensions[metrics_pb2.monitoring_info_spec], | |
| extensions[metrics_pb2.label_props], | |
| ) | |
| reprs = [self.python_repr(x) for x in prop] | |
| if all(x == "''" or x.startswith('EMPTY_') for x in reprs): | |
| continue | |
| ctx.line( | |
| '%s = PropertiesFromEnumValue(%s)' % | |
| (enum_value_name, ', '.join(self.python_repr(x) for x in prop))) | |
| if ctx.lines: | |
| ctx.prepend('class %s(object):' % enum_name) | |
| ctx.prepend('') | |
| ctx.line('') | |
| return ctx.lines | |
| def write_message(self, message_name, message, indent=0): | |
| ctx = Context(indent=indent) | |
| with ctx.indent(): | |
| for obj_name, obj in inspect.getmembers(message): | |
| if obj_name == 'DESCRIPTOR': | |
| for enum_name in obj.enum_types_by_name: | |
| enum = obj.enum_types_by_name[enum_name] | |
| ctx.lines += self.write_enum(enum_name, enum, ctx._indent) | |
| if ctx.lines: | |
| ctx.prepend('class %s(object):' % message_name) | |
| ctx.prepend('') | |
| return ctx.lines | |
| pb2_files = list(glob.glob(os.path.join(out_dir, '*_pb2.py'))) | |
| with PythonPath(os.path.dirname(api_path), front=True): | |
| beam_runner_api_pb2 = import_module( | |
| 'api.org.apache.beam.model.pipeline.v1.beam_runner_api_pb2') | |
| metrics_pb2 = import_module( | |
| 'api.org.apache.beam.model.pipeline.v1.metrics_pb2') | |
| for pb2_file in pb2_files: | |
| modname = os.path.splitext(pb2_file)[0] | |
| out_file = modname + '_urns.py' | |
| api_start_idx = modname.index(os.path.sep + 'api' + os.path.sep) | |
| import_path = modname[api_start_idx + 1:].replace(os.path.sep, '.') | |
| mod = import_module(import_path) | |
| ctx = Context() | |
| for obj_name, obj in inspect.getmembers(mod): | |
| if ctx.is_message_type(obj): | |
| ctx.lines += ctx.write_message(obj_name, obj) | |
| if ctx.lines: | |
| for line in reversed(sorted(ctx.empty_types)): | |
| ctx.prepend(line) | |
| for modname in reversed(sorted(ctx.imports)): | |
| pkg, target = modname.rsplit('.', 1) | |
| rel_import = build_relative_import(api_path, pkg, out_file) | |
| ctx.prepend('from %s import %s' % (rel_import, target)) | |
| rel_import = build_relative_import( | |
| os.path.dirname(api_path), 'utils', out_file) | |
| ctx.prepend('from %s import PropertiesFromEnumValue' % rel_import) | |
| LOG.info("Writing urn stubs: %s" % out_file) | |
| with open(out_file, 'w') as f: | |
| f.writelines(ctx.lines) | |
| def _find_protoc_gen_mypy(): | |
| # NOTE: this shouldn't be necessary if the virtualenv's environment | |
| # is passed to tasks below it, since protoc will search the PATH itself | |
| fname = 'protoc-gen-mypy' | |
| if platform.system() == 'Windows': | |
| fname += ".exe" | |
| pathstr = os.environ.get('PATH') | |
| search_paths = pathstr.split(os.pathsep) if pathstr else [] | |
| # should typically be installed into the venv's bin dir | |
| search_paths.insert(0, os.path.dirname(sys.executable)) | |
| for path in search_paths: | |
| fullpath = os.path.join(path, fname) | |
| if os.path.exists(fullpath): | |
| LOG.info('Found protoc_gen_mypy at %s' % fullpath) | |
| return fullpath | |
| raise RuntimeError( | |
| "Could not find %s in %s" % (fname, ', '.join(search_paths))) | |
| def find_by_ext(root_dir, ext): | |
| for root, _, files in os.walk(root_dir): | |
| for file in files: | |
| if file.endswith(ext): | |
| yield clean_path(os.path.join(root, file)) | |
| def build_relative_import(root_path, import_path, start_file_path): | |
| tail_path = import_path.replace('.', os.path.sep) | |
| source_path = os.path.join(root_path, tail_path) | |
| is_module = os.path.isfile(source_path + '.py') | |
| if is_module: | |
| source_path = os.path.dirname(source_path) | |
| rel_path = os.path.relpath( | |
| source_path, start=os.path.dirname(start_file_path)) | |
| if rel_path == '.': | |
| if is_module: | |
| rel_path += os.path.basename(tail_path) | |
| return rel_path | |
| if rel_path.endswith('..'): | |
| rel_path += os.path.sep | |
| # In a path that looks like ../../../foo, every double dot | |
| # after the right most double dot needs to be collapsed to | |
| # a single dot to look like ././../foo to which we can convert | |
| # to ....foo for the proper relative import. | |
| first_half_idx = rel_path.rfind('..' + os.path.sep) | |
| if first_half_idx == 0: | |
| return rel_path.replace(os.path.sep, '') | |
| first_half = rel_path[:first_half_idx].replace('..', '.') | |
| final_import = first_half.replace(os.path.sep, '') + '..' + \ | |
| rel_path[first_half_idx+3:].replace(os.path.sep, '.') | |
| if is_module: | |
| if final_import.count('.') == len(final_import): | |
| return final_import + os.path.basename(tail_path) | |
| return final_import + '.{}'.format(os.path.basename(tail_path)) | |
| return final_import | |
| def generate_init_files_lite(api_root): | |
| proto_root = os.path.join(api_root, 'org') | |
| for root, _, _ in os.walk(proto_root): | |
| init_file = os.path.join(root, '__init__.py') | |
| with open(init_file, 'w+'): | |
| pass | |
| def generate_init_files_full(api_root): | |
| proto_root = os.path.join(api_root, 'org') | |
| api_module_root = os.path.join(api_root, '__init__.py') | |
| modules = defaultdict(list) | |
| for root, _, files in os.walk(proto_root): | |
| init_file = os.path.join(root, '__init__.py') | |
| with open(init_file, 'w+') as f: | |
| f.write(LICENSE_HEADER.lstrip()) | |
| for file in files: | |
| if not file.endswith('.py') or file == '__init__.py': | |
| continue | |
| module_name = file.split('.')[0] | |
| f.write('from . import {}\n'.format(module_name)) | |
| modules[root].append(module_name) | |
| with open(api_module_root, 'w+') as f: | |
| f.write(LICENSE_HEADER.lstrip()) | |
| f.write(NO_PROMISES_NOTICE.lstrip()) | |
| remaining_lines = [] | |
| duplicate_modules = {} | |
| for module_root, modules in modules.items(): | |
| import_path = os.path.relpath(module_root, | |
| api_root).replace(os.path.sep, '.') | |
| import_root, imported_module = import_path.rsplit('.', 1) | |
| if imported_module not in duplicate_modules: | |
| f.write('from .{} import {}\n'.format(import_root, imported_module)) | |
| duplicate_modules[imported_module] = 1 | |
| else: | |
| duplicate_modules[imported_module] += 1 | |
| module_alias = '{}_{}'.format( | |
| imported_module, duplicate_modules[imported_module]) | |
| f.write( | |
| 'from .{} import {} as {}\n'.format( | |
| import_root, imported_module, module_alias)) | |
| imported_module = module_alias | |
| for module in modules: | |
| remaining_lines.append( | |
| '{module} = {}.{module}\n'.format(imported_module, module=module)) | |
| f.write('\n') | |
| f.writelines(remaining_lines) | |
| def generate_proto_files(force=False): | |
| """ | |
| Will compile proto files for python. If force is not true, then several | |
| heuristics are used to determine whether a compilation is necessary. If | |
| a compilation is not necessary, no compilation will be performed. | |
| :param force: Whether to force a recompilation of the proto files. | |
| """ | |
| proto_dirs = [ | |
| clean_path(os.path.join(PROJECT_ROOT, path)) for path in BEAM_PROTO_PATHS | |
| ] | |
| proto_files = [ | |
| proto_file for d in proto_dirs for proto_file in find_by_ext(d, '.proto') | |
| ] | |
| out_files = list(find_by_ext(PYTHON_OUTPUT_PATH, '_pb2.py')) | |
| if out_files and not proto_files and not force: | |
| # We have out_files but no protos; assume they're up-to-date. | |
| # This is actually the common case (e.g. installation from an sdist). | |
| LOG.info('No proto files; using existing generated files.') | |
| return | |
| elif not out_files and not proto_files: | |
| model = os.path.join(PROJECT_ROOT, 'model') | |
| if os.path.exists(model): | |
| error_msg = 'No proto files found in %s.' % proto_dirs | |
| else: | |
| error_msg = 'Not in apache git tree, unable to find proto definitions.' | |
| raise RuntimeError(error_msg) | |
| if force: | |
| regenerate_reason = 'forced' | |
| elif not out_files: | |
| regenerate_reason = 'no output files' | |
| elif len(out_files) < len(proto_files): | |
| regenerate_reason = 'not enough output files' | |
| elif (min(os.path.getmtime(path) for path in out_files) <= max( | |
| os.path.getmtime(path) | |
| for path in proto_files + [os.path.realpath(__file__)])): | |
| regenerate_reason = 'output files are out-of-date' | |
| elif len(out_files) > len(proto_files): | |
| regenerate_reason = 'output files without corresponding .proto files' | |
| # too many output files: probably due to switching between git branches. | |
| # remove them so they don't trigger constant regeneration. | |
| for out_file in out_files: | |
| os.remove(out_file) | |
| else: | |
| regenerate_reason = '' | |
| if not regenerate_reason: | |
| LOG.info('Skipping proto regeneration: all files up to date') | |
| return | |
| shutil.rmtree(PYTHON_OUTPUT_PATH, ignore_errors=True) | |
| if not os.path.exists(PYTHON_OUTPUT_PATH): | |
| os.mkdir(PYTHON_OUTPUT_PATH) | |
| protoc_gen_mypy = _find_protoc_gen_mypy() | |
| from grpc_tools import protoc | |
| builtin_protos = importlib.resources.files('grpc_tools') / '_proto' | |
| args = ( | |
| [sys.executable] + # expecting to be called from command line | |
| ['--proto_path=%s' % builtin_protos] + | |
| ['--proto_path=%s' % d | |
| for d in proto_dirs] + ['--python_out=%s' % PYTHON_OUTPUT_PATH] + | |
| ['--plugin=protoc-gen-mypy=%s' % protoc_gen_mypy] + | |
| # new version of mypy-protobuf converts None to zero default value | |
| # and remove Optional from the param type annotation. This causes | |
| # some mypy errors. So to mitigate and fall back to old behavior, | |
| # use `relax_strict_optional_primitives` flag. more at | |
| # https://github.com/nipunn1313/mypy-protobuf/tree/main#relax_strict_optional_primitives # pylint:disable=line-too-long | |
| ['--mypy_out=relax_strict_optional_primitives:%s' % PYTHON_OUTPUT_PATH | |
| ] + | |
| # TODO(robertwb): Remove the prefix once it's the default. | |
| ['--grpc_python_out=grpc_2_0:%s' % PYTHON_OUTPUT_PATH] + proto_files) | |
| LOG.info('Regenerating Python proto definitions (%s).' % regenerate_reason) | |
| ret_code = protoc.main(args) | |
| if ret_code: | |
| raise RuntimeError( | |
| 'Protoc returned non-zero status (see logs for details): ' | |
| '%s' % ret_code) | |
| # copy resource files | |
| for path in MODEL_RESOURCES: | |
| shutil.copy2(os.path.join(PROJECT_ROOT, path), PYTHON_OUTPUT_PATH) | |
| proto_packages = set() | |
| # see: https://github.com/protocolbuffers/protobuf/issues/1491 | |
| # force relative import paths for proto files | |
| compiled_import_re = re.compile('^from (.*) import (.*)$') | |
| for file_path in find_by_ext(PYTHON_OUTPUT_PATH, | |
| ('_pb2.py', '_pb2_grpc.py', '_pb2.pyi')): | |
| proto_packages.add(os.path.dirname(file_path)) | |
| lines = [] | |
| with open(file_path, encoding='utf-8') as f: | |
| for line in f: | |
| match_obj = compiled_import_re.match(line) | |
| if match_obj and \ | |
| match_obj.group(1).startswith('org.apache.beam.model'): | |
| new_import = build_relative_import( | |
| PYTHON_OUTPUT_PATH, match_obj.group(1), file_path) | |
| line = 'from %s import %s\n' % (new_import, match_obj.group(2)) | |
| lines.append(line) | |
| with open(file_path, 'w') as f: | |
| f.writelines(lines) | |
| generate_init_files_lite(PYTHON_OUTPUT_PATH) | |
| for proto_package in proto_packages: | |
| generate_urn_files(proto_package, PYTHON_OUTPUT_PATH) | |
| generate_init_files_full(PYTHON_OUTPUT_PATH) | |
| if __name__ == '__main__': | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument('--no-force', dest='force', action='store_false') | |
| args = parser.parse_args() | |
| generate_proto_files(force=args.force) |