| #!/usr/bin/env vpython3 |
| |
| # Copyright 2025 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Build is a helper script for performing all tasks in this repo. |
| |
| Run it without arguments to find available tasks and their documentation. |
| |
| Typically this is run as `build.py all` to do all the things. |
| """ |
| |
| from __future__ import annotations |
| |
| import ast |
| import collections |
| import contextlib |
| import filecmp |
| import glob |
| import gzip |
| import inspect |
| import os |
| from pathlib import Path |
| import re |
| import subprocess |
| import sys |
| import tempfile |
| import textwrap |
| from typing import NoReturn |
| |
| from google.protobuf.descriptor_pb2 import ( |
| DescriptorProto, |
| FieldDescriptorProto, |
| FileDescriptorSet, |
| ) |
| |
| _RepoRoot = Path(__file__).absolute().parent |
| |
| _env = os.environ.copy() |
| _env['PATH'] = os.path.pathsep.join( |
| (str((_RepoRoot / 'tools' / 'bin').absolute()), _env['PATH']) |
| ) |
| |
| |
| def check_output( |
| cmd: list[str | Path], *, cwd: Path = _RepoRoot |
| ) -> str | NoReturn: |
| print(f"running: {' '.join(str(c) for c in cmd)}") |
| with subprocess.Popen( |
| cmd, |
| cwd=cwd, |
| env=_env, |
| stdout=subprocess.PIPE, |
| stderr=sys.stderr, |
| encoding='utf-8', |
| ) as p: |
| out, _ = p.communicate(None) |
| if p.returncode != 0: |
| sys.exit(p.returncode) |
| return out |
| |
| |
| def check_call(cmd: list[str | Path], *, cwd: Path = _RepoRoot): |
| scmd: list[str] = [str(x) for x in cmd] |
| if len(scmd) == 2 and scmd[0] == 'protoc' and scmd[1].startswith('@'): |
| with open(scmd[1][1:], encoding='utf-8') as args: |
| print(f'running: protoc {args.read()}') |
| else: |
| print(f"running: {' '.join(str(c) for c in scmd)}") |
| ret = subprocess.call(scmd, cwd=cwd, env=_env) |
| if ret != 0: |
| sys.exit(ret) |
| |
| |
| def _ensure_tools(): |
| check_call([ |
| 'cipd', |
| 'ensure', |
| '-root', |
| _RepoRoot, |
| '-ensure-file', |
| _RepoRoot / 'tools.ensure', |
| ]) |
| |
| |
| def task_clean(): |
| """Removes all generated files.""" |
| print('cleaning go/**/*.pb.go') |
| for file in quick_glob('go/**/*.pb.go'): |
| os.remove(_RepoRoot / file) |
| for file in quick_glob('py/**/*_pb2.py*'): |
| os.remove(_RepoRoot / file) |
| |
| |
| def task_format(mode: None | str = None): |
| """Formats all proto files. |
| |
| If `mode` is `check`, then this will just check that the protos are correctly |
| formatted and will not write to disk. |
| |
| Example: |
| build.py format check |
| """ |
| if mode not in (None, 'check'): |
| print(f'format: unknown mode={mode!r}') |
| sys.exit(1) |
| |
| if not mode: |
| check_call(['buf', 'format', '-w']) |
| else: |
| delta = check_output(['buf', 'format', '-d']) |
| if delta: |
| print(delta) |
| print() |
| print(f'Fix formatting by running `{sys.argv[0]} format`.') |
| sys.exit(1) |
| |
| |
| def task_lint(): |
| """Runs `buf lint` on all protos in the current repo.""" |
| check_call(['buf', 'lint']) |
| |
| |
| def task_breaking(basis: None | str = None): |
| """Runs `buf breaking` on all protos in the current repo. |
| |
| Uses base of the current branch as the reference to calculate breaking changes |
| against. If you want a different reference, pass it as `basis` to this |
| command. |
| |
| Example: |
| build.py breaking HEAD~2 |
| """ |
| if basis is None: |
| basis = check_output(['git', 'mark-merge-base']).split()[-1] |
| if basis == 'None': |
| # In a `bot_update` style checkout, mark-merge-base may return |
| # None. In this context, HEAD~1 is correct because the CL was |
| # cherry-picked onto the appropriate parent context (previous CL or |
| # current ref value). |
| basis = 'HEAD~1' |
| check_call([ |
| 'buf', |
| 'breaking', |
| '--against', |
| f'.git#ref={basis}', |
| '--exclude-path', |
| 'testing', |
| ]) |
| |
| |
| def task_check_service_definitions(): |
| """Checks that there is a maximum of one service definition per package. |
| |
| Also checks that the file with that definition contains only the service, and |
| no other top-level declarations. |
| """ |
| with _fds() as fds: |
| _task_check_service_definitions(fds) |
| |
| |
| def _task_check_service_definitions(desc: FileDescriptorSet): |
| ok = True |
| per_namespace: dict[str, set[str]] = {} |
| for file in desc.file: |
| if not file.service: |
| continue |
| to_add = per_namespace.get(file.package) |
| if not to_add: |
| to_add = set() |
| per_namespace[file.package] = to_add |
| to_add.update(s.name for s in file.service) |
| other_types = [] |
| for msg in file.message_type: |
| other_types.append(f'message {msg.name}') |
| for enum in file.enum_type: |
| other_types.append(f'enum {enum.name}') |
| for ext in file.extension: |
| other_types.append(f'ext {ext.name}') |
| if not other_types: |
| continue |
| ok = False |
| print(f'{file.name}: found other types along with service definition:') |
| for typ in other_types: |
| print(f' {typ}') |
| |
| for ns, services in per_namespace.items(): |
| if len(services) > 1: |
| ok = False |
| print(f'namespace {ns!r} had multiple services:') |
| for svc in services: |
| print(f' {svc!r}') |
| |
| if not ok: |
| sys.exit(1) |
| |
| |
| def task_check_go_package(): |
| """Checks that go_package options make sense. |
| |
| Protos are always organized like: |
| * turboci.dir...something.vX |
| * the base of the go_package option must be (. replaced with /): |
| go.chromium.org/turboci/proto/go/dir...vX:PKGNAME |
| * PKGNAME must be `something` (the last component before the version) |
| * for service protos: |
| * the proto file's name must end with _service.proto. |
| * the importpath must have an additional '/grpcpb' at the end. |
| * the PKGNAME must have 'grpcpb' appended. |
| * for non-service protos: |
| * the proto file's name must NOT end with _service.proto. |
| """ |
| with _fds() as fds: |
| _task_check_go_package(fds) |
| |
| |
| _filenameRegex = re.compile(r'^turboci/(?:.*/)?([^/]*)/v[^/]*/(.*)\.proto$') |
| |
| # Files which do not need to have their go package restricted. |
| # |
| # The turboci/tag.proto is intentionally designed for minimal syntax; requiring |
| # it to have a package of `turboci.v1` just adds unnecessary noise, and the |
| # typical rational for this (needing to version these for service API |
| # versioning) does not apply. |
| _filenameGoPackageExceptions = frozenset([ |
| 'turboci/tag.proto', |
| ]) |
| |
| |
| def _task_check_go_package(desc: FileDescriptorSet): |
| ok = True |
| for file in desc.file: |
| if file.name in _filenameGoPackageExceptions: |
| continue |
| |
| mtch = _filenameRegex.match(file.name) |
| if not mtch: |
| print(f'bad filename {file.name}') |
| ok = False |
| continue |
| |
| pkgname, filename = mtch.group(1), mtch.group(2) |
| |
| expectPkg = file.package.removeprefix('turboci.').replace('.', '/') |
| |
| if bool(file.service): |
| if not filename.endswith('_service'): |
| print(f'{file.name}: bad filename: need _service suffix.') |
| expectOption = f'{expectPkg}/grpcpb;{pkgname}grpcpb' |
| else: |
| if filename.endswith('_service'): |
| print(f'{file.name}: bad filename: has _service suffix.') |
| expectOption = f'{expectPkg};{pkgname}pb' |
| expectOption = f'go.chromium.org/turboci/proto/go/{expectOption}' |
| |
| if (got := file.options.go_package) != expectOption: |
| print(f'{file.name}: bad go_package {got!r}: want {expectOption!r}') |
| ok = False |
| |
| if not ok: |
| sys.exit(1) |
| |
| |
| def _check_message_fields( |
| file_name: str, message: DescriptorProto, errors: list[str] |
| ): |
| """Recursively checks fields in a message and its nested types.""" |
| for field in message.field: |
| # Check if the field is not repeated and not part of a oneof. |
| # Note that map fields are also repeated fields. |
| is_repeated = field.label == FieldDescriptorProto.Label.LABEL_REPEATED |
| is_oneof = field.HasField('oneof_index') |
| |
| if not is_repeated and not is_oneof: |
| # In proto3, the presence of `proto3_optional` means the `optional` |
| # keyword was used. |
| if not field.proto3_optional: |
| errors.append( |
| f'{file_name}: {message.name}.{field.name}: Non-repeated,' |
| ' non-oneof field must use the `optional` keyword.' |
| ) |
| |
| for nested_message in message.nested_type: |
| if not nested_message.options.map_entry: |
| _check_message_fields(file_name, nested_message, errors) |
| |
| |
| def _task_check_all_fields_optional(desc: FileDescriptorSet): |
| """Checks non-repeated, non-oneof fields uses the 'optional' keyword.""" |
| errors = [] |
| for file in desc.file: |
| for message in file.message_type: |
| _check_message_fields(file.name, message, errors) |
| |
| if errors: |
| for error in errors: |
| print(error) |
| sys.exit(1) |
| |
| |
| def task_check_all_fields_optional(): |
| """Checks non-repeated, non-oneof fields uses the 'optional' keyword.""" |
| with _fds() as fds: |
| _task_check_all_fields_optional(fds) |
| |
| |
| def _check_message_next_id( |
| file_name: str, |
| message: DescriptorProto, |
| message_name: str, |
| path: tuple[int, ...], |
| cmap: dict[tuple[int, ...], str], |
| errors: list[str], |
| ): |
| """Recursively checks 'Next ID' comments in a message and its children.""" |
| if path in cmap: |
| comment = cmap[path] |
| lines = [line.strip() for line in comment.splitlines() if line.strip()] |
| if lines: |
| last_line = lines[-1] |
| if re.search(r'\bnext[_\s-]*id\b', last_line, re.IGNORECASE): |
| numbers = [field.number for field in message.field] + [ |
| r.end - 1 for r in message.reserved_range |
| ] |
| expected_next_id = (max(numbers) + 1) if numbers else 1 |
| canonical_match = re.match(r'^Next ID:\s+(\d+)$', last_line) |
| if canonical_match: |
| comment_next_id = int(canonical_match.group(1)) |
| if comment_next_id != expected_next_id: |
| errors.append( |
| f'{file_name}: {message_name}: Next ID comment says' |
| f' {comment_next_id}, but expected {expected_next_id}.' |
| ) |
| else: |
| errors.append( |
| f'{file_name}: {message_name}: Non-canonical Next ID comment' |
| f' {last_line!r}. Please use the canonical form "Next ID: XXX"' |
| f' (e.g. "Next ID: {expected_next_id}").' |
| ) |
| |
| for i, nested_message in enumerate(message.nested_type): |
| if not nested_message.options.map_entry: |
| _check_message_next_id( |
| file_name, |
| nested_message, |
| f'{message_name}.{nested_message.name}', |
| path + (3, i), |
| cmap, |
| errors, |
| ) |
| |
| |
| def _task_check_next_id(desc: FileDescriptorSet): |
| """Checks that 'Next ID' comment on messages matches expected next ID.""" |
| errors = [] |
| for file in desc.file: |
| cmap = {} |
| for loc in file.source_code_info.location: |
| comments = [] |
| if loc.leading_comments: |
| comments.append(loc.leading_comments) |
| if loc.trailing_comments: |
| comments.append(loc.trailing_comments) |
| if loc.leading_detached_comments: |
| comments.extend(loc.leading_detached_comments) |
| if comments: |
| cmap[tuple(loc.path)] = '\n'.join(comments) |
| |
| for i, message in enumerate(file.message_type): |
| _check_message_next_id( |
| file.name, message, message.name, (4, i), cmap, errors |
| ) |
| |
| if errors: |
| for error in errors: |
| print(error) |
| sys.exit(1) |
| |
| |
| def task_check_next_id(): |
| """Checks that 'Next ID' comment on messages matches expected next ID.""" |
| with _fds() as fds: |
| _task_check_next_id(fds) |
| |
| |
| def protoc(*args: str, include_testing=False): |
| with tempfile.NamedTemporaryFile() as argfile: |
| argfile.writelines((arg + '\n').encode() for arg in args) |
| # include the whole repo as a proto path |
| argfile.write(b'-I.\n') |
| # compile all proto files under the turboci and testing directories |
| for file in quick_glob('turboci/**/*.proto'): |
| argfile.write(file.encode()) |
| argfile.write(b'\n') |
| if include_testing: |
| for file in quick_glob('testing/**/*.proto'): |
| argfile.write(file.encode()) |
| argfile.write(b'\n') |
| argfile.flush() |
| check_call(['protoc', f'@{argfile.name}']) |
| |
| |
| def quick_glob(pattern: str) -> list[str]: |
| return sorted(glob.glob(pattern, root_dir=_RepoRoot, recursive=True)) |
| |
| |
| def task_compile_desc( |
| outfile: None | str = None, |
| include_imports: bool = False, |
| ): |
| """Runs `protoc` to ensure all protos can compile to a proto descriptor. |
| |
| The descriptor is discarded, unless outfile is provided. |
| """ |
| if outfile is None: |
| guardFn = tempfile.NamedTemporaryFile |
| else: |
| |
| @contextlib.contextmanager |
| def _guardFn(): |
| yield collections.namedtuple('fakeNamed', 'name')(outfile) |
| |
| guardFn = _guardFn |
| |
| with guardFn() as tf: |
| args = ['-o', tf.name] |
| if outfile: |
| args += ['--retain_options', '--include_source_info'] |
| if include_imports: |
| args += ['--include_imports'] |
| protoc(*args) |
| |
| |
| @contextlib.contextmanager |
| def _fds(): |
| fds = FileDescriptorSet() |
| with tempfile.NamedTemporaryFile() as tf: |
| task_compile_desc(tf.name) |
| |
| dat = tf.read() |
| fds.MergeFromString(dat) |
| yield fds |
| |
| |
| def _install_stubs( |
| flavor: str, check: bool, src: Path, pattern: str, dst: Path |
| ): |
| newFiles: list[str] = glob.glob(pattern, recursive=True, root_dir=src) |
| |
| if not check: |
| for file in newFiles: |
| print(f'{flavor}: {file}') |
| target = dst / file |
| os.makedirs(target.parent, exist_ok=True) |
| os.rename(src / file, target) |
| return |
| |
| got = set(glob.glob(pattern, recursive=True, root_dir=src)) |
| want = set(newFiles) |
| |
| report = {} |
| |
| report['missing in repo'] = want - got |
| report['extra in repo'] = got - want |
| _, report['with diff'], errs = filecmp.cmpfiles( |
| dst, src, want.intersection(got), shallow=False |
| ) |
| if errs: |
| for err in errs: |
| print('error for file', err) |
| sys.exit(1) |
| |
| if all(not value for value in report.values()): |
| return # ok! |
| |
| relDst = dst.relative_to(_RepoRoot) |
| for category, files in sorted(report.items()): |
| if files: |
| print(f'{category}:') |
| for file in files: |
| print(f' {relDst}/{file}') |
| |
| sys.exit(1) |
| |
| |
| def task_compile_stubs(mode: None | str = None): |
| """Runs `protoc` to compile all Go and Python stubs. |
| |
| If `mode` is `check`, then this will just check that the currently generated |
| stubs are correct and will not write to disk. |
| |
| Example: |
| build.py compile_stubs check |
| """ |
| if mode not in (None, 'check'): |
| print(f'compile_stubs: unknown mode={mode!r}') |
| sys.exit(1) |
| |
| goModule = 'go.chromium.org/turboci/proto/go' |
| |
| # build to tempdir to implement mode=check |
| with tempfile.TemporaryDirectory(dir=_RepoRoot) as tdir: |
| tpth = Path(tdir) |
| tgo = tpth / 'go' |
| tpy = tpth / 'py' |
| tgo.mkdir() |
| tpy.mkdir() |
| protoc( |
| f'--go_out={tgo}', |
| f'--go_opt=module={goModule}', |
| f'--go-grpc_out={tgo}', |
| f'--go-grpc_opt=module={goModule}', |
| '--go_opt=default_api_level=API_OPAQUE', |
| f'--python_out={tpy}', |
| f'--pyi_out={tpy}', |
| include_testing=True, |
| ) |
| |
| check = mode == 'check' |
| if not check: |
| task_clean() |
| |
| _install_stubs('go', check, tgo, '**/*.pb.go', _RepoRoot / 'go') |
| _install_stubs('py', check, tpy, '**/*.*', _RepoRoot / 'py') |
| |
| |
| def task_store_descriptors(mode: None | str = None): |
| """Runs `protoc` to store the transitive set of proto descriptors. |
| |
| If `mode` is `check`, then this will just check that the currently stored |
| descriptors are correct. |
| |
| Example: |
| build.py store_descriptors check |
| """ |
| descPath = _RepoRoot / 'go' / 'utils' / 'turbocidesc' / 'desc.pb.gz' |
| |
| with tempfile.NamedTemporaryFile() as tf: |
| task_compile_desc(tf.name, include_imports=True) |
| raw = tf.read() |
| gzipped = gzip.compress(raw, mtime=1) |
| |
| if mode == 'check': |
| with open(descPath, 'rb') as existing: |
| if existing.read() != gzipped: |
| print('stored descriptors bundle is out of date') |
| sys.exit(1) |
| return |
| |
| with open(descPath, 'wb') as f: |
| f.write(gzipped) |
| |
| |
| def task_check_python_imports(): |
| """Checks that Python files have only one import per import statement.""" |
| violations = False |
| for rel_path in quick_glob('py/**/*.py'): |
| if ( |
| rel_path.endswith('__init__.py') |
| or rel_path.endswith('_pb2.py') |
| or '/.' in rel_path |
| ): |
| continue |
| abs_path = _RepoRoot / rel_path |
| with open(abs_path, encoding='utf-8') as f: |
| content = f.read() |
| lines = content.splitlines() |
| tree = ast.parse(content, rel_path) |
| for node in ast.walk(tree): |
| if isinstance(node, (ast.Import, ast.ImportFrom)): |
| if getattr(node, 'module', None) == '__future__': |
| continue |
| if len(node.names) > 1: |
| violations = True |
| end_lineno = getattr(node, 'end_lineno', node.lineno) |
| found = '\n'.join(lines[node.lineno - 1 : end_lineno]) |
| recommended = [] |
| if isinstance(node, ast.Import): |
| for alias in node.names: |
| if alias.asname: |
| recommended.append(f'import {alias.name} as {alias.asname}') |
| else: |
| recommended.append(f'import {alias.name}') |
| else: |
| prefix = '.' * node.level + (node.module or '') |
| for alias in node.names: |
| if alias.asname: |
| recommended.append( |
| f'from {prefix} import {alias.name} as {alias.asname}' |
| ) |
| else: |
| recommended.append(f'from {prefix} import {alias.name}') |
| rec_str = '\n'.join(f' {r}' for r in recommended) |
| found_str = '\n'.join(f' {line}' for line in found.splitlines()) |
| print(f'{rel_path}:{node.lineno}: multiple imports per statement') |
| print(' Found:') |
| print(found_str) |
| print(' Recommended:') |
| print(rec_str) |
| print() |
| |
| if violations: |
| sys.exit(1) |
| |
| |
| def task_test_python(verbose: None | str = None): |
| """Runs python unittests.""" |
| args = [ |
| 'vpython3', |
| '-m', |
| 'unittest', |
| 'discover', |
| '--buffer', |
| '--locals', |
| '--start-directory', |
| _RepoRoot / 'py', |
| ] |
| if verbose in ('-v', '--verbose'): |
| args.append('-v') |
| check_call(args) |
| |
| |
| def task_install_venv_link(): |
| """Refreshes the `py/.venv` symlink.""" |
| link = _RepoRoot / 'py' / '.venv' |
| link.unlink(missing_ok=True) |
| link.symlink_to(sys.prefix, target_is_directory=True) |
| |
| |
| def task_test_go(verbose: None | str = None): |
| """Runs python unittests.""" |
| args = ['go', 'test', 'go.chromium.org/turboci/proto/go/...'] |
| if verbose in ('-v', '--verbose'): |
| args.append('-v') |
| check_call(args, cwd=_RepoRoot / 'go') |
| |
| |
| def _clean_gclient_cruft(): |
| (_RepoRoot / '.gclient_entries').unlink(missing_ok=True) |
| (_RepoRoot / '.gclient_previous_sync_commits').unlink(missing_ok=True) |
| |
| |
| def task_all(): |
| """Shorthand to run all presubmit checks.""" |
| fail = False |
| |
| # We used to have a .gclient spec embedded in this repo; clean up all the |
| # cruft. |
| _clean_gclient_cruft() |
| |
| with _fds() as fds: |
| |
| def check_service_definitions(): |
| _task_check_service_definitions(fds) |
| |
| def check_go_package(): |
| _task_check_go_package(fds) |
| |
| def check_all_fields_optional(): |
| _task_check_all_fields_optional(fds) |
| |
| def check_next_id(): |
| _task_check_next_id(fds) |
| |
| allTasks = ( |
| task_format, |
| check_service_definitions, |
| check_go_package, |
| check_all_fields_optional, |
| check_next_id, |
| task_lint, |
| task_breaking, |
| task_compile_stubs, |
| task_store_descriptors, |
| task_check_python_imports, |
| task_test_python, |
| task_install_venv_link, |
| task_test_go, |
| ) |
| |
| for i, fn in enumerate(allTasks): |
| if i > 0: |
| print() |
| print(f'$ {sys.argv[0]} {fn.__name__.removeprefix("task_")}') |
| try: |
| fn() |
| print('ok') |
| except SystemExit: |
| print('FAIL') |
| fail = True |
| |
| if fail: |
| sys.exit(1) |
| |
| |
| def main(args: list[str]): |
| # Note: this is probably too cute - if argument parsing ever gets more |
| # serious than "subcommand with one additional optional positional |
| # argument", it would be best to convert this to argparse. |
| tasks = { |
| name.removeprefix('task_'): value |
| for name, value in globals().items() |
| if name.startswith('task_') |
| } |
| |
| def _help() -> NoReturn: |
| print(f'Usage: {sys.argv[0]} [cmd] [additional args...]') |
| print() |
| print('Commands:') |
| for task, fn in sorted(tasks.items()): |
| print() |
| print(f'{task}{inspect.signature(fn)}:') |
| print(textwrap.dedent(fn.__doc__.strip() or '')) |
| sys.exit(1) |
| |
| if not args: |
| _help() |
| |
| cmd = args[0] |
| fn = tasks.get(cmd) |
| |
| if fn is None: |
| _help() |
| |
| _ensure_tools() |
| |
| fn(*args[1:]) |
| print('ok') |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main(sys.argv[1:])) |